fix(javascript): preserve runtime path bytes and Electron ownership (#4941)

* fix(javascript): preserve literal percent runtime paths

* fix(javascript): ignore resolve prefixes before the final anchor

* fix(electron): retain application roots for loadFile declarations

* fix(electron): resolve loadFile from enclosing application package

* fix(electron): retain outer application root across package scopes

* fix(electron): derive application ownership from inventoried packages

* fix(electron): retain resolved package entrypoint ownership

* fix(electron): preserve competing application ownership

* fix(electron): retain invalid and omitted runtime ownership

* fix(electron): preserve omitted module ownership and runtime paths

* fix(electron): preserve proven roots before confining page paths

* fix(electron): retain possible owners behind missing imports

* fix(electron): distinguish absent imports and owned page candidates

* fix(electron): keep unavailable JSON out of owner gaps

* fix(electron): retain exact loader formats and default entry status

* fix(electron): retain unavailable entrypoint ownership candidates

* test(ghidra): preserve Windows Node probe prerequisites

* test(ghidra): assert cancellation during profile shutdown

* fix(electron): separate examined imports from frontend ownership

* fix(electron): retain unavailable directory entrypoint owners

* test(jeb): retain Linux ownership prerequisite in isolation

* fix(javascript): retain unchecked loader ownership and literal main paths

* fix(javascript): preserve runtime paths and call-site module aliases

* fix(javascript): bound module alias traversal and preserve projections

* fix(javascript): retain host module and nested artifact path boundaries

* fix(javascript): preserve ESM and archive module loader provenance

* test(javascript): distinguish unmapped containers from missing exports

* fix: retain default module aliases and Electron builtin imports

* fix: preserve host file URL and inward ASAR ownership

* fix: preserve CommonJS file URL classification in CI fixtures

* fix: retain bound module loaders and Windows entrypoint uncertainty

* fix: retain ASAR uncertainty and invalidate changed Electron app roots

* fix(javascript): retain ownership uncertainty across loader boundaries

* fix(javascript): preserve loader sources and bound alias queries

* fix(javascript): retain destructured loader sources

* fix(javascript): preserve loader uncertainty at resolution boundaries

* fix(javascript): preserve callable and package launch ownership

* fix(javascript): preserve captured values across member reads

* test(process): assert tagged filesystem cancellation failures

* fix(javascript): preserve inventory bases and invocation read timing

* fix(javascript): preserve ownership across indirect and unread loads

* test(javascript): order fixture directory creation on Windows

* test(javascript): share ordered filesystem fixture writer

* fix(javascript): unify loader provenance and package validation

* fix(javascript): preserve captured Node require loader values

* fix(javascript): preserve loader invocation authority

* fix(javascript): verify generic loader invocation authority

* fix(javascript): preserve conditional loader method alternatives

* fix(javascript): require proven factories for source association

* fix(javascript): honor replaced Electron app path setters

* fix(javascript): prove path and assignment source ownership

* fix(javascript): retain native delegate eligibility through shared writes

* fix(process): settle stale baseline errors after confirmed absence

* fix(javascript): preserve assignment and path boundary evidence

* fix(javascript): prove source-directory resolve anchors

* fix: preserve application path provenance and portable schemas

* fix(javascript): retain CommonJS exported setter effects

* fix: isolate Linux identity observations and retain native module exports

* fix(javascript): preserve published app and helper provenance

* fix(javascript): normalize typed stores and native path fixtures
This commit is contained in:
ShuaixinHuang authored and GitHub committed 2026-10-20 17:25:19 +08:00
1 parent d532b18123
commit e9fc8fb30c
149 files changed
+16881 -988

No files matched your search

@@ -0,0 +1,64 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
const packageObservation = (text: string) =>
analyzeJavaScriptArtifactFiles({
files: [
{
path: "app/package.json",
container_sha256: "a".repeat(64),
sha256: "b".repeat(64),
bytes: Buffer.byteLength(text),
inventory_artifact_id: "package-main-fixture",
kind: "package-json",
unpacked: false,
text: { included: true, value: text },
},
],
unavailable_relevant_files: [],
containers: [],
text_bytes_read: Buffer.byteLength(text),
invalid_utf8_files: 0,
}).packages[0];
it.each([null, false, 123, {}, []].map((main) => ({ main })))(
"retains non-string main $main evidence separately from Node index fallback",
({ main }) => {
expect(packageObservation(JSON.stringify({ main }))).toMatchObject({
status: "included",
main: null,
main_status: "invalid",
main_entrypoint: "",
});
},
);
it.each([
["{}", "default", ""],
['{"main":""}', "default", ""],
['{"main":"main.cjs"}', "declared", "main.cjs"],
['{"main":"sub\\\\main.cjs"}', "declared", "sub\\main.cjs"],
['{"main":"C:main.cjs"}', "declared", "C:main.cjs"],
] as const)(
"preserves package launch declarations from %s",
(text, status, entry) => {
expect(packageObservation(text)).toMatchObject({
status: "included",
main_status: status,
main_entrypoint: entry,
});
},
);
it.each(["{", "null", "[]", "[{}]", "123", '"root"', "false"])(
"keeps malformed package metadata %s out of known launch fallback",
(text) => {
expect(packageObservation(text)).toMatchObject({
status: "invalid",
main: null,
main_status: "unavailable",
main_entrypoint: null,
});
},
);
@@ -512,6 +512,13 @@ const parsePackage = (
name: optionalString(value.name),
version: optionalString(value.version),
main: optionalString(value.main),
main_status:
value.main === undefined || value.main === ""
? "default"
: typeof value.main === "string"
? "declared"
: "invalid",
main_entrypoint: typeof value.main === "string" ? value.main : "",
renderer:
optionalString(value.renderer) ??
optionalString(value.browser) ??
@@ -538,6 +545,8 @@ const unavailablePackage = (
name: null,
version: null,
main: null,
main_status: "unavailable",
main_entrypoint: null,
renderer: null,
limitation,
});
@@ -20,6 +20,9 @@ interface JavaScriptPackageValues {
readonly name: string | null;
readonly version: string | null;
readonly main: string | null;
readonly main_status: "default" | "declared" | "invalid";
/** Node launch declaration; an empty string requests the package index fallback. */
readonly main_entrypoint: string;
readonly renderer: string | null;
}
@@ -35,6 +38,8 @@ export type JavaScriptPackageObservation = JavaScriptStructuredObservation &
readonly name: null;
readonly version: null;
readonly main: null;
readonly main_status: "unavailable";
readonly main_entrypoint: null;
readonly renderer: null;
readonly limitation: string;
}
@@ -12,6 +12,8 @@ import {
HTML_INLINE_SCRIPT_LOCATION_COVERAGE_LIMIT,
omittedHtmlInlineScriptLocations,
selfReferenceOmissions,
dependencyManifest,
applicationContainerIdentity,
type JavaScriptArtifactGraphContext,
} from "./JavaScriptArtifactGraphContext.js";
import {
@@ -93,6 +95,57 @@ function* buildJavaScriptArtifactGraphInputSteps(
): Generator<void, unknown> {
const accumulator = new JavaScriptArtifactGraphAccumulator();
const root = createJavaScriptArtifactRootNode(accumulator, snapshot);
const parsedPackages = new Set(
analysis.packages
.filter((metadata) => metadata.status === "included")
.map(({ path }) => path),
);
const applicationOwnershipUnknownContainers = new Set<string>();
const applicationModuleGapSources = new Map<string, Set<string>>();
const applicationModuleReferencesRetained = new Set<string>();
const recordModuleGap = (file: {
path: string;
container_sha256: string;
}) => {
const container = applicationContainerIdentity(fileSet, file);
const sources =
applicationModuleGapSources.get(container) ?? new Set<string>();
sources.add(file.path);
applicationModuleGapSources.set(container, sources);
};
for (const files of [fileSet.files, fileSet.unavailable_relevant_files]) {
for (const file of files) {
if (
file.kind === "package-json" &&
!dependencyManifest(file.path) &&
!parsedPackages.has(file.path)
)
applicationOwnershipUnknownContainers.add(
applicationContainerIdentity(fileSet, file),
);
if (
file.kind === "javascript" &&
(!("text" in file) || !file.text.included)
)
recordModuleGap(file);
}
}
// Omitted module relationships can hide another launch owner. Do not expand
// retained facts beyond the projection guard merely to recover ownership.
for (const analyzed of analysis.files) {
if (
analyzed.application_projection_failure !== undefined ||
analyzed.analysis_failure !== undefined ||
analyzed.javascript?.parse_status === "failed" ||
(analyzed.javascript?.parse_error_count ?? 0) > 0
)
recordModuleGap(analyzed.file);
else if (analyzed.javascript !== null)
applicationModuleReferencesRetained.add(analyzed.file.path);
}
const unavailableFilesByPath = new Map(
fileSet.unavailable_relevant_files.map((file) => [file.path, file]),
);
const context: JavaScriptArtifactGraphContext = {
accumulator,
snapshot,
@@ -105,13 +158,23 @@ function* buildJavaScriptArtifactGraphInputSteps(
),
root,
filesByPath: new Map(fileSet.files.map((file) => [file.path, file])),
unanalyzedFiles: fileSet.unanalyzed_files,
unanalyzedFiles: {
get: (path) =>
unavailableFilesByPath.get(path) ?? fileSet.unanalyzed_files?.get(path),
hasDirectory: (path, container) =>
fileSet.unanalyzed_files?.hasDirectory(path, container) ?? false,
},
fileNodes: new Map(),
assetNodes: new Map(),
sourceModuleNodes: new Map(),
chunkNodes: new Map(),
moduleNodes: new Map(),
containerNodes: new Map([[snapshot.manifest.root_sha256, root]]),
applicationRootsBySource: new Map(),
applicationOwnershipUnknownContainers,
applicationModuleGapSources,
applicationModuleReferencesRetained,
applicationPossibleRootsByContainer: new Map(),
};
addJavaScriptArtifactContainers(context);
yield;
@@ -1,6 +1,9 @@
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
import {
compositeKey,
compareUnicodeCodePoints,
} from "../../domain/unicodeCodePointOrder.js";
import {
completeApplicationCoverage,
partialApplicationCoverage,
@@ -51,8 +54,112 @@ export interface JavaScriptArtifactGraphContext {
readonly chunkNodes: Map<string, ApplicationNode>;
readonly moduleNodes: Map<string, ApplicationNode>;
readonly containerNodes: Map<string, ApplicationNode>;
/** Resolved entrypoints/imports retain unique or competing application owners. */
readonly applicationRootsBySource: Map<string, Set<string>>;
/** Incomplete package facts can hide competing launch declarations. */
readonly applicationOwnershipUnknownContainers: ReadonlySet<string>;
/** Gap origins retain possible owners separately from proven source owners. */
readonly applicationModuleGapSources: Map<string, Set<string>>;
readonly applicationPossibleRootsByContainer: Map<string, Set<string>>;
/** Sources with retained references; unchecked loads carry their own gaps. */
readonly applicationModuleReferencesRetained: ReadonlySet<string>;
}
/** Application manifests exclude dependency packages but retain nested app roots. */
export const dependencyManifest = (path: string): boolean =>
path.split("/").slice(0, -1).includes("node_modules");
/** Two distinct roots prove ambiguity; further roots cannot restore uniqueness. */
export const addApplicationRootCandidate = (
roots: Set<string>,
root: string,
): void => {
if (roots.size < 2) roots.add(root);
};
const applicationContainerRoot = (
fileSet: JavaScriptArtifactFileSet,
source: Pick<JavaScriptArtifactFile, "path" | "container_sha256">,
): string =>
fileSet.containers.find(
({ sha256, path }) =>
sha256 === source.container_sha256 && source.path.startsWith(`${path}/`),
)?.path ?? "";
/** Equal archive bytes at different logical paths are distinct ownership scopes. */
export const applicationContainerIdentity = (
fileSet: JavaScriptArtifactFileSet,
source: Pick<JavaScriptArtifactFile, "path" | "container_sha256">,
): string =>
compositeKey([
source.container_sha256,
applicationContainerRoot(fileSet, source),
]);
/** Select the inventory's display root; this does not establish runtime ownership. */
export const applicationManifestPath = (
context: JavaScriptArtifactGraphContext,
): string | undefined =>
context.analysis.packages
.map(({ path }) => path)
.filter((path) => {
const file = context.filesByPath.get(path);
return file !== undefined && !dependencyManifest(path);
})
.sort(
(left, right) =>
left.split("/").length - right.split("/").length ||
compareUnicodeCodePoints(left, right),
)[0];
/** Inspect inventoried ancestor packages; absent parsing is not absent ownership.
* Entry-bearing packages are candidates; multiple candidates stay unknown.
*/
export const applicationRootPath = (
context: JavaScriptArtifactGraphContext,
source: JavaScriptArtifactFile,
): string | null => {
// Unread launch metadata can name a main in another inventoried container.
// Archive occurrence identity does not prove isolation from the Node loader.
if (context.applicationOwnershipUnknownContainers.size > 0) return null;
const entryRoots = context.applicationRootsBySource.get(source.path);
const container = applicationContainerIdentity(context.fileSet, source);
for (const root of context.applicationPossibleRootsByContainer.get(
container,
) ?? [])
if (!entryRoots?.has(root)) return null;
if (
entryRoots === undefined &&
context.applicationModuleGapSources.has(container)
)
return null;
const roots = new Set<string>(entryRoots);
const directories = source.path.split("/").slice(0, -1);
for (let depth = directories.length; depth >= 0; depth -= 1) {
const root = directories.slice(0, depth).join("/");
const prefix = root === "" ? "" : `${root}/`;
const path = `${prefix}package.json`;
if (dependencyManifest(path)) continue;
const file =
context.filesByPath.get(path) ?? context.unanalyzedFiles?.get(path);
if (file?.container_sha256 !== source.container_sha256) continue;
const metadata = context.analysis.packages.find(
(value) => value.path === path,
);
if (metadata?.status !== "included") return null;
const defaultEntry = context.filesByPath.get(`${prefix}index.js`);
if (
entryRoots === undefined &&
(metadata.main_status === "declared" ||
defaultEntry?.container_sha256 === source.container_sha256)
)
addApplicationRootCandidate(roots, root);
}
if (roots.size > 1) return null;
for (const root of roots) return root;
return applicationContainerRoot(context.fileSet, source);
};
/** Evidence coverage shape shared by reconstruction graph helpers. */
export type JavaScriptArtifactGraphCoverage =
ApplicationGraphEvidence["coverage"];
@@ -302,13 +409,25 @@ export const artifactFileNodeKind = (
export const createElectronRoleNode = (
context: JavaScriptArtifactGraphContext,
input: RoleNodeInput,
): ApplicationNode =>
context.accumulator.addNode({
): ApplicationNode => {
const target =
input.resolution.resolved_path ??
(input.resolution.runtime_path_base_proven === true
? input.resolution.candidate_path
: undefined) ??
input.resolution.declared_path;
// Raw application-relative declarations keep their source occurrence even
// when identical source bytes appear under separate application prefixes.
const identityPath =
input.resolution.runtime_path_base === "application-root"
? compositeKey([input.anchor.path, target])
: target;
return context.accumulator.addNode({
kind: input.kind,
identity: artifactLocalIdentity(
input.anchor.sha256,
"electron-role",
`${input.kind}:${input.resolution.resolution_context}:${input.resolution.resolved_path ?? input.resolution.declared_path}`,
`${input.kind}:${input.resolution.resolution_context}:${identityPath}`,
),
observations: [
{
@@ -333,6 +452,7 @@ export const createElectronRoleNode = (
},
],
});
};
/** Resolve an Electron role to an inventoried asset when available. */
export const linkElectronRoleToAsset = (
@@ -6,6 +6,9 @@ import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascrip
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
addStaticInferenceEdge,
applicationRootPath,
applicationContainerIdentity,
addApplicationRootCandidate,
artifactLocalIdentity,
chunkLookupKey,
createElectronRoleNode,
@@ -26,7 +29,10 @@ import {
resolveArtifactPathByContext,
type ArtifactPathResolution,
} from "./JavaScriptArtifactPathResolution.js";
import { moduleResolutionModeForJavaScriptReference } from "../../domain/javascript/javascriptModuleResolutionMode.js";
import {
moduleResolutionModeForJavaScriptReference,
isJsonModuleTarget,
} from "../../domain/javascript/javascriptModuleResolutionMode.js";
type StaticReference = JavaScriptStaticAnalysis["references"][number];
type StaticEndpoint = JavaScriptStaticAnalysis["endpoints"][number];
@@ -42,11 +48,11 @@ interface FindingInput<Value> {
}
interface ResolvedReference {
readonly targetImportsUnexamined?: true;
readonly path: string | null;
readonly file: JavaScriptArtifactFile | null;
readonly node: ApplicationNode | null;
readonly limitations: readonly string[];
readonly resolutionStatus?: string;
readonly resolution?: ArtifactPathResolution;
}
@@ -54,7 +60,10 @@ interface ResolvedReference {
export function* addJavaScriptStaticFindingsSteps(
context: JavaScriptArtifactGraphContext,
): Generator<void, readonly string[]> {
const omissions = { selfReferences: 0 };
const omissions = {
selfReferences: 0,
imports: new Map<string, Set<string>>(),
};
for (const analyzed of context.analysis.files) {
yield;
const { file, javascript } = analyzed;
@@ -67,11 +76,42 @@ export function* addJavaScriptStaticFindingsSteps(
addEndpoint(context, { file, asset, value, coverage });
for (const value of javascript.storage)
addStorage(context, { file, asset, value, coverage });
for (const value of javascript.role_paths)
addDiscoveredRole(context, { file, asset, value, coverage });
for (const value of javascript.source_map_urls)
addSourceMapEdge(context, { file, asset, value, coverage });
}
yield* propagateApplicationOwners(context, omissions.imports);
const possible = new Set<string>();
for (const roots of context.applicationPossibleRootsByContainer.values())
for (const root of roots) addApplicationRootCandidate(possible, root);
for (const gaps of context.applicationModuleGapSources.values()) {
for (const gap of gaps) {
yield;
for (const root of context.applicationRootsBySource.get(gap) ?? [])
addApplicationRootCandidate(possible, root);
}
}
// An unknown loader may cross an inventoried archive boundary in either
// direction. Retain possible owners in every container it could reach.
for (const file of context.fileSet.files) {
yield;
context.applicationPossibleRootsByContainer.set(
applicationContainerIdentity(context.fileSet, file),
possible,
);
}
const changedRoots = changedApplicationRoots(context);
for (const { file, javascript } of context.analysis.files) {
yield;
const asset = context.assetNodes.get(file.path);
if (javascript === null || asset === undefined) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const value of javascript.role_paths)
addDiscoveredRole(
context,
{ file, asset, value, coverage },
changedRoots,
);
}
return selfReferenceOmissions(
omissions.selfReferences,
["static reference", "static references"],
@@ -79,10 +119,78 @@ export function* addJavaScriptStaticFindingsSteps(
);
}
/** A setter changes process-wide runtime state, not package ownership. */
const changedApplicationRoots = (
context: JavaScriptArtifactGraphContext,
): ReadonlySet<string> | null => {
const roots = new Set<string>();
// An unchecked load can execute a setter even when the imported source has
// no retained AST. Package ownership alone does not prove its runtime path
// unchanged. Only launch owners that can reach the gap are affected; an
// unrelated unselected source is not evidence of a running setter.
for (const sources of context.applicationModuleGapSources.values()) {
for (const source of sources) {
const owners = context.applicationRootsBySource.get(source);
if (owners === undefined) continue;
if (owners.size >= 2) return null;
for (const root of owners) roots.add(root);
}
}
for (const { file, javascript } of context.analysis.files) {
if (!javascript?.electron.app_path_changes?.length) continue;
const owners = context.applicationRootsBySource.get(file.path);
// A setter whose launching application is unknown cannot establish an
// unchanged root for any package-based fallback.
if (owners === undefined) return null;
// Owner sets stop at two distinct roots once uniqueness is disproven.
// They cannot enumerate every application affected by a shared setter.
if (owners.size >= 2) return null;
for (const root of owners) roots.add(root);
const possibleOwners = context.applicationPossibleRootsByContainer.get(
applicationContainerIdentity(context.fileSet, file),
);
// An unchecked loader can execute this setter in another application's
// process even when only one entrypoint has a proven import of this source.
// Possible-owner sets have the same two-root saturation as proven owners.
if (possibleOwners !== undefined) {
if (possibleOwners.size >= 2) return null;
for (const root of possibleOwners) roots.add(root);
}
}
return roots;
};
/** Carry every entrypoint owner through already-resolved imports before roles.
* Sets and change-driven traversal retain competition and terminate on cycles.
*/
function* propagateApplicationOwners(
context: JavaScriptArtifactGraphContext,
imports: ReadonlyMap<string, ReadonlySet<string>>,
): Generator<void> {
const pending = [...context.applicationRootsBySource.keys()];
while (pending.length > 0) {
yield;
const source = pending.pop();
if (source === undefined) break;
const roots = context.applicationRootsBySource.get(source);
if (roots === undefined) continue;
for (const target of imports.get(source) ?? []) {
yield;
const candidates =
context.applicationRootsBySource.get(target) ?? new Set<string>();
const previousSize = candidates.size;
for (const root of roots) addApplicationRootCandidate(candidates, root);
if (candidates.size === previousSize) continue;
context.applicationRootsBySource.set(target, candidates);
pending.push(target);
}
}
}
const addReference = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticReference>,
omissions: { selfReferences: number },
omissions: { selfReferences: number; imports: Map<string, Set<string>> },
): void => {
const source =
sourceNodeFor(
@@ -95,6 +203,32 @@ const addReference = (
input.value.specifier === null
? null
: resolveReference(context, input.file.path, input.value, input.coverage);
if (
(input.value.kind === "static-import" ||
input.value.kind === "dynamic-import" ||
input.value.kind === "require") &&
(input.value.specifier === null ||
resolved === null ||
resolved.targetImportsUnexamined === true)
) {
const container = applicationContainerIdentity(context.fileSet, input.file);
const gaps =
context.applicationModuleGapSources.get(container) ?? new Set<string>();
gaps.add(input.file.path);
context.applicationModuleGapSources.set(container, gaps);
}
if (
resolved !== null &&
resolved.path !== null &&
resolved.file !== null &&
(input.value.kind === "static-import" ||
input.value.kind === "dynamic-import" ||
input.value.kind === "require")
) {
const targets = omissions.imports.get(input.file.path) ?? new Set<string>();
targets.add(resolved.path);
omissions.imports.set(input.file.path, targets);
}
if (input.value.kind === "worker" || input.value.kind === "service-worker") {
const worker = context.accumulator.addNode({
kind: input.value.kind === "worker" ? "worker" : "service-worker",
@@ -110,7 +244,7 @@ const addReference = (
declared_specifier: input.value.specifier,
expression: input.value.expression,
resolved_path: resolved?.path ?? null,
resolution_status: resolved?.resolutionStatus ?? null,
resolution_status: resolved?.resolution?.resolution_status ?? null,
...artifactPathResolutionProperties(resolved?.resolution),
},
evidence: staticInferenceEvidence({
@@ -133,7 +267,7 @@ const addReference = (
relation: "loads",
properties: {
kind: input.value.kind,
resolution_status: resolved?.resolutionStatus ?? null,
resolution_status: resolved?.resolution?.resolution_status ?? null,
...artifactPathResolutionProperties(resolved?.resolution),
},
limitations: resolved?.limitations ?? [],
@@ -182,7 +316,7 @@ const addReference = (
)
: null,
resolved_path: resolved?.path ?? null,
resolution_status: resolved?.resolutionStatus ?? null,
resolution_status: resolved?.resolution?.resolution_status ?? null,
...artifactPathResolutionProperties(resolved?.resolution),
},
...(resolved === null ? {} : { limitations: resolved.limitations }),
@@ -344,7 +478,9 @@ const addStorage = (
const addDiscoveredRole = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticRolePath>,
changedRoots: ReadonlySet<string> | null,
): void => {
const root = applicationRootPath(context, input.file);
const resolution = resolveArtifactPathByContext({
declaredPath: input.value.path,
sourcePath: input.file.path,
@@ -352,14 +488,25 @@ const addDiscoveredRole = (
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
...(input.value.role === "preload" ||
(input.value.role === "renderer" &&
input.value.resolution_context === "filesystem-expression")
...(input.value.role === "preload"
? { runtimePathKind: "exact-file" as const }
: input.value.role === "renderer" &&
input.value.resolution_context === "module-specifier"
? { runtimePathKind: "url-resource" as const }
: {}),
input.value.resolution_context === "filesystem-expression"
? {
// Raw loadFile literals belong to the application, not its caller.
runtimePathKind: "application-file" as const,
applicationRootPath:
changedRoots === null || (root !== null && changedRoots.has(root))
? null
: root,
applicationRootIsProven: context.applicationRootsBySource.has(
input.file.path,
),
}
: input.value.role === "renderer" &&
input.value.resolution_context === "module-specifier"
? { runtimePathKind: "url-resource" as const }
: {}),
});
const role = createElectronRoleNode(context, {
kind:
@@ -533,6 +680,23 @@ const resolveReference = (
: {}),
});
const path = resolution.resolved_path;
const targetPath = path ?? resolution.candidate_path;
const targetKind =
targetPath === undefined
? undefined
: (
context.filesByPath.get(targetPath) ??
context.unanalyzedFiles?.get(targetPath)
)?.kind;
// Resolving bytes does not prove that the loader's module imports were read.
// Only an exact JSON loader target can be inert without JavaScript analysis.
const targetIsJson =
targetPath !== undefined && isJsonModuleTarget(targetPath, targetKind);
const targetImportsUnexamined =
!targetIsJson &&
(resolution.resolution_status === "unavailable" ||
(path !== null &&
!context.applicationModuleReferencesRetained.has(path)));
if (path === null) {
const source = context.filesByPath.get(sourcePath);
return source === undefined
@@ -550,7 +714,9 @@ const resolveReference = (
resolution,
),
limitations: resolution.limitations,
resolutionStatus: resolution.resolution_status,
...(targetImportsUnexamined
? { targetImportsUnexamined: true as const }
: {}),
resolution,
};
}
@@ -563,7 +729,9 @@ const resolveReference = (
file,
node,
limitations: resolution.limitations,
resolutionStatus: resolution.resolution_status,
...(targetImportsUnexamined
? { targetImportsUnexamined: true as const }
: {}),
resolution,
};
};
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { createHash } from "node:crypto";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
@@ -14,6 +15,8 @@ import {
addJavaScriptArtifactFiles,
createJavaScriptArtifactRootNode,
} from "./JavaScriptArtifactGraphStructure.js";
import { addJavaScriptStaticFindingsSteps } from "./JavaScriptArtifactGraphFindings.js";
import { applicationContainerIdentity } from "./JavaScriptArtifactGraphContext.js";
const SHA256 = "a".repeat(64);
const REASON =
@@ -98,6 +101,11 @@ const contextFor = (
chunkNodes: new Map(),
moduleNodes: new Map(),
containerNodes: new Map(),
applicationRootsBySource: new Map(),
applicationOwnershipUnknownContainers: new Set(),
applicationModuleGapSources: new Map(),
applicationPossibleRootsByContainer: new Map(),
applicationModuleReferencesRetained: new Set(),
};
};
@@ -146,3 +154,225 @@ describe("skipped JavaScript parse reporting", () => {
expect(fileObservation(context).parse_status).toBe(javascript.parse_status);
});
});
it.each([false, true])(
"retains possible setter owners independently of proven imports (possible owner: %s)",
(possibleOwner) => {
const window: JavaScriptArtifactFile = {
...skippedFile(),
path: "b/window.cjs",
sha256: "b".repeat(64),
};
const setter: JavaScriptArtifactFile = {
...skippedFile(),
path: "shared/change.cjs",
sha256: "c".repeat(64),
};
const page: JavaScriptArtifactFile = {
...skippedFile(),
path: "b/index.html",
sha256: "d".repeat(64),
kind: "html",
};
const analysis = analysisFor([
{
file: setter,
javascript: analyzeJavaScriptStaticSource(
'const { app } = require("electron"); app.setAppPath("/changed");',
),
semantic: null,
},
{
file: window,
javascript: analyzeJavaScriptStaticSource(
'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");',
),
semantic: null,
},
{ file: page, javascript: null, semantic: null },
]);
const base = contextFor(window, analysis);
const files = [setter, window, page];
const context: JavaScriptArtifactGraphContext = {
...base,
fileSet: { ...base.fileSet, files },
filesByPath: new Map(files.map((file) => [file.path, file])),
};
context.applicationRootsBySource.set(setter.path, new Set(["a"]));
context.applicationRootsBySource.set(window.path, new Set(["b"]));
if (possibleOwner)
context.applicationPossibleRootsByContainer.set(
applicationContainerIdentity(context.fileSet, setter),
new Set(["b"]),
);
addJavaScriptArtifactFiles(context);
const steps = addJavaScriptStaticFindingsSteps(context);
let step = steps.next();
while (!step.done) step = steps.next();
const role = context.accumulator
.nodes()
.find(({ kind }) => kind === "electron-renderer");
expect(role?.observations).toEqual(
expect.arrayContaining([
expect.objectContaining({
properties: expect.objectContaining({
resolved_path: possibleOwner ? null : "b/index.html",
resolution_status: possibleOwner ? "unavailable" : "resolved",
}),
}),
]),
);
},
);
it.each([
"unselected",
"transitive-unselected",
"invalid-utf8",
"worker-failure",
"parse-failed",
"opaque",
"unknown-loader",
"read-inert",
"unreferenced",
"json",
"missing",
] as const)(
"does not prove an unchanged application path across a reachable %s load gap",
(gap) => {
const makeFile = (
path: string,
kind: JavaScriptArtifactFile["kind"] = "javascript",
): JavaScriptArtifactFile => ({
...skippedFile(),
path,
sha256: createHash("sha256").update(path).digest("hex"),
kind,
});
const main = makeFile("a/main.cjs");
const other = makeFile("b/main.cjs");
const target = makeFile(
gap === "json" ? "a/change.json" : "a/change.cjs",
gap === "json" ? "json" : "javascript",
);
const page = makeFile("a/index.html", "html");
const otherPage = makeFile("b/index.html", "html");
const window =
'const {BrowserWindow}=require("electron");new BrowserWindow().loadFile("index.html");';
const statement =
gap === "unknown-loader"
? "require(process.env.CHANGE);"
: gap === "transitive-unselected"
? 'require("./middle.cjs");'
: gap === "unreferenced"
? ""
: `require("./${gap === "json" ? "change.json" : "change.cjs"}");`;
const entry: AnalyzedJavaScriptArtifactFile =
gap === "worker-failure"
? skippedEntry(target)
: {
file:
gap === "invalid-utf8"
? {
...target,
text: { included: false, reason: "invalid-utf8" },
}
: target,
javascript:
gap === "read-inert"
? analyzeJavaScriptStaticSource("module.exports=42;")
: gap === "parse-failed"
? {
...analyzeJavaScriptStaticSource(""),
parse_status: "failed",
parse_error_count: 1,
}
: null,
semantic: null,
};
const entries: AnalyzedJavaScriptArtifactFile[] = [
{
file: main,
javascript: analyzeJavaScriptStaticSource(statement + window),
semantic: null,
},
{
file: other,
javascript: analyzeJavaScriptStaticSource(window),
semantic: null,
},
...(gap === "transitive-unselected"
? [
{
file: makeFile("a/middle.cjs"),
javascript: analyzeJavaScriptStaticSource(
'require("./change.cjs");',
),
semantic: null,
},
]
: []),
...(gap === "missing" || gap === "opaque" ? [] : [entry]),
{ file: page, javascript: null, semantic: null },
{ file: otherPage, javascript: null, semantic: null },
];
const analysis = analysisFor(entries);
const base = contextFor(main, analysis);
const files = entries.map(({ file }) => file);
const context: JavaScriptArtifactGraphContext = {
...base,
fileSet: { ...base.fileSet, files },
filesByPath: new Map(files.map((file) => [file.path, file])),
unanalyzedFiles:
gap === "opaque"
? {
get: (path) => (path === target.path ? target : undefined),
hasDirectory: () => false,
}
: undefined,
applicationModuleReferencesRetained: new Set([
main.path,
other.path,
...(gap === "transitive-unselected" ? ["a/middle.cjs"] : []),
...(gap === "read-inert" ? [target.path] : []),
]),
};
context.applicationRootsBySource.set(main.path, new Set(["a"]));
context.applicationRootsBySource.set(other.path, new Set(["b"]));
addJavaScriptArtifactFiles(context);
const steps = addJavaScriptStaticFindingsSteps(context);
while (!steps.next().done) {}
const roles = context.accumulator
.nodes()
.filter(({ kind }) => kind === "electron-renderer");
expect(roles).toHaveLength(2);
const changed = !["read-inert", "unreferenced", "json", "missing"].includes(
gap,
);
for (const [path, expected] of [
[main.path, changed ? null : "a/index.html"],
// Existing loader ownership also allows an unchecked source to import
// the other app's module; no process isolation has been established.
[other.path, changed ? null : "b/index.html"],
] as const) {
const role = roles.find(({ observations }) =>
observations.some(
({ evidence }) =>
evidence.location.available &&
evidence.location.value.kind === "source-range" &&
evidence.location.value.source === path,
),
);
expect(role?.observations).toEqual(
expect.arrayContaining([
expect.objectContaining({
properties: expect.objectContaining({
resolved_path: expected,
resolution_status: expected === null ? "unavailable" : "resolved",
}),
}),
]),
);
}
},
);
@@ -1,4 +1,5 @@
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import { isJsonModuleTarget } from "../../domain/javascript/javascriptModuleResolutionMode.js";
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
import { htmlInlineScriptLimitation } from "./JavaScriptArtifactAnalysis.js";
import {
@@ -18,6 +19,10 @@ import type { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGra
import { isSkippedJavaScriptAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import {
addArtifactContainsEdge,
applicationManifestPath,
applicationContainerIdentity,
addApplicationRootCandidate,
dependencyManifest,
addUnavailableStaticParseScope,
artifactFileNodeKind,
artifactLocalIdentity,
@@ -36,8 +41,8 @@ import {
import {
artifactPathResolutionProperties,
resolveArtifactPathByContext,
type ArtifactPathResolution,
} from "./JavaScriptArtifactPathResolution.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
interface PackageRoleInput {
readonly packageNode: ApplicationNode;
@@ -480,6 +485,7 @@ export const addJavaScriptPackageNodes = (
name: packageValue.name,
version: packageValue.version,
main: packageValue.main,
main_status: packageValue.main_status,
renderer: packageValue.renderer,
parse_status: packageValue.status,
},
@@ -516,6 +522,24 @@ export const addJavaScriptPackageNodes = (
operation: "inventory-package",
});
if (dependencyManifest(file.path)) continue;
if (
packageValue.status === "included" &&
packageValue.main_entrypoint === ""
) {
registerPackageResolutionOwner(
context,
file,
resolveArtifactPathByContext({
declaredPath: packageValue.main_entrypoint,
sourcePath: file.path,
context: "package-entrypoint",
runtimePathKind: "node-entrypoint",
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
}),
);
}
addPackageRole(context, {
packageNode: node,
packageFile: file,
@@ -532,24 +556,6 @@ export const addJavaScriptPackageNodes = (
return roots;
};
/** Dependency-only artifacts retain their inventory root. */
const applicationManifestPath = (
context: JavaScriptArtifactGraphContext,
): string | undefined =>
context.analysis.packages
.map(({ path }) => path)
.filter(
(path) => context.filesByPath.has(path) && !dependencyManifest(path),
)
.sort(
(left, right) =>
left.split("/").length - right.split("/").length ||
compareUnicodeCodePoints(left, right),
)[0];
const dependencyManifest = (path: string): boolean =>
path.split("/").slice(0, -1).includes("node_modules");
const createFileTarget = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
@@ -645,10 +651,17 @@ const addPackageRole = (
declaredPath: input.declaredPath,
sourcePath: input.packageFile.path,
context: "package-entrypoint",
// Launch main uses Node's filesystem lookup, independently of renderer
// metadata. Literal percent bytes are filename bytes at this boundary.
...(input.kind === "electron-main"
? { runtimePathKind: "node-entrypoint" as const }
: {}),
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
});
if (input.kind === "electron-main")
registerPackageResolutionOwner(context, input.packageFile, resolution);
const role = createElectronRoleNode(context, {
kind: input.kind,
anchor: input.packageFile,
@@ -685,6 +698,61 @@ const addPackageRole = (
});
};
const registerPackageResolutionOwner = (
context: JavaScriptArtifactGraphContext,
packageFile: JavaScriptArtifactFile,
resolution: ArtifactPathResolution,
): void => {
if (resolution.resolution_status === "resolved")
registerPackageEntrypointOwner(
context,
packageFile,
resolution.resolved_path,
);
else if (
resolution.resolution_status === "unavailable" &&
!(
resolution.candidate_path !== undefined &&
isJsonModuleTarget(
resolution.candidate_path,
context.unanalyzedFiles?.get(resolution.candidate_path)?.kind,
)
)
)
registerPossiblePackageOwner(context, packageFile);
};
const registerPackageEntrypointOwner = (
context: JavaScriptArtifactGraphContext,
packageFile: JavaScriptArtifactFile,
sourcePath: string,
): void => {
const roots =
context.applicationRootsBySource.get(sourcePath) ?? new Set<string>();
addApplicationRootCandidate(roots, packageRoot(packageFile));
context.applicationRootsBySource.set(sourcePath, roots);
if (
!context.applicationModuleReferencesRetained.has(sourcePath) &&
!isJsonModuleTarget(sourcePath, context.filesByPath.get(sourcePath)?.kind)
)
registerPossiblePackageOwner(context, packageFile);
};
const packageRoot = (file: JavaScriptArtifactFile): string =>
file.path.slice(0, -"package.json".length).replace(/\/$/u, "");
const registerPossiblePackageOwner = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
): void => {
const container = applicationContainerIdentity(context.fileSet, file);
const roots =
context.applicationPossibleRootsByContainer.get(container) ??
new Set<string>();
addApplicationRootCandidate(roots, packageRoot(file));
context.applicationPossibleRootsByContainer.set(container, roots);
};
const containerProperties = (container: JavaScriptArtifactContainer) => ({
path: container.path,
format: "asar",
@@ -0,0 +1,67 @@
import { expect, it } from "vitest";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
const files = new Map(
["app/main.mjs", "shared/window.cjs", "rea-artifact/shared/window.cjs"].map(
(path) => [
path,
{
path,
container_sha256: "a".repeat(64),
sha256: "b".repeat(64),
bytes: 0,
inventory_artifact_id: path,
kind: "javascript",
unpacked: false,
text: { included: true, value: "" },
} satisfies JavaScriptArtifactFile,
],
),
);
it.each([
"../../rea-artifact/shared/window.cjs",
"../%2e%2e/rea-artifact/shared/window.cjs",
"%2e%2e/%2e%2e/rea-artifact/shared/window.cjs",
"../../rea-artifact/shared/../shared/window.cjs",
])(
"retains above-root traversal before synthetic URL normalization: %s",
(declaredPath) => {
const actual = new URL(declaredPath, "file:///selected-root/app/main.mjs");
expect(actual.pathname.startsWith("/selected-root/")).toBe(false);
expect(
resolveArtifactPathByContext({
declaredPath,
sourcePath: "app/main.mjs",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({ resolution_status: "rejected", resolved_path: null });
},
);
it.each([
["../shared/window.cjs", "shared/window.cjs"],
["./discard%2Fsegment/../../shared/window.cjs", "shared/window.cjs"],
["./discard%/../../shared/window.cjs", "shared/window.cjs"],
["./%252e%252e/../../shared/window.cjs", "shared/window.cjs"],
["../rea-artifact/shared/window.cjs", "rea-artifact/shared/window.cjs"],
])(
"retains an inside-root URL without banning synthetic-looking names: %s",
(declaredPath, target) => {
const actual = new URL(declaredPath, "file:///selected-root/app/main.mjs");
expect(actual.pathname).toBe(`/selected-root/${target}`);
expect(
resolveArtifactPathByContext({
declaredPath,
sourcePath: "app/main.mjs",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({ resolution_status: "resolved", resolved_path: target });
},
);
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,6 @@
import { isBuiltin } from "node:module";
import { posix } from "node:path";
import { posix, win32 } from "node:path";
import { ELECTRON_MODULE } from "../../domain/javascript/javascriptElectronMemberWrites.js";
import {
normalizeJoinedLogicalPath,
@@ -27,6 +28,7 @@ import {
type TypeScriptEmittedSourceCandidate,
} from "../TypeScriptEmittedSourceCandidates.js";
import { isRelativeJavaScriptModuleSpecifier } from "../../domain/javascript/javascriptModuleSpecifier.js";
import { selectPackageSubpathPattern } from "../../domain/javascript/javascriptPackageSubpathPatterns.js";
import type { JavaScriptModuleResolutionMode } from "../../domain/javascript/javascriptModuleResolutionMode.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
@@ -46,6 +48,8 @@ type UnresolvedArtifactPathStatus =
interface ArtifactPathResolutionBase {
readonly declared_path: string;
readonly runtime_path_base?: "application-root";
readonly runtime_path_base_proven?: true;
readonly resolution_context: ArtifactPathResolutionContext;
readonly limitations: readonly string[];
readonly conditional_resolutions?: readonly ConditionalModuleResolution[];
@@ -87,7 +91,14 @@ export interface ResolveArtifactPathInput {
/** Import/require semantics; runtime resource paths omit this provenance. */
readonly moduleKind?: JavaScriptModuleResolutionMode;
/** Runtime paths retain their loader owner independently of source imports. */
readonly runtimePathKind?: "exact-file" | "node-entrypoint" | "url-resource";
readonly runtimePathKind?:
| "exact-file"
| "application-file"
| "node-entrypoint"
| "url-resource";
/** Logical root of an inventoried nested application container. */
readonly applicationRootPath?: string | null;
readonly applicationRootIsProven?: boolean;
}
/** Preserve an explicitly retained loader mode on graph relationships. */
@@ -193,6 +204,14 @@ export const resolveArtifactPathByContext = (
const resolution = resolvePath(input);
const facts = {
declared_path: input.declaredPath,
...(input.runtimePathKind === "application-file"
? { runtime_path_base: "application-root" as const }
: {}),
...(input.runtimePathKind === "application-file" &&
input.applicationRootIsProven === true &&
input.applicationRootPath !== null
? { runtime_path_base_proven: true as const }
: {}),
resolution_context: input.context,
limitations: resolution.limitations,
...(resolution.candidatePath === undefined
@@ -215,6 +234,12 @@ export const resolveArtifactPathByContext = (
export const artifactPathResolutionProperties = (
resolution: ArtifactPathResolution | undefined,
): Readonly<Record<string, JsonValue>> => ({
...(resolution?.runtime_path_base_proven === undefined
? {}
: { runtime_path_base_proven: resolution.runtime_path_base_proven }),
...(resolution?.runtime_path_base === undefined
? {}
: { runtime_path_base: resolution.runtime_path_base }),
...(resolution?.candidate_path === undefined
? {}
: { candidate_path: resolution.candidate_path }),
@@ -248,7 +273,50 @@ const conditionalResolution = (
: { candidate_path: result.candidatePath }),
});
/** Host path admission is shared by top-level and metadata-derived inputs. */
const resolveRuntimeHostPath = (
input: ResolveArtifactPathInput,
): CandidateResolution | null => {
if (
(input.runtimePathKind !== undefined || isNodeFilesystemLookup(input)) &&
input.runtimePathKind !== "url-resource" &&
(posix.isAbsolute(input.declaredPath) ||
win32.isAbsolute(input.declaredPath) ||
(input.context === "module-specifier" &&
input.moduleKind !== "require" &&
/^file:/iu.test(input.declaredPath)))
) {
const esm =
input.context === "module-specifier" && input.moduleKind === "import";
if (esm) {
const normalized = URL.parse(
htmlUrlText(input.declaredPath),
PACKAGE_ARTIFACT_ROOT_URL,
);
if (normalized === null)
return unresolvedCandidate("rejected", [
"The module URL cannot be parsed.",
]);
const admitted = decodeModuleUrlPath(normalized.pathname);
if (typeof admitted !== "string") return admitted;
}
// Host syntax is not a canonical logical path. In particular, Windows
// separators and drive letters must not be reclassified as invalid/URLs.
return input.declaredPath.includes("\0") ||
(!esm && fileUrlPath(input.declaredPath) === null)
? unresolvedCandidate("rejected", [
"NUL or unsafe file URL path syntax is not admitted in a runtime path.",
])
: unresolvedCandidate("unavailable", [
"An absolute runtime host path has no established mapping to the selected logical artifact root.",
]);
}
return null;
};
const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
const hostPath = resolveRuntimeHostPath(input);
if (hostPath !== null) return hostPath;
if (input.runtimePathKind === "node-entrypoint")
return resolveNodeRuntimeEntrypoint(input);
if (input.moduleKind === "compiler-dependent") {
@@ -286,7 +354,11 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
}
if (input.runtimePathKind === "url-resource")
return resolveExactUrlResource(input);
if (input.runtimePathKind === "exact-file" && isRuntimePathDeclaration(input))
if (
(input.runtimePathKind === "exact-file" ||
input.runtimePathKind === "application-file") &&
isRuntimePathDeclaration(input)
)
return resolveRuntimeEntrypoint(input);
// Package import keys are literal lookup identities, not artifact paths.
// The selected target is admitted separately by resolveExportTarget.
@@ -365,18 +437,37 @@ const isRuntimePathDeclaration = (input: ResolveArtifactPathInput): boolean =>
const resolveNodeRuntimeEntrypoint = (
input: ResolveArtifactPathInput,
): CandidateResolution => {
const { runtimePathKind: _runtimePathKind, ...sourceInput } = input;
const runtimeInput = { ...sourceInput, moduleKind: "require" as const };
if (!isRuntimePathDeclaration(runtimeInput)) return resolvePath(runtimeInput);
const windowsPath = unknownWindowsRuntimePath(input.declaredPath);
if (windowsPath !== null) return windowsPath;
const runtimeInput = { ...input, moduleKind: "require" as const };
if (input.context === "package-entrypoint" && input.declaredPath === "")
return resolveNodePackageMain(
runtimeInput,
posix.dirname(input.sourcePath),
undefined,
);
if (
runtimeInput.context !== "package-entrypoint" &&
!isRuntimePathDeclaration(runtimeInput)
) {
const { runtimePathKind: _runtimePathKind, ...moduleInput } = runtimeInput;
return resolvePath(moduleInput);
}
const candidate = contextualCandidate(runtimeInput);
if (typeof candidate !== "string") return candidate;
const confined = confineCandidate(candidate);
if (typeof confined !== "string") return confined;
// Node entrypoints use runtime file/directory probes. They do not originate
// in emitted TypeScript import syntax and cannot select a source substitute.
const resolution = resolveCandidate(runtimeInput, confined, new Set());
// Runtime entrypoint analysis has no extension or directory fallback, but
// retain the normalized loader path when that exact target is absent.
const resolution =
input.context === "package-entrypoint"
? resolveNodePackageMain(
runtimeInput,
posix.dirname(input.sourcePath),
confined,
)
: resolveCandidate(runtimeInput, confined, new Set());
// Retain the normalized declaration when Node's owned probes are exhausted.
return resolution.status === "not-found" &&
resolution.candidatePath === undefined
? { ...resolution, candidatePath: confined }
@@ -386,14 +477,30 @@ const resolveNodeRuntimeEntrypoint = (
const resolveRuntimeEntrypoint = (
input: ResolveArtifactPathInput,
): CandidateResolution => {
const rejected = rejectDeclaration(input.declaredPath);
if (input.runtimePathKind === "application-file") {
const windowsPath = unknownWindowsRuntimePath(input.declaredPath);
if (windowsPath !== null) return windowsPath;
}
const rejected = rejectDeclaration(
input.declaredPath,
declaresLiteralPercent(input),
);
if (rejected !== null) return rejected;
if (hasScheme(input.declaredPath) || looksExternal(input.declaredPath))
return unresolvedCandidate("external", [
"Electron runtime entrypoints must identify local paths, not external URLs.",
]);
if (
input.runtimePathKind === "application-file" &&
input.applicationRootPath === null
)
return unresolvedCandidate("unavailable", [
"Inventoried package metadata does not establish one application root for this source; the loadFile base is unknown.",
]);
const candidate = confineCandidate(
relativeCandidate(input.sourcePath, input.declaredPath),
input.runtimePathKind === "application-file"
? posix.join(input.applicationRootPath ?? "", input.declaredPath)
: relativeCandidate(input.sourcePath, input.declaredPath),
);
if (typeof candidate !== "string") return candidate;
return (
@@ -408,6 +515,17 @@ const resolveRuntimeEntrypoint = (
);
};
/** Host filesystem spellings need target-platform context before admission. */
const unknownWindowsRuntimePath = (
declared: string,
): CandidateResolution | null =>
!declared.includes("\0") &&
(declared.includes("\\") || /^[a-z]:/iu.test(declared))
? unresolvedCandidate("unavailable", [
"Windows-relative runtime paths require target-platform and drive context that the selected logical artifact inventory does not establish.",
])
: null;
const resolveRuntimeSourceCandidates = (
input: ResolveArtifactPathInput,
exactPath: string,
@@ -453,14 +571,19 @@ const rejectDeclaration = (
};
/**
* Node's CommonJS loader never percent-decodes a request or a legacy package
* main, so `%2e` and `%2f` there are literal filename bytes, not encoded dots
* or separators. Root confinement still applies to the joined candidate.
* Filesystem expressions, exact runtime paths, and CommonJS requests retain
* literal filename bytes. URL-to-path conversion belongs to the producer;
* these consumers must not decode or reject the resulting percent spelling.
* Root confinement still applies to the joined candidate.
*/
const declaresLiteralPercent = (input: ResolveArtifactPathInput): boolean =>
input.moduleKind === "require" &&
(input.context === "module-specifier" ||
input.context === "package-entrypoint");
input.context === "filesystem-expression" ||
(input.runtimePathKind === "exact-file" &&
isRuntimePathDeclaration(input) &&
!looksExternal(input.declaredPath)) ||
(input.moduleKind === "require" &&
(input.context === "module-specifier" ||
input.context === "package-entrypoint"));
const contextualCandidate = (
input: ResolveArtifactPathInput,
@@ -497,6 +620,15 @@ const contextualCandidate = (
"File URL authorities or invalid file URL forms cannot be mapped to artifact-local paths.",
]);
}
if (
context === "module-specifier" &&
input.moduleKind === "require" &&
declared.includes("\\") &&
(!hasScheme(declared) || /^[a-z]:/iu.test(declared))
) {
const windowsPath = unknownWindowsRuntimePath(declared);
if (windowsPath !== null) return windowsPath;
}
const hasUrlPathSemantics =
context === "module-specifier" && input.moduleKind === "import";
if (hasUrlPathSemantics) declared = htmlUrlText(declared);
@@ -516,15 +648,59 @@ const contextualCandidate = (
)
return bareModuleCandidate(input);
if (input.moduleKind !== "require") {
if (/%(?:2f|5c)/iu.test(stripQueryAndFragment(declared)))
return unresolvedCandidate("rejected", [
"ES module URL paths cannot contain percent-encoded slash or backslash bytes.",
]);
if (hasUrlPathSemantics) {
// URL normalization can leave and then re-enter the synthetic namespace.
// Preserve the shared URL owner's above-root markers before that loss.
if (
applyUrlReference(
urlDirectoryState(posix.dirname(input.sourcePath)),
stripQueryAndFragment(declared),
true,
).escapes > 0
)
return unresolvedCandidate("rejected", [
"The module URL escapes the selected logical artifact root.",
]);
const sourceUrl = packageMetadataUrl(input.sourcePath, declared);
const base = sourceUrl.ok
? sourceUrl.value
: sourceUrl.error.structuralUrl;
const normalized = URL.parse(declared, base);
if (normalized === null)
return unresolvedCandidate("rejected", [
"The module URL cannot be parsed.",
]);
if (!normalized.pathname.startsWith(PACKAGE_ARTIFACT_ROOT_URL.pathname))
return unresolvedCandidate("rejected", [
"The module URL escapes the selected logical artifact root.",
]);
// Node validates the complete normalized file URL, including encoded
// separator bytes inherited from an inventoried source directory.
const decoded = decodeModuleUrlPath(
normalized.pathname.slice(PACKAGE_ARTIFACT_ROOT_URL.pathname.length),
);
if (typeof decoded !== "string") return decoded;
if (
!sourceUrl.ok &&
decoded
.split("/")
.some((segment) => sourceUrl.error.structuralSegments.has(segment))
)
return unresolvedCandidate("unavailable", [
sourceUrl.error.limitation,
]);
const candidate = urlRelativeCandidate("", `/${decoded}`);
return typeof candidate === "string"
? exactUrlPathCandidate(decoded, candidate, "module URL")
: candidate;
}
const decoded = decodeModuleUrlPath(stripQueryAndFragment(declared));
if (typeof decoded !== "string") return decoded;
declared = decoded;
}
} else if (looksExternal(declared))
} else if (context !== "package-entrypoint" && looksExternal(declared))
// Package main is a filesystem declaration. Scheme-like filename bytes
// remain literal; URI classification belongs to module and URL owners.
return unresolvedCandidate("external", [
"URL schemes and protocol-relative URLs are outside this local artifact path context.",
]);
@@ -574,6 +750,10 @@ const contextualCandidate = (
const decodeModuleUrlPath = (
declared: string,
): string | CandidateResolution => {
if (/%(?:2f|5c)/iu.test(declared))
return unresolvedCandidate("rejected", [
"ES module URL paths cannot contain percent-encoded slash or backslash bytes.",
]);
let decoded: string;
try {
decoded = decodeURIComponent(declared);
@@ -618,17 +798,19 @@ const urlDirectoryState = (directory: string): UrlPathState => ({
const applyUrlReference = (
base: UrlPathState,
reference: string,
encodedDotSegments = false,
): UrlPathState => {
const absolute = reference.startsWith("/");
const segments = absolute ? [] : [...base.segments];
let escapes = absolute ? 0 : base.escapes;
const parts = (absolute ? reference.slice(1) : reference).split("/");
for (const [index, part] of parts.entries()) {
if (part !== "." && part !== "..") {
const dot = encodedDotSegments ? part.replace(/%2e/giu, ".") : part;
if (dot !== "." && dot !== "..") {
segments.push(part);
continue;
}
if (part === "..")
if (dot === "..")
if (segments.length > 0) segments.pop();
else escapes += 1;
if (index === parts.length - 1) segments.push("");
@@ -704,7 +886,6 @@ const bareModuleCandidate = (
if (typeof confined !== "string") return confined;
dependencyDirectory = posix.dirname(confined);
}
const source = input.files.get(input.sourcePath);
let nearestNotFound: CandidateResolution | null = null;
for (const directory of packageLookupDirectories(input)) {
const candidate = posix.join(directory, dependencyDirectory);
@@ -716,14 +897,7 @@ const bareModuleCandidate = (
(!request.dependencyExportsEligible ||
metadata.exports === undefined)));
const packageResolution =
metadata.kind !== "absent" ||
hasContainerDirectory(
input.files,
candidate,
source?.container_sha256,
input.directoryPaths,
input.unanalyzedFiles,
)
metadata.kind !== "absent" || hasContainerDirectory(input, candidate)
? resolvePackageDependency(input, candidate, request, metadata)
: request.packageSubpath === "." && input.moduleKind !== "import"
? resolveFileCandidates(
@@ -795,10 +969,6 @@ const resolvePackageImportAlias = (
return unresolvedCandidate("rejected", [
`The package import specifier ${JSON.stringify(specifier)} is invalid; "#" and specifiers that end with "/" are not valid import aliases.`,
]);
if (classified.kind === "pattern")
return unresolvedCandidate("external", [
`Wildcard and directory package import aliases remain unresolved; ${JSON.stringify(specifier)} is not an exact imports key.`,
]);
const metadata = enclosingPackageMetadata(input);
if (metadata.kind === "none")
return unresolvedCandidate("external", [
@@ -816,7 +986,7 @@ const resolvePackageImportAlias = (
`Package metadata ${metadata.path} does not define package import alias ${specifier}; it declares ${lookup.declared.join(", ") || "no imports keys"}.`,
]);
if (lookup.kind === "pattern-unresolved")
return unresolvedCandidate("external", [
return unresolvedCandidate("unavailable", [
`Wildcard package import aliases remain unresolved; package metadata ${metadata.path} has no exact imports key ${specifier}.`,
]);
const selected = selectExportTarget(
@@ -839,11 +1009,18 @@ const resolvePackageImportAlias = (
]);
if (selected.kind === "non-relative") {
const target = classifyPackageImportTargetString(selected.value);
return unresolvedCandidate("external", [
target.kind === "recursive"
? `Recursive package import aliases remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`
: `External-package import targets remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`,
]);
// Unsupported lookup may still reach a selected module and another launch
// owner. Only a known builtin establishes that no local imports follow.
return unresolvedCandidate(
isBuiltin(selected.value) || ELECTRON_MODULE.test(selected.value)
? "external"
: "unavailable",
[
target.kind === "recursive"
? `Recursive package import aliases remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`
: `External-package import targets remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`,
],
);
}
return resolveExportTarget(input, metadata.path, selected, "imports");
};
@@ -958,16 +1135,7 @@ const resolveLegacyEsmSubpath = (
return unresolvedCandidate("unavailable", [unrepresentableBase.limitation]);
const file = resolveFileCandidates(input, [candidate]);
if (file !== null) return file;
const source = input.files.get(input.sourcePath);
if (
hasContainerDirectory(
input.files,
candidate,
source?.container_sha256,
input.directoryPaths,
input.unanalyzedFiles,
)
)
if (hasContainerDirectory(input, candidate))
return unresolvedCandidate("rejected", [
`ESM package subpath ${candidate} names a directory; directory imports are unsupported and package main or index lookup was not attempted.`,
]);
@@ -998,6 +1166,10 @@ const resolvePackageExports = (
return unresolvedCandidate("external", [
`Directory package metadata ${packagePath} blocks subpath ${subpath} with ${exported.cause === "null" ? "an explicit null exports target" : "an empty exports target array"}.`,
]);
if (exported.status === "pattern-unresolved")
return unresolvedCandidate("unavailable", [
`Package metadata ${packagePath} selects wildcard exports key ${JSON.stringify(exported.pattern)} for subpath ${subpath}; wildcard target expansion remains unresolved.`,
]);
return resolveExportTarget(input, packagePath, exported);
};
@@ -1080,37 +1252,35 @@ const parseBarePackageRequest = (
};
const hasContainerDirectory = (
files: ReadonlyMap<string, JavaScriptArtifactFile>,
input: ResolveArtifactPathInput,
candidate: string,
containerSha256: string | undefined,
directoryPaths: ReadonlySet<string> | undefined,
unanalyzedFiles?: JavaScriptUnanalyzedArtifactLookup,
): boolean => {
if (directoryPaths?.has(candidate) === true) return true;
if (input.directoryPaths?.has(candidate) === true) return true;
if (
[...indexCandidates(candidate), posix.join(candidate, "package.json")].some(
(path) => {
const file = files.get(path);
const file = input.files.get(path);
return (
file !== undefined &&
(containerSha256 === undefined ||
file.container_sha256 === containerSha256)
admitsRuntimeContainer(input, path, file.container_sha256)
);
},
)
)
return true;
// Inventory has files rather than directory entries. Any same-container
// descendant establishes a package directory even without package.json.
// Inventory has files rather than directory entries. An admitted descendant
// establishes a package directory even without package.json.
const prefix = `${candidate}/`;
for (const [path, file] of files)
for (const [path, file] of input.files)
if (
path.startsWith(prefix) &&
(containerSha256 === undefined ||
file.container_sha256 === containerSha256)
admitsRuntimeContainer(input, path, file.container_sha256)
)
return true;
return unanalyzedFiles?.hasDirectory(candidate, containerSha256) ?? false;
const container = crossesRuntimeArchiveBoundary(input, `${candidate}/`)
? undefined
: input.files.get(input.sourcePath)?.container_sha256;
return input.unanalyzedFiles?.hasDirectory(candidate, container) ?? false;
};
const htmlCandidate = (
@@ -1228,12 +1398,12 @@ const readPackageMetadata = (
): PackageMetadata => {
const path = posix.join(directory, "package.json");
const file = input.files.get(path);
const source = input.files.get(input.sourcePath);
if (file === undefined) {
const unavailable = input.unanalyzedFiles?.get(path);
if (
source !== undefined &&
unavailable?.container_sha256 === source.container_sha256
unavailable !== undefined &&
(admitsRuntimeContainer(input, path, unavailable.container_sha256) ||
input.moduleKind !== undefined)
)
return {
kind: "unavailable",
@@ -1243,8 +1413,13 @@ const readPackageMetadata = (
};
return { kind: "absent" };
}
if (source !== undefined && file.container_sha256 !== source.container_sha256)
return { kind: "absent" };
if (!admitsRuntimeContainer(input, path, file.container_sha256))
return input.moduleKind === undefined
? { kind: "absent" }
: {
kind: "unavailable",
limitation: `Inventoried package metadata ${path} crosses an artifact container boundary without an established runtime loader mapping.`,
};
if (!file.text.included)
return {
kind: "unavailable",
@@ -1260,7 +1435,7 @@ const readPackageMetadata = (
limitation: `Directory package metadata ${path} is not valid package JSON.`,
};
}
if (typeof value !== "object" || value === null)
if (typeof value !== "object" || value === null || Array.isArray(value))
return {
kind: "unavailable",
limitation: `Directory package metadata ${path} is not valid package JSON.`,
@@ -1351,12 +1526,12 @@ const resolveDirectory = (
? metadata.module
: undefined;
if (entry === undefined)
return (
resolveFileCandidates(
input,
indexCandidates(directory, input.moduleKind),
) ?? notFoundCandidate()
);
return input.moduleKind !== undefined
? resolveNodePackageMain(input, directory, undefined)
: (resolveFileCandidates(
input,
indexCandidates(directory, input.moduleKind),
) ?? notFoundCandidate());
if (typeof entry !== "string" || entry.length === 0)
return unresolvedCandidate("unavailable", [
`Directory package metadata ${metadata.path} is not valid package JSON.`,
@@ -1374,6 +1549,12 @@ const resolveDirectory = (
: { unanalyzedFiles: input.unanalyzedFiles }),
...(input.moduleKind === undefined ? {} : { moduleKind: input.moduleKind }),
};
const hostPath = resolveRuntimeHostPath(entryInput);
if (hostPath !== null) return hostPath;
if (entryInput.moduleKind !== undefined) {
const windowsPath = unknownWindowsRuntimePath(entry);
if (windowsPath !== null) return windowsPath;
}
const candidate = contextualCandidate(entryInput);
if (typeof candidate !== "string") return candidate;
const confined = confineCandidate(candidate);
@@ -1397,55 +1578,47 @@ const resolveDirectory = (
const resolveNodePackageMain = (
input: ResolveArtifactPathInput,
directory: string,
mainPath: string,
mainPath: string | undefined,
): CandidateResolution => {
const normalizedMain = mainPath.replace(/\/$/u, "");
for (const suffix of NODE_PACKAGE_MAIN_SUFFIXES) {
const isDirectoryIndex = suffix.startsWith("/");
if (normalizedMain === "" && !isDirectoryIndex) continue;
const selected = resolveFileCandidates(input, [
isDirectoryIndex
? posix.join(normalizedMain, suffix.slice(1))
: posix.normalize(`${normalizedMain}${suffix}`),
]);
if (selected === null) continue;
if (input.moduleKind === "require") return selected;
// ESM probes the normalized main, then appends the selected suffix to
// the original declaration before finalizing the resulting file path.
const finalized = confineCandidate(
relativeCandidate(input.sourcePath, `${input.declaredPath}${suffix}`),
);
if (typeof finalized !== "string") return finalized;
const finalizedFile = resolveFileCandidates(input, [
finalized.replace(/\/$/u, ""),
]);
if (finalizedFile !== null) return finalizedFile;
const source = input.files.get(input.sourcePath);
if (
hasContainerDirectory(
input.files,
finalized,
source?.container_sha256,
input.directoryPaths,
input.unanalyzedFiles,
)
)
return {
...unresolvedCandidate("rejected", [
`The selected legacy ESM package main finalizes to directory ${finalized}; directory imports are unsupported and no later main or index candidate was tried.`,
]),
candidatePath: finalized,
};
return unresolvedCandidate("not-found", [
`The selected legacy ESM package main finalizes to ${finalized}, which is not an inventoried file; no later main or index candidate was tried.`,
]);
}
const normalizedMain = mainPath?.replace(/\/$/u, "");
if (normalizedMain !== undefined)
for (const suffix of NODE_PACKAGE_MAIN_SUFFIXES) {
const isDirectoryIndex = suffix.startsWith("/");
if (normalizedMain === "" && !isDirectoryIndex) continue;
const selected = resolveFileCandidates(input, [
isDirectoryIndex
? posix.join(normalizedMain, suffix.slice(1))
: posix.normalize(`${normalizedMain}${suffix}`),
]);
if (selected === null) continue;
if (input.moduleKind === "require") return selected;
// ESM probes the normalized main, then appends the selected suffix to
// the original declaration before finalizing the resulting file path.
const finalized = confineCandidate(
relativeCandidate(input.sourcePath, `${input.declaredPath}${suffix}`),
);
if (typeof finalized !== "string") return finalized;
const finalizedFile = resolveFileCandidates(input, [
finalized.replace(/\/$/u, ""),
]);
if (finalizedFile !== null) return finalizedFile;
if (hasContainerDirectory(input, finalized))
return {
...unresolvedCandidate("rejected", [
`The selected legacy ESM package main finalizes to directory ${finalized}; directory imports are unsupported and no later main or index candidate was tried.`,
]),
candidatePath: finalized,
};
return unresolvedCandidate("not-found", [
`The selected legacy ESM package main finalizes to ${finalized}, which is not an inventoried file; no later main or index candidate was tried.`,
]);
}
const fallback = resolveFileCandidates(
input,
indexCandidates(directory, input.moduleKind),
);
if (fallback !== null) return fallback;
return input.moduleKind === "require"
return input.moduleKind === "require" && mainPath !== undefined
? { ...notFoundCandidate(), stopsAncestorLookup: true }
: notFoundCandidate();
};
@@ -1472,24 +1645,83 @@ const resolveFileCandidates = (
return null;
};
/** Classify the extension as inventory does; retain exact archive occurrence bytes. */
const runtimeArchiveRoot = (path: string): string | undefined => {
const components = path.split("/");
const archive = components.findLastIndex(
(component, index) =>
index < components.length - 1 &&
component.toLowerCase().endsWith(".asar"),
);
return archive < 0
? undefined
: `${components.slice(0, archive + 1).join("/")}/`;
};
/** One archive-path admission rule serves files, directories and their metadata. */
const isNodeFilesystemLookup = (input: ResolveArtifactPathInput): boolean =>
input.runtimePathKind === "node-entrypoint" ||
(input.moduleKind !== undefined &&
(input.context === "module-specifier" ||
input.context === "package-entrypoint"));
/** Runtime launch and module lookup share archive occurrence admission. */
const crossesRuntimeArchiveBoundary = (
input: ResolveArtifactPathInput,
path: string,
): boolean => {
// Electron's archive filesystem normalizes a relative module path before
// opening it, including entry into an inventoried archive occurrence and
// escape from one, after the existing artifact-root confinement check.
const sourceArchive = runtimeArchiveRoot(input.sourcePath);
const targetArchive = runtimeArchiveRoot(path);
return (
isNodeFilesystemLookup(input) &&
((sourceArchive !== undefined && !path.startsWith(sourceArchive)) ||
(targetArchive !== undefined &&
!input.sourcePath.startsWith(targetArchive)))
);
};
const admitsRuntimeContainer = (
input: ResolveArtifactPathInput,
path: string,
containerSha256: string,
): boolean => {
const source = input.files.get(input.sourcePath);
return (
crossesRuntimeArchiveBoundary(input, path) ||
(input.runtimePathKind === "application-file" &&
input.applicationRootIsProven === true) ||
source === undefined ||
containerSha256 === source.container_sha256
);
};
/** Own candidate presence and container identity independently of loader policy. */
const findFileCandidate = (
input: ResolveArtifactPathInput,
path: string,
): CandidateResolution | null => {
const source = input.files.get(input.sourcePath);
const target = input.files.get(path);
if (
target !== undefined &&
(source === undefined ||
target.container_sha256 === source.container_sha256)
admitsRuntimeContainer(input, path, target.container_sha256)
)
return { resolvedPath: path, status: "resolved", limitations: [] };
if (target !== undefined && isNodeFilesystemLookup(input))
return {
resolvedPath: null,
status: "unavailable",
candidatePath: path,
limitations: [
"The inventoried module crosses an artifact container boundary; its runtime loader mapping has not been established.",
],
};
const unanalyzed = input.unanalyzedFiles?.get(path);
if (
unanalyzed !== undefined &&
(source === undefined ||
unanalyzed.container_sha256 === source.container_sha256)
admitsRuntimeContainer(input, path, unanalyzed.container_sha256)
)
return {
resolvedPath: null,
@@ -1542,9 +1774,10 @@ type PackageExportOutcome =
| ({ readonly status: "value" } & PackageFileTarget)
| { readonly status: "rejected"; readonly limitation: string }
| { readonly status: "blocked"; readonly cause: PackageTargetBlockCause }
| { readonly status: "pattern-unresolved"; readonly pattern: string }
| { readonly status: "unmatched"; readonly declared: readonly string[] };
/** Only exact non-directory keys participate; wildcard expansion is unsupported. */
/** Exact keys precede the best matching pattern; pattern expansion remains unknown. */
const selectPackageExport = (
value: unknown,
subpath: string,
@@ -1552,6 +1785,7 @@ const selectPackageExport = (
packagePath: string,
): PackageExportOutcome => {
let target = value;
let pattern: string | undefined;
let declared: string[] = [];
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
const keys = Object.keys(value);
@@ -1564,13 +1798,14 @@ const selectPackageExport = (
};
declared = keys;
if (hasSubpaths) {
if (
subpath.endsWith("/") ||
subpath.includes("*") ||
!Object.hasOwn(value, subpath)
)
return { status: "unmatched", declared };
target = Reflect.get(value, subpath);
if (subpath.endsWith("/")) return { status: "unmatched", declared };
if (!subpath.includes("*") && Object.hasOwn(value, subpath))
target = Reflect.get(value, subpath);
else {
pattern = selectPackageSubpathPattern(keys, subpath);
if (pattern === undefined) return { status: "unmatched", declared };
target = Reflect.get(value, pattern);
}
} else if (subpath !== ".") return { status: "unmatched", declared };
} else if (subpath !== ".") return { status: "unmatched", declared };
const selected = selectExportTarget(
@@ -1592,6 +1827,7 @@ const selectPackageExport = (
status: "rejected",
limitation: `The package exports target ${JSON.stringify(selected.value)} must start with "./".`,
};
if (pattern !== undefined) return { status: "pattern-unresolved", pattern };
return {
status: "value",
value: selected.value,
@@ -2,6 +2,7 @@ import { primitiveByteExpansionSource } from "../../../tests/fixtures/javascript
import { graphForJavaScript as graphFor } from "../../../tests/fixtures/javascriptSemanticGraph.js";
import * as t from "@babel/types";
import { expect, it } from "vitest";
import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js";
import {
SEMANTIC_GRAPH_FILE_NODE_CEILING,
@@ -26,6 +27,36 @@ import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterE
const SHA256 = "a".repeat(64);
it("projects bounded alias selection as a resource-limit unknown", () => {
const source = aliasSelectionBudgetSource();
const ir = analyzeJavaScriptSemantics(source);
const graph = graphFor(source, ir);
expect(ir.coverage).toMatchObject({
status: "partial",
resourceLimits: ["alias-selections"],
});
const unknown = graph.unknowns.find(
({ reason, detail }) =>
reason === "resource-limit" && /Alias selection expansion/u.test(detail),
);
expect(unknown).toBeDefined();
expect(
graph.evidence_contexts.find(
({ context_id }) => context_id === unknown?.evidence.context_id,
),
).toMatchObject({
coverage: {
limits: expect.arrayContaining([
{
name: "javascript_semantic_alias_selections",
value: 512,
unit: "items",
},
]),
},
});
});
it.each([
["plain export", "const answer = 42; export { answer };", 0],
["aliased export", "const answer = 42; export { answer as result };", 0],
@@ -660,10 +660,10 @@ function* projectFileSteps(
const projectFileResourceLimits = (
context: Pick<FileContext, "file" | "ir" | "state" | "moduleNode">,
): void => {
// Getter limits describe query effects without tagged values; other limits
// Query limits describe effects without tagged values; other limits
// already project from binding or return-site values and must not be duplicated.
for (const limit of semanticCoverageResourceLimits(context.ir.coverage)) {
if (limit !== "getter-expansion") continue;
if (limit !== "getter-expansion" && limit !== "alias-selections") continue;
addSemanticGraphUnknown(context.state, {
node_id: context.moduleNode.node_id,
family: "data-flow",
@@ -0,0 +1,68 @@
import { Ajv } from "ajv";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it } from "vitest";
import { z } from "zod";
import { canonicalBase64Schema } from "../domain/stringPatterns.js";
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
import { toolContract } from "./toolContracts.js";
import {
toolInputSchemaWithMetadata,
toolOutputSchemaWithMetadata,
} from "./toolSchemaMetadata.js";
it.each(["draft-2020-12", "draft-07"] as const)(
"retains native base64 metadata and intersections in both MCP directions: %s",
(target) => {
const literal = { pattern: "^(?!literal)annotation$" };
const canonical = z.strictObject({
encoded: z.intersection(canonicalBase64Schema, z.string().max(4)),
literal: z
.strictObject({ pattern: z.literal(literal.pattern) })
.meta({ default: literal, examples: [literal] }),
});
const contract = {
...toolContract("open_binary"),
inputSchema: canonical,
outputSchema: canonical,
};
const input = toolInputSchemaWithMetadata(contract);
const output = toolOutputSchemaWithMetadata(contract);
const projectedInput = input["~standard"].jsonSchema.input({ target });
const projectedOutput = output["~standard"].jsonSchema.output({ target });
const { $id, ...withoutId } = projectedOutput;
expect($id).toBe(
`urn:rea:tool-output:sha256:${digestCanonicalValue({ target, schema: withoutId }, "Tool output schema")}`,
);
for (const projected of [projectedInput, projectedOutput]) {
const properties = projected.properties as Record<string, unknown>;
const literalProperty = properties.literal as Record<string, unknown>;
const literalSchema =
typeof literalProperty.$ref === "string"
? literalProperty.$ref
.slice(2)
.split("/")
.reduce<unknown>(
(value, key) => (value as Record<string, unknown>)[key],
projected,
)
: literalProperty;
expect(literalSchema).toMatchObject({
default: literal,
examples: [literal],
});
const Validator = target === "draft-2020-12" ? Ajv2020 : Ajv;
const validate = new Validator({ strict: false }).compile(projected);
for (const [encoded, valid] of [
["AA==", true],
["AB==", false],
["AAAAAA==", false],
] as const) {
const value = { encoded, literal };
expect(validate(value), encoded).toBe(valid);
expect(input.safeParse(value).success, encoded).toBe(valid);
expect(output.safeParse(value).success, encoded).toBe(valid);
}
}
},
);
@@ -1,9 +1,12 @@
import type * as t from "@babel/types";
import * as t from "@babel/types";
import { inspectElectronBrowserNode } from "./electronStaticAnalysisBrowser.js";
import { inspectElectronIpcNode } from "./electronStaticAnalysisIpc.js";
import { inspectElectronNativeNode } from "./electronStaticAnalysisNative.js";
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
import { electronCalleePath } from "./electronStaticAnalysisValues.js";
import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
/** Collect Electron-specific syntax during the shared inert AST traversal. */
export const inspectElectronStaticNode = (
@@ -13,4 +16,26 @@ export const inspectElectronStaticNode = (
inspectElectronBrowserNode(node, context);
inspectElectronIpcNode(node, context);
inspectElectronNativeNode(node, context);
const path =
t.isCallExpression(node) || t.isOptionalCallExpression(node)
? (electronCalleePath(node.callee, context) ??
context.electronInvocationPath?.(node))
: undefined;
if (
(path !== undefined &&
path[0] === "app" &&
path[1] === "setAppPath" &&
path
.slice(2)
.every((member) => ["call", "apply", "bind"].includes(member))) ||
context.electronAppPathChangePossible?.(node) === true
)
// A bound or exported setter may escape this source before it changes the
// shared app path. Keep that possibility without claiming an invocation.
addLocatedFinding(context, {
collection: context.accumulator.appPathChanges,
key: "electron:app-path-change",
node,
value: { module_key: null, location: range(node) },
});
};
@@ -0,0 +1,127 @@
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const prefix = "const {app,BrowserWindow}=require('electron');";
const cases = [
{
name: "assigned native setter callee",
body: "let change;(change=app.setAppPath)('/changed');",
possible: true,
calls: [1],
},
{
name: "assigned local setter callee",
body: "let change;(change=()=>{})('/changed');",
possible: false,
calls: [0],
},
{
name: "assigned native app receiver",
body: "let runtime;(runtime=app).setAppPath('/changed');",
possible: true,
calls: [1],
},
{
name: "assigned local app receiver",
body: "let runtime;(runtime={setAppPath(){}}).setAppPath('/changed');",
possible: false,
calls: [0],
},
{
name: "logical-or assigned app receiver",
body: "let runtime=flag?{setAppPath(){}}:null;(runtime||=app).setAppPath('/changed');",
possible: true,
calls: [1, 0],
},
{
name: "logical-and assigned app receiver",
body: "let runtime=flag?app:null;(runtime&&=app)?.setAppPath('/changed');",
possible: true,
calls: [0, 1],
},
{
name: "nullish assigned app receiver",
body: "let runtime=flag?{setAppPath(){}}:null;(runtime??=app).setAppPath('/changed');",
possible: true,
calls: [1, 0],
},
{
name: "definite local setter replacement",
body: "app.setAppPath=()=>{};app.setAppPath('/changed');",
possible: false,
calls: [0],
},
{
name: "unchanged native setter",
body: "app.setAppPath('/changed');",
possible: true,
calls: [1],
},
{
name: "conditional setter replacement",
body: "if(flag)app.setAppPath=()=>{};app.setAppPath('/changed');",
possible: true,
calls: [1, 0],
},
{
name: "setter captured before replacement",
body: "const change=app.setAppPath;app.setAppPath=()=>{};change('/changed');",
possible: true,
calls: [1],
},
{
name: "setter captured after replacement",
body: "app.setAppPath=()=>{};const change=app.setAppPath;change('/changed');",
possible: false,
calls: [0],
},
{
name: "setter invoked before replacement",
body: "app.setAppPath('/changed');app.setAppPath=()=>{};",
possible: true,
calls: [1],
},
];
it.each(cases)(
"selects the app-path callable for $name",
({ body, possible, calls }) => {
const source = `${prefix}${body}new BrowserWindow().loadFile('index.html');`;
for (const [index, expected] of calls.entries()) {
let changed = 0;
const rendered: string[] = [];
// Record JavaScript member/capture behavior without executing Electron.
runInNewContext(source, {
flag: index === 1,
require: () => ({
app: {
setAppPath() {
changed += 1;
},
},
BrowserWindow: class {
loadFile(file: string) {
rendered.push(file);
}
},
}),
});
expect(changed).toBe(expected);
expect(rendered).toEqual(["index.html"]);
}
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.electron.app_path_changes ?? []).toEqual(
possible
? [
expect.objectContaining({
module_key: null,
location: expect.any(Object),
}),
]
: [],
);
},
);
@@ -23,6 +23,7 @@ import {
range,
staticPath,
staticPathResolutionContext,
type JavaScriptStaticPathIntrinsicQuery,
} from "./javascriptStaticAnalysisHelpers.js";
import {
semanticStaticPropertyKey,
@@ -133,6 +134,7 @@ const inspectBrowserWindow = (
context.source,
lookup.status,
resolvedPreferences,
context.nodePathResolve,
);
context.accumulator.unknownFindings += collected.unknown;
const finding: ElectronBrowserWindowFinding = {
@@ -163,6 +165,7 @@ const collectWindowOptions = (
source: string,
lookupStatus: "explicit" | "missing" | "dynamic",
preferences: t.Node | undefined,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
): {
readonly status: ElectronBrowserWindowFinding["web_preferences_status"];
readonly preferences: readonly ElectronWebPreference[];
@@ -176,12 +179,13 @@ const collectWindowOptions = (
preload: { preload_path: null, preload_resolution_context: null },
unknown: lookupStatus === "missing" ? 0 : 1,
};
return collectWebPreferences(source, preferences);
return collectWebPreferences(source, preferences, nodePathResolve);
};
const collectWebPreferences = (
source: string,
object: t.ObjectExpression,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
): {
readonly status: "object-literal";
readonly preferences: readonly ElectronWebPreference[];
@@ -193,7 +197,7 @@ const collectWebPreferences = (
const effectivePreload = staticObjectProperty(object, "preload");
const preloadPath =
effectivePreload.status === "explicit"
? staticPath(effectivePreload.property.value)
? staticPath(effectivePreload.property.value, nodePathResolve)
: undefined;
const preload: ElectronBrowserWindowPreload =
effectivePreload.status === "explicit" && preloadPath !== undefined
@@ -201,6 +205,7 @@ const collectWebPreferences = (
preload_path: preloadPath,
preload_resolution_context: staticPathResolutionContext(
effectivePreload.property.value,
nodePathResolve,
),
}
: { preload_path: null, preload_resolution_context: null };
@@ -230,7 +235,10 @@ const collectWebPreferences = (
});
continue;
}
const path = name === "preload" ? staticPath(property.value) : undefined;
const path =
name === "preload"
? staticPath(property.value, nodePathResolve)
: undefined;
const value: ElectronStaticValue =
path === undefined
? electronStaticValue(source, property.value)
@@ -305,12 +313,14 @@ const inspectUtilityProcess = (
return;
const moduleNode = argumentNode(node.arguments[0]);
const modulePath =
moduleNode === undefined ? undefined : staticPath(moduleNode);
moduleNode === undefined
? undefined
: staticPath(moduleNode, context.nodePathResolve);
const options = argumentNode(node.arguments[2]);
const serviceNameProperty = staticObjectProperty(options, "serviceName");
const serviceName =
serviceNameProperty.status === "explicit"
? staticPath(serviceNameProperty.property.value)
? staticPath(serviceNameProperty.property.value, context.nodePathResolve)
: undefined;
if (serviceNameProperty.status !== "missing" && serviceName === undefined)
context.accumulator.unknownFindings += 1;
@@ -324,7 +334,10 @@ const inspectUtilityProcess = (
}
: {
module_path: modulePath,
module_resolution_context: staticPathResolutionContext(moduleNode),
module_resolution_context: staticPathResolutionContext(
moduleNode,
context.nodePathResolve,
),
module_expression: null,
};
const finding: ElectronUtilityProcessFinding = {
@@ -0,0 +1,136 @@
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const cases = [
["native setter", "const change=app.setAppPath;", true, [1]],
[
"local replacement",
"app.setAppPath=()=>{};const change=app.setAppPath;",
false,
[0],
],
[
"conditional replacement",
"if(flag)app.setAppPath=()=>{};const change=app.setAppPath;",
true,
[1, 0],
],
[
"capture before replacement",
"const change=app.setAppPath;app.setAppPath=()=>{};",
true,
[1],
],
[
"capture after replacement",
"app.setAppPath=()=>{};const change=app.setAppPath;",
false,
[0],
],
] as const;
it.each(cases)(
"retains the possible exported app-path effect after %s",
(_name, body, possible, calls) => {
const defining = `const {app}=require('electron');${body}`;
for (const [index, expected] of calls.entries()) {
let changed = 0;
// Authored module-namespace values model capture and invocation in two
// separate VM contexts; the fixture does not execute Electron itself.
const namespace = runInNewContext(`${defining}({change,app});`, {
flag: index === 1,
require: () => ({ app: { setAppPath: () => changed++ } }),
});
expect(changed).toBe(0);
runInNewContext("change.call(app,'./lib');", namespace);
expect(changed).toBe(expected);
}
const exported = analyzeJavaScriptStaticSource(
`${defining}export{change,app};`,
);
expect(exported.parse_error_count).toBe(0);
expect((exported.electron.app_path_changes ?? []).length > 0).toBe(
possible,
);
const caller = analyzeJavaScriptStaticSource(
"import{change,app}from'./helper.mjs';change.call(app,'./lib');",
);
expect(caller.parse_error_count).toBe(0);
expect(caller.electron.app_path_changes ?? []).toEqual([]);
},
);
it("does not treat a type-only exported setter as a runtime escape", () => {
const analysis = analyzeJavaScriptStaticSource(
"const{app}=require('electron');const change=app.setAppPath;export type{change};",
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.electron.app_path_changes ?? []).toEqual([]);
});
it.each([
["export const change=app.setAppPath;", true],
["export default app.setAppPath;", true],
["export const change=()=>{};", false],
["export default ()=>{};", false],
] as const)(
"selects the published runtime value in %s",
(declaration, possible) => {
const analysis = analyzeJavaScriptStaticSource(
`const{app}=require('electron');${declaration}`,
);
expect(analysis.parse_error_count).toBe(0);
expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(
possible,
);
},
);
it.each(cases)(
"retains the captured CommonJS namespace setter after %s",
(_name, body, possible, calls) => {
const defining = `const {app}=require('electron');${body}module.exports={change,app};`;
for (const [index, expected] of calls.entries()) {
let changed = 0;
const module = { exports: {} };
runInNewContext(defining, {
module,
exports: module.exports,
flag: index === 1,
require: () => ({ app: { setAppPath: () => changed++ } }),
});
expect(changed).toBe(0);
runInNewContext("change.call(app,'./lib');", module.exports);
expect(changed).toBe(expected);
}
const analysis = analyzeJavaScriptStaticSource(defining);
expect(analysis.parse_error_count).toBe(0);
expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(
possible,
);
},
);
it.each([
["module.exports=change;", true],
["exports.change=change;", true],
["module.exports.change=change;", true],
["Object.defineProperty(exports,'change',{value:change});", true],
["exports.change=change;module.exports={};", false],
["const module={exports:{}};module.exports={change};", false],
["const exports={};exports.change=change;", false],
["export const namespace={change,app};", true],
] as const)(
"follows shared runtime export values and receivers in %s",
(published, possible) => {
const analysis = analyzeJavaScriptStaticSource(
`const {app}=require('electron');const change=app.setAppPath;${published}`,
);
expect(analysis.parse_error_count).toBe(0);
expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(
possible,
);
},
);
@@ -2,6 +2,130 @@ import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
"module.require = replacement;",
"const holder = { loader: module }; holder.loader.require = replacement;",
"const holder = {}; holder.loader = module; holder.loader.require = replacement;",
])(
"does not prove Electron exports after a delegate replacement: %s",
(write) => {
const analysis = analyzeJavaScriptStaticSource(
`${write} const { BrowserWindow } = require("electron"); new BrowserWindow();`,
);
expect(analysis.electron.browser_windows).toEqual([]);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: null }),
]);
},
);
it.each([
['const { app } = require("electron"); app.setAppPath("./other");', true],
[
'import { app as runtime } from "electron/main"; runtime.setAppPath(path);',
true,
],
[
'import * as electron from "electron"; electron.app.setAppPath("./other");',
true,
],
[
'const { app } = require("electron"); const runtime = app; runtime.setAppPath("./other");',
true,
],
[
'const { app } = require("electron"); const change = app.setAppPath; change("./other");',
true,
],
['const app = { setAppPath() {} }; app.setAppPath("./other");', false],
[
'import { app } from "electron"; function local(app) { app.setAppPath("./other"); }',
false,
],
[
'const electron = { app: { setAppPath() {} } }; electron.app.setAppPath("./other");',
false,
],
])(
"retains Electron application path changes by binding identity: %s",
(source, expected) => {
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.electron.app_path_changes ?? []).toEqual(
expected
? [
expect.objectContaining({
module_key: null,
location: expect.any(Object),
}),
]
: [],
);
},
);
it.each([
['app.setAppPath.call(app, "./other");', true],
['app.setAppPath.apply(app, ["./other"]);', true],
['Reflect.apply(app.setAppPath, app, ["./other"]);', true],
[
'const invoke = Reflect.apply; invoke(app.setAppPath, app, ["./other"]);',
true,
],
[
'const invoke = Reflect.apply.bind(Reflect); invoke(app.setAppPath, app, ["./other"]);',
true,
],
[
'const invoke = Reflect.apply; Reflect.apply = () => {}; invoke(app.setAppPath, app, ["./other"]);',
true,
],
['const change = app.setAppPath.bind(app); change("./other");', true],
['app["setAppPath"][`call`](app, "./other");', true],
[
'const runtime = app; runtime.setAppPath.apply(runtime, ["./other"]);',
true,
],
['const change = app.setAppPath; change.call(app, "./other");', true],
['function local(app) { app.setAppPath.call(app, "./other"); }', false],
[
'const local = { setAppPath() {} }; local.setAppPath.bind(local)("./other");',
false,
],
['app.setAppPath.other(app, "./other");', false],
[
'const Reflect = { apply() {} }; Reflect.apply(app.setAppPath, app, ["./other"]);',
false,
],
[
'Reflect.apply = () => {}; Reflect.apply(app.setAppPath, app, ["./other"]);',
false,
],
[
'const local = { setAppPath() {} }; Reflect.apply(local.setAppPath, local, ["./other"]);',
false,
],
])(
"retains indirect application path changes by Electron identity: %s",
(call, expected) => {
const analysis = analyzeJavaScriptStaticSource(`
const { app } = require("electron");
${call}
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.electron.app_path_changes ?? []).toEqual(
expected
? [
expect.objectContaining({
module_key: null,
location: expect.any(Object),
}),
]
: [],
);
},
);
it("recovers APIs through an ES module default import of Electron", () => {
const analysis = analyzeJavaScriptStaticSource(`
import electron from "electron";
@@ -0,0 +1,112 @@
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
it.each([
["export {app};", true],
["export default app;", true],
["export const exposed={app};", true],
["export const exposed=[app];", true],
["app.setAppPath=()=>{};export{app};", false],
["const local={setAppPath(){}};export{local};", false],
] as const)(
"retains whole exported application capabilities in %s",
(published, possible) => {
const result = analyzeJavaScriptStaticSource(
`import {app} from 'electron';${published}`,
);
expect(result.parse_error_count).toBe(0);
expect((result.electron.app_path_changes ?? []).length > 0).toBe(possible);
},
);
it.each([
[
"later local",
"Object.assign(exports,{change:app.setAppPath},{change(){}});",
false,
],
[
"later native",
"Object.assign(exports,{change(){}},{change:app.setAppPath});",
true,
],
[
"inherited",
"const source={__proto__:{change:app.setAppPath}};Object.assign(exports,source);",
false,
],
[
"own",
"const source={__proto__:{change(){}},change:app.setAppPath};Object.assign(exports,source);",
true,
],
[
"later local alias",
"const local={change(){}};Object.assign(exports,{change:app.setAppPath},local);",
false,
],
[
"unrelated later field",
"Object.assign(exports,{change:app.setAppPath},{other(){}});",
true,
],
["array own", "Object.assign(exports,[app.setAppPath]);", true],
[
"nullish sources",
"Object.assign(exports,{change:app.setAppPath},null,undefined);",
true,
],
[
"getter deletes a later source slot",
"Object.assign(exports,{change:app.setAppPath},{get first(){delete this.change},change(){}});",
true,
],
] as const)("publishes only copied fields for %s", (_name, body, possible) => {
let changed = 0;
const app = {
setAppPath() {
changed++;
},
};
const module = { exports: {} };
runInNewContext(body, { app, module, exports: module.exports });
const fields = Object.values(module.exports) as unknown[];
for (const field of fields)
if (typeof field === "function") field.call(app, "./lib");
expect(changed > 0).toBe(possible);
const result = analyzeJavaScriptStaticSource(
`const{app}=require('electron');${body}`,
);
expect(result.parse_error_count).toBe(0);
expect((result.electron.app_path_changes ?? []).length > 0).toBe(possible);
});
it("retains the final named own exports without publishing overwritten fields", () => {
const result = analyzeJavaScriptSemantics(
"const local=()=>{};Object.assign(exports,{change:require('electron').app.setAppPath,retained:local},{change:local});",
);
expect(
result.moduleLinks
.filter(({ kind }) => kind === "commonjs-export")
.map(({ exportedName }) => exportedName)
.sort(),
).toEqual(["change", "retained"]);
});
it("retains copied module provenance without publishing the namespace object", () => {
const result = analyzeJavaScriptSemantics(
"Object.assign(exports,require('./factory.cjs'));",
);
expect(result.moduleLinks).toContainEqual(
expect.objectContaining({
kind: "commonjs-export",
exportedName: "*",
specifier: "./factory.cjs",
importedName: null,
localName: null,
callableId: null,
}),
);
});
@@ -0,0 +1,231 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
[
"direct factory",
"const getApp=()=>app;getApp().setAppPath('./lib');",
true,
],
[
"returned alias",
"const getApp=()=>app;const runtime=getApp();runtime.setAppPath('./lib');",
true,
],
[
"method factory",
"const api={get(){return app}};api.get().setAppPath('./lib');",
true,
],
[
"projected return",
"const getApp=()=>({runtime:app});getApp().runtime.setAppPath('./lib');",
true,
],
["method return", "const change=()=>app.setAppPath;change()('./lib');", true],
[
"awaited factory",
"const getApp=async()=>app;(await getApp()).setAppPath('./lib');",
true,
],
[
"unawaited promise",
"const getApp=async()=>app;getApp().setAppPath('./lib');",
false,
],
[
"generator",
"function*getApp(){return app}getApp().setAppPath('./lib');",
false,
],
[
"awaited generator",
"function*getApp(){return app}(await getApp()).setAppPath('./lib');",
false,
],
[
"local factory",
"const getApp=()=>({setAppPath(){}});getApp().setAppPath('./lib');",
false,
],
[
"escaped local",
"const local={setAppPath(){}};escape(local);local.setAppPath('./lib');",
false,
],
[
"external local",
"const local=require('local-library');local.setAppPath('./lib');",
false,
],
[
"unknown return",
"const getApp=()=>unknown;getApp().setAppPath('./lib');",
false,
],
[
"unrelated unknown invocation",
"const local={make(){}};escape(local);local.make();",
false,
],
[
"local method",
"const api={get(){return {setAppPath(){}}}};api.get().setAppPath('./lib');",
false,
],
[
"shadowed app",
"function local(app){const getApp=()=>app;getApp().setAppPath('./lib');}",
false,
],
[
"mixed return",
"const getApp=()=>flag?app:{setAppPath(){}};getApp().setAppPath('./lib');",
true,
],
[
"replacement factory",
"const api={get:()=>({setAppPath(){}})};api.get=()=>app;api.get().setAppPath('./lib');",
true,
],
[
"local replacement",
"const api={get:()=>app};api.get=()=>({setAppPath(){}});api.get().setAppPath('./lib');",
false,
],
[
"later member replacement",
"const getApp=()=>app;getApp().setAppPath('./lib');app.setAppPath=()=>{};",
true,
],
[
"returned setter call",
"const getApp=()=>app;getApp().setAppPath.call(app,'./lib');",
true,
],
[
"returned setter reflect",
"const getApp=()=>app;Reflect.apply(getApp().setAppPath,app,['./lib']);",
true,
],
[
"factory call delegate",
"const getApp=()=>app;getApp.call(null).setAppPath('./lib');",
true,
],
[
"factory apply delegate",
"const getApp=()=>app;getApp.apply(null,[]).setAppPath('./lib');",
true,
],
[
"captured app before replacement",
"let runtime=app;const held=runtime;runtime={setAppPath(){}};const getApp=()=>held;getApp().setAppPath('./lib');",
true,
],
[
"captured local before replacement",
"let runtime={setAppPath(){}};const held=runtime;runtime=app;const getApp=()=>held;getApp().setAppPath('./lib');",
false,
],
[
"captured local after replacement",
"let runtime=app;runtime={setAppPath(){}};const held=runtime;const getApp=()=>held;getApp().setAppPath('./lib');",
false,
],
[
"captured app after replacement",
"let runtime={setAppPath(){}};runtime=app;const held=runtime;const getApp=()=>held;getApp().setAppPath('./lib');",
true,
],
] as const)(
"retains only possible Electron app path changes from %s",
(_name, body, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`const {app}=require('electron');${body}`,
);
expect(analysis.parse_error_count).toBe(0);
expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(
expected,
);
// A possible return must not promote other APIs to proven Electron identity.
expect(analysis.electron.browser_windows).toEqual([]);
},
);
it.each([
"const getApp=()=>require('electron').app;getApp().setAppPath('./lib');",
"const getElectron=()=>require('electron');getElectron().app.setAppPath('./lib');",
"const getApp=()=>require('other').app;getApp().setAppPath('./lib');",
])(
"follows returned require origins without an import shortcut: %s",
(source) => {
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(
!source.includes("'other'"),
);
},
);
it("does not promote a returned Electron constructor to proven API identity", () => {
const analysis = analyzeJavaScriptStaticSource(
"const {BrowserWindow}=require('electron');const get=()=>BrowserWindow;new (get())();",
);
expect(analysis.electron.browser_windows).toEqual([]);
expect(analysis.electron.app_path_changes ?? []).toEqual([]);
});
it.each([
["call", true],
["call", false],
["apply", true],
["apply", false],
] as const)(
"uses the actual receiver of a captured %s wrapper (host=%s)",
(method, host) => {
const local = "({setAppPath(){}})";
const analysis = analyzeJavaScriptStaticSource(
`const {app}=require('electron');
const original=()=>${host ? local : "app"};
const selected=()=>${host ? "app" : local};
const invoke=original.${method};
invoke.call(selected,null${method === "apply" ? ",[]" : ""}).setAppPath('./lib');`,
);
expect(analysis.parse_error_count).toBe(0);
expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(host);
},
);
it.each([true, false])(
"keeps a finite source closure after an Electron app selector budget is reached (host=%s)",
(host) => {
const declarations = Array.from(
{ length: 550 },
(_, index) =>
`const selected${index}=selected${index === 0 ? "Root" : index - 1};`,
).join("");
const analysis = analyzeJavaScriptStaticSource(
`const {app}=require('electron');const selectedRoot={runtime:${host ? "app" : "{setAppPath(){}}"}};${declarations}const getApp=()=>selected549.runtime;getApp().setAppPath('./lib');`,
);
expect(analysis.parse_error_count).toBe(0);
expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(host);
expect(analysis.limitations).toEqual(
expect.arrayContaining([
expect.stringContaining("Alias selection expansion"),
]),
);
},
);
it.each([true, false])(
"closes cyclic returned app copies without inventing an Electron origin (host=%s)",
(host) => {
const analysis = analyzeJavaScriptStaticSource(
`const {app}=require('electron');let box={runtime:${host ? "app" : "{setAppPath(){}}"}};while(flag)box={...box};const getApp=()=>box.runtime;getApp().setAppPath('./lib');`,
);
expect(analysis.parse_error_count).toBe(0);
expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(host);
},
);
@@ -193,6 +193,8 @@ export interface ElectronNativeAddonBindingFinding {
/** All Electron-specific facts collected during one shared AST traversal. */
export interface ElectronStaticFindings {
/** Calls that may replace the application path used by Electron loadFile. */
readonly app_path_changes?: readonly ElectronAppPathChangeFinding[];
readonly browser_windows: readonly ElectronBrowserWindowFinding[];
readonly context_bridge_apis: readonly ElectronContextBridgeFinding[];
readonly ipc: readonly ElectronIpcFinding[];
@@ -203,6 +205,13 @@ export interface ElectronStaticFindings {
readonly call_paths?: readonly ElectronCallPath[];
}
/** An Electron application path setter invocation or bound delegate capture. */
export interface ElectronAppPathChangeFinding {
readonly module_key: string | null;
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
}
export interface ElectronCallPathStep {
readonly kind:
| "exposure"
@@ -10,6 +10,7 @@ export const estimateJavaScriptStaticApplicationProjection = (
// Allow for identifiers, locations, authority, coverage and schema copies,
// independently of the producer's compact checkpoint representation.
const findings =
(facts.electron.app_path_changes?.length ?? 0) +
facts.references.length +
facts.endpoints.length +
facts.storage.length +
@@ -0,0 +1,141 @@
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const cases = [
[
"native source spread",
"Object.assign(exports,...[{change:app.setAppPath,app}]);",
true,
],
[
"native target spread",
"Object.assign(...[exports,{change:app.setAppPath,app}]);",
true,
],
[
"native call",
"Object.assign.call(null,exports,{change:app.setAppPath,app});",
true,
],
[
"native apply",
"Object.assign.apply(null,[exports,{change:app.setAppPath,app}]);",
true,
],
[
"captured intrinsic before replacement",
"const publish=Object.assign;Object.assign=()=>{};publish(exports,{change:app.setAppPath,app});",
true,
],
[
"replaced intrinsic",
"Object.assign=()=>{};Object.assign(exports,{change:app.setAppPath,app});",
false,
],
[
"replaced call",
"Object.assign.call=()=>{};Object.assign.call(null,exports,{change:app.setAppPath,app});",
false,
],
[
"replaced apply",
"Object.assign.apply=()=>{};Object.assign.apply(null,[exports,{change:app.setAppPath,app}]);",
false,
],
[
"replaced Function call",
"Function.prototype.call=()=>{};Object.assign.call(null,exports,{change:app.setAppPath,app});",
false,
],
[
"replaced Function apply",
"Function.prototype.apply=()=>{};Object.assign.apply(null,[exports,{change:app.setAppPath,app}]);",
false,
],
[
"native exports",
"Object.assign(exports,{change:app.setAppPath,app});",
true,
],
[
"native module exports",
"Object.assign(module.exports,{change:app.setAppPath,app});",
true,
],
[
"detached before",
"module.exports={};Object.assign(exports,{change:app.setAppPath,app});",
false,
],
[
"detached after",
"Object.assign(exports,{change:app.setAppPath,app});module.exports={};",
false,
],
[
"reconnected",
"exports=module.exports={};Object.assign(exports,{change:app.setAppPath,app});",
true,
],
[
"local exports",
"const exports={};Object.assign(exports,{change:app.setAppPath,app});",
false,
],
[
"local Object",
"const Object={assign(){}};Object.assign(exports,{change:app.setAppPath,app});",
false,
],
[
"local setter",
"app.setAppPath=()=>{};Object.assign(exports,{change:app.setAppPath,app});",
false,
],
] as const;
it.each(cases)(
"retains an Object.assign-published setter for %s",
(_name, body, possible) => {
const defining = `const{app}=require('electron');${body}`;
let changed = 0;
const module = { exports: {} };
runInNewContext(defining, {
module,
exports: module.exports,
require: () => ({ app: { setAppPath: () => changed++ } }),
});
expect(changed).toBe(0);
runInNewContext(
"if(typeof change==='function')change.call(app,'./lib');",
module.exports,
);
expect(changed).toBe(possible ? 1 : 0);
const analysis = analyzeJavaScriptStaticSource(defining);
expect(analysis.parse_error_count).toBe(0);
expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(
possible,
);
},
);
it("retains native source uncertainty when an array iterator is overwritten", () => {
const defining =
"const{app}=require('electron');const sources=[{change:app.setAppPath,app}];sources[Symbol.iterator]=function*(){yield{change(){},app}};Object.assign(exports,...sources);";
let changed = 0;
const module = { exports: {} };
runInNewContext(defining, {
module,
exports: module.exports,
require: () => ({ app: { setAppPath: () => changed++ } }),
});
runInNewContext("change.call(app,'./lib');", module.exports);
expect(changed).toBe(0);
// The static owner does not execute this custom iterator; it must retain the
// native origin still present in the source rather than silently drop it.
const analysis = analyzeJavaScriptStaticSource(defining);
expect((analysis.electron.app_path_changes ?? []).length).toBeGreaterThan(0);
});
@@ -2,16 +2,54 @@ import * as t from "@babel/types";
import {
semanticStaticPropertyKey,
semanticFinalObjectProperty,
semanticWriteTargets,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import {
isUnshadowedGlobal,
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js";
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
import { evaluateSemanticExpression } from "./javascriptSemanticValues.js";
import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js";
import { semanticProjectedSourcesAtRead } from "./javascriptSemanticProjectedSources.js";
import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js";
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
const exportInvocations = new WeakMap<
JavaScriptSemanticAnalysisState,
ReturnType<typeof createSemanticModuleInterop>
>();
/** Share the intrinsic owner's positional and capture proofs across CJS consumers. */
const commonJsExportInvocation = (
node: t.CallExpression,
state: JavaScriptSemanticAnalysisState,
) => {
let interop = exportInvocations.get(state);
if (interop === undefined) {
interop = createSemanticModuleInterop(state);
for (const { node: write, path } of state.ambientWriteEffects)
if (write !== undefined && path[0] === "global")
interop.retainWrite(write, [
"intrinsic",
...path
.slice(1)
.map((key) =>
typeof key === "string" || typeof key === "number"
? String(key)
: null,
),
]);
exportInvocations.set(state, interop);
}
return interop.intrinsicInvocation(node);
};
export type CommonJsExportReceiverEffect =
| "connected"
@@ -21,6 +59,111 @@ export type CommonJsExportReceiverEffect =
/** A CommonJS export assignment or `Object.defineProperty` export call. */
export type CommonJsExportSite = t.AssignmentExpression | t.CallExpression;
interface CopiedOwnFields {
readonly definite: ReadonlySet<string>;
readonly complete: boolean;
}
/** Definite own fields survive aliases, but not a possible prior shape change. */
const copiedOwnFields = (
source: t.Node,
state: JavaScriptSemanticAnalysisState,
): CopiedOwnFields => {
const unknown = { definite: new Set<string>(), complete: false };
const pending = [source];
const seen = new Set<t.Node>();
let common: Set<string> | undefined;
let complete = true;
while (pending.length > 0) {
const node = unwrapJavaScriptExpression(pending.pop()!).node;
if (seen.has(node)) continue;
if (seen.size >= SEMANTIC_ALIAS_SELECTION_LIMIT) {
state.valueResourceLimits.add("alias-selections");
return unknown;
}
seen.add(node);
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined) return unknown;
if (
binding.memberWrites.some(
(effect) =>
semanticEffectTiming(source, effect.node, state) !== "after",
) ||
binding.escapedPaths.some(
(effect) =>
effect.node !== undefined &&
!(
(effect.node.start ?? -1) <= (source.start ?? -1) &&
(effect.node.end ?? -1) >= (source.end ?? -1)
) &&
semanticEffectTiming(source, effect.node, state) !== "after",
)
)
return unknown;
const selected = semanticProjectedSourcesAtRead(
binding,
node,
[],
state,
false,
source,
);
if (
selected === undefined ||
selected.length === 0 ||
selected.some((value) => value.path.length !== 0)
)
return unknown;
if (
seen.size + pending.length + selected.length >
SEMANTIC_ALIAS_SELECTION_LIMIT
) {
state.valueResourceLimits.add("alias-selections");
return unknown;
}
for (const value of selected) pending.push(value.node);
continue;
}
// A getter can remove a later own slot while Object.assign is copying.
// Its initial presence cannot prove an earlier source was overwritten.
if (
t.isObjectExpression(node) &&
node.properties.some(
(property) => t.isObjectMethod(property) && property.kind !== "method",
)
)
return unknown;
const selected = evaluateSemanticExpression(node, state, source);
const fields =
selected.status === "object"
? selected.properties
: selected.status === "array"
? selected.items
: [];
const own = new Set(
fields
.filter((field) => field.presence === "present")
.map((field) => field.name),
);
const exact =
selected.status === "object"
? !selected.unknownProperties
: selected.status === "array"
? !selected.unknownItems
: selected.status === "literal" && selected.value === null;
complete &&=
exact &&
(common === undefined ||
(common.size === own.size && [...common].every((key) => own.has(key))));
common =
common === undefined
? own
: new Set([...common].filter((key) => own.has(key)));
}
return { definite: common ?? new Set(), complete };
};
const isCommonJsModuleExports = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
@@ -90,6 +233,103 @@ export const commonJsDefinedExport = (
};
};
/** Native Object.assign publishes source values only through a CJS receiver. */
const commonJsAssignedExports = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
):
| { readonly receiver: t.Node; readonly sources: readonly t.Node[] }
| undefined => {
if (!t.isCallExpression(node)) return undefined;
const invocation = commonJsExportInvocation(node, state);
if (
invocation?.kind !== "known" ||
invocation.path.length !== 3 ||
invocation.path[0] !== "intrinsic" ||
invocation.path[1] !== "Object" ||
invocation.path[2] !== "assign"
)
return undefined;
const [target, ...sources] = invocation.arguments ?? [];
if (!t.isExpression(target)) return undefined;
const receiver = unwrapJavaScriptExpression(target).node;
if (
!isUnshadowedGlobal(receiver, state, "exports") &&
!isCommonJsModuleExports(receiver, state)
)
return undefined;
return {
receiver,
sources: sources.flatMap((source) =>
t.isSpreadElement(source)
? [source.argument]
: t.isExpression(source)
? [source]
: [],
),
};
};
/** Runtime values published by the CommonJS syntax owner, before receiver applicability. */
export const commonJsRuntimeExportValues = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): readonly {
readonly exportedName: string;
readonly value: t.Node | null;
/** Own enumerable fields copied at this source, after later-source overwrites. */
readonly projection?: JavaScriptSemanticPropertyPath;
readonly ownEnumerable?: true;
}[] => {
if (t.isAssignmentExpression(node)) {
const exportedName = commonJsExportName(node.left, state);
return exportedName === undefined
? []
: [{ exportedName, value: node.operator === "=" ? node.right : null }];
}
const assigned = commonJsAssignedExports(node, state);
if (assigned !== undefined) {
const exported: ReturnType<typeof commonJsRuntimeExportValues>[number][] =
[];
const overwritten = new Set<string>();
for (let index = assigned.sources.length - 1; index >= 0; index--) {
const value = assigned.sources[index]!;
const fields = copiedOwnFields(value, state);
if (fields.complete) {
for (const name of fields.definite)
if (!overwritten.has(name))
exported.push({
exportedName: name,
value,
projection: [name],
ownEnumerable: true,
});
} else
exported.push({
exportedName: "*",
value,
projection: [{ excludedKeys: [...overwritten] }],
ownEnumerable: true,
});
// Copy applicability belongs to the shared own-slot inventory. Unknown
// later fields cannot prove that an earlier native field was replaced.
for (const field of fields.definite) overwritten.add(field);
}
return exported;
}
const defined = commonJsDefinedExport(node, state);
if (defined === undefined) return [];
const descriptor = unwrapJavaScriptExpression(defined.descriptor).node;
return [
{
exportedName: defined.exportedName,
value: t.isObjectExpression(descriptor)
? (semanticFinalObjectProperty(descriptor, "value") ?? null)
: null,
},
];
};
type Identity = t.Node | null;
type Execution = "ordered" | "conditional" | "deferred";
@@ -217,7 +457,9 @@ export function* indexCommonJsExportReceiversSteps(
: publicIdentity,
);
}
const defined = commonJsDefinedExport(node, state);
const defined =
commonJsDefinedExport(node, state) ??
commonJsAssignedExports(node, state);
if (defined !== undefined && t.isCallExpression(node))
captures.set(
node,
@@ -0,0 +1,390 @@
import * as t from "@babel/types";
import {
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import {
semanticAliasSelectionChoices,
type SemanticAliasSelectionPath,
} from "./javascriptSemanticAliasSelections.js";
import {
semanticArrayPropertyReferences,
semanticObjectPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import {
semanticNormalizeCapturedSelections,
semanticProjectedSourceFactsAtRead,
semanticShiftCapturedSelections,
type SemanticCapturedSelection,
} from "./javascriptSemanticProjectedSources.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import type { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js";
import { assignmentValueSources } from "./javascriptSemanticRequireOrigin.js";
import { semanticRuntimeExportValueSources } from "./javascriptModuleDeclaration.js";
import {
commonJsRuntimeExportValues,
indexCommonJsExportReceiversSteps,
type CommonJsExportReceiverEffect,
type CommonJsExportSite,
} from "./javascriptCommonJsExportReceivers.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
interface Candidate {
readonly node: t.Node;
readonly path: SemanticAliasSelectionPath | undefined;
readonly readAt?: t.Node | undefined;
readonly capturedSelections?:
| readonly SemanticCapturedSelection[]
| undefined;
readonly awaitDepths: readonly number[];
readonly escaping?: true;
}
const isSetterPath = (path: SemanticAliasSelectionPath): boolean =>
path.length >= 2 &&
path.every((segment, index) =>
semanticAliasSelectionChoices(segment).some(
(key) =>
key === null ||
(index < 2
? key === ["app", "setAppPath"][index]
: ["call", "apply", "bind"].includes(String(key))),
),
);
/**
* A source-local return may carry the shared Electron app even when it cannot
* establish a concrete API identity. Retain only the possibility of changing
* its root; callers must not use this query to prove other Electron APIs.
*/
export const createSemanticElectronAppPathChangeQuery = (
state: JavaScriptSemanticAnalysisState,
exportPath: (node: t.Node) => readonly string[] | undefined,
interop: Pick<
ReturnType<typeof createSemanticModuleInterop>,
"intrinsicInvocation" | "intrinsicPath"
>,
): ((node: t.Node) => boolean) => {
const directReturns = createSemanticReturnedReferences(state, "direct-call");
const awaitedReturns = createSemanticReturnedReferences(
state,
"awaited-call",
);
let commonJsReceivers:
| ReadonlyMap<CommonJsExportSite, CommonJsExportReceiverEffect>
| undefined;
const possiblePath = (
root: t.Node,
escaping = false,
projection: SemanticAliasSelectionPath = [],
ownEnumerable = false,
): boolean => {
const pending: Candidate[] = [];
const seen = new WeakMap<t.Node, Set<string>>();
let scheduled = 0;
let widened = false;
const enqueue = (candidate: Candidate): void => {
const node = unwrapJavaScriptExpression(candidate.node).node;
// Close the finite source graph after the precise context budget is
// exhausted. Widening alone is never evidence of an Electron origin.
if (!widened && scheduled >= SEMANTIC_ALIAS_SELECTION_LIMIT) {
state.valueResourceLimits.add("alias-selections");
widened = true;
}
if (widened)
candidate = {
node,
path: undefined,
awaitDepths: candidate.awaitDepths.length > 0 ? [0] : [],
...(candidate.escaping ? { escaping: true as const } : {}),
};
const capturedSelections = semanticNormalizeCapturedSelections(
candidate.capturedSelections,
candidate.path?.length,
);
const key = JSON.stringify([
candidate.path,
candidate.readAt?.start,
candidate.readAt?.end,
capturedSelections?.map(({ at, depth, ownEnumerable }) => [
at.start,
at.end,
depth,
ownEnumerable,
]),
candidate.awaitDepths,
candidate.escaping,
]);
const contexts = seen.get(node) ?? new Set<string>();
if (contexts.has(key)) return;
scheduled++;
contexts.add(key);
seen.set(node, contexts);
pending.push({ ...candidate, node, capturedSelections });
};
enqueue({
node: root,
path: projection,
awaitDepths: [],
...(escaping ? { escaping: true as const } : {}),
...(ownEnumerable
? {
capturedSelections: [
{ at: root, depth: 1, ownEnumerable: true as const },
],
}
: {}),
});
while (pending.length > 0) {
const current = pending.pop()!;
if (widened && current.path !== undefined) {
enqueue(current);
continue;
}
const { node, path } = current;
const binding = t.isIdentifier(node)
? resolveSemanticBindingState(state, node, node.name)
: undefined;
const selected =
binding === undefined || path === undefined
? undefined
: semanticProjectedSourceFactsAtRead(
binding,
node,
path,
state,
false,
current.readAt ?? node,
current.capturedSelections,
);
// Ordinary aliases are followed below with shared source selections.
// Re-evaluating their whole-binding provenance at every DAG node would
// repeat that graph merely to obtain a concrete identity we do not need.
const exported =
!t.isIdentifier(node) ||
binding?.directOrigins.length ||
binding?.initializers.some((source) =>
t.isCallExpression(unwrapJavaScriptExpression(source.node).node),
)
? exportPath(node)
: undefined;
if (
current.escaping &&
path?.length === 0 &&
exported !== undefined &&
(exported.length === 0 ||
(exported.length === 1 && exported[0] === "app"))
) {
// Publishing the app (or its namespace) lets an importer select the
// setter. Re-enter the same projected-source proof so a definite local
// method replacement still suppresses that native alternative.
enqueue({
...current,
path: exported.length === 0 ? ["app", "setAppPath"] : ["setAppPath"],
});
}
if (
exported !== undefined &&
// The retained app identity alone cannot prove its selected method
// survived an own write. Conditional or unknown writes keep the native
// alternative; a definite replacement follows its selected RHS below.
selected?.retainsOrigin !== false &&
(path === undefined
? exported.length === 0 ||
(exported.length === 1 && exported[0] === "app") ||
isSetterPath(exported)
: isSetterPath([...exported, ...path]))
)
return true;
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
enqueue({
...current,
node: node.object,
path:
path === undefined
? undefined
: [
semanticStaticPropertyKey(node.property, node.computed),
...path,
],
readAt: current.readAt ?? node,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
1,
),
});
} else if (t.isIdentifier(node)) {
if (binding === undefined) continue;
const sources = selected?.sources;
// Unknown iteration cannot prove an Electron origin, but must not drop
// retained source references that can still carry the shared app.
if (sources === undefined) {
for (const sources of [
binding.initializers,
binding.referenceInitializers,
])
for (const source of sources) {
if (semanticReferenceSourcesInactive(source, state)) continue;
enqueue({
...current,
node: source.node,
path:
path === undefined
? undefined
: [...source.projection, ...path],
});
}
for (const effect of binding.memberWrites)
for (const source of effect.assignedSources ?? []) {
if (semanticReferenceSourcesInactive(source, state)) continue;
enqueue({ ...current, node: source.node, path: undefined });
}
continue;
}
for (const source of sources)
enqueue({
...current,
...source,
readAt: path?.length === 0 ? undefined : (current.readAt ?? node),
capturedSelections: [
...(semanticShiftCapturedSelections(
current.capturedSelections,
source.path.length - (path?.length ?? 0),
) ?? []),
...(source.capturedSelections ?? []),
],
});
} else if (t.isAwaitExpression(node)) {
enqueue({
...current,
node: node.argument,
awaitDepths: [
...new Set([...current.awaitDepths, path?.length ?? 0]),
].sort((left, right) => left - right),
});
} else if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const returned = current.awaitDepths.includes(path?.length ?? 0)
? awaitedReturns
: directReturns;
for (const source of returned(node.callee, node).references)
enqueue({
...current,
node: source.node,
path:
path === undefined ? undefined : [...source.projection, ...path],
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
source.projection.length,
),
});
} else if (
t.isConditionalExpression(node) ||
t.isLogicalExpression(node)
) {
enqueue({
...current,
node: t.isConditionalExpression(node) ? node.consequent : node.left,
});
enqueue({
...current,
node: t.isConditionalExpression(node) ? node.alternate : node.right,
});
} else if (t.isSequenceExpression(node)) {
const last = node.expressions.at(-1);
if (last !== undefined) enqueue({ ...current, node: last });
} else if (t.isAssignmentExpression(node)) {
for (const source of assignmentValueSources(node))
enqueue({ ...current, node: source });
} else if (
(t.isObjectExpression(node) || t.isArrayExpression(node)) &&
(path === undefined || path.length > 0 || current.escaping)
) {
const [key, ...remaining] =
path?.length === 0 ? [null] : (path ?? [null]);
for (const choice of semanticAliasSelectionChoices(key!)) {
const sources = t.isObjectExpression(node)
? semanticObjectPropertyReferences(node, [choice], state)
: semanticArrayPropertyReferences(node, [choice]);
for (const source of sources)
if (
!source.inherited ||
!current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
)
enqueue({
...current,
node: source.node,
path:
path === undefined
? undefined
: [...source.path, ...remaining],
awaitDepths: current.awaitDepths.filter(
(depth) => depth !== path?.length,
),
capturedSelections: [
...(semanticShiftCapturedSelections(
current.capturedSelections,
source.path.length - 1,
) ?? []),
...(source.capturedSelectionAt === undefined
? []
: [
{
at: source.capturedSelectionAt,
depth: source.path.length,
...(source.ownEnumerable
? { ownEnumerable: true as const }
: {}),
},
]),
],
});
}
}
}
return false;
};
return (node) => {
if (
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
(possiblePath(node.callee) ||
interop.intrinsicInvocation(node, (callee) =>
possiblePath(callee)
? ["electron-app-path-change"]
: interop.intrinsicPath(callee),
)?.path[0] === "electron-app-path-change")
)
return true;
if (
semanticRuntimeExportValueSources(node).some((value) =>
possiblePath(value, true),
)
)
return true;
const exported = commonJsRuntimeExportValues(node, state);
if (
!exported.some(({ value }) => value !== null) ||
(!t.isAssignmentExpression(node) && !t.isCallExpression(node))
)
return false;
commonJsReceivers ??= completeSemanticSteps(
indexCommonJsExportReceiversSteps(state),
);
return (
commonJsReceivers.get(node) !== "detached" &&
exported.some(
({ value, projection, ownEnumerable }) =>
value !== null &&
possiblePath(value, true, projection, ownEnumerable),
)
);
};
};
@@ -2,6 +2,11 @@ import { expect, it } from "vitest";
import * as t from "@babel/types";
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
import {
javaScriptSourceInvocationContext,
javaScriptGenericInvocationMethod,
resolveJavaScriptGenericInvocation,
} from "./javascriptIntrinsicInvocation.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import {
@@ -52,3 +57,44 @@ it("keeps a comma-expression intrinsic call unknown when its receiver is erased"
}),
]);
});
it.each([
["get.call(null)", "get"],
["get.apply(null,[])", "get"],
["get.call.call(other,null)", "get.call"],
["get.apply.call(other,null,[])", "get.apply"],
["(0,get.call)(null)", undefined],
])(
"retains one generic callable receiver transition through %s",
(source, name) => {
const parsed = parseJavaScriptSource(source);
const statement = parsed?.program.body[0];
if (
!t.isExpressionStatement(statement) ||
!t.isCallExpression(statement.expression)
)
throw new Error("Expected source invocation fixture");
const call = statement.expression;
const method = javaScriptGenericInvocationMethod(call.callee);
const context = javaScriptSourceInvocationContext(call);
const result =
method === undefined
? undefined
: resolveJavaScriptGenericInvocation(method, context);
expect(result?.callable).toEqual(
name === undefined
? undefined
: name.includes(".")
? expect.objectContaining({
type: "MemberExpression",
object: expect.objectContaining({ name: "get" }),
property: expect.objectContaining({ name: name.split(".")[1] }),
})
: expect.objectContaining({ type: "Identifier", name }),
);
if (name?.includes("."))
expect(result?.context.receiver).toEqual(
expect.objectContaining({ type: "Identifier", name: "other" }),
);
},
);
@@ -6,9 +6,16 @@ import {
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
/** Expand only literal array spreads whose positional arguments are all known. */
export type JavaScriptArrayIterationProof = (
array: t.ArrayExpression,
) => boolean;
const unknownArrayIteration: JavaScriptArrayIterationProof = () => false;
/** Expand literal array spreads only when their native iterator is proven. */
export const expandJavaScriptInvocationArguments = (
arguments_: readonly t.CallExpression["arguments"][number][],
nativeArrayIteration: JavaScriptArrayIterationProof = unknownArrayIteration,
): t.CallExpression["arguments"] => {
const expanded: t.CallExpression["arguments"] = [];
const pending = [...arguments_].reverse();
@@ -20,6 +27,7 @@ export const expandJavaScriptInvocationArguments = (
: undefined;
if (
t.isArrayExpression(array) &&
nativeArrayIteration(array) &&
array.elements.every(
(element) => t.isExpression(element) || t.isSpreadElement(element),
)
@@ -33,7 +41,10 @@ export const expandJavaScriptInvocationArguments = (
return expanded;
};
const arrayArguments = (node: t.Node | undefined) => {
const arrayArguments = (
node: t.Node | undefined,
nativeArrayIteration: JavaScriptArrayIterationProof,
) => {
const array =
node === undefined ? undefined : unwrapJavaScriptExpression(node).node;
if (
@@ -45,6 +56,7 @@ const arrayArguments = (node: t.Node | undefined) => {
array.elements.filter(
(element): element is t.Expression | t.SpreadElement => element !== null,
),
nativeArrayIteration,
);
return expanded.every((element) => t.isExpression(element))
? expanded.filter((element): element is t.Expression =>
@@ -56,13 +68,80 @@ const arrayArguments = (node: t.Node | undefined) => {
const invocationArguments = (
method: "call" | "apply",
arguments_: JavaScriptIntrinsicInvocation["arguments"],
nativeArrayIteration: JavaScriptArrayIterationProof,
) =>
t.isSpreadElement(arguments_?.[0])
? undefined
: method === "apply"
? arrayArguments(arguments_?.[1])
? arrayArguments(arguments_?.[1], nativeArrayIteration)
: arguments_?.slice(1);
/** Positional state needed only by generic Function.call/apply invocation. */
export interface JavaScriptSourceInvocationContext {
readonly receiver: t.Node | undefined;
readonly receiverUnknown?: boolean;
readonly arguments: JavaScriptIntrinsicInvocation["arguments"];
}
/** Only these generic function members consume a callable receiver. */
export const javaScriptGenericInvocationMethod = (
callee: t.Node,
): "call" | "apply" | undefined => {
const expression = unwrapJavaScriptExpression(callee).node;
const method =
t.isMemberExpression(expression) || t.isOptionalMemberExpression(expression)
? semanticStaticPropertyKey(expression.property, expression.computed)
: undefined;
return method === "call" || method === "apply" ? method : undefined;
};
/** A member call supplies its receiver; a captured/comma callable does not. */
export const javaScriptSourceInvocationContext = (
node: t.CallExpression | t.OptionalCallExpression,
nativeArrayIteration: JavaScriptArrayIterationProof = unknownArrayIteration,
): JavaScriptSourceInvocationContext => {
const callee = unwrapJavaScriptExpression(node.callee).node;
return {
receiver:
t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)
? callee.object
: undefined,
arguments: expandJavaScriptInvocationArguments(
node.arguments,
nativeArrayIteration,
),
};
};
/** Select one generic function receiver without evaluating function arguments. */
export const resolveJavaScriptGenericInvocation = (
method: "call" | "apply",
context: JavaScriptSourceInvocationContext,
nativeArrayIteration: JavaScriptArrayIterationProof = unknownArrayIteration,
):
| {
readonly callable: t.Expression;
readonly context: JavaScriptSourceInvocationContext;
}
| undefined =>
!t.isExpression(context.receiver)
? undefined
: {
callable: context.receiver,
context: {
receiver: context.arguments?.[0],
...(context.arguments === undefined ||
t.isSpreadElement(context.arguments[0])
? { receiverUnknown: true }
: {}),
arguments: invocationArguments(
method,
context.arguments,
nativeArrayIteration,
),
},
};
/**
* Normalize known invocation syntax once, retaining bound argument prefixes.
* Binding a function alone is not a write; only its eventual call is examined.
@@ -72,11 +151,12 @@ export const resolveJavaScriptIntrinsicInvocation = (
intrinsicPath: (node: t.Node) => readonly string[] | undefined,
boundIntrinsic: (node: t.Node) => JavaScriptIntrinsicInvocation | undefined,
intrinsicReadChanged: (node: t.Node, path: readonly string[]) => boolean,
nativeArrayIteration: JavaScriptArrayIterationProof = unknownArrayIteration,
): JavaScriptIntrinsicInvocation | undefined => {
const visited = new Set<t.Node>();
let callee: t.Node = node.callee;
let arguments_: JavaScriptIntrinsicInvocation["arguments"] =
expandJavaScriptInvocationArguments(node.arguments);
expandJavaScriptInvocationArguments(node.arguments, nativeArrayIteration);
let receiver: t.Node | undefined;
let explicitReceiver = false;
for (;;) {
@@ -106,15 +186,21 @@ export const resolveJavaScriptIntrinsicInvocation = (
);
if (method === "call" || method === "apply") {
const bound = boundIntrinsic(callee.object);
// Caller-selected native loaders share Function.prototype authority.
const callablePath = bound?.path ?? intrinsicPath(callee.object);
if (
bound !== undefined &&
intrinsicReadChanged(callee, [...bound.path, method])
callablePath !== undefined &&
intrinsicReadChanged(callee, [...callablePath, method])
)
return { kind: "opaque", path: bound.path, arguments: undefined };
return { kind: "opaque", path: callablePath, arguments: undefined };
receiver = arguments_?.[0];
explicitReceiver = true;
callee = callee.object;
arguments_ = invocationArguments(method, arguments_);
arguments_ = invocationArguments(
method,
arguments_,
nativeArrayIteration,
);
continue;
}
}
@@ -138,12 +224,20 @@ export const resolveJavaScriptIntrinsicInvocation = (
: t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)
? callee.object
: undefined;
if (!t.isExpression(callable))
const selected = resolveJavaScriptGenericInvocation(
method,
{
receiver: callable,
arguments: arguments_,
},
nativeArrayIteration,
);
if (selected === undefined)
return { kind: "opaque", path, arguments: undefined };
callee = callable;
receiver = arguments_?.[0];
callee = selected.callable;
receiver = selected.context.receiver;
explicitReceiver = true;
arguments_ = invocationArguments(method, arguments_);
arguments_ = selected.context.arguments;
continue;
}
if (path.join(".") === "intrinsic.Reflect.apply") {
@@ -152,7 +246,7 @@ export const resolveJavaScriptIntrinsicInvocation = (
callee = callable;
receiver = arguments_?.[1];
explicitReceiver = true;
arguments_ = arrayArguments(arguments_?.[2]);
arguments_ = arrayArguments(arguments_?.[2], nativeArrayIteration);
continue;
}
return {
@@ -1,4 +1,5 @@
import * as t from "@babel/types";
import { semanticWriteTargets } from "./javascriptAstValues.js";
/** Declaration syntax that establishes no runtime value, regardless of export form. */
export const isErasedJavaScriptDeclaration = (node: t.Node): boolean =>
@@ -52,6 +53,36 @@ export const isTypeOnlyImportSpecifier = (node: t.ImportSpecifier): boolean =>
export const isTypeOnlyExportSpecifier = (node: t.ExportSpecifier): boolean =>
node.exportKind === "type";
/** Runtime values published by a local ESM export; source re-exports have no local value. */
export const semanticRuntimeExportValueSources = (
node: t.Node,
): readonly t.Node[] => {
if (t.isExportDefaultDeclaration(node))
return isErasedJavaScriptDeclaration(node.declaration)
? []
: [node.declaration];
if (
!t.isExportNamedDeclaration(node) ||
node.source != null ||
isTypeOnlyExportDeclaration(node)
)
return [];
const values: t.Node[] = [];
if (t.isVariableDeclaration(node.declaration))
for (const declaration of node.declaration.declarations)
values.push(...semanticWriteTargets(declaration.id));
else if (
(t.isFunctionDeclaration(node.declaration) ||
t.isClassDeclaration(node.declaration)) &&
node.declaration.id != null
)
values.push(node.declaration.id);
for (const specifier of node.specifiers)
if (t.isExportSpecifier(specifier) && !isTypeOnlyExportSpecifier(specifier))
values.push(specifier.local);
return values;
};
/** A regular import whose bindings are all erased but may retain side effects. */
export const hasOnlyInlineTypeImportSpecifiers = (
node: t.ImportDeclaration,
@@ -29,6 +29,7 @@ import {
runtimeFunctionParameters,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticKnownArrayIterationAt } from "./javascriptSemanticReferenceSources.js";
// Match the complete Rolldown runtime operations, including its live getters.
// Names and formatting are irrelevant; lexical roles, operators and keys are not.
@@ -82,6 +83,8 @@ export const createSemanticModuleInterop = (
readonly intrinsicPath: (node: t.Node) => readonly string[] | undefined;
},
) => {
const nativeArrayIteration = (array: t.ArrayExpression) =>
semanticKnownArrayIterationAt(array, state);
const helpers = new Map<string, InteropHelper | null>();
const origins = new Map<string, InteropOrigin>();
const calls = new WeakMap<t.Node, InteropOrigin>();
@@ -272,7 +275,10 @@ export const createSemanticModuleInterop = (
const { root, members } = electronMemberPath(current.callee);
let bindTarget = root;
let bindingArguments: JavaScriptIntrinsicInvocation["arguments"] =
expandJavaScriptInvocationArguments(current.arguments);
expandJavaScriptInvocationArguments(
current.arguments,
nativeArrayIteration,
);
let captured = capturedIntrinsic(current.callee);
let terminal = captured?.path.at(-1) === "bind";
let path: readonly string[] | undefined = captured?.path.slice(0, -1);
@@ -287,6 +293,7 @@ export const createSemanticModuleInterop = (
intrinsicPath,
boundIntrinsic,
intrinsicReadChanged,
nativeArrayIteration,
);
if (
invocation?.kind !== "known" ||
@@ -618,12 +625,16 @@ export const createSemanticModuleInterop = (
origin,
callOrigin,
intrinsicPath,
intrinsicInvocation: (node: t.CallExpression | t.OptionalCallExpression) =>
intrinsicInvocation: (
node: t.CallExpression | t.OptionalCallExpression,
callablePath = intrinsicPath,
) =>
resolveJavaScriptIntrinsicInvocation(
node,
intrinsicPath,
callablePath,
boundIntrinsic,
intrinsicReadChanged,
nativeArrayIteration,
),
boundIntrinsic,
staticString,
@@ -1,4 +1,12 @@
import { posix } from "node:path";
import type { JavaScriptArtifactFileKind } from "./javascriptArtifactFiles.js";
/** JSON dispatch uses the exact loader filename, not the inventory's folded kind. */
export const isJsonModuleTarget = (
path: string,
kind: JavaScriptArtifactFileKind | undefined,
): boolean =>
path.endsWith(".json") && (kind === "json" || kind === "package-json");
/** Loader evidence retained for a source-level module reference. */
export type JavaScriptModuleResolutionMode =
@@ -75,6 +75,48 @@ describe("exact package import lookup", () => {
});
});
it.each([
["#bar/*", "#foo", "missing-key"],
["#internal/*.js", "#internal/file.cjs", "missing-key"],
["#internal/*.js", "#internal/.js", "missing-key"],
["#internal/*end", "#internal/end", "missing-key"],
["#internal/*/*", "#internal/a/b", "missing-key"],
["#internal/", "#internal/a", "missing-key"],
["#internal/*.js", "#internal/a.js", "pattern-unresolved"],
["#internal/*.js", "#internal/deep/a.js", "pattern-unresolved"],
["#internal/*end", "#internal/aend", "pattern-unresolved"],
] as const)(
"matches only a single nonempty wildcard between its prefix and suffix: %s, %s",
(pattern, specifier, kind) => {
expect(
lookupExactPackageImport(
{ [pattern]: "./unsupported/*.js" },
specifier,
),
).toEqual({ kind, declared: [pattern] });
},
);
it.each(
[null, { require: "./config.cjs" }, ["./config.cjs"]].map((target) => ({
target,
})),
)(
"preserves an exact target $target ahead of a matching wildcard",
({ target }) => {
expect(
lookupExactPackageImport(
{ "#config": target, "#*": "./unsupported/*.js" },
"#config",
),
).toEqual({
kind: "found",
target,
declared: ["#config", "#*"],
});
},
);
it("still selects an exact key when the same map also has patterns", () => {
expect(
lookupExactPackageImport(
@@ -1,3 +1,5 @@
import { packageSubpathPatternMatches } from "./javascriptPackageSubpathPatterns.js";
/**
* Exact package-private import-alias lookup. Node resolves `#` specifiers
* against the importer's enclosing package `imports` map; this module owns the
@@ -55,9 +57,8 @@ export const isPackageImportPatternKey = (key: string): boolean =>
/**
* Look up one exact `#` key in a package `imports` value.
* A missing exact key is reported as missing when every declared key is also
* exact; if the map contains pattern keys, the miss stays pattern-unresolved
* rather than claiming the specifier is undefined.
* Only a matching wildcard keeps an exact miss unresolved. Unrelated patterns
* cannot select a target and therefore do not hide an undefined specifier.
*/
export const lookupExactPackageImport = (
imports: unknown,
@@ -72,7 +73,7 @@ export const lookupExactPackageImport = (
target: Reflect.get(imports, key),
declared,
};
return declared.some(isPackageImportPatternKey)
return declared.some((pattern) => packageSubpathPatternMatches(pattern, key))
? { kind: "pattern-unresolved", declared }
: { kind: "missing-key", declared };
};
@@ -0,0 +1,30 @@
import { expect, it } from "vitest";
import {
packageSubpathPatternMatches,
selectPackageSubpathPattern,
} from "./javascriptPackageSubpathPatterns.js";
it.each([
["./*", "./window", true],
["./*end", "./end", false],
["./*.js", "./nested/a.js", true],
["./*.js", "./a.cjs", false],
["./*/*", "./nested/a", false],
["./other/*", "./window", false],
] as const)(
"matches only the selected package pattern %s against %s",
(pattern, subpath, expected) => {
expect(packageSubpathPatternMatches(pattern, subpath)).toBe(expected);
},
);
it.each([
[["./*", "./private/*"], "./private/window", "./private/*"],
[["./*", "./*.hidden"], "./window.hidden", "./*.hidden"],
[["./private/*", "./*"], "./private/window", "./private/*"],
] as const)(
"selects the most specific pattern independently of declaration order: %j",
(patterns, subpath, expected) => {
expect(selectPackageSubpathPattern(patterns, subpath)).toBe(expected);
},
);
@@ -0,0 +1,33 @@
/** Node package patterns contain one star matching at least one character. */
export const packageSubpathPatternMatches = (
pattern: string,
subpath: string,
): boolean => {
const wildcard = pattern.indexOf("*");
return (
wildcard !== -1 &&
pattern.lastIndexOf("*") === wildcard &&
subpath.length >= pattern.length &&
subpath.startsWith(pattern.slice(0, wildcard)) &&
subpath.endsWith(pattern.slice(wildcard + 1))
);
};
/** Node chooses the longest prefix, then the longest complete pattern key. */
export const selectPackageSubpathPattern = (
patterns: readonly string[],
subpath: string,
): string | undefined => {
let selected: string | undefined;
for (const pattern of patterns) {
if (!packageSubpathPatternMatches(pattern, subpath)) continue;
if (
selected === undefined ||
pattern.indexOf("*") > selected.indexOf("*") ||
(pattern.indexOf("*") === selected.indexOf("*") &&
pattern.length > selected.length)
)
selected = pattern;
}
return selected;
};
@@ -199,6 +199,8 @@ const nodePaths = (node: ApplicationNode): string[] => {
isRawApplicationFileObservation(observation);
if (typeof observation.properties.path === "string")
paths.push(observation.properties.path);
const applicationPath = ownedApplicationPath(observation);
if (applicationPath !== undefined) paths.push(applicationPath);
if (rawApplicationFile) continue;
if (typeof observation.properties.resolved_path === "string")
paths.push(observation.properties.resolved_path);
@@ -231,20 +233,44 @@ const hasOwnedArtifactPath = (node: ApplicationNode): boolean =>
) ||
node.observations.some(
(observation) =>
!isRawApplicationFileObservation(observation) &&
typeof observation.properties.resolved_path === "string",
ownedApplicationPath(observation) !== undefined ||
(!isRawApplicationFileObservation(observation) &&
typeof observation.properties.resolved_path === "string"),
);
// loadFile receives a raw literal here: the syntax producer does not retain
// computed __dirname expressions. Electron resolves this relative to the app
// root, so its source-relative inference is not an owned runtime target.
// Ownership of the application base survives missing target bytes. A confined
// candidate is a path identity, not proof that a static file exists.
const ownedApplicationPath = (
observation: ApplicationNode["observations"][number],
): string | undefined => {
const properties = observation.properties;
if (properties.runtime_path_base !== "application-root") return undefined;
if (
properties.resolution_status === "resolved" &&
typeof properties.resolved_path === "string"
)
return properties.resolved_path;
if (
properties.runtime_path_base_proven === true &&
(properties.resolution_status === "not-found" ||
properties.resolution_status === "unavailable") &&
typeof properties.candidate_path === "string"
)
return properties.candidate_path;
return undefined;
};
// Native declaration matching is needed only without an owned logical path.
const isRawApplicationFileObservation = (
observation: ApplicationNode["observations"][number],
): boolean =>
observation.properties.resolution_context === "filesystem-expression" &&
typeof observation.properties.mechanism === "string" &&
observation.properties.mechanism.startsWith("call:") &&
observation.properties.mechanism.endsWith("loadFile");
observation.properties.runtime_path_base === "application-root"
? ownedApplicationPath(observation) === undefined
: // Compatibility for captures reconstructed before the producer retained its base.
observation.properties.resolution_context === "filesystem-expression" &&
typeof observation.properties.mechanism === "string" &&
observation.properties.mechanism.startsWith("call:") &&
observation.properties.mechanism.endsWith("loadFile");
const usesApplicationFileDeclaration = (node: ApplicationNode): boolean =>
node.kind === "electron-renderer" &&
@@ -0,0 +1,126 @@
import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js";
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
type PropertyKey = JavaScriptSemanticPropertyPath[number];
/** Finite alternatives retain selected keys without admitting unrelated slots. */
export type SemanticAliasSelection =
| PropertyKey
| { readonly alternatives: readonly PropertyKey[] };
export type SemanticAliasSelectionPath = readonly SemanticAliasSelection[];
export const semanticAliasSelectionChoices = (
key: SemanticAliasSelection,
): readonly PropertyKey[] =>
key !== null && typeof key === "object" && "alternatives" in key
? key.alternatives
: [key];
const selection = (choices: readonly PropertyKey[]): SemanticAliasSelection => {
if (choices.includes(null)) return null;
const alternatives = [
...new Map(choices.map((choice) => [JSON.stringify(choice), choice])),
]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, choice]) => choice);
return alternatives.length === 1 ? alternatives[0]! : { alternatives };
};
const selectionCovers = (
prior: SemanticAliasSelection,
next: SemanticAliasSelection,
): boolean => {
if (prior === null) return true;
const choices = new Set(
semanticAliasSelectionChoices(prior).map((key) => JSON.stringify(key)),
);
return semanticAliasSelectionChoices(next).every((key) =>
choices.has(JSON.stringify(key)),
);
};
const pathCovers = (
prior: SemanticAliasSelectionPath,
next: SemanticAliasSelectionPath,
): boolean =>
prior.every(
(key, index) =>
next[index] !== undefined && selectionCovers(key, next[index]!),
);
/** Only one differing selector can be joined without inventing crossed paths. */
const joinSelections = (
prior: SemanticAliasSelectionPath,
next: SemanticAliasSelectionPath,
): SemanticAliasSelectionPath | undefined => {
let changed = -1;
for (let index = 0; index < prior.length; index++) {
if (JSON.stringify(prior[index]) === JSON.stringify(next[index])) continue;
if (changed !== -1) return undefined;
changed = index;
}
return prior.map((key, index) =>
index === changed
? selection([
...semanticAliasSelectionChoices(key),
...semanticAliasSelectionChoices(next[index]!),
])
: key,
);
};
/** Preserve correlated selector alternatives while coalescing complete grids. */
export class SemanticAliasSelections {
#paths = new WeakMap<object, Map<number, SemanticAliasSelectionPath[]>>();
#calls = 0;
#limited = false;
constructor(readonly onLimit: () => void) {}
get limited(): boolean {
return this.#limited;
}
normalize(
node: object,
next: SemanticAliasSelectionPath,
): SemanticAliasSelectionPath {
if (this.#limited) return next.map(() => null);
const existing = this.#paths.get(node);
const byDepth = existing ?? new Map<number, SemanticAliasSelectionPath[]>();
const paths = [...(byDepth.get(next.length) ?? [])];
let candidate = next.map((key) =>
selection(semanticAliasSelectionChoices(key)),
);
const covered = paths.find((prior) => pathCovers(prior, candidate));
if (covered !== undefined) return covered;
if (
existing !== undefined &&
++this.#calls > SEMANTIC_ALIAS_SELECTION_LIMIT
) {
this.#limited = true;
this.#paths = new WeakMap();
this.onLimit();
return next.map(() => null);
}
for (let index = 0; index < paths.length;) {
const prior = paths[index]!;
if (pathCovers(prior, candidate)) return prior;
const joined = pathCovers(candidate, prior)
? candidate
: joinSelections(prior, candidate);
if (joined === undefined) {
index++;
continue;
}
candidate = [...joined];
paths.splice(index, 1);
index = 0;
}
paths.push(candidate);
byDepth.set(candidate.length, paths);
this.#paths.set(node, byDepth);
return candidate;
}
}
@@ -24,6 +24,32 @@ it("keeps wrapper module aliases distinct from global-object module properties",
]);
});
it.each([
["const copy={...module};consume(copy);", false],
["const copy={...{child:module}};consume(copy);", true],
[
"const original={child:module};const copy={...original};consume(copy);",
true,
],
])(
"exposes copied children without escaping the source container: %s",
(body, moduleEscaped) => {
const parsed = parseJavaScriptSource(`function copy(){${body}}`);
if (parsed === null) throw new Error("Expected copied escape fixture");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
expect(
state.ambientEscapeEffects.some(
({ path }) =>
path.length === 2 &&
path[0] === "commonjs-module" &&
path[1] === null,
),
).toBe(moduleEscaped);
},
);
it.each([
{
name: "reconverging projections with different alias-cycle histories",
@@ -22,6 +22,11 @@ import {
type SemanticIterationSource,
} from "./javascriptSemanticIterationSources.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
import {
semanticCopiedReferencePath,
semanticReferenceIterationUnknown,
semanticReferenceSourcesInactive,
} from "./javascriptSemanticReferenceSources.js";
const GLOBAL_OBJECT_NAMES = ["globalThis", "global", "window", "self"];
@@ -236,30 +241,44 @@ export const createAmbientPathResolver = (
}
const binding = resolveSemanticBindingState(state, root, root.name);
if (binding === undefined) continue;
const stable = semanticStableInitializer(binding, state);
const stable = possibleWrites
? undefined
: semanticStableInitializer(binding, state);
const readAt = current.readsAtSelf ? root : capture;
const initializers = possibleWrites
? readAt === undefined
? binding.initializers
const selected =
!possibleWrites || readAt === undefined
? binding
: semanticBindingAtRead(binding, readAt, state.parentsByNode)
.binding.initializers
.binding;
const initializers = possibleWrites
? [...selected.initializers, ...selected.referenceInitializers]
: stable === undefined
? []
: [stable];
for (const initializer of initializers)
for (const initializer of initializers) {
if (semanticReferenceSourcesInactive(initializer, state)) continue;
// Pattern assignments share the source selection owner with member
// effects: copying a container preserves its children, not itself.
const path = semanticCopiedReferencePath(
initializer,
suffix ?? [null],
escapedContents ? "escape" : "identity",
);
if (path === null) continue;
pending.push({
node: initializer.node,
readsAtSelf: true,
suffix:
suffix === undefined
suffix === undefined ||
semanticReferenceIterationUnknown(initializer, state)
? undefined
: [
...initializer.projection.map((key) =>
key === null ? null : String(key),
),
...suffix,
],
: path.map((key) =>
key === null || typeof key === "object"
? null
: String(key),
),
});
}
continue;
}
if (
@@ -332,7 +351,8 @@ export const createAmbientPathResolver = (
if (t.isObjectExpression(root)) {
for (const property of root.properties)
if (t.isSpreadElement(property))
continueWith(property.argument, []);
// A fresh copy exposes selected children, not its source container.
continueWith(property.argument, [null]);
else if (
t.isObjectProperty(property) &&
t.isExpression(property.value)
@@ -433,6 +453,7 @@ export function* indexJavaScriptAmbientWritesSteps(
program: t.Program,
state: JavaScriptSemanticAnalysisState,
observeOpaqueInvocation?: (node: t.Node) => void,
observeWriteSite?: (node: t.Node) => void,
): Generator<void> {
const ambientReads = createAmbientPathResolver(state, false);
const browserReceiverMethods = new Set<string>();
@@ -482,6 +503,7 @@ export function* indexJavaScriptAmbientWritesSteps(
program,
(root, kind, writeSite) => {
if (kind === "opaque-call") return [];
observeWriteSite?.(writeSite);
// Reassigning a local alias changes the binding, not the referenced object.
if (kind === "binding")
return t.isIdentifier(root) &&
@@ -626,8 +648,11 @@ export function* indexJavaScriptAmbientWritesSteps(
for (const { node, target, browserReceiver, projection } of escapes)
for (const path of ambientWrites(target, node, true, projection))
// Only proven browser APIs keep their global receiver from user code.
if (!(browserReceiver && path.length === 1 && path[0] === "global"))
state.ambientEscapes.retain([...path, null]);
if (!(browserReceiver && path.length === 1 && path[0] === "global")) {
const escaped = [...path, null];
state.ambientEscapes.retain(escaped);
state.ambientEscapeEffects.push({ path: escaped, node });
}
// A function may call a browser method replaced later in source order.
if (
[...browserReceiverMethods].some((method) =>
@@ -58,7 +58,11 @@ import {
completeSemanticSteps,
traverseJavaScriptAstSteps,
} from "./javascriptSemanticTraversal.js";
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
import {
semanticRequireOrigin,
observePossibleCommonJsDelegateWrite,
invalidateSemanticRequireDelegateWrites,
} from "./javascriptSemanticRequireOrigin.js";
import { semanticBundledRequireOrigin } from "./javascriptBundledRequireOrigin.js";
import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js";
import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js";
@@ -104,9 +108,11 @@ import {
type JavaScriptSemanticPropertyPath,
} from "./javascriptSemanticPropertyPaths.js";
import { indexCommonJsExportReceiversSteps } from "./javascriptCommonJsExportReceivers.js";
import { createSemanticElectronAppPathChangeQuery } from "./javascriptElectronAppPathChanges.js";
import type { JavaScriptModuleResolutionMode } from "./javascriptModuleResolutionMode.js";
interface BindPatternInput {
readonly iterationSources?: JavaScriptSemanticBindingState["initializers"][number]["iterationSources"];
readonly pattern: t.Node;
readonly initializer: t.Node | null;
readonly scope: JavaScriptSemanticScopeState;
@@ -116,6 +122,7 @@ interface BindPatternInput {
readonly kind?: JavaScriptSemanticDefinition["kind"];
readonly projection?: readonly (string | number | null)[];
readonly referenceOnly?: boolean;
readonly assignmentReference?: true;
readonly copyKind?: "object-rest" | "array-rest";
readonly copyProjectionOffset?: number;
readonly copyExcludedKeys?: readonly string[];
@@ -123,14 +130,21 @@ interface BindPatternInput {
readonly fallbackSources?: readonly {
readonly node: t.Node;
readonly projection: readonly (string | number | null)[];
readonly iterationSources?: NonNullable<
JavaScriptSemanticBindingState["initializers"][number]["iterationSources"]
>;
}[];
readonly requiredSources?: readonly {
readonly node: t.Node;
readonly projection: readonly (string | number | null)[];
readonly iterationSources?: NonNullable<
JavaScriptSemanticBindingState["initializers"][number]["iterationSources"]
>;
}[];
}
interface AddBindingInput {
readonly iterationSources?: JavaScriptSemanticBindingState["initializers"][number]["iterationSources"];
readonly state: JavaScriptSemanticAnalysisState;
readonly scope: JavaScriptSemanticScopeState;
readonly name: string;
@@ -177,6 +191,7 @@ function* indexSemanticAmbientWritesSteps(
state.program,
state,
createSemanticOpaqueGlobalObserver(state),
(node) => observePossibleCommonJsDelegateWrite(node, state),
);
if (state.opaqueEffects.globalSites.length > 0)
clearSemanticValueCaches(state);
@@ -196,6 +211,7 @@ function* indexSemanticMemberMutationFactsSteps(
referenceEffectsMayHaveChanged(state, site, reference, path),
);
indexedMemberMutationStates.add(state);
invalidateSemanticRequireDelegateWrites(state);
}
/** One owner projects source-site effects for complete reports or early exit. */
@@ -420,6 +436,9 @@ export function* analyzeParsedJavaScriptSemanticsSteps(
[...state.returnedReferenceWidenings].map(range),
),
limitations: [
...(state.valueResourceLimits.has("alias-selections")
? [semanticResourceLimitReason("alias-selections")]
: []),
...(state.returnedReferenceWidenings.size > 0
? [RETURNED_REFERENCE_WIDENING_LIMITATION]
: []),
@@ -801,6 +820,10 @@ export interface JavaScriptElectronIdentity {
readonly bindings: ReadonlyMap<number, readonly string[]>;
readonly intactExport: (path: readonly (string | null)[]) => boolean;
readonly exportPath: (root: t.Node) => readonly string[] | undefined;
readonly invocationPath: (
node: t.CallExpression | t.OptionalCallExpression,
) => readonly string[] | undefined;
readonly mayChangeAppPath: (node: t.Node) => boolean;
}
export function* indexJavaScriptElectronIdentitySteps(
@@ -881,7 +904,35 @@ export function* indexJavaScriptElectronIdentitySteps(
facts.set(root.start ?? -1, exported);
},
});
return { bindings: facts, intactExport, exportPath: rootExport };
const invocationPath = (
node: t.CallExpression | t.OptionalCallExpression,
): readonly string[] | undefined => {
const invocation = interop.intrinsicInvocation(node, (callee) => {
const { root, members } = electronMemberPath(callee);
const exported = rootExport(root);
if (
exported !== undefined &&
members.every((member): member is string => member !== null) &&
intactExport(["electron", ...exported, ...members])
)
return ["electron", ...exported, ...members];
return interop.intrinsicPath(callee);
});
return invocation?.path[0] === "electron"
? invocation.path.slice(1)
: undefined;
};
return {
bindings: facts,
intactExport,
exportPath: rootExport,
invocationPath,
mayChangeAppPath: createSemanticElectronAppPathChangeQuery(
state,
rootExport,
interop,
),
};
}
// Read import and require origins directly; general value evaluation is
@@ -951,6 +1002,7 @@ const createState = (
bindings: new Map(),
};
return {
arrayIterationUnknown: false,
program,
opaqueEffects: {
mayReferenceGlobalEval: false,
@@ -983,6 +1035,7 @@ const createState = (
ambientBindingWrites: new Map(),
ambientWrites: new SemanticPropertyPathCoverage(),
ambientEscapes: new SemanticPropertyPathCoverage(),
ambientEscapeEffects: [],
ambientWriteEffects: [],
};
};
@@ -1433,6 +1486,9 @@ const bindAssignment = (
): void => {
if (t.isAssignmentExpression(node)) {
const left = unwrapJavaScriptExpression(node.left).node;
const selectedPattern =
node.operator === "=" &&
(t.isObjectPattern(left) || t.isArrayPattern(left));
if (t.isIdentifier(left))
addAssignment(
left,
@@ -1446,17 +1502,28 @@ const bindAssignment = (
conditionallySkipped,
node,
);
else
else {
for (const identifier of semanticWriteTargets(node.left))
if (t.isIdentifier(identifier))
addAssignment(
identifier,
node,
selectedPattern ? null : node,
scope,
state,
conditionallySkipped,
node,
);
if (selectedPattern)
bindPattern({
pattern: left,
initializer: node.right,
scope,
state,
mutable: true,
referenceOnly: true,
assignmentReference: true,
});
}
} else if (t.isUpdateExpression(node)) {
const argument = unwrapJavaScriptExpression(node.argument).node;
if (t.isIdentifier(argument))
@@ -1677,12 +1744,14 @@ const bindPattern = (input: BindPatternInput): void => {
kind = "variable",
projection = [],
referenceOnly = false,
assignmentReference,
copyKind,
copyProjectionOffset,
copyExcludedKeys,
copyStartIndex,
fallbackSources,
requiredSources,
iterationSources,
} = input;
if (t.isTSParameterProperty(pattern)) {
bindPattern({ ...input, pattern: pattern.parameter });
@@ -1699,6 +1768,7 @@ const bindPattern = (input: BindPatternInput): void => {
binding.referenceInitializers.push({
node: initializer,
projection,
...(assignmentReference === undefined ? {} : { assignment: true }),
...(copyKind === undefined ? {} : { copyKind }),
...(copyProjectionOffset === undefined
? {}
@@ -1707,6 +1777,7 @@ const bindPattern = (input: BindPatternInput): void => {
...(copyStartIndex === undefined ? {} : { copyStartIndex }),
...(fallbackSources === undefined ? {} : { fallbackSources }),
...(requiredSources === undefined ? {} : { requiredSources }),
...(iterationSources === undefined ? {} : { iterationSources }),
});
return;
}
@@ -1720,6 +1791,7 @@ const bindPattern = (input: BindPatternInput): void => {
definitionNode: pattern,
initializer,
projection,
...(iterationSources === undefined ? {} : { iterationSources }),
});
return;
}
@@ -1733,7 +1805,15 @@ const bindPattern = (input: BindPatternInput): void => {
: (initializer ?? pattern.right),
requiredSources: [
...(requiredSources ?? []),
...(initializer === null ? [] : [{ node: initializer, projection }]),
...(initializer === null
? []
: [
{
node: initializer,
projection,
...(iterationSources === undefined ? {} : { iterationSources }),
},
]),
],
});
bindPattern({
@@ -1745,9 +1825,18 @@ const bindPattern = (input: BindPatternInput): void => {
initializer: pattern.right,
projection: [],
referenceOnly: true,
...(assignmentReference === undefined ? {} : { assignmentReference }),
fallbackSources: [
...(fallbackSources ?? []),
...(initializer === null ? [] : [{ node: initializer, projection }]),
...(initializer === null
? []
: [
{
node: initializer,
projection,
...(iterationSources === undefined ? {} : { iterationSources }),
},
]),
],
...(requiredSources === undefined ? {} : { requiredSources }),
});
@@ -1790,17 +1879,28 @@ const bindPattern = (input: BindPatternInput): void => {
});
}
}
else if (t.isArrayPattern(pattern))
else if (t.isArrayPattern(pattern)) {
const arrayInput = {
...input,
...(initializer === null
? {}
: {
iterationSources: [
...(iterationSources ?? []),
{ node: initializer, projection },
],
}),
};
pattern.elements.forEach((element, index) => {
if (element !== null) {
bindPattern({
...input,
...arrayInput,
pattern: element,
projection: [...projection, index],
});
if (t.isRestElement(element))
bindPattern({
...input,
...arrayInput,
pattern: element.argument,
projection: [...projection, null],
referenceOnly: true,
@@ -1810,6 +1910,7 @@ const bindPattern = (input: BindPatternInput): void => {
});
}
});
}
};
const addBinding = (input: AddBindingInput): void => {
@@ -1825,6 +1926,7 @@ const addBinding = (input: AddBindingInput): void => {
directOrigin,
runtimeImport,
projection = [],
iterationSources,
} = input;
let binding = scope.bindings.get(name);
if (binding === undefined) {
@@ -1838,7 +1940,11 @@ const addBinding = (input: AddBindingInput): void => {
binding.declarationKinds.add(declarationKind);
binding.definitions.push({ kind, location: range(definitionNode) });
if (initializer !== null)
binding.initializers.push({ node: initializer, projection });
binding.initializers.push({
node: initializer,
projection,
...(iterationSources === undefined ? {} : { iterationSources }),
});
if (directOrigin !== undefined)
binding.directOrigins.push(
directOrigin.resolutionMode === undefined &&
@@ -1875,12 +1981,14 @@ const createBinding = (
const addAssignment = (
identifier: t.Identifier,
initializer: t.Node,
initializer: t.Node | null,
scope: JavaScriptSemanticScopeState,
state: JavaScriptSemanticAnalysisState,
conditionallySkipped: boolean,
writeSite?: t.Node,
): void => {
const mutation = writeSite ?? initializer;
if (mutation === null) return;
const lookup = semanticBindingLookupFromScope(scope, identifier.name, state);
if (lookup.kind === "captured") return;
const binding = "binding" in lookup ? lookup.binding : undefined;
@@ -1890,16 +1998,17 @@ const addAssignment = (
// Unbound identifiers can denote host wrapper bindings (such as require),
// which are separate from properties written through the global object.
const writes = state.ambientBindingWrites.get(identifier.name) ?? [];
writes.push(writeSite ?? initializer);
writes.push(mutation);
state.ambientBindingWrites.set(identifier.name, writes);
return;
}
const writes = state.bindingWrites.get(binding.bindingId) ?? [];
writes.push(writeSite ?? initializer);
writes.push(mutation);
state.bindingWrites.set(binding.bindingId, writes);
binding.mutable = true;
binding.definitions.push({ kind: "assignment", location: range(identifier) });
binding.initializers.push({ node: initializer, projection: [] });
if (initializer !== null)
binding.initializers.push({ node: initializer, projection: [] });
if (conditionallySkipped)
state.conditionallySkippedExpressions.add(initializer);
state.conditionallySkippedExpressions.add(initializer ?? mutation);
};
@@ -1,7 +1,10 @@
import * as t from "@babel/types";
import { expect, it, vi } from "vitest";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import {
analyzeJavaScriptSemantics,
indexJavaScriptBindingsSteps,
} from "./javascriptSemanticAnalysis.js";
import {
resolveLocalCallables,
type LocalCallableResolution,
@@ -37,6 +40,88 @@ const indexed = (source: string) => {
};
};
it.each([
"if(flag)({make}={make:replacement});",
"if(flag)[make]=[replacement];",
"({make}={make:replacement});",
"[make]=[replacement];",
])("does not prove the old callable after a projected write: %s", (write) => {
const analysis = analyzeJavaScriptSemantics(
`function original(){}function replacement(){}let make=original;make();${write}make();`,
);
const original = analysis.callables.find(({ name }) => name === "original");
expect(original).toBeDefined();
const [before, after] = analysis.callSites;
expect(before).toMatchObject({
resolution: "exact",
calleeCallableIds: [original?.callableId],
});
expect(after).toBeDefined();
expect(after?.resolution).not.toBe("exact");
expect(analysis.frontiers).toContainEqual(
expect.objectContaining({
kind: "dynamic-call",
location: after?.calleeLocation,
}),
);
});
it.each([
"if(flag)({make}={make:replacement});",
"if(flag)[make]=[replacement];",
])(
"retains known callable candidates without claiming completeness: %s",
(write) => {
const context = indexed(
`function original(){}function replacement(){}let make=original;${write}make();`,
);
const last = context.state.program.body.at(-1);
if (!t.isExpressionStatement(last) || !t.isCallExpression(last.expression))
throw new Error("Missing projected call");
const original = context.state.callables.find(
({ name }) => name === "original",
);
expect(original).toBeDefined();
expect(context.resolve(last.expression.callee)).toMatchObject({
complete: false,
callableIds: [original?.callableId],
});
},
);
it("retains selected callable keys without admitting an unrelated return", () => {
const context = indexed(`
const table = { x() { return {}; }, y() { return {}; }, z() { return module; } };
const make = flag ? table.x : table.y;
make();
`);
const last = context.state.program.body.at(-1);
if (!t.isExpressionStatement(last) || !t.isCallExpression(last.expression))
throw new Error("Missing selected call");
const returns = createSemanticReturnedReferences(context.state)(
last.expression.callee,
).references;
expect(returns).toHaveLength(2);
expect(returns.every(({ node }) => t.isObjectExpression(node))).toBe(true);
});
it("retains correlations between selected callable path segments", () => {
const context = indexed(`
const table = { a: { b() { return {}; }, d() { return module; } },
c: { d() { return {}; }, b() { return module; } } };
const make = flag ? table.a.b : table.c.d;
make();
`);
const last = context.state.program.body.at(-1);
if (!t.isExpressionStatement(last) || !t.isCallExpression(last.expression))
throw new Error("Missing selected call");
const returns = createSemanticReturnedReferences(context.state)(
last.expression.callee,
).references;
expect(returns).toHaveLength(2);
expect(returns.every(({ node }) => t.isObjectExpression(node))).toBe(true);
});
it("bounds shared partial callback alias expansion by AST size", () => {
const measure = (depth: number) => {
const context =
@@ -6,7 +6,10 @@ import type {
} from "./javascriptSemanticIr.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import {
semanticBindingAtRead,
semanticEffectTiming,
} from "./javascriptSemanticMutationInitializers.js";
import {
unwrapJavaScriptExpression,
runtimeFunctionParameters,
@@ -119,6 +122,7 @@ const resolveIdentifier = (
};
const candidates = resolveBindingCallables(
read.binding,
node,
selectsAll
? {
...context,
@@ -168,6 +172,7 @@ const resolveAlternatives = (
const resolveBindingCallables = (
binding: JavaScriptSemanticBindingState,
site: t.Identifier,
context: CallResolutionContext,
): LocalCallableResolution => {
if (binding.initializers.length === 0)
@@ -199,11 +204,21 @@ const resolveBindingCallables = (
const callableIds = uniqueCallableIds(
candidates.flatMap((candidate) => candidate.callableIds),
);
// Pattern writes are owned reference facts, not ordinary initializers. A
// read may retain both an earlier callable and a conditional projected
// replacement. Until projected callable resolution is supported here,
// preserve known candidates without claiming they cover every target.
const complete =
!binding.referenceInitializers.some(
(initializer) =>
initializer.assignment &&
semanticEffectTiming(site, initializer.node, context.state) !== "after",
) && candidates.every(({ complete }) => complete);
const resolution = {
callableIds,
complete: candidates.every(({ complete }) => complete),
complete,
reason:
callableIds.length === 1 && candidates.every(({ complete }) => complete)
callableIds.length === 1 && complete
? "Unique local callable target."
: `Call target ${binding.name} has ambiguous or unsupported assignments.`,
...(candidates.some((candidate) => candidate.cycleDependent)
@@ -4,6 +4,7 @@ import type { ApplicationCoverage } from "./javascriptApplicationEvidenceSchemas
import {
SEMANTIC_EXPRESSION_DEPTH_LIMIT,
SEMANTIC_GETTER_EXPANSION_LIMIT,
SEMANTIC_ALIAS_SELECTION_LIMIT,
SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT,
SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT,
} from "./javascriptSemanticResourceLimits.js";
@@ -91,6 +92,10 @@ const semanticResourceBounds: Record<
value: SEMANTIC_GETTER_EXPANSION_LIMIT,
unit: "items",
},
"alias-selections": {
value: SEMANTIC_ALIAS_SELECTION_LIMIT,
unit: "items",
},
};
/** Map semantic evaluator bounds to their caller-visible application coverage. */
@@ -161,22 +161,31 @@ export const builtinDataEffectMethod = (
state: JavaScriptSemanticAnalysisState,
specifiers: readonly string[],
): string | null => {
const path = builtinDataEffectCallablePath(callee, state, specifiers);
return path?.length === 1 ? (path[0] ?? null) : null;
};
/** Recover the complete callable export path using the shared mutation proof. */
export const builtinDataEffectCallablePath = (
callee: t.Node,
state: JavaScriptSemanticAnalysisState,
specifiers: readonly string[],
): readonly string[] | null => {
const expression = unwrapJavaScriptExpression(callee).node;
if (expression !== callee)
return builtinDataEffectMethod(expression, state, specifiers);
return builtinDataEffectCallablePath(expression, state, specifiers);
if (t.isSequenceExpression(expression)) {
const final = expression.expressions.at(-1);
return final === undefined
? null
: builtinDataEffectMethod(final, state, specifiers);
: builtinDataEffectCallablePath(final, state, specifiers);
}
const required = semanticRequireOrigin(callee, state);
if (required !== undefined) {
const origin = builtinNamespaceOrigin(required.origin);
return origin.importedPath.length === 1 &&
specifiers.some(
(specifier) => specifier.replace(/^node:/u, "") === origin.specifier,
) &&
return specifiers.some(
(specifier) => specifier.replace(/^node:/u, "") === origin.specifier,
) &&
!requiredModuleChanged(
required.origin.specifier,
required.origin.importedPath,
@@ -184,7 +193,7 @@ export const builtinDataEffectMethod = (
state,
true,
)
? (origin.importedPath[0] ?? null)
? origin.importedPath
: null;
}
if (t.isIdentifier(callee)) {
@@ -197,7 +206,7 @@ export const builtinDataEffectMethod = (
[],
true,
);
return path?.length === 1 ? (path[0] ?? null) : null;
return path;
}
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
return null;
@@ -227,27 +236,26 @@ export const builtinDataEffectMethod = (
callee,
[...receiver.path, method],
true,
)?.length === 0
? method
) !== null
? [...origin.importedPath, method]
: null;
}
}
const source = semanticExpressionModuleOrigin(callee.object, state);
if (method === null || source === undefined) return null;
const origin = builtinNamespaceOrigin(source);
return origin.importedPath.length === 0 &&
specifiers.some(
(specifier) => specifier.replace(/^node:/u, "") === origin.specifier,
) &&
return specifiers.some(
(specifier) => specifier.replace(/^node:/u, "") === origin.specifier,
) &&
!requiredModuleChanged(
origin.specifier,
[...origin.importedPath, method],
[...source.importedPath, method],
callee,
state,
true,
callee.object,
)
? method
? [...origin.importedPath, method]
: null;
}
const binding = resolveSemanticBindingState(
@@ -265,7 +273,7 @@ export const builtinDataEffectMethod = (
[method],
true,
);
if (resolvedPath?.length === 0) return method;
if (resolvedPath !== null) return [...resolvedPath, method];
const initializer = binding?.initializers[0];
if (
initializer === undefined ||
@@ -298,7 +306,7 @@ export const builtinDataEffectMethod = (
true,
initializer.node,
)
? method
? [method]
: null;
};
@@ -588,6 +596,9 @@ const affectsBuiltinPath = (
const BUILTIN_RECEIVER_METHODS: Readonly<Record<string, ReadonlySet<string>>> =
{
path: new Set(["resolve", "join", "dirname"]),
"path/posix": new Set(["resolve", "join", "dirname"]),
url: new Set(["fileURLToPath"]),
fs: new Set([
"readFile",
"readFileSync",
@@ -762,7 +773,7 @@ const computeOriginalBuiltinReceiver = (
): boolean => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return false;
const callee = node.callee;
const callee = unwrapJavaScriptExpression(node.callee).node;
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
return false;
const receiver = t.isIdentifier(callee.object)
@@ -780,14 +791,21 @@ const computeOriginalBuiltinReceiver = (
semanticGlobalProcessOrigin(callee.object, state));
if (sourceOrigin === undefined) return false;
const origin = builtinNamespaceOrigin(sourceOrigin);
if (origin.importedPath.length !== 0) return false;
const posixReceiver =
origin.specifier === "path" &&
origin.importedPath.length === 1 &&
origin.importedPath[0] === "posix";
if (origin.importedPath.length !== 0 && !posixReceiver) return false;
const method = semanticStaticPropertyKey(callee.property, callee.computed);
const identity = semanticRequireExportsIdentity(origin.specifier, [
...origin.importedPath,
method ?? "",
]);
if (
method === null ||
!BUILTIN_RECEIVER_METHODS[origin.specifier]?.has(method) ||
!BUILTIN_RECEIVER_METHODS[
posixReceiver ? "path/posix" : origin.specifier
]?.has(method) ||
receiver?.memberWrites.some(
({ path, node: write }) =>
affectsBuiltinPath(path, [method]) &&
@@ -21,7 +21,10 @@ import {
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import {
semanticBindingAtRead,
semanticEffectTiming,
} from "./javascriptSemanticMutationInitializers.js";
import {
builtinDataEffectMethod as builtinMethod,
dataEffectArgumentBindingId as argumentBindingId,
@@ -254,11 +257,35 @@ const soleWriteReachesRead = (
binding: JavaScriptSemanticBindingState | undefined,
read: t.Node,
context: TraversalContext,
): boolean =>
binding?.initializers.length === 1 &&
(semanticStableInitializer(binding, context.state) !== undefined ||
semanticBindingAtRead(binding, read, context.state.parentsByNode)
.hasDefiniteInitializer);
request: t.Node | undefined,
): boolean => {
if (binding === undefined || request === undefined) return false;
const snapshot = semanticBindingAtRead(
binding,
read,
context.state.parentsByNode,
);
const initializers = snapshot.binding.initializers.filter(
({ node }) => semanticEffectTiming(read, node, context.state) !== "after",
);
const [initializer] = initializers;
if (initializers.length !== 1 || initializer === undefined) return false;
// The result itself must be this request. Containing a request in a branch
// or child allocation does not prove the entire initializer is its response.
let source = unwrapJavaScriptExpression(initializer.node).node;
while (t.isAwaitExpression(source))
source = unwrapJavaScriptExpression(source.argument).node;
return (
source === request &&
!snapshot.binding.referenceInitializers.some(
({ assignment, node }) =>
assignment &&
semanticEffectTiming(read, node, context.state) !== "after",
) &&
(semanticStableInitializer(binding, context.state) !== undefined ||
snapshot.hasDefiniteInitializer)
);
};
const collectResponseConsumers = (
program: t.Program,
@@ -308,7 +335,12 @@ const collectResponseConsumers = (
)
? linked[0]
: undefined;
const soleWrite = soleWriteReachesRead(binding, member.object, context);
const soleWrite = soleWriteReachesRead(
binding,
member.object,
context,
uniqueRequest?.node,
);
output.push({
requestId: `response:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: "response-consumer",
@@ -0,0 +1,47 @@
import { expect, it } from "vitest";
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
const indexedBindings = (body: string) => {
const parsed = parseJavaScriptSource(
`const shared={};const get=()=>shared;${body}`,
);
if (parsed === null) throw new Error("Expected callback escape fixture");
const index = completeSemanticSteps(
indexJavaScriptBindingsWithReceiversSteps(parsed.program),
);
completeSemanticSteps(index.indexAmbientWritesSteps());
completeSemanticSteps(index.indexMemberMutationFactsSteps());
return [...index.bindings.bindingsById.values()];
};
it.each([
["consume(get);", true],
["consume(()=>shared);", true],
["const alias=get;consume(alias);", true],
["consume(()=>({}));", false],
["const local=()=>({});consume(local);", false],
["const local=()=>({});const alias=local;consume(alias);", false],
["get.own=1;", false],
["const local=()=>({});get.call=local;consume(get);", true],
["const local=()=>({});get.call=local;consume(get.call);", false],
])("preserves callback result escape sources through %s", (body, escaped) => {
const bindings = indexedBindings(body);
const shared = bindings.find(({ name }) => name === "shared");
expect(shared).toBeDefined();
expect((shared?.escapedPaths.length ?? 0) > 0).toBe(escaped);
});
it.each(["call", "apply", "other"])(
"keeps a function's own %s write separate from its returned object",
(member) => {
const bindings = indexedBindings(`get.${member}=()=>({});`);
const get = bindings.find(({ name }) => name === "get");
const shared = bindings.find(({ name }) => name === "shared");
expect(get?.memberWrites.map(({ path }) => path)).toContainEqual([member]);
expect(shared?.memberWrites).toEqual([]);
expect(shared?.escapedPaths).toEqual([]);
},
);
@@ -4,20 +4,24 @@ import {
semanticObjectPatternKeys,
runtimeFunctionParameters,
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import {
semanticArrayIndex,
type JavaScriptSemanticPropertyPath,
} from "./javascriptSemanticPropertyPaths.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import { semanticCopiedReferencePath } from "./javascriptSemanticReferenceSources.js";
interface IterationSourceValue {
readonly node: t.Node;
readonly projection: JavaScriptSemanticPropertyPath;
readonly iterationSources?: readonly IterationSourceValue[];
}
/** An iterator receiver or property reference store, with default guards. */
export interface SemanticIterationSource extends IterationSourceValue {
readonly iterationSources?: readonly IterationSourceValue[];
readonly kind: "iteration" | "reference-store";
readonly mutation: t.Node;
readonly target?: t.MemberExpression;
@@ -30,7 +34,44 @@ interface PatternSource extends IterationSourceValue {
readonly restStartIndex?: number;
}
/** Select an assigned slot while retaining fresh rest-copy identity and offsets. */
export const semanticStoredSourceSelection = (
source: SemanticIterationSource,
selected: JavaScriptSemanticPropertyPath,
): {
readonly projection: JavaScriptSemanticPropertyPath;
readonly capturedSelectionDepth?: number;
readonly ownEnumerable?: true;
} | null => {
const copy = source.projection.at(-1);
if (copy === null || typeof copy !== "object")
return { projection: [...source.projection, ...selected] };
const projection = semanticCopiedReferencePath(
{
projection: source.projection,
copyProjectionOffset: source.projection.length - 1,
copyKind: copy.startIndex === undefined ? "object-rest" : "array-rest",
copyExcludedKeys: copy.excludedKeys,
...(copy.startIndex === undefined
? {}
: { copyStartIndex: copy.startIndex }),
},
selected,
"identity",
);
return projection === null
? null
: {
projection,
capturedSelectionDepth: source.projection.length,
...(copy.startIndex === undefined
? { ownEnumerable: true as const }
: {}),
};
};
interface SourceGuards {
readonly iterationSources?: readonly IterationSourceValue[];
readonly fallbackSources?: readonly IterationSourceValue[];
readonly requiredSources?: readonly IterationSourceValue[];
}
@@ -79,6 +120,7 @@ const visit = (
source: PatternSource | undefined,
guards: SourceGuards = {},
): void => {
pattern = unwrapJavaScriptExpression(pattern).node;
const { sources, mutation } = context;
if (t.isMemberExpression(pattern)) {
// Property stores have no lexical alias binding. Preserve the selected
@@ -111,7 +153,16 @@ const visit = (
...guards,
requiredSources: [
...(guards.requiredSources ?? []),
...(source === undefined ? [] : [source]),
...(source === undefined
? []
: [
{
...source,
...(guards.iterationSources === undefined
? {}
: { iterationSources: guards.iterationSources }),
},
]),
],
});
visit(
@@ -122,7 +173,16 @@ const visit = (
...guards,
fallbackSources: [
...(guards.fallbackSources ?? []),
...(source === undefined ? [] : [source]),
...(source === undefined
? []
: [
{
...source,
...(guards.iterationSources === undefined
? {}
: { iterationSources: guards.iterationSources }),
},
]),
],
},
);
@@ -168,6 +228,13 @@ const visit = (
if (t.isArrayPattern(pattern)) {
if (source !== undefined && source.restStartIndex === undefined)
add(sources, source, mutation, guards);
const selectedGuards = {
...guards,
iterationSources: [
...(guards.iterationSources ?? []),
...(source === undefined ? [] : [source]),
],
};
pattern.elements.forEach((element, index) => {
if (element === null) return;
if (t.isRestElement(element))
@@ -181,9 +248,9 @@ const visit = (
projection: source.projection,
restStartIndex: (source.restStartIndex ?? 0) + index,
},
guards,
selectedGuards,
);
else visit(context, element, project(source, index), guards);
else visit(context, element, project(source, index), selectedGuards);
});
return;
}
@@ -8,6 +8,7 @@ import {
semanticMutableBuiltinOrigin,
semanticRequireExportsIdentity,
semanticRequireOrigin,
hasSemanticCreateRequireFactoryOrigin,
} from "./javascriptSemanticRequireOrigin.js";
import {
@@ -23,16 +24,22 @@ import {
} from "./javascriptSemanticValues.js";
import { semanticContainer } from "./javascriptSemanticSlots.js";
import {
semanticArrayPropertyReferences,
semanticClassCandidates,
semanticClassPropertyReferences,
semanticObjectPropertyReferences,
type JavaScriptSemanticObjectReference,
} from "./javascriptSemanticObjectReferences.js";
import { semanticIterationSources } from "./javascriptSemanticIterationSources.js";
import {
semanticIterationSources,
type SemanticIterationSource,
} from "./javascriptSemanticIterationSources.js";
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js";
import {
semanticCopiedReferencePath,
semanticKnownArrayIteration,
semanticReferenceSourcesInactive,
semanticSelectedReferenceSlot,
} from "./javascriptSemanticReferenceSources.js";
@@ -54,6 +61,23 @@ import {
type PropertyPath = JavaScriptSemanticPropertyPath;
type ValueEffect = "write" | "escape";
/** Only replacing or possibly selecting an iterator-bearing prototype slot changes iteration. */
const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => {
// Array.prototype and Object.prototype are on native arrays' prototype
// chain. Their default constructor.prototype pairs return to the same owner.
let offset = 0;
while (path[offset] === "constructor" && path[offset + 1] === "prototype")
offset += 2;
const key = path[offset];
return (
key === undefined ||
key === null ||
typeof key === "object" ||
semanticArrayIndex(key) !== null ||
key === "__proto__"
);
};
/** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */
/** Setter keys of one object literal; a computed setter key may match any key. */
@@ -198,7 +222,7 @@ export function* collectSemanticMemberMutationsSteps(
recordRequiredEffect("node:process", path.slice(2), "write", node);
const selections = new Map<string, readonly string[]>();
const coveringPaths = new Map<string, SemanticPropertyPathCoverage>();
let arrayIterationUnknown = false;
state.arrayIterationUnknown = false;
// Definition collection finishes before this phase, so a scope's bindings no
// longer change and one node resolves to one binding. Repeated visits of the
// same node otherwise repeat the same scope walk.
@@ -278,11 +302,21 @@ export function* collectSemanticMemberMutationsSteps(
source.node.start,
source.node.end,
source.projection,
source.iterationSources?.map((iterable) => [
iterable.node.start,
iterable.node.end,
iterable.projection,
]),
]),
reference.fallbackSources?.map((source) => [
source.node.start,
source.node.end,
source.projection,
source.iterationSources?.map((iterable) => [
iterable.node.start,
iterable.node.end,
iterable.projection,
]),
]),
[...reference.bindings].sort(compareUnicodeCodePoints),
]);
@@ -300,6 +334,7 @@ export function* collectSemanticMemberMutationsSteps(
originAt: t.Node | undefined = mutation,
ownerInitializer?: t.Node,
widenedGetterResult = false,
assignedSources?: readonly SemanticIterationSource[],
): void => {
const pending: (ReferencedValue & {
readonly bindings: ReadonlySet<string>;
@@ -351,15 +386,18 @@ export function* collectSemanticMemberMutationsSteps(
mutation,
);
if (
(expression.name === "Array" &&
(current.path[0] === "prototype" || current.path[0] === null)) ||
(["Array", "Object"].includes(expression.name) &&
(current.path[0] === "prototype" || current.path[0] === null) &&
arrayPrototypeIterationMayChange(current.path.slice(1))) ||
(["globalThis", "global", "window", "self"].includes(
expression.name,
) &&
(current.path[0] === "Array" || current.path[0] === null) &&
(current.path[1] === "prototype" || current.path[1] === null))
(["Array", "Object"].includes(String(current.path[0])) ||
current.path[0] === null) &&
(current.path[1] === "prototype" || current.path[1] === null) &&
arrayPrototypeIterationMayChange(current.path.slice(2)))
)
arrayIterationUnknown = true;
state.arrayIterationUnknown = true;
continue;
}
// An alias restored from a saved reference can revisit the same binding
@@ -487,7 +525,7 @@ export function* collectSemanticMemberMutationsSteps(
current.originAt?.start ?? -1
}\u0001${current.originAt?.end ?? -1}\u0001${
current.ownerInitializer?.start ?? -1
}\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}`;
}\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}\u0001${assignedSources?.[0]?.target?.start ?? -1}`;
let covering = coveringPaths.get(pathState);
if (covering === undefined) {
covering = new SemanticPropertyPathCoverage();
@@ -521,9 +559,29 @@ export function* collectSemanticMemberMutationsSteps(
mutation,
);
}
const receiverInitializers = [
...origins.initializers,
...origins.referenceInitializers,
];
if (effect === "escape")
binding.escapedPaths.push({ path, node: mutation });
else binding.memberWrites.push({ path, node: mutation });
binding.escapedPaths.push({
path,
node: mutation,
receiverInitializers,
...(current.originAt === undefined
? {}
: { receiverAt: current.originAt }),
});
else
binding.memberWrites.push({
path,
node: mutation,
receiverInitializers,
...(current.originAt === undefined
? {}
: { receiverAt: current.originAt }),
...(assignedSources === undefined ? {} : { assignedSources }),
});
clearSemanticValueCaches(state);
for (const initializer of origins.initializers)
pending.push({
@@ -534,7 +592,11 @@ export function* collectSemanticMemberMutationsSteps(
});
}
for (const initializer of origins.referenceInitializers) {
const copiedPath = copiedReferencePath(initializer, path, effect);
const copiedPath = semanticCopiedReferencePath(
initializer,
path,
effect,
);
if (copiedPath === null) continue;
pendingReferences.push({
initializer,
@@ -617,10 +679,14 @@ export function* collectSemanticMemberMutationsSteps(
): readonly ReferencedValue[] {
if (
t.isArrayExpression(expression) &&
((path[0] === "__proto__" && (effect === "escape" || path.length > 1)) ||
(path[0] === "constructor" && path[1] === "prototype"))
((path[0] === "__proto__" &&
(effect === "escape" || path.length > 1) &&
arrayPrototypeIterationMayChange(path.slice(1))) ||
(path[0] === "constructor" &&
path[1] === "prototype" &&
arrayPrototypeIterationMayChange(path.slice(2))))
)
arrayIterationUnknown = true;
state.arrayIterationUnknown = true;
const references = referencedValues(
expression,
path,
@@ -732,6 +798,21 @@ export function* collectSemanticMemberMutationsSteps(
markEscaped(expression.object, mutation);
};
const markTarget = (node: t.Node, mutation: t.Node): void => {
const sources = semanticIterationSources(
mutation,
state.parentsByNode.get(mutation) ?? null,
);
const sourcesByTarget = new Map<
t.MemberExpression | t.OptionalMemberExpression,
SemanticIterationSource[]
>();
for (const source of sources) {
if (source.kind !== "reference-store" || source.target === undefined)
continue;
const selected = sourcesByTarget.get(source.target) ?? [];
selected.push(source);
sourcesByTarget.set(source.target, selected);
}
for (const target of semanticWriteTargets(node))
if (!t.isIdentifier(target))
markValue(
@@ -740,6 +821,10 @@ export function* collectSemanticMemberMutationsSteps(
new Set(),
"write",
mutation,
mutation,
undefined,
false,
sourcesByTarget.get(target) ?? [],
);
};
yield* traverseJavaScriptAstSteps(program, {
@@ -798,7 +883,7 @@ export function* collectSemanticMemberMutationsSteps(
) {
const effectsBefore = recordedEffectCount;
const iterablesBefore = iterableReferences.length;
const iterationBefore: boolean = arrayIterationUnknown;
const iterationBefore: boolean = state.arrayIterationUnknown;
for (const [index, reference] of pendingReferences.splice(0).entries()) {
if (index % 64 === 0) yield;
const { initializer } = reference;
@@ -866,12 +951,7 @@ export function* collectSemanticMemberMutationsSteps(
const slot = semanticSelectedReferenceSlot(reference, state);
// Custom iteration can yield the iterable itself as well as any child.
// Treat its root as escaped, including writes through yielded references.
const knownArray =
!arrayIterationUnknown &&
slot?.value.status === "array" &&
slot.value.items.every(
(item) => semanticArrayIndex(item.name) !== null,
);
const knownArray = semanticKnownArrayIteration(slot?.value, state);
if (knownArray && reference.iterationOnly) continue;
markValue(
reference.node,
@@ -901,7 +981,7 @@ export function* collectSemanticMemberMutationsSteps(
if (
recordedEffectCount === effectsBefore &&
iterableReferences.length === iterablesBefore &&
arrayIterationUnknown === iterationBefore
state.arrayIterationUnknown === iterationBefore
)
break;
}
@@ -922,14 +1002,12 @@ interface IterableReference extends ReferencedValue {
readonly mutation?: t.Node;
readonly originAt: t.Node | undefined;
readonly iterationOnly?: boolean;
readonly requiredSources?: readonly {
readonly node: t.Node;
readonly projection: PropertyPath;
}[];
readonly fallbackSources?: readonly {
readonly node: t.Node;
readonly projection: PropertyPath;
}[];
readonly requiredSources?: NonNullable<
SemanticIterationSource["requiredSources"]
>;
readonly fallbackSources?: NonNullable<
SemanticIterationSource["fallbackSources"]
>;
}
const expandedIterationReferences = (
@@ -1004,7 +1082,7 @@ const referencedValues = (
if (effect === "write" && path.length < 2) return [];
return t.isObjectExpression(node)
? semanticObjectPropertyReferences(node, path, state)
: arrayReferencedValues(node, path);
: semanticArrayPropertyReferences(node, path);
}
if (t.isClass(node) || t.isNewExpression(node))
return constructedReferenceValues(
@@ -1081,6 +1159,7 @@ type MutationObservableOrigin =
| "ambient"
| "array"
| "array-prototype"
| "function"
| "opaque";
interface MutationObserverNode {
@@ -1172,7 +1251,9 @@ const observableMutationPaths = (
// CommonJS namespace from `require("node:fs")`). Keep those writes at their
// exact member paths instead of widening the first path to a wildcard.
const hasParsedRequireOrigin = binding.initializers.some(
({ node }) => semanticRequireOrigin(node, state) !== undefined,
({ node }) =>
semanticRequireOrigin(node, state) !== undefined ||
hasSemanticCreateRequireFactoryOrigin(node, state),
);
if (
binding.directOrigins.length > 0 ||
@@ -1205,6 +1286,10 @@ const observableMutationPaths = (
const selected = pendingExpressions.pop();
if (selected === undefined) break;
const { expression, observer } = selected;
if (t.isFunction(expression))
// Keep own-member writes precise without replacing the existing
// links to references handed to a function's eventual caller.
(observer.origins ??= new Set()).add("function");
// Keep class/getter projections distinct so cycle pruning does not
// discard observable escapes through a shared getter result.
if (
@@ -1386,98 +1471,3 @@ const observableMutationPaths = (
};
return { normalize, repeatsUnpruned };
};
const arrayReferencedValues = (
node: t.ArrayExpression,
path: PropertyPath,
): readonly ReferencedValue[] => {
const [key, ...remaining] = path;
const selected = key ?? null;
const selectedIndex =
typeof selected === "object" ? null : semanticArrayIndex(selected);
if (typeof selected !== "object" && selectedIndex === null) return [];
const references: ReferencedValue[] = [];
let minimumIndex = 0;
let uncertainIndex = false;
for (const element of node.elements) {
if (t.isSpreadElement(element)) {
if (selectedIndex === null || selectedIndex >= minimumIndex) {
let sourceKey: PropertyPath[number] = null;
if (!uncertainIndex) {
if (selectedIndex !== null) sourceKey = selectedIndex - minimumIndex;
else if (selected !== null && typeof selected === "object")
sourceKey = {
excludedKeys: selected.excludedKeys.flatMap((name) => {
const index = semanticArrayIndex(name);
return index === null || index < minimumIndex
? []
: [String(index - minimumIndex)];
}),
startIndex: Math.max(
0,
(selected.startIndex ?? 0) - minimumIndex,
),
};
}
references.push({
node: element.argument,
path: [sourceKey, ...remaining],
iterableFallbackPath: [],
});
}
uncertainIndex = true;
continue;
}
if (
element !== null &&
(uncertainIndex
? selectedIndex === null || selectedIndex >= minimumIndex
: semanticPropertyPathKeyMatches(selected, String(minimumIndex)))
)
references.push({ node: element, path: remaining });
minimumIndex++;
}
return references;
};
const copiedReferencePath = (
initializer: JavaScriptSemanticBindingState["referenceInitializers"][number],
path: PropertyPath,
effect: ValueEffect,
): PropertyPath | null => {
const offset = initializer.copyProjectionOffset;
if (offset === undefined) return [...initializer.projection, ...path];
const copiedPath = [...initializer.projection.slice(offset + 1), ...path];
if (effect === "write" && copiedPath.length < 2) return null;
let [key, ...remaining] = copiedPath;
if (initializer.copyKind === "array-rest") {
const startIndex = initializer.copyStartIndex ?? 0;
if (key === undefined || key === null)
key = { excludedKeys: [], startIndex };
else if (typeof key === "object")
key = {
excludedKeys: key.excludedKeys.flatMap((name) => {
const index = semanticArrayIndex(name);
return index === null ? [] : [String(index + startIndex)];
}),
startIndex: (key.startIndex ?? 0) + startIndex,
};
else {
const index = semanticArrayIndex(key);
if (index === null) return null;
key = index + startIndex;
}
} else {
const excludedKeys = initializer.copyExcludedKeys ?? [];
if (key === undefined || key === null) key = { excludedKeys };
else if (typeof key === "object")
key = {
...key,
excludedKeys: [...new Set([...key.excludedKeys, ...excludedKeys])].sort(
compareUnicodeCodePoints,
),
};
else if (excludedKeys.includes(String(key))) return null;
}
return [...initializer.projection.slice(0, offset), key, ...remaining];
};
@@ -471,6 +471,25 @@ const directMutationPosition = (
return computed;
};
/** Prove that an unconditional member replacement precedes this read. */
export const semanticDefiniteEffectPrecedesReference = (
effect: t.Node,
reference: t.Node,
parents: WeakMap<t.Node, t.Node>,
): boolean => {
const before = directMutationPosition(effect, parents);
const after = bindingReadPosition(reference, parents);
if (before === null || after.position === null || after.repeating)
return false;
if (positionPrecedes(before, after.position)) return true;
return (
before.body === after.position.body &&
before.index === after.position.index &&
before.declaratorIndex === after.position.declaratorIndex &&
expressionEvaluationOrder(effect, reference, parents) === "before"
);
};
const computeReferenceInitializerPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
@@ -481,6 +500,11 @@ const computeReferenceInitializerPosition = (
const direct = directInitializerPosition(current, parents);
if (direct !== null) return direct;
const parent = parents.get(current);
if (
t.isAssignmentExpression(parent, { operator: "=" }) &&
(t.isObjectPattern(parent.left) || t.isArrayPattern(parent.left))
)
return directMutationPosition(parent, parents);
if (t.isFunction(parent))
return t.isBlockStatement(parent.body)
? { body: parent.body.body, index: -1, declaratorIndex: -1 }
@@ -660,8 +684,15 @@ export const semanticBindingAtRead = (
// can cover entry even when another write's order remains unknown.
const hasDefiniteInitializer =
position !== null &&
binding.initializers.some((initializer) => {
const source = initializerPosition(initializer, parents);
[
...binding.initializers,
...binding.referenceInitializers.filter(({ assignment }) => assignment),
].some((initializer) => {
const source = initializerPosition(
initializer,
parents,
binding.referenceInitializers.includes(initializer),
);
return (
source !== null &&
positionPrecedes(source, position) &&
@@ -8,6 +8,7 @@ import {
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import {
semanticArrayIndex,
semanticPropertyPathKeyMatches,
type JavaScriptSemanticPropertyPath as PropertyPath,
} from "./javascriptSemanticPropertyPaths.js";
@@ -21,6 +22,12 @@ import { expandSemanticGetterResult } from "./javascriptSemanticGetterResults.js
export interface JavaScriptSemanticObjectReference {
readonly node: t.Node;
readonly path: PropertyPath;
/** This selection crossed the object's prototype, rather than an own slot. */
readonly inherited?: true;
/** A shallow copy selects this prefix now while retaining its child identity. */
readonly capturedSelectionAt?: t.Node;
readonly ownEnumerable?: true;
readonly iterableFallbackPath?: PropertyPath;
readonly escapes?: true;
/** A retry omitted suffixes, so escaping constructed children must cover them. */
readonly widenedGetterResult?: true;
@@ -638,6 +645,8 @@ class ObjectReferenceWalk {
this.#references.push({
node: property.argument,
path: [this.#spreadKey(), ...this.#remaining],
capturedSelectionAt: property.argument,
ownEnumerable: true,
});
if (
selected !== null &&
@@ -672,6 +681,7 @@ class ObjectReferenceWalk {
this.#references.push({
node: property.value,
path: [selected, ...this.#remaining],
inherited: true,
});
return true;
}
@@ -777,3 +787,57 @@ const objectExpressionDefinesKey = (
}
return false;
};
export const semanticArrayPropertyReferences = (
node: t.ArrayExpression,
path: PropertyPath,
): readonly JavaScriptSemanticObjectReference[] => {
const [key, ...remaining] = path;
const selected = key ?? null;
const selectedIndex =
typeof selected === "object" ? null : semanticArrayIndex(selected);
if (typeof selected !== "object" && selectedIndex === null) return [];
const references: JavaScriptSemanticObjectReference[] = [];
let minimumIndex = 0;
let uncertainIndex = false;
for (const element of node.elements) {
if (t.isSpreadElement(element)) {
if (selectedIndex === null || selectedIndex >= minimumIndex) {
let sourceKey: PropertyPath[number] = null;
if (!uncertainIndex) {
if (selectedIndex !== null) sourceKey = selectedIndex - minimumIndex;
else if (selected !== null && typeof selected === "object")
sourceKey = {
excludedKeys: selected.excludedKeys.flatMap((name) => {
const index = semanticArrayIndex(name);
return index === null || index < minimumIndex
? []
: [String(index - minimumIndex)];
}),
startIndex: Math.max(
0,
(selected.startIndex ?? 0) - minimumIndex,
),
};
}
references.push({
node: element.argument,
path: [sourceKey, ...remaining],
capturedSelectionAt: element.argument,
iterableFallbackPath: [],
});
}
uncertainIndex = true;
continue;
}
if (
element !== null &&
(uncertainIndex
? selectedIndex === null || selectedIndex >= minimumIndex
: semanticPropertyPathKeyMatches(selected, String(minimumIndex)))
)
references.push({ node: element, path: remaining });
minimumIndex++;
}
return references;
};
@@ -0,0 +1,403 @@
import * as t from "@babel/types";
import type {
JavaScriptSemanticAnalysisState,
JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import {
semanticPropertyPathKeyMatches,
type JavaScriptSemanticPropertyPath,
} from "./javascriptSemanticPropertyPaths.js";
import { unwrapJavaScriptExpression } from "./javascriptAstValues.js";
import {
semanticObjectPropertyReferences,
semanticArrayPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
import {
semanticBindingAtRead,
semanticDefiniteEffectPrecedesReference,
semanticEffectTiming,
semanticTopLevelEffectFollowsCapture,
} from "./javascriptSemanticMutationInitializers.js";
import {
semanticCopiedReferencePath,
semanticReferenceIterationUnknown,
semanticReferenceSourcesInactive,
} from "./javascriptSemanticReferenceSources.js";
import { semanticStoredSourceSelection } from "./javascriptSemanticIterationSources.js";
import {
semanticAliasSelectionChoices,
type SemanticAliasSelectionPath,
} from "./javascriptSemanticAliasSelections.js";
export interface SemanticCapturedSelection {
readonly at: t.Node;
readonly depth: number;
/** Object spread/rest reads only an own enumerable slot at this depth. */
readonly ownEnumerable?: true;
}
/** The next origin crossed owns a repeated copy boundary at the same depth. */
export const semanticNormalizeCapturedSelections = (
selections: readonly SemanticCapturedSelection[] | undefined,
pathLength?: number,
): readonly SemanticCapturedSelection[] | undefined => {
const byDepth = new Map<number, SemanticCapturedSelection>();
for (const capture of selections ?? [])
if (
capture.depth > 0 &&
(pathLength === undefined || capture.depth <= pathLength)
)
byDepth.set(capture.depth, capture);
const normalized = [...byDepth.values()].sort(
(left, right) => left.depth - right.depth,
);
return normalized.length ? normalized : undefined;
};
/** Keep capture boundaries aligned as a selected prefix is added or consumed. */
export const semanticShiftCapturedSelections = (
selections: readonly SemanticCapturedSelection[] | undefined,
delta: number,
): readonly SemanticCapturedSelection[] | undefined => {
const shifted = selections?.flatMap((selection) =>
selection.depth + delta > 0
? [{ ...selection, depth: selection.depth + delta }]
: [],
);
return semanticNormalizeCapturedSelections(shifted);
};
interface ProjectedSource {
readonly node: t.Node;
readonly path: SemanticAliasSelectionPath;
readonly capturedSelections?: readonly SemanticCapturedSelection[];
}
/** A fresh parent does not prove a descendant receiver is a fresh allocation. */
const hasFreshProjectedReceiver = (
root: t.Node,
path: JavaScriptSemanticPropertyPath,
state: JavaScriptSemanticAnalysisState,
): boolean => {
const pending = [{ node: root, path }];
const examined = new WeakMap<t.Node, Set<string>>();
let selections = 0;
while (pending.length > 0) {
const current = pending.pop()!;
const node = unwrapJavaScriptExpression(current.node).node;
const key = JSON.stringify(current.path);
const paths = examined.get(node) ?? new Set<string>();
if (paths.has(key)) return false;
paths.add(key);
examined.set(node, paths);
if (++selections > SEMANTIC_ALIAS_SELECTION_LIMIT) {
state.valueResourceLimits.add("alias-selections");
return false;
}
if (current.path.length === 0) {
if (
!t.isObjectExpression(node) &&
!t.isArrayExpression(node) &&
!t.isFunction(node) &&
!t.isClassExpression(node)
)
return false;
continue;
}
const [selected, ...remaining] = current.path;
const sources = t.isObjectExpression(node)
? semanticObjectPropertyReferences(node, [selected!], state)
: t.isArrayExpression(node)
? semanticArrayPropertyReferences(node, [selected!])
: [];
if (sources.length === 0) return false;
if (
selections + pending.length + sources.length >
SEMANTIC_ALIAS_SELECTION_LIMIT
) {
state.valueResourceLimits.add("alias-selections");
return false;
}
pending.push(
...sources.map((source) => ({
node: source.node,
path: [...source.path, ...remaining],
})),
);
}
return true;
};
/** Select retained assignment and copy sources at one projected member read. */
export const semanticProjectedSourcesAtRead = (
...arguments_: Parameters<typeof semanticProjectedSourceFactsAtRead>
): readonly ProjectedSource[] | undefined =>
semanticProjectedSourceFactsAtRead(...arguments_)?.sources;
/** Retain the selected receiver's possible owners after its current value becomes opaque. */
export const semanticProjectedSourceEligibilityAtRead = (
binding: JavaScriptSemanticBindingState,
reference: t.Node,
path: SemanticAliasSelectionPath,
state: JavaScriptSemanticAnalysisState,
memberReadAt: t.Node = reference,
capturedSelections: readonly SemanticCapturedSelection[] = [],
): readonly ProjectedSource[] | undefined =>
projectedSourceFactsAtRead(
binding,
reference,
path,
state,
false,
memberReadAt,
capturedSelections,
true,
)?.sources;
/** Keep imported origins subject to the same selected writes as other values. */
export const semanticProjectedSourceFactsAtRead = (
...arguments_: Parameters<typeof projectedSourceFactsAtRead>
): ReturnType<typeof projectedSourceFactsAtRead> =>
projectedSourceFactsAtRead(...arguments_);
const projectedSourceFactsAtRead = (
binding: JavaScriptSemanticBindingState,
reference: t.Node,
path: SemanticAliasSelectionPath,
state: JavaScriptSemanticAnalysisState,
includeUnknown: boolean,
memberReadAt: t.Node = reference,
capturedSelections: readonly SemanticCapturedSelection[] = [],
retainEligibility = false,
):
| {
readonly sources: readonly ProjectedSource[];
readonly retainsOrigin: boolean;
}
| undefined => {
const read = semanticBindingAtRead(binding, reference, state.parentsByNode);
const captured = [
...read.binding.initializers,
...read.binding.referenceInitializers,
];
const normalizedCaptures = semanticNormalizeCapturedSelections(
capturedSelections,
path.length,
);
const captureForDepth = (depth: number): t.Node | undefined =>
normalizedCaptures?.find((capture) => capture.depth >= depth)?.at;
const selectedAt = (depth: number): t.Node =>
captureForDepth(depth) ?? memberReadAt;
const samePrefix = (
prefix: JavaScriptSemanticBindingState["memberWrites"][number],
effect: JavaScriptSemanticBindingState["memberWrites"][number],
): boolean =>
prefix.path.length < effect.path.length &&
prefix.path.every(
(key, index) =>
key !== null &&
typeof key !== "object" &&
effect.path[index] !== null &&
typeof effect.path[index] !== "object" &&
String(key) === String(effect.path[index]),
);
const targetsReplacedPrefix = (
effect: JavaScriptSemanticBindingState["memberWrites"][number],
): boolean =>
capturedSelections.length > 0 &&
binding.memberWrites.some((replacement) => {
const capture = captureForDepth(replacement.path.length);
const receiverAt = effect.receiverAt ?? effect.node;
return (
capture !== undefined &&
receiverAt !== undefined &&
t.isAssignmentExpression(replacement.node, { operator: "=" }) &&
samePrefix(replacement, effect) &&
semanticTopLevelEffectFollowsCapture(
capture,
replacement.node,
state.parentsByNode,
) &&
semanticDefiniteEffectPrecedesReference(
replacement.node,
receiverAt,
state.parentsByNode,
) &&
hasFreshProjectedReceiver(
replacement.node.right,
effect.path.slice(replacement.path.length, -1),
state,
)
);
});
const matches = (
effect: JavaScriptSemanticBindingState["memberWrites"][number],
) =>
(effect.receiverInitializers === undefined ||
effect.receiverInitializers.length === 0 ||
captured.length === 0 ||
effect.receiverInitializers.some((receiver) =>
captured.some(
(source) =>
source.node === receiver.node &&
JSON.stringify(source.projection) ===
JSON.stringify(receiver.projection),
),
)) &&
path.length > 0 &&
effect.path.length <= path.length &&
effect.path.every((key, index) => {
const selected = path[index];
return (
selected !== undefined &&
semanticAliasSelectionChoices(selected).some(
(choice) =>
choice === null ||
typeof choice === "object" ||
semanticPropertyPathKeyMatches(key, String(choice)),
)
);
}) &&
semanticEffectTiming(selectedAt(effect.path.length), effect.node, state) !==
"after" &&
!targetsReplacedPrefix(effect);
const writes = binding.memberWrites.filter(matches);
let replacement:
| JavaScriptSemanticBindingState["memberWrites"][number]
| undefined;
for (const effect of writes) {
if (
t.isAssignmentExpression(effect.node, { operator: "=" }) &&
effect.path.every(
(key, index) =>
key !== null &&
typeof key !== "object" &&
path[index] !== null &&
typeof path[index] !== "object" &&
String(key) === String(path[index]),
) &&
semanticDefiniteEffectPrecedesReference(
effect.node,
selectedAt(effect.path.length),
state.parentsByNode,
) &&
(replacement === undefined ||
semanticDefiniteEffectPrecedesReference(
replacement.node!,
effect.node,
state.parentsByNode,
) ||
(replacement.node === effect.node &&
(replacement.assignedSources?.[0]?.target?.start ?? -1) <
(effect.assignedSources?.[0]?.target?.start ?? -1)))
)
replacement = effect;
}
const superseded = (
effect: JavaScriptSemanticBindingState["memberWrites"][number],
) =>
replacement !== undefined &&
effect.node !== undefined &&
effect !== replacement &&
(semanticEffectTiming(replacement.node!, effect.node, state) === "before" ||
(effect.node === replacement.node &&
(effect.assignedSources?.[0]?.target?.start ?? -1) <
(replacement.assignedSources?.[0]?.target?.start ?? -1)));
if (
includeUnknown &&
binding.escapedPaths.some(
(effect) => matches(effect) && !superseded(effect),
)
)
return undefined;
const sources: ProjectedSource[] = [];
for (const effect of writes) {
if (!retainEligibility && superseded(effect)) continue;
if (
!t.isAssignmentExpression(effect.node) ||
!["=", "||=", "&&=", "??="].includes(effect.node.operator)
) {
if (includeUnknown) return undefined;
continue;
}
if (effect.assignedSources === undefined) {
if (includeUnknown) return undefined;
continue;
}
const remaining = path.slice(effect.path.length);
for (const source of effect.assignedSources) {
if (semanticReferenceIterationUnknown(source, state)) return undefined;
if (semanticReferenceSourcesInactive(source, state)) continue;
const [key, ...suffix] = remaining;
for (const choice of key === undefined
? [undefined]
: semanticAliasSelectionChoices(key)) {
const selection = semanticStoredSourceSelection(
source,
choice === undefined ? [] : [choice],
);
if (selection !== null)
sources.push({
node: source.node,
path: [...selection.projection, ...suffix],
...(selection.capturedSelectionDepth === undefined
? {}
: {
capturedSelections: [
{
at: source.node,
depth: selection.capturedSelectionDepth,
...(selection.ownEnumerable
? { ownEnumerable: true as const }
: {}),
},
],
}),
});
}
}
}
if (!retainEligibility && replacement !== undefined)
return { sources, retainsOrigin: false };
for (const initializer of read.binding.initializers) {
if (semanticReferenceIterationUnknown(initializer, state)) return undefined;
if (semanticReferenceSourcesInactive(initializer, state)) continue;
sources.push({
node: initializer.node,
path: [...initializer.projection, ...path],
});
}
for (const initializer of read.binding.referenceInitializers) {
if (semanticReferenceIterationUnknown(initializer, state)) return undefined;
if (semanticReferenceSourcesInactive(initializer, state)) continue;
const [key, ...suffix] = path;
for (const choice of key === undefined
? [undefined]
: semanticAliasSelectionChoices(key)) {
const projection = semanticCopiedReferencePath(
initializer,
choice === undefined ? [] : [choice],
"identity",
);
if (projection !== null)
sources.push({
node: initializer.node,
path: [...projection, ...suffix],
capturedSelections:
initializer.copyProjectionOffset === undefined
? []
: [
{
at: initializer.node,
depth: projection.length + suffix.length - path.length + 1,
...(initializer.copyKind === "object-rest"
? { ownEnumerable: true as const }
: {}),
},
],
});
}
}
return { sources, retainsOrigin: true };
};
@@ -25,7 +25,6 @@ import {
import { range } from "./javascriptStaticAnalysisHelpers.js";
import {
propertyName,
semanticFinalObjectProperty,
semanticStaticPropertyKey,
semanticWrappedWriteSite,
semanticWriteSiteReadsFirst,
@@ -40,11 +39,11 @@ import {
semanticImportEqualsSpecifier,
} from "./javascriptModuleDeclaration.js";
import {
commonJsDefinedExport,
commonJsExportName,
commonJsRuntimeExportValues,
type CommonJsExportReceiverEffect,
type CommonJsExportSite,
} from "./javascriptCommonJsExportReceivers.js";
import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js";
interface CollectCallableInput {
readonly node: t.Node;
@@ -124,7 +123,7 @@ export const collectSemanticModuleLink = (
else if (t.isAssignmentExpression(node))
collectCommonJsExport(node, state, commonJsReceivers.get(node));
else if (t.isCallExpression(node)) {
collectDefinedCommonJsExport(node, state, commonJsReceivers.get(node));
collectCommonJsExport(node, state, commonJsReceivers.get(node));
collectUnboundRequireLink(node, state);
}
};
@@ -472,34 +471,21 @@ const requirePatternBindings = (
};
const collectCommonJsExport = (
node: t.AssignmentExpression,
node: CommonJsExportSite,
state: JavaScriptSemanticAnalysisState,
receiverEffect: CommonJsExportReceiverEffect | undefined,
): void => {
const exportedName = commonJsExportName(node.left, state);
if (exportedName === undefined) return;
if (receiverEffect === "detached") return;
// Compound and logical writes also depend on the previous value, so remain
// unknown.
const value =
node.operator === "=" && receiverEffect !== "uncertain" ? node.right : null;
addCommonJsExport(node, state, exportedName, value);
};
const collectDefinedCommonJsExport = (
node: t.CallExpression,
state: JavaScriptSemanticAnalysisState,
receiverEffect: CommonJsExportReceiverEffect | undefined,
): void => {
const defined = commonJsDefinedExport(node, state);
if (defined === undefined || receiverEffect === "detached") return;
// A data descriptor exports its value; an accessor's result stays unknown.
const descriptor = unwrapJavaScriptExpression(defined.descriptor).node;
const value =
receiverEffect !== "uncertain" && t.isObjectExpression(descriptor)
? (semanticFinalObjectProperty(descriptor, "value") ?? null)
: null;
addCommonJsExport(node, state, defined.exportedName, value);
for (const exported of commonJsRuntimeExportValues(node, state))
addCommonJsExport(
node,
state,
exported.exportedName,
receiverEffect === "uncertain" ? null : exported.value,
exported.projection,
);
};
const addCommonJsExport = (
@@ -507,6 +493,7 @@ const addCommonJsExport = (
state: JavaScriptSemanticAnalysisState,
exportedName: string,
exported: t.Node | null,
projection?: JavaScriptSemanticPropertyPath,
): void => {
// Simple assignment chains yield their rightmost assigned value.
let value = exported;
@@ -514,21 +501,41 @@ const addCommonJsExport = (
value = value.right;
const required =
value === null ? undefined : semanticRequireOrigin(value, state);
const origin = required?.origin;
// Copies retain their module provenance, but do not export the source object
// itself. A wildcard with exclusions cannot express one module link.
const origin = projection?.some(
(key) =>
typeof key === "object" && key !== null && key.excludedKeys.length > 0,
)
? undefined
: required?.origin;
const selectedName = projection?.at(-1);
addModuleLink(
state,
{
kind: "commonjs-export",
specifier: origin?.specifier ?? null,
importedName: origin?.importedPath.at(-1) ?? null,
localName: value !== null && t.isIdentifier(value) ? value.name : null,
importedName:
origin === undefined
? null
: typeof selectedName === "string" || typeof selectedName === "number"
? String(selectedName)
: (origin.importedPath.at(-1) ?? null),
localName:
projection === undefined && value !== null && t.isIdentifier(value)
? value.name
: null,
exportedName,
callableId: value === null ? null : semanticCallableIdForNode(value),
callableId:
projection !== undefined || value === null
? null
: semanticCallableIdForNode(value),
location: range(node),
},
node,
);
if (required !== undefined) state.linkedRequireCalls.add(required.call);
if (origin !== undefined && required !== undefined)
state.linkedRequireCalls.add(required.call);
};
const addModuleLink = (
@@ -0,0 +1,87 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const provenance = (source: string, name: string) => {
const result = analyzeJavaScriptSemantics(source);
const binding = result.bindings.find((candidate) => candidate.name === name);
if (binding === undefined) throw new Error(`Missing binding ${name}`);
return binding.provenance;
};
it.each([
"let api;({api}={api:require('node:fs')});",
"let api;[api]=[require('node:fs')];",
"let api;({api}={api:{readFileSync(){}}});",
"let api={};({api}={api:require('node:fs')});",
"let api;if(flag)({api}={api:require('node:fs')});",
])("keeps projected replacement provenance unresolved: %s", (source) => {
expect(provenance(source, "api")).toMatchObject({
status: "unknown",
origins: [],
});
});
it.each([
"let api=require('node:fs');({api}={api:{}});",
"let api=require('node:fs');if(flag)({api}={api:{}});",
"let api=require('node:fs');[api]=[{}];",
])(
"retains possible earlier module origins without proving them current: %s",
(source) => {
expect(provenance(source, "api")).toMatchObject({
status: "ambiguous",
origins: [{ specifier: "node:fs", importedPath: [] }],
});
},
);
it.each([
["let api=require('node:fs');const saved=api;({api}={api:{}});", "module"],
["let api=require('node:fs');({api}={api:{}});const saved=api;", "unknown"],
[
"let api=require('node:fs');if(flag)({api}={api:{}});const saved=api;",
"ambiguous",
],
])("selects provenance at the alias capture: %s", (source, status) => {
expect(provenance(source, "saved").status).toBe(status);
if (status === "module")
expect(provenance(source, "saved").origins).toEqual([
{ specifier: "node:fs", importedPath: [], resolutionMode: "require" },
]);
});
it.each([
["const {api=fallback}={};", "local"],
["const {...api}=require('node:fs');", "local"],
["const api=require('node:fs');", "module"],
])(
"preserves declaration and ordinary initializer policy: %s",
(source, status) => {
expect(provenance(source, "api").status).toBe(status);
},
);
it("retains an uncertain sqlite candidate instead of asserting a local constructor", () => {
const source =
"let Database;({Database}={Database:require('better-sqlite3')});new Database('x.db');";
expect(provenance(source, "Database").status).toBe("unknown");
expect(analyzeJavaScriptStaticSource(source).storage).toEqual([
expect.objectContaining({
kind: "sqlite",
interpretation: "candidate",
name: "x.db",
}),
]);
});
it("preserves captured Electron origin and rejects certainty after replacement", () => {
const source =
"let electron=require('electron');const saved=electron;({electron}={electron:{}});const later=electron;";
expect(provenance(source, "saved")).toMatchObject({
status: "module",
origins: [{ specifier: "electron" }],
});
expect(provenance(source, "later").status).toBe("unknown");
});
@@ -1,17 +1,114 @@
import type * as t from "@babel/types";
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js";
import { evaluateSemanticExpression } from "./javascriptSemanticValues.js";
import {
semanticArrayIndex,
semanticPropertyPathKeyMatches,
type JavaScriptSemanticPropertyPath as PropertyPath,
} from "./javascriptSemanticPropertyPaths.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import {
evaluateSemanticExpression,
semanticValueDefinitelyDefined,
} from "./javascriptSemanticValues.js";
import { semanticSlotAtPath } from "./javascriptSemanticSlots.js";
import type { JavaScriptSemanticValue } from "./javascriptSemanticValueTypes.js";
import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js";
/**
* Node's wrapper module inherits require. A copy cannot read that delegate
* unless an earlier own-slot write or opaque module escape may have exposed it.
*/
export const semanticCommonJsRequireOwnEnumerableAt = (
capture: t.Node,
state: JavaScriptSemanticAnalysisState,
): boolean => {
const selected = ["commonjs-module", "require"];
const mayAffect = (
effect: JavaScriptSemanticAnalysisState["ambientWriteEffects"][number],
) =>
effect.path.length <= selected.length &&
effect.path.every((key, index) =>
semanticPropertyPathKeyMatches(key, selected[index] ?? ""),
) &&
semanticEffectTiming(capture, effect.node, state) !== "after";
return (
state.ambientWriteEffects.some(mayAffect) ||
state.ambientEscapeEffects.some(mayAffect)
);
};
interface ReferenceSource {
readonly node: t.Node;
readonly projection: PropertyPath;
readonly iterationSources?: readonly ReferenceSource[];
}
/** Native array iteration preserves numeric slots only while its iterator is intact. */
export const semanticKnownArrayIteration = (
value: JavaScriptSemanticValue | undefined,
state: JavaScriptSemanticAnalysisState,
): boolean =>
!state.arrayIterationUnknown &&
value?.status === "array" &&
value.items.every((item) => semanticArrayIndex(item.name) !== null);
/** Invocation spreads use the same native-iteration proof at their source read. */
export const semanticKnownArrayIterationAt = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): boolean =>
semanticKnownArrayIteration(
evaluateSemanticExpression(node, state, node),
state,
);
/** Numeric destructuring projections do not select slots of custom iterators. */
export const semanticReferenceIterationUnknown = (
source: { readonly iterationSources?: readonly ReferenceSource[] },
state: JavaScriptSemanticAnalysisState,
): boolean =>
source.iterationSources?.some(
(iterable) => !selectedReferenceIterationKnown(iterable, state),
) === true;
const selectedReferenceIterationKnown = (
source: ReferenceSource,
state: JavaScriptSemanticAnalysisState,
): boolean => {
let values = [evaluateSemanticExpression(source.node, state, source.node)];
for (const key of source.projection) {
if (typeof key === "object" && key !== null) return false;
const selected = new Set<JavaScriptSemanticValue>();
for (const value of values) {
if (key === null) {
// for-of projects any position of a native array. Its finite slot
// inventory can prove every possible nested iterable without treating
// a computed object property or custom iterator as an own-index read.
if (
!semanticKnownArrayIteration(value, state) ||
value.status !== "array" ||
value.unknownItems
)
return false;
for (const slot of value.items) {
if (slot.presence !== "present") return false;
selected.add(slot.value);
}
} else {
const slot = semanticSlotAtPath(value, [String(key)]);
if (slot.presence !== "present") return false;
selected.add(slot.value);
}
}
values = [...selected];
}
return values.every((value) => semanticKnownArrayIteration(value, state));
};
/** Exclude references whose source slot or destructuring fallback is absent. */
export const semanticReferenceSourcesInactive = (
reference: {
readonly iterationSources?: readonly ReferenceSource[];
readonly requiredSources?: readonly ReferenceSource[];
readonly fallbackSources?: readonly ReferenceSource[];
},
@@ -19,19 +116,18 @@ export const semanticReferenceSourcesInactive = (
): boolean =>
reference.requiredSources?.some(
(source) =>
!semanticReferenceIterationUnknown(source, state) &&
semanticSelectedReferenceSlot(source, state, source.node)?.presence ===
"absent",
"absent",
) === true ||
reference.fallbackSources?.some((source) => {
// Selection precedes effects through the chosen reference. A later escape
// cannot make a fallback that was excluded at destructuring reachable.
const slot = semanticSelectedReferenceSlot(source, state, source.node);
return (
!semanticReferenceIterationUnknown(source, state) &&
slot?.presence === "present" &&
(slot.value.status === "literal" ||
slot.value.status === "union" ||
slot.value.status === "object" ||
slot.value.status === "array")
semanticValueDefinitelyDefined(slot.value)
);
}) === true;
@@ -51,3 +147,55 @@ export const semanticSelectedReferenceSlot = (
path,
);
};
interface CopiedReferenceSource {
readonly projection: PropertyPath;
readonly copyKind?: "object-rest" | "array-rest";
readonly copyProjectionOffset?: number;
readonly copyExcludedKeys?: readonly string[];
readonly copyStartIndex?: number;
}
/** Map copied child selections once; the fresh copy itself keeps its own identity. */
export const semanticCopiedReferencePath = (
initializer: CopiedReferenceSource,
path: PropertyPath,
effect: "write" | "escape" | "identity",
): PropertyPath | null => {
const offset = initializer.copyProjectionOffset;
if (offset === undefined) return [...initializer.projection, ...path];
const copiedPath = [...initializer.projection.slice(offset + 1), ...path];
if (effect === "identity" && copiedPath.length === 0) return null;
if (effect === "write" && copiedPath.length < 2) return null;
let [key, ...remaining] = copiedPath;
if (initializer.copyKind === "array-rest") {
const startIndex = initializer.copyStartIndex ?? 0;
if (key === undefined || key === null)
key = { excludedKeys: [], startIndex };
else if (typeof key === "object")
key = {
excludedKeys: key.excludedKeys.flatMap((name) => {
const index = semanticArrayIndex(name);
return index === null ? [] : [String(index + startIndex)];
}),
startIndex: (key.startIndex ?? 0) + startIndex,
};
else {
const index = semanticArrayIndex(key);
if (index === null) return null;
key = index + startIndex;
}
} else {
const excludedKeys = initializer.copyExcludedKeys ?? [];
if (key === undefined || key === null) key = { excludedKeys };
else if (typeof key === "object")
key = {
...key,
excludedKeys: [...new Set([...key.excludedKeys, ...excludedKeys])].sort(
compareUnicodeCodePoints,
),
};
else if (excludedKeys.includes(String(key))) return null;
}
return [...initializer.projection.slice(0, offset), key, ...remaining];
};
@@ -0,0 +1,151 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
['const h={require:module.require};h.require("./shared.cjs");', true],
[
'const h={require:module.require};h.require.call(module,"./shared.cjs");',
true,
],
[
'const h={require:module.require};h.require.apply(module,["./shared.cjs"]);',
true,
],
[
'const h={inner:{require:module.require}};h.inner.require("./shared.cjs");',
true,
],
['const h={require(){}};h.require("./shared.cjs");', false],
['const h={require(){}};h.require.call(module,"./shared.cjs");', false],
[
'const h={require:module.require};h.require=()=>{};h.require("./shared.cjs");',
false,
],
[
'const h={require(){}};h.require=module.require;h.require("./shared.cjs");',
true,
],
])(
"follows the actual named delegate as well as its receiver: %s",
(source, expected) => {
expect(analyzeJavaScriptStaticSource(source).references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
['(0,module).require("./shared.cjs");', true],
['(module,{}).require("./shared.cjs");', false],
['const get=()=>module;(0,get()).require("./shared.cjs");', true],
['const loader=(0,module);loader.require("./shared.cjs");', true],
['const load=(0,module.require);load("./shared.cjs");', true],
['const load=(module.require,()=>{});load("./shared.cjs");', false],
])(
"selects only the final sequence value as a loader: %s",
(source, expected) => {
const result = analyzeJavaScriptStaticSource(source);
expect(result.parse_error_count).toBe(0);
expect(result.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
[
'const old={loader:module};const middle={...old};old.loader={};const box={...middle};middle.loader={};box.loader.require("./x");',
true,
],
[
'const old={loader:{}};const middle={...old};old.loader=module;const box={...middle};middle.loader=module;box.loader.require("./x");',
false,
],
[
'const old={loader:module};const middle={...old};old.loader={};const {...box}=middle;middle.loader={};box.loader.require("./x");',
true,
],
[
'const old={loader:{}};const middle={...old};old.loader=module;const {...box}=middle;middle.loader=module;box.loader.require("./x");',
false,
],
[
'const inner={loader:{}};const box={inner};inner.loader=module;box.inner.loader.require("./x");',
true,
],
[
'const inner={loader:module};const box={inner};inner.loader={};box.inner.loader.require("./x");',
false,
],
[
'let inner={loader:module};const box={inner};inner={loader:{}};inner.loader={};box.inner.loader.require("./x");',
true,
],
[
'let inner={loader:{}};const box={inner};inner={loader:{}};inner.loader=module;box.inner.loader.require("./x");',
false,
],
[
'const old={loader:module};const box={...old};old.loader={};box.loader.require("./x");',
true,
],
[
'const old={loader:{}};const box={...old};old.loader=module;box.loader.require("./x");',
false,
],
[
'const old={child:{loader:module}};const box={...old};old.child={loader:{}};box.child.loader.require("./x");',
true,
],
[
'const old={child:{loader:module}};const box={...old};old.child.loader={};box.child.loader.require("./x");',
false,
],
[
'const old={child:{loader:{}}};const box={...old};old.child.loader=module;box.child.loader.require("./x");',
true,
],
[
'const old={child:{loader:{}}};const box={...old};old.child={loader:module};old.child.loader=module;box.child.loader.require("./x");',
false,
],
[
'const old=[module];const box=[...old];old[0]={};box[0].require("./x");',
true,
],
[
'const old=[{}];const box=[...old];old[0]=module;box[0].require("./x");',
false,
],
[
'const old={loader:module};const {...box}=old;old.loader={};box.loader.require("./x");',
true,
],
[
'const old={loader:{}};const {...box}=old;old.loader=module;box.loader.require("./x");',
false,
],
[
'const old=[module];const [...box]=old;old[0]={};box[0].require("./x");',
true,
],
[
'const old=[{}];const [...box]=old;old[0]=module;box[0].require("./x");',
false,
],
])(
"keeps module receiver capture and later member selection separate: %s",
(source, expected) => {
const result = analyzeJavaScriptStaticSource(source);
expect(
result.references
.filter(({ kind }) => kind === "require")
.map(({ specifier }) => specifier),
).toEqual(expected ? [null] : []);
},
);
File diff suppressed because it is too large. Load diff
@@ -9,6 +9,8 @@ export const SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT = 256;
export const SEMANTIC_EXPRESSION_DEPTH_LIMIT = 256;
/** Getter jobs and local result work retained in one precise query. */
export const SEMANTIC_GETTER_EXPANSION_LIMIT = 512;
/** Additional selector states retained by one precise alias query. */
export const SEMANTIC_ALIAS_SELECTION_LIMIT = 512;
/** Bound normalized strings and transient keys independently of transport limits. */
export const SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT = 1_048_576;
@@ -21,6 +23,8 @@ const RESOURCE_LIMIT_REASON: Record<JavaScriptSemanticResourceLimit, string> = {
"Semantic expression depth budget exceeded (maximum 256 nested expressions).",
"getter-expansion":
"Getter expansion was conservatively bounded (maximum 512 scheduled states or a growing receiver cycle).",
"alias-selections":
"Alias selection expansion was conservatively bounded (maximum 512 additional selector states); selected paths remain unknown.",
};
/** Check UTF-16 length against worst-case JSON escaping without flattening. */
@@ -0,0 +1,81 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
it.each([
["response.json();", "/original"],
["response=other;response.json();", null],
["response.json();response=other;", "/original"],
["if(flag)response=other;response.json();", null],
["({response}={response:other});response.json();", null],
["response.json();({response}={response:other});", "/original"],
["if(flag)({response}={response:other});response.json();", null],
["response.json();if(flag)({response}={response:other});", "/original"],
["[response]=[other];response.json();", null],
["response.json();[response]=[other];", "/original"],
["if(flag)[response]=[other];response.json();", null],
["response.json();if(flag)[response]=[other];", "/original"],
])(
"retains request endpoints only before response replacement: %s",
(body, endpoint) => {
const result = analyzeJavaScriptSemantics(
`let response=await fetch('/original');${body}`,
);
const request = result.requestOperations.find(
(operation) => operation.kind === "request",
);
expect(request?.endpoint).toBe("/original");
const consumers = result.requestOperations.filter(
(operation) => operation.kind === "response-consumer",
);
expect(consumers).toHaveLength(1);
expect(consumers[0]).toMatchObject({ endpoint, resolution: "complete" });
expect(consumers[0]?.linkedRequestIds).toEqual([request?.requestId]);
},
);
it("does not prove an incoming response was initialized by a conditional request", () => {
const result = analyzeJavaScriptSemantics(
"let response;if(flag)response=await fetch('/original');response.json();",
);
expect(
result.requestOperations.filter(
(operation) => operation.kind === "response-consumer",
),
).toEqual([
expect.objectContaining({ endpoint: null, resolution: "complete" }),
]);
});
it.each([
"let response=flag?await fetch('/original'):other;response.json();",
"let response=other||(await fetch('/original'));response.json();",
])(
"does not invent a response link through conditional value combinations: %s",
(source) => {
const result = analyzeJavaScriptSemantics(source);
expect(
result.requestOperations.filter(
(operation) => operation.kind === "request",
),
).toEqual([expect.objectContaining({ endpoint: "/original" })]);
expect(
result.requestOperations.filter(
(operation) => operation.kind === "response-consumer",
),
).toEqual([]);
},
);
it("keeps the existing contract for a response linked to multiple requests", () => {
const result = analyzeJavaScriptSemantics(
"let response=await fetch('/original');response=await fetch('/other');response.json();",
);
expect(
result.requestOperations.filter(
(operation) => operation.kind === "response-consumer",
),
).toEqual([
expect.objectContaining({ endpoint: null, resolution: "partial" }),
]);
});
@@ -0,0 +1,110 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js";
it.each([
[
"let box;const old={loader:module};({...box}=old);old.loader={};box.loader",
true,
],
[
"let box;const old={loader:{}};({...box}=old);old.loader=module;box.loader",
false,
],
[
"const holder={};const old={loader:module};({...holder.box}=old);old.loader={};holder.box.loader",
true,
],
[
"const holder={};const old={loader:{}};({...holder.box}=old);old.loader=module;holder.box.loader",
false,
],
["let box;const old=[module];([...box]=old);old[0]={};box[0]", true],
["let box;const old=[{}];([...box]=old);old[0]=module;box[0]", false],
[
"const holder={};const old=[module];([...holder.box]=old);old[0]={};holder.box[0]",
true,
],
[
"const holder={};const old=[{}];([...holder.box]=old);old[0]=module;holder.box[0]",
false,
],
[
"let box;const old={make:()=>module};({...box}=old);old.make=()=>({});box.make()",
true,
],
[
"let box;const old={make:()=>({})};({...box}=old);old.make=()=>module;box.make()",
false,
],
[
"const holder={};const old={make:()=>module};({...holder.box}=old);old.make=()=>({});holder.box.make()",
true,
],
[
"const holder={};const old={make:()=>({})};({...holder.box}=old);old.make=()=>module;holder.box.make()",
false,
],
[
"let box;const old=[()=>module];([...box]=old);old[0]=()=>({});box[0]()",
true,
],
[
"let box;const old=[()=>({})];([...box]=old);old[0]=()=>module;box[0]()",
false,
],
[
"const holder={};const old=[()=>module];([...holder.box]=old);old[0]=()=>({});holder.box[0]()",
true,
],
[
"const holder={};const old=[()=>({})];([...holder.box]=old);old[0]=()=>module;holder.box[0]()",
false,
],
[
"let box;const old={child:{loader:module}};({child:{...box}}=old);old.child={loader:{}};box.loader",
true,
],
[
"const holder={};const old={child:[module]};({child:[...holder.box]}=old);old.child=[{}];holder.box[0]",
true,
],
[
"const holder={};const old={child:{loader:module}};({...holder.box}=old);old.child.loader={};holder.box.child.loader",
false,
],
["let make=()=>module;({make}={make:()=>({})});make()", false],
["let make;({make=()=>module}={});make()", true],
["let make;({make=()=>module}={make:()=>({})});make()", false],
["let make=()=>module;if(flag)({make}={make:()=>({})});make()", true],
[
"let make=()=>module;const saved=make;({make}={make:()=>({})});saved()",
true,
],
])(
"retains assigned rest-copy slots at their capture: %s",
(receiver, expected) => {
const result = analyzeJavaScriptStaticSource(
`${receiver}.require('./shared.cjs');`,
);
expect(result.parse_error_count).toBe(0);
expect(result.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each(["[value]=['new']", "({key:value}={key:'new'})"])(
"preserves whole-binding and read-value ambiguity after %s",
(assignment) => {
const result = analyzeJavaScriptSemantics(
`let value='old';${assignment};const observed=value;`,
);
expect(topLevelBinding(result, "value").value.status).toBe("ambiguous");
expect(topLevelBinding(result, "observed").value.status).toBe("ambiguous");
},
);
@@ -5,14 +5,43 @@ import {
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import {
semanticArrayIndex,
javaScriptSourceInvocationContext,
javaScriptGenericInvocationMethod,
resolveJavaScriptGenericInvocation,
type JavaScriptSourceInvocationContext,
} from "./javascriptIntrinsicInvocation.js";
import {
semanticBindingAtRead,
semanticEffectTiming,
} from "./javascriptSemanticMutationInitializers.js";
import {
semanticReferenceSourcesInactive,
semanticKnownArrayIteration,
semanticKnownArrayIterationAt,
semanticSelectedReferenceSlot,
} from "./javascriptSemanticReferenceSources.js";
import { semanticArrayPropertyReferences } from "./javascriptSemanticObjectReferences.js";
import {
semanticNormalizeCapturedSelections,
semanticProjectedSourcesAtRead,
semanticShiftCapturedSelections,
type SemanticCapturedSelection,
} from "./javascriptSemanticProjectedSources.js";
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
import {
semanticPropertyPathKeyMatches,
type JavaScriptSemanticPropertyPath,
} from "./javascriptSemanticPropertyPaths.js";
import {
SemanticAliasSelections,
semanticAliasSelectionChoices,
type SemanticAliasSelectionPath,
} from "./javascriptSemanticAliasSelections.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
/** A reference potentially handed to a caller or generator consumer. */
@@ -24,34 +53,23 @@ export interface SemanticReturnedReference {
interface Candidate {
readonly node: t.Node;
/** `undefined` means any projection depth after different projection depths merge. */
readonly path: readonly (string | number | null)[] | undefined;
readonly path: SemanticAliasSelectionPath | undefined;
/** Member selection happens at its use, while a captured function keeps its read. */
readonly readAt?: t.Node;
readonly capturedSelections?:
| readonly SemanticCapturedSelection[]
| undefined;
readonly invocation?: JavaScriptSourceInvocationContext;
}
export interface SemanticReturnedReferenceResult {
readonly references: readonly SemanticReturnedReference[];
/** A visible invocation-method mutation prevents a source-local result proof. */
readonly unresolvedInvocation?: boolean;
/** Both states close the supported source-local may-target graph. Widened joins projections without dropping represented targets; it does not claim runtime completeness. */
readonly precision: "exact" | "widened";
}
const joinProjection = (
left: Candidate["path"],
right: Candidate["path"],
): Candidate["path"] => {
if (left === undefined || right === undefined || left.length !== right.length)
return undefined;
return left.map((key, index) => (key === right[index] ? key : null));
};
const sameProjection = (
left: Candidate["path"],
right: Candidate["path"],
): boolean =>
left === undefined
? right === undefined
: right !== undefined &&
left.length === right.length &&
left.every((key, index) => key === right[index]);
interface ObjectTargetIndex {
readonly propertyByName: ReadonlyMap<
string,
@@ -71,49 +89,301 @@ interface ObjectTargetUnknown {
/** Resolve local result references conservatively, without claiming call execution. */
export const createSemanticReturnedReferences = (
state: JavaScriptSemanticAnalysisState,
): ((callee: t.Node) => SemanticReturnedReferenceResult) => {
const results = new WeakMap<t.Node, SemanticReturnedReferenceResult>();
consumption:
| "potential-reference"
| "direct-call"
| "awaited-call" = "potential-reference",
): ((
callee: t.Node,
invocation?: t.CallExpression | t.OptionalCallExpression,
) => SemanticReturnedReferenceResult) => {
const results = new WeakMap<
t.Node,
{
readonly result: SemanticReturnedReferenceResult;
readonly invocation: t.Node | undefined;
readonly iterationUnknown: boolean;
readonly ambientWrites: number;
readonly dependencies: readonly {
readonly binding: JavaScriptSemanticBindingState;
readonly writes: number;
readonly escapes: number;
}[];
}
>();
const objectTargets = new WeakMap<t.ObjectExpression, ObjectTargetIndex>();
return (callee) => {
return (callee, invocation) => {
const existing = results.get(callee);
if (existing !== undefined) return existing;
if (
existing !== undefined &&
existing.invocation === invocation &&
existing.iterationUnknown === state.arrayIterationUnknown &&
existing.ambientWrites === state.ambientWriteEffects.length &&
existing.dependencies.every(
({ binding, writes, escapes }) =>
binding.memberWrites.length === writes &&
binding.escapedPaths.length === escapes,
)
)
return existing.result;
const dependencies = new Map<
JavaScriptSemanticBindingState,
{ readonly writes: number; readonly escapes: number }
>();
const functions = new Set<t.Function>();
const work: t.Node[] = [];
const work: Candidate[] = [];
// A transition depends on the source read and current projection, not the
// initializer history used to reach it. Keeping history out of the key lets
// shared diamonds and reassignment cycles converge to the graph fixed point.
const projections = new WeakMap<t.Node, Candidate["path"]>();
const queued = new WeakSet<t.Node>();
const projectionDepths = new WeakMap<
t.Node,
Map<string, number | undefined>
>();
const expanded = new WeakMap<t.Node, Set<string>>();
let additionalContexts = 0;
let contextsWidened = false;
const result: {
references: readonly SemanticReturnedReference[];
precision: SemanticReturnedReferenceResult["precision"];
unresolvedInvocation?: boolean;
} = { references: [], precision: "exact" };
const enqueue = (node: t.Node, path: Candidate["path"]): void => {
const source = unwrapJavaScriptExpression(node).node;
if (!projections.has(source)) {
projections.set(source, path);
} else {
const previous = projections.get(source);
const joined = joinProjection(previous, path);
if (sameProjection(previous, joined)) return;
projections.set(source, joined);
result.precision = "widened";
}
if (!queued.has(source)) {
work.push(source);
queued.add(source);
}
// The invocation owner retains the actual callable receiver. A bare alias
// of fn.call has lost that receiver and cannot execute fn by itself.
const nativeArrayIteration = (array: t.ArrayExpression) =>
semanticKnownArrayIterationAt(array, state);
const sourceInvocation =
invocation === undefined
? undefined
: javaScriptSourceInvocationContext(invocation, nativeArrayIteration);
const sourceGenericInvocation =
invocation !== undefined &&
javaScriptGenericInvocationMethod(invocation.callee) !== undefined;
const inheritedInvocationChanged = (method: "call" | "apply"): boolean => {
if (invocation === undefined) return false;
const inheritedPath = ["global", "Function", "prototype", method];
return state.ambientWriteEffects.some(
(effect) =>
effect.path.length <= inheritedPath.length &&
effect.path.every(
(key, index) =>
key === null ||
semanticPropertyPathKeyMatches(key, inheritedPath[index] ?? ""),
) &&
semanticEffectTiming(invocation, effect.node, state) !== "after",
);
};
enqueue(callee, []);
// Exact finite alternatives retain unrelated keys and cross-path
// correlations. Growing/shrinking cycles still converge at any depth;
// selector-budget widening retains the complete may-target closure.
const selections = new SemanticAliasSelections(() => {
state.valueResourceLimits.add("alias-selections");
result.precision = "widened";
});
const enqueue = (
node: t.Node,
path: Candidate["path"],
readAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
invocationContext?: JavaScriptSourceInvocationContext,
): void => {
const source = unwrapJavaScriptExpression(node).node;
if (contextsWidened) {
path = undefined;
capturedSelections = undefined;
invocationContext = undefined;
}
const invocationKey = () =>
invocationContext === undefined
? null
: [
invocationContext.receiver?.start,
invocationContext.receiver?.end,
invocationContext.receiverUnknown,
invocationContext.arguments?.map((argument) => [
argument.start,
argument.end,
]),
];
let readKey = JSON.stringify([
readAt?.start ?? -1,
readAt?.end ?? -1,
invocationKey(),
]);
const depths = projectionDepths.get(source) ?? new Map();
projectionDepths.set(source, depths);
if (!depths.has(readKey)) {
depths.set(readKey, path?.length);
} else {
const previous = depths.get(readKey);
if (previous === undefined || previous !== path?.length) {
if (previous !== undefined) result.precision = "widened";
depths.set(readKey, undefined);
path = undefined;
}
}
if (path !== undefined) path = selections.normalize(source, path);
capturedSelections =
path === undefined
? undefined
: semanticNormalizeCapturedSelections(
capturedSelections,
path.length,
);
const expansionKey = () =>
JSON.stringify([
readKey,
path ?? null,
capturedSelections?.map(({ at, depth, ownEnumerable }) => [
at.start,
at.end,
depth,
ownEnumerable,
]),
]);
let key = expansionKey();
const paths = expanded.get(source) ?? new Set<string>();
if (paths.has(key)) return;
// Copy/invocation contexts add selector dimensions. Bound them by the
// same query budget, then visit the complete unknown-projection closure.
if (
!contextsWidened &&
(capturedSelections !== undefined || invocationContext !== undefined) &&
paths.size > 0 &&
++additionalContexts > SEMANTIC_ALIAS_SELECTION_LIMIT
) {
contextsWidened = true;
state.valueResourceLimits.add("alias-selections");
result.precision = "widened";
// Generic transitions select only the source receiver and authored
// argument values. Keep that complete finite source closure when their
// precise positional contexts merge, including later callable receivers.
if (sourceInvocation !== undefined) {
if (sourceInvocation.receiver !== undefined)
enqueue(sourceInvocation.receiver, undefined);
for (const argument of sourceInvocation.arguments ?? [])
if (t.isExpression(argument)) enqueue(argument, undefined);
else if (t.isSpreadElement(argument))
enqueue(argument.argument, undefined);
}
}
if (contextsWidened) {
path = undefined;
capturedSelections = undefined;
invocationContext = undefined;
readKey = JSON.stringify([
readAt?.start ?? -1,
readAt?.end ?? -1,
null,
]);
if (sourceGenericInvocation) result.unresolvedInvocation = true;
depths.set(readKey, undefined);
key = expansionKey();
if (paths.has(key)) return;
}
paths.add(key);
expanded.set(source, paths);
work.push({
node: source,
path,
...(readAt === undefined ? {} : { readAt }),
...(capturedSelections === undefined ? {} : { capturedSelections }),
...(invocationContext === undefined
? {}
: { invocation: invocationContext }),
});
};
enqueue(callee, [], undefined, undefined, sourceInvocation);
while (work.length > 0) {
const node = work.pop();
if (node === undefined) break;
queued.delete(node);
const current: Candidate = { node, path: projections.get(node) };
const current = work.pop();
if (current === undefined) break;
const { node } = current;
// An exact empty selection with an invocation is the value being called,
// not a container whose callable children are potential escape targets.
// Ordinary objects and arrays cannot supply that callable receiver. An
// unknown projection still visits the full closure after budget widening.
if (
current.invocation !== undefined &&
current.path?.length === 0 &&
(t.isObjectExpression(node) || t.isArrayExpression(node))
)
continue;
const pending: Candidate[] = [];
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined) continue;
if (binding === undefined) {
if (sourceGenericInvocation && current.path?.length === 0)
result.unresolvedInvocation = true;
continue;
}
dependencies.set(binding, {
writes: binding.memberWrites.length,
escapes: binding.escapedPaths.length,
});
if (current.path !== undefined) {
if (
sourceGenericInvocation &&
current.path.length > 0 &&
semanticProjectedSourcesAtRead(
binding,
node,
current.path,
state,
true,
// The current invocation receives its callee before its own
// opaque effects; earlier captured member reads keep their site.
invocation !== undefined &&
current.readAt ===
unwrapJavaScriptExpression(invocation.callee).node
? invocation
: (current.readAt ?? node),
current.capturedSelections,
) === undefined
)
result.unresolvedInvocation = true;
const selectedSources = semanticProjectedSourcesAtRead(
binding,
node,
current.path,
state,
false,
current.readAt ?? node,
current.capturedSelections,
);
if (selectedSources !== undefined) {
for (const source of selectedSources) {
const capturedSelections = [
...(semanticShiftCapturedSelections(
current.capturedSelections,
source.path.length - current.path.length,
) ?? []),
...(source.capturedSelections ?? []),
];
pending.push({
...source,
...(current.path.length === 0
? {}
: { readAt: current.readAt ?? node }),
...(capturedSelections.length === 0
? {}
: { capturedSelections }),
});
}
for (let index = pending.length - 1; index >= 0; index--) {
const child = pending[index];
if (child !== undefined)
enqueue(
child.node,
child.path,
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
);
}
continue;
}
result.precision = "widened";
if (sourceGenericInvocation) result.unresolvedInvocation = true;
}
const selected = semanticBindingAtRead(
binding,
node,
@@ -123,14 +393,31 @@ export const createSemanticReturnedReferences = (
...selected.initializers,
...selected.referenceInitializers,
]) {
if (semanticReferenceSourcesInactive(initializer, state)) continue;
pending.push({
node: initializer.node,
path:
current.path === undefined
? undefined
: [...initializer.projection, ...current.path],
...(current.path?.length === 0
? {}
: { readAt: current.readAt ?? node }),
...(current.capturedSelections === undefined
? {}
: {
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
initializer.projection.length,
),
}),
});
}
if (current.path === undefined)
for (const effect of binding.memberWrites)
for (const source of effect.assignedSources ?? [])
if (!semanticReferenceSourcesInactive(source, state))
pending.push({ node: source.node, path: undefined });
} else if (
t.isMemberExpression(node) ||
t.isOptionalMemberExpression(node)
@@ -149,6 +436,15 @@ export const createSemanticReturnedReferences = (
pending.push({
...current,
node: receiver,
readAt: current.readAt ?? node,
...(current.capturedSelections === undefined
? {}
: {
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
path.length,
),
}),
path:
current.path === undefined
? undefined
@@ -171,19 +467,94 @@ export const createSemanticReturnedReferences = (
} else if (t.isSequenceExpression(node)) {
const last = node.expressions.at(-1);
if (last !== undefined) pending.push({ ...current, node: last });
} else if (
t.isFunction(node) &&
(current.path === undefined || current.path.length === 0)
)
functions.add(node);
else if (t.isObjectExpression(node))
} else if (t.isFunction(node)) {
if (current.path === undefined || current.path.length === 0)
functions.add(node);
else if (
current.invocation !== undefined &&
current.path.every((key) =>
semanticAliasSelectionChoices(key).every(
(choice) => choice === "call" || choice === "apply",
),
)
) {
// Own member writes have already selected their actual RHS above.
// Only a remaining inherited invocation member reaches this branch.
const methods = semanticAliasSelectionChoices(
current.path.at(-1)!,
).filter(
(method): method is "call" | "apply" =>
method === "call" || method === "apply",
);
for (const method of methods) {
const selected = resolveJavaScriptGenericInvocation(
method,
current.invocation,
nativeArrayIteration,
);
if (selected !== undefined)
pending.push({
node: selected.callable,
path: [],
invocation: selected.context,
});
else if (current.invocation.receiverUnknown)
result.unresolvedInvocation = true;
if (inheritedInvocationChanged(method))
result.unresolvedInvocation = true;
}
}
} else if (t.isObjectExpression(node))
selectObjectTargets(node, current, pending, objectTargets);
else if (t.isArrayExpression(node)) {
const [key, ...path] = current.path ?? [];
const index =
current.path === undefined || key === undefined || key === null
? null
: semanticArrayIndex(key);
if (current.path !== undefined && key !== undefined) {
for (const choice of semanticAliasSelectionChoices(key))
for (const source of semanticArrayPropertyReferences(node, [
choice,
])) {
let projection = source.path;
if (
source.capturedSelectionAt !== undefined &&
!semanticKnownArrayIteration(
semanticSelectedReferenceSlot(
{ node: source.node, projection: [] },
state,
source.capturedSelectionAt,
)?.value,
state,
)
)
projection = [null, ...source.path.slice(1)];
const selectedPath = [...projection, ...path];
pending.push({
...current,
node: source.node,
path: selectedPath,
capturedSelections: [
...(semanticShiftCapturedSelections(
current.capturedSelections,
selectedPath.length - current.path.length,
) ?? []),
...(source.capturedSelectionAt === undefined
? []
: [{ at: source.capturedSelectionAt, depth: 1 }]),
],
});
}
for (let index = pending.length - 1; index >= 0; index--) {
const child = pending[index];
if (child !== undefined)
enqueue(
child.node,
child.path,
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
);
}
continue;
}
let uncertainOffset = false;
node.elements.forEach((element, offset) => {
if (element === null) return;
@@ -193,12 +564,28 @@ export const createSemanticReturnedReferences = (
...current,
node: element.argument,
path: current.path === undefined ? undefined : [null, ...path],
capturedSelections: [
...(current.capturedSelections ?? []),
{ at: element.argument, depth: 1 },
],
});
} else if (index === null || uncertainOffset || index === offset)
} else if (
current.path === undefined ||
key === undefined ||
uncertainOffset ||
semanticAliasSelectionChoices(key).some((choice) =>
semanticPropertyPathKeyMatches(choice, String(offset)),
)
)
pending.push({
...current,
node: element,
path: current.path === undefined ? undefined : path,
capturedSelections:
semanticShiftCapturedSelections(
current.capturedSelections,
-1,
) ?? [],
});
});
} else if (t.isNewExpression(node))
@@ -212,14 +599,25 @@ export const createSemanticReturnedReferences = (
member.kind === "constructor") ||
current.path === undefined ||
(key !== undefined &&
(key === null ||
semanticStaticPropertyKey(member.key, member.computed) ===
String(key)))
semanticAliasSelectionChoices(key).some((choice) => {
const name = semanticStaticPropertyKey(
member.key,
member.computed,
);
return (
name === null ||
semanticPropertyPathKeyMatches(choice, name)
);
}))
)
pending.push({
...current,
node: member,
path: current.path === undefined ? undefined : path,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
} else if (
t.isClassProperty(member) &&
@@ -227,14 +625,26 @@ export const createSemanticReturnedReferences = (
member.value !== undefined &&
(key !== undefined || current.path === undefined) &&
(current.path === undefined ||
key === null ||
semanticStaticPropertyKey(member.key, member.computed) ===
String(key))
(key !== undefined &&
semanticAliasSelectionChoices(key).some((choice) => {
const name = semanticStaticPropertyKey(
member.key,
member.computed,
);
return (
name === null ||
semanticPropertyPathKeyMatches(choice, name)
);
})))
)
pending.push({
...current,
node: member.value,
path: current.path === undefined ? undefined : path,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
}
if (node.superClass !== null && node.superClass !== undefined)
@@ -242,17 +652,45 @@ export const createSemanticReturnedReferences = (
}
for (let index = pending.length - 1; index >= 0; index--) {
const child = pending[index];
if (child !== undefined) enqueue(child.node, child.path);
if (child !== undefined)
enqueue(
child.node,
child.path,
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
);
}
}
const output = [...functions].flatMap((callable) =>
semanticCallableResultExpressions(callable).flatMap(
({ node, delegated }) =>
node === null ? [] : [{ node, projection: delegated ? [null] : [] }],
),
);
const output = [...functions].flatMap((callable) => {
// A direct generator/async call produces a fresh iterator/Promise, not
// the yielded/resolved contents. Escape analysis still retains those
// potential references for their eventual consumers.
if (
consumption !== "potential-reference" &&
(callable.generator ||
(consumption === "direct-call" && callable.async))
)
return [];
return semanticCallableResultExpressions(callable).flatMap(
({ node, delegated, kind }) =>
node === null ||
(consumption !== "potential-reference" && kind !== "return")
? []
: [{ node, projection: delegated ? [null] : [] }],
);
});
result.references = output;
results.set(callee, result);
results.set(callee, {
result,
invocation,
iterationUnknown: state.arrayIterationUnknown,
ambientWrites: state.ambientWriteEffects.length,
dependencies: [...dependencies].map(([binding, counts]) => ({
binding,
...counts,
})),
});
if (result.precision === "widened")
state.returnedReferenceWidenings.add(callee);
return result;
@@ -267,7 +705,17 @@ const selectObjectTargets = (
): void => {
const [key, ...path] = item.path ?? [];
const childPath = item.path === undefined ? undefined : path;
if (key !== null && key !== undefined) {
if (key !== null && typeof key === "object" && "alternatives" in key) {
for (const choice of semanticAliasSelectionChoices(key))
selectObjectTargets(
node,
{ ...item, path: [choice, ...path] },
pending,
indexes,
);
return;
}
if (key !== null && key !== undefined && typeof key !== "object") {
const index = objectTargetIndex(node, indexes);
const property = index.propertyByName.get(String(key));
if (property !== undefined) {
@@ -287,11 +735,20 @@ const selectObjectTargets = (
? unknown.value
: unknown,
...(t.isSpreadElement(unknown) ? {} : { path: childPath }),
capturedSelections: t.isSpreadElement(unknown)
? [
...(item.capturedSelections ?? []),
{ at: unknown.argument, depth: 1, ownEnumerable: true },
]
: (semanticShiftCapturedSelections(item.capturedSelections, -1) ??
[]),
});
pending.push({
...item,
node: t.isObjectProperty(property) ? property.value : property,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
return;
}
@@ -300,7 +757,14 @@ const selectObjectTargets = (
for (const property of [...node.properties].reverse()) {
if (replaced) break;
if (t.isSpreadElement(property)) {
pending.push({ ...item, node: property.argument });
pending.push({
...item,
node: property.argument,
capturedSelections: [
...(item.capturedSelections ?? []),
{ at: property.argument, depth: 1, ownEnumerable: true },
],
});
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
@@ -308,13 +772,31 @@ const selectObjectTargets = (
key !== null &&
key !== undefined &&
name !== null &&
name !== String(key)
!semanticPropertyPathKeyMatches(key, name)
)
continue;
if (t.isObjectProperty(property))
pending.push({ ...item, node: property.value, path: childPath });
else pending.push({ ...item, node: property, path: childPath });
if (key !== null && key !== undefined && name === String(key))
pending.push({
...item,
node: property.value,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
else
pending.push({
...item,
node: property,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
if (
key !== null &&
key !== undefined &&
typeof key !== "object" &&
name === String(key)
)
replaced = true;
}
};
@@ -123,3 +123,35 @@ it("bounds returned-reference expansion across a React-shaped switch mesh", () =
expect(small.reads).toBeGreaterThan(0);
expect(large.reads).toBeLessThan(4 * small.reads);
});
it.each([
["{make:()=>module}", "{...box}", "box.make"],
["[()=>module]", "[...box]", "box[0]"],
])(
"converges repeated copy boundaries without dropping callable results: %s",
(initial, replacement, target) => {
const state = indexed(
`let box=${initial};while(flag)box=${replacement};${target}();`,
);
let callee: t.Node | undefined;
t.traverseFast(state.program, (node) => {
if (t.isCallExpression(node)) callee = node.callee;
});
if (callee === undefined) throw new Error("Missing copied call");
const lookups = state.scopes.map((scope) =>
vi.spyOn(scope.bindings, "get"),
);
try {
const result = createSemanticReturnedReferences(state)(callee);
expect(result.references).toContainEqual({
node: expect.objectContaining({ type: "Identifier", name: "module" }),
projection: [],
});
expect(
lookups.reduce((count, lookup) => count + lookup.mock.calls.length, 0),
).toBeLessThan(100);
} finally {
for (const lookup of lookups) lookup.mockRestore();
}
},
);
@@ -0,0 +1,83 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
[
"const old={child:{make:()=>module}};const box={...old};old.child={make:()=>({})};box.child.make()",
true,
],
[
"const old={child:{make:()=>module}};const box={...old};old.child.make=()=>({});box.child.make()",
false,
],
[
"const old={child:{make:()=>({})}};const box={...old};old.child.make=()=>module;box.child.make()",
true,
],
[
"const old={child:{make:()=>({})}};const box={...old};old.child={make:()=>module};old.child.make=()=>module;box.child.make()",
false,
],
[
"const old={child:{make:()=>module}};const {...box}=old;old.child={make:()=>({})};box.child.make()",
true,
],
[
"const old={child:{make:()=>module}};const {...box}=old;old.child.make=()=>({});box.child.make()",
false,
],
[
"let old={child:{make:()=>module}};const {...box}=old;old={child:{make:()=>({})}};box.child.make()",
true,
],
[
"const old=[{make:()=>module}];const box=[...old];old[0]={make:()=>({})};box[0].make()",
true,
],
[
"const old=[{make:()=>module}];const box=[...old];old[0].make=()=>({});box[0].make()",
false,
],
[
"const old=[{make:()=>module}];const [...box]=old;old[0]={make:()=>({})};box[0].make()",
true,
],
[
"const old=[{}, {make:()=>module}];const [skip,...box]=old;old[1]={make:()=>({})};box[0].make()",
true,
],
[
"const old=[{}, {make:()=>module}];const [skip,...box]=old;old[1].make=()=>({});box[0].make()",
false,
],
[
"const old={make:()=>module};const middle={...old};old.make=()=>({});const box={...middle};middle.make=()=>({});box.make()",
true,
],
[
"const old={make:()=>({})};const middle={...old};old.make=()=>module;const box={...middle};middle.make=()=>module;box.make()",
false,
],
[
"const old={child:{make:()=>module}};const middle={...old};old.child={make:()=>({})};const box={...middle};box.child.make()",
true,
],
[
"const old={make:()=>module};const middle={...old};old.make=()=>({});const {...box}=middle;box.make()",
true,
],
])(
"preserves copied child identity and later writes through it: %s",
(receiver, expected) => {
const result = analyzeJavaScriptStaticSource(
`${receiver}.require('./shared.cjs');`,
);
expect(result.parse_error_count).toBe(0);
expect(result.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -0,0 +1,203 @@
import * as t from "@babel/types";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
it.each([
[
'const box={make(){return {};}};box.make=()=>module;box.make().require("./shared.cjs");',
true,
],
[
'const box={make(){return module;}};box.make=()=>({});box.make().require("./shared.cjs");',
false,
],
[
'const box={make(){return {};}};if(flag)box.make=()=>module;box.make().require("./shared.cjs");',
true,
],
[
'const box={make(){return module;}};if(flag)box.make=()=>({});box.make().require("./shared.cjs");',
true,
],
[
'const box={make(){return {};}};box.make().require("./shared.cjs");box.make=()=>module;',
false,
],
[
'const box={make(){return {};}};const alias=box;box.make=()=>module;alias.make().require("./shared.cjs");',
true,
],
[
'const box={make(){return module;}};const alias=box;box.make=()=>({});alias.make().require("./shared.cjs");',
false,
],
[
'const box={make(){return {};}};const make=box.make;box.make=()=>module;make().require("./shared.cjs");',
false,
],
[
'const box={make(){return module;}};const make=box.make;box.make=()=>({});make().require("./shared.cjs");',
true,
],
[
'const box={inner:{make(){return {};}}};box.inner.make=()=>module;box.inner.make().require("./shared.cjs");',
true,
],
[
'const box={inner:{make(){return module;}}};box.inner={make(){return {};}};box.inner.make().require("./shared.cjs");',
false,
],
[
'const box={};({make:box.make}={make:()=>module});box.make().require("./shared.cjs");',
true,
],
[
'const box=[()=>({})];box[0]=()=>module;box[0]().require("./shared.cjs");',
true,
],
[
'const box={make(){return {};}};box.other=()=>module;box.make().require("./shared.cjs");',
false,
],
[
'const box={make(){return {};}};box.make=async()=>module;box.make().require("./shared.cjs");',
false,
],
[
'const box={make(){return {};}};box.make=async()=>module;(await box.make()).require("./shared.cjs");',
true,
],
[
'const box={make(){return {};}};box.make=function*(){return module;};(await box.make()).require("./shared.cjs");',
false,
],
] as const)(
"selects returned references at the member read: %s",
(source, expected) => {
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
["let make=()=>module;const box={make};make=()=>({});box.make()", true],
["let make=()=>({});const box={make};make=()=>module;box.make()", false],
["let make=()=>module;const box={make:make};make=()=>({});box.make()", true],
["let make=()=>module;const box=[make];make=()=>({});box[0]()", true],
["let make=()=>({});const box=[make];make=()=>module;box[0]()", false],
[
"let make=()=>module;const box={make};const saved=box.make;make=()=>({});saved()",
true,
],
[
"let make=()=>module;const box={};box.make=make;make=()=>({});box.make()",
true,
],
[
"let make=()=>({});const box={};box.make=make;make=()=>module;box.make()",
false,
],
[
"let make=()=>module;const box={};[box.make]=[make];make=()=>({});box.make()",
true,
],
[
"let make=()=>module;const old={make};const box={...old};make=()=>({});box.make()",
true,
],
[
"const old={make:()=>module};const box={...old};old.make=()=>({});box.make()",
true,
],
[
"const old={make:()=>({})};const box={...old};old.make=()=>module;box.make()",
false,
],
[
"const old={make:()=>module};const {...box}=old;old.make=()=>({});box.make()",
true,
],
[
"const old={make:()=>({})};const {...box}=old;old.make=()=>module;box.make()",
false,
],
["const old=[()=>module];const [...box]=old;old[0]=()=>({});box[0]()", true],
["const old=[()=>({})];const [...box]=old;old[0]=()=>module;box[0]()", false],
[
"let make=()=>module;const {make:saved=make}={};make=()=>({});saved()",
true,
],
[
"let inner={make:()=>module};const box={inner};inner={make:()=>({})};inner.make=()=>({});box.inner.make()",
true,
],
[
"let inner={make:()=>({})};const box={inner};inner={make:()=>module};inner.make=()=>module;box.inner.make()",
false,
],
[
"let inner={make:()=>({})};const box={inner};inner.make=()=>module;box.inner.make()",
true,
],
[
"let inner={make:()=>({})};const saved=inner;const box={inner};inner={make:()=>({})};saved.make=()=>module;box.inner.make()",
true,
],
] as const)(
"retains captured values independently of later member reads: %s",
(expression, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${expression}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it("refreshes cached returned targets after the shared member index settles", () => {
const parsed = parseJavaScriptSource(
"const box={make(){return {};}};box.make=()=>module;box.make();",
"member-writes.cjs",
);
if (parsed === null) throw new Error("Expected parsed member writes");
const steps = indexJavaScriptBindingsWithReceiversSteps(parsed.program);
let step = steps.next();
while (!step.done) step = steps.next();
const index = step.value;
let callee: t.Node | undefined;
traverseJavaScriptAst(parsed.program, {
enter(node) {
if (t.isCallExpression(node)) callee = node.callee;
},
});
if (callee === undefined) throw new Error("Expected member call");
const returned = createSemanticReturnedReferences(
index.bindings,
"direct-call",
);
expect(
returned(callee).references.some(({ node }) => t.isIdentifier(node)),
).toBe(false);
const effects = index.indexMemberMutationFactsSteps();
let effect = effects.next();
while (!effect.done) effect = effects.next();
expect(returned(callee).references).toEqual([
expect.objectContaining({
node: expect.objectContaining({ name: "module" }),
}),
]);
});
@@ -0,0 +1,223 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import * as t from "@babel/types";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js";
import { semanticProjectedSourcesAtRead } from "./javascriptSemanticProjectedSources.js";
const shapes = [
{
before: "const old={child:{inner:INITIAL}};const box={...old};",
shared: "old.child={inner:box.child.inner};",
fresh: "old.child={inner:INITIAL};",
write: "old.child.inner.SLOT=NEXT;",
read: "box.child.inner.READ",
},
{
before: "const old={child:{inner:INITIAL}};const {...box}=old;",
shared: "old.child={inner:box.child.inner};",
fresh: "old.child={inner:INITIAL};",
write: "old.child.inner.SLOT=NEXT;",
read: "box.child.inner.READ",
},
{
before: "const old=[{inner:INITIAL}];const box=[...old];",
shared: "old[0]={inner:box[0].inner};",
fresh: "old[0]={inner:INITIAL};",
write: "old[0].inner.SLOT=NEXT;",
read: "box[0].inner.READ",
},
{
before: "const old=[{inner:INITIAL}];const [...box]=old;",
shared: "old[0]={inner:box[0].inner};",
fresh: "old[0]={inner:INITIAL};",
write: "old[0].inner.SLOT=NEXT;",
read: "box[0].inner.READ",
},
{
before: "const old={child:[INITIAL]};const box={...old};",
shared: "old.child=[box.child[0]];",
fresh: "old.child=[INITIAL];",
write: "old.child[0].SLOT=NEXT;",
read: "box.child[0].READ",
},
] as const;
const values = [
{
initial: "{make:()=>({})}",
next: "()=>module",
slot: "make",
read: "make().require('./shared.cjs')",
host: false,
},
{
initial: "{make:()=>module}",
next: "()=>({})",
slot: "make",
read: "make().require('./shared.cjs')",
host: true,
},
{
initial: "{loader:{}}",
next: "module",
slot: "loader",
read: "loader.require('./shared.cjs')",
host: false,
},
{
initial: "{loader:module}",
next: "{}",
slot: "loader",
read: "loader.require('./shared.cjs')",
host: true,
},
{
initial: "{load:()=>({})}",
next: "module.require",
slot: "load",
read: "load('./shared.cjs')",
host: false,
},
{
initial: "{load:module.require}",
next: "()=>({})",
slot: "load",
read: "load('./shared.cjs')",
host: true,
},
] as const;
it.each(
shapes.flatMap((shape) =>
values.flatMap((value) =>
[true, false].map((shared) => ({
source: (
shape.before +
(shared ? shape.shared : shape.fresh) +
shape.write +
shape.read +
";"
)
.replaceAll("INITIAL", value.initial)
.replaceAll("NEXT", value.next)
.replaceAll("SLOT", value.slot)
.replaceAll("READ", value.read),
expression: shape.read.replaceAll("READ", value.read),
// The public module/delegate owner retains unknown after the copy's child
// escapes into the new parent. Factory result selection can exclude it.
expected: shared ? !value.host || value.slot !== "make" : value.host,
})),
),
),
)(
"distinguishes shared and newly allocated descendant receivers: $source",
({ source, expression, expected }) => {
const result = analyzeJavaScriptStaticSource(source);
expect(result.parse_error_count).toBe(0);
expect(
result.references.filter(
(reference) => reference.expression === expression,
),
).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each(["module", "{}"])(
"selects the later write to a shared projected receiver: %s",
(next) => {
const parsed = parseJavaScriptSource(
`const old={child:{inner:{loader:module}}};const box={...old};old.child={inner:box.child.inner};old.child.inner.loader=${next};box.child.inner.loader.require('./shared.cjs');`,
"shared-child.cjs",
);
if (parsed === null) throw new Error("Expected parsed fixture");
const steps = indexJavaScriptBindingsWithReceiversSteps(parsed.program);
let step = steps.next();
while (!step.done) step = steps.next();
const index = step.value;
const effects = index.indexMemberMutationFactsSteps();
let effect = effects.next();
while (!effect.done) effect = effects.next();
const binding = [...index.bindings.bindingsById.values()].find(
(candidate) => candidate.name === "old",
);
let capture: t.Node | undefined;
let assignment: t.AssignmentExpression | undefined;
t.traverseFast(parsed.program, (node) => {
if (t.isSpreadElement(node)) capture = node.argument;
if (t.isAssignmentExpression(node)) assignment = node;
});
if (
binding === undefined ||
capture === undefined ||
assignment === undefined
)
throw new Error("Missing selected receiver");
const statement = parsed.program.body.at(-1)!;
if (!t.isExpressionStatement(statement))
throw new Error("Missing receiver use");
const use = statement.expression;
expect(
semanticProjectedSourcesAtRead(
binding,
capture,
["child", "inner", "loader"],
index.bindings,
true,
use,
[{ at: capture, depth: 1 }],
),
).toEqual([{ node: assignment.right, path: [] }]);
},
);
it.each([
"old.child={inner:flag?box.child.inner:{make:()=>({})}};",
"old.child={get inner(){return box.child.inner}};",
"old.child={...box.child};",
])(
"retains a possible shared receiver when identity is incomplete: %s",
(replacement) => {
const expression = "box.child.inner.make().require('./shared.cjs')";
const result = analyzeJavaScriptStaticSource(
`const old={child:{inner:{make:()=>({})}}};const box={...old};${replacement}old.child.inner.make=()=>module;${expression};`,
);
expect(result.parse_error_count).toBe(0);
expect(result.references).toContainEqual(
expect.objectContaining({ kind: "require", specifier: null, expression }),
);
},
);
it.each([16, 600])(
"bounds descendant receiver alternatives before expanding %i selections",
(count) => {
const properties = Array.from(
{ length: count },
(_, index) => `p${index}:{make:()=>({})}`,
).join(",");
const expression = "box.child.inner.make().require('./shared.cjs')";
const result = analyzeJavaScriptStaticSource(
`const old={child:{inner:{make:()=>({})}}};const box={...old};old.child={${properties}};old.child[key].make=()=>module;${expression};`,
);
expect(result.parse_error_count).toBe(0);
const limited = result.limitations.some((limitation) =>
limitation.includes(
"Alias selection expansion was conservatively bounded",
),
);
expect(limited).toBe(count === 600);
const selected = result.references.filter(
(reference) => reference.expression === expression,
);
expect(selected).toEqual(
count === 600
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -1,9 +1,14 @@
import type { JavaScriptOpaqueScopeEffects } from "./javascriptSemanticOpaqueEffects.js";
import type { SemanticIterationSource } from "./javascriptSemanticIterationSources.js";
import * as t from "@babel/types";
import {
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import {
semanticBindingAtRead,
semanticEffectTiming,
} from "./javascriptSemanticMutationInitializers.js";
import type {
JavaScriptSemanticPropertyPath,
@@ -29,10 +34,16 @@ export interface JavaScriptSemanticInitializer {
readonly node: t.Node;
readonly projection: readonly (string | number | null)[];
readonly entryBody?: t.BlockStatement;
readonly iterationSources?: readonly {
readonly node: t.Node;
readonly projection: JavaScriptSemanticPropertyPath;
}[];
}
/** Additional references from destructuring copies and possible defaults. */
interface JavaScriptSemanticReferenceInitializer extends JavaScriptSemanticInitializer {
/** This source is a value replacement, rather than a declaration fallback. */
readonly assignment?: true;
readonly copyKind?: "object-rest" | "array-rest";
readonly copyProjectionOffset?: number;
readonly copyExcludedKeys?: readonly string[];
@@ -45,6 +56,12 @@ interface JavaScriptSemanticReferenceInitializer extends JavaScriptSemanticIniti
export interface JavaScriptSemanticMemberEffect {
readonly path: JavaScriptSemanticPropertyPath;
readonly node: t.Node | undefined;
/** Selected RHS values retained by the shared pattern-store owner. */
readonly assignedSources?: readonly SemanticIterationSource[];
/** Binding values whose receiver identities were selected for this effect. */
readonly receiverInitializers?: readonly JavaScriptSemanticInitializer[];
/** Capture of this binding used while following an aliased effect receiver. */
readonly receiverAt?: t.Node;
}
export type JavaScriptSemanticEffectTiming = "before" | "after" | "unknown";
@@ -101,6 +118,7 @@ export interface JavaScriptSemanticScopeState {
/** Shared state for semantic collection and evaluation. */
export interface JavaScriptSemanticAnalysisState {
arrayIterationUnknown: boolean;
readonly program: t.Program;
readonly opaqueEffects: JavaScriptOpaqueScopeEffects;
/** Effective loader for static imports in this source artifact. */
@@ -150,6 +168,8 @@ export interface JavaScriptSemanticAnalysisState {
readonly ambientWrites: SemanticPropertyPathCoverage;
/** Ambient object identities passed to calls whose effects are not evaluated. */
readonly ambientEscapes: SemanticPropertyPathCoverage;
/** Capture sites keep later opaque effects from changing earlier copies. */
readonly ambientEscapeEffects: JavaScriptSemanticMemberEffect[];
/** Source sites distinguish replacement before an ambient identity capture from later writes. */
readonly ambientWriteEffects: JavaScriptSemanticMemberEffect[];
}
@@ -537,7 +557,52 @@ export const isUnmodifiedCommonJsWrapperBinding = (
state: JavaScriptSemanticAnalysisState,
): boolean => {
const binding = commonJsWrapperBindingDeclaration(node, name, state);
return binding !== undefined && binding.initializers.length === 0;
return (
binding !== undefined &&
!semanticBindingHasPriorValueWrite(node, binding, state)
);
};
/** Retained value and pattern stores share one source-ordered write boundary. */
export const semanticBindingHasPriorValueWrite = (
site: t.Node,
binding: JavaScriptSemanticBindingState,
state: JavaScriptSemanticAnalysisState,
): boolean => {
if (binding.definitions.some(({ kind }) => kind === "function")) return true;
const writes = state.bindingWrites.get(binding.bindingId) ?? [];
const read = semanticBindingAtRead(binding, site, state.parentsByNode);
const retained =
read.status === "ordered"
? new Set([
...read.binding.initializers,
...read.binding.referenceInitializers,
])
: undefined;
const representedWrites = new Set<t.Node>();
const retainedWrites = new Set<t.Node>();
for (const source of [
...binding.initializers,
...binding.referenceInitializers,
]) {
let owner: t.Node | undefined = source.node;
while (owner !== undefined && !writes.includes(owner))
owner = state.parentsByNode.get(owner);
if (owner !== undefined) {
representedWrites.add(owner);
if (retained === undefined || retained.has(source))
retainedWrites.add(owner);
} else if (
(retained === undefined || retained.has(source)) &&
semanticEffectTiming(site, source.node, state) !== "after"
)
return true;
}
return writes.some(
(write) =>
(!representedWrites.has(write) || retainedWrites.has(write)) &&
semanticEffectTiming(site, write, state) !== "after",
);
};
/** Program `var` redeclarations retain wrapper identity until a source write. */
@@ -550,7 +615,9 @@ export const commonJsWrapperBindingDeclaration = (
// specifier resolution mode does not establish that distinction.
if (
state.program.sourceType !== "script" ||
!["module", "exports", "require"].includes(name) ||
!["module", "exports", "require", "__dirname", "__filename"].includes(
name,
) ||
!t.isIdentifier(node, { name })
)
return undefined;
@@ -6,7 +6,8 @@ export type JavaScriptSemanticResourceLimit =
| "primitive-candidates"
| "primitive-bytes"
| "expression-depth"
| "getter-expansion";
| "getter-expansion"
| "alias-selections";
type JavaScriptSemanticObjectValue = {
readonly status: "object";
@@ -63,6 +63,27 @@ import {
semanticResourceLimitReason,
} from "./javascriptSemanticResourceLimits.js";
// Function identity is outside the public constant lattice. Retain its
// non-undefined proof with the immutable value rather than interpreting an
// "unknown" reason or recovering callable syntax in default consumers.
const definitelyDefinedValues = new WeakSet<JavaScriptSemanticValue>();
const definedNonPrimitiveValue = (reason: string): JavaScriptSemanticValue => {
const value: JavaScriptSemanticValue = { status: "unknown", reason };
definitelyDefinedValues.add(value);
return value;
};
/** Definedness is independent of whether the constant lattice retains a value. */
export const semanticValueDefinitelyDefined = (
value: JavaScriptSemanticValue,
): boolean =>
value.status === "literal" ||
value.status === "union" ||
value.status === "object" ||
value.status === "array" ||
definitelyDefinedValues.has(value);
interface MemoizedEvaluation<T> {
readonly value: T;
readonly dependencies: SemanticDependencyProof;
@@ -433,6 +454,16 @@ const evaluateBinding = (
const reusable = readMemoizedEvaluation(retained, context);
if (reusable !== undefined) return reusable;
}
if (
binding.referenceInitializers.some(({ assignment }) => assignment) &&
(binding.initializers.length > 0 ||
(context.state.bindingsById.get(binding.bindingId)?.initializers.length ??
0) > 0)
)
return {
status: "ambiguous",
reason: `Binding ${binding.name} has value and projected-reference assignments.`,
};
if (binding.initializers.length === 0)
return {
status: "unknown",
@@ -615,6 +646,14 @@ const evaluateExpressionValue = (
}
const literal = readExactJavaScriptLiteral(node);
if (literal.found) return primitiveSet([literal.value]);
if (
t.isFunctionExpression(node) ||
t.isFunctionDeclaration(node) ||
t.isArrowFunctionExpression(node) ||
t.isClassExpression(node) ||
t.isClassDeclaration(node)
)
return definedNonPrimitiveValue(`Unsupported ${node.type} value.`);
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(context.state, node, node.name);
if (binding === undefined)
@@ -759,10 +798,14 @@ const evaluateObject = (
presence: "present",
value: t.isObjectProperty(property)
? evaluateCapturedExpression(property.value, nestedContext(context))
: {
status: "unknown",
reason: "Object method or accessor value is not a primitive.",
},
: property.kind === "method"
? definedNonPrimitiveValue(
"Object method or accessor value is not a primitive.",
)
: {
status: "unknown",
reason: "Object method or accessor value is not a primitive.",
},
});
}
const properties = [...propertiesByName.values()].sort((left, right) =>
@@ -1011,6 +1054,29 @@ const provenanceForBindingUncached = (
const provenanceForBindingValue = (
binding: JavaScriptSemanticBindingState,
context: EvaluationContext,
): JavaScriptBindingProvenance => {
const provenance = provenanceForBindingInitializers(binding, context);
if (!binding.referenceInitializers.some(({ assignment }) => assignment))
return provenance;
// Projected replacements are retained reference sources, not evidence of a
// local value or an unchanged earlier module. Apply this only to the selected
// read snapshot so a later replacement does not erase captured origins.
if (provenance.status === "local")
return semanticUnresolvedProvenance(
"unknown",
`Binding ${binding.name} has projected reference assignments whose module provenance is unresolved.`,
);
return provenance.status === "module"
? semanticAmbiguousProvenance(
provenance.origins,
`Binding ${binding.name} has projected reference assignments that may replace its module origin.`,
)
: provenance;
};
const provenanceForBindingInitializers = (
binding: JavaScriptSemanticBindingState,
context: EvaluationContext,
): JavaScriptBindingProvenance => {
if (binding.kind === "parameter" || binding.kind === "catch")
return semanticUnresolvedProvenance(
@@ -1239,11 +1305,14 @@ const mergeValues = (
const primitives = values.flatMap(
(value) => primitiveCandidates(value) ?? [],
);
return values.every(
const merged: JavaScriptSemanticValue = values.every(
(value) => value.status === "union" || value.status === "literal",
)
? primitiveSet(primitives)
: { status: "ambiguous", reason: "Branches have incompatible values." };
if (values.length > 0 && values.every(semanticValueDefinitelyDefined))
definitelyDefinedValues.add(merged);
return merged;
};
const nestedContext = (context: EvaluationContext): EvaluationContext => ({
@@ -0,0 +1,119 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
'var require;require("./before.cjs");',
'var require;require("./before.cjs");({require}={require:()=>({})});',
'var require;require("./before.cjs");[require]=[()=>({})];',
'var require;({require}={require:require("./before.cjs")});',
'var require;[require]=[require("./before.cjs")];',
])("preserves the host loader at its earlier read: %s", (source) => {
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toContainEqual(
expect.objectContaining({ kind: "require", specifier: "./before.cjs" }),
);
});
it.each([
"({require}={require:()=>({BrowserWindow:class{}})});",
"[require]=[()=>({BrowserWindow:class{}})];",
"({nested:{require}}={nested:{require:()=>({BrowserWindow:class{}})}});",
"({require=()=>({BrowserWindow:class{}})}={});",
"if(flag)({require}={require:()=>({BrowserWindow:class{}})});",
])(
"keeps projected loader writes out of proven Electron identity: %s",
(write) => {
const analysis = analyzeJavaScriptStaticSource(
`var require;${write}const {BrowserWindow}=require("electron");new BrowserWindow({webPreferences:{preload:"./unused.cjs"}});`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toContainEqual(
expect.objectContaining({ kind: "require", specifier: null }),
);
expect(analysis.references).not.toContainEqual(
expect.objectContaining({ kind: "require", specifier: "electron" }),
);
expect(analysis.electron.browser_windows).toEqual([]);
},
);
it.each([
"({require}={require:()=>({BrowserWindow:class{}})});",
"[require]=[()=>({BrowserWindow:class{}})];",
])("preserves an Electron capture before the projected write: %s", (write) => {
const analysis = analyzeJavaScriptStaticSource(
`var require;const {BrowserWindow}=require("electron");${write}new BrowserWindow({webPreferences:{preload:"./before.cjs"}});`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toContainEqual(
expect.objectContaining({ kind: "require", specifier: "electron" }),
);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: "./before.cjs" },
]);
});
it.each([
["({module}={module:{require(){}}});", false],
["[module]=[{require(){}}];", false],
["if(flag)({module}={module:{require(){}}});", true],
["if(flag)[module]=[{require(){}}];", true],
])(
"distinguishes definite and conditional wrapper replacement: %s",
(write, unknown) => {
const analysis = analyzeJavaScriptStaticSource(
`var module;${write}module.require("./unused.cjs");`,
);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references.filter(({ kind }) => kind === "require"),
).toEqual(unknown ? [expect.objectContaining({ specifier: null })] : []);
},
);
it.each([
["({loader}={loader:()=>({})});loader('./unused.cjs');", null],
["[loader]=[()=>({})];loader('./unused.cjs');", null],
["if(flag)({loader}={loader:()=>({})});loader('./unused.cjs');", null],
["loader('./before.cjs');({loader}={loader:()=>({})});", "./before.cjs"],
["loader('./before.cjs');[loader]=[()=>({})];", "./before.cjs"],
])(
"uses the shared write facts for a factory loader: %s",
(body, specifier) => {
const analysis = analyzeJavaScriptStaticSource(
`globalThis.webpackChunkApp.push([[1],{entry:function(module,exports,loader){${body}}}]);`,
);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references.filter(({ kind }) => kind === "require"),
).toEqual([expect.objectContaining({ specifier })]);
},
);
it.each([
"({require}={require:()=>({BrowserWindow:class{}})});",
"if(flag)({require}={require:()=>({BrowserWindow:class{}})});",
])("retains ambient projected writes as unknown: %s", (write) => {
const analysis = analyzeJavaScriptStaticSource(
`${write}const {BrowserWindow}=require("electron");new BrowserWindow({});`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toContainEqual(
expect.objectContaining({ kind: "require", specifier: null }),
);
expect(analysis.electron.browser_windows).toEqual([]);
});
it("keeps projected reference provenance unknown until its sources are resolved", () => {
const analysis = analyzeJavaScriptSemantics(
'let selected;({selected}={selected:require("electron")});',
);
expect(topLevelBinding(analysis, "selected").provenance).toMatchObject({
status: "unknown",
origins: [],
});
});
@@ -12,6 +12,9 @@ import {
} from "./javascriptSemanticTraversal.js";
import { inspectElectronStaticNode } from "./electronStaticAnalysis.js";
import { hasPossibleCommonJsDelegateWrite } from "./javascriptSemanticRequireOrigin.js";
import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js";
import { createStaticNodePathResolveQuery } from "./javascriptStaticPathIntrinsics.js";
import { finishElectronBrowserWindowsSteps } from "./electronStaticAnalysisBrowser.js";
import {
detectVendors,
@@ -24,6 +27,7 @@ import {
addReference,
addSourceMapDirectives,
finishPartialNetworkEndpoints,
finishModuleRequireCalls,
finishSqliteStorageCalls,
inspectCall,
inspectRouteProperty,
@@ -89,20 +93,29 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
const index = yield* indexJavaScriptBindingsWithReceiversSteps(file.program);
const { bindings, receivers } = index;
yield* index.indexAmbientWritesSteps();
if (hasPossibleCommonJsDelegateWrite(bindings))
yield* index.indexMemberMutationFactsSteps();
const electronIdentity = yield* indexJavaScriptElectronIdentitySteps(
file.program,
bindings,
);
const findings: JavaScriptFindingContext = {
nodePathResolve: createStaticNodePathResolveQuery(index),
source,
accumulator,
vitePreloadDependencyTable: indexVitePreloadDependencyTable(file.program),
openReceiverFacts: receivers.openReceivers,
electronBindings: electronIdentity.bindings,
electronInvocationPath: electronIdentity.invocationPath,
electronAppPathChangePossible: electronIdentity.mayChangeAppPath,
rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index),
};
yield* traverseStaticSourceSteps(file, findings, bindings);
yield* finishElectronBrowserWindowsSteps(findings, index);
if (accumulator.pendingModuleRequireCalls.length > 0) {
yield* index.indexMemberMutationFactsSteps();
finishModuleRequireCalls(findings, bindings);
}
if (
accumulator.pendingNetworkCalls.length > 0 ||
accumulator.pendingSqliteCalls.length > 0
@@ -145,6 +158,9 @@ const finalizeStaticAnalysis = (
): JavaScriptStaticAnalysis => {
const parserErrors = file.errors.length;
const limitations = [
...(bindings.valueResourceLimits.has("alias-selections")
? [semanticResourceLimitReason("alias-selections")]
: []),
...(bindings.returnedReferenceWidenings.size > 0
? [RETURNED_REFERENCE_WIDENING_LIMITATION]
: []),
@@ -183,7 +199,9 @@ const finalizeStaticAnalysis = (
];
return {
parse_status:
parserErrors > 0 || accumulator.unknownFindings > 0
parserErrors > 0 ||
accumulator.unknownFindings > 0 ||
bindings.valueResourceLimits.has("alias-selections")
? "partial"
: "complete",
parse_error_count: parserErrors,
@@ -211,6 +229,14 @@ const finalizeStaticAnalysis = (
source_map_urls: accumulator.sourceMaps,
vendors: detectVendors(source),
electron: {
...(accumulator.appPathChanges.length > 0
? {
app_path_changes: finalizeLocatedFindings(
accumulator.appPathChanges,
accumulator.moduleRangeIndex,
),
}
: {}),
...(accumulator.electronCallPaths?.length
? { call_paths: accumulator.electronCallPaths }
: {}),
@@ -0,0 +1,158 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
[
"const values={*[Symbol.iterator](){yield module;}};const h={};[h.loader]=values;h.loader",
true,
],
[
"const values=[{}];values[Symbol.iterator]=function*(){yield module;};const h={};[h.loader]=values;h.loader",
true,
],
[
"const values={*[Symbol.iterator](){yield module;}};const [api]=values;api",
true,
],
[
"const values={*[Symbol.iterator](){yield module;}};const [...api]=values;api[0]",
true,
],
[
"Array.prototype[Symbol.iterator]=function*(){yield module;};const [api]=[{}];api",
true,
],
["const h={};[h.loader]=[{}];h.loader", false],
["const [api]=[{}];api", false],
["const [api]=module;api", true],
])("keeps custom iterable selections unknown: %s", (source, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${source}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
});
it.each(["??=", "||=", "&&="])(
"retains possible logical assignment module sources for %s",
(operator) => {
const analysis = analyzeJavaScriptStaticSource(`
const h={ loader: choose() };
h.loader ${operator} module;
h.loader.require=replacement;
require('./shared.cjs');
`);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: null }),
]);
},
);
it.each(["??=", "||=", "&&="])(
"keeps logical assignment local sources separate for %s",
(operator) => {
const analysis = analyzeJavaScriptStaticSource(`
const h={ loader: {} };
h.loader ${operator} { require() {} };
h.loader.require=replacement;
require('./shared.cjs');
`);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }),
]);
},
);
it.each(["??=", "||=", "&&="])(
"keeps a load before a later logical module assignment for %s",
(operator) => {
const analysis = analyzeJavaScriptStaticSource(`
require('./shared.cjs');
const h={ loader: choose() };
h.loader ${operator} module;
h.loader.require=replacement;
`);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }),
]);
},
);
it.each([
["const {...h}={loader:module};h.loader", true],
["const [...h]=[module];h[0]", true],
["const [ignored,...h]=[{},module];h[0]", true],
["const {ignored,...h}={ignored:module,loader:module};h.loader", true],
["const {loader:ignored,...h}={loader:module};h.loader", false],
["const {...h}=module;h", false],
["const [...h]=[module];h", false],
["const {...h}={loader:module.exports};h.loader", false],
["const [...h]=[{}];h[0]", false],
])(
"preserves child identities of declaration rest copies: %s",
(source, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${source}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
["({x:h.loader}={x:module});h.loader", true],
["[h.loader]=[module];h.loader", true],
["({x:[{y:h.loader}]}={x:[{y:module}]});h.loader", true],
["({x:h.loader=module}={});h.loader", true],
["({x:{y:h.loader=module}={}}={});h.loader", true],
["({[key]:h.loader}={x:module});h.loader", true],
["({['x']:h.loader}={x:module});h.loader", true],
["[ignore,...h.loader]=[{},module];h.loader[0]", true],
["({...h.loader}={loader:module});h.loader.loader", true],
["({exports:h.loader}=module);h.loader", false],
["({...h.loader}=module);h.loader", false],
["({x:ignore,...h.loader}={x:module});h.loader.x", false],
["({x:h.loader=module}={x:{}});h.loader", false],
["h.loader=module;({x:h.loader}={x:{}});h.loader", false],
["({a:h.loader,b:h.loader}={a:module,b:{}});h.loader", false],
["({a:h.loader,b:h.loader}={a:{},b:module});h.loader", true],
["({x:h.a,y:h.b}={x:{},y:module});h.a", false],
["({x:h.a,y:h.b}={x:{},y:module});h.b", true],
])("preserves selected assigned module sources: %s", (assignment, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`const h={};${assignment}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
});
it("retains a destructured member delegate call", () => {
const analysis = analyzeJavaScriptStaticSource(
`const h={};({load:h.load}={load:module.require});h.load.call(module,'./shared.cjs');`,
);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: null }),
]);
});
it("revokes the host delegate after a destructured module receiver write", () => {
const analysis = analyzeJavaScriptStaticSource(
`const h={};({x:h.loader}={x:module});h.loader.require=replacement;require('./shared.cjs');`,
);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: null }),
]);
});
@@ -0,0 +1,88 @@
import { Module } from "node:module";
import * as path from "node:path";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const cases = [
{
name: "assigned host receiver",
source: "let holder;(holder=module).require('node:path');",
outcomes: ["host"],
possible: true,
},
{
name: "assigned local receiver",
source:
"let holder;(holder={require:()=>({kind:'local'})}).require('node:path');",
outcomes: ["local"],
possible: false,
},
{
name: "assigned delegate",
source: "let load;(load=module.require).call(module,'node:path');",
outcomes: ["host"],
possible: true,
},
{
name: "conditional assignment value",
source:
"let holder;(holder=flag?module:{require:()=>({kind:'local'})}).require('node:path');",
outcomes: ["local", "host"],
possible: true,
},
{
name: "logical or assignment",
source:
"let holder=flag?{require:()=>({kind:'local'})}:null;(holder||=module).require('node:path');",
outcomes: ["host", "local"],
possible: true,
},
{
name: "logical and assignment",
source: "let holder=flag?{}:null;(holder&&=module).require('node:path');",
outcomes: ["throw", "host"],
possible: true,
},
{
name: "nullish assignment",
source:
"let holder=flag?{require:()=>({kind:'local'})}:null;(holder??=module).require('node:path');",
outcomes: ["host", "local"],
possible: true,
},
{
name: "logical assignment of local receivers",
source:
"let holder=flag?{require:()=>({kind:'local'})}:null;(holder||={require:()=>({kind:'local'})}).require('node:path');",
outcomes: ["local", "local"],
possible: false,
},
];
it.each(cases)(
"retains the expression result for $name",
({ source, outcomes, possible }) => {
for (const [index, expected] of outcomes.entries()) {
const execute = () =>
runInNewContext(source, {
module: new Module("assignment-loader.cjs"),
flag: index === 1,
});
if (expected === "throw") expect(execute).toThrow();
else {
const result = execute();
expect(result.join === path.join).toBe(expected === "host");
if (expected === "local") expect(result.kind).toBe("local");
}
}
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
possible
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -0,0 +1,99 @@
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const generic = "const get=()=>({});const invoke=get.call;";
const cases = [
{
name: "plain object receiver",
source: `${generic}const holder={invoke,other:()=>module};holder.invoke(null)`,
callable: false,
},
{
name: "sequence object receiver",
source: `${generic}const holder={invoke,other:()=>module};(0,holder).invoke(null)`,
callable: false,
},
{
name: "spread object receiver",
source: `${generic}({...{invoke,other:()=>module}}).invoke(null)`,
callable: false,
},
{
name: "array receiver",
source: `${generic}const holder=[invoke,()=>module];holder[0](null)`,
callable: false,
},
{
name: "explicit object receiver",
source: `${generic}invoke.call({other:()=>module},null)`,
callable: false,
},
{
name: "conditional object receiver",
source: `${generic}const holder=flag?{invoke,other:()=>module}:{invoke,other:()=>module};holder.invoke(null)`,
callable: false,
},
{
name: "direct object callee",
source: "const holder={other:()=>module};holder(null)",
callable: false,
},
{
name: "direct array callee",
source: "const holder=[()=>module];holder(null)",
callable: false,
},
{
name: "callable receiver with an own invocation alias",
source: `${generic}const holder=()=>module;holder.invoke=invoke;holder.other=()=>({});holder.invoke(null)`,
callable: true,
},
{
name: "explicit callable member receiver",
source: `${generic}const holder={invoke,other:()=>module};holder.invoke.call(holder.other,null)`,
callable: true,
},
{
name: "selected object callable",
source: `${generic}const holder={other:()=>module};invoke.call(holder.other,null)`,
callable: true,
},
{
name: "selected array callable",
source: `${generic}const holder=[()=>module];invoke.call(holder[0],null)`,
callable: true,
},
{
name: "selected spread callable",
source: `${generic}const holder={...{other:()=>module}};invoke.call(holder.other,null)`,
callable: true,
},
];
it.each(cases)(
"distinguishes the callable value from children for $name",
({ source, callable }) => {
const invocation = `${source}.require('./shared.cjs');`;
expect(
runInNewContext(
`try{${invocation}}catch(error){error.name;}`,
{ module: { require: () => "host" }, flag: true },
{ timeout: 1_000 },
),
).toBe(callable ? "host" : "TypeError");
const analysis = analyzeJavaScriptStaticSource(invocation);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
callable
? [
expect.objectContaining({
kind: "require",
specifier: null,
}),
]
: [],
);
},
);
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js";
const expressionWrappers = [
["plain", (value: string) => value],
@@ -9,6 +10,207 @@ const expressionWrappers = [
["satisfies", (value: string) => `(${value} satisfies any)`],
] as const;
it.each([
["module.require = replacement;", true],
[
"const holder = { loader: module }; holder.loader.require = replacement;",
true,
],
[
"const holder = {}; holder.loader = module; holder.loader.require = replacement;",
true,
],
["const holder = [module]; holder[0].require = replacement;", true],
["Object.defineProperty(module, 'require', { value: replacement });", true],
["const api = module; Object.assign(api, { require: replacement });", true],
["const module = { require() {} }; module.require = replacement;", false],
["const api = module.exports; api.require = replacement;", false],
[
"const holder = { a: module }; const target = flag ? holder.a : holder; target.require = replacement;",
true,
],
[
"const table = { x: {}, y: {}, z: module }; const target = flag ? table.x : table.y; target.require = replacement;",
false,
],
])("keeps CommonJS delegate replacement unknown: %s", (write, changed) => {
const analysis = analyzeJavaScriptStaticSource(
`${write} require("./shared.cjs");`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual([
expect.objectContaining({
kind: "require",
specifier: changed ? null : "./shared.cjs",
}),
]);
});
it("keeps a require capture before a later delegate replacement", () => {
const analysis = analyzeJavaScriptStaticSource(
'require("./shared.cjs"); const holder = { loader: module }; holder.loader.require = replacement;',
);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }),
]);
});
it.each([
["const holder = {}; holder.loader = { require() {} }; holder.loader", false],
[
"const holder = { loader: module }; holder.loader = {}; holder.loader",
false,
],
[
"const holder = {}; if (flag) holder.loader = { require() {} }; holder.loader",
false,
],
["const holder = { loader: module }; holder.loader", true],
["const holder = { inner: { loader: module } }; holder.inner.loader", true],
["const holder = [module]; holder[0]", true],
[
"const holder = { loader: module }; const { loader } = holder; loader",
true,
],
[
"const holder = { loader: module }; const loader = holder.loader; loader",
true,
],
[
"const holder = { loader: module }; const alias = { ...holder }; alias.loader",
true,
],
["const holder = { loader: module, loader: {} }; holder.loader", false],
["const holder = { loader: {} }; holder.loader", false],
["const holder = { loader: module.exports }; holder.loader", false],
["const holder = { loader: module }; holder.missing", false],
[
"const table = { a: { b: {}, d: module }, c: { d: {}, b: {} } }; const selected = flag ? table.a.b : table.c.d; selected",
false,
],
["let holder = {}; while (flag) holder = holder.slot; holder", true],
["const holder = {}; holder.loader = module; holder.loader", true],
["const holder = {}; if (flag) holder.loader = module; holder.loader", true],
[
"const holder = {}; const alias = holder; alias.loader = module; holder.loader",
true,
],
])(
"tracks the selected module-object container slot: %s",
(receiver, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${receiver}.require("./shared.cjs");`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it("bounds branching selector work and reports unknown ownership explicitly", () => {
const analysis = analyzeJavaScriptStaticSource(aliasSelectionBudgetSource());
expect(analysis.parse_error_count).toBe(0);
expect(analysis.parse_status).toBe("partial");
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: null }),
]);
expect(analysis.limitations).toContainEqual(
expect.stringMatching(/Alias selection expansion.*512/u),
);
});
it.each([
['const load = module.require.bind(module); load("./shared.cjs");', true],
[
'const api = module; const load = api.require.bind(api); load("./shared.cjs");',
true,
],
[
'const load = module["require"][`bind`](module); load("./shared.cjs");',
true,
],
['module.require.call(module, "./shared.cjs");', true],
['module.require.apply(module, ["./shared.cjs"]);', true],
[
'const load = module.require.bind.call(module.require, module); load("./shared.cjs");',
true,
],
[
'const module = { require() {} }; const load = module.require.bind(module); load("./shared.cjs");',
false,
],
[
'const api = module.exports; const load = api.require.bind(api); load("./shared.cjs");',
false,
],
])(
"retains unchecked module delegate invocation and capture: %s",
(source, possibleModule) => {
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
possibleModule
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
["const { api = module } = options;", true],
["const { api = module } = {};", true],
["const { api = module } = { api: {} };", false],
["const { api = module.exports } = options;", false],
["const { ...api } = module;", false],
])(
"tracks active direct module defaults: %s",
(declaration, possibleModule) => {
const analysis = analyzeJavaScriptStaticSource(
`${declaration} api.require("./shared.cjs");`,
);
expect(analysis.references).toEqual(
possibleModule
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
"const { exports: api } = module;",
"const { exports: { api } } = module;",
"const [api] = [module.exports];",
])(
"keeps projected module members separate from the loader: %s",
(declaration) => {
const analysis = analyzeJavaScriptStaticSource(`${declaration}
api.require = () => {}; api.require("./shared.cjs");`);
expect(analysis.references).toEqual([]);
},
);
it.each(["module", "local"])(
"examines a repeated conditional alias graph once per source read for %s",
(origin) => {
const source = [`const a0 = ${origin === "module" ? "module" : "{}"};`];
for (let index = 1; index <= 128; index += 1)
source.push(
`let a${index} = a${index - 1}; if (flag) a${index} = a${index - 1};`,
);
source.push('a128.require("./shared.cjs");');
const analysis = analyzeJavaScriptStaticSource(source.join("\n"));
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
origin === "module"
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it("shares erased call-target names with endpoint consumers without inventing dynamic keys", () => {
const analysis = analyzeJavaScriptStaticSource(`
(client.get as typeof client.get)("/api/items");
@@ -50,8 +50,9 @@ import {
import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js";
import {
semanticFactoryLoaderMemberAt,
semanticFactoryRequireLoaderAt,
semanticRequireLoaderAt,
semanticRequireCallAt,
semanticModuleRequireCallAt,
semanticModuleRequireReceiver,
} from "./javascriptSemanticRequireOrigin.js";
type JavaScriptCallLike =
@@ -69,7 +70,13 @@ export const inspectCall = (
const { accumulator } = context;
const name = calleeName(node.callee);
const first = stringValue(node.arguments[0]);
const requireCall = requireCallSpecifier(node, bindings, context);
const deferredModuleRequire =
semanticModuleRequireReceiver(node) !== undefined ||
semanticModuleRequireCallAt(node, bindings, false);
if (deferredModuleRequire) accumulator.pendingModuleRequireCalls.push(node);
const requireCall = deferredModuleRequire
? null
: requireCallSpecifier(node, bindings, context);
if (requireCall !== null)
addReference(context, {
node,
@@ -249,43 +256,37 @@ const requireCallSpecifier = (
bindings: JavaScriptSemanticAnalysisState,
context: FindingContext,
): { readonly specifier: string | undefined } | null => {
const bareRequire = t.isIdentifier(node.callee, { name: "require" });
if (bareRequire) {
const loader = semanticRequireLoaderAt(
node,
bindings,
context.accumulator.factoryRequireBindingIds,
);
if (loader === "local") return null;
if (loader === "unknown") context.accumulator.unknownFindings += 1;
return {
specifier:
loader === "unknown" ? undefined : argumentValue(node.arguments[0]),
};
}
const factoryLoader = semanticFactoryRequireLoaderAt(
const call = semanticRequireCallAt(
node,
bindings,
context.accumulator.factoryRequireBindingIds,
);
if (factoryLoader === "factory-loader")
return {
specifier: argumentValue(
node.arguments[
t.isMemberExpression(unwrapJavaScriptExpression(node.callee).node) ||
t.isOptionalMemberExpression(
unwrapJavaScriptExpression(node.callee).node,
)
? 1
: 0
],
),
};
if (factoryLoader === "unknown") {
if (call === undefined || call.loader === "local") return null;
if (call.loader === "unknown" || !call.sourceRelative) {
context.accumulator.unknownFindings += 1;
return { specifier: undefined };
}
return null;
return { specifier: argumentValue(call.arguments?.[0]) };
};
/** Examine member-loader receivers only after shared alias mutation facts settle. */
export const finishModuleRequireCalls = (
context: FindingContext,
bindings: JavaScriptSemanticAnalysisState,
): void => {
for (const node of context.accumulator.pendingModuleRequireCalls) {
const call = requireCallSpecifier(node, bindings, context);
if (call !== null)
addReference(context, {
node,
kind: "require",
specifier: call.specifier,
});
}
context.accumulator.pendingModuleRequireCalls.length = 0;
context.accumulator.references.sort(
(left, right) => left.offset - right.offset,
);
};
const chunkLoadSpecifier = (
@@ -760,7 +761,7 @@ export const inspectRoleProperty = (
): void => {
if (semanticStaticPropertyName(node.key, node.computed) !== "preload") return;
if (context.accumulator.mediaElementPreloads.has(node)) return;
const path = staticPath(node.value);
const path = staticPath(node.value, context.nodePathResolve);
if (path === undefined) return;
addLocatedFinding(context, {
collection: context.accumulator.roles,
@@ -769,7 +770,10 @@ export const inspectRoleProperty = (
value: {
role: "preload",
path,
resolution_context: staticPathResolutionContext(node.value),
resolution_context: staticPathResolutionContext(
node.value,
context.nodePathResolve,
),
mechanism: "property:preload",
module_key: null,
location: range(node),
@@ -0,0 +1,98 @@
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import * as t from "@babel/types";
import { capturedGenericInvocations } from "../../../tests/fixtures/javascriptCapturedGenericInvocations.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
it.each(capturedGenericInvocations)(
"retains the authored runtime receiver for $name",
({ source, host, expression }) => {
expect(
runInNewContext(
source,
{
module: { require: () => "host" },
},
{ timeout: 1_000 },
),
).toBe(host ? "host" : "local");
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
host
? [
expect.objectContaining({
kind: "require",
specifier: null,
expression,
}),
]
: [],
);
},
);
it.each([
"const table={x:()=>({}),y:()=>({}),z:()=>module};const selected=flag?table.x:table.y;const get=()=>module;const invoke=get.call;invoke.call.call(selected,null).require('./unused.cjs');",
"const get=()=>({});const invoke=get.call;const other=function*(){return module;};invoke.call.call(other,null).require('./unused.cjs');",
"const get=()=>({});const invoke=get.call;const other=async()=>module;invoke.call.call(other,null).require('./unused.cjs');",
])("preserves unrelated, generator and promise receivers: %s", (source) => {
expect(analyzeJavaScriptStaticSource(source).references).toEqual([]);
});
it("does not reclassify an ordinary Electron construction as an unknown loader", () => {
const analysis = analyzeJavaScriptStaticSource(
"const {BrowserWindow}=require('electron');new BrowserWindow().loadFile('index.html');",
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "electron" }),
]);
});
it.each(
[32, 550].flatMap((depth) => [true, false].map((host) => ({ depth, host }))),
)(
"retains generic receiver sources when invocation contexts converge at depth $depth (host=$host)",
({ depth, host }) => {
const source = `const get=()=>({kind:'local'});const invoke=get.call;const other=()=>${host ? "module" : "({kind:'local'})"};invoke.call(${Array(depth).fill("invoke").join(",")},other,null);`;
expect(
runInNewContext(source, { module: { kind: "host" } }, { timeout: 1_000 })
.kind,
).toBe(host ? "host" : "local");
const parsed = parseJavaScriptSource(source);
if (parsed === null)
throw new Error("Expected bounded generic invocation fixture");
const index = completeSemanticSteps(
indexJavaScriptBindingsWithReceiversSteps(parsed.program),
);
completeSemanticSteps(index.indexAmbientWritesSteps());
completeSemanticSteps(index.indexMemberMutationFactsSteps());
const statement = parsed.program.body.at(-1);
if (
!t.isExpressionStatement(statement) ||
!t.isCallExpression(statement.expression)
)
throw new Error("Expected final generic invocation");
const call = statement.expression;
const returned = createSemanticReturnedReferences(
index.bindings,
"direct-call",
)(call.callee, call);
expect(
returned.references.some(({ node }) =>
t.isIdentifier(node, { name: "module" }),
),
).toBe(host);
expect(returned.precision).toBe(depth > 512 ? "widened" : "exact");
expect(index.bindings.valueResourceLimits.has("alias-selections")).toBe(
depth > 512,
);
if (depth > 512) expect(returned.unresolvedInvocation).toBe(true);
},
);
@@ -0,0 +1,49 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
['const load = module.require; load.call(module, "../shared.cjs");', true],
['const load = module.require; load.apply(module, ["../shared.cjs"]);', true],
['const load = module.require; load("../shared.cjs");', true],
[
'const load = module.require; const alias = load; alias.call(module, "../shared.cjs");',
true,
],
[
'const { require: load } = module; load.call(module, "../shared.cjs");',
true,
],
[
'const holder = { load: module.require }; holder.load.call(module, "../shared.cjs");',
true,
],
['const holder = [module.require]; holder[0]("../shared.cjs");', true],
[
'let load = () => {}; if (flag) load = module.require; load("../shared.cjs");',
true,
],
[
'let load = module.require; load = () => {}; load.call(module, "../shared.cjs");',
false,
],
[
'const api = { require() {} }; const load = api.require; load.call(api, "../shared.cjs");',
false,
],
[
'const load = module.exports.require; load.call(module.exports, "../shared.cjs");',
false,
],
])(
"retains captured CommonJS delegates by source identity: %s",
(source, expected) => {
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -0,0 +1,75 @@
import { Module } from "node:module";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it("checks inherited and own enumerable require against a real Node Module", () => {
const host = new Module("copied-require-oracle.cjs");
expect(Object.hasOwn(host, "require")).toBe(false);
expect(typeof host.require).toBe("function");
const { ...rest } = host;
expect(Object.hasOwn(rest, "require")).toBe(false);
expect(Object.hasOwn({ ...host }, "require")).toBe(false);
host.require = host.require;
expect(Object.getOwnPropertyDescriptor(host, "require")?.enumerable).toBe(
true,
);
const { ...ownRest } = host;
expect(ownRest.require).toBe(host.require);
expect({ ...host }.require).toBe(host.require);
});
it("keeps copied child module identity without copying its prototype", () => {
const host = new Module("copied-child-oracle.cjs");
const { ...copy } = { loader: host };
expect(Object.hasOwn(copy, "loader")).toBe(true);
expect(copy.loader).toBe(host);
expect(Object.hasOwn(copy.loader, "require")).toBe(false);
expect(copy.loader.require).toBe(host.require);
});
it.each(["const {...h}=module;", "const h={...module};"])(
"keeps a copied inherited delegate absent inside a function: %s",
(declaration) => {
const result = analyzeJavaScriptStaticSource(
`function copy(){${declaration}h.require('./shared.cjs');}`,
);
expect(result.references).toEqual([]);
},
);
it.each([
["const {...h}=module;h", false],
["const holder={};({...holder.h}=module);holder.h", false],
["const h={...module};h", false],
["const source=module;const h={...source};h", false],
["const source=module;const {...h}=source;h", false],
["const h={...{loader:module}};h.loader", true],
["const {...h}={loader:module};h.loader", true],
["const h={...[module]};h[0]", true],
["const source={require:module.require};const h={...source};h", true],
["const source={require:module.require};const {...h}=source;h", true],
["module.require=module.require;const h={...module};h", true],
["module.require=module.require;const {...h}=module;h", true],
["const h={...module};module.require=module.require;h", false],
["const {...h}=module;module.require=module.require;h", false],
["if(flag)module.require=module.require;const h={...module};h", true],
["globalThis.module.require=unknown;const h={...module};h", false],
["expose(module);const h={...module};h", true],
["const h={...module};expose(module);h", false],
["const module={require(){}};const h={...module};h", false],
])(
"selects only possibly own enumerable copied delegates: %s",
(receiver, expected) => {
const result = analyzeJavaScriptStaticSource(
`${receiver}.require('./shared.cjs');`,
);
expect(result.parse_error_count).toBe(0);
expect(result.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -0,0 +1,60 @@
import { Module } from "node:module";
import * as path from "node:path";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it("keeps repeated Node resolver calls separate from loader delegate eligibility", () => {
const expression = "path.resolve(runtimePath,__dirname,'preload.js')";
const source = `
const path=require('node:path');
const {BrowserWindow,utilityProcess}=require('electron');
new BrowserWindow({webPreferences:{preload:${expression}}});
utilityProcess.fork(${expression});
`;
const observed: string[] = [];
runInNewContext(source, {
runtimePath: "/runtime",
__dirname: "/fixture/app",
require: (specifier: string) =>
specifier === "node:path"
? path.posix
: {
BrowserWindow: class {
constructor(options: { webPreferences: { preload: string } }) {
observed.push(options.webPreferences.preload);
}
},
utilityProcess: { fork: (file: string) => observed.push(file) },
},
});
expect(observed).toEqual([
"/fixture/app/preload.js",
"/fixture/app/preload.js",
]);
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_status).toBe("complete");
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "node:path" }),
expect.objectContaining({ kind: "require", specifier: "electron" }),
]);
expect(analysis.electron.browser_windows[0]?.preload_path).toBe("preload.js");
expect(analysis.electron.utility_processes[0]?.module_path).toBe(
"preload.js",
);
});
it("retains an escaped selected native delegate as a possible loader", () => {
const source =
"const box={load:module.require};opaque(box);box.load.call(module,'node:path');";
const module = new Module("delegate-eligibility.cjs");
expect(runInNewContext(source, { module, opaque: () => {} }).join).toBe(
path.join,
);
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: null }),
]);
});
@@ -0,0 +1,50 @@
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it("observes an authored factory returning a loader from another directory", () => {
const source = resolve("factory-oracle-source/main.cjs");
const alternate = resolve("factory-oracle-other/main.cjs");
for (const flag of [false, true]) {
const actual = runInNewContext(
"const make=flag?native:getFactory();const load=make(source);load.resolve.paths('./window.cjs')[0];",
{
native: createRequire,
getFactory: () => () => createRequire(alternate),
source,
flag,
},
);
expect(actual).toBe(dirname(flag ? source : alternate));
}
});
it.each([
["conditional opaque factory", "flag?native:getFactory()", null],
["logical opaque factory", "native||getFactory()", null],
["optional opaque factory", "flag?native:getFactory?.()", null],
["constructed opaque factory", "flag?native:new Factory()", null],
["opaque member factory", "flag?native:getContainer().make", null],
["unbound member factory", "flag?native:holder.make", null],
["known native choices", "flag?native:native", "./window.cjs"],
["only opaque factory", "getFactory()", undefined],
["only local factory", "()=>()=>({})", undefined],
] as const)(
"retains every selected origin for %s",
(_name, choice, specifier) => {
const analysis = analyzeJavaScriptStaticSource(
`import{createRequire as native}from'node:module';const make=${choice};const load=make(import.meta.url);load('./window.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references.filter(({ kind }) => kind === "require"),
).toEqual(
specifier === undefined
? []
: [expect.objectContaining({ kind: "require", specifier })],
);
},
);
@@ -171,22 +171,31 @@ export const staticArrayValues = (
};
/** Classify whether inert path syntax is a module specifier or file expression. */
export type JavaScriptStaticPathIntrinsicQuery = (
node: t.Node,
fact?: "source-directory",
) => boolean;
export const staticPathResolutionContext = (
node: t.Node,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
): JavaScriptStaticPathContext =>
isFilesystemPathExpression(node)
isFilesystemPathExpression(node, nodePathResolve)
? "filesystem-expression"
: "module-specifier";
/** Resolve a path composed only from inert literal syntax. */
export const staticPath = (node: t.Node): string | undefined => {
/** Resolve a path string composed only from inert literal syntax. */
export const staticPath = (
node: t.Node,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
): string | undefined => {
node = unwrapJavaScriptExpression(node).node;
const value = stringValue(node);
if (value !== undefined) return value;
if (t.isBinaryExpression(node, { operator: "+" }))
return staticConcatenation(node);
return staticConcatenation(node, nodePathResolve);
if (t.isCallExpression(node) || t.isNewExpression(node))
return staticCallPath(node);
return staticCallPath(node, nodePathResolve);
return undefined;
};
@@ -195,18 +204,21 @@ export const staticPath = (node: t.Node): string | undefined => {
* `__dirname + "/preload.js"` names a file inside it, while text without a
* leading separator extends the directory's own name and stays unknown.
*/
const staticConcatenation = (node: t.BinaryExpression): string | undefined => {
const staticConcatenation = (
node: t.BinaryExpression,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
): string | undefined => {
const operands: t.Node[] = [];
let current: t.Node = node;
while (t.isBinaryExpression(current, { operator: "+" })) {
operands.push(current.right);
current = unwrapJavaScriptExpression(current.left).node;
}
const anchored = isDirectoryPath(current);
const anchored = isDirectoryPath(current, nodePathResolve);
if (!anchored) operands.push(current);
let text = "";
for (const operand of operands.reverse()) {
const value = staticPath(operand);
const value = staticPath(operand, nodePathResolve);
if (value === undefined) return undefined;
text += value;
}
@@ -216,29 +228,27 @@ const staticConcatenation = (node: t.BinaryExpression): string | undefined => {
const staticCallPath = (
node: t.CallExpression | t.NewExpression,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
): string | undefined => {
if (t.isCallExpression(node) && nodePathResolve?.(node.callee))
return staticResolvedPath(node, nodePathResolve);
const name = calleeName(node.callee);
if (name === "URL" || name.endsWith(".URL"))
return isFileIdentity(argumentNode(node.arguments[1]))
? stringValue(node.arguments[0])
: undefined;
if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) {
return staticFileUrlPath(node);
return staticFileUrlPath(node, nodePathResolve);
}
if (name.endsWith(".resolve"))
return name === "win32.resolve" || name.endsWith(".win32.resolve")
? undefined
: staticResolvedPath(node);
if (name.endsWith(".resolve")) return undefined;
if (!name.endsWith(".join") && name !== "join") return undefined;
const parts: string[] = [];
for (const argument of node.arguments) {
if (isDirectoryIdentity(argumentNode(argument))) {
if (isDirectoryPath(argumentNode(argument), nodePathResolve)) {
// join never resets at an absolute segment, so `/preload.js` after the
// source directory stays inside it.
parts.push(".");
continue;
}
const value = t.isNode(argument) ? staticPath(argument) : undefined;
const value = t.isNode(argument)
? staticPath(argument, nodePathResolve)
: undefined;
if (value === undefined) return undefined;
parts.push(value);
}
@@ -247,13 +257,14 @@ const staticCallPath = (
const staticFileUrlPath = (
node: t.CallExpression | t.NewExpression,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
): string | undefined => {
if (node.arguments.length !== 1) return undefined;
const rawArgument = argumentNode(node.arguments[0]);
if (rawArgument === undefined) return undefined;
const argument = unwrapJavaScriptExpression(rawArgument).node;
if (isSourceFileUrl(argument)) return sourceRelativeFileUrlPath(argument);
const value = staticPath(argument);
const value = staticPath(argument, nodePathResolve);
const rawPath = value?.split(/[?#]/u, 1)[0] ?? "";
if (
value === undefined ||
@@ -324,82 +335,86 @@ const isSourceFileUrl = (
const staticResolvedPath = (
node: t.CallExpression | t.NewExpression,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
): string | undefined => {
const parts: (string | null)[] = [];
for (const argument of node.arguments) {
if (isDirectoryIdentity(argumentNode(argument))) {
parts.push(null);
continue;
const parts: string[] = [];
let anchored = false;
// Node walks right-to-left and never inspects arguments before its anchor.
for (let index = node.arguments.length - 1; index >= 0; index -= 1) {
const argument = node.arguments[index];
if (isDirectoryPath(argumentNode(argument), nodePathResolve)) {
anchored = true;
break;
}
const value = t.isNode(argument) ? staticPath(argument) : undefined;
// Validate every argument, including those before the last anchor.
const value = t.isNode(argument)
? staticPath(argument, nodePathResolve)
: undefined;
if (value === undefined) return undefined;
parts.push(value);
if (posix.isAbsolute(value)) {
anchored = true;
break;
}
}
const anchor = parts.findLastIndex(
(part) => part === null || posix.isAbsolute(part),
);
// Relative-only calls depend on the target process cwd, which is unknown.
if (anchor === -1) return undefined;
// A null anchor is the source directory; preserve its relative projection.
const path = posix.join(
...parts.slice(anchor).filter((part) => part !== null),
);
if (!anchored) return undefined;
const path = posix.join(...parts.reverse());
return path === "/" ? path : path.replace(/\/$/u, "");
};
const isFilesystemPathExpression = (node: t.Node): boolean => {
const isFilesystemPathExpression = (
node: t.Node,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
): boolean => {
node = unwrapJavaScriptExpression(node).node;
const value = stringValue(node);
if (value !== undefined) return value.startsWith("/");
if (isFileIdentity(node) || isDirectoryIdentity(node)) return true;
if (isFileIdentity(node) || isDirectoryPath(node, nodePathResolve))
return true;
if (t.isBinaryExpression(node, { operator: "+" }))
return (
isFilesystemPathExpression(node.left) ||
isFilesystemPathExpression(node.right)
isFilesystemPathExpression(node.left, nodePathResolve) ||
isFilesystemPathExpression(node.right, nodePathResolve)
);
if (t.isCallExpression(node) || t.isNewExpression(node)) {
const name = calleeName(node.callee);
if (name.endsWith(".resolve") && staticCallPath(node) !== undefined)
if (
nodePathResolve?.(node.callee) &&
staticCallPath(node, nodePathResolve) !== undefined
)
return true;
if (name === "URL" || name.endsWith(".URL"))
return isFileIdentity(argumentNode(node.arguments[1]));
// A URL object is not a path string until fileURLToPath converts it.
if (name === "URL" || name.endsWith(".URL")) return false;
if (name === "fileURLToPath" || name.endsWith(".fileURLToPath"))
return (
staticFileUrlPath(node) !== undefined ||
staticFileUrlPath(node, nodePathResolve) !== undefined ||
node.arguments.some((argument) => {
const value = argumentNode(argument);
return value !== undefined && isFilesystemPathExpression(value);
return (
value !== undefined &&
isFilesystemPathExpression(value, nodePathResolve)
);
})
);
return node.arguments.some((argument) => {
const value = argumentNode(argument);
return value !== undefined && isFilesystemPathExpression(value);
return (
value !== undefined &&
isFilesystemPathExpression(value, nodePathResolve)
);
});
}
return false;
};
const isDirectoryIdentity = (node: t.Node | undefined): boolean => {
if (node !== undefined) node = unwrapJavaScriptExpression(node).node;
if (isDirectoryPath(node)) return true;
if (!t.isCallExpression(node) && !t.isNewExpression(node)) return false;
const name = calleeName(node.callee);
return (
(name === "URL" || name.endsWith(".URL")) &&
isFileIdentity(argumentNode(node.arguments[1]))
);
};
/** `__dirname` or the dirname of the source file, as a string. */
const isDirectoryPath = (node: t.Node | undefined): boolean => {
const isDirectoryPath = (
node: t.Node | undefined,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
): boolean => {
if (node !== undefined) node = unwrapJavaScriptExpression(node).node;
if (t.isIdentifier(node, { name: "__dirname" })) return true;
if (!t.isCallExpression(node)) return false;
const name = calleeName(node.callee);
return (
(name === "dirname" || name.endsWith(".dirname")) &&
isFileIdentity(argumentNode(node.arguments[0]))
node !== undefined && nodePathResolve?.(node, "source-directory") === true
);
};
@@ -0,0 +1,122 @@
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const changedIterator =
"Array.prototype[Symbol.iterator]=function*(){yield '../shared/window.cjs'};";
const cases = [
[
"ordinary spread",
"",
"require(...['./inert.cjs'])",
"./inert.cjs",
"./inert.cjs",
],
[
"ordinary nested spread",
"",
"require(...[...['./inert.cjs']])",
"./inert.cjs",
"./inert.cjs",
],
[
"changed direct spread",
changedIterator,
"require(...['./inert.cjs'])",
"../shared/window.cjs",
null,
],
[
"changed nested spread",
changedIterator,
"require(...[...['./inert.cjs']])",
"../shared/window.cjs",
null,
],
[
"changed generic call spread",
changedIterator,
"require.call(null,...['./inert.cjs'])",
"../shared/window.cjs",
null,
],
[
"changed apply argument spread",
changedIterator,
"require.apply(null,[...['./inert.cjs']])",
"../shared/window.cjs",
null,
],
[
"changed reflected argument spread",
changedIterator,
"Reflect.apply(require,null,[...['./inert.cjs']])",
"../shared/window.cjs",
null,
],
[
"apply arraylike without spread",
changedIterator,
"require.apply(null,['./inert.cjs'])",
"./inert.cjs",
"./inert.cjs",
],
[
"reflected arraylike without spread",
changedIterator,
"Reflect.apply(require,null,['./inert.cjs'])",
"./inert.cjs",
"./inert.cjs",
],
[
"known argument before custom spread",
changedIterator,
"require('./inert.cjs',...[])",
"./inert.cjs",
"./inert.cjs",
],
[
"shadowed Array mutation",
"const Array={prototype:{}};Array.prototype[Symbol.iterator]=function*(){yield '../shared/window.cjs'};",
"require(...['./inert.cjs'])",
"./inert.cjs",
"./inert.cjs",
],
[
"ordinary captured generic spread",
"const get=()=>module;const invoke=get.call;",
"invoke.call(...[get,null]).require('./inert.cjs')",
"./inert.cjs",
null,
],
[
"changed captured generic spread receiver",
"const get=()=>({});const invoke=get.call;Array.prototype[Symbol.iterator]=function*(){yield ()=>module;yield null;};",
"invoke.call(...[get,null]).require('./inert.cjs')",
"./inert.cjs",
null,
],
] as const;
it.each(cases)(
"retains invocation iterator authority for %s",
(_name, setup, call, actual, specifier) => {
const source = `${setup}${call};`;
expect(
runInNewContext(
source,
{
require: (request: string) => request,
module: { require: (request: string) => request },
},
{ timeout: 1_000 },
),
).toBe(actual);
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier }),
]);
},
);
@@ -0,0 +1,56 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
"handlers[key]()",
"handlers[key].call(null)",
"handlers[key].apply(null,[])",
])("selects a local table callee before its own opaque effects: %s", (call) => {
const result = analyzeJavaScriptStaticSource(
`const handlers={first(){},second(){}};${call};`,
);
expect(result.references).toEqual([]);
expect(result.parse_status).toBe("complete");
});
it.each([
'handlers[key]=module.require;handlers[key].call(module,"./shared.cjs");',
'handlers.first=module.require;handlers.first.call(module,"./shared.cjs");',
])("retains earlier uncertainty or a real delegate: %s", (use) => {
const result = analyzeJavaScriptStaticSource(
`const handlers={first(){},second(){}};${use}`,
);
expect(result.references).toContainEqual(
expect.objectContaining({ kind: "require", specifier: null }),
);
expect(result.parse_status).toBe("partial");
});
it("retains complete coverage for an inline Electron factory export", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules={
5(module,exports){
exports.self=exports;
exports.events=require("electron").ipcMain;
},
9(module,exports,load){
const helper=load(5);
helper.self.events.addListener("test:copy",()=>{});
}
};
function load(id){
const m={exports:{}};
modules[id].call(m.exports,m,m.exports,load);
return m.exports;
}
load(9);
})();`);
expect(result.parse_status).toBe("complete");
expect(result.references.every(({ specifier }) => specifier !== null)).toBe(
true,
);
expect(result.electron.ipc).toContainEqual(
expect.objectContaining({ channel: "test:copy", side: "main" }),
);
});
@@ -0,0 +1,290 @@
import { Module, createRequire } from "node:module";
import * as path from "node:path";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { unshadowedReferenceSourceRequireCalls } from "../referenceSourceRequireBindings.js";
it.each(["require", "require.resolve", "require.main"])(
"distinguishes invoked loading from reference association for %s",
(callee) => {
const source = `${callee}('node:path');`;
const execute = () =>
runInNewContext(source, { require: createRequire(import.meta.url) });
if (callee === "require") expect(execute().join).toBe(path.join);
else if (callee === "require.resolve") expect(execute()).toBe("node:path");
else expect(execute).toThrow();
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
callee === "require"
? [expect.objectContaining({ kind: "require", specifier: "node:path" })]
: [],
);
const parsed = parseJavaScriptSource(source);
if (parsed === null) throw new Error("Expected loader association fixture");
expect(
unshadowedReferenceSourceRequireCalls(parsed, "entry.cjs").size,
).toBe(1);
},
);
const imported = "import {createRequire as make} from 'node:module';";
it.each([
[
"proven factory",
`${imported}const require=make(import.meta.url);require('node:path');`,
1,
"node:path",
],
[
"opaque replacement factory",
"import*as mod from'node:module';mod.default.createRequire=replacement;const require=mod.default.createRequire(import.meta.url);require('node:path');",
0,
null,
],
] as const)(
"uses invocation proof for external source association after a %s",
(_name, source, associated, specifier) => {
const parsed = parseJavaScriptSource(source);
if (parsed === null) throw new Error("Expected factory proof fixture");
expect(
unshadowedReferenceSourceRequireCalls(parsed, "entry.mjs").size,
).toBe(associated);
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references.filter(({ kind }) => kind === "require"),
).toEqual([expect.objectContaining({ kind: "require", specifier })]);
},
);
it.each([
[
"captured wrapper",
"const load=require;load('node:path')",
"node:path",
true,
],
[
"wrapper member",
"const box={load:require};box.load('node:path')",
"node:path",
true,
],
[
"wrapper array",
"const box=[require];box[0]('node:path')",
"node:path",
true,
],
[
"projected wrapper",
"const {load}={load:require};load('node:path')",
"node:path",
true,
],
[
"captured before wrapper replacement",
"const load=require;require=()=>({});load('node:path')",
"node:path",
true,
],
[
"shadowed wrapper",
"const require=()=>({});const load=require;load('node:path')",
undefined,
false,
],
[
"delegate replaced after capture",
"const load=require;module.require=()=>({});load('node:path')",
null,
false,
],
] as const)(
"retains the selected native %s value",
(_name, source, specifier, host) => {
const module = new Module("wrapper-alias.cjs");
const result = runInNewContext(source, {
module,
require: (request: string) => module.require(request),
});
expect(result.join === path.join).toBe(host);
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
specifier === undefined
? []
: [expect.objectContaining({ kind: "require", specifier })],
);
},
);
it.each([
[
"renamed loader",
"const load=make(import.meta.url);load('./shared.cjs')",
"./shared.cjs",
],
[
"captured renamed loader",
"const load=make(import.meta.url);const saved=load;saved('./shared.cjs')",
"./shared.cjs",
],
[
"object member loader",
"const box={load:make(import.meta.url)};box.load('./shared.cjs')",
"./shared.cjs",
],
[
"destructured loader",
"const {load}={load:make(import.meta.url)};load('./shared.cjs')",
"./shared.cjs",
],
[
"array member loader",
"const box=[make(import.meta.url)];box[0]('./shared.cjs')",
"./shared.cjs",
],
[
"generic loader invocation",
"const load=make(import.meta.url);load.call(null,'./shared.cjs')",
"./shared.cjs",
],
[
"reflected created loader",
"const load=make(import.meta.url);Reflect.apply(load,null,['./shared.cjs'])",
"./shared.cjs",
],
[
"unknown create base",
"const load=make('/elsewhere/entry.cjs');load('./shared.cjs')",
null,
],
[
"conditional create bases",
"const load=flag?make(import.meta.url):make('/elsewhere/entry.cjs');load('./shared.cjs')",
null,
],
[
"opaque loader replacement",
"let load=make(import.meta.url);load=replacement;load('./shared.cjs')",
null,
],
[
"copied child with a later leaf replacement",
"const box={child:{load:make(import.meta.url)}};const copy={...box};box.child.load=make('/elsewhere/entry.cjs');copy.child.load('./shared.cjs')",
null,
],
[
"copied child before a parent replacement",
"const box={child:{load:make(import.meta.url)}};const copy={...box};box.child={load:make('/elsewhere/entry.cjs')};copy.child.load('./shared.cjs')",
"./shared.cjs",
],
[
"spread excludes an inherited loader",
"const box={__proto__:{load:make(import.meta.url)}};const copy={...box};copy.load('./shared.cjs')",
undefined,
],
[
"local loader replacement",
"let load=make(import.meta.url);load=()=>({});load('./shared.cjs')",
undefined,
],
["local loader", "const load=()=>({});load('./shared.cjs')", undefined],
[
"shadowed create factory",
"function f(make){const load=make(import.meta.url);load('./shared.cjs')}",
undefined,
],
] as const)(
"classifies %s from the selected loader value",
(_name, body, specifier) => {
const result = analyzeJavaScriptStaticSource(`${imported}${body};`);
expect(result.parse_error_count).toBe(0);
const calls = result.references.filter(({ kind }) => kind === "require");
expect(calls).toEqual(
specifier === undefined ? [] : [expect.objectContaining({ specifier })],
);
},
);
it("preserves a renamed native createRequire callable", () => {
const load = createRequire(import.meta.url);
const saved = load;
expect(saved("node:path").join).toBe(path.join);
});
it.each(["leaf", "parent"])(
"keeps the native created loader identity after a copied child %s write",
(write) => {
const selected = runInNewContext(
`const original=make(base);const outside=make(outsideBase);
const box={child:{load:original}};const copy={...box};
${write === "leaf" ? "box.child.load=outside" : "box.child={load:outside}"};
({actual:copy.child.load,expected:${write === "leaf" ? "outside" : "original"}});`,
{
make: createRequire,
base: import.meta.url,
outsideBase: new URL("./elsewhere/entry.cjs", import.meta.url).href,
},
);
expect(selected.actual).toBe(selected.expected);
},
);
it.each([
[
"direct Reflect.apply",
"Reflect.apply(module.require,module,['node:path'])",
true,
],
[
"captured Reflect.apply",
"const invoke=Reflect.apply;invoke(module.require,module,['node:path'])",
true,
],
[
"generic Reflect.apply",
"Reflect.apply.call(null,module.require,module,['node:path'])",
true,
],
[
"Reflect.apply captured before replacement",
"const invoke=Reflect.apply;Reflect.apply=()=>({join:false});invoke(module.require,module,['node:path'])",
true,
],
[
"local Reflect",
"const Reflect={apply(){return {join:false}}};Reflect.apply(module.require,module,['node:path'])",
false,
],
[
"local callable",
"Reflect.apply(()=>({join:false}),module,['node:path'])",
false,
],
[
"local module",
"const module={require(){return {join:false}}};Reflect.apply(module.require,module,['node:path'])",
false,
],
] as const)(
"preserves module delegate provenance through %s",
(_name, source, host) => {
const result = runInNewContext(source, {
module: new Module("reflected-loader.cjs"),
});
expect(result.join === path.join).toBe(host);
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
host
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -0,0 +1,70 @@
import { createRequire } from "node:module";
import * as path from "node:path";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it("keeps the actual CommonJS module export separate from an ESM synthetic default", () => {
const require = createRequire(import.meta.url);
const exported = require("node:module");
expect(Object.hasOwn(exported, "default")).toBe(false);
expect(exported.default).toBeUndefined();
expect(() =>
runInNewContext(
"const load=require('node:module').default.createRequire(base);load('node:path');",
{ require, base: import.meta.url },
),
).toThrow();
const analysis = analyzeJavaScriptStaticSource(
"const load=require('node:module').default.createRequire(import.meta.url);load('node:path');",
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "node:module" }),
]);
});
it.each(["call", "apply"] as const)(
"retains both native and local created-loader %s alternatives",
(method) => {
const body = (base: string) =>
`const load=make(${base});if(flag)load.${method}=()=>({kind:'local'});load.${method}(null,${method === "call" ? "'node:path'" : "['node:path']"});`;
for (const flag of [false, true]) {
const result = runInNewContext(body("base"), {
make: createRequire,
base: import.meta.url,
flag,
});
expect(result.join === path.join).toBe(!flag);
if (flag) expect(result.kind).toBe("local");
}
const analysis = analyzeJavaScriptStaticSource(
`import {createRequire as make} from 'node:module';${body("import.meta.url")}`,
);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references.filter(({ kind }) => kind === "require"),
).toEqual([expect.objectContaining({ kind: "require", specifier: null })]);
},
);
it.each(["call", "apply"] as const)(
"retains both native and local captured-wrapper %s alternatives",
(method) => {
const body = `const load=require;if(flag)load.${method}=()=>({kind:'local'});load.${method}(null,${method === "call" ? "'node:path'" : "['node:path']"});`;
for (const flag of [false, true]) {
const result = runInNewContext(body, {
require: createRequire(import.meta.url),
flag,
});
expect(result.join === path.join).toBe(!flag);
if (flag) expect(result.kind).toBe("local");
}
const analysis = analyzeJavaScriptStaticSource(body);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: null }),
]);
},
);
@@ -0,0 +1,79 @@
import { createRequire } from "node:module";
import * as path from "node:path";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const named = "import {createRequire as make} from 'node:module';";
const namespace = "import * as mod from 'node:module';";
const cases = [
{
name: "own call replacement",
prefix: named,
body: (base: string) =>
`const load=make(${base});load.call=()=>({kind:'local'});load.call(null,'node:path');`,
host: false,
},
{
name: "own apply replacement",
prefix: named,
body: (base: string) =>
`const load=make(${base});load.apply=()=>({kind:'local'});load.apply(null,['node:path']);`,
host: false,
},
{
name: "callable captured before method replacement",
prefix: named,
body: (base: string) =>
`const load=make(${base});const saved=load;load.call=()=>({kind:'local'});saved('node:path');`,
host: true,
},
{
name: "mutable default factory replacement",
prefix: namespace,
body: (base: string) =>
`mod.default.createRequire=()=>()=>({kind:'local'});const load=mod.default.createRequire(${base});load('node:path');`,
host: false,
},
{
name: "factory captured before its default slot replacement",
prefix: namespace,
body: (base: string) =>
`const make=mod.default.createRequire;mod.default.createRequire=()=>()=>({kind:'local'});const load=make(${base});load('node:path');`,
host: true,
},
{
name: "loader created before its default factory replacement",
prefix: namespace,
body: (base: string) =>
`const load=mod.default.createRequire(${base});mod.default.createRequire=()=>()=>({kind:'local'});load('node:path');`,
host: true,
},
];
it.each(cases)(
"keeps loader and selected member identity for $name",
({ prefix, body, host }) => {
// The mutable namespace default belongs to this VM fixture. Native created
// require functions retain their real Node behavior without patching builtins.
const result = runInNewContext(body("base"), {
make: createRequire,
mod: { default: { createRequire } },
base: import.meta.url,
});
expect(result.join === path.join).toBe(host);
if (!host) expect(result.kind).toBe("local");
const analysis = analyzeJavaScriptStaticSource(
`${prefix}${body("import.meta.url")}`,
);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references.filter(({ kind }) => kind === "require"),
).toEqual(
host
? [expect.objectContaining({ kind: "require", specifier: "node:path" })]
: [],
);
},
);
@@ -0,0 +1,90 @@
import { Module } from "node:module";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const cases = [
["native main", "require.main.require('node:path');", true, true],
[
"captured main",
"const main=require.main;main.require('node:path');",
true,
true,
],
[
"captured delegate",
"const load=require.main.require;load.call(require.main,'node:path');",
true,
true,
],
["wrapper var", "var require;require.main.require('node:path');", true, true],
[
"local require",
"const require={main:{require:()=>({kind:'local'})}};require.main.require('node:path');",
false,
false,
],
[
"local alias",
"const load={main:{require:()=>({kind:'local'})}};load.main.require('node:path');",
false,
false,
],
[
"wrapper main replacement stays conservative",
"var require;require.main={require:()=>({kind:'local'})};require.main.require('node:path');",
false,
true,
],
[
"conditional main replacement",
"var require;if(flag)require.main={require:()=>({kind:'local'})};require.main.require('node:path');",
false,
true,
],
[
"main captured before replacement",
"const main=require.main;require.main={require:()=>({kind:'local'})};main.require('node:path');",
true,
true,
],
[
"ambient main replacement stays conservative",
"require.main={require:()=>({kind:'local'})};require.main.require('node:path');",
false,
true,
],
[
"shared deep child local write",
"const old={child:{inner:{make:()=>module}}};const box={...old};old.child={inner:box.child.inner};old.child.inner.make=()=>({require(){}});box.child.inner.make().require('node:path');",
false,
false,
],
[
"shared deep child host write",
"const old={child:{inner:{make:()=>({})}}};const box={...old};old.child={inner:box.child.inner};old.child.inner.make=()=>module;box.child.inner.make().require('node:path');",
true,
true,
],
] as const;
it.each(cases)(
"retains main-module source for %s",
(_name, source, host, unknown) => {
const module = new Module("main-module-source.cjs");
const require = Object.assign(
(request: string) => module.require(request),
{ main: module },
);
const observed = runInNewContext(source, { module, require, flag: true });
expect(typeof observed?.join === "function").toBe(host);
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
unknown
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -0,0 +1,49 @@
import { Module } from "node:module";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const cases = [
["native process", "process.mainModule.require('node:path');", true],
[
"captured main",
"const main=process.mainModule;main.require('node:path');",
true,
],
[
"captured delegate",
"const main=process.mainModule;const load=main.require;load.call(main,'node:path');",
true,
],
[
"local process",
"const process={mainModule:{require:()=>({})}};process.mainModule.require('node:path');",
false,
],
[
"replaced process",
"process={mainModule:{require:()=>({})}};process.mainModule.require('node:path');",
false,
],
["unrelated process member", "process.other.require('node:path');", false],
] as const;
it.each(cases)(
"retains the native Module loader for %s",
(_name, source, possible) => {
const main = new Module("process-main-source.cjs");
const observed = runInNewContext(source, {
process: { mainModule: main, other: { require: () => ({}) } },
});
expect(typeof observed?.join === "function").toBe(possible);
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
possible
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -0,0 +1,144 @@
import { Module } from "node:module";
import * as path from "node:path";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const projectedModuleWrites = [
[
"object assignment",
"let selected;({selected}={selected:module});selected.require=module.require;",
true,
],
[
"object declaration",
"const {selected}={selected:module};selected.require=module.require;",
true,
],
[
"array assignment",
"let selected;[selected]=[module];selected.require=module.require;",
true,
],
[
"array declaration",
"const [selected]=[module];selected.require=module.require;",
true,
],
[
"nested assignment",
"let selected;({nested:{selected}}={nested:{selected:module}});selected.require=module.require;",
true,
],
[
"assignment fallback",
"let selected;({selected=module}={});selected.require=module.require;",
true,
],
[
"nested fallback",
"let selected;({nested:{selected=module}}={nested:{}});selected.require=module.require;",
true,
],
[
"object rest child",
"let selected;({...selected}={loader:module});selected.loader.require=module.require;",
true,
],
[
"array rest child",
"let selected;[,...selected]=[0,module];selected[0].require=module.require;",
true,
],
[
"captured alias",
"let selected;({selected}={selected:module});const saved=selected;selected={};saved.require=module.require;",
true,
],
[
"captured source container",
"let source={selected:module};let selected;({selected}=source);source={selected:{}};selected.require=module.require;",
true,
],
[
"projected escape",
"let selected;({selected}={selected:module});expose(selected);",
true,
],
[
"copied child escape",
"let selected;({...selected}={loader:module});expose(selected.loader);",
true,
],
[
"inactive fallback",
"const {selected=module}={selected:{}};selected.require=module.require;",
false,
],
[
"local alias",
"let selected;({selected}={selected:{require(){}}});selected.require=module.require;",
false,
],
[
"object rest container",
"let selected;({...selected}=module);selected.require=module.require;",
false,
],
[
"rebound alias",
"let selected;({selected}={selected:module});selected={};selected.require=module.require;",
false,
],
[
"rebound fallback",
"let selected;({selected=module}={});selected={};selected.require=module.require;",
false,
],
[
"copied container escape",
"let selected;({...selected}=module);expose(selected);",
false,
],
] as const;
it.each(projectedModuleWrites)(
"tracks copied require after a projected module write: %s",
(_name, setup, expected) => {
const body = `${setup}const copy={...module};const load=copy.require;load.call(module,'node:path');`;
const host = new Module("projected-module-write-oracle.cjs");
const context = {
module: host,
expose: (value: Module) => {
value.require = host.require;
},
};
if (expected) expect(runInNewContext(body, context).join).toBe(path.join);
else expect(() => runInNewContext(body, context)).toThrow();
const result = analyzeJavaScriptStaticSource(body);
expect(result.parse_error_count).toBe(0);
expect(result.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each(["selected.require=module.require;", "expose(selected);"])(
"captures copied require before a later projected module effect: %s",
(effect) => {
const body = `let selected;({selected}={selected:module});const copy={...module};${effect}const load=copy.require;load.call(module,'node:path');`;
const host = new Module("late-module-effect.cjs");
expect(() =>
runInNewContext(body, {
module: host,
expose: (value: Module) => {
value.require = host.require;
},
}),
).toThrow();
expect(analyzeJavaScriptStaticSource(body).references).toEqual([]);
},
);
@@ -0,0 +1,180 @@
import { expect, it } from "vitest";
import * as t from "@babel/types";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
it.each([
["const get=()=>module;get.call(null)", true],
["const get=()=>module;get.apply(null,[])", true],
["const get=()=>module;get['call'](null)", true],
["const get=()=>module;const alias=get;alias.apply(null,[])", true],
["const box={get(){return module;}};box.get.call(null)", true],
["const get=()=>module;get.call.call(get,null)", true],
["const get=()=>module;get.apply.call(get,null,[])", true],
["const get=()=>module;const invoke=get.call;invoke.call(get,null)", true],
[
"const get=()=>module;const invoke=get.call;const other=()=>({});invoke.call(other,null)",
false,
],
[
"const get=()=>({});const invoke=get.call;const other=()=>module;invoke.call(other,null)",
true,
],
[
"const get=()=>module;const invoke=get.apply;invoke.call(get,null,[])",
true,
],
["const get=()=>module;const invoke=get.call;invoke.apply(get,[null])", true],
["const get=()=>({});get.call(null)", false],
["const get=()=>({});get.apply(null,[])", false],
["const get=()=>module.exports;get.call(null)", false],
["const get=()=>module;get.call=()=>({require(){}});get.call(null)", false],
[
"const get=()=>module;get.apply=()=>({require(){}});get.apply(null,[])",
false,
],
["const get=()=>({});get.call=()=>module;get.call(null)", true],
["const get=()=>({});get.apply=()=>module;get.apply(null,[])", true],
["const get=()=>module;const invoke=get.call;invoke(get)", false],
["const get=()=>module;(0,get.call)(null)", false],
["const get=()=>module;(0,get.apply)(null,[])", false],
[
"const get=()=>module;const other=()=>({});get.call.call(other,null)",
false,
],
["const get=()=>({});const other=()=>module;get.call.call(other,null)", true],
["function* get(){yield module;}get.call(null)", false],
["async function get(){return module;}get.apply(null,[])", false],
["async function get(){return module;}(await get.call(null))", true],
["const get=()=>module;get.call=unknown;get.call(null)", true],
["const get=()=>module;if(flag)get.call=()=>({});get.call(null)", true],
[
"const get=()=>({});Object.setPrototypeOf(get,{call:()=>module});get.call(null)",
true,
],
[
"const get=()=>module;Object.setPrototypeOf(get,{call:()=>({})});get.call(null)",
true,
],
[
"const get=()=>1;Object.setPrototypeOf(get,{call:()=>module});get.call(null)",
true,
],
[
"const get=()=>({});Object.setPrototypeOf(get,{call:()=>module});get.call=()=>({});get.call(null)",
false,
],
[
"const get=()=>({});Object.setPrototypeOf(get,{call:()=>({})});get.call=()=>module;get.call(null)",
true,
],
])(
"retains invocation results after selecting source-local member identity: %s",
(receiver, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${receiver}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
["Function.prototype.call=unknown;const get=()=>({});get.call(null)", true],
[
"Function.prototype.apply=unknown;const get=()=>({});get.apply(null,[])",
true,
],
[
"Function.prototype.call=unknown;const get=()=>module;get.call=()=>({});get.call(null)",
false,
],
["Function.prototype.extra=unknown;const get=()=>({});get.call(null)", false],
])(
"retains inherited invocation uncertainty without revoking own slots: %s",
(receiver, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${receiver}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected ? [expect.objectContaining({ specifier: null })] : [],
);
},
);
it("keeps source reads before later function-method replacement", () => {
const analysis = analyzeJavaScriptStaticSource(`
const get=()=>module;
get.call(null).require('./shared.cjs');
get.call=()=>({});
get.call(null).require('./unused.cjs');
`);
expect(analysis.references).toEqual([
expect.objectContaining({ specifier: null }),
]);
});
it("keeps an earlier native invocation before a later prototype write", () => {
const analysis = analyzeJavaScriptStaticSource(`
const get=()=>({});
get.call(null).require('./unused.cjs');
Function.prototype.call=unknown;
`);
expect(analysis.references).toEqual([]);
});
it("does not treat a shadowed Function namespace as native prototype mutation", () => {
const analysis = analyzeJavaScriptStaticSource(`
function local(Function){
Function.prototype.call=unknown;
const get=()=>({});
get.call(null).require('./unused.cjs');
}
`);
expect(analysis.references).toEqual([]);
});
it("retains a finite shared callable alias graph", () => {
const aliases = Array.from(
{ length: 64 },
(_, index) => `const get${index + 1}=flag?get${index}:get${index};`,
).join("");
const parsed = parseJavaScriptSource(
`const get0=()=>module;${aliases}get64.apply(null,[]);`,
);
if (parsed === null) throw new Error("Expected valid shared alias fixture");
const index = completeSemanticSteps(
indexJavaScriptBindingsWithReceiversSteps(parsed.program),
);
completeSemanticSteps(index.indexAmbientWritesSteps());
completeSemanticSteps(index.indexMemberMutationFactsSteps());
const statement = parsed.program.body.at(-1);
if (
!t.isExpressionStatement(statement) ||
!t.isCallExpression(statement.expression)
)
throw new Error("Expected final invocation");
const call = statement.expression;
const result = createSemanticReturnedReferences(
index.bindings,
"direct-call",
)(call.callee, call);
expect(result).toEqual({
references: [
{
node: expect.objectContaining({ type: "Identifier", name: "module" }),
projection: [],
},
],
precision: "exact",
});
});
@@ -0,0 +1,285 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
["async function get(){return module;}(await get())", true],
["async function get(){return module;}const api=await get();api", true],
[
"async function get(){return module;}const pending=get();const api=await pending;api",
true,
],
["function get(){return module;}const api=await get();api", true],
["function* get(){yield module;}const api=await get();api", false],
["function* get(){return module;}const api=await get();api", false],
["async function* get(){yield module;}const api=await get();api", false],
[
"async function get(){return {require(){}};}const api=await get();api",
false,
],
["async function get(){return module;}const api=get();api", false],
[
"async function child(){return module;}async function get(){return {child:child()};}(await get()).child",
false,
],
[
"async function child(){return module;}function get(){return {child:child()};}(await get().child)",
true,
],
[
"async function child(){return module;}async function get(){return {child:child()};}const {child:pending}=await get();const api=await pending;api",
true,
],
[
"async function get(){return module;}const pending=get();const api=flag?pending:await pending;api",
true,
],
[
"async function get(){return module;}const pending=get();const api=flag?await pending:pending;api",
true,
],
[
"async function inner(){return module;}function get(){return inner();}(await get())",
true,
],
])(
"preserves awaited result consumption through aliases and projections: %s",
(receiver, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${receiver}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
"const {make=host}={make:class{}};",
"class Local{}const {make=host}={make:Local};",
"const [make=host]=[class{}];",
])(
"excludes inactive defaults for known class constructors: %s",
(declaration) => {
const analysis = analyzeJavaScriptStaticSource(
`function host(){return module;}${declaration}new make().require=()=>{};require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }),
]);
},
);
it.each([
["const {make=host}={make(){return {};}};", false],
["const {make=host}={make:()=>({})};", false],
["const [make=host]=[()=>({})];", false],
["const {box:{make=host}={}}={box:{make(){return {};}}};", false],
["const {make=host}={};", true],
["const [make=host]=[];", true],
["const {box:{make=host}={}}={};", true],
["const {make=host}=unknown;", true],
["const [make=host]=unknown;", true],
["const {make=host}={make:unknown};", true],
["const {make=host}={make:undefined};", true],
["const [make=host]=[undefined];", true],
["const {make=host}={get make(){return undefined;}};", true],
["const {make=host}={make:flag?()=>({}):()=>({})};", false],
[
"const values=[()=>({})];values[Symbol.iterator]=function*(){yield undefined;};const [make=host]=values;",
true,
],
[
"Array.prototype[Symbol.iterator]=function*(){yield undefined;};const [make=host]=[()=>({})];",
true,
],
["const {make=host}={make:host};", true],
["const [make=host]=[host];", true],
["const make=host;", true],
["const make=()=>({});", false],
])(
"retains only applicable default callable sources: %s",
(declaration, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`function host(){return module;}${declaration}make().require=()=>{};require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references.at(-1)).toEqual(
expect.objectContaining({
kind: "require",
specifier: expected ? null : "./shared.cjs",
}),
);
},
);
it.each([
"function* get(){yield module;}",
"function* get(){return module;}",
"async function get(){return module;}",
"async function* get(){yield module;}",
"const get=async()=>module;",
])(
"distinguishes direct wrapper objects from their eventual contents: %s",
(declaration) => {
const analysis = analyzeJavaScriptStaticSource(
`${declaration}const api=get();api.require=()=>{};api.require('./unused.cjs');require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }),
]);
},
);
it.each([
"const table={x(){return{};},y(){return{};},z(){return module;}};const get=flag?table.x:table.y;",
"const table={a:{b(){return{};},d(){return module;}},c:{d(){return{};},b(){return module;}}};const get=flag?table.a.b:table.c.d;",
])("retains precise selected direct-return receivers: %s", (declaration) => {
const analysis = analyzeJavaScriptStaticSource(
`${declaration}get().require('./unused.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual([]);
});
it.each([
["Object.prototype[0]=module;", true],
["Object.prototype['0']=module;", true],
["Object.prototype.constructor.prototype[0]=module;", true],
["globalThis.Object.prototype[0]=module;", true],
["global.Object.prototype[0]=module;", true],
["window.Object.prototype[0]=module;", true],
["self.Object.prototype[0]=module;", true],
["Object.prototype['01']=module;", false],
["Object.prototype.extra=module;", false],
["Object.prototype.require=replacement;", false],
["Array.prototype[0]=module;", true],
["Array.prototype['0']=module;", true],
["Array.prototype.constructor.prototype[0]=module;", true],
["Array.prototype['01']=module;", false],
["Array.prototype.extra=module;", false],
["Array.prototype.require=replacement;", false],
])("retains inherited numeric array iteration slots: %s", (write, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${write}const [api]=[,];api.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
});
it.each([
"function local(Object){Object.prototype[0]=module;const[api]=[,];api.require('./shared.cjs');}",
"function local(globalThis){globalThis.Object.prototype[0]=module;const[api]=[,];api.require('./shared.cjs');}",
])("retains shadowed prototype controls: %s", (source) => {
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual([]);
});
it.each([
[
"Array.prototype.constructor.prototype[Symbol.iterator]=function*(){yield module;};",
true,
],
[
"Array.prototype.constructor.prototype.constructor.prototype[Symbol.iterator]=custom;",
true,
],
["Array.prototype.constructor.prototype.extra=1;", false],
["Array.prototype.constructor.prototype.require=replacement;", false],
["Array.prototype.require=replacement;", false],
])(
"tracks iterator changes through prototype constructor cycles: %s",
(write, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${write}const [selected]=[];selected.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
["const get=()=>module;get()", true],
["function get(){return module;}get()", true],
["const get=()=>module;const alias=get;alias()", true],
["const get=()=>({loader:module});get().loader", true],
["const get=()=>flag?module:{};get()", true],
["const inner=()=>module;const get=()=>inner();get()", true],
["(()=>module)()", true],
["const get=()=>({require(){}});get()", false],
["const get=()=>module.exports;get()", false],
["function get(module){return module;}get({require(){}})", false],
["let get=()=>module;get=()=>({require(){}});get()", false],
])("retains possible returned module receivers: %s", (receiver, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${receiver}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
});
it.each([
[
"const [api]=[module];Array.prototype.extra=1;api.require=replacement;",
true,
],
[
"const [api]=[module];Array.prototype[Symbol.iterator]=custom;api.require=replacement;",
true,
],
["const [api]=[{}];Array.prototype.extra=1;api.require=replacement;", false],
[
"const values={*[Symbol.iterator](){yield module;}};const [api]=values;api.require=replacement;",
true,
],
[
"const values=[{}];values[Symbol.iterator]=function*(){yield module;};const [api]=values;api.require=replacement;",
true,
],
[
"Array.prototype[Symbol.iterator]=custom;const [api]=[{}];api.require=replacement;",
true,
],
["const get=()=>module;get().require=replacement;", true],
["const get=()=>({require(){}});get().require=replacement;", false],
])(
"retains host delegate uncertainty after possible receiver writes: %s",
(write, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${write}require('./shared.cjs');`,
);
expect(analysis.references).toEqual([
expect.objectContaining({
kind: "require",
specifier: expected ? null : "./shared.cjs",
}),
]);
},
);
it("preserves a require before later returned-receiver delegate writes", () => {
const analysis = analyzeJavaScriptStaticSource(
`require('./shared.cjs');const get=()=>module;get().require=replacement;`,
);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }),
]);
});
@@ -0,0 +1,83 @@
import changedPath from "node:path";
import path from "node:path";
import posix from "node:path/posix";
import changedUrl from "node:url";
import url from "node:url";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it("observes that separate Node default imports share mutable helper slots", () => {
expect(changedPath).toBe(path);
expect(changedUrl).toBe(url);
expect(path.posix).toBe(posix);
const resolve = changedPath.resolve;
const decode = changedUrl.fileURLToPath;
try {
changedPath.resolve = (first) => first;
changedUrl.fileURLToPath = () => "/changed/main.mjs";
expect(path.resolve("/runtime/actual.cjs", "/source", "decoy.cjs")).toBe(
"/runtime/actual.cjs",
);
expect(url.fileURLToPath("file:///source/main.mjs")).toBe(
"/changed/main.mjs",
);
} finally {
changedPath.resolve = resolve;
changedUrl.fileURLToPath = decode;
}
});
it.each([
[
"path resolve",
"import changed from 'node:path';import path from 'path';changed.resolve=first=>first;",
"path.resolve(runtimePath,__dirname,'decoy.cjs')",
],
[
"POSIX path resolve",
"import changed from 'node:path/posix';import path from 'path/posix';changed.resolve=first=>first;",
"path.resolve(runtimePath,__dirname,'decoy.cjs')",
],
[
"decoded URL source anchor",
"import changed from 'node:url';import url from 'url';import path from 'node:path';changed.fileURLToPath=()=>'/changed/main.mjs';",
"path.resolve(runtimePath,path.dirname(url.fileURLToPath(import.meta.url)),'decoy.cjs')",
],
[
"POSIX exported child identity",
"import changed from 'node:path/posix';import path from 'node:path';changed.resolve=first=>first;",
"path.posix.resolve(runtimePath,__dirname,'decoy.cjs')",
],
[
"direct POSIX module identity",
"import changed from 'node:path';import path from 'node:path/posix';changed.posix.resolve=first=>first;",
"path.resolve(runtimePath,__dirname,'decoy.cjs')",
],
] as const)(
"retains shared default writes before proving %s",
(_name, setup, expression) => {
const analysis = analyzeJavaScriptStaticSource(`${setup}
import {BrowserWindow,utilityProcess} from 'electron';
new BrowserWindow({webPreferences:{preload:${expression}}});
utilityProcess.fork(${expression});`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: null },
]);
expect(analysis.electron.utility_processes).toMatchObject([
{ module_path: null },
]);
},
);
it("distinguishes replacing a path child slot from changing its exported object", () => {
const analysis = analyzeJavaScriptStaticSource(`
import changed from 'node:path';import path from 'node:path/posix';
changed.posix={resolve(first){return first}};
import{BrowserWindow}from'electron';
new BrowserWindow({webPreferences:{preload:path.resolve(runtimePath,__dirname,'decoy.cjs')}});`);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: "decoy.cjs" },
]);
});
@@ -1,6 +1,7 @@
import type * as t from "@babel/types";
import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js";
import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
import type { JavaScriptStaticPathIntrinsicQuery } from "./javascriptStaticAnalysisHelpers.js";
import type {
JavaScriptBundlerRegistration,
@@ -11,6 +12,7 @@ import type {
JavaScriptStaticStorage,
} from "./javascriptStaticAnalysisTypes.js";
import type {
ElectronAppPathChangeFinding,
ElectronCallPath,
ElectronBrowserWindowFinding,
ElectronContextBridgeFinding,
@@ -38,6 +40,12 @@ export interface LocatedJavaScriptFinding<Value> {
/** Mutable collections used during one inert AST traversal. */
export interface JavaScriptAnalysisAccumulator {
readonly pendingModuleRequireCalls: (
| t.CallExpression
| t.OptionalCallExpression
| t.NewExpression
)[];
readonly appPathChanges: LocatedJavaScriptFinding<ElectronAppPathChangeFinding>[];
electronCallPaths?: readonly ElectronCallPath[];
electronCallPathLimitReached?: boolean;
readonly references: LocatedJavaScriptFinding<JavaScriptStaticReference>[];
@@ -80,6 +88,8 @@ export interface JavaScriptAnalysisAccumulator {
/** Shared source and accumulator for helper inspections. */
export interface JavaScriptFindingContext {
/** Node path.resolve identity established by shared callee and mutation owners. */
readonly nodePathResolve?: JavaScriptStaticPathIntrinsicQuery;
readonly rpcScan: JavaScriptRpcScan;
readonly source: string;
readonly accumulator: JavaScriptAnalysisAccumulator;
@@ -87,6 +97,12 @@ export interface JavaScriptFindingContext {
readonly openReceiverFacts?: ReadonlyMap<number, JavaScriptOpenReceiverFact>;
/** Proven Electron export path segments by root offset. */
readonly electronBindings?: ReadonlyMap<number, readonly string[]>;
/** Normalized invocation target from the shared Electron and intrinsic owners. */
readonly electronInvocationPath?: (
node: t.CallExpression | t.OptionalCallExpression,
) => readonly string[] | undefined;
/** A possible shared-app mutation does not establish concrete API identity. */
readonly electronAppPathChangePossible?: (node: t.Node) => boolean;
}
/** Candidate import, require, worker, or service-worker reference. */
@@ -120,6 +136,8 @@ export interface LocatedJavaScriptFindingInput<
export const createJavaScriptAnalysisAccumulator = (
sourceLength: number,
): JavaScriptAnalysisAccumulator => ({
pendingModuleRequireCalls: [],
appPathChanges: [],
references: [],
endpoints: [],
pendingNetworkCalls: [],
@@ -0,0 +1,55 @@
import { Module, stripTypeScriptTypes } from "node:module";
import { runInNewContext } from "node:vm";
import * as t from "@babel/types";
import { expect, it } from "vitest";
import { semanticWriteTargets } from "./javascriptAstValues.js";
import { semanticIterationSources } from "./javascriptSemanticIterationSources.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each(
[false, true].flatMap((typed) =>
[false, true].map((host) => ({ typed, host })),
),
)(
"retains the selected member store with types=$typed and host=$host",
({ typed, host }) => {
const target = typed ? "(box.make as any)" : "box.make";
const source = `const box={make:()=>({require(){}})};${target}=()=>${host ? "module" : "({require(){}})"};box.make().require('./shared.cjs');`;
const parsed = parseJavaScriptSource(source);
if (parsed === null) throw new Error("Expected a parsed member store");
const statement = parsed.program.body[1];
if (
!t.isExpressionStatement(statement) ||
!t.isAssignmentExpression(statement.expression)
)
throw new Error("Expected the member assignment");
const assignment = statement.expression;
const stores = semanticIterationSources(assignment, statement).filter(
({ kind }) => kind === "reference-store",
);
expect(stores).toHaveLength(1);
expect(stores[0]?.target).toBe(
[...semanticWriteTargets(assignment.left)][0],
);
expect(stores[0]?.node).toBe(assignment.right);
expect(stores[0]?.projection).toEqual([]);
const requests: string[] = [];
const module = new Module("typed-member-store-oracle.cjs");
module.require = (request) => {
requests.push(request);
return {};
};
runInNewContext(stripTypeScriptTypes(source), { module });
expect(requests).toEqual(host ? ["./shared.cjs"] : []);
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
host
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -0,0 +1,73 @@
import * as t from "@babel/types";
import { builtinDataEffectCallablePath } from "./javascriptSemanticDataEffectHelpers.js";
import type { JavaScriptBindingIndex } from "./javascriptSemanticState.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import { evaluateSemanticExpressionProvenance } from "./javascriptSemanticValues.js";
import {
isUnmodifiedGlobalReference,
isUnmodifiedImportMetaUrl,
} from "./javascriptSemanticAmbientWrites.js";
import { unwrapJavaScriptExpression } from "./javascriptAstValues.js";
import type { JavaScriptStaticPathIntrinsicQuery } from "./javascriptStaticAnalysisHelpers.js";
/** Prove Node's path resolver before applying its argument-selection semantics. */
export const createStaticNodePathResolveQuery = (
index: JavaScriptBindingIndex,
): JavaScriptStaticPathIntrinsicQuery => {
const facts = new WeakMap<t.Node, boolean>();
const nativeMethod = (
callee: t.Node,
method: string,
modules: readonly string[],
): boolean => {
const provenance = evaluateSemanticExpressionProvenance(
callee,
index.bindings,
);
if (
provenance.status !== "module" ||
provenance.origins[0].importedPath.at(-1) !== method ||
!modules.includes(provenance.origins[0].specifier.replace(/^node:/u, ""))
)
return false;
completeSemanticSteps(index.indexMemberMutationFactsSteps());
const path = builtinDataEffectCallablePath(callee, index.bindings, modules);
return (
path !== null &&
((path.length === 1 && path[0] === method) ||
(modules.includes("path") &&
path.length === 2 &&
path[0] === "posix" &&
path[1] === method))
);
};
const sourceFile = (node: t.Node | undefined): boolean => {
if (node === undefined) return false;
node = unwrapJavaScriptExpression(node).node;
if (t.isIdentifier(node, { name: "__filename" }))
return isUnmodifiedGlobalReference(node, index.bindings, "__filename");
return (
t.isCallExpression(node) &&
node.arguments.length === 1 &&
nativeMethod(node.callee, "fileURLToPath", ["url"]) &&
isUnmodifiedImportMetaUrl(node.arguments[0], index.bindings)
);
};
return (callee, fact) => {
if (fact === "source-directory") {
callee = unwrapJavaScriptExpression(callee).node;
return t.isIdentifier(callee, { name: "__dirname" })
? isUnmodifiedGlobalReference(callee, index.bindings, "__dirname")
: t.isCallExpression(callee) &&
callee.arguments.length === 1 &&
nativeMethod(callee.callee, "dirname", ["path", "path/posix"]) &&
sourceFile(callee.arguments[0]);
}
const cached = facts.get(callee);
if (cached !== undefined) return cached;
const proven = nativeMethod(callee, "resolve", ["path", "path/posix"]);
facts.set(callee, proven);
return proven;
};
};
@@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { parserPluginsForPath } from "./javascriptSourceParser.js";
import {
calleeName,
staticPath,
staticPathResolutionContext,
} from "./javascriptStaticAnalysisHelpers.js";
@@ -27,6 +28,22 @@ const anchoredExpressions = [
['path[`resolve`](__dirname, "preload.js")', "preload.js"],
['path.posix.resolve("ignored", __dirname, "preload.js")', "preload.js"],
['path.resolve("/ignored", __dirname, "preload.js")', "preload.js"],
['path.resolve(runtimeBase, "/app/preload.js")', "/app/preload.js"],
['path.resolve(runtimeBase, __dirname, "preload.js")', "preload.js"],
['path.resolve(...parts, "/app/preload.js")', "/app/preload.js"],
['path.resolve(42, "/app/preload.js")', "/app/preload.js"],
[
'path.resolve(new URL("./", import.meta.url), __dirname, "preload.js")',
"preload.js",
],
[
'path.resolve(new URL("./", import.meta.url), dirname(fileURLToPath(import.meta.url)), "preload.js")',
"preload.js",
],
[
'path.posix.resolve(new URL("./", import.meta.url), "/app", "preload.js")',
"/app/preload.js",
],
[
'path.posix.resolve("ignored", dirname(fileURLToPath(import.meta.url)), "preload.js")',
"preload.js",
@@ -58,13 +75,9 @@ const unknownExpressions = [
"path.posix.resolve()",
'path.resolve("sub", "../preload.js")',
'path.resolve("", "")',
'path.resolve(runtimeBase, "/app/preload.js")',
'path.resolve("/app", runtimeFile)',
'path.resolve(runtimeBase, __dirname, "preload.js")',
"path.resolve(__dirname, runtimeFile)",
'path.resolve(...parts, "/app/preload.js")',
'path.resolve("/app", ...parts)',
'path.resolve(42, "/app/preload.js")',
'path.resolve("/app", null)',
"path.resolve(`/app/${runtimeFile}`)",
'path.win32.resolve("/app", "/shared/preload.js")',
@@ -73,6 +86,12 @@ const unknownExpressions = [
'resolve("/app", "preload.js")',
'__dirname + "preload.js"',
'path.dirname(__filename) + "preload.js"',
'new URL("./pre%2Fload.js", import.meta.url)',
'path.join(new URL("./", import.meta.url), "preload.js")',
'path.resolve(new URL("./", import.meta.url), "preload.js")',
'path.join(__dirname, new URL("./pre%2Fload.js", import.meta.url))',
'path.resolve(__dirname, new URL("./", import.meta.url), "preload.js")',
'path.resolve("/app", new URL("./", import.meta.url), "preload.js")',
];
const pathValueExpressions = [
@@ -324,6 +343,36 @@ it.each([
);
});
it("decodes a source-relative file URL once before composing a filesystem path", () => {
assertPathProjection(
'fileURLToPath(new URL("./pre%252Fload.js", import.meta.url))',
"./pre%2Fload.js",
"filesystem-expression",
);
assertPathProjection(
'path.join(dirname(fileURLToPath(import.meta.url)), "pre%2Fload.js")',
"pre%2Fload.js",
"filesystem-expression",
);
});
it("retains Worker URL references through their separate URL-aware producer", () => {
const specifier = "./worker%252Fentry.js?version=1#worker";
const analysis = analyzeJavaScriptStaticSource(`
import { Worker } from "node:worker_threads";
new Worker(new URL(${JSON.stringify(specifier)}, import.meta.url));
`);
expect(analysis.parse_status).toBe("complete");
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references
.filter(({ kind }) => kind === "worker")
.map(({ specifier, expression }) => ({ specifier, expression })),
).toEqual([{ specifier, expression: null }]);
expect(analysis.role_paths).toEqual([]);
});
it.each([
'fileURLToPath("file:///app/%2Fpreload.js")',
'fileURLToPath("file:///app/%5Cpreload.js")',
@@ -344,7 +393,8 @@ const parsePathExpression = (expression: string) =>
const analyzePathConsumers = (expression: string) => {
const property = `preload: ${expression}`;
const genericPrefix = "const options = { ";
const genericPrefix =
'import path, {dirname} from "node:path"; import {fileURLToPath} from "node:url"; const options = { ';
const windowPrefix = "new BrowserWindow({ webPreferences: { ";
const windowExpression = `${windowPrefix}${property} } })`;
const utilityExpression = `utilityProcess.fork(${expression})`;
@@ -366,8 +416,24 @@ const assertPathProjection = (
context: JavaScriptStaticPathContext,
) => {
const node = parsePathExpression(expression);
expect(staticPath(node)).toBe(path);
expect(staticPathResolutionContext(node)).toBe(context);
// This syntax matrix supplies identity explicitly; full-source cases prove it.
const nodePathResolve = (
callee: import("@babel/types").Node,
fact?: "source-directory",
) => {
if (fact === "source-directory") {
const name = calleeName(
callee.type === "CallExpression" ? callee.callee : callee,
);
return (
name === "__dirname" || name === "dirname" || name.endsWith(".dirname")
);
}
const name = calleeName(callee);
return name.endsWith(".resolve") && !name.includes("win32");
};
expect(staticPath(node, nodePathResolve)).toBe(path);
expect(staticPathResolutionContext(node, nodePathResolve)).toBe(context);
const {
analysis,
property,
@@ -0,0 +1,166 @@
import { posix } from "node:path";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each(["/other", "relative"])(
"keeps a shadowed source-directory parameter dynamic: %s",
(directory) => {
const actual = posix.resolve("/runtime/base", directory, "decoy.cjs");
expect(actual).not.toBe("/source/app/decoy.cjs");
const analysis = analyzeJavaScriptStaticSource(`
const path = require('node:path');
const {BrowserWindow, utilityProcess} = require('electron');
function build(__dirname) {
new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,__dirname,'decoy.cjs')}});
utilityProcess.fork(path.resolve(runtimeBase,__dirname,'decoy.cjs'));
}
build(${JSON.stringify(directory)});
`);
expect(analysis.role_paths).toEqual([]);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: null, preload_resolution_context: null },
]);
expect(analysis.electron.utility_processes).toMatchObject([
{ module_path: null, module_resolution_context: null },
]);
},
);
const localResolvers = [
[
"local object",
"const helper = {resolve(first) {return first;}};",
"helper.resolve",
],
[
"familiar name",
"const path = {resolve(first) {return first;}};",
"path.resolve",
],
[
"shadowed require",
"const require = () => ({resolve(first) {return first;}}); const helper = require('node:path');",
"helper.resolve",
],
[
"replaced Node method",
"const helper = require('node:path'); helper.resolve = first => first;",
"helper.resolve",
],
[
"ambiguous owner",
"const helper = flag ? require('node:path') : {resolve(first) {return first;}};",
"helper.resolve",
],
] as const;
it.each(localResolvers)(
"keeps %s resolve calls dynamic",
(_name, setup, callee) => {
const expression = `${callee}(runtimePath, __dirname, "decoy.cjs")`;
const native = new Function(
"require",
"runtimePath",
"__dirname",
"flag",
`{ ${setup} return ${expression}; }`,
);
expect(
native(
() => ({ ...posix }),
"/actual/runtime.cjs",
"/fixture/app",
false,
),
).toBe("/actual/runtime.cjs");
const analysis = analyzeJavaScriptStaticSource(`
import {BrowserWindow, utilityProcess} from 'electron';
${setup}
new BrowserWindow({webPreferences:{preload:${expression}}});
utilityProcess.fork(${expression});
`);
expect(analysis.role_paths).toEqual([]);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: null, preload_resolution_context: null },
]);
expect(analysis.electron.utility_processes).toMatchObject([
{ module_path: null, module_resolution_context: null },
]);
},
);
const nodeResolvers = [
["require", "const helper = require('node:path');", "helper.resolve"],
[
"destructured alias",
"const {resolve: select} = require('path');",
"select",
],
[
"POSIX namespace",
"const helper = require('node:path');",
"helper.posix.resolve",
],
[
"POSIX alias",
"const {posix: helper} = require('node:path');",
"helper.resolve",
],
[
"POSIX module",
"const helper = require('node:path/posix');",
"helper.resolve",
],
["default import", "import helper from 'node:path';", "helper.resolve"],
[
"namespace import",
"import * as helper from 'node:path';",
"helper.posix.resolve",
],
["named import", "import {resolve as select} from 'node:path';", "select"],
] as const;
it("does not treat a constructed Node resolver as a path call", () => {
expect(() => Reflect.construct(posix.resolve, ["/app", "decoy.cjs"])).toThrow(
TypeError,
);
const analysis = analyzeJavaScriptStaticSource(`
const helper = require('node:path');
const {BrowserWindow} = require('electron');
new BrowserWindow({webPreferences:{preload:new helper.resolve(runtimePath,__dirname,'decoy.cjs')}});
`);
expect(analysis.role_paths).toEqual([]);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: null },
]);
});
it.each(nodeResolvers)(
"retains proven Node %s right-to-left anchoring",
(_name, setup, callee) => {
expect(
posix.resolve("/actual/runtime.cjs", "/fixture/app", "decoy.cjs"),
).toBe("/fixture/app/decoy.cjs");
const expression = `${callee}(runtimePath, __dirname, "decoy.cjs")`;
const analysis = analyzeJavaScriptStaticSource(`
const {BrowserWindow, utilityProcess} = require('electron');
${setup}
new BrowserWindow({webPreferences:{preload:${expression}}});
utilityProcess.fork(${expression});
`);
expect(analysis.electron.browser_windows).toMatchObject([
{
preload_path: "decoy.cjs",
preload_resolution_context: "filesystem-expression",
},
]);
expect(analysis.electron.utility_processes).toMatchObject([
{
module_path: "decoy.cjs",
module_resolution_context: "filesystem-expression",
},
]);
},
);
@@ -0,0 +1,121 @@
import { createRequire, wrap } from "node:module";
import { posix } from "node:path";
import { fileURLToPath } from "node:url";
import { runInThisContext } from "node:vm";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([false, true])(
"preserves only untouched authored CommonJS dirname vars: assigned=%s",
(assigned) => {
const setup = `const path=require('node:path/posix');var __dirname;${assigned ? '__dirname="relative";' : ""}`;
const fixture = { exports: "" };
const run = runInThisContext(
wrap(`${setup}module.exports=path.join(__dirname,'preload.cjs');`),
);
run(
fixture.exports,
createRequire(import.meta.url),
fixture,
"/source/app/main.cjs",
"/source/app",
);
expect(fixture.exports).toBe(
assigned ? "relative/preload.cjs" : "/source/app/preload.cjs",
);
const analysis = analyzeJavaScriptStaticSource(
`${setup}const{BrowserWindow}=require('electron');new BrowserWindow({webPreferences:{preload:path.join(__dirname,'preload.cjs')}});`,
);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: assigned ? null : "preload.cjs" },
]);
},
);
it("keeps local dirname methods from proving a source anchor", () => {
const helper = { dirname: (_url: string) => "/other" };
expect(
posix.resolve(
"/runtime/base",
helper.dirname("file:///source/app/main.mjs"),
"decoy.cjs",
),
).toBe("/other/decoy.cjs");
const analysis = analyzeJavaScriptStaticSource(
`import path from 'node:path';const helper={dirname:()=>'/other'};const{BrowserWindow}=require('electron');new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,helper.dirname(import.meta.url),'decoy.cjs')}});`,
);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: null },
]);
});
it("retains a proven Node dirname of the decoded module URL", () => {
expect(
posix.dirname(
fileURLToPath("file:///source/app/main.mjs", { windows: false }),
),
).toBe("/source/app");
const analysis = analyzeJavaScriptStaticSource(
`import path from 'node:path';import{fileURLToPath}from'node:url';const{BrowserWindow}=require('electron');new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,path.dirname(fileURLToPath(import.meta.url)),'preload.cjs')}});`,
);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: "preload.cjs" },
]);
});
it.each([false, true])(
"preserves a decoded module URL through its real dirname owner: local=%s",
(local) => {
const decoded = fileURLToPath("file:///source/app/main.mjs", {
windows: false,
});
const helper = { dirname: (_file: string) => "/other" };
expect(local ? helper.dirname(decoded) : posix.dirname(decoded)).toBe(
local ? "/other" : "/source/app",
);
const analysis = analyzeJavaScriptStaticSource(`
import path from 'node:path';import*as url from 'node:url';
const helper={dirname:()=>'/other'};const{BrowserWindow}=require('electron');
new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,${local ? "helper" : "path"}.dirname(url.fileURLToPath(import.meta.url)),'preload.cjs')}});
`);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: local ? null : "preload.cjs" },
]);
},
);
it("does not use a URL string as a filesystem source-file anchor", () => {
expect(posix.dirname("file:///source/app/main.mjs")).not.toBe("/source/app");
const analysis = analyzeJavaScriptStaticSource(
`import path from 'node:path';const{BrowserWindow}=require('electron');new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,path.dirname(import.meta.url),'decoy.cjs')}});`,
);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: null },
]);
});
it.each([false, true])(
"proves the CommonJS filename dirname owner: local=%s",
(local) => {
const analysis = analyzeJavaScriptStaticSource(
`const path=require('node:path');const helper={dirname:()=>'.'};const{BrowserWindow}=require('electron');new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,${local ? "helper" : "path"}.dirname(__filename),'decoy.cjs')}});`,
);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: local ? null : "decoy.cjs" },
]);
},
);
it.each([false, true])(
"preserves the utility-process dirname parameter until assigned: assigned=%s",
(assigned) => {
const analysis = analyzeJavaScriptStaticSource(
`var __dirname;${assigned ? "__dirname='relative';" : ""}const path=require('node:path');const{utilityProcess}=require('electron');utilityProcess.fork(path.join(__dirname,'worker.cjs'));`,
);
expect(analysis.electron.utility_processes).toMatchObject([
{ module_path: assigned ? null : "worker.cjs" },
]);
},
);
+7 -5
View File
@@ -39,11 +39,13 @@ export const unshadowedReferenceSourceRequireCalls = (
if (!t.isCallExpression(node)) return;
const identifier = requireCalleeIdentifier(node.callee);
if (identifier === undefined) return;
const fact = semanticRequireLoaderAt(
node,
index.bindings,
factoryBindings,
);
let fact = semanticRequireLoaderAt(node, index.bindings, factoryBindings);
if (fact === "create-require") {
// Definitions identify a possible factory. Its mutable member reads
// need the shared write index before they can prove an association.
completeSemanticSteps(index.indexMemberMutationFactsSteps());
fact = semanticRequireLoaderAt(node, index.bindings, factoryBindings);
}
if (
fact === "create-require" ||
(fact === "commonjs-loader" && wrapperMode !== "absent")
@@ -8,6 +8,41 @@ import {
} from "./JavaScriptAnalysisWorkerSchemas.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterExpansion.js";
import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js";
it("transfers bounded alias selection coverage across the worker boundary", () => {
const source = aliasSelectionBudgetSource();
const facts = analyzeJavaScriptStaticSource(source);
const ir = analyzeJavaScriptSemantics(source);
const header = { coverage: ir.coverage, limitations: ir.limitations };
expect(ir.coverage).toMatchObject({
status: "partial",
resourceLimits: ["alias-selections"],
});
expect(
javaScriptWorkerModuleHeaderSchema.parse(
JSON.parse(JSON.stringify(header)) as unknown,
),
).toEqual(header);
expect(
javaScriptWorkerStaticAnalysisSchema.parse(
JSON.parse(JSON.stringify(facts)) as unknown,
),
).toEqual(facts);
});
it("retains application path change identity and provenance across the worker boundary", () => {
const facts = analyzeJavaScriptStaticSource(`
const { app } = require("electron");
app.setAppPath("./other");
`);
expect(facts.electron.app_path_changes).toHaveLength(1);
expect(
javaScriptWorkerStaticAnalysisSchema.parse(
JSON.parse(JSON.stringify(facts)) as unknown,
),
).toEqual(facts);
});
it("transfers getter expansion limits and unknown bindings across the worker boundary", () => {
const ir = analyzeJavaScriptSemantics(getterExpansionSource());
@@ -155,6 +155,9 @@ const bridgeShape = {
const utilityShape = { ...findingContext, service_name: z.string().nullable() };
const electron = z
.strictObject({
app_path_changes: z
.array(z.strictObject(findingContext).transform(withRuntime))
.optional(),
call_paths: z
.array(
z.strictObject({
@@ -285,8 +288,9 @@ const electron = z
}),
),
})
.transform(({ call_paths, ...rest }) => ({
.transform(({ call_paths, app_path_changes, ...rest }) => ({
...rest,
...(app_path_changes === undefined ? {} : { app_path_changes }),
...(call_paths === undefined ? {} : { call_paths }),
}));
const referenceShape = {
@@ -422,6 +426,7 @@ const resourceLimit = z.enum([
"primitive-bytes",
"expression-depth",
"getter-expansion",
"alias-selections",
]);
const primitive = z.union([z.string(), z.number(), z.boolean(), z.null()]);
const property: z.ZodType<JavaScriptSemanticProperty> = z.lazy(() =>
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more