Compare commits

..
50 changed files with 839 additions and 2114 deletions

No files matched your search

@@ -5,6 +5,8 @@ import { expect, it, vi } from "vitest";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { apiContractSchema } from "../../domain/apiContract/apiContract.js";
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
import { parseEvidence } from "../../domain/evidence.js";
import { projectAnalysisView } from "../../domain/analysisView/analysisView.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { inspectApiContractEvidence } from "./ApiContractService.js";
import * as readOwner from "./ApiContractRead.js";
@@ -317,3 +319,89 @@ it("cancels a cooperative document traversal after the actual verified file read
await resources.close();
}
});
it.each(["between-paths", "after-projection"] as const)(
"preserves authenticated OpenAPI facts on cancellation %s",
async (stage) => {
const bytes = JSON.stringify({
openapi: "3.1.2",
info: { title: "Cancellation", version: "1" },
paths: { "/first": { get: {} }, "/second": { post: {} } },
});
const { path } = await inputFile(bytes);
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const realParse = parser.parseApiContractSteps;
try {
const baseline = await inspectApiContractEvidence({ path }, resources);
if (!baseline.ok) throw baseline.error;
const complete = apiContractSchema.parse(
baseline.value.normalized_result,
);
const scheduled = vi
.spyOn(parser, "parseApiContractSteps")
.mockImplementationOnce(function* (...args) {
const steps = realParse(...args);
if (stage === "between-paths") {
expect(steps.next().done).toBe(false);
expect(steps.next().done).toBe(false);
setImmediate(() => controller.abort(stage));
return yield* steps;
}
const result = yield* steps;
setImmediate(() => controller.abort(stage));
return result;
});
try {
const result = await inspectApiContractEvidence({ path }, resources, {
signal: controller.signal,
});
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(stage);
const evidence = parseEvidence(result.error.partialObservation);
const retained = apiContractSchema.parse(evidence.normalized_result);
expect(evidence.subject).toEqual(baseline.value.subject);
expect(evidence.parameters).toEqual(baseline.value.parameters);
expect(retained.artifact).toEqual(complete.artifact);
expect(retained.contract_id).toBe(complete.contract_id);
if (stage === "between-paths") {
expect(retained.operations).toEqual(complete.operations.slice(0, 1));
expect(retained.source_coverage.status).toBe("partial");
expect(evidence.evidence_id).not.toBe(baseline.value.evidence_id);
const page = projectAnalysisView(
{
evidenceId: evidence.evidence_id,
operation: evidence.operation,
predicateType: evidence.predicate_type,
normalizedResult: evidence.normalized_result,
limitations: evidence.limitations,
},
{
kind: "api-contract",
collection: "operations",
offset: 0,
limit: 1,
},
);
expect(page).toMatchObject({
ok: true,
value: {
parent_evidence_id: evidence.evidence_id,
items: [{ path: "/first", state: "declared" }],
coverage: { exhausted: true },
source_coverage: { status: "partial" },
},
});
} else {
expect(retained).toEqual(complete);
expect(evidence.evidence_id).toBe(baseline.value.evidence_id);
}
} finally {
scheduled.mockRestore();
}
} finally {
await resources.close();
}
},
);
@@ -11,6 +11,8 @@ import { err, ok, type Result } from "../../domain/result.js";
import {
API_CONTRACT_OPERATION as operation,
API_CONTRACT_PROVIDER,
apiContractSchema,
type ApiContract,
type ApiContractArtifact,
} from "../../domain/apiContract/apiContract.js";
import {
@@ -70,8 +72,32 @@ function* documentSteps(document: unknown, artifact: ApiContractArtifact) {
return yield* parseApiContractSteps(document, artifact);
}
type OpenApiInput = Extract<
InspectApiContractInput,
{ format: "openapi-json" }
>;
const documentEvidence = (input: OpenApiInput, result: ApiContract): Evidence =>
createEvidence(
{ path: input.path, format: "file", sha256: result.artifact.sha256 },
API_CONTRACT_PROVIDER,
{
operation,
predicateType: "rea.api-contract-inventory",
parameters: {
path: input.path,
format: input.format,
max_file_bytes: input.max_file_bytes,
},
result,
confidence: "observed",
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: input.path }],
},
);
const importDocument = async (
input: Extract<InspectApiContractInput, { format: "openapi-json" }>,
input: OpenApiInput,
bytes: Buffer,
sha256: string,
signal?: AbortSignal,
@@ -86,26 +112,16 @@ const importDocument = async (
}),
signal,
);
if (!parsed.ok) return parsed;
const result = parsed.value;
if (!parsed.ok) {
const partial = apiContractSchema.safeParse(
parsed.error.partialObservation,
);
if (partial.success)
parsed.error.partialObservation = documentEvidence(input, partial.data);
return parsed;
}
return completeProviderEvidence(
createEvidence(
{ path: input.path, format: "file", sha256 },
API_CONTRACT_PROVIDER,
{
operation,
predicateType: "rea.api-contract-inventory",
parameters: {
path: input.path,
format: input.format,
max_file_bytes: input.max_file_bytes,
},
result,
confidence: "observed",
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: input.path }],
},
),
documentEvidence(input, parsed.value),
signal,
);
};
@@ -10,7 +10,6 @@ import {
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
unknownSemanticEvidence,
builtinSemanticNode,
builtinSemanticRelation,
@@ -40,7 +39,6 @@ export const projectSemanticEvents = (
source: eventEmitterNode(context, operation),
target: event,
relation,
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
@@ -50,7 +48,6 @@ export const projectSemanticEvents = (
source: event,
target: listener,
relation,
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
@@ -480,8 +480,10 @@ export const createJavaScriptSemanticGraphProjection = (
limit,
);
}
const remainingTreeBudget = Math.max(0, nodeCeiling - state.nodes.size);
state.fileNodeBudget = Math.min(fileNodeCeiling, remainingTreeBudget);
state.fileNodeBudget = Math.min(
fileNodeCeiling,
Math.max(0, nodeCeiling - state.nodes.size),
);
const module = retainNode(state, file, {
kind: "module",
roleKey: "module",
@@ -491,14 +493,6 @@ export const createJavaScriptSemanticGraphProjection = (
});
state.fileNodeBudget = null;
if (module !== null) state.roots.add(module.node_id);
else {
truncatedFiles += 1;
noteSemanticCeilingReached(
nodeCeilingsReached,
remainingTreeBudget,
fileNodeCeiling,
);
}
const evidence = {
...observedSemanticEvidence(file, null),
authority: "unknown" as const,
@@ -10,7 +10,6 @@ import {
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
builtinSemanticNode,
builtinSemanticRelation,
unknownSemanticEvidence,
@@ -136,7 +135,7 @@ export const projectSemanticRequests = (
if (request === undefined) continue;
if (operation.kind === "request")
projectRequestConstruction(context, operation, request);
else projectResponseConsumer(context, operation, request, requestNodes);
else projectResponseConsumer(context, operation, request);
if (operation.resolution !== "complete")
addDataUnknown({
context,
@@ -225,31 +224,16 @@ const projectResponseConsumer = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticRequestOperation,
response: JavaScriptSemanticGraphNode,
requestNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const authority = builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
);
const evidence =
authority.evidence ??
inferredSemanticEvidenceAt(context.file, operation.location);
for (const requestId of operation.linkedRequestIds)
addSemanticGraphRelation(context.state, {
source: requestNodes.get(requestId),
target: response,
relation: "consumed-by",
...authority,
evidence,
properties: { method: operation.method },
});
addSemanticGraphRelation(context.state, {
source: response,
target: context.callSiteAt(operation.location),
relation: "consumed-by",
...authority,
evidence,
...builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
),
properties: { method: operation.method },
});
};
@@ -14,10 +14,7 @@ import {
addSemanticGraphRelation,
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
unknownSemanticEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.js";
@@ -50,7 +47,6 @@ export function* projectSemanticObjects(
...ends,
relation: OBJECT_RELATIONS[operation.kind],
resolution: resolved ? "resolved" : "candidate",
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
});
if (!resolved)
addObjectUnknown(context, operation, target.slot ?? occurrence, target);
@@ -340,37 +340,3 @@ it("bounds reads within unmodeled nested operator operands", () => {
);
expect(trace.status).not.toBe("found");
});
it.each([1, 2])(
"reports unavailable-module node omission only when total capacity %s rejects it",
(totalNodes) => {
const projection = createJavaScriptSemanticGraphProjection({
totalNodes,
fileNodes: 10,
});
const retained = source("retained.js", "");
projection.projectFile(retained, analyzeJavaScriptSemantics(""));
projection.recordUnavailableFile(
source("unavailable.js"),
"Source unavailable",
null,
"incomplete-module",
);
const graph = projection.finish(digest, application);
expect(graph.nodes).toHaveLength(totalNodes);
expect(graph.unknowns).toHaveLength(12);
expect(graph.coverage).toMatchObject({
status: "partial",
truncated: totalNodes === 1,
omitted_nodes: null,
});
if (totalNodes === 1) {
expect(graph.coverage.limits).toContainEqual({
name: "max_semantic_nodes",
value: 1,
unit: "items",
});
expect(graph.limitations.join("\n")).toContain("max_semantic_nodes is 1");
} else expect(graph.coverage.limits).toEqual([]);
},
);
+2
View File
@@ -1,6 +1,7 @@
import type { PartialTaskMeasurement } from "./desktop/taskDifficulty.js";
import type { AppleIpsPartialObservation } from "./native/appleIps.js";
import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js";
import type { ApiContract } from "./apiContract/apiContract.js";
import type { GraphqlContract } from "./apiContract/graphqlContract.js";
import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js";
import type { AnalysisSettingLimit } from "./analysisSettings.js";
@@ -498,6 +499,7 @@ export type AnalysisPartialObservation =
}
| { readonly kind: "unretained-evidence"; readonly evidence: Evidence }
| { readonly kind: "retained-evidence"; readonly evidence_id: string }
| ApiContract
| GraphqlContract
| PlaywrightTrace
| SavedAnalysisSnapshotPartialObservation
+1 -1
View File
@@ -185,7 +185,7 @@ export const projectApiContractView = (
unknowns:
context.source_coverage.status === "partial"
? [
"Unresolved or malformed declarations limit this inventory; query path-item-references and diagnostics for exact source pointers.",
"This inventory is partial. Inspect limitations for coverage and query path-item-references and diagnostics for any retained source pointers.",
]
: [],
};
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { apiContractSchema, type ApiContractArtifact } from "./apiContract.js";
import { parseApiContractSteps } from "./parseApiContract.js";
import { AnalysisCancelledError } from "../analysisErrorCore.js";
const artifact: ApiContractArtifact = {
path: "/contract.json",
@@ -305,9 +306,39 @@ it("handles 3.2 QUERY and case-sensitive additional methods, diagnosing invalid
it("bounds expanded identities and source pointers before building a giant row", () => {
const result = parse(
document({ [`/${"a".repeat(1_100_000)}`]: { get: {} } }),
document({
"/retained": { get: {} },
[`/${"a".repeat(1_100_000)}`]: { get: {} },
}),
);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
if (result.ok) throw new Error("Expected a retained-facts refusal");
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
const partial = apiContractSchema.parse(result.error.partialObservation);
expect(partial.operations.map((row) => row.path)).toEqual(["/retained"]);
expect(partial.source_coverage.status).toBe("partial");
});
it("retains an interpreted path when cancellation interrupts traversal", () => {
const input = document({
"/first": { get: { operationId: "first" } },
"/second": { get: { operationId: "second" } },
});
const steps = parseApiContractSteps(input, artifact);
for (let checkpoint = 0; checkpoint < 3; checkpoint++)
expect(steps.next().done).toBe(false);
const cancellation = new AnalysisCancelledError("inspect_api_contract");
const stopped = steps.throw(cancellation);
if (!stopped.done || stopped.value.ok)
throw new Error("Expected cancellation");
expect(stopped.value.error).toBe(cancellation);
const partial = apiContractSchema.parse(cancellation.partialObservation);
const complete = report(input);
expect(partial.operations).toEqual(complete.operations.slice(0, 1));
expect(partial.summary.operation_slots).toBe(1);
expect(partial.source_coverage).toMatchObject({
status: "partial",
path_entries_examined: 1,
});
expect(complete.source_coverage.status).toBe("complete-within-scope");
});
+72 -39
View File
@@ -1,4 +1,5 @@
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
AnalysisUnsupportedTargetError,
@@ -316,14 +317,17 @@ function* pathItem(
return ok(undefined);
}
const completedInventory = (
interface ContractReportContext {
readonly openapi: string;
readonly info: ApiContract["info"];
readonly pathsState: ApiContract["source_coverage"]["paths_state"];
examined: number;
}
const contractReport = (
inventory: ContractInventory,
context: {
openapi: string;
info: ApiContract["info"];
pathsState: ApiContract["source_coverage"]["paths_state"];
examined: number;
},
context: ContractReportContext,
incomplete = false,
): ApiContract => {
const artifact = inventory.artifact;
let declared = 0;
@@ -348,7 +352,9 @@ const completedInventory = (
source_coverage: {
scope: "inline-path-operations",
status:
inventory.references.length > 0 || inventory.diagnostics.length > 0
incomplete ||
inventory.references.length > 0 ||
inventory.diagnostics.length > 0
? "partial"
: "complete-within-scope",
paths_state: context.pathsState,
@@ -358,6 +364,11 @@ const completedInventory = (
duplicate_json_members: "last-value-wins",
},
limitations: [
...(incomplete
? [
"Traversal stopped before the inventory was complete. Counts describe the retained rows.",
]
: []),
"Operations are document declarations, not evidence of runtime implementation, reachability or authorization.",
"Only inline operations under paths are inventoried; callbacks, webhooks, components, servers, parameters, schemas, request/response contracts and security are not analyzed.",
"No reference is resolved, including local fragments. A Path Item reference makes its effective operations unknown; adjacent inline slots are preserved without merging referenced definitions.",
@@ -368,6 +379,32 @@ const completedInventory = (
};
};
function* collectPaths(
sourcePaths: Record<string, unknown>,
inventory: ContractInventory,
context: ContractReportContext,
modern: boolean,
): Generator<void, Result<void, AnalysisError>> {
for (const path in sourcePaths) {
if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue;
yield;
context.examined++;
// Reserve expanded pointer/path strings before escaping or building a row.
const admitted = inventory.reserve(512 + path.length * 16);
if (!admitted.ok) return admitted;
const pointer = `/paths/${pointerPart(path)}`;
const read = path.startsWith("/")
? yield* pathItem(sourcePaths[path], { inventory, path, pointer, modern })
: inventory.diagnostic(
"invalid-path-key",
pointer,
"A Paths entry must start with /; it was not interpreted as an endpoint.",
);
if (!read.ok) return read;
}
return ok(undefined);
}
/** Enumerate declarations only; never resolve references, servers, callbacks or runtime expressions. */
export function* parseApiContractSteps(
value: unknown,
@@ -417,36 +454,32 @@ export function* parseApiContractSteps(
);
if (!diagnosed.ok) return diagnosed;
}
let examined = 0;
const sourcePaths = paths ?? {};
for (const path in sourcePaths) {
if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue;
yield;
examined++;
// Reserve expanded pointer/path strings before escaping or building a row.
const admitted = inventory.reserve(512 + path.length * 16);
if (!admitted.ok) return admitted;
const pointer = `/paths/${pointerPart(path)}`;
const read = path.startsWith("/")
? yield* pathItem(sourcePaths[path], {
inventory,
path,
pointer,
modern: version[1] === "2",
})
: inventory.diagnostic(
"invalid-path-key",
pointer,
"A Paths entry must start with /; it was not interpreted as an endpoint.",
);
if (!read.ok) return read;
const context: ContractReportContext = {
openapi: root.openapi,
info: info.value,
pathsState,
examined: 0,
};
const interrupted = (error: AnalysisError) => {
const retainedRows =
inventory.operations.length +
inventory.references.length +
inventory.diagnostics.length;
if (retainedRows > 0)
error.retainPartialObservation(contractReport(inventory, context, true));
return err(error);
};
try {
const read = yield* collectPaths(
paths ?? {},
inventory,
context,
version[1] === "2",
);
if (!read.ok) return interrupted(read.error);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) return interrupted(cause);
throw cause;
}
return ok(
completedInventory(inventory, {
openapi: root.openapi,
info: info.value,
pathsState,
examined,
}),
);
return ok(contractReport(inventory, context));
}
@@ -650,32 +650,6 @@ it("connects exposed closures to IPC sends and text clipboard calls", () => {
expect(ipcPath?.steps.at(-1)).toBeDefined();
expect(ipcFinding?.location).toEqual(ipcPath?.steps.at(-1)?.location);
});
it("uses a defineProperty value that replaces an exported sender", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules = {
2(module) { module.exports = require("electron"); },
9(module,exports,load) {
const electron=load(2),helper=load(5);
electron.contextBridge.exposeInMainWorld("desktop", {copy:helper.sender("defined:copy")});
},
5(module,exports,load) {
const electron=load(2);
exports.sender=function(){return ()=>{};};
Object.defineProperty(exports,"sender",{value:function(channel){return (...args)=>send(channel,...args);}});
function send(channel,...args){electron.ipcRenderer.send(channel,...args);}
}
};
function load(id) { const m={exports:{}}; modules[id].call(m.exports,m,m.exports,load); return m.exports; }
load(9);
})();`);
expect(result.electron.call_paths).toContainEqual(
expect.objectContaining({
entry: "desktop.copy",
channel: "defined:copy",
terminal: "electron.ipcRenderer.send",
}),
);
});
it("resolves a helper factory listed after its bundle consumer", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules = {
@@ -1,11 +1,7 @@
import * as t from "@babel/types";
import type { javascriptBootstrapAt } from "./javascriptBootstrap.js";
import {
semanticStaticPropertyKey,
semanticWriteSiteTarget,
semanticWriteTargets,
} from "./javascriptAstValues.js";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import { semanticIdentifierRole } from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
@@ -289,13 +285,8 @@ const moduleObjectContained = (
state: JavaScriptSemanticAnalysisState,
): boolean => {
let contained = true;
const writtenTargets = new Set<t.Node>();
traverseJavaScriptValueAst(loader.body, state.erasedSyntaxNodes, {
enter: (node, parent, readAncestors) => {
const target = semanticWriteSiteTarget(node);
if (target !== null)
for (const leaf of semanticWriteTargets(target))
writtenTargets.add(leaf);
if (!contained || !t.isIdentifier(node) || parent === null) return;
const ancestors = readAncestors();
if (semanticIdentifierRole(node, parent, () => ancestors) === null)
@@ -303,7 +294,7 @@ const moduleObjectContained = (
if (bindingOf(state, node) !== moduleId) return;
contained =
!ancestors.some((ancestor) => t.isFunction(ancestor)) &&
moduleUse(node, parent, dispatch, writtenTargets.has(parent));
moduleUse(node, [parent, ancestors.at(-2)], dispatch);
},
});
return contained;
@@ -311,18 +302,21 @@ const moduleObjectContained = (
const moduleUse = (
node: t.Identifier,
parent: t.Node,
[parent, grandparent]: readonly [t.Node, t.Node | undefined],
dispatch: t.CallExpression,
written: boolean,
): boolean => {
if (parent === dispatch) return dispatch.arguments.includes(node);
if (!t.isMemberExpression(parent) || parent.object !== node) return false;
const key = semanticStaticPropertyKey(parent.property, parent.computed);
const written =
(t.isAssignmentExpression(grandparent) && grandparent.left === parent) ||
t.isUpdateExpression(grandparent) ||
t.isUnaryExpression(grandparent, { operator: "delete" });
if (written) return key !== null && key !== "exports";
if (key !== "exports") return false;
// `module.exports` passed to the factory, or read after the dispatch ran.
return (
dispatch.arguments.some((argument) => argument === parent) ||
grandparent === dispatch ||
(parent.start ?? -1) > (dispatch.end ?? Number.POSITIVE_INFINITY)
);
};
@@ -10,54 +10,9 @@ import {
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import {
createGlobalPathReader,
createGlobalReferenceReader,
} from "./javascriptSemanticAmbientWrites.js";
import { createGlobalReferenceReader } from "./javascriptSemanticAmbientWrites.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
it.each([
[
"await value; Object.prototype.then=null; await value;",
["unmodified", "null"],
],
[
"Object.prototype.then=function(resolve){resolve()}; await value;",
["function"],
],
["if(flag) Object.prototype.then=null; await value;", ["unknown"]],
["mutate(Object.prototype); await value;", ["unknown"]],
["await value; mutate(Object.prototype);", ["unmodified"]],
["Array.prototype.then=null; await value;", ["unmodified"]],
] as const)(
"reads intrinsic property facts at implicit await sites: %s",
(source, expected) => {
const parsed = parseJavaScriptSource(source, "app.mjs");
if (parsed === null) throw new Error("Expected implicit read fixture");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const read = createGlobalPathReader(state, ["Object", "prototype", "then"]);
const statuses: string[] = [];
traverseJavaScriptAst(parsed.program, {
enter(node) {
if (!t.isAwaitExpression(node)) return;
const fact = read(node);
statuses.push(
fact.status === "replacement"
? t.isNullLiteral(fact.value)
? "null"
: t.isFunction(fact.value)
? "function"
: "other"
: fact.status,
);
},
});
expect(statuses).toEqual(expected);
},
);
it("keeps wrapper module aliases distinct from global-object module properties", () => {
const parsed = parseJavaScriptSource(
`
@@ -351,11 +306,6 @@ it.each([
"Object.defineProperty=()=>{};inspect(globalThis.Object.defineProperty);",
["replacement"],
],
[
"an escaped global owner",
"mutate(Object);inspect(Object.defineProperty);",
["unknown"],
],
[
"a namespaced global",
"inspect(globalThis.Object.defineProperty);globalThis.Object.defineProperty=()=>{};inspect(globalThis.Object.defineProperty);",
@@ -902,92 +902,37 @@ export const createGlobalReferenceReader = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((callee: t.Node) => GlobalReferenceRead) => {
const read = createGlobalPathRead(state, path);
return (callee) => {
const namespace = globalReferenceNamespace(callee, state, path);
return namespace === undefined
? { status: "unrelated" }
: read(callee, namespace);
};
};
/** Read an implicit intrinsic property at its actual source site, without inventing a callee. */
export const createGlobalPathReader = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((site: t.Node) => GlobalReferenceRead) => {
const read = createGlobalPathRead(state, path);
return (site) => read(site, null);
};
const createGlobalPathRead = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((site: t.Node, namespace: string | null) => GlobalReferenceRead) => {
const selected = ["global", ...path];
let writesGeneration = -1;
let escapesGeneration = -1;
let generation = -1;
const byNamespace = new Map<
string | null,
{
writes: typeof state.ambientWriteEffects;
escapes: typeof state.ambientEscapeEffects;
}
typeof state.ambientWriteEffects
>();
return (site, namespace) => {
const covered = (paths: typeof state.ambientWrites): boolean =>
paths.covers(selected) ||
(namespace !== null && paths.covers(["global", namespace]));
const writesCovered = covered(state.ambientWrites);
const escapesCovered = covered(state.ambientEscapes);
if (!writesCovered && !escapesCovered) return { status: "unmodified" };
return (callee) => {
const namespace = globalReferenceNamespace(callee, state, path);
if (namespace === undefined) return { status: "unrelated" };
if (
writesGeneration !== state.ambientWriteEffects.length ||
escapesGeneration !== state.ambientEscapeEffects.length
) {
writesGeneration = state.ambientWriteEffects.length;
escapesGeneration = state.ambientEscapeEffects.length;
!state.ambientWrites.covers(selected) &&
(namespace === null || !state.ambientWrites.covers(["global", namespace]))
)
return { status: "unmodified" };
if (generation !== state.ambientWriteEffects.length) {
generation = state.ambientWriteEffects.length;
byNamespace.clear();
}
let effects = byNamespace.get(namespace);
if (effects === undefined) {
const matches = (
effect: (typeof state.ambientWriteEffects)[number],
): boolean =>
ambientEffectCovers(effect, selected) ||
(namespace !== null &&
ambientEffectCovers(effect, ["global", namespace]));
effects = {
writes: state.ambientWriteEffects.filter(matches),
escapes: state.ambientEscapeEffects.filter(matches),
};
effects = state.ambientWriteEffects.filter(
(effect) =>
ambientEffectCovers(effect, selected) ||
(namespace !== null &&
ambientEffectCovers(effect, ["global", namespace])),
);
byNamespace.set(namespace, effects);
}
// The call that escapes a receiver contains the callee being identified.
// That escape cannot have replaced the callee before the call selects it.
const escapePrecedesRead = effects.escapes.some((effect) => {
const escape = effect.node;
if (
escape !== undefined &&
escape !== site &&
typeof escape.start === "number" &&
typeof escape.end === "number" &&
typeof site.start === "number" &&
typeof site.end === "number" &&
site.start >= escape.start &&
site.end <= escape.end
)
return false;
return semanticEffectTiming(site, escape, state) !== "after";
});
if (
(escapesCovered && effects.escapes.length === 0) ||
escapePrecedesRead ||
(writesCovered && effects.writes.length === 0)
)
return { status: "unknown" };
const active = effects.writes.filter(
(effect) => semanticEffectTiming(site, effect.node, state) !== "after",
if (effects.length === 0) return { status: "unknown" };
const active = effects.filter(
(effect) => semanticEffectTiming(callee, effect.node, state) !== "after",
);
if (active.length === 0) return { status: "unmodified" };
if (
@@ -998,57 +943,47 @@ const createGlobalPathRead = (
)
)
return { status: "unknown" };
return globalPathReplacement(state, path, site, active);
let replacement: t.AssignmentExpression | undefined;
for (const effect of active) {
const write = effect.node;
const writeNamespace = t.isAssignmentExpression(write)
? globalReferenceNamespace(write.left, state, path)
: undefined;
if (
t.isAssignmentExpression(write, { operator: "=" }) &&
writeNamespace !== undefined &&
(writeNamespace === null ||
!state.ambientWrites.covers(["global", writeNamespace])) &&
semanticDefiniteEffectPrecedesReference(
write,
callee,
state.parentsByNode,
) &&
(replacement === undefined ||
semanticDefiniteEffectPrecedesReference(
replacement,
write,
state.parentsByNode,
))
)
replacement = write;
}
if (replacement === undefined) return { status: "unknown" };
const confirmed = replacement;
if (
active.some(
(effect) =>
effect.node !== confirmed &&
semanticEffectTiming(confirmed, effect.node, state) !== "before" &&
(effect.node === undefined ||
!semanticDefiniteEffectPrecedesReference(
effect.node,
confirmed,
state.parentsByNode,
)),
)
)
return { status: "unknown" };
return { status: "replacement", value: confirmed.right };
};
};
/** Select a definite replacement only when every competing write is proven earlier. */
const globalPathReplacement = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
site: t.Node,
effects: typeof state.ambientWriteEffects,
): GlobalReferenceRead => {
let replacement: t.AssignmentExpression | undefined;
for (const effect of effects) {
const write = effect.node;
const writeNamespace = t.isAssignmentExpression(write)
? globalReferenceNamespace(write.left, state, path)
: undefined;
if (
t.isAssignmentExpression(write, { operator: "=" }) &&
writeNamespace !== undefined &&
(writeNamespace === null ||
!state.ambientWrites.covers(["global", writeNamespace])) &&
semanticDefiniteEffectPrecedesReference(
write,
site,
state.parentsByNode,
) &&
(replacement === undefined ||
semanticDefiniteEffectPrecedesReference(
replacement,
write,
state.parentsByNode,
))
)
replacement = write;
}
if (replacement === undefined) return { status: "unknown" };
const confirmed = replacement;
if (
effects.some(
(effect) =>
effect.node !== confirmed &&
semanticEffectTiming(confirmed, effect.node, state) !== "before" &&
(effect.node === undefined ||
!semanticDefiniteEffectPrecedesReference(
effect.node,
confirmed,
state.parentsByNode,
)),
)
)
return { status: "unknown" };
return { status: "replacement", value: confirmed.right };
};
@@ -249,20 +249,3 @@ it("does not initialize a class self name during computed-key evaluation", () =>
expect(ir.callSites[0]).toBeDefined();
expect(ir.callSites[0]?.resolution).not.toBe("exact");
});
it.each(["__proto__", '"__proto__"', "['__proto__']"])(
"distinguishes prototype setters from own data keys in loader-write scoping: %s",
(key) => {
const ir = analyze(`
with ({require: local, [Symbol.unscopables]: {${key}: {require: true}}}) {
require = replacement;
}
const selected = require("node:fs");
`);
const selected = onlyBinding(ir, "selected");
if (key.startsWith("[")) {
// A computed key is an own data property, so it supplies no inherited exclusion.
expect(selected.provenance.status).toBe("module");
} else expect(selected.provenance.status).not.toBe("module");
},
);
@@ -1,11 +1,7 @@
import { describe, expect, it } from "vitest";
import { runInNewContext } from "node:vm";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import {
onlyBinding,
onlyCallable,
} from "./javascriptSemanticAnalysis.fixture.js";
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
const resultValue = (body: string) =>
onlyCallable(
@@ -13,128 +9,6 @@ const resultValue = (body: string) =>
"result",
).returnSites[0]?.value;
it.each([
[
"own getter",
"const value = { count: 1, get then() { this.count = 2; } };",
2,
],
[
"own method",
"const value = { count: 1, then(resolve) { this.count = 2; resolve(); } };",
2,
],
[
"function-valued slot",
"const value = { count: 1, then: function(resolve) { this.count = 2; resolve(); } };",
2,
],
[
"getter returning a method",
"const value = { count: 1, get then() { return function(resolve) { this.count = 2; resolve(); }; } };",
2,
],
[
"inherited getter",
"const proto = { get then() { this.count = 2; } }; const value = { __proto__: proto, count: 1 };",
2,
],
[
"inherited method",
"const proto = { then(resolve) { this.count = 2; resolve(); } }; const value = { __proto__: proto, count: 1 };",
2,
],
[
"class method",
"class Value { count = 1; then(resolve) { this.count = 2; resolve(); } } const value = new Value();",
2,
],
[
"stored method",
"const value = { count: 1 }; value.then = function(resolve) { this.count = 2; resolve(); };",
2,
],
[
"method stored on a copied shared child",
"const source = { child: { count: 1 } }; const box = { ...source }; source.child.then = function(resolve) { this.count = 2; resolve(); }; const value = box.child;",
2,
],
[
"copied method retained after source replacement",
"const source = { count: 1, then(resolve) { this.count = 2; resolve(); } }; const value = { ...source }; source.then = null;",
2,
],
["inert object", "const value = { count: 1 };", 1],
["noncallable then", "const value = { count: 1, then: null };", 1],
[
"noncallable getter result",
"const value = { count: 1, get then() { return 3; } };",
1,
],
[
"own noncallable shadow",
"const proto = { get then() { this.count = 2; } }; const value = { __proto__: proto, count: 1, then: null };",
1,
],
[
"method replaced before await",
"const value = { count: 1, then(resolve) { this.count = 2; resolve(); } }; value.then = null;",
1,
],
] as const)(
"accounts for thenable effects at await: %s",
async (_kind, declaration, expected) => {
const body = `${declaration} await value; return value.count;`;
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(expected);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
const value = onlyCallable(ir, "result").returnSites[0]?.value;
if (expected === 2) expect(value?.status).toBe("unknown");
else expect(value).toEqual({ status: "literal", value: 1 });
},
);
it("keeps a then slot captured before a later top-level source write", async () => {
const body = `
const source = { count: 1 };
const value = { ...source };
source.then = function(resolve) { this.count = 2; resolve(); };
await value;
const observed = value.count;
`;
expect(
await runInNewContext(`(async () => { ${body} return observed; })()`),
).toBe(1);
expect(
onlyBinding(analyzeJavaScriptSemantics(body), "observed").value,
).toEqual({ status: "literal", value: 1 });
});
it.each([
"const value = { count: 1, then: external };",
"const value = { count: 1, then: null }; value.then = external;",
])("keeps an externally supplied then callable possible: %s", async (slot) => {
const body = `${slot} await value; return value.count;`;
for (const external of [
null,
function (this: { count: number }, resolve: () => void) {
this.count = 2;
resolve();
},
]) {
expect(
await runInNewContext(`(async () => { ${body} })()`, { external }),
).toBe(external === null ? 1 : 2);
}
const ir = analyzeJavaScriptSemantics(
`export async function result(external) { ${body} }`,
);
expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe(
"unknown",
);
});
it.each([
["field = shared;", "declare field: { mode: string };", "new Derived()"],
[
@@ -167,83 +41,6 @@ it.each([
},
);
it("retains then invocation effects through a getter-invoked async callable", async () => {
const body = `
const value = {
count: 1,
get start() { (async () => { await this; })(); return null; },
then(resolve) { this.count = 2; resolve(); },
};
await value.start;
return value.count;
`;
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(2);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe(
"unknown",
);
});
it.each([
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { count: 1 }; await value; return value.count;",
2,
],
[
"Array.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { count: 1, then: null }; await value; return value.count;",
1,
],
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { __proto__: null, count: 1 }; await value; return value.count;",
1,
],
[
"Array.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; value.then = null; await value; return value[0];",
1,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; Array.prototype.then = null; const value = [1]; await value; return value[0];",
1,
],
[
"Object.prototype.then = null; const value = { count: 1 }; await value; return value.count;",
1,
],
[
"Object.defineProperty(Object.prototype, 'then', { get() { this.count = 2; } }); const value = { count: 1 }; await value; return value.count;",
2,
],
[
"Array.prototype.__proto__ = { then(resolve) { this[0] = 2; resolve(); } }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; Array.prototype.__proto__ = null; const value = [1]; await value; return value[0];",
1,
],
] as const)(
"retains source-written prototype then invocation: %s",
async (body, expected) => {
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(expected);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
const value = onlyCallable(ir, "result").returnSites[0]?.value;
if (expected === 2) expect(value?.status).toBe("unknown");
else expect(value).toEqual({ status: "literal", value: 1 });
},
);
it.each(["field;", "field = { mode: 'other' };"])(
"preserves a real derived field's shadowing: %s",
(field) => {
@@ -269,18 +269,3 @@ describe("JavaScript semantic analysis: configuration value escapes", () => {
expect(configurationReads(body)).toEqual([]);
});
});
it.each([
["if (flag) child = spawn('tool');", "partial"],
["child = spawn('tool');", "complete"],
] as const)(
"keeps child-process incoming parameter uncertainty after %s",
(assignment, resolution) => {
const ir = analyzeJavaScriptSemantics(
`import { spawn } from 'node:child_process'; function stop(child, flag) { ${assignment} child.kill(); }`,
);
expect(ir.childProcessInteractions).toEqual([
expect.objectContaining({ kind: "signal", resolution }),
]);
},
);
@@ -28,13 +28,6 @@ const eventsFor = (call: string) =>
}));
it.each([
["a missing on listener", `e.on("x")`],
["a missing once listener", `e.once("x")`],
["a missing addListener listener", `e.addListener("x")`],
["a missing prependListener listener", `e.prependListener("x")`],
["a missing prependOnceListener listener", `e.prependOnceListener("x")`],
["a missing removeListener listener", `e.removeListener("x")`],
["a missing off listener", `e.off("x")`],
["a numeric listener", `e.on("x", 1)`],
["a string listener", `e.on("x", "fn")`],
["a template-literal listener", 'e.on("x", `fn`)'],
@@ -22,54 +22,6 @@ const releases = (source: string) =>
resolution,
}));
it.each([
["net", "connect", "-0"],
["net", "createConnection", "-0.0"],
["tls", "connect", "-0"],
["net", "connect", "typeof 1.5"],
["net", "connect", "~(-1.5)"],
["tls", "connect", "void 0, { port: 443 }"],
] as const)(
"retains %s.%s(%s) and its socket release when the unary result is valid",
(module, method, argument) => {
expect(
operations(
`import socket from "node:${module}"; const handle = socket.${method}(${argument}); handle.destroy();`,
).map(({ kind, method, resolution }) => ({ kind, method, resolution })),
).toEqual([
{ kind: "acquire", method, resolution: "complete" },
{ kind: "release", method: "destroy", resolution: "complete" },
]);
},
);
it.each([
["connect", "void 0"],
["createConnection", "void value"],
["connect", "undefined"],
["connect", "void 0, { port: 443 }"],
] as const)(
"emits no net.%s acquisition or release for a missing first argument: %s",
(method, argument) => {
expect(
operations(
`import net from 'node:net'; const handle = net.${method}(${argument}); handle.destroy();`,
),
).toEqual([]);
},
);
it("retains unknown net argument positions and shadowed undefined values", () => {
for (const body of [
"function use(undefined) { const handle=net.connect(undefined);handle.destroy(); }",
"const handle=net.connect(...args, void 0);handle.destroy();",
])
expect(operations(`import net from 'node:net';${body}`)).toEqual([
expect.objectContaining({ kind: "acquire", method: "connect" }),
expect.objectContaining({ kind: "release", method: "destroy" }),
]);
});
const commonJsPromise = `const { open } = require("node:fs/promises"); const h = open("input.txt"); h.close();`;
it("keeps conditional release facts when the loader is unknown", () => {
@@ -246,17 +198,3 @@ it("keeps an awaited handle release uncertain after an opaque receiver write", (
),
).toEqual([{ method: "close", linked: 1, resolution: "partial" }]);
});
it.each([
["if (flag) handle = createReadStream('input.txt');", "partial"],
["handle = createReadStream('input.txt');", "complete"],
] as const)(
"preserves incoming parameter uncertainty before release: %s",
(assignment, resolution) => {
expect(
releases(
`import { createReadStream } from 'node:fs'; function release(handle, flag) { ${assignment} handle.destroy(); }`,
),
).toEqual([{ method: "destroy", linked: 1, resolution }]);
},
);
@@ -16,8 +16,7 @@ import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
* net.connect("") -> THREW ERR_SOCKET_BAD_PORT
* net.connect("/tmp/x.sock") -> Socket created (Unix socket path)
* net.connect({ port: 80 }) -> Socket created
* net.connect() / undefined / void 0 -> THREW ERR_MISSING_ARGS
* net.connect({}) / [] -> THREW ERR_MISSING_ARGS (undecided here)
* net.connect(undefined) / {} / [] -> THREW ERR_MISSING_ARGS (undecided here)
*/
const socketAcquisitionsFor = (call: string) =>
analyzeJavaScriptSemantics(
@@ -35,10 +34,6 @@ it.each([
["a boolean port", `net.connect(true)`],
["a null port", `net.connect(null)`],
["an empty path", `net.connect("")`],
["an omitted argument", `net.connect()`],
["an explicit undefined port", `net.connect(undefined)`],
["a void port", `net.connect(void 0)`],
["an omitted createConnection argument", `net.createConnection()`],
["a tls port above the range", `tls.connect(65536)`],
["a tls negative port", `tls.connect(-1)`],
["a tls NaN port", `tls.connect(NaN)`],
@@ -54,12 +49,8 @@ it.each([
["the highest port", `net.connect(65535)`, "connect"],
["a Unix socket path", `net.connect("/tmp/x.sock")`, "connect"],
["an options object", `net.connect({ port: 80 })`, "connect"],
["a computed argument", `net.connect(p)`, "connect"],
[
"a TLS port supplied by later options",
`tls.connect(void 0, { port: 443 })`,
"connect",
],
["an omitted argument", `net.connect()`, "connect"],
["an explicit undefined port", `net.connect(undefined)`, "connect"],
["a tls port", `tls.connect(80)`, "connect"],
["a createConnection port", `net.createConnection(80)`, "createConnection"],
] as const)("keeps the socket acquisition for %s", (_name, call, method) => {
@@ -16,6 +16,7 @@ import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js";
@@ -159,10 +160,7 @@ export const collectJavaScriptSemanticAsyncEffects = (
*
* A class expression passes Node's own `typeof` check, so it is kept.
*/
export const rejectsSemanticEventListener = (
listener: t.Node | undefined,
): boolean => {
if (listener === undefined) return true;
const rejectsEventListener = (listener: t.Node): boolean => {
if (t.isSpreadElement(listener)) return false;
const value = unwrapJavaScriptExpression(listener).node;
if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value))
@@ -206,9 +204,9 @@ const collectEvent = (
// provably the listener: `e.on(...["x", fn], 1)` registers `fn` and ignores the
// trailing `1`.
if (
EVENT_LISTENER_METHODS.includes(method) &&
!t.isSpreadElement(eventArgument) &&
rejectsSemanticEventListener(listenerArgument)
listenerArgument !== undefined &&
rejectsEventListener(listenerArgument)
)
return;
const emitter = emitterIdentity(member.object, state);
@@ -7,25 +7,6 @@ const analyze = (source: string) =>
`import { spawn } from 'node:child_process';\n${source}`,
);
it.each(
["on", "once", "addListener"].flatMap((method) =>
["", ", 1", ", null", ", {}", ", void 0"].map((listener) => ({
method,
listener,
})),
),
)(
"does not invent a child $method registration with rejected listener $listener",
({ method, listener }) => {
const ir = analyze(
`const child = spawn('worker'); child.${method}('exit'${listener});`,
);
expect(ir.childProcessSpawns).toHaveLength(1);
expect(ir.childProcessInteractions).toEqual([]);
expect(ir.eventOperations).toEqual([]);
},
);
it("keeps interactions before replacement resolved and later ownership uncertain", () => {
const ir = analyze(`
let child = spawn('old');
@@ -7,7 +7,6 @@ import type {
JavaScriptSemanticChildProcessSpawn,
} from "./javascriptSemanticIr.js";
import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js";
import { rejectsSemanticEventListener } from "./javascriptSemanticAsyncEffects.js";
import {
createSemanticExpressionShapeResolver,
optionsAfterOptionalArgv,
@@ -226,11 +225,6 @@ const childInteraction = (
const method = semanticStaticPropertyName(member.property, member.computed);
const classification = classifyInteraction(method, node.arguments[0]);
if (classification === null) return null;
if (
classification.kind === "listener" &&
rejectsSemanticEventListener(node.arguments[1])
)
return null;
const binding = objectBinding(member.object, state);
const { candidates: linked, complete } = resolveChildCandidates(
member.object,
@@ -30,8 +30,6 @@ export interface SemanticPropertyReadSource
extends IterationSourceValue, SourceGuards {
readonly mutation: t.Node;
readonly lookup: "prototype" | "own";
/** The language protocol invokes a callable value read from this slot. */
readonly invoke?: true;
}
interface PatternSource extends IterationSourceValue {
@@ -339,7 +337,6 @@ const semanticSourceFacts = (
t.isAssignmentExpression(node) ||
t.isForOfStatement(node) ||
t.isSpreadElement(node) ||
t.isAwaitExpression(node) ||
t.isYieldExpression(node) ||
t.isCatchClause(node) ||
t.isFunction(node)
@@ -375,15 +372,11 @@ const semanticSourceFacts = (
mutation: node,
lookup: "own",
});
} else if (t.isAwaitExpression(node))
propertyReads.push({
node: node.argument,
projection: ["then"],
mutation: node,
lookup: "prototype",
invoke: true,
});
else if (t.isYieldExpression(node) && node.delegate && node.argument != null)
} else if (
t.isYieldExpression(node) &&
node.delegate &&
node.argument != null
)
add(sources, { node: node.argument, projection: [] }, node, {});
else if (
t.isCatchClause(node) &&
@@ -2,7 +2,6 @@ import * as t from "@babel/types";
import { semanticKnownSymbolKey } from "./javascriptSemanticPropertyKeys.js";
import {
createAmbientPathResolver,
createGlobalPathReader,
retainSemanticAmbientEffect,
} from "./javascriptSemanticAmbientWrites.js";
import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js";
@@ -38,7 +37,6 @@ import {
import { semanticContainer } from "./javascriptSemanticSlots.js";
import {
semanticProjectedSourceFactsAtRead,
semanticCapturedSelectionsAfterPropertyRead,
semanticShiftCapturedSelections,
semanticNormalizeCapturedSelections,
type SemanticCapturedSelection,
@@ -51,7 +49,6 @@ import {
semanticClassAccessorReferences,
semanticObjectPropertyReferences,
semanticObjectAccessorReferences,
semanticNativePrototypeChain,
type JavaScriptSemanticObjectReference,
} from "./javascriptSemanticObjectReferences.js";
import {
@@ -125,55 +122,27 @@ const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => {
/** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */
/** Local methods use the same slots, prototype shadowing and array indices as aliases. */
const localPropertyMethods = (
/** Accessor selection uses the same slots, prototype shadowing and array indices as aliases. */
const localAccessorMethods = (
root: t.Node,
path: PropertyPath,
accessor: "get" | "set" | "invoke",
accessor: "get" | "set",
state: JavaScriptSemanticAnalysisState,
keyMayExist: (
key: PropertyPath[number] | undefined,
kind: "get" | "set",
) => boolean,
lookup: "prototype" | "own" = "prototype",
): readonly t.Function[] | undefined => {
if (accessor !== "invoke" && !keyMayExist(path.at(-1), accessor)) return [];
const methods = new Set<t.Function>();
const pending: (MutationSelectionContext & {
node: t.Node;
path: PropertyPath;
})[] = [{ node: root, path, memberReadAt: root }];
): readonly (t.ObjectMethod | t.ClassMethod)[] | undefined => {
if (!keyMayExist(path.at(-1), accessor)) return [];
const methods = new Set<t.ObjectMethod | t.ClassMethod>();
const pending = [{ node: root, path }];
const examined = new WeakMap<t.Node, Set<string>>();
const nativeReaders = new Map<
string,
ReturnType<typeof createGlobalPathReader>
>();
const nativeRead = (
prototype: "Object" | "Array",
key: string,
at: t.Node,
) => {
const identity = JSON.stringify([prototype, key]);
let read = nativeReaders.get(identity);
if (read === undefined) {
read = createGlobalPathReader(state, [prototype, "prototype", key]);
nativeReaders.set(identity, read);
}
return read(at);
};
let selections = 0;
for (let cursor = 0; cursor < pending.length; cursor++) {
const current = pending[cursor]!;
const node = unwrapJavaScriptExpression(current.node).node;
const key = JSON.stringify([
current.path,
current.capturedSelections?.map(({ at, depth, ownEnumerable }) => [
at.start,
at.end,
depth,
ownEnumerable,
]),
]);
const key = JSON.stringify(current.path);
const paths = examined.get(node) ?? new Set<string>();
if (paths.has(key)) continue;
paths.add(key);
@@ -183,10 +152,7 @@ const localPropertyMethods = (
return undefined;
}
if (
t.isFunction(node) &&
(accessor === "invoke" ||
t.isObjectMethod(node) ||
t.isClassMethod(node)) &&
(t.isObjectMethod(node) || t.isClassMethod(node)) &&
current.path.length === 0
) {
methods.add(node);
@@ -194,42 +160,15 @@ const localPropertyMethods = (
}
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined) {
if (accessor === "invoke") return undefined;
continue;
}
if (
accessor === "invoke" &&
!semanticBindingAtRead(binding, node, state).hasDefiniteInitializer
)
return undefined;
const selected = semanticProjectedSourceFactsAtRead(
binding,
node,
current.path,
state,
true,
current.memberReadAt,
current.capturedSelections,
false,
// A spread copy escapes the copied values. That must not make the
// source's own accessors unknown, or the next read escapes the
// receiver itself.
accessor === "invoke",
);
if (selected === undefined) return undefined;
for (const source of selected.sources)
if (binding === undefined) continue;
const selected = semanticBindingAtRead(binding, node, state).binding;
for (const source of [
...selected.initializers,
...selected.referenceInitializers,
])
pending.push({
node: source.node,
path: source.path,
memberReadAt: current.memberReadAt,
capturedSelections: semanticNormalizeCapturedSelections([
...(semanticShiftCapturedSelections(
current.capturedSelections,
source.path.length - current.path.length,
) ?? []),
...(source.capturedSelections ?? []),
]),
path: [...source.projection, ...current.path],
});
} else if (
t.isMemberExpression(node) ||
@@ -241,57 +180,15 @@ const localPropertyMethods = (
semanticStaticPropertyKey(node.property, node.computed),
...current.path,
],
memberReadAt: current.memberReadAt,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
1,
),
});
} else if (
current.path.length > 0 &&
(t.isObjectExpression(node) || t.isArrayExpression(node))
) {
const [selected, ...remaining] = current.path;
if (
accessor === "invoke" &&
lookup === "prototype" &&
!current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
) {
for (const prototype of semanticNativePrototypeChain(node, selected!)) {
if (selected === null || typeof selected === "object")
return undefined;
const at = current.memberReadAt ?? root;
const member = nativeRead(prototype, String(selected), at);
if (member.status === "unknown" || member.status === "unrelated")
return undefined;
if (member.status === "replacement") {
pending.push({
node: member.value,
path: remaining,
memberReadAt: at,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
break;
}
// A changed native link selects its retained source rather than the
// default next prototype. Null naturally ends that lookup.
const link = nativeRead(prototype, "__proto__", at);
if (link.status === "unknown" || link.status === "unrelated")
return undefined;
if (link.status === "replacement") {
pending.push({ ...current, node: link.value });
break;
}
}
}
const references = t.isArrayExpression(node)
? semanticArrayPropertyReferences(node, [selected!])
: remaining.length === 0 && accessor !== "invoke"
: remaining.length === 0
? semanticObjectAccessorReferences(
node,
selected!,
@@ -300,24 +197,11 @@ const localPropertyMethods = (
lookup,
)
: semanticObjectPropertyReferences(node, [selected!], state);
for (const source of references) {
if (
source.inherited &&
current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
)
continue;
for (const source of references)
pending.push({
node: source.node,
path: [...source.path, ...remaining],
memberReadAt: current.memberReadAt,
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else if (
(t.isClass(node) || t.isNewExpression(node)) &&
current.path.length > 0
@@ -334,39 +218,23 @@ const localPropertyMethods = (
const references =
remaining.length > 0
? semanticClassPropertyReferences(selection, [selected!], state)
: accessor === "invoke"
? semanticClassPropertyReferences(selection, [selected!], state)
: lookup === "own"
? []
: semanticClassAccessorReferences(
selection,
selected!,
state,
accessor,
);
: lookup === "own"
? []
: semanticClassAccessorReferences(
selection,
selected!,
state,
accessor,
);
for (const source of references)
pending.push({
node: source.node,
path: [...source.path, ...remaining],
memberReadAt: current.memberReadAt,
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else {
const references = branchReferenceValues(node, current.path);
if (accessor === "invoke" && references.length === 0) {
const value = evaluateSemanticExpression(
node,
state,
current.memberReadAt,
);
if (!["literal", "union", "object", "array"].includes(value.status))
return undefined;
}
for (const source of references) pending.push({ ...current, ...source });
for (const source of branchReferenceValues(node, current.path))
pending.push(source);
}
}
return [...methods];
@@ -379,7 +247,6 @@ type AccessorEffectOperand =
readonly node: t.Node;
readonly path: PropertyPath;
readonly lookup: "prototype" | "own";
readonly invoke?: true;
readonly guards?: SemanticPropertyReadSource;
}
| {
@@ -466,7 +333,6 @@ const accessorEffectOperands = (
node: source.node,
path: source.projection,
lookup: source.lookup,
...(source.invoke ? { invoke: true } : {}),
guards: source,
});
if (
@@ -517,14 +383,13 @@ export function* collectSemanticMemberMutationsSteps(
);
};
const returnedReferences = createSemanticReturnedReferences(state);
const propertyReadEffects = (
const getterEffects = (
root: t.Node,
path: PropertyPath,
lookup: "prototype" | "own" = "prototype",
invoke = false,
): readonly JavaScriptSemanticObjectReference[] => {
if (!invoke && !accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [{ node: root, path, lookup, invoke }];
if (!accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [{ node: root, path, lookup }];
const examined = new Set<string>();
const effects: JavaScriptSemanticObjectReference[] = [];
for (let cursor = 0; cursor < pending.length; cursor++) {
@@ -532,14 +397,12 @@ export function* collectSemanticMemberMutationsSteps(
node: selectedRoot,
path: selected,
lookup: selectedLookup,
invoke: selectedInvoke,
} = pending[cursor]!;
const identity = JSON.stringify([
selectedRoot.start,
selectedRoot.end,
selected,
selectedLookup,
selectedInvoke,
]);
if (examined.has(identity)) continue;
examined.add(identity);
@@ -547,20 +410,7 @@ export function* collectSemanticMemberMutationsSteps(
state.valueResourceLimits.add("alias-selections");
return [{ node: root, path: [] }];
}
const receiver = selected.slice(0, -1);
if (
selectedInvoke &&
(localPropertyMethods(
selectedRoot,
selected,
"invoke",
state,
accessorKeyMayExist,
selectedLookup,
)?.length ?? 1) > 0
)
effects.push({ node: selectedRoot, path: receiver });
const methods = localPropertyMethods(
const methods = localAccessorMethods(
selectedRoot,
selected,
"get",
@@ -568,12 +418,12 @@ export function* collectSemanticMemberMutationsSteps(
accessorKeyMayExist,
selectedLookup,
);
const receiver = selected.slice(0, -1);
if (methods === undefined) {
effects.push({ node: selectedRoot, path: receiver });
continue;
}
for (const method of methods) {
if (!t.isObjectMethod(method) && !t.isClassMethod(method)) continue;
const callables: t.Function[] = [method];
const visitedCallables = new Set<t.Function>();
for (let index = 0; index < callables.length; index++) {
@@ -591,8 +441,7 @@ export function* collectSemanticMemberMutationsSteps(
}
if (
operand.kind === "read" &&
((!operand.invoke &&
!accessorKeyMayExist(operand.path.at(-1), "get")) ||
(!accessorKeyMayExist(operand.path.at(-1), "get") ||
(operand.guards !== undefined &&
semanticReferenceSourcesInactive(operand.guards, state)))
)
@@ -616,7 +465,6 @@ export function* collectSemanticMemberMutationsSteps(
node: targetRoot,
path: [...target, ...operand.path],
lookup: operand.lookup,
invoke: operand.invoke === true,
});
}
}
@@ -1266,7 +1114,7 @@ export function* collectSemanticMemberMutationsSteps(
if (
origins.initializers.some(
(source) =>
(localPropertyMethods(
(localAccessorMethods(
source.node,
[...source.projection, ...selectedPath],
"set",
@@ -1748,7 +1596,7 @@ export function* collectSemanticMemberMutationsSteps(
source.kind === "reference-store" &&
source.target !== undefined &&
!state.escapingReferenceStores.has(source.target) &&
(localPropertyMethods(
(localAccessorMethods(
source.target.object,
[
semanticStaticPropertyKey(
@@ -1810,18 +1658,17 @@ export function* collectSemanticMemberMutationsSteps(
}
for (const source of semanticPropertyReadSources(node, parent, state)) {
if (semanticReferenceSourcesInactive(source, state)) continue;
for (const effect of propertyReadEffects(
for (const effect of getterEffects(
source.node,
source.projection,
source.lookup,
source.invoke === true,
))
markEscaped(effect.node, source.mutation, effect.path);
}
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
// The write owner classifies direct and destructuring stores once.
if (!writeOnlyMembers.has(node))
for (const effect of propertyReadEffects(node.object, [
for (const effect of getterEffects(node.object, [
semanticStaticPropertyKey(node.property, node.computed),
]))
markEscaped(effect.node, node, effect.path);
@@ -4,7 +4,6 @@ import { expect, it } from "vitest";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import {
semanticClassPropertyReferences,
semanticNativePrototypeChain,
semanticObjectPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
@@ -37,37 +36,6 @@ const spelled = (
],
);
it.each([
["{}", "then", ["Object"]],
["{then:null}", "then", []],
["{then}", "then", []],
["{...{then:null}}", "then", []],
["{...unknown}", "then", ["Object"]],
["{__proto__:null}", "then", []],
["{__proto__:{}}", "then", []],
["{__proto__:0}", "then", ["Object"]],
["{__proto__:unknown}", "then", ["Object"]],
['{["__proto__"]:null}', "then", ["Object"]],
["[]", "then", ["Array", "Object"]],
["[]", "length", []],
["[value]", "0", []],
["[,value]", "0", ["Array", "Object"]],
["[value,...unknown]", "0", []],
["[...unknown,value]", "0", ["Array", "Object"]],
] as const)(
"retains possible native prototype lookup for %s[%s]",
(value, key, expected) => {
const parsed = parseJavaScriptSource(`const o=${value};`, "prototype.mjs");
const declaration = parsed?.program.body[0];
if (!t.isVariableDeclaration(declaration))
throw new Error("Expected declaration");
const node = declaration.declarations[0]?.init;
if (!t.isObjectExpression(node) && !t.isArrayExpression(node))
throw new Error("Expected literal");
expect(semanticNativePrototypeChain(node, key)).toEqual(expected);
},
);
it("resolves exact keys with source-order overrides, spreads and accessors", () => {
const fixture = objectAndState(
`const o = { a: 1, ...base, b: 2, a: 3, [dynamic]: 4, __proto__: proto, ...{ b: 5 }, get c() { return 6; }, c: 7 };`,
@@ -86,13 +54,14 @@ it("resolves exact keys with source-order overrides, spreads and accessors", ()
["4", "[]"],
["base", '["missing"]'],
]);
// A wildcard keeps possible slots, excluding data replaced by a known spread.
// A wildcard key still scans every property.
expect(spelled(fixture, null)).toEqual([
["7", "[]"],
["{ b: 5 }", '[{"excludedKeys":["c"]}]'],
["proto", "[null]"],
["4", "[]"],
["3", "[]"],
["2", "[]"],
["base", '[{"excludedKeys":["a","b","c"]}]'],
]);
// Numeric keys use the same exact spelling as string keys.
@@ -101,112 +70,6 @@ it("resolves exact keys with source-order overrides, spreads and accessors", ()
).toEqual([['"one"', "[]"]]);
});
it.each([1, "1"])(
"preserves inherited numeric property selection %j and own shadowing",
(key) => {
expect(
spelled(objectAndState("const o = { __proto__: prototype };"), key),
).toEqual([["prototype", JSON.stringify([String(key)])]]);
expect(
spelled(
objectAndState('const o = { __proto__: prototype, 1: "own" };'),
key,
),
).toEqual([['"own"', "[]"]]);
},
);
it.each([
[
"static",
"static method(){return 1;} method(){return 2;}",
"static method(){return 1;}",
],
[
"instance",
"static method(){return 1;} method(){return 2;}",
"method(){return 2;}",
],
[
"prototype",
"static method(){return 1;} method(){return 2;}",
"method(){return 2;}",
],
["static", "static method(){return 1;} static set method(value){}", null],
["static", "static method=child; static method(){return 1;}", "child"],
[
"static",
"static get method(){return child;} static method(){return 1;}",
"static method(){return 1;}",
],
[
"static",
"static method(){return 1;} static get method(){return child;}",
"child",
],
[
"static",
"static get method(){return child;} static method(){return 1;} static set method(value){}",
null,
],
] as const)(
"selects method-valued %s descriptors with runtime precedence: %s",
(kind, body, expected) => {
const source = `class Box { ${body} }`;
const parsed = parseJavaScriptSource(source, "methods.mjs");
if (parsed === null) throw new Error("Expected a parsed class");
const owner = parsed.program.body[0];
if (!t.isClassDeclaration(owner)) throw new Error("Expected a class");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
expect(
semanticClassPropertyReferences({ owner, kind }, ["method"], state).map(
({ node }) => source.slice(node.start ?? 0, node.end ?? 0),
),
).toEqual(expected === null ? [] : [expected]);
},
);
it.each(["method(){return 1;}", "...{method: child}"])(
"keeps an intervening object data definition between accessor pairs: %s",
(definition) => {
expect(
spelled(
objectAndState(
`const o = { get method(){return child;} , ${definition}, set method(value){} };`,
),
"method",
),
).toEqual([]);
},
);
it.each(["static", "instance"] as const)(
"retains inherited %s field ownership for enumerable copies",
(kind) => {
const source = `class Base { ${kind === "static" ? "static" : ""} method=child; } class Box extends Base {}`;
const parsed = parseJavaScriptSource(source, "fields.mjs");
if (parsed === null) throw new Error("Expected a parsed class");
const owner = parsed.program.body[1];
if (!t.isClassDeclaration(owner)) throw new Error("Expected derived class");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const references = semanticClassPropertyReferences(
{ owner, kind, fieldsOnly: true },
["method"],
state,
);
expect(
references.map(({ node }) =>
source.slice(node.start ?? 0, node.end ?? 0),
),
).toEqual(["child"]);
expect(references[0]?.inherited).toBe(kind === "static" ? true : undefined);
},
);
it("preserves references through a small static getter permutation", () => {
const getters = Array.from(
{ length: 5 },
@@ -154,73 +154,96 @@ const classPropertyDescriptors = (
initializedBefore?: t.ClassProperty,
accessor: "get" | "set" = "get",
) => {
const isStatic = kind === "static";
const selectedAccessors: t.ClassMethod[] = [];
const fields: t.ClassProperty[] = [];
const methods: t.ClassMethod[] = [];
const accessors: t.ClassMethod[] = [];
const definitions: (t.ClassMethod | t.ClassProperty)[] = [];
const indexedMembers: {
readonly member: t.ClassMethod | t.ClassProperty;
readonly index: number;
readonly key: string | null;
}[] = [];
const before =
initializedBefore === undefined
? -1
: klass.body.body.indexOf(initializedBefore);
for (const [index, member] of klass.body.body.entries()) {
klass.body.body.forEach((member, index) => {
if (
(!t.isClassMethod(member) &&
!(kind === "static" && isRuntimeJavaScriptClassProperty(member))) ||
member.static !== (kind === "static") ||
(before >= 0 &&
isRuntimeJavaScriptClassProperty(member) &&
index >= before) ||
(propertyName !== null &&
member.computed &&
semanticKnownSymbolKey(member.key, state))
(t.isClassMethod(member) || t.isClassProperty(member)) &&
propertyName !== null &&
member.computed &&
semanticKnownSymbolKey(member.key, state)
)
continue;
definitions.push(member);
}
// Methods/accessors are installed before static fields execute, even when a
// field appears earlier in source. Instance fields are owned separately.
const ordered = [
...definitions.filter((member) => t.isClassMethod(member)),
...definitions.filter((member) => t.isClassProperty(member)),
];
const data = new Set<string>();
const getterNames = new Set<string>();
const setterNames = new Set<string>();
let inherits = true;
for (const member of ordered.reverse()) {
const key = semanticStaticPropertyKey(member.key, member.computed);
if (propertyName !== null && key !== null && key !== propertyName) continue;
if (propertyName !== null && key === propertyName) inherits = false;
const descriptor =
t.isClassMethod(member) &&
(member.kind === "get" || member.kind === "set")
? member.kind
: "data";
const selected = key ?? propertyName;
return;
if (
selected !== null &&
(data.has(selected) ||
(descriptor === "data"
? getterNames.has(selected) || setterNames.has(selected)
: (descriptor === "get" ? getterNames : setterNames).has(selected)))
) {
// A later accessor replaced this data descriptor, but the data definition
// still prevents an earlier accessor from pairing across it.
if (descriptor === "data" && key !== null) data.add(key);
continue;
}
if (t.isClassProperty(member)) fields.push(member);
else if (member.kind === "method") methods.push(member);
else if (member.kind === accessor) accessors.push(member);
if (key !== null)
(descriptor === "data"
? data
: descriptor === "get"
? getterNames
: setterNames
).add(key);
before >= 0 &&
index >= before &&
isRuntimeJavaScriptClassProperty(member)
)
return;
if (
(t.isClassMethod(member) ||
(kind === "static" && isRuntimeJavaScriptClassProperty(member))) &&
member.static === isStatic
)
indexedMembers.push({
member,
index,
key: semanticStaticPropertyKey(member.key, member.computed),
});
});
const matchingMembers =
propertyName === null
? []
: indexedMembers.filter(({ key }) => key === propertyName);
const uncertainAccessors = indexedMembers.filter(
(entry): entry is typeof entry & { member: t.ClassMethod } => {
const { member, key } = entry;
return (
t.isClassMethod(member) &&
member.kind === accessor &&
(propertyName === null || (member.computed && key === null))
);
},
);
const getters = matchingMembers.filter(
(entry): entry is typeof entry & { member: t.ClassMethod } =>
t.isClassMethod(entry.member) && entry.member.kind === accessor,
);
const getter = getters.at(-1);
const getterIndex = getter?.index ?? -1;
const laterDataMember = matchingMembers.some(
({ member, index }) =>
t.isClassProperty(member) ||
(t.isClassMethod(member) &&
member.kind === "method" &&
index > getterIndex),
);
const seenFields = new Set<string>();
for (const { member, key } of [...indexedMembers].reverse()) {
if (!t.isClassProperty(member)) continue;
if (key !== null && seenFields.has(key)) continue;
if (key !== null) seenFields.add(key);
if (propertyName === null || key === null || key === propertyName)
fields.push(member);
}
return { accessors, fields, methods, inherits };
if (getters.length > 0 && !laterDataMember && getter !== undefined)
selectedAccessors.push(getter.member);
for (const { member, index } of uncertainAccessors)
if (
!matchingMembers.some(
({ member: matching, index: matchingIndex }) =>
(t.isClassProperty(matching) ||
(t.isClassMethod(matching) &&
(matching.kind === "method" || matching.kind === accessor))) &&
matchingIndex > index,
)
)
selectedAccessors.push(member);
return {
accessors: selectedAccessors,
fields,
inherits: matchingMembers.length === 0,
};
};
export const semanticClassPropertyReferences = (
@@ -322,21 +345,9 @@ class ClassReferenceWalk {
? descriptors.fields
: [])
if (field.value != null)
this.#references.push({
node: field.value,
path: this.#path,
...(this.lookup.kind === "static" && klass !== this.lookup.owner
? { inherited: true }
: {}),
});
this.#references.push({ node: field.value, path: this.#path });
for (const getter of this.lookup.fieldsOnly ? [] : descriptors.accessors)
this.#getter(klass, getter);
if (this.context.accessor === undefined && !this.lookup.fieldsOnly)
for (const method of descriptors.methods) {
const name = semanticStaticPropertyKey(method.key, method.computed);
if (name !== null && this.#fields.has(name)) continue;
this.#references.push({ node: method, path: this.#path });
}
// Only a definite own descriptor can suppress possible superclass results.
if (descriptors.inherits && klass.superClass != null)
for (const base of semanticClassCandidates(klass.superClass, this.state))
@@ -548,18 +559,6 @@ const expandGetterResult = (
type ObjectLiteralMember = t.ObjectExpression["properties"][number];
/** Only this colon-form definition selects an object literal's prototype. */
const isObjectPrototypeSetter = (
property: ObjectLiteralMember,
name = t.isSpreadElement(property)
? null
: semanticStaticPropertyKey(property.key, property.computed),
): property is t.ObjectProperty =>
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__";
/** Property positions grouped by whether an exact key can reach them. */
interface ObjectPropertyIndex {
/** Spreads, computed keys and `__proto__` data entries reach every key. */
@@ -580,7 +579,13 @@ const objectPropertyIndex = (node: t.ObjectExpression): ObjectPropertyIndex => {
const name = t.isSpreadElement(property)
? null
: semanticStaticPropertyKey(property.key, property.computed);
if (name === null || isObjectPrototypeSetter(property, name)) {
if (
name === null ||
(t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__")
) {
keyIndependent.push(index);
return;
}
@@ -666,9 +671,7 @@ class ObjectReferenceWalk {
readonly accessor?: "get" | "set",
readonly accessorLookup: "prototype" | "own" = "prototype",
) {
const [key, ...remaining] = path;
this.#key = typeof key === "number" ? String(key) : key;
this.#remaining = remaining;
[this.#key, ...this.#remaining] = path;
}
#spreadKey(): PropertyPath[number] {
@@ -688,29 +691,38 @@ class ObjectReferenceWalk {
#spread(property: t.SpreadElement): void {
const selected = this.#key ?? null;
if (typeof selected !== "object" && this.#overwritten.has(String(selected)))
return;
// Spread creates data properties; the source descriptor is not installed.
if (
this.accessor === undefined &&
(typeof selected === "object" || !this.#overwritten.has(String(selected)))
)
if (this.accessor === undefined)
this.#references.push({
node: property.argument,
path: [this.#spreadKey(), ...this.#remaining],
capturedSelectionAt: property.argument,
ownEnumerable: true,
});
if (t.isObjectExpression(property.argument))
for (const name of objectExpressionDefinedKeys(property.argument)) {
this.#overwritten.add(name);
this.#data.add(name);
}
if (
selected !== null &&
typeof selected !== "object" &&
t.isObjectExpression(property.argument) &&
objectExpressionDefinesKey(property.argument, String(selected))
) {
this.#overwritten.add(String(selected));
this.#data.add(String(selected));
}
}
#prototype(
property: t.ObjectMethod | t.ObjectProperty,
name: string | null,
): boolean {
if (!isObjectPrototypeSetter(property, name)) return false;
if (
!t.isObjectProperty(property) ||
property.computed ||
property.shorthand ||
name !== "__proto__"
)
return false;
const selected = this.#key ?? null;
if (this.accessor !== undefined && this.accessorLookup === "own")
return true;
@@ -719,7 +731,7 @@ class ObjectReferenceWalk {
(selected === null ||
typeof selected === "object" ||
(typeof selected === "string" &&
!objectExpressionDefinedKeys(this.node).has(selected)))
!objectExpressionDefinesKey(this.node, selected)))
)
this.#references.push({
node: property.value,
@@ -807,10 +819,8 @@ class ObjectReferenceWalk {
? this.#data.has(name) || this.#accessors.has(name)
: this.#data.has(name) ||
(kind === "get" ? this.#getters : this.#setters).has(name))
) {
if (kind === "data") this.#data.add(name);
)
return;
}
this.#read(property, name);
if (name !== null) this.#record(name, kind);
}
@@ -829,12 +839,12 @@ const objectPropertyReferences = (
): readonly JavaScriptSemanticObjectReference[] =>
new ObjectReferenceWalk(node, path, query).run();
const objectExpressionDefinedKeys = (
const objectExpressionDefinesKey = (
root: t.ObjectExpression,
): ReadonlySet<string> => {
key: string,
): boolean => {
const pending: t.ObjectExpression[] = [root];
const seen = new Set<t.ObjectExpression>();
const names = new Set<string>();
for (let cursor = 0; cursor < pending.length; cursor++) {
const object = pending[cursor];
if (object === undefined || seen.has(object)) continue;
@@ -846,11 +856,19 @@ const objectExpressionDefinedKeys = (
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (name !== null && !isObjectPrototypeSetter(property, name))
names.add(name);
if (
name === key &&
!(
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__"
)
)
return true;
}
}
return names;
return false;
};
export const semanticArrayPropertyReferences = (
@@ -906,44 +924,3 @@ export const semanticArrayPropertyReferences = (
}
return references;
};
/** Possible native fallback after own slots; explicit prototype references are walked separately. */
export const semanticNativePrototypeChain = (
node: t.ObjectExpression | t.ArrayExpression,
key: PropertyPath[number],
): readonly ("Object" | "Array")[] => {
const exact =
key !== null && typeof key !== "object" ? String(key) : undefined;
if (t.isArrayExpression(node)) {
if (exact === "length") return [];
if (exact !== undefined) {
const sources = semanticArrayPropertyReferences(node, [exact]);
if (
sources.length > 0 &&
sources.every((source) => source.capturedSelectionAt === undefined)
)
return [];
}
return ["Array", "Object"];
}
if (exact !== undefined && objectExpressionDefinedKeys(node).has(exact))
return [];
for (const property of node.properties) {
if (!isObjectPrototypeSetter(property)) continue;
const prototype = unwrapJavaScriptExpression(property.value).node;
// Null removes the prototype. Objects select the explicitly retained
// chain, while primitive values are ignored and unknowns may be primitive.
if (
t.isNullLiteral(prototype) ||
t.isObjectExpression(prototype) ||
t.isArrayExpression(prototype) ||
t.isFunctionExpression(prototype) ||
t.isArrowFunctionExpression(prototype) ||
t.isClassExpression(prototype) ||
t.isRegExpLiteral(prototype) ||
t.isNewExpression(prototype)
)
return [];
}
return ["Object"];
};
@@ -190,7 +190,6 @@ export function semanticProjectedSourceFactsAtRead(
memberReadAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
retainEligibility?: boolean,
valueEscapesHideSources?: boolean,
):
| {
readonly sources: readonly (Omit<ProjectedSource, "path"> & {
@@ -220,9 +219,6 @@ const projectedSourceFactsAtRead = (
memberReadAt: t.Node = reference,
capturedSelections: readonly SemanticCapturedSelection[] = [],
retainEligibility = false,
// Accessor lookup reads source syntax. A prior value escape does not hide
// that syntax; unknown writes and invocations still fail closed.
valueEscapesHideSources = true,
):
| {
readonly sources: readonly ProjectedSource[];
@@ -369,7 +365,6 @@ const projectedSourceFactsAtRead = (
(replacement.assignedSources?.[0]?.target?.start ?? -1)));
if (
includeUnknown &&
valueEscapesHideSources &&
semanticSomeEffectPathCandidate(
binding.escapedPaths,
path,
@@ -2,40 +2,6 @@ import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
"load('./dep.cjs')",
"load.call(null,'./dep.cjs')",
"load.apply(null,['./dep.cjs'])",
"Reflect.apply(load,null,['./dep.cjs'])",
])("retains a createRequire filename through %s", (call) => {
const result = analyzeJavaScriptStaticSource(
`import {createRequire} from 'node:module';const load=createRequire(new URL('./sub/entry.cjs',import.meta.url));${call};`,
);
expect(result.references.filter(({ kind }) => kind === "require")).toEqual([
expect.objectContaining({
specifier: "./dep.cjs",
resolution_base_path: "./sub/entry.cjs",
}),
]);
});
it.each([
"load.call(null,'./dep.cjs')",
"Reflect.apply(load,null,['./dep.cjs'])",
])("keeps mixed createRequire bases unknown through %s", (call) => {
const result = analyzeJavaScriptStaticSource(
`import {createRequire} from 'node:module';const load=createRequire(flag?import.meta.url:new URL('./sub/entry.cjs',import.meta.url));${call};`,
);
expect(result.references.filter(({ kind }) => kind === "require")).toEqual([
expect.objectContaining({ specifier: null }),
]);
expect(
result.references.every(
(reference) => !("resolution_base_path" in reference),
),
).toBe(true);
});
it.each([
['const h={require:module.require};h.require("./shared.cjs");', true],
[
@@ -30,8 +30,6 @@ import {
import {
semanticStaticPropertyKey,
semanticMemberChain,
semanticWriteSiteTarget,
semanticWriteTargets,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js";
@@ -73,33 +71,30 @@ export type {
const possibleCommonJsDelegateWriteStates =
new WeakSet<JavaScriptSemanticAnalysisState>();
/** Admission and indexing share the same direct, projected and iteration leaves. */
function* commonJsDelegateWriteTargets(
node: t.Node,
): Generator<t.MemberExpression | t.OptionalMemberExpression> {
const write = semanticWriteSiteTarget(node);
if (write === null) return;
for (const target of semanticWriteTargets(write)) {
if (!t.isMemberExpression(target) && !t.isOptionalMemberExpression(target))
continue;
const key = semanticStaticPropertyKey(target.property, target.computed);
if (key === "require" || (key === null && target.computed)) yield target;
}
}
/** Source eligibility only; the shared write owner still proves receiver identity. */
export const observePossibleCommonJsDelegateWrite = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): void => {
if (possibleCommonJsDelegateWriteStates.has(state)) return;
const target = t.isAssignmentExpression(node)
? node.left
: t.isUpdateExpression(node) ||
t.isUnaryExpression(node, { operator: "delete" })
? node.argument
: undefined;
if (
semanticIterationSources(node, state.parentsByNode.get(node) ?? null).some(
(source) => source.kind === "reference-store",
)
)
possibleCommonJsDelegateWriteStates.add(state);
if (!commonJsDelegateWriteTargets(node).next().done)
if (
(t.isMemberExpression(target) || t.isOptionalMemberExpression(target)) &&
["require", null].includes(
semanticStaticPropertyKey(target.property, target.computed),
)
)
possibleCommonJsDelegateWriteStates.add(state);
else if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
possibleCommonJsDelegateWriteStates.add(state);
@@ -891,9 +886,6 @@ export const semanticRequireCallAt = (
}
const interop = loaderInvocationInterop(state);
if (!t.isNewExpression(node)) {
// Invocation normalization retains the selected path identity. Keep its
// filename alongside that path rather than selecting the outer call again.
const sourceRelativePaths = new Map<readonly string[], string>();
const invocation = interop.intrinsicInvocation(node, (callee) => {
const loader = requireLoaderValueAt(
callee,
@@ -920,21 +912,22 @@ export const semanticRequireCallAt = (
state,
factoryBindingIds,
);
if (selected !== undefined) {
const path = [
if (selected !== undefined)
return [
"require-loader",
selected.loader,
selected.sourceRelative ? "relative" : "unknown",
...(selected.loaderAuthority === "candidate" ? ["candidate"] : []),
];
if (selected.sourceRelativePath !== undefined)
sourceRelativePaths.set(path, selected.sourceRelativePath);
return path;
}
return interop.intrinsicPath(callee);
});
if (invocation?.path[0] === "require-loader") {
const sourceRelativePath = sourceRelativePaths.get(invocation.path);
const selected = selectedRequireSourcesAt(
node.callee,
node,
state,
factoryBindingIds,
);
return {
operation: "load",
loader:
@@ -949,9 +942,9 @@ export const semanticRequireCallAt = (
: {}),
sourceRelative:
invocation.kind === "known" && invocation.path[2] === "relative",
...(invocation.kind === "known" && sourceRelativePath !== undefined
? { sourceRelativePath }
: {}),
...(selected?.sourceRelativePath === undefined
? {}
: { sourceRelativePath: selected.sourceRelativePath }),
};
}
}
@@ -1563,8 +1556,20 @@ const writesModuleRequire = (
};
traverseJavaScriptValueAst(state.program, state.erasedSyntaxNodes, {
enter: (node) => {
for (const target of commonJsDelegateWriteTargets(node))
record(node, target.object);
const target = t.isAssignmentExpression(node)
? node.left
: t.isUpdateExpression(node) ||
t.isUnaryExpression(node, { operator: "delete" })
? node.argument
: undefined;
if (
t.isMemberExpression(target) ||
t.isOptionalMemberExpression(target)
) {
const key = semanticStaticPropertyKey(target.property, target.computed);
if (key === "require" || (key === null && target.computed))
record(node, target.object);
}
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const invocation = interop.intrinsicInvocation(node);
const owner =
@@ -1578,21 +1583,42 @@ const writesModuleRequire = (
const key = interop.staticString(args?.[1]);
if (key === "require" || key === undefined)
record(node, args?.[0] ?? t.nullLiteral());
} else if (owner === "Reflect" && method === "set") {
const key = interop.staticString(args?.[1]);
if (key === "require" || key === undefined)
record(node, args?.[3] ?? args?.[0] ?? t.nullLiteral());
} else if (owner === "Object" && method === "defineProperties") {
const descriptors = args?.[1];
if (objectMayProvideRequire(descriptors))
if (
!t.isObjectExpression(descriptors) ||
descriptors.properties.some(
(property) =>
t.isSpreadElement(property) ||
(t.isObjectProperty(property) &&
semanticStaticPropertyKey(property.key, property.computed) ===
"require"),
)
)
record(node, args?.[0] ?? t.nullLiteral());
} else if (owner === "Object" && method === "assign") {
const target = args?.[0];
if (
target !== undefined &&
args?.slice(1).some(objectMayProvideRequire)
)
record(node, target);
if (target !== undefined) {
const sourceHasRequire = args
?.slice(1)
.some(
(arg) =>
t.isObjectExpression(arg) &&
arg.properties.some(
(property) =>
t.isObjectProperty(property) &&
semanticStaticPropertyKey(
property.key,
property.computed,
) === "require",
),
);
if (
sourceHasRequire ||
args?.slice(1).some((arg) => !t.isObjectExpression(arg))
)
record(node, target);
}
}
}
},
@@ -1601,15 +1627,6 @@ const writesModuleRequire = (
return writes;
};
/** Methods, spreads and unknown computed keys can all supply an own delegate. */
const objectMayProvideRequire = (node: t.Node | undefined): boolean =>
!t.isObjectExpression(node) ||
node.properties.some((property) => {
if (t.isSpreadElement(property)) return true;
const key = semanticStaticPropertyKey(property.key, property.computed);
return key === "require" || key === null;
});
/** Whether the original global process object can still own effects at this source site. */
export const semanticOriginalGlobalProcessAt = (
node: t.Node,
@@ -78,11 +78,7 @@ export const collectJavaScriptSemanticResources = (
const acquisitions = collectAcquisitions(program, context).filter(
(candidate) =>
!socketPortInFirstArgument(candidate) ||
(!rejectsSocketLiteralArguments(
candidate.node,
candidate.specifier,
context,
) &&
(!rejectsSocketPortLiteral(candidate.node, context) &&
!rejectsTlsProtocolVersionLiteral(
candidate.node,
candidate.specifier,
@@ -451,9 +447,6 @@ const collectReleases = (
resolution:
!opaqueReceiver &&
linked.length === 1 &&
(binding.kind !== "parameter" ||
semanticBindingAtRead(binding, member.object, context.state)
.hasDefiniteInitializer) &&
holdsOnlyAcquisitions(binding, linked, context.state)
? "complete"
: "partial",
@@ -580,25 +573,16 @@ const resourceCandidateId = (candidate: ResourceCandidate): string =>
* readable non-port literal is decided. `""` is a path with no length, which
* Node also rejects.
*
* `net` also rejects an omitted or undefined port, before it returns a socket.
* TLS merges later options, so an undefined first argument alone cannot decide
* its admission. Object arguments and spread positions remain undecided here.
* `undefined` and an empty object throw `ERR_MISSING_ARGS`, but those are a
* missing option rather than a bad port, so they stay undecided here.
*/
const rejectsSocketLiteralArguments = (
const rejectsSocketPortLiteral = (
node: t.CallExpression | t.OptionalCallExpression,
specifier: string,
context: DataEffectTraversalContext,
): boolean => {
const port = node.arguments[0];
if (port === undefined) return specifier === "net";
if (t.isSpreadElement(port)) return false;
if (port === undefined || t.isSpreadElement(port)) return false;
const value = unwrapJavaScriptExpression(port).node;
if (
specifier === "net" &&
(t.isUnaryExpression(value, { operator: "void" }) ||
isUnmodifiedGlobalReference(value, context.state, "undefined"))
)
return true;
if (t.isStringLiteral(value)) return value.value.length === 0;
if (
t.isNullLiteral(value) ||
@@ -617,14 +601,13 @@ const rejectsSocketLiteralArguments = (
isUnmodifiedGlobalReference(value, context.state, value.name)
)
return true;
// Numeric unary operators are judged by their result: `-0` is port zero,
// and `~(-1.5)` is zero too. `typeof` instead yields a nonempty IPC path.
// `-1` and `-1.5` are unary expressions over numeric literals: always out of
// range because a port is never negative.
if (t.isUnaryExpression(value)) {
if (value.operator === "!" || value.operator === "delete") return true;
const number = unaryOperandNumber(value, context, 0);
const operand = unwrapJavaScriptExpression(value.argument).node;
return (
number !== undefined &&
(!Number.isInteger(number) || number < 0 || number > 65535)
t.isNumericLiteral(operand) &&
(value.operator === "-" || !Number.isInteger(operand.value))
);
}
return false;
@@ -205,21 +205,3 @@ it("widens a cycle that rewrites the path head while increasing its depth", () =
returnedExpression(parsed.program, "target"),
);
});
it("retains both finite class-prototype callable selections", () => {
const { parsed, state } = indexed(`
class C { first(){return firstResult} second(){return secondResult} }
const selected=flag?C.prototype.first:C.prototype.second;
selected();
`);
const [read] = callReads(parsed.program, "selected");
if (read === undefined)
throw new Error("Missing selected prototype method call");
const result = createSemanticReturnedReferences(state)(read);
expect(result.precision).toBe("exact");
expect(
result.references
.map(({ node }) => (t.isIdentifier(node) ? node.name : null))
.sort(),
).toEqual(["firstResult", "secondResult"]);
});
@@ -24,13 +24,8 @@ import {
semanticKnownArrayIterationAt,
semanticSelectedReferenceSlot,
} from "./javascriptSemanticReferenceSources.js";
import { semanticArrayPropertyReferences } from "./javascriptSemanticObjectReferences.js";
import {
semanticArrayPropertyReferences,
semanticObjectPropertyReferences,
semanticClassPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import {
semanticCapturedSelectionsAfterPropertyRead,
semanticNormalizeCapturedSelections,
semanticProjectedSourcesAtRead,
semanticShiftCapturedSelections,
@@ -67,7 +62,6 @@ interface Candidate {
| readonly SemanticCapturedSelection[]
| undefined;
readonly invocation?: JavaScriptSourceInvocationContext;
readonly classKind?: "instance" | "static";
}
export interface SemanticReturnedReferenceResult {
@@ -80,6 +74,22 @@ export interface SemanticReturnedReferenceResult {
readonly precision: "exact" | "widened";
}
interface ObjectTargetIndex {
readonly propertyByName: ReadonlyMap<
string,
t.ObjectMethod | t.ObjectProperty
>;
readonly trailingUnknownsByProperty: ReadonlyMap<
t.ObjectMethod | t.ObjectProperty,
ObjectTargetUnknown
>;
}
interface ObjectTargetUnknown {
readonly node: t.ObjectMethod | t.ObjectProperty | t.SpreadElement;
readonly previous: ObjectTargetUnknown | undefined;
}
/** Resolve local result references conservatively, without claiming call execution. */
export const createSemanticReturnedReferences = (
state: JavaScriptSemanticAnalysisState,
@@ -105,6 +115,7 @@ export const createSemanticReturnedReferences = (
}[];
}
>();
const objectTargets = new WeakMap<t.ObjectExpression, ObjectTargetIndex>();
return (callee, invocation) => {
const existing = results.get(callee);
if (
@@ -130,7 +141,7 @@ export const createSemanticReturnedReferences = (
// shared diamonds and reassignment cycles converge to the graph fixed point.
const projectionDepths = new WeakMap<
t.Node,
Map<string, { depth: number | undefined }>
Map<string, number | undefined>
>();
const expanded = new WeakMap<t.Node, Set<string>>();
let additionalContexts = 0;
@@ -179,7 +190,6 @@ export const createSemanticReturnedReferences = (
readAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
invocationContext?: JavaScriptSourceInvocationContext,
classKind?: Candidate["classKind"],
): void => {
const source = unwrapJavaScriptExpression(node).node;
if (contextsWidened) {
@@ -203,25 +213,20 @@ export const createSemanticReturnedReferences = (
readAt?.start ?? -1,
readAt?.end ?? -1,
invocationKey(),
classKind,
]);
const depths = projectionDepths.get(source) ?? new Map();
projectionDepths.set(source, depths);
let context = depths.get(readKey);
if (context === undefined) {
context = { depth: path?.length };
depths.set(readKey, context);
} else if (
context.depth === undefined ||
context.depth !== path?.length
) {
if (context.depth !== undefined) result.precision = "widened";
context.depth = undefined;
path = undefined;
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;
}
}
// Selector joins share the transition's read/invocation/receiver context.
// Joining only by syntax would cross static and instance member names.
if (path !== undefined) path = selections.normalize(context, path);
if (path !== undefined) path = selections.normalize(source, path);
capturedSelections =
path === undefined
? undefined
@@ -274,10 +279,9 @@ export const createSemanticReturnedReferences = (
readAt?.start ?? -1,
readAt?.end ?? -1,
null,
classKind,
]);
if (sourceGenericInvocation) result.unresolvedInvocation = true;
depths.set(readKey, { depth: undefined });
depths.set(readKey, undefined);
key = expansionKey();
if (paths.has(key)) return;
}
@@ -285,7 +289,6 @@ export const createSemanticReturnedReferences = (
expanded.set(source, paths);
work.push({
node: source,
...(classKind === undefined ? {} : { classKind }),
path,
...(readAt === undefined ? {} : { readAt }),
...(capturedSelections === undefined ? {} : { capturedSelections }),
@@ -379,7 +382,6 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
continue;
@@ -503,38 +505,9 @@ export const createSemanticReturnedReferences = (
result.unresolvedInvocation = true;
}
}
} else if (t.isObjectExpression(node)) {
const [key, ...suffix] = current.path ?? [];
for (const choice of key === undefined
? [undefined]
: semanticAliasSelectionChoices(key))
for (const source of semanticObjectPropertyReferences(
node,
choice === undefined ? [] : [choice],
state,
)) {
if (source.widenedGetterResult) result.precision = "widened";
if (
source.inherited &&
current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
)
continue;
pending.push({
...current,
node: source.node,
path:
current.path === undefined || source.widenedGetterResult
? undefined
: [...source.path, ...suffix],
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else if (t.isArrayExpression(node)) {
} else if (t.isObjectExpression(node))
selectObjectTargets(node, current, pending, objectTargets);
else if (t.isArrayExpression(node)) {
const [key, ...path] = current.path ?? [];
if (current.path !== undefined && key !== undefined) {
for (const choice of semanticAliasSelectionChoices(key))
@@ -579,7 +552,6 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
continue;
@@ -618,83 +590,63 @@ export const createSemanticReturnedReferences = (
});
});
} else if (t.isNewExpression(node))
pending.push({ ...current, node: node.callee, classKind: "instance" });
pending.push({ ...current, node: node.callee });
else if (t.isClass(node)) {
const [key, ...path] = current.path ?? [];
if (current.path !== undefined && key !== undefined) {
for (const choice of semanticAliasSelectionChoices(key)) {
const prototype =
current.classKind !== "instance" && choice === "prototype";
if (prototype && path.length === 0) continue;
const ownEnumerable = current.capturedSelections?.some(
(capture) =>
capture.depth === (prototype ? 2 : 1) && capture.ownEnumerable,
);
const member = prototype ? path[0] : choice;
if (member === undefined) continue;
for (const selected of semanticAliasSelectionChoices(member))
for (const source of semanticClassPropertyReferences(
{
owner: node,
kind: prototype
? "prototype"
: (current.classKind ?? "static"),
...(ownEnumerable ? { fieldsOnly: true as const } : {}),
},
[selected],
state,
)) {
if (source.inherited && ownEnumerable) continue;
if (source.widenedGetterResult) result.precision = "widened";
pending.push({
...current,
node: source.node,
path: source.widenedGetterResult
? undefined
: [...source.path, ...(prototype ? path.slice(1) : path)],
classKind: "static",
capturedSelections:
semanticCapturedSelectionsAfterPropertyRead(
prototype
? semanticShiftCapturedSelections(
current.capturedSelections,
-1,
)
: current.capturedSelections,
source,
),
});
}
}
for (const child of pending)
enqueue(
child.node,
child.path,
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
continue;
}
// Unknown projections retain every possible callable descendant. At
// zero depth only construction contributes a source-local result.
for (const member of node.body.body) {
if (
t.isClassMethod(member) &&
(current.path === undefined || member.kind === "constructor")
if (t.isClassMethod(member)) {
if (
((current.path === undefined || current.path.length === 0) &&
member.kind === "constructor") ||
current.path === undefined ||
(key !== undefined &&
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) &&
member.value !== null &&
member.value !== undefined &&
(key !== undefined || current.path === undefined) &&
(current.path === undefined ||
(key !== undefined &&
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 : [],
node: member.value,
path: current.path === undefined ? undefined : path,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
else if (
current.path === undefined &&
t.isClassProperty(member) &&
member.value != null
)
pending.push({ ...current, node: member.value, path: undefined });
}
if (node.superClass !== null && node.superClass !== undefined)
pending.push({ ...current, node: node.superClass });
@@ -708,7 +660,6 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
}
@@ -748,3 +699,139 @@ export const createSemanticReturnedReferences = (
return result;
};
};
const selectObjectTargets = (
node: t.ObjectExpression,
item: Candidate,
pending: Candidate[],
indexes: WeakMap<t.ObjectExpression, ObjectTargetIndex>,
): void => {
const [key, ...path] = item.path ?? [];
const childPath = item.path === undefined ? undefined : path;
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) {
const unknowns: ObjectTargetUnknown[] = [];
for (
let unknown = index.trailingUnknownsByProperty.get(property);
unknown !== undefined;
unknown = unknown.previous
)
unknowns.push(unknown);
for (const { node: unknown } of unknowns.reverse())
pending.push({
...item,
node: t.isSpreadElement(unknown)
? unknown.argument
: t.isObjectProperty(unknown)
? 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;
}
}
let replaced = false;
for (const property of [...node.properties].reverse()) {
if (replaced) break;
if (t.isSpreadElement(property)) {
pending.push({
...item,
node: property.argument,
capturedSelections: [
...(item.capturedSelections ?? []),
{ at: property.argument, depth: 1, ownEnumerable: true },
],
});
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (
key !== null &&
key !== undefined &&
name !== null &&
!semanticPropertyPathKeyMatches(key, name)
)
continue;
if (t.isObjectProperty(property))
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;
}
};
const objectTargetIndex = (
node: t.ObjectExpression,
indexes: WeakMap<t.ObjectExpression, ObjectTargetIndex>,
): ObjectTargetIndex => {
const existing = indexes.get(node);
if (existing !== undefined) return existing;
const propertyByName = new Map<string, t.ObjectMethod | t.ObjectProperty>();
const trailingUnknownsByProperty = new Map<
t.ObjectMethod | t.ObjectProperty,
ObjectTargetUnknown
>();
let trailingUnknown: ObjectTargetUnknown | undefined;
for (const property of [...node.properties].reverse()) {
if (t.isSpreadElement(property)) {
trailingUnknown = { node: property, previous: trailingUnknown };
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (name === null) {
trailingUnknown = { node: property, previous: trailingUnknown };
continue;
}
if (propertyByName.has(name)) continue;
propertyByName.set(name, property);
if (trailingUnknown !== undefined)
trailingUnknownsByProperty.set(property, trailingUnknown);
}
const created = { propertyByName, trailingUnknownsByProperty };
indexes.set(node, created);
return created;
};
@@ -155,31 +155,3 @@ it.each([
}
},
);
it("discloses getter-result widening when selecting returned callable targets", () => {
const parsed = parseJavaScriptSource(
"const box={get item(){return {make:()=>result}}};box.item.make();",
);
if (parsed === null) throw new Error("Expected getter fixture");
const steps = indexJavaScriptBindingsSteps(parsed.program, {
getter_expansions: 1,
});
let step = steps.next();
while (!step.done) step = steps.next();
let call: t.CallExpression | undefined;
t.traverseFast(parsed.program, (node) => {
if (t.isCallExpression(node)) call = node;
});
if (call === undefined)
throw new Error("Expected getter-produced callable invocation");
const result = createSemanticReturnedReferences(step.value)(
call.callee,
call,
);
expect(result.precision).toBe("widened");
expect(result.references).toContainEqual({
node: expect.objectContaining({ type: "Identifier", name: "result" }),
projection: [],
});
expect(step.value.returnedReferenceWidenings.has(call.callee)).toBe(true);
});
@@ -81,29 +81,3 @@ it.each([
);
},
);
it.each([
["const box={...{__proto__:{make:()=>module}}};box.make()", false],
["const box={...{get make(){return ()=>module}}};box.make()", true],
["class C{static make(){return module}};const box={...C};box.make()", false],
["class C{make(){return module}};const box={...new C()};box.make()", false],
[
"class Base{static make=()=>module};class C extends Base{};const box={...C};box.make()",
false,
],
["class C{static make=()=>module};const box={...C};box.make()", true],
["class C{make=()=>module};const box={...new C()};box.make()", true],
] as const)(
"copies only own enumerable callable values: %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 })]
: [],
);
},
);
@@ -201,82 +201,3 @@ it("refreshes cached returned targets after the shared member index settles", ()
}),
]);
});
it.each([
["const box={set make(value){return module}};box.make()", false],
["const box={get make(){return module}};box.make()", false],
["const box={get make(){return ()=>module}};box.make()", true],
[
"const box={get make(){return ()=>module},set make(value){}};box.make()",
true,
],
["const box={get make(){return ()=>module},make:()=>({})};box.make()", false],
["const box={__proto__:{make:()=>module}};box.make()", true],
["const box={__proto__:{make:()=>module},make:()=>({})};box.make()", false],
] as const)(
"uses property-read ownership before invoking a returned callable: %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.each([
["class C{make(){return module}};C.make()", false],
["class C{static make(){return module}};new C().make()", false],
["class C{static make(){return module}};C.make()", true],
["class C{make(){return module}};new C().make()", true],
["class C{make(){return module}};C.prototype.make()", true],
["class C{get make(){return ()=>module}};new C().make()", true],
["class C{set make(value){return module}};new C().make()", false],
[
"class Base{static make(){return module}};class C extends Base{};C.make()",
true,
],
[
"class Base{make(){return module}};class C extends Base{make=()=>({})};new C().make()",
false,
],
] as const)(
"preserves the class property receiver before invoking its value: %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.each([
["C.first:new C().second", false],
["C.second:new C().first", true],
] as const)(
"keeps static and instance selector alternatives correlated: %s",
(selection, expected) => {
const analysis = analyzeJavaScriptStaticSource(`
class C {
static first(){return {}} first(){return module}
static second(){return module} second(){return {}}
}
const selected=flag?${selection};selected().require('./shared.cjs');
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -535,7 +535,7 @@ const semanticWithPrototypeKind = (
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
semanticStaticPropertyKey(property.key, false) === "__proto__"
t.isIdentifier(property.key, { name: "__proto__" })
)
return t.isNullLiteral(unwrapJavaScriptExpression(property.value).node)
? "null"
@@ -3,58 +3,7 @@ import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import {
analyzeCommonJsStaticFixture,
analyzeCommonJsSemanticFixture,
} from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
it.each([
"Object.assign(module,{require(){return {}}});",
"Object.assign(module,{...replacement});",
"Object.defineProperties(module,{[key]:{value:()=>({})}});",
'Reflect.set(module,"require",()=>({}));',
'Reflect.set({},"require",()=>({}),module);',
"({x:module.require}={x:replacement});",
"[module.require]=[replacement];",
"for(module.require of [replacement]){}",
])("retains uncertainty after a possible delegate replacement: %s", (write) => {
const analysis = analyzeCommonJsStaticFixture(`${write}require("electron");`);
expect(analysis.references).not.toContainEqual(
expect.objectContaining({ kind: "require", specifier: "electron" }),
);
expect(analysis.references).toContainEqual(
expect.objectContaining({ kind: "require", specifier: null }),
);
});
it.each([
"Object.assign(module,{other(){}});",
"Object.defineProperties(module,{other:{value:()=>({})}});",
"Object.assign({},{require(){}});",
'Reflect.set(module,"other",()=>({}));',
'Reflect.set(module,"require",()=>({}),{});',
"({x:module.other}={x:replacement});",
"[module.other]=[replacement];",
"for(module.other of [replacement]){}",
])("preserves the host loader after an unrelated write: %s", (write) => {
const analysis = analyzeCommonJsStaticFixture(`${write}require("electron");`);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "electron" }),
]);
});
it.each([
"({x:module.require}={x:replacement});",
"[module.require]=[replacement];",
"for(module.require of [replacement]){}",
])("preserves a builtin captured before its delegate write: %s", (write) => {
const analysis = analyzeCommonJsSemanticFixture(
`const fs=require('node:fs');${write}fs.readFileSync('kept');`,
);
expect(analysis.configurationOperations.map(({ key }) => key)).toEqual([
"kept",
]);
});
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
it.each([
'var require;require("./before.cjs");',
@@ -76,44 +76,3 @@ it("recognizes a bundler runtime through nested member names", () => {
modules: [expect.objectContaining({ exports: ["ok"] })],
});
});
it.each(["1 as const", "1 satisfies number"])(
"keeps numeric bundle identities through erased syntax: %s",
(entry) => {
const analysis = analyzeJavaScriptStaticSource(`
globalThis.webpackChunkApp.push([[(${entry})], {
1: function(module, exports, req) { req((2 as const)); req.e((3 satisfies number)); },
2: function() {}
}, function(req) { req((${entry})); }]);
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.bundler_registrations).toMatchObject([
{
chunk_keys: ["1"],
unknown_chunk_keys: 0,
entry_module_keys: ["1"],
unknown_entry_module_keys: 0,
async_chunk_keys: ["3"],
unknown_async_chunk_keys: 0,
},
]);
expect(analysis.references).toContainEqual(
expect.objectContaining({
specifier: "2",
loader: "factory-loader",
module_key: "1",
}),
);
},
);
it.each(["0, {}", "[], modules"])(
"does not grant factory-loader authority to a rejected registration: %s",
(entry) => {
const analysis = analyzeJavaScriptStaticSource(`
globalThis.webpackChunkApp.push([${entry}, function(req) { req("electron"); }]);
`);
expect(analysis.bundler_registrations).toEqual([]);
expect(analysis.references).toEqual([]);
},
);
@@ -140,7 +140,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
accumulator,
vitePreloadDependencyTable: indexVitePreloadDependencyTable(
file.program,
bindings,
bindings.erasedSyntaxNodes,
),
openReceiverFacts: receivers.openReceivers,
electronBindings: electronIdentity.bindings,
@@ -48,12 +48,12 @@ export const inspectBundlerRegistration = (
if (runtime === undefined || !t.isArrayExpression(entry)) return;
const chunkIds = entry.elements[0];
const table = entry.elements[1];
if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return;
const runtimeValue = runtimeMetadata(
entry.elements[2],
bindings,
accumulator,
);
if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return;
const recovered = recoverBundlerModules(source, table, accumulator, {
runtime,
bindings,
@@ -43,7 +43,6 @@ import type {
import { VITE_OUTPUT_DEPENDENCY_PREFIX } from "./javascriptStaticAnalysisTypes.js";
import {
resolveSemanticBindingState,
semanticBindingLookupAt,
semanticStableInitializer,
type JavaScriptBindingIndex,
type JavaScriptSemanticAnalysisState,
@@ -412,7 +411,7 @@ const chunkLoadSpecifier = (
const vitePreloadDependencySpecifiers = (
node: JavaScriptCallLike,
name: string,
table: FindingContext["vitePreloadDependencyTable"],
table: readonly (string | undefined)[] | null,
bindings: JavaScriptSemanticAnalysisState,
): readonly (string | undefined)[] => {
if (
@@ -421,18 +420,10 @@ const vitePreloadDependencySpecifiers = (
!t.isArrayExpression(node.arguments[0])
)
return [];
const callee = unwrapJavaScriptExpression(node.callee).node;
const lookup = semanticBindingLookupAt(bindings, callee, name);
if (
(lookup.kind !== "binding" && lookup.kind !== "uncertain-binding") ||
semanticStableInitializer(lookup.binding, bindings)?.node !== table.helper
)
return [];
const indexes = node.arguments[0].elements;
// Explicit cache overrides change which table the helper reads. Retain the
// call slots as unknown unless every override is statically undefined.
if (
lookup.kind !== "binding" ||
!node.arguments
.slice(1)
.every((argument) => isExplicitDefaultArgument(argument, bindings))
@@ -442,7 +433,7 @@ const vitePreloadDependencySpecifiers = (
if (element === null || !t.isNumericLiteral(element)) return undefined;
const index = element.value;
return Number.isSafeInteger(index) && index >= 0
? viteDependencySpecifier(table.dependencies[index])
? viteDependencySpecifier(table[index])
: undefined;
});
};
@@ -482,9 +473,8 @@ type ViteMapDepsFunction =
/** Index the canonical Vite helper's cache initializer from parsed syntax. */
export const indexVitePreloadDependencyTable = (
program: t.Program,
bindings: JavaScriptSemanticAnalysisState,
): FindingContext["vitePreloadDependencyTable"] => {
const { erasedSyntaxNodes } = bindings;
erasedSyntaxNodes: WeakSet<t.Node>,
): readonly (string | undefined)[] | null => {
const helpers: ViteMapDepsFunction[] = [];
traverseJavaScriptValueAst(program, erasedSyntaxNodes, {
enter: (node) => {
@@ -507,7 +497,7 @@ export const indexVitePreloadDependencyTable = (
const mapIdentifier = t.isAssignmentPattern(mapParameter)
? mapParameter.left
: mapParameter;
if (!t.isIdentifier(mapIdentifier)) return { helper, dependencies: [] };
if (!t.isIdentifier(mapIdentifier)) return [];
const tables: t.ArrayExpression[] = [];
traverseJavaScriptValueAst(helper, erasedSyntaxNodes, {
skip: (node) => t.isFunction(node),
@@ -525,9 +515,9 @@ export const indexVitePreloadDependencyTable = (
tables.push(node.right);
},
});
if (tables.length !== 1) return { helper, dependencies: [] };
if (tables.length !== 1) return [];
const table = tables[0];
if (table === undefined) return { helper, dependencies: [] };
if (table === undefined) return [];
// A spread contributes an unknown number of entries, so every later slot
// has an unknown index; leave those out instead of shifting them.
const spread = table.elements.findIndex((element) =>
@@ -550,14 +540,6 @@ export const indexVitePreloadDependencyTable = (
semanticIdentifierRole(node, parent, readAncestors) === null
)
return;
const lookup = semanticBindingLookupAt(bindings, node, node.name);
if (
lookup.kind === "ambient" ||
lookup.kind === "captured" ||
(lookup.kind === "binding" &&
semanticStableInitializer(lookup.binding, bindings)?.node !== helper)
)
return;
if (
(t.isCallExpression(parent) || t.isOptionalCallExpression(parent)) &&
parent.callee === node
@@ -566,12 +548,7 @@ export const indexVitePreloadDependencyTable = (
escapedOrMutated = true;
},
});
return {
helper,
dependencies: escapedOrMutated
? dependencies.map(() => undefined)
: dependencies,
};
return escapedOrMutated ? dependencies.map(() => undefined) : dependencies;
};
const requestConstructorUrl = (
@@ -769,11 +769,8 @@ export const stringValue = (
/** Return a string or numeric argument literal. */
export const argumentValue = (
node: t.Node | null | undefined,
): string | undefined => {
if (node === null || node === undefined) return undefined;
const value = unwrapJavaScriptExpression(node).node;
return t.isNumericLiteral(value) ? String(value.value) : stringValue(value);
};
): string | undefined =>
t.isNumericLiteral(node) ? String(node.value) : stringValue(node);
/** Prefix a static argument value when one is available. */
export const prefixedArgument = (
@@ -133,10 +133,7 @@ export interface JavaScriptFindingContext {
readonly expressionShapes: JavaScriptExpressionShapeResolver;
readonly source: string;
readonly accumulator: JavaScriptAnalysisAccumulator;
readonly vitePreloadDependencyTable: {
readonly helper: t.Node;
readonly dependencies: readonly (string | undefined)[];
} | null;
readonly vitePreloadDependencyTable: readonly (string | undefined)[] | null;
readonly openReceiverFacts?: ReadonlyMap<number, JavaScriptOpenReceiverFact>;
/** Proven Electron export path segments by root offset. */
readonly electronBindings?: ReadonlyMap<number, readonly string[]>;
@@ -1,7 +1,6 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
const windowPrefix =
"import {BrowserWindow} from 'electron'; import {fileURLToPath} from 'node:url';";
@@ -153,52 +152,3 @@ it("keeps a reused renderer receiver uncertain after its native method call", ()
"independent.html",
]);
});
it.each([
["untouched exports", "", 1],
[
"object-pattern exports write",
"({value: module.exports} = replacement);",
0,
],
["array-pattern exports write", "[module.exports] = replacement;", 0],
["loop-target exports write", "for (module.exports of replacement) {}", 0],
] as const)(
"requires intact bootstrap exports after %s",
(_name, write, expected) => {
const source = `(() => {
const table = {
external(module, exports, load) { module.exports = require('electron'); },
entry(module, exports, load) { new (load('external').BrowserWindow)(); }
};
function load(key) {
const module = {exports: {}};
table[key](module, module.exports, load);
${write}
return module.exports;
}
load('entry');
})();`;
const observed: string[] = [];
const windowClass = (identity: string) =>
class {
constructor() {
observed.push(identity);
}
};
const local = { BrowserWindow: windowClass("replacement") };
runInNewContext(source, {
replacement: Object.assign([local], { value: local }),
require: (specifier: string) => {
expect(specifier).toBe("electron");
return { BrowserWindow: windowClass("electron") };
},
});
expect(observed).toEqual([expected === 1 ? "electron" : "replacement"]);
expect(
analyzeCommonJsStaticFixture(source).electron.browser_windows,
).toHaveLength(expected);
},
);
import { runInNewContext } from "node:vm";
@@ -7,7 +7,6 @@ import {
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { JavaScriptSymbolicBindings } from "./javascriptSymbolicBindings.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import {
symbolicTruth,
symbolicVariants,
@@ -1077,94 +1076,3 @@ it("retains each external selection guard once across deep member reads", () =>
});
expect(resolver.truncated).toBe(false);
});
it.each([
[
"module property after replacement",
'module.exports={api:null};module.exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"module property overwritten after replacement",
'module.exports={api:require("electron")};module.exports.api=null;',
{ kind: "literal", value: null },
],
[
"detached exports property",
'module.exports={api:null};exports.api=require("electron");',
{ kind: "literal", value: null },
],
[
"rebound exports parameter",
'module.exports={api:null};exports={api:require("electron")};',
{ kind: "literal", value: null },
],
[
"identity-preserving module write",
'module.exports=module.exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"identity-preserving original exports write",
'module.exports=exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"reconnected exports parameter",
'module.exports={api:null};exports=module.exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"rebound module parameter",
'module={exports:{}};module.exports.api=require("electron");',
{ kind: "unknown", reason: "ambiguous export writes" },
],
] as const)("retains namespace write order for %s", (_name, body, expected) => {
const value = inspectOne(`(() => {
const modules={
1(module,exports){${body}},
2(module,exports,load){inspect(load(1).api);}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(value).toEqual(expected);
});
it("lists members added to the public module object after its replacement", () => {
const { resolver, values } = inspectFixture(`(() => {
const modules={
1(module,exports){module.exports={first:1};module.exports.later=2;exports.detached=3;},
2(module,exports,load){inspect(load(1));}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(values).toHaveLength(1);
expect(resolver.members(values[0]!)).toEqual(["first", "later"]);
});
it("derives bridge paths from the latest public module member writes", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules={
1(module,exports){
const e=require("electron");
module.exports={action:()=>e.ipcRenderer.send("stale")};
module.exports.action=()=>e.ipcRenderer.send("current");
exports.detached=()=>e.ipcRenderer.send("detached");
},
2(module,exports,load){
const api=load(1);
require("electron").contextBridge.exposeInMainWorld("desktop",api);
}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(
result.electron.call_paths?.map(({ entry, channel }) => ({
entry,
channel,
})),
).toEqual([{ entry: "desktop.action", channel: "current" }]);
});
@@ -52,9 +52,6 @@ interface PropertyWrite {
readonly path: readonly string[];
readonly value: t.Node;
readonly site: t.Node;
readonly exportReceiver?: "module" | "exports";
readonly exportValueReceiver?: "module" | "exports";
readonly receiverUncertain?: true;
}
interface SymbolicReturnFlow extends SymbolicMetadata {
readonly values: readonly SymbolicValue[];
@@ -201,12 +198,6 @@ export class JavaScriptSymbolicBindings {
}
},
});
for (const writes of this.#writes.values())
writes.sort(
(left, right) =>
(left.site.end ?? Infinity) - (right.site.end ?? Infinity) ||
(right.site.start ?? 0) - (left.site.start ?? 0),
);
for (const table of this.#bootstrapTables)
this.#invalidateBootstrapTable(table.tableAliases, table.modules);
}
@@ -405,19 +396,19 @@ export class JavaScriptSymbolicBindings {
this.unresolved = true;
return symbolicUnknown("module export read boundary is unavailable");
}
const write = available.findLast(
(write) =>
write.path.length === 0 ||
(write.path.length === 1 && write.path[0] === key),
const replacement = available.findLast(
(write) => write.path.length === 0,
);
if (write?.receiverUncertain)
return symbolicUnknown("uncertain export receiver");
if (write?.path.length === 0)
if (replacement)
return this.property(
this.evaluate(write.value, value.environment),
this.evaluate(replacement.value, value.environment),
key,
read,
);
const writes = available.filter(
(w) => w.path.length === 1 && w.path[0] === key,
);
const write = writes.at(-1);
return write
? this.evaluate(write.value, value.environment)
: symbolicUnknown("unresolved module export");
@@ -518,23 +509,19 @@ export class JavaScriptSymbolicBindings {
if (value.kind === "namespace") {
const available = this.#namespaceWrites(value);
if (available === undefined) return [];
const replacementIndex = available.findLastIndex(
const replacement = available.findLast(
(write) => write.path.length === 0,
);
const replacement = available[replacementIndex];
return [
...new Set([
...(replacement === undefined
? []
: this.members(
this.evaluate(replacement.value, value.environment),
active,
)),
...available
.slice(replacementIndex + 1)
.flatMap((write) => (write.path.length === 1 ? write.path : [])),
]),
];
return replacement
? this.members(
this.evaluate(replacement.value, value.environment),
active,
)
: [
...new Set(
available.flatMap((w) => (w.path.length === 1 ? w.path : [])),
),
];
}
if (value.kind !== "object") return [];
return [
@@ -576,7 +563,6 @@ export class JavaScriptSymbolicBindings {
if (this.#mutated.has(value.bindingId)) return 1;
const available = this.#namespaceWrites(value);
if (available === undefined) return 1;
if (available.some((write) => write.receiverUncertain)) return 1;
const replacement = available.findLast(
(write) => write.path.length === 0,
);
@@ -1057,9 +1043,7 @@ export class JavaScriptSymbolicBindings {
if (boundary !== undefined && sourcePoint == null) return undefined;
if (completedAt !== undefined && completedAt.end == null) return undefined;
if (sourcePoint == null && completedAt === undefined)
return read === undefined
? this.#publicNamespaceWrites(writes)
: undefined;
return read === undefined ? writes : undefined;
// An owned snapshot cannot read beyond either its capture or this exit.
const point = Math.min(
sourcePoint ?? Infinity,
@@ -1085,84 +1069,17 @@ export class JavaScriptSymbolicBindings {
}
}
}
return this.#publicNamespaceWrites(
writes.filter(
(write) =>
write.site.end != null &&
write.site.end <= point &&
(changed.size === 0 ||
(write.path.length > 0
? !changed.has(write.path[0]!)
: selectedKey !== undefined && !changed.has(selectedKey))),
),
return writes.filter(
(write) =>
write.site.end != null &&
write.site.end <= point &&
(changed.size === 0 ||
(write.path.length > 0
? !changed.has(write.path[0]!)
: selectedKey !== undefined && !changed.has(selectedKey))),
);
}
/** Keep publication separate from writes through a detached or rebound alias. */
#publicNamespaceWrites(
writes: readonly PropertyWrite[],
): readonly PropertyWrite[] {
let relation: "connected" | "detached" | "uncertain" = "connected";
const published: PropertyWrite[] = [];
for (const write of writes) {
if (write.path.length === 0 && write.exportReceiver === "exports") {
if (write.exportValueReceiver === "module") relation = "connected";
else if (write.exportValueReceiver !== "exports")
relation = "uncertain";
continue;
}
if (write.path.length === 0 && write.exportReceiver === "module") {
if (
write.exportValueReceiver === "module" ||
(write.exportValueReceiver === "exports" && relation === "connected")
)
continue;
relation =
write.exportValueReceiver === "exports"
? "connected"
: relation === "uncertain"
? "uncertain"
: "detached";
}
if (write.exportReceiver === "exports") {
if (relation === "detached") continue;
if (relation === "uncertain") {
published.push({ ...write, receiverUncertain: true });
continue;
}
}
published.push(write);
}
return published;
}
#exportValueReceiver(
input: t.Node,
alias: string,
): "module" | "exports" | undefined {
const expression = unwrapJavaScriptExpression(input).node;
const node = t.isAssignmentExpression(expression, { operator: "=" })
? expression.left
: expression;
const root = t.isMemberExpression(node) ? node.object : node;
if (!t.isIdentifier(root)) return undefined;
const binding = resolveSemanticBindingState(this.state, root, root.name);
if (
binding === undefined ||
this.#exportAliases.get(binding.bindingId) !== alias
)
return undefined;
if (t.isIdentifier(node))
return this.#moduleBindings.has(binding.bindingId)
? undefined
: "exports";
return t.isMemberExpression(node) &&
this.#moduleBindings.has(binding.bindingId) &&
semanticStaticPropertyKey(node.property, node.computed) === "exports"
? "module"
: undefined;
}
#completeNamespace(
value: SymbolicNamespace,
point: t.Node,
@@ -1631,14 +1548,6 @@ export class JavaScriptSymbolicBindings {
const binding = resolveSemanticBindingState(this.state, root, root.name);
if (!binding) return;
const alias = this.#exportAliases.get(binding.bindingId);
if (
alias &&
this.#moduleBindings.has(binding.bindingId) &&
path.length === 0
) {
this.#mutated.add(alias);
return;
}
if (this.#moduleBindings.has(binding.bindingId) && path[0] !== "exports")
return;
const key = alias ?? binding.bindingId;
@@ -1668,25 +1577,7 @@ export class JavaScriptSymbolicBindings {
}
}
const writes = this.#writes.get(key) ?? [];
const exportValueReceiver =
alias !== undefined &&
selected.length === 0 &&
t.isAssignmentExpression(value, { operator: "=" })
? this.#exportValueReceiver(value.right, alias)
: undefined;
writes.push({
path: selected,
value,
site,
...(alias === undefined
? {}
: {
exportReceiver: this.#moduleBindings.has(binding.bindingId)
? ("module" as const)
: ("exports" as const),
}),
...(exportValueReceiver === undefined ? {} : { exportValueReceiver }),
});
writes.push({ path: selected, value, site });
this.#writes.set(key, writes);
}
}
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
analyzeJavaScriptStaticSource,
analyzeParsedJavaScriptStaticSource,
} from "./javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
describe("Vite preload dependency recovery", () => {
it("decodes helper table values and preserves sparse and dynamic slots", () => {
@@ -147,84 +143,3 @@ describe("Vite preload dependency recovery", () => {
]);
});
});
it.each([
{
name: "canonical declaration",
prefix: "",
suffix: "__vite__mapDeps([0]);",
specifiers: ["./canonical.js"],
},
{
name: "captured outer binding",
prefix: "",
suffix: "function load(){return __vite__mapDeps([0]);}load();",
specifiers: ["./canonical.js"],
},
{
name: "shadowed parameter",
prefix: "",
suffix:
"function load(__vite__mapDeps){return __vite__mapDeps([0]);}load(()=>[]);",
specifiers: [],
},
{
name: "out-of-scope declaration",
prefix: "function setup(){",
suffix: "}__vite__mapDeps([0]);",
specifiers: [],
},
{
name: "unrelated shadowed cache mutation",
prefix: "",
suffix:
"function change(__vite__mapDeps){__vite__mapDeps.f=['./other.js'];}__vite__mapDeps([0]);",
specifiers: ["./canonical.js"],
},
{
name: "uncertain with receiver",
prefix: "",
suffix: "with(unknown){__vite__mapDeps([0]);}",
specifiers: [null],
},
{
name: "uncertain with mutation",
prefix: "",
suffix:
"with(unknown){__vite__mapDeps.f=['./other.js'];}__vite__mapDeps([0]);",
specifiers: [null],
},
])(
"uses the canonical helper's lexical identity for $name",
({ prefix, suffix, specifiers }) => {
const source = `${prefix}
const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies =
(map.f || (map.f = ["./canonical.js"]))) => indexes.map((index) => dependencies[index]);
${suffix}
`;
const parsed = parseJavaScriptSource(source, "preload.cjs");
expect(parsed).not.toBeNull();
if (parsed === null) throw new Error("preload fixture did not parse");
const analysis = analyzeParsedJavaScriptStaticSource(source, parsed);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references
.filter(({ kind }) => kind === "dynamic-import")
.map(({ specifier }) => specifier),
).toEqual(specifiers);
},
);
it("retains the identity of a function-declared preload helper", () => {
const analysis = analyzeJavaScriptStaticSource(`
function __vite__mapDeps(indexes, map = __vite__mapDeps, dependencies =
(map.f || (map.f = ["./canonical.js"]))) { return indexes.map((index) => dependencies[index]); }
__vite__mapDeps([0]);
`);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references
.filter(({ kind }) => kind === "dynamic-import")
.map(({ specifier }) => specifier),
).toEqual(["./canonical.js"]);
});
@@ -923,27 +923,12 @@ it("traces all endpoint carriers and their response consumers through CLI and MC
"response",
]);
const response = carriers.find(({ kind }) => kind === "response");
const request = carriers.find(({ kind }) => kind === "request");
if (response === undefined || request === undefined)
throw new Error("Expected matching request and response");
if (response === undefined) throw new Error("Expected matching response");
const consumer = graph.relations.find(
({ source_node_id, relation }) =>
source_node_id === response.node_id && relation === "consumed-by",
);
const requestLink = graph.relations.find(
({ source_node_id, target_node_id, relation }) =>
source_node_id === request.node_id &&
target_node_id === response.node_id &&
relation === "consumed-by",
);
expect(consumer).toMatchObject({
resolution: "resolved",
properties: { method: "json" },
});
expect(requestLink).toMatchObject({
resolution: "resolved",
properties: { method: "json" },
});
expect(consumer).toMatchObject({ resolution: "resolved" });
const client = await connectedMcpClient();
const trace = async (seed: JavaScriptSemanticQueryInput["seed"]) => {
const input = {
@@ -995,13 +980,13 @@ it("traces all endpoint carriers and their response consumers through CLI and MC
"request",
"response",
]);
expect(selected.relations).toEqual([requestLink, consumer]);
expect(selected.relations).toEqual([consumer]);
expect(selected).toMatchObject({
status: "ambiguous",
summary: {
total_seed_matches: 2,
traversed_nodes: 3,
traversed_relations: 2,
traversed_relations: 1,
},
coverage: { status: "partial" },
});