diff --git a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts index 9e597bc565..97f97c6d14 100644 --- a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts @@ -10,6 +10,7 @@ import { addSemanticGraphUnknown, } from "./JavaScriptSemanticGraphConstruction.js"; import { + inferredSemanticEvidenceAt, unknownSemanticEvidence, builtinSemanticNode, builtinSemanticRelation, @@ -39,6 +40,7 @@ export const projectSemanticEvents = ( source: eventEmitterNode(context, operation), target: event, relation, + evidence: inferredSemanticEvidenceAt(context.file, operation.location), resolution: operation.resolution === "complete" ? "resolved" : "candidate", }); @@ -48,6 +50,7 @@ export const projectSemanticEvents = ( source: event, target: listener, relation, + evidence: inferredSemanticEvidenceAt(context.file, operation.location), resolution: operation.resolution === "complete" ? "resolved" : "candidate", }); diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index ea29bb7cbf..c1e4ade32c 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -480,10 +480,8 @@ export const createJavaScriptSemanticGraphProjection = ( limit, ); } - state.fileNodeBudget = Math.min( - fileNodeCeiling, - Math.max(0, nodeCeiling - state.nodes.size), - ); + const remainingTreeBudget = Math.max(0, nodeCeiling - state.nodes.size); + state.fileNodeBudget = Math.min(fileNodeCeiling, remainingTreeBudget); const module = retainNode(state, file, { kind: "module", roleKey: "module", @@ -493,6 +491,14 @@ 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, diff --git a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts index 69fe3cdf52..796ca7a839 100644 --- a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts @@ -10,6 +10,7 @@ import { addSemanticGraphUnknown, } from "./JavaScriptSemanticGraphConstruction.js"; import { + inferredSemanticEvidenceAt, builtinSemanticNode, builtinSemanticRelation, unknownSemanticEvidence, @@ -135,7 +136,7 @@ export const projectSemanticRequests = ( if (request === undefined) continue; if (operation.kind === "request") projectRequestConstruction(context, operation, request); - else projectResponseConsumer(context, operation, request); + else projectResponseConsumer(context, operation, request, requestNodes); if (operation.resolution !== "complete") addDataUnknown({ context, @@ -224,16 +225,31 @@ const projectResponseConsumer = ( context: SemanticFlowProjectionContext, operation: JavaScriptSemanticRequestOperation, response: JavaScriptSemanticGraphNode, + requestNodes: ReadonlyMap, ): 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", - ...builtinSemanticRelation( - context.file, - operation, - operation.resolution === "complete" ? "resolved" : "candidate", - ), + ...authority, + evidence, properties: { method: operation.method }, }); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts index d8cdb54ec9..7f414fb7bc 100644 --- a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts @@ -14,7 +14,10 @@ import { addSemanticGraphRelation, addSemanticGraphUnknown, } from "./JavaScriptSemanticGraphConstruction.js"; -import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import { + inferredSemanticEvidenceAt, + unknownSemanticEvidence, +} from "./JavaScriptSemanticGraphEvidence.js"; import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.js"; @@ -47,6 +50,7 @@ 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); diff --git a/src/application/javascript/JavaScriptSemanticUnknownCapacity.test.ts b/src/application/javascript/JavaScriptSemanticUnknownCapacity.test.ts index 6a95a43dfb..e74c6f5e43 100644 --- a/src/application/javascript/JavaScriptSemanticUnknownCapacity.test.ts +++ b/src/application/javascript/JavaScriptSemanticUnknownCapacity.test.ts @@ -340,3 +340,37 @@ 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([]); + }, +); diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 9d6a47ee3f..deffeb4731 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -735,6 +735,32 @@ 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 = { diff --git a/src/domain/javascript/javascriptBootstrapIntegrity.ts b/src/domain/javascript/javascriptBootstrapIntegrity.ts index 7870abe4c3..8dc5737529 100644 --- a/src/domain/javascript/javascriptBootstrapIntegrity.ts +++ b/src/domain/javascript/javascriptBootstrapIntegrity.ts @@ -1,7 +1,11 @@ import * as t from "@babel/types"; import type { javascriptBootstrapAt } from "./javascriptBootstrap.js"; -import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { + semanticStaticPropertyKey, + semanticWriteSiteTarget, + semanticWriteTargets, +} from "./javascriptAstValues.js"; import { semanticIdentifierRole } from "./javascriptSemanticProjection.js"; import { resolveSemanticBindingState, @@ -285,8 +289,13 @@ const moduleObjectContained = ( state: JavaScriptSemanticAnalysisState, ): boolean => { let contained = true; + const writtenTargets = new Set(); 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) @@ -294,7 +303,7 @@ const moduleObjectContained = ( if (bindingOf(state, node) !== moduleId) return; contained = !ancestors.some((ancestor) => t.isFunction(ancestor)) && - moduleUse(node, [parent, ancestors.at(-2)], dispatch); + moduleUse(node, parent, dispatch, writtenTargets.has(parent)); }, }); return contained; @@ -302,21 +311,18 @@ const moduleObjectContained = ( const moduleUse = ( node: t.Identifier, - [parent, grandparent]: readonly [t.Node, t.Node | undefined], + parent: t.Node, 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 ( - grandparent === dispatch || + dispatch.arguments.some((argument) => argument === parent) || (parent.start ?? -1) > (dispatch.end ?? Number.POSITIVE_INFINITY) ); }; diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts index ca6a1cbbd2..5044197dcc 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -10,9 +10,54 @@ import { completeSemanticSteps, traverseJavaScriptAst, } from "./javascriptSemanticTraversal.js"; -import { createGlobalReferenceReader } from "./javascriptSemanticAmbientWrites.js"; +import { + createGlobalPathReader, + 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( ` @@ -306,6 +351,11 @@ 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);", diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 0d35023b63..e8c5e87b56 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -902,37 +902,92 @@ export const createGlobalReferenceReader = ( state: JavaScriptSemanticAnalysisState, path: readonly [string, ...string[]], ): ((callee: t.Node) => GlobalReferenceRead) => { - const selected = ["global", ...path]; - let generation = -1; - const byNamespace = new Map< - string | null, - typeof state.ambientWriteEffects - >(); + const read = createGlobalPathRead(state, path); return (callee) => { const namespace = globalReferenceNamespace(callee, state, path); - if (namespace === undefined) return { status: "unrelated" }; + 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; + const byNamespace = new Map< + string | null, + { + writes: typeof state.ambientWriteEffects; + escapes: typeof state.ambientEscapeEffects; + } + >(); + 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" }; if ( - !state.ambientWrites.covers(selected) && - (namespace === null || !state.ambientWrites.covers(["global", namespace])) - ) - return { status: "unmodified" }; - if (generation !== state.ambientWriteEffects.length) { - generation = state.ambientWriteEffects.length; + writesGeneration !== state.ambientWriteEffects.length || + escapesGeneration !== state.ambientEscapeEffects.length + ) { + writesGeneration = state.ambientWriteEffects.length; + escapesGeneration = state.ambientEscapeEffects.length; byNamespace.clear(); } let effects = byNamespace.get(namespace); if (effects === undefined) { - effects = state.ambientWriteEffects.filter( - (effect) => - ambientEffectCovers(effect, selected) || - (namespace !== null && - ambientEffectCovers(effect, ["global", namespace])), - ); + 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), + }; byNamespace.set(namespace, effects); } - if (effects.length === 0) return { status: "unknown" }; - const active = effects.filter( - (effect) => semanticEffectTiming(callee, effect.node, state) !== "after", + // 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 (active.length === 0) return { status: "unmodified" }; if ( @@ -943,47 +998,57 @@ export const createGlobalReferenceReader = ( ) ) return { status: "unknown" }; - 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 }; + return globalPathReplacement(state, path, site, active); }; }; + +/** 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 }; +}; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.initialization_boundaries.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.initialization_boundaries.test.ts index 767ad1b740..5debf20bbf 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.initialization_boundaries.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.initialization_boundaries.test.ts @@ -249,3 +249,20 @@ 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"); + }, +); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts index f1142d9227..d3b3f9c136 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; +import { runInNewContext } from "node:vm"; import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; -import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; +import { + onlyBinding, + onlyCallable, +} from "./javascriptSemanticAnalysis.fixture.js"; const resultValue = (body: string) => onlyCallable( @@ -9,6 +13,128 @@ 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()"], [ @@ -41,6 +167,83 @@ 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) => { diff --git a/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts index 6083e2bfe7..8ae9dfa622 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts @@ -269,3 +269,18 @@ 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 }), + ]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 3e217f94d0..8897a08e46 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -28,6 +28,13 @@ 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`)'], diff --git a/src/domain/javascript/javascriptSemanticAnalysis.resource_handles.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.resource_handles.test.ts index 6cb01c4a4e..0b97c184a5 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.resource_handles.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.resource_handles.test.ts @@ -22,6 +22,54 @@ 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", () => { @@ -198,3 +246,17 @@ 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 }]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.socket_port_literal.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.socket_port_literal.test.ts index a2b1d004ec..66c89c73cf 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.socket_port_literal.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.socket_port_literal.test.ts @@ -16,7 +16,8 @@ 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) / {} / [] -> THREW ERR_MISSING_ARGS (undecided here) + * net.connect() / undefined / void 0 -> THREW ERR_MISSING_ARGS + * net.connect({}) / [] -> THREW ERR_MISSING_ARGS (undecided here) */ const socketAcquisitionsFor = (call: string) => analyzeJavaScriptSemantics( @@ -34,6 +35,10 @@ 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)`], @@ -49,8 +54,12 @@ 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"], - ["an omitted argument", `net.connect()`, "connect"], - ["an explicit undefined port", `net.connect(undefined)`, "connect"], + ["a computed argument", `net.connect(p)`, "connect"], + [ + "a TLS port supplied by later options", + `tls.connect(void 0, { port: 443 })`, + "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) => { diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 9dc48f6313..4e5802e8c4 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -16,7 +16,6 @@ import { resolveSemanticBindingState, semanticResolutionBlocked, type JavaScriptSemanticAnalysisState, - type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; @@ -160,7 +159,10 @@ export const collectJavaScriptSemanticAsyncEffects = ( * * A class expression passes Node's own `typeof` check, so it is kept. */ -const rejectsEventListener = (listener: t.Node): boolean => { +export const rejectsSemanticEventListener = ( + listener: t.Node | undefined, +): boolean => { + if (listener === undefined) return true; if (t.isSpreadElement(listener)) return false; const value = unwrapJavaScriptExpression(listener).node; if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value)) @@ -204,9 +206,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) && - listenerArgument !== undefined && - rejectsEventListener(listenerArgument) + rejectsSemanticEventListener(listenerArgument) ) return; const emitter = emitterIdentity(member.object, state); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.test.ts b/src/domain/javascript/javascriptSemanticChildProcesses.test.ts index 6e0c64aecd..2f1ef4acf7 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.test.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.test.ts @@ -7,6 +7,25 @@ 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'); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 4a944c4e38..38d0f04bc2 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -7,6 +7,7 @@ import type { JavaScriptSemanticChildProcessSpawn, } from "./javascriptSemanticIr.js"; import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; +import { rejectsSemanticEventListener } from "./javascriptSemanticAsyncEffects.js"; import { createSemanticExpressionShapeResolver, optionsAfterOptionalArgv, @@ -225,6 +226,11 @@ 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, diff --git a/src/domain/javascript/javascriptSemanticIterationSources.ts b/src/domain/javascript/javascriptSemanticIterationSources.ts index 0c7bc77dc4..a69e440f55 100644 --- a/src/domain/javascript/javascriptSemanticIterationSources.ts +++ b/src/domain/javascript/javascriptSemanticIterationSources.ts @@ -30,6 +30,8 @@ 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 { @@ -337,6 +339,7 @@ const semanticSourceFacts = ( t.isAssignmentExpression(node) || t.isForOfStatement(node) || t.isSpreadElement(node) || + t.isAwaitExpression(node) || t.isYieldExpression(node) || t.isCatchClause(node) || t.isFunction(node) @@ -372,11 +375,15 @@ const semanticSourceFacts = ( mutation: node, lookup: "own", }); - } else if ( - t.isYieldExpression(node) && - node.delegate && - node.argument != null - ) + } 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) add(sources, { node: node.argument, projection: [] }, node, {}); else if ( t.isCatchClause(node) && diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 451597cc81..b4bf18206f 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -2,6 +2,7 @@ import * as t from "@babel/types"; import { semanticKnownSymbolKey } from "./javascriptSemanticPropertyKeys.js"; import { createAmbientPathResolver, + createGlobalPathReader, retainSemanticAmbientEffect, } from "./javascriptSemanticAmbientWrites.js"; import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; @@ -37,6 +38,7 @@ import { import { semanticContainer } from "./javascriptSemanticSlots.js"; import { semanticProjectedSourceFactsAtRead, + semanticCapturedSelectionsAfterPropertyRead, semanticShiftCapturedSelections, semanticNormalizeCapturedSelections, type SemanticCapturedSelection, @@ -49,6 +51,7 @@ import { semanticClassAccessorReferences, semanticObjectPropertyReferences, semanticObjectAccessorReferences, + semanticNativePrototypeChain, type JavaScriptSemanticObjectReference, } from "./javascriptSemanticObjectReferences.js"; import { @@ -122,27 +125,55 @@ const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => { /** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */ -/** Accessor selection uses the same slots, prototype shadowing and array indices as aliases. */ -const localAccessorMethods = ( +/** Local methods use the same slots, prototype shadowing and array indices as aliases. */ +const localPropertyMethods = ( root: t.Node, path: PropertyPath, - accessor: "get" | "set", + accessor: "get" | "set" | "invoke", state: JavaScriptSemanticAnalysisState, keyMayExist: ( key: PropertyPath[number] | undefined, kind: "get" | "set", ) => boolean, lookup: "prototype" | "own" = "prototype", -): readonly (t.ObjectMethod | t.ClassMethod)[] | undefined => { - if (!keyMayExist(path.at(-1), accessor)) return []; - const methods = new Set(); - const pending = [{ node: root, path }]; +): readonly t.Function[] | undefined => { + if (accessor !== "invoke" && !keyMayExist(path.at(-1), accessor)) return []; + const methods = new Set(); + const pending: (MutationSelectionContext & { + node: t.Node; + path: PropertyPath; + })[] = [{ node: root, path, memberReadAt: root }]; const examined = new WeakMap>(); + const nativeReaders = new Map< + string, + ReturnType + >(); + 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); + const key = JSON.stringify([ + current.path, + current.capturedSelections?.map(({ at, depth, ownEnumerable }) => [ + at.start, + at.end, + depth, + ownEnumerable, + ]), + ]); const paths = examined.get(node) ?? new Set(); if (paths.has(key)) continue; paths.add(key); @@ -152,7 +183,10 @@ const localAccessorMethods = ( return undefined; } if ( - (t.isObjectMethod(node) || t.isClassMethod(node)) && + t.isFunction(node) && + (accessor === "invoke" || + t.isObjectMethod(node) || + t.isClassMethod(node)) && current.path.length === 0 ) { methods.add(node); @@ -160,15 +194,42 @@ const localAccessorMethods = ( } if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); - if (binding === undefined) continue; - const selected = semanticBindingAtRead(binding, node, state).binding; - for (const source of [ - ...selected.initializers, - ...selected.referenceInitializers, - ]) + 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) pending.push({ node: source.node, - path: [...source.projection, ...current.path], + path: source.path, + memberReadAt: current.memberReadAt, + capturedSelections: semanticNormalizeCapturedSelections([ + ...(semanticShiftCapturedSelections( + current.capturedSelections, + source.path.length - current.path.length, + ) ?? []), + ...(source.capturedSelections ?? []), + ]), }); } else if ( t.isMemberExpression(node) || @@ -180,15 +241,57 @@ const localAccessorMethods = ( 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 + : remaining.length === 0 && accessor !== "invoke" ? semanticObjectAccessorReferences( node, selected!, @@ -197,11 +300,24 @@ const localAccessorMethods = ( lookup, ) : semanticObjectPropertyReferences(node, [selected!], state); - for (const source of references) + for (const source of references) { + if ( + source.inherited && + current.capturedSelections?.some( + (capture) => capture.depth === 1 && capture.ownEnumerable, + ) + ) + continue; 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 @@ -218,23 +334,39 @@ const localAccessorMethods = ( const references = remaining.length > 0 ? semanticClassPropertyReferences(selection, [selected!], state) - : lookup === "own" - ? [] - : semanticClassAccessorReferences( - selection, - selected!, - state, - accessor, - ); + : accessor === "invoke" + ? semanticClassPropertyReferences(selection, [selected!], state) + : 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 { - for (const source of branchReferenceValues(node, current.path)) - pending.push(source); + 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 }); } } return [...methods]; @@ -247,6 +379,7 @@ type AccessorEffectOperand = readonly node: t.Node; readonly path: PropertyPath; readonly lookup: "prototype" | "own"; + readonly invoke?: true; readonly guards?: SemanticPropertyReadSource; } | { @@ -333,6 +466,7 @@ const accessorEffectOperands = ( node: source.node, path: source.projection, lookup: source.lookup, + ...(source.invoke ? { invoke: true } : {}), guards: source, }); if ( @@ -383,13 +517,14 @@ export function* collectSemanticMemberMutationsSteps( ); }; const returnedReferences = createSemanticReturnedReferences(state); - const getterEffects = ( + const propertyReadEffects = ( root: t.Node, path: PropertyPath, lookup: "prototype" | "own" = "prototype", + invoke = false, ): readonly JavaScriptSemanticObjectReference[] => { - if (!accessorKeyMayExist(path.at(-1), "get")) return []; - const pending = [{ node: root, path, lookup }]; + if (!invoke && !accessorKeyMayExist(path.at(-1), "get")) return []; + const pending = [{ node: root, path, lookup, invoke }]; const examined = new Set(); const effects: JavaScriptSemanticObjectReference[] = []; for (let cursor = 0; cursor < pending.length; cursor++) { @@ -397,12 +532,14 @@ 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); @@ -410,7 +547,20 @@ export function* collectSemanticMemberMutationsSteps( state.valueResourceLimits.add("alias-selections"); return [{ node: root, path: [] }]; } - const methods = localAccessorMethods( + 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( selectedRoot, selected, "get", @@ -418,12 +568,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(); for (let index = 0; index < callables.length; index++) { @@ -441,7 +591,8 @@ export function* collectSemanticMemberMutationsSteps( } if ( operand.kind === "read" && - (!accessorKeyMayExist(operand.path.at(-1), "get") || + ((!operand.invoke && + !accessorKeyMayExist(operand.path.at(-1), "get")) || (operand.guards !== undefined && semanticReferenceSourcesInactive(operand.guards, state))) ) @@ -465,6 +616,7 @@ export function* collectSemanticMemberMutationsSteps( node: targetRoot, path: [...target, ...operand.path], lookup: operand.lookup, + invoke: operand.invoke === true, }); } } @@ -1114,7 +1266,7 @@ export function* collectSemanticMemberMutationsSteps( if ( origins.initializers.some( (source) => - (localAccessorMethods( + (localPropertyMethods( source.node, [...source.projection, ...selectedPath], "set", @@ -1596,7 +1748,7 @@ export function* collectSemanticMemberMutationsSteps( source.kind === "reference-store" && source.target !== undefined && !state.escapingReferenceStores.has(source.target) && - (localAccessorMethods( + (localPropertyMethods( source.target.object, [ semanticStaticPropertyKey( @@ -1658,17 +1810,18 @@ export function* collectSemanticMemberMutationsSteps( } for (const source of semanticPropertyReadSources(node, parent, state)) { if (semanticReferenceSourcesInactive(source, state)) continue; - for (const effect of getterEffects( + for (const effect of propertyReadEffects( 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 getterEffects(node.object, [ + for (const effect of propertyReadEffects(node.object, [ semanticStaticPropertyKey(node.property, node.computed), ])) markEscaped(effect.node, node, effect.path); diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.test.ts b/src/domain/javascript/javascriptSemanticObjectReferences.test.ts index 2f6633fa4d..148608ca2d 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.test.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.test.ts @@ -4,6 +4,7 @@ import { expect, it } from "vitest"; import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js"; import { semanticClassPropertyReferences, + semanticNativePrototypeChain, semanticObjectPropertyReferences, } from "./javascriptSemanticObjectReferences.js"; import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; @@ -36,6 +37,37 @@ 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 };`, @@ -54,14 +86,13 @@ it("resolves exact keys with source-order overrides, spreads and accessors", () ["4", "[]"], ["base", '["missing"]'], ]); - // A wildcard key still scans every property. + // A wildcard keeps possible slots, excluding data replaced by a known spread. 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. @@ -70,6 +101,112 @@ 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 }, diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 7cf109bf47..9012dc8ef4 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -154,96 +154,73 @@ const classPropertyDescriptors = ( initializedBefore?: t.ClassProperty, accessor: "get" | "set" = "get", ) => { - const isStatic = kind === "static"; - const selectedAccessors: t.ClassMethod[] = []; const fields: t.ClassProperty[] = []; - const indexedMembers: { - readonly member: t.ClassMethod | t.ClassProperty; - readonly index: number; - readonly key: string | null; - }[] = []; + const methods: t.ClassMethod[] = []; + const accessors: t.ClassMethod[] = []; + const definitions: (t.ClassMethod | t.ClassProperty)[] = []; const before = initializedBefore === undefined ? -1 : klass.body.body.indexOf(initializedBefore); - klass.body.body.forEach((member, index) => { + for (const [index, member] of klass.body.body.entries()) { if ( - (t.isClassMethod(member) || t.isClassProperty(member)) && - propertyName !== null && - member.computed && - semanticKnownSymbolKey(member.key, state) + (!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)) ) - return; - if ( - 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(); - 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); + continue; + definitions.push(member); } - if (getters.length > 0 && !laterDataMember && getter !== undefined) - selectedAccessors.push(getter.member); - for (const { member, index } of uncertainAccessors) + // 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(); + const getterNames = new Set(); + const setterNames = new Set(); + 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; 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, - }; + 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); + } + return { accessors, fields, methods, inherits }; }; export const semanticClassPropertyReferences = ( @@ -345,9 +322,21 @@ class ClassReferenceWalk { ? descriptors.fields : []) if (field.value != null) - this.#references.push({ node: field.value, path: this.#path }); + this.#references.push({ + node: field.value, + path: this.#path, + ...(this.lookup.kind === "static" && klass !== this.lookup.owner + ? { inherited: true } + : {}), + }); 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)) @@ -559,6 +548,18 @@ 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. */ @@ -579,13 +580,7 @@ const objectPropertyIndex = (node: t.ObjectExpression): ObjectPropertyIndex => { const name = t.isSpreadElement(property) ? null : semanticStaticPropertyKey(property.key, property.computed); - if ( - name === null || - (t.isObjectProperty(property) && - !property.computed && - !property.shorthand && - name === "__proto__") - ) { + if (name === null || isObjectPrototypeSetter(property, name)) { keyIndependent.push(index); return; } @@ -671,7 +666,9 @@ class ObjectReferenceWalk { readonly accessor?: "get" | "set", readonly accessorLookup: "prototype" | "own" = "prototype", ) { - [this.#key, ...this.#remaining] = path; + const [key, ...remaining] = path; + this.#key = typeof key === "number" ? String(key) : key; + this.#remaining = remaining; } #spreadKey(): PropertyPath[number] { @@ -691,38 +688,29 @@ 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) + if ( + this.accessor === undefined && + (typeof selected === "object" || !this.#overwritten.has(String(selected))) + ) this.#references.push({ node: property.argument, path: [this.#spreadKey(), ...this.#remaining], capturedSelectionAt: property.argument, ownEnumerable: true, }); - 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)); - } + if (t.isObjectExpression(property.argument)) + for (const name of objectExpressionDefinedKeys(property.argument)) { + this.#overwritten.add(name); + this.#data.add(name); + } } #prototype( property: t.ObjectMethod | t.ObjectProperty, name: string | null, ): boolean { - if ( - !t.isObjectProperty(property) || - property.computed || - property.shorthand || - name !== "__proto__" - ) - return false; + if (!isObjectPrototypeSetter(property, name)) return false; const selected = this.#key ?? null; if (this.accessor !== undefined && this.accessorLookup === "own") return true; @@ -731,7 +719,7 @@ class ObjectReferenceWalk { (selected === null || typeof selected === "object" || (typeof selected === "string" && - !objectExpressionDefinesKey(this.node, selected))) + !objectExpressionDefinedKeys(this.node).has(selected))) ) this.#references.push({ node: property.value, @@ -819,8 +807,10 @@ 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); } @@ -839,12 +829,12 @@ const objectPropertyReferences = ( ): readonly JavaScriptSemanticObjectReference[] => new ObjectReferenceWalk(node, path, query).run(); -const objectExpressionDefinesKey = ( +const objectExpressionDefinedKeys = ( root: t.ObjectExpression, - key: string, -): boolean => { +): ReadonlySet => { const pending: t.ObjectExpression[] = [root]; const seen = new Set(); + const names = new Set(); for (let cursor = 0; cursor < pending.length; cursor++) { const object = pending[cursor]; if (object === undefined || seen.has(object)) continue; @@ -856,19 +846,11 @@ const objectExpressionDefinesKey = ( continue; } const name = semanticStaticPropertyKey(property.key, property.computed); - if ( - name === key && - !( - t.isObjectProperty(property) && - !property.computed && - !property.shorthand && - name === "__proto__" - ) - ) - return true; + if (name !== null && !isObjectPrototypeSetter(property, name)) + names.add(name); } } - return false; + return names; }; export const semanticArrayPropertyReferences = ( @@ -924,3 +906,44 @@ 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"]; +}; diff --git a/src/domain/javascript/javascriptSemanticProjectedSources.ts b/src/domain/javascript/javascriptSemanticProjectedSources.ts index 2f0a6f5458..b2cf9c7db9 100644 --- a/src/domain/javascript/javascriptSemanticProjectedSources.ts +++ b/src/domain/javascript/javascriptSemanticProjectedSources.ts @@ -190,6 +190,7 @@ export function semanticProjectedSourceFactsAtRead( memberReadAt?: t.Node, capturedSelections?: readonly SemanticCapturedSelection[], retainEligibility?: boolean, + valueEscapesHideSources?: boolean, ): | { readonly sources: readonly (Omit & { @@ -219,6 +220,9 @@ 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[]; @@ -365,6 +369,7 @@ const projectedSourceFactsAtRead = ( (replacement.assignedSources?.[0]?.target?.start ?? -1))); if ( includeUnknown && + valueEscapesHideSources && semanticSomeEffectPathCandidate( binding.escapedPaths, path, diff --git a/src/domain/javascript/javascriptSemanticRequireCaptures.test.ts b/src/domain/javascript/javascriptSemanticRequireCaptures.test.ts index ec7e04bc26..0edc713ada 100644 --- a/src/domain/javascript/javascriptSemanticRequireCaptures.test.ts +++ b/src/domain/javascript/javascriptSemanticRequireCaptures.test.ts @@ -2,6 +2,40 @@ 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], [ diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 94d95f004b..104b7ca1fb 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -30,6 +30,8 @@ import { import { semanticStaticPropertyKey, semanticMemberChain, + semanticWriteSiteTarget, + semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js"; @@ -71,30 +73,33 @@ export type { const possibleCommonJsDelegateWriteStates = new WeakSet(); +/** Admission and indexing share the same direct, projected and iteration leaves. */ +function* commonJsDelegateWriteTargets( + node: t.Node, +): Generator { + 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 ( - (t.isMemberExpression(target) || t.isOptionalMemberExpression(target)) && - ["require", null].includes( - semanticStaticPropertyKey(target.property, target.computed), - ) - ) + if (!commonJsDelegateWriteTargets(node).next().done) possibleCommonJsDelegateWriteStates.add(state); else if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) possibleCommonJsDelegateWriteStates.add(state); @@ -886,6 +891,9 @@ 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(); const invocation = interop.intrinsicInvocation(node, (callee) => { const loader = requireLoaderValueAt( callee, @@ -912,22 +920,21 @@ export const semanticRequireCallAt = ( state, factoryBindingIds, ); - if (selected !== undefined) - return [ + if (selected !== undefined) { + const path = [ "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 selected = selectedRequireSourcesAt( - node.callee, - node, - state, - factoryBindingIds, - ); + const sourceRelativePath = sourceRelativePaths.get(invocation.path); return { operation: "load", loader: @@ -942,9 +949,9 @@ export const semanticRequireCallAt = ( : {}), sourceRelative: invocation.kind === "known" && invocation.path[2] === "relative", - ...(selected?.sourceRelativePath === undefined - ? {} - : { sourceRelativePath: selected.sourceRelativePath }), + ...(invocation.kind === "known" && sourceRelativePath !== undefined + ? { sourceRelativePath } + : {}), }; } } @@ -1556,20 +1563,8 @@ const writesModuleRequire = ( }; traverseJavaScriptValueAst(state.program, state.erasedSyntaxNodes, { enter: (node) => { - 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); - } + for (const target of commonJsDelegateWriteTargets(node)) + record(node, target.object); if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { const invocation = interop.intrinsicInvocation(node); const owner = @@ -1583,42 +1578,21 @@ 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 ( - !t.isObjectExpression(descriptors) || - descriptors.properties.some( - (property) => - t.isSpreadElement(property) || - (t.isObjectProperty(property) && - semanticStaticPropertyKey(property.key, property.computed) === - "require"), - ) - ) + if (objectMayProvideRequire(descriptors)) record(node, args?.[0] ?? t.nullLiteral()); } else if (owner === "Object" && method === "assign") { const target = args?.[0]; - 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); - } + if ( + target !== undefined && + args?.slice(1).some(objectMayProvideRequire) + ) + record(node, target); } } }, @@ -1627,6 +1601,15 @@ 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, diff --git a/src/domain/javascript/javascriptSemanticResources.ts b/src/domain/javascript/javascriptSemanticResources.ts index 322b54e96c..635ade30db 100644 --- a/src/domain/javascript/javascriptSemanticResources.ts +++ b/src/domain/javascript/javascriptSemanticResources.ts @@ -78,7 +78,11 @@ export const collectJavaScriptSemanticResources = ( const acquisitions = collectAcquisitions(program, context).filter( (candidate) => !socketPortInFirstArgument(candidate) || - (!rejectsSocketPortLiteral(candidate.node, context) && + (!rejectsSocketLiteralArguments( + candidate.node, + candidate.specifier, + context, + ) && !rejectsTlsProtocolVersionLiteral( candidate.node, candidate.specifier, @@ -447,6 +451,9 @@ const collectReleases = ( resolution: !opaqueReceiver && linked.length === 1 && + (binding.kind !== "parameter" || + semanticBindingAtRead(binding, member.object, context.state) + .hasDefiniteInitializer) && holdsOnlyAcquisitions(binding, linked, context.state) ? "complete" : "partial", @@ -573,16 +580,25 @@ const resourceCandidateId = (candidate: ResourceCandidate): string => * readable non-port literal is decided. `""` is a path with no length, which * Node also rejects. * - * `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. + * `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. */ -const rejectsSocketPortLiteral = ( +const rejectsSocketLiteralArguments = ( node: t.CallExpression | t.OptionalCallExpression, + specifier: string, context: DataEffectTraversalContext, ): boolean => { const port = node.arguments[0]; - if (port === undefined || t.isSpreadElement(port)) return false; + if (port === undefined) return specifier === "net"; + if (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) || @@ -601,13 +617,14 @@ const rejectsSocketPortLiteral = ( isUnmodifiedGlobalReference(value, context.state, value.name) ) return true; - // `-1` and `-1.5` are unary expressions over numeric literals: always out of - // range because a port is never negative. + // 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. if (t.isUnaryExpression(value)) { - const operand = unwrapJavaScriptExpression(value.argument).node; + if (value.operator === "!" || value.operator === "delete") return true; + const number = unaryOperandNumber(value, context, 0); return ( - t.isNumericLiteral(operand) && - (value.operator === "-" || !Number.isInteger(operand.value)) + number !== undefined && + (!Number.isInteger(number) || number < 0 || number > 65535) ); } return false; diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.projection.test.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.projection.test.ts index 28957b55af..a82a2423a7 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferences.projection.test.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.projection.test.ts @@ -205,3 +205,21 @@ 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"]); +}); diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.ts index 0cc8e377b9..6062bb972c 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferences.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.ts @@ -24,8 +24,13 @@ import { semanticKnownArrayIterationAt, semanticSelectedReferenceSlot, } from "./javascriptSemanticReferenceSources.js"; -import { semanticArrayPropertyReferences } from "./javascriptSemanticObjectReferences.js"; import { + semanticArrayPropertyReferences, + semanticObjectPropertyReferences, + semanticClassPropertyReferences, +} from "./javascriptSemanticObjectReferences.js"; +import { + semanticCapturedSelectionsAfterPropertyRead, semanticNormalizeCapturedSelections, semanticProjectedSourcesAtRead, semanticShiftCapturedSelections, @@ -62,6 +67,7 @@ interface Candidate { | readonly SemanticCapturedSelection[] | undefined; readonly invocation?: JavaScriptSourceInvocationContext; + readonly classKind?: "instance" | "static"; } export interface SemanticReturnedReferenceResult { @@ -74,22 +80,6 @@ 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, @@ -115,7 +105,6 @@ export const createSemanticReturnedReferences = ( }[]; } >(); - const objectTargets = new WeakMap(); return (callee, invocation) => { const existing = results.get(callee); if ( @@ -141,7 +130,7 @@ export const createSemanticReturnedReferences = ( // shared diamonds and reassignment cycles converge to the graph fixed point. const projectionDepths = new WeakMap< t.Node, - Map + Map >(); const expanded = new WeakMap>(); let additionalContexts = 0; @@ -190,6 +179,7 @@ export const createSemanticReturnedReferences = ( readAt?: t.Node, capturedSelections?: readonly SemanticCapturedSelection[], invocationContext?: JavaScriptSourceInvocationContext, + classKind?: Candidate["classKind"], ): void => { const source = unwrapJavaScriptExpression(node).node; if (contextsWidened) { @@ -213,20 +203,25 @@ export const createSemanticReturnedReferences = ( readAt?.start ?? -1, readAt?.end ?? -1, invocationKey(), + classKind, ]); const depths = projectionDepths.get(source) ?? new Map(); projectionDepths.set(source, depths); - if (!depths.has(readKey)) { - depths.set(readKey, path?.length); - } else { - const previous = depths.get(readKey); - if (previous === undefined || previous !== path?.length) { - if (previous !== undefined) result.precision = "widened"; - depths.set(readKey, undefined); - path = undefined; - } + 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 (path !== undefined) path = selections.normalize(source, path); + // 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); capturedSelections = path === undefined ? undefined @@ -279,9 +274,10 @@ export const createSemanticReturnedReferences = ( readAt?.start ?? -1, readAt?.end ?? -1, null, + classKind, ]); if (sourceGenericInvocation) result.unresolvedInvocation = true; - depths.set(readKey, undefined); + depths.set(readKey, { depth: undefined }); key = expansionKey(); if (paths.has(key)) return; } @@ -289,6 +285,7 @@ export const createSemanticReturnedReferences = ( expanded.set(source, paths); work.push({ node: source, + ...(classKind === undefined ? {} : { classKind }), path, ...(readAt === undefined ? {} : { readAt }), ...(capturedSelections === undefined ? {} : { capturedSelections }), @@ -382,6 +379,7 @@ export const createSemanticReturnedReferences = ( child.readAt, child.capturedSelections, child.invocation ?? current.invocation, + child.classKind ?? current.classKind, ); } continue; @@ -505,9 +503,38 @@ export const createSemanticReturnedReferences = ( result.unresolvedInvocation = true; } } - } else if (t.isObjectExpression(node)) - selectObjectTargets(node, current, pending, objectTargets); - else if (t.isArrayExpression(node)) { + } 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)) { const [key, ...path] = current.path ?? []; if (current.path !== undefined && key !== undefined) { for (const choice of semanticAliasSelectionChoices(key)) @@ -552,6 +579,7 @@ export const createSemanticReturnedReferences = ( child.readAt, child.capturedSelections, child.invocation ?? current.invocation, + child.classKind ?? current.classKind, ); } continue; @@ -590,63 +618,83 @@ export const createSemanticReturnedReferences = ( }); }); } else if (t.isNewExpression(node)) - pending.push({ ...current, node: node.callee }); + pending.push({ ...current, node: node.callee, classKind: "instance" }); 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)) { - 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) - ); - }))) + if ( + t.isClassMethod(member) && + (current.path === undefined || member.kind === "constructor") ) pending.push({ ...current, - node: member.value, - path: current.path === undefined ? undefined : path, - capturedSelections: semanticShiftCapturedSelections( - current.capturedSelections, - -1, - ), + node: member, + path: current.path === undefined ? undefined : [], }); + 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 }); @@ -660,6 +708,7 @@ export const createSemanticReturnedReferences = ( child.readAt, child.capturedSelections, child.invocation ?? current.invocation, + child.classKind ?? current.classKind, ); } } @@ -699,139 +748,3 @@ export const createSemanticReturnedReferences = ( return result; }; }; - -const selectObjectTargets = ( - node: t.ObjectExpression, - item: Candidate, - pending: Candidate[], - indexes: WeakMap, -): 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, -): ObjectTargetIndex => { - const existing = indexes.get(node); - if (existing !== undefined) return existing; - const propertyByName = new Map(); - 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; -}; diff --git a/src/domain/javascript/javascriptSemanticReturnedReferencesBounded.test.ts b/src/domain/javascript/javascriptSemanticReturnedReferencesBounded.test.ts index 00a3eee4e4..65ea590887 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferencesBounded.test.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferencesBounded.test.ts @@ -155,3 +155,31 @@ 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); +}); diff --git a/src/domain/javascript/javascriptSemanticReturnedReferencesCopies.test.ts b/src/domain/javascript/javascriptSemanticReturnedReferencesCopies.test.ts index f61bb7b7c3..d209dc1f12 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferencesCopies.test.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferencesCopies.test.ts @@ -81,3 +81,29 @@ 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 })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptSemanticReturnedReferencesWrites.test.ts b/src/domain/javascript/javascriptSemanticReturnedReferencesWrites.test.ts index 87e35c6dcb..f64cae8454 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferencesWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferencesWrites.test.ts @@ -201,3 +201,82 @@ 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 })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 35c8b98008..a7d2c008c4 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -535,7 +535,7 @@ const semanticWithPrototypeKind = ( t.isObjectProperty(property) && !property.computed && !property.shorthand && - t.isIdentifier(property.key, { name: "__proto__" }) + semanticStaticPropertyKey(property.key, false) === "__proto__" ) return t.isNullLiteral(unwrapJavaScriptExpression(property.value).node) ? "null" diff --git a/src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts b/src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts index 2c886c27c1..71d6a7e25a 100644 --- a/src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts +++ b/src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts @@ -3,7 +3,58 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; -import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.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", + ]); +}); it.each([ 'var require;require("./before.cjs");', diff --git a/src/domain/javascript/javascriptStaticAnalysis.member_path_depth.test.ts b/src/domain/javascript/javascriptStaticAnalysis.member_path_depth.test.ts index 4dc7bccffb..63ffcf9de6 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.member_path_depth.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.member_path_depth.test.ts @@ -76,3 +76,44 @@ 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([]); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 27082ded33..5bdfa47a04 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -140,7 +140,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( accumulator, vitePreloadDependencyTable: indexVitePreloadDependencyTable( file.program, - bindings.erasedSyntaxNodes, + bindings, ), openReceiverFacts: receivers.openReceivers, electronBindings: electronIdentity.bindings, diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index dfb3f7cb74..429654af56 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -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, diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 40c1d20abe..4a25d8d23d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -43,6 +43,7 @@ import type { import { VITE_OUTPUT_DEPENDENCY_PREFIX } from "./javascriptStaticAnalysisTypes.js"; import { resolveSemanticBindingState, + semanticBindingLookupAt, semanticStableInitializer, type JavaScriptBindingIndex, type JavaScriptSemanticAnalysisState, @@ -55,7 +56,10 @@ import { evaluateSemanticExpression, evaluateSemanticExpressionProvenance, } from "./javascriptSemanticValues.js"; -import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticBindingAtRead, + semanticTopLevelEffectFollowsCapture, +} from "./javascriptSemanticMutationInitializers.js"; import { semanticFactoryLoaderMemberAt, semanticRequireCallAt, @@ -411,7 +415,7 @@ const chunkLoadSpecifier = ( const vitePreloadDependencySpecifiers = ( node: JavaScriptCallLike, name: string, - table: readonly (string | undefined)[] | null, + table: FindingContext["vitePreloadDependencyTable"], bindings: JavaScriptSemanticAnalysisState, ): readonly (string | undefined)[] => { if ( @@ -420,10 +424,23 @@ 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") + return []; + const projected = semanticBindingAtRead(lookup.binding, callee, bindings); + // An ordered read drops assignments that have not happened yet. An + // unresolved read keeps the single stable initializer, which is how a + // captured call still names the helper when nothing else writes it. + const visible = + projected.status === "ordered" ? projected.binding : lookup.binding; + if (semanticStableInitializer(visible, 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)) @@ -433,7 +450,7 @@ const vitePreloadDependencySpecifiers = ( if (element === null || !t.isNumericLiteral(element)) return undefined; const index = element.value; return Number.isSafeInteger(index) && index >= 0 - ? viteDependencySpecifier(table[index]) + ? viteDependencySpecifier(table.dependencies[index]) : undefined; }); }; @@ -473,8 +490,9 @@ type ViteMapDepsFunction = /** Index the canonical Vite helper's cache initializer from parsed syntax. */ export const indexVitePreloadDependencyTable = ( program: t.Program, - erasedSyntaxNodes: WeakSet, -): readonly (string | undefined)[] | null => { + bindings: JavaScriptSemanticAnalysisState, +): FindingContext["vitePreloadDependencyTable"] => { + const { erasedSyntaxNodes } = bindings; const helpers: ViteMapDepsFunction[] = []; traverseJavaScriptValueAst(program, erasedSyntaxNodes, { enter: (node) => { @@ -497,7 +515,7 @@ export const indexVitePreloadDependencyTable = ( const mapIdentifier = t.isAssignmentPattern(mapParameter) ? mapParameter.left : mapParameter; - if (!t.isIdentifier(mapIdentifier)) return []; + if (!t.isIdentifier(mapIdentifier)) return { helper, dependencies: [] }; const tables: t.ArrayExpression[] = []; traverseJavaScriptValueAst(helper, erasedSyntaxNodes, { skip: (node) => t.isFunction(node), @@ -515,9 +533,9 @@ export const indexVitePreloadDependencyTable = ( tables.push(node.right); }, }); - if (tables.length !== 1) return []; + if (tables.length !== 1) return { helper, dependencies: [] }; const table = tables[0]; - if (table === undefined) return []; + if (table === undefined) return { helper, dependencies: [] }; // 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) => @@ -540,6 +558,17 @@ export const indexVitePreloadDependencyTable = ( semanticIdentifierRole(node, parent, readAncestors) === null ) return; + const lookup = semanticBindingLookupAt(bindings, node, node.name); + if (lookup.kind === "ambient" || lookup.kind === "captured") return; + if (lookup.kind === "binding" || lookup.kind === "uncertain-binding") { + // A later reassignment removes the whole-binding initializer. The + // mutation is still the helper when the ordered read at this site is. + const projected = semanticBindingAtRead(lookup.binding, node, bindings); + const visible = + projected.status === "ordered" ? projected.binding : lookup.binding; + if (semanticStableInitializer(visible, bindings)?.node !== helper) + return; + } if ( (t.isCallExpression(parent) || t.isOptionalCallExpression(parent)) && parent.callee === node @@ -548,7 +577,12 @@ export const indexVitePreloadDependencyTable = ( escapedOrMutated = true; }, }); - return escapedOrMutated ? dependencies.map(() => undefined) : dependencies; + return { + helper, + dependencies: escapedOrMutated + ? dependencies.map(() => undefined) + : dependencies, + }; }; const requestConstructorUrl = ( diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 209b3eacdd..25862ab5a8 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -769,8 +769,11 @@ export const stringValue = ( /** Return a string or numeric argument literal. */ export const argumentValue = ( node: t.Node | null | undefined, -): string | undefined => - t.isNumericLiteral(node) ? String(node.value) : stringValue(node); +): string | undefined => { + if (node === null || node === undefined) return undefined; + const value = unwrapJavaScriptExpression(node).node; + return t.isNumericLiteral(value) ? String(value.value) : stringValue(value); +}; /** Prefix a static argument value when one is available. */ export const prefixedArgument = ( diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 04c179262b..3025fc01fd 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -133,7 +133,10 @@ export interface JavaScriptFindingContext { readonly expressionShapes: JavaScriptExpressionShapeResolver; readonly source: string; readonly accumulator: JavaScriptAnalysisAccumulator; - readonly vitePreloadDependencyTable: readonly (string | undefined)[] | null; + readonly vitePreloadDependencyTable: { + readonly helper: t.Node; + readonly dependencies: readonly (string | undefined)[]; + } | null; readonly openReceiverFacts?: ReadonlyMap; /** Proven Electron export path segments by root offset. */ readonly electronBindings?: ReadonlyMap; diff --git a/src/domain/javascript/javascriptStaticApiIdentity.test.ts b/src/domain/javascript/javascriptStaticApiIdentity.test.ts index 9f13dc990d..c8d85e3194 100644 --- a/src/domain/javascript/javascriptStaticApiIdentity.test.ts +++ b/src/domain/javascript/javascriptStaticApiIdentity.test.ts @@ -1,6 +1,7 @@ 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';"; @@ -152,3 +153,52 @@ 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"; diff --git a/src/domain/javascript/javascriptSymbolicBindings.test.ts b/src/domain/javascript/javascriptSymbolicBindings.test.ts index 5f27f27619..abdafa1fb9 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.test.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.test.ts @@ -7,6 +7,7 @@ import { traverseJavaScriptAst, } from "./javascriptSemanticTraversal.js"; import { JavaScriptSymbolicBindings } from "./javascriptSymbolicBindings.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; import { symbolicTruth, symbolicVariants, @@ -1076,3 +1077,94 @@ 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" }]); +}); diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index 18a706a616..bd0df9cc1e 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -52,6 +52,9 @@ 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[]; @@ -198,6 +201,12 @@ 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); } @@ -396,19 +405,19 @@ export class JavaScriptSymbolicBindings { this.unresolved = true; return symbolicUnknown("module export read boundary is unavailable"); } - const replacement = available.findLast( - (write) => write.path.length === 0, + const write = available.findLast( + (write) => + write.path.length === 0 || + (write.path.length === 1 && write.path[0] === key), ); - if (replacement) + if (write?.receiverUncertain) + return symbolicUnknown("uncertain export receiver"); + if (write?.path.length === 0) return this.property( - this.evaluate(replacement.value, value.environment), + this.evaluate(write.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"); @@ -509,19 +518,23 @@ export class JavaScriptSymbolicBindings { if (value.kind === "namespace") { const available = this.#namespaceWrites(value); if (available === undefined) return []; - const replacement = available.findLast( + const replacementIndex = available.findLastIndex( (write) => write.path.length === 0, ); - return replacement - ? this.members( - this.evaluate(replacement.value, value.environment), - active, - ) - : [ - ...new Set( - available.flatMap((w) => (w.path.length === 1 ? w.path : [])), - ), - ]; + 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 : [])), + ]), + ]; } if (value.kind !== "object") return []; return [ @@ -563,6 +576,7 @@ 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, ); @@ -1043,7 +1057,9 @@ 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 ? writes : undefined; + return read === undefined + ? this.#publicNamespaceWrites(writes) + : undefined; // An owned snapshot cannot read beyond either its capture or this exit. const point = Math.min( sourcePoint ?? Infinity, @@ -1069,17 +1085,84 @@ export class JavaScriptSymbolicBindings { } } } - 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))), + 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))), + ), ); } + /** 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, @@ -1548,6 +1631,14 @@ 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; @@ -1577,7 +1668,25 @@ export class JavaScriptSymbolicBindings { } } const writes = this.#writes.get(key) ?? []; - writes.push({ path: selected, value, site }); + 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 }), + }); this.#writes.set(key, writes); } } diff --git a/src/domain/javascript/javascriptVitePreloadDependencies.test.ts b/src/domain/javascript/javascriptVitePreloadDependencies.test.ts index 27554181c6..3188c1dd96 100644 --- a/src/domain/javascript/javascriptVitePreloadDependencies.test.ts +++ b/src/domain/javascript/javascriptVitePreloadDependencies.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { + analyzeJavaScriptStaticSource, + analyzeParsedJavaScriptStaticSource, +} from "./javascriptStaticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; describe("Vite preload dependency recovery", () => { it("decodes helper table values and preserves sparse and dynamic slots", () => { @@ -143,3 +147,109 @@ 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("keeps a preload call that precedes a later helper reassignment", () => { + const specifiers = (source: string) => + analyzeJavaScriptStaticSource(source) + .references.filter(({ kind }) => kind === "dynamic-import") + .map(({ specifier }) => specifier); + const helper = `let __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies = + (map.f || (map.f = ["./canonical.js"]))) => indexes.map((index) => dependencies[index]);`; + expect( + specifiers(`${helper} __vite__mapDeps([0]); __vite__mapDeps = () => [];`), + ).toEqual(["./canonical.js"]); + expect( + specifiers(`${helper} __vite__mapDeps = () => []; __vite__mapDeps([0]);`), + ).toEqual([]); + expect( + specifiers( + `${helper} __vite__mapDeps.f = ["./changed.js"]; __vite__mapDeps([0]); __vite__mapDeps = other;`, + ), + ).toEqual([null]); + expect( + specifiers( + `${helper} __vite__mapDeps([0]); __vite__mapDeps = other; __vite__mapDeps.f = ["./changed.js"];`, + ), + ).toEqual(["./canonical.js"]); +}); + +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"]); +}); diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index 1c1b5d4c1d..266b8748bd 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -923,12 +923,27 @@ it("traces all endpoint carriers and their response consumers through CLI and MC "response", ]); const response = carriers.find(({ kind }) => kind === "response"); - if (response === undefined) throw new Error("Expected matching response"); + const request = carriers.find(({ kind }) => kind === "request"); + if (response === undefined || request === undefined) + throw new Error("Expected matching request and response"); const consumer = graph.relations.find( ({ source_node_id, relation }) => source_node_id === response.node_id && relation === "consumed-by", ); - expect(consumer).toMatchObject({ resolution: "resolved" }); + 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" }, + }); const client = await connectedMcpClient(); const trace = async (seed: JavaScriptSemanticQueryInput["seed"]) => { const input = { @@ -980,13 +995,13 @@ it("traces all endpoint carriers and their response consumers through CLI and MC "request", "response", ]); - expect(selected.relations).toEqual([consumer]); + expect(selected.relations).toEqual([requestLink, consumer]); expect(selected).toMatchObject({ status: "ambiguous", summary: { total_seed_matches: 2, traversed_nodes: 3, - traversed_relations: 1, + traversed_relations: 2, }, coverage: { status: "partial" }, });