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:
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",
|
||||
|
||||
+67
@@ -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" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
@@ -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
Reference in new issue
Block a user