From 896696b2e761e34c267977bb5c6982c607a622d0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:29 +0800 Subject: [PATCH] fix(javascript): preserve semantic uncertainty and lexical ownership --- .../JavaScriptSemanticGraphBuilder.test.ts | 34 +++- .../electronStaticAnalysisOptions.test.ts | 30 ++++ .../javascriptApplicationChangeGraph.ts | 9 +- ...scriptApplicationVersionComparison.test.ts | 56 ++++++- .../javascriptSemanticAnalysis.calls.test.ts | 22 +++ ...tSemanticAnalysis.member_mutations.test.ts | 26 ++++ .../javascript/javascriptSemanticAnalysis.ts | 17 +- .../javascript/javascriptSemanticCalls.ts | 38 ++++- .../javascriptSemanticFingerprints.test.ts | 31 ++++ .../javascriptSemanticFingerprints.ts | 2 + .../javascript/javascriptSemanticGraph.ts | 6 +- .../javascriptSemanticGraphQuery.test.ts | 145 ++++++++++++++++++ .../javascriptSemanticObjectReferences.ts | 27 ++++ .../javascript/javascriptSemanticQuery.ts | 17 +- .../javascriptSymbolicBindings.test.ts | 56 +++++++ .../javascript/javascriptSymbolicBindings.ts | 29 ++++ 16 files changed, 525 insertions(+), 20 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticFingerprints.test.ts diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index c15437b08a..d9bcd6ca70 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -65,16 +65,40 @@ it("projects closure and direct interprocedural flow without execution", () => { ); }); -it("projects a destructuring assignment member target as a property write", () => { - const graph = graphFor(` - const source = { count: 1 }; - [source.count] = [2]; - `); +it.each([ + ["direct assignment", "source.count = 2;"], + ["destructuring assignment", "[source.count] = [2];"], + ["for-of head", "for (source.count of [4]) {}"], + ["for-in head", "for (source.count in { key: true }) {}"], + ["delete", "delete source.count;"], +])("projects a %s member target as a property write", (_name, statement) => { + const graph = graphFor(`const source = { count: 1 }; ${statement}`); const operations = graph.nodes.filter( ({ properties }) => properties.operation_kind !== undefined, ); expect(operations.map(({ label }) => label)).toEqual(["write:/count"]); + const write = operations[0]; + if (write === undefined) throw new Error("Expected write operation node"); + expect(graph.relations).toContainEqual( + expect.objectContaining({ + source_node_id: write.node_id, + relation: "writes-property", + }), + ); expect(graph.relations).not.toContainEqual( + expect.objectContaining({ + source_node_id: write.node_id, + relation: "reads-property", + }), + ); +}); + +it("continues to project an ordinary member access as a property read", () => { + const graph = graphFor(` + const source = { count: 1 }; + const value = source.count; + `); + expect(graph.relations).toContainEqual( expect.objectContaining({ relation: "reads-property" }), ); }); diff --git a/src/domain/javascript/electronStaticAnalysisOptions.test.ts b/src/domain/javascript/electronStaticAnalysisOptions.test.ts index 0fc0446966..418920777e 100644 --- a/src/domain/javascript/electronStaticAnalysisOptions.test.ts +++ b/src/domain/javascript/electronStaticAnalysisOptions.test.ts @@ -25,6 +25,36 @@ describe("Electron option evidence", () => { }); }); + it.each([ + ["for initializer", 'for (var electron = require("electron"); false;) {}'], + ["do body", 'do { var electron = require("electron"); } while (false);'], + ])( + "recovers Electron bindings initialized unconditionally in a %s", + (_name, declaration) => { + const analysis = analyzeJavaScriptStaticSource( + `${declaration} new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}});`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "./preload.js" }, + ]); + }, + ); + + it.each([ + 'for (; false;) { var electron = require("electron"); }', + 'while (false) { var electron = require("electron"); }', + 'if (flag) { for (var electron = require("electron"); false;) {} }', + ])( + "keeps conditionally skipped loop declarations unresolved: %s", + (declaration) => { + const analysis = analyzeJavaScriptStaticSource( + `${declaration} new electron.BrowserWindow({});`, + ); + expect(analysis.electron.browser_windows).toEqual([]); + }, + ); + it("does not read an audio or video preload attribute as a preload script", () => { const analysis = analyzeJavaScriptStaticSource(` jsx("audio", { controls: true, preload: "metadata", src }); diff --git a/src/domain/javascript/javascriptApplicationChangeGraph.ts b/src/domain/javascript/javascriptApplicationChangeGraph.ts index 134fff5200..3935038447 100644 --- a/src/domain/javascript/javascriptApplicationChangeGraph.ts +++ b/src/domain/javascript/javascriptApplicationChangeGraph.ts @@ -180,7 +180,12 @@ const comparisonEvidence = ( executable_sha256: null, }, coverage: complete - ? { status: "complete", truncated: false, omitted_count: 0, limits: [] } + ? { + status: "complete", + truncated: false, + omitted_count: 0, + limits: sourceCoverageLimits(input), + } : { status: "partial", truncated: @@ -210,7 +215,7 @@ const changeGraphCoverage = ( status: "complete", truncated: false, omitted_count: 0, - limits: [], + limits: sourceCoverageLimits(input), }; return { status: "partial", diff --git a/src/domain/javascript/javascriptApplicationVersionComparison.test.ts b/src/domain/javascript/javascriptApplicationVersionComparison.test.ts index d73dceaf6b..fdb36b5dc5 100644 --- a/src/domain/javascript/javascriptApplicationVersionComparison.test.ts +++ b/src/domain/javascript/javascriptApplicationVersionComparison.test.ts @@ -143,7 +143,7 @@ const nativeEvidence = (sha256: string | undefined, ordinal: number) => }, ); -describe("application version comparison result", () => { +describe("application version comparison Evidence links", () => { it("links every exact digest observation only to its paired or unmatched item", () => { const left = buildGraph(LEFT_SHA, 1); const right = buildGraph(RIGHT_SHA, 2); @@ -227,7 +227,9 @@ describe("application version comparison result", () => { ].sort(), ); }); +}); +describe("application version comparison graph ownership", () => { it("returns a sealed change graph that passes complete validation", () => { const result = compare(); expect( @@ -243,6 +245,58 @@ describe("application version comparison result", () => { ); }); + it("retains deduplicated complete input limits on the change graph and inferred edge", () => { + const leftLimits = [ + { name: "modules", value: 100, unit: "items" as const }, + ]; + const rightLimits = [ + { name: "source-bytes", value: 4096, unit: "bytes" as const }, + { name: "modules", value: 100, unit: "items" as const }, + ]; + const withLimits = ( + graph: JavaScriptApplicationGraph, + limits: JavaScriptApplicationGraph["coverage"]["limits"], + ): JavaScriptApplicationGraph => { + const { graph_id: _graphId, ...input } = graph; + return createJavaScriptApplicationGraph({ + ...input, + coverage: { ...graph.coverage, limits }, + }); + }; + const result = compareJavaScriptApplicationVersions({ + left: { + evidenceId: `ev_${"1".repeat(64)}`, + rootArtifactSha256: LEFT_SHA, + graph: withLimits(buildGraph(LEFT_SHA, 1), leftLimits), + }, + right: { + evidenceId: `ev_${"2".repeat(64)}`, + rootArtifactSha256: RIGHT_SHA, + graph: withLimits(buildGraph(RIGHT_SHA, 2), rightLimits), + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); + const expectedLimits = [leftLimits[0]!, rightLimits[0]!]; + const changed = result.graph.edges.find( + ({ relation }) => relation === "changed_from", + ); + + expect(changed).toBeDefined(); + expect(result.graph.coverage).toMatchObject({ + status: "complete", + truncated: false, + omitted_count: 0, + limits: expectedLimits, + }); + expect(changed?.evidence.coverage).toMatchObject({ + status: "complete", + truncated: false, + omitted_count: 0, + limits: expectedLimits, + }); + }); + it("does not reuse a change graph without an owned proof", () => { const result = compare(); const copy = structuredClone(result.graph); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts index f5ac65b7ac..217480175f 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts @@ -8,6 +8,28 @@ import { } from "./javascriptSemanticAnalysis.fixture.js"; describe("JavaScript semantic analysis: calls 1", () => { + it("does not assign computed method-name reads to method closures", () => { + const ir = analyzeJavaScriptSemantics(` + function outer() { + const name = "m"; + const object = { [name]() { return 1; }, ["literal"]() { return 2; } }; + class Example { + [name]() { return 3; } + ["literal"]() { return 4; } + } + } + `); + expect(ir.closureCaptures).toEqual([]); + + const bodyRead = analyzeJavaScriptSemantics(` + function outer() { + const value = 1; + const object = { method() { return value; } }; + } + `); + expect(bodyRead.closureCaptures).toHaveLength(1); + }); + it("links exact local calls to parameters, returns, and closure captures", () => { const ir = analyzeJavaScriptSemantics(` const prefix = "rea"; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts index ee4e602b12..a9145a92cd 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts @@ -164,6 +164,32 @@ describe("JavaScript semantic values after explicit property mutations", () => { }); }); +it("invalidates a shared value reached through an object-literal prototype getter", () => { + const value = resultValue(` + const shared = { x: 1 }; + const proto = { get v() { return shared; } }; + const box = { __proto__: proto }; + box.v.x = 2; + return shared.x; + `); + expect( + value?.status === "unknown" || + (value?.status === "literal" && value.value === 2), + ).toBe(true); +}); + +it("does not follow an object-literal prototype getter past an own property", () => { + expect( + resultValue(` + const shared = { x: 1 }; + const proto = { get v() { return shared; } }; + const box = { __proto__: proto, v: { x: 3 } }; + box.v.x = 2; + return shared.x; + `), + ).toEqual({ status: "literal", value: 1 }); +}); + describe("JavaScript semantic mutations through getter results (#1494)", () => { it.each([ [ diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index dcb913afd4..e8bbffb487 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -860,13 +860,18 @@ const hasConditionalInitializer = (ancestors: readonly t.Node[]): boolean => { const node = ancestors[index]; if (t.isFunction(node) || t.isProgram(node) || t.isStaticBlock(node)) return false; - if ( - t.isIfStatement(node) || - t.isLoop(node) || - t.isSwitchCase(node) || - t.isTryStatement(node) - ) + if (t.isIfStatement(node) || t.isSwitchCase(node) || t.isTryStatement(node)) return true; + if (t.isLoop(node)) { + const bodyOrTest = ancestors[index + 1]; + if ( + (t.isForStatement(node) && bodyOrTest === node.init) || + (t.isDoWhileStatement(node) && + (bodyOrTest === node.body || bodyOrTest === node.test)) + ) + continue; + return true; + } } return false; }; diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index 53ada16541..b07715af89 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -58,6 +58,7 @@ interface CallCollectionContext { readonly JavaScriptSemanticBindingState[] >; readonly ancestors: t.Node[]; + readonly callableStack: JavaScriptSemanticCallable[]; } /** Recover direct-call and lexical-capture candidates from inert syntax. */ @@ -77,6 +78,7 @@ export const collectJavaScriptSemanticCalls = ( const callableById = new Map( callables.map((callable) => [callable.callableId, callable]), ); + const callableStack: JavaScriptSemanticCallable[] = []; const context: CallCollectionContext = { state, callableById, @@ -84,8 +86,8 @@ export const collectJavaScriptSemanticCalls = ( bindingResolutionCache: new Map(), parameterBindingCache: new Map(), ancestors: [], + callableStack, }; - const callableStack: JavaScriptSemanticCallable[] = []; traverseJavaScriptAst(program, { enter: (node, parent) => { const callableId = semanticCallableIdForNode(node); @@ -316,19 +318,49 @@ const collectCapture = ( ) return; if (semanticReferenceRole(node, parent) === null) return; + const captureOwner = computedMethodKeyOwner(node, owner, context); + if (captureOwner === undefined || captureOwner.bodyScopeId === null) return; const binding = resolveSemanticBindingState(state, node, node.name); if ( binding === undefined || - bindingIsWithinCallable(binding, owner.bodyScopeId, state) + bindingIsWithinCallable(binding, captureOwner.bodyScopeId, state) ) return; output.closureCaptures.push({ - callableId: owner.callableId, + callableId: captureOwner.callableId, bindingId: binding.bindingId, referenceLocation: range(node), }); }; +const computedMethodKeyOwner = ( + node: t.Node, + owner: JavaScriptSemanticCallable, + context: CallCollectionContext, +): JavaScriptSemanticCallable | undefined => { + const ownerNode = context.ancestors.findLast( + (candidate) => semanticCallableIdForNode(candidate) === owner.callableId, + ); + if ( + ownerNode === undefined || + (!t.isObjectMethod(ownerNode) && !t.isClassMethod(ownerNode)) || + !ownerNode.computed + ) + return owner; + let child = node; + let parent = context.state.parentsByNode.get(child); + while (parent !== undefined && parent !== ownerNode) { + child = parent; + parent = context.state.parentsByNode.get(parent); + } + if (parent !== ownerNode || ownerNode.key !== child) return owner; + return enclosingFunction( + context.callableStack.filter( + ({ callableId }) => callableId !== owner.callableId, + ), + ); +}; + const collectDynamicProperty = ( node: t.Node, owner: JavaScriptSemanticCallable | undefined, diff --git a/src/domain/javascript/javascriptSemanticFingerprints.test.ts b/src/domain/javascript/javascriptSemanticFingerprints.test.ts new file mode 100644 index 0000000000..48a88d7353 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticFingerprints.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const normalizedSyntaxFingerprint = (source: string): string => { + const [fingerprint] = analyzeJavaScriptSemantics(source).functionFingerprints; + expect(fingerprint?.status).toBe("complete"); + return fingerprint?.components.normalizedAstSha256 ?? ""; +}; + +it("distinguishes yielding an iterator result from delegating to the iterator", () => { + expect( + normalizedSyntaxFingerprint("function* sample(gen) { yield* gen(); }"), + ).not.toBe( + normalizedSyntaxFingerprint("function* sample(gen) { yield gen(); }"), + ); +}); + +it("distinguishes returned prefix and postfix updates", () => { + expect( + normalizedSyntaxFingerprint("function sample(i) { return ++i; }"), + ).not.toBe(normalizedSyntaxFingerprint("function sample(i) { return i++; }")); +}); + +it("keeps formatting and local renames normalized", () => { + expect( + normalizedSyntaxFingerprint( + "function renamed(value) {\n return value++ ;\n }", + ), + ).toBe(normalizedSyntaxFingerprint("function sample(i) { return i++; }")); +}); diff --git a/src/domain/javascript/javascriptSemanticFingerprints.ts b/src/domain/javascript/javascriptSemanticFingerprints.ts index c8615788c2..6212ee5e41 100644 --- a/src/domain/javascript/javascriptSemanticFingerprints.ts +++ b/src/domain/javascript/javascriptSemanticFingerprints.ts @@ -198,6 +198,8 @@ const normalizedNodeToken = (node: t.Node, parent: t.Node | null): string => { primitiveAttribute(node, "optional"), primitiveAttribute(node, "async"), primitiveAttribute(node, "generator"), + primitiveAttribute(node, "delegate"), + primitiveAttribute(node, "prefix"), ].filter((value): value is string => value !== null); return [node.type, ...attributes].join(":"); }; diff --git a/src/domain/javascript/javascriptSemanticGraph.ts b/src/domain/javascript/javascriptSemanticGraph.ts index cdaa8c28b8..4b77391f9e 100644 --- a/src/domain/javascript/javascriptSemanticGraph.ts +++ b/src/domain/javascript/javascriptSemanticGraph.ts @@ -306,9 +306,11 @@ function* checkCoverageSteps( relationsByFamily.set(family, (relationsByFamily.get(family) ?? 0) + 1); } const unknownsById = new Map(); + const unknownFamilies = new Set(); for (const [index, unknown] of graph.unknowns.entries()) { if (index % 256 === 0) yield; unknownsById.set(unknown.unknown_id, unknown); + unknownFamilies.add(unknown.family); } for (const [index, family] of graph.coverage.families.entries()) { if (family.retained_relations !== relationsByFamily.get(family.family)) @@ -329,7 +331,9 @@ function* checkCoverageSteps( }); if ( family.status === "complete" && - (family.omitted_relations !== 0 || family.unknown_ids.length > 0) + (family.omitted_relations !== 0 || + family.unknown_ids.length > 0 || + unknownFamilies.has(family.family)) ) context.addIssue({ code: "custom", diff --git a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts index 8b3e0e1112..b24e105f33 100644 --- a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts +++ b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts @@ -2,6 +2,7 @@ import { semanticFixtureGraph, semanticFixtureInput, semanticGraphTestEvidence, + unknownSemanticGraphTestEvidence, } from "../../../tests/support/javascriptSemanticGraphFixture.js"; import { expect, it } from "vitest"; import { z } from "zod"; @@ -10,6 +11,7 @@ import { digestCanonicalValue } from "../canonicalDigest.js"; import type { JsonValue } from "../jsonValue.js"; import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js"; import { + JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, JAVASCRIPT_SEMANTIC_RELATION_FAMILY, JAVASCRIPT_SEMANTIC_NODE_KINDS, JAVASCRIPT_SEMANTIC_RELATIONS, @@ -244,6 +246,86 @@ it("matches scalar literal seeds and skips non-scalar literal metadata", () => { expect(query("unmatched")).toEqual([]); }); +it("scopes callers unknowns to call relations while preserving graph uncertainty", () => { + const contexts = new JavaScriptSemanticEvidenceContextRegistry(); + const caller = node("function", "caller", {}, contexts); + const seed = node("function", "seed", {}, contexts); + const call = createJavaScriptSemanticGraphRelation( + { + source_node_id: caller.node_id, + target_node_id: seed.node_id, + relation: "calls", + resolution: "resolved", + properties: {}, + evidence: semanticGraphTestEvidence("inferred"), + }, + contexts, + ); + const readUnknown = createJavaScriptSemanticGraphUnknown( + { + node_id: caller.node_id, + family: "data-flow", + relation_kinds: ["reads"], + reason: "dynamic-property", + detail: "Unresolved property read at the caller.", + candidate_node_ids: [], + evidence: unknownSemanticGraphTestEvidence(), + }, + contexts, + ); + const graph = createJavaScriptSemanticGraph({ + schema: "JavaScriptSemanticRelationGraph", + root_artifact_sha256: SHA, + application_graph_id: `jag_${"b".repeat(64)}`, + root_node_ids: [seed.node_id], + evidence_contexts: contexts.contexts, + nodes: [caller, seed], + relations: [call], + fingerprints: [], + unknowns: [readUnknown], + coverage: { + status: "partial", + truncated: false, + omitted_nodes: null, + omitted_relations: null, + limits: [], + families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({ + family, + status: + family === "data-flow" ? ("unknown" as const) : ("complete" as const), + retained_relations: + JAVASCRIPT_SEMANTIC_RELATION_FAMILY[call.relation] === family ? 1 : 0, + omitted_relations: family === "data-flow" ? null : 0, + unknown_ids: family === "data-flow" ? [readUnknown.unknown_id] : [], + })), + }, + limitations: ["One property read remains unresolved."], + }); + const query = ( + direction: "callers" | "backward-provenance", + allowed_relations?: ["calls"], + ) => + queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: seed.node_id }, + direction, + ...(allowed_relations === undefined ? {} : { allowed_relations }), + }); + const callers = query("callers"); + const filtered = query("callers", ["calls"]); + const backward = query("backward-provenance"); + expect(callers.summary.traversed_nodes).toBe(2); + expect(callers.unknowns).toEqual([]); + expect(callers.summary.relevant_unknowns).toBe(0); + expect(callers.limitations).not.toContain( + "Relevant dynamic, unsupported, incomplete, or ambiguous semantics remain unknown.", + ); + expect(filtered.unknowns).toEqual(callers.unknowns); + expect(backward.unknowns.map(({ unknown_id }) => unknown_id)).toEqual([ + readUnknown.unknown_id, + ]); + expect(callers.coverage.status).toBe("partial"); +}); + it("counts candidate ownership cycles once and traverses them finitely", () => { const graph = graphWithCandidateRelations(); const binding = graph.nodes.find(({ label }) => label === "binding"); @@ -336,6 +418,69 @@ it("rejects stale identities, dangling endpoints, and incomplete coverage claims ); }); +it("rejects complete family coverage with an unlisted retained frontier", () => { + const input = semanticFixtureInput(true); + input.coverage = { + ...input.coverage, + status: "complete", + truncated: false, + omitted_nodes: 0, + omitted_relations: 0, + families: input.coverage.families.map((family) => + family.family === "call-flow" + ? { + ...family, + status: "complete", + omitted_relations: 0, + unknown_ids: [], + } + : family, + ), + }; + input.limitations = []; + expect(() => createJavaScriptSemanticGraph(input)).toThrow( + /Complete family coverage cannot omit or retain unknown facts/u, + ); + + const valid = createJavaScriptSemanticGraph(semanticFixtureInput(true)); + const coverage = { + ...valid.coverage, + status: "complete" as const, + truncated: false, + omitted_nodes: 0, + omitted_relations: 0, + families: valid.coverage.families.map((family) => + family.family === "call-flow" + ? { + ...family, + status: "complete" as const, + omitted_relations: 0, + unknown_ids: [], + } + : family, + ), + }; + const { graph_id: _previousId, ...semantic } = valid; + const changed = { ...semantic, coverage, limitations: [] }; + const invalid = { + ...changed, + graph_id: `jsrg_${digestCanonicalValue( + changed, + "JavaScript semantic graph", + )}`, + }; + const parsed = javaScriptSemanticGraphSchema.safeParse(invalid); + expect(parsed.success).toBe(false); + if (parsed.success) throw new Error("Expected inconsistent graph rejection"); + expect(parsed.error.issues.map(({ message }) => message)).toContain( + "Complete family coverage cannot omit or retain unknown facts", + ); + expect( + javaScriptSemanticGraphSchema.safeParse(JSON.parse(JSON.stringify(invalid))) + .success, + ).toBe(false); +}); + it("validates evidence context ownership and references", () => { const graph = semanticFixtureGraph(); const alteredContext = { diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 425e5fea5d..2e83131675 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -785,6 +785,33 @@ export const semanticObjectPropertyReferences = ( continue; } const name = semanticStaticPropertyKey(property.key, property.computed); + if ( + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__" + ) { + const selected = key ?? null; + if ( + (selected === null || typeof selected === "object") && + t.isExpression(property.value) + ) { + references.push({ + node: property.value, + path: [selected, ...remaining], + }); + } else if ( + typeof selected === "string" && + !objectExpressionDefinesKey(node, selected) && + t.isExpression(property.value) + ) { + references.push({ + node: property.value, + path: [selected, ...remaining], + }); + } + continue; + } const isGetter = t.isObjectMethod(property) && property.kind === "get"; const isSetter = t.isObjectMethod(property) && property.kind === "set"; if ( diff --git a/src/domain/javascript/javascriptSemanticQuery.ts b/src/domain/javascript/javascriptSemanticQuery.ts index a101cd934c..7b883521aa 100644 --- a/src/domain/javascript/javascriptSemanticQuery.ts +++ b/src/domain/javascript/javascriptSemanticQuery.ts @@ -250,11 +250,24 @@ const relevantUnknownFrontiers = ( return graph.unknowns.filter( (unknown) => (unknown.node_id === null || nodeIds.has(unknown.node_id)) && - (allowed === null || - unknown.relation_kinds.some((relation) => allowed.has(relation))), + unknown.relation_kinds.some( + (relation) => + directionAllowsRelation(input.direction, relation) && + (allowed === null || allowed.has(relation)), + ), ); }; +const directionAllowsRelation = ( + direction: JavaScriptSemanticQueryInput["direction"], + relation: JavaScriptSemanticGraphRelation["relation"], +): boolean => + direction === "callers" + ? relation === "calls" + : direction === "ownership" + ? ownershipRelation(relation) + : true; + const relevantCandidateRelationCount = ( graph: JavaScriptSemanticGraph, nodeIds: ReadonlySet, diff --git a/src/domain/javascript/javascriptSymbolicBindings.test.ts b/src/domain/javascript/javascriptSymbolicBindings.test.ts index 4e0a2acac0..b78bd543a1 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.test.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.test.ts @@ -389,6 +389,62 @@ it("composes fallthrough guards across successive conditional returns", () => { ]); }); +it.each([ + ["while", 'inspect((() => { while (flag) { return "loop"; } })());'], + ["for", 'inspect((() => { for (;;) { return "forever"; } })());'], + ["try", 'inspect((() => { try { return "try"; } finally {} })());'], + [ + "switch", + 'inspect((() => { switch (x) { default: return "switch"; } })());', + ], +])( + "keeps unsupported %s return control flow explicitly unknown", + (_name, source) => { + const value = inspectOne(source); + expect( + symbolicVariants(value).some((variant) => variant.kind === "unknown"), + ).toBe(true); + expect(value).not.toEqual({ kind: "literal", value: undefined }); + }, +); + +it("preserves later returns with unresolved conditions after unsupported return flow", () => { + const variants = symbolicVariants( + inspectOne(`inspect((() => { + while (flag) { return "inside"; } + return "after"; + })());`), + ); + + expect(variants.some((variant) => variant.kind === "unknown")).toBe(true); + expect(variants).toContainEqual( + expect.objectContaining({ + kind: "literal", + value: "after", + conditionUnresolved: true, + }), + ); +}); + +it("does not treat loops without owned returns as unknown return flow", () => { + expect( + inspectOne(`inspect((() => { + while (flag) { work(); } + return "after"; + })());`), + ).toEqual({ kind: "literal", value: "after" }); +}); + +it("does not treat nested-function returns as wrapper return flow", () => { + expect( + inspectOne(`inspect((() => { + try { function nested() { return "inner"; } } + catch {} + return "outer"; + })());`), + ).toEqual({ kind: "literal", value: "outer" }); +}); + it("marks return guards unresolved when a nested join cannot be represented", () => { const value = inspectOne(` function choose() { diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index ec1585a1b6..9d771df04b 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -58,6 +58,17 @@ const isObjectPrototypeSetter = ( !property.shorthand && name === "__proto__"; +const containsOwnedReturn = (root: t.Node): boolean => { + let found = false; + traverseJavaScriptAst(root, { + skip: (node) => found || t.isFunction(node) || t.isClass(node), + enter: (node) => { + if (t.isReturnStatement(node)) found = true; + }, + }); + return found; +}; + /** Demand-driven substitutions for local bindings, closures and literal module tables. */ export class JavaScriptSymbolicBindings { readonly #writes = new Map(); @@ -832,6 +843,24 @@ export class JavaScriptSymbolicBindings { unresolvedFallthrough = true; } } + if ( + t.isWhileStatement(statement) || + t.isDoWhileStatement(statement) || + t.isForStatement(statement) || + t.isForInStatement(statement) || + t.isForOfStatement(statement) || + t.isSwitchStatement(statement) || + t.isTryStatement(statement) + ) { + if (containsOwnedReturn(statement)) { + possible.push( + symbolicUnknown( + `unsupported return control flow: ${statement.type}`, + ), + ); + unresolvedFallthrough = true; + } + } } return { values: possible, fallsThrough: true }; }