From 9df94a62cd56b6ab29d55d56cb7db87ad8e5e439 Mon Sep 17 00:00:00 2001 From: Iven Koo <73214171+IvenKooLab@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:04:38 +0800 Subject: [PATCH] feat(javascript): link assignment right-hand sides to their writes (#6206) * feat(javascript): link assignment right-hand sides to their writes A declaration carries its value forward, but a later assignment to a variable, a property or through a compound operator stopped the forward trace at the right-hand-side read. The semantic IR now records one syntactic assignment flow per simple assignment target (ranges, operator, owning callable), and the graph projection connects each retained read of the right-hand side to the write occurrence through a defines relation. Compound assignments also consume the previous value of the target; the written value itself stays unknown, keeping the boundary of #4342 intact. Fixes #6193 * fix(javascript): preserve computed key assignment ownership * test(javascript): retain nested callback assignment ownership * docs(javascript): record assignment influence verification * test(mcp): isolate mocks and acknowledge cancellation boundaries * test(javascript): retain CLI diagnostics and current-main verification --------- Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- docs/javascript-application-workflows.md | 8 + ...ascript-assignment-influence-2026-10-24.md | 193 ++++++++++++++++++ scripts/lib/test-projects.mjs | 9 +- ...ScriptSemanticGraphAssignmentProjection.ts | 63 ++++++ ...JavaScriptSemanticGraphAssignments.test.ts | 184 +++++++++++++++++ .../JavaScriptSemanticGraphBuilder.ts | 10 +- .../JavaScriptSemanticGraphFlowProjection.ts | 2 + ...JavaScriptSemanticGraphObjectProjection.ts | 4 + .../JavaScriptSemanticGraphProjection.ts | 8 +- .../javascriptSemanticDerivedAnalysis.ts | 44 +++- src/domain/javascript/javascriptSemanticIr.ts | 13 ++ .../javascriptSemanticProjection.ts | 7 + .../JavaScriptAnalysisWorkerSchemas.ts | 1 + .../cli/javascriptAssignmentInfluence.test.ts | 87 ++++++++ .../filesystem/ciTestProjects.test.ts | 17 +- .../evidenceBundleHeadroom.test.ts | 14 +- .../javascriptAnalysisCancellation.test.ts | 73 ++++++- 17 files changed, 702 insertions(+), 35 deletions(-) create mode 100644 docs/verification/javascript-assignment-influence-2026-10-24.md create mode 100644 src/application/javascript/JavaScriptSemanticGraphAssignmentProjection.ts create mode 100644 src/application/javascript/JavaScriptSemanticGraphAssignments.test.ts create mode 100644 tests/boundary/cli/javascriptAssignmentInfluence.test.ts rename src/application/EvidenceBundleFiles.test.ts => tests/process-global/evidenceBundleHeadroom.test.ts (78%) rename tests/{boundary/mcp => process-global}/javascriptAnalysisCancellation.test.ts (70%) diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 44645da9ee..4a4c622f5b 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -197,6 +197,14 @@ registrations/removals and dispatch, Node timers and cancellation handles, async `node:child_process` creation and ownership, configuration sources and direct defaults, request construction and response consumers, parse/coercion/ validation boundaries, and built-in resource acquisition/release. +Reassignments connect retained right-hand reads to the binding or property +write. Compound assignments also retain the previous-value input; logical +assignments keep conditional inputs as candidates. These edges describe +influence without evaluating arithmetic or establishing execution order or the +resulting value. Deferred callback bodies do not become inputs to the enclosing +assignment. Computed method keys belong to the enclosing callable; parameter +defaults and method bodies belong to the method. Uncertain later property reads +remain opt-in through `include_ambiguous_dynamic_edges`. Global timer, `fetch`, `WebSocket`, `JSON.parse`, and coercion calls receive these built-in effects only when the file has no visible possible write to that global, including a computed write through a global object. The check does not diff --git a/docs/verification/javascript-assignment-influence-2026-10-24.md b/docs/verification/javascript-assignment-influence-2026-10-24.md new file mode 100644 index 0000000000..1b87ec9ccb --- /dev/null +++ b/docs/verification/javascript-assignment-influence-2026-10-24.md @@ -0,0 +1,193 @@ +# JavaScript assignment influence verification — 2026-10-24 + +PR #6206 answers which retained inputs can influence a reassignment. It links +reads to binding/property writes, retains both compound operands, and keeps +conditional inputs and resulting values uncertain. It does not evaluate +arithmetic or prove execution order. + +## Source and ownership + +The comparison baseline is `b25895f9522a3432f0c2f9dfd2fc3e819510298f`, the original +assignment feature integrated with main `a062529b`. The corrected runtime is +`3224acae383501c5559c43ef521220406f3fee5d`. This compares two implementations +that both answer the ordinary fixtures; main without the feature cannot answer +all reassignment questions and is not a completed equivalent baseline. + +The parser now retains nullable computed-key source ranges on callable facts. +The shared graph owner lookup excludes those ranges from the method while +retaining the method's full source identity. Domain traversal and graph lookup +reuse the same computed-method predicate. Parameter defaults and method bodies +keep method ownership; callbacks nested inside a key keep their own ownership. +The strict worker schema carries the parsed field across processes. Graph +consumers do not reparse the source or special-case assignment owners. + +Assignment collection uses the shared cooperative traversal (a checkpoint every +1,024 visited nodes). Per-projection read/write range indexes reuse retained +nodes and have the immutable file projection's lifetime; no cache was added. + +## Executed correctness checks + +- Three new object/class/enclosing-function computed-key cases fail before the + ownership fix (11 pass / 3 fail) and pass afterward. Parameter defaults and + nested deferred callbacks are covered. The final assignment file has **15 + passing cases**, including an assignment genuinely owned by a callback inside + a computed key. +- Removing the assignment owner filter temporarily makes both deferred-callback + negatives fail (12 pass / 2 fail in that control). Candidate traversal is + explicitly enabled and the graph is checked for erroneous assignment edges. + The production file was restored before further verification. +- Full `npm run check` passed after integrating main `1a48a820` at + `0fbd13b77b15c6906138eaac232456d3a2141ac5`. The initial 14-file run + had **295 pass / 1 fail**: graph construction/scope/destructuring, semantic + analysis, worker schema/transfer/budgets, 24 cancellation cases, and the + production CLI fixture workflow. +- That remaining `projects literal seeds and static object flow` assertion + expects 3 matches but receives 4 on both this feature and exact main + `1a48a820208880c0cc383d196c7ed96d35479412`; the diagnostics match after checkout + path normalization. The historical failed assertion is not counted as passing in that run. +- The CLI test executes analyze plus default and candidate-expanded traces for + declaration, variable, property, compound and computed-key fixtures. It + verifies the intended write and reachable return through the public commands. + Full documentation and test-discovery checks passed. + No target code is executed. MCP runtime delivery and other platforms were + not newly verified for this change. + +## Later integration and verification + +The integration with main `1c07bee5b6262a537758e8bfa235e90d99e35d71` +produces the same tree as actual CI merge `49527572db7de2dfb260344f8c69c74f7b466423`. +Full `npm run check` and **all 296** selected feature/worker/CLI/cancellation +checks pass on that tree. Upstream changes repair the earlier literal-seed +assertion recorded above. + +A cancelled shard also exposed a stale introductory-description assertion in +`analysisViewMcp.test.ts`. It prevented the oversized native discovery workflow +from running. Removing that unrelated catalog/prose precheck preserves the +metadata filters, 6 MiB retained-dossier discovery, paging and advertised-schema +checks; **all 22** tests in the file pass. The unchanged original case fails +before this cleanup. Full static checks pass with the revised test. + +Separately, the CI source lane's 14 failures reproduce on both exact main and +its actual merge (each 167 pass / 14 fail across 181 checks). Twelve shared-child +failures have matching diagnostics; two 2-second timing assertions fail on main +as well. Failure multisets match after normalizing checkout paths and the +elapsed number only; timing measurements themselves are retained, not claimed +equal. These independent main failures are recorded rather than reported green. +Performance costs of this later integration were not remeasured. + +The isolation audit also found that the headroom mock test added in #5660 was +incorrectly placed in the shared services project. It and the existing MCP +cancellation mock now live in `tests/process-global/`, as the established test +policy requires. All original budget, cancellation, retained-fact and subsequent +request assertions remain. The cancellation fixture holds the selected progress +boundary until an ordered client ping acknowledges delivery of its cancellation; +cleanup releases the gate even on failure. The fixture checks that this gate was +actually used. All **18** headroom, SDK-cancellation, progress and project-policy +checks pass. Bypassing the fixture's producer gate makes **both** cancellation +cases fail; restoring it preserves the passing results. An earlier two-file +preload control passed and does not prove import order caused the CI failure. +No CI speedup or new real-provider verification is claimed for these test moves. + +## Completed task comparison + +Each task starts two fresh production CLI processes: analyze the same source +folder, save its complete Evidence, then trace forward from literal `0.001` +with candidate edges enabled. The source oracle expects 1 or 32 reachable +returns. The 32-function fixture contains 96 syntactic assignments and retains +128 assignment relations, 386 nodes, 481 relations and 192 unknowns; its +ambiguity remains visible. All six semantic answers per fixture compare equal, +including identities, provenance contexts, unknowns and coverage. Only the +retained parent Evidence identity/link fields are excluded from equality. + +Three pairs per fixture alternate before/after order. These are fresh process +runs and repeated filesystem-cache runs, not warm analysis-cache hits. OS file +caches were not flushed; the host was shared. Medians: + +| Task | Runtime | CLI stdout token estimate | Elapsed s | Waited family CPU s | Sampled peak family RSS MiB | +| ------------ | ------- | ------------------------: | --------: | ------------------: | --------------------------: | +| 1 function | before | 18,430 | 5.160 | 6.017 | 497.1 | +| 1 function | after | 18,431 | 4.809 | 5.696 | 503.8 | +| 32 functions | before | 588,112 | 8.356 | 7.483 | 557.2 | +| 32 functions | after | 588,112 | 6.277 | 7.784 | 561.6 | + +Output token estimates are effectively unchanged. They use pinned `tokenx +1.3.0` on **both complete CLI responses**, not model usage. The saved query file +repeats complete Evidence on disk: 13,440 / 402,585 additional estimated tokens +for the respective fixtures, unchanged in both runtimes. These are separate +file payload costs, not agent context usage. Known-command runs do not request +a help/schema/catalog. Invocation text, prompts, model framing and rendering +are unmeasured. Estimation runs after the timed task. + +No speedup is claimed: small elapsed ranges are 4.857–6.328 s before and +4.756–6.426 s after; 32-function ranges are 5.719–9.262 s before and +5.871–10.679 s after. The latter CPU median increases by about 0.30 s (4.0%) +and peak-RSS median by about 4.5 MiB (0.8%). This is a correctness and +cooperative-cancellation change, with the measured cost retained explicitly. +These small source fixtures do not establish multi-megabyte application +performance. Later main integration costs were not remeasured. + +## Reproduction and accounting + +Linux, Node 24.22.1 / npm 11.16.0. Node processes use CPU affinity 0–1 and +2 GiB heaps. The observer samples recursive process-family RSS every 50 ms, +with observed 6 GiB RSS, 180-second CPU and 300-second wall stop thresholds. +All 12 tasks exit successfully without a resource stop and preserve source +bytes. Timings include both CLI invocations, JSON parsing, saved Evidence/query +writes and the source oracle. CPU is waited-descendant accounting. RSS sums +shared pages and may miss short-lived or unobserved descendant transitions; +it is not a kernel-enforced memory ceiling. Fixture hashes: + +- `fixtures/small/main.js`: 4e69e2708b212d1659eb0372e4fb5942e00906b2e9b44b3fa3e1e54eddb13ecb; 94 source bytes. +- `fixtures/large/main.js`: faea1107b93c09fd2594049ddac60425d16ad7c2989a912377332fc84373dc68; 4,875 source bytes. + +Small source: `const accel = 0.001; let speed = 6; export function update() { speed = accel; return speed; }` +followed by one newline. The other source starts with `const accel = 0.001;` +and a newline, then joins the following line for `i = 0..31` with newlines, +including a final newline: + +```javascript +export function update{i}() { let speed = 6; const state = { speed: 6 }; speed = accel; state.speed = speed; state.speed += accel; return state.speed; } +``` + +For each pinned checkout, build with the locked toolchain, then invoke +`node scripts/rea.mjs analyze-javascript-application --format json --full-output`. +Save its envelope's `data` as the `application` in a query with +`seed: {kind: "literal", value: 0.001}`, `direction: "forward-influence"`, +`include_ambiguous_dynamic_edges: true`; run +`node scripts/rea.mjs trace-javascript-semantics --format json --full-output`. +The task harness SHA-256 is `93a2b5908013bdaaa39cc1492a61fc159fc71874fc7dad51a74a57f9f5d6f346`. +Raw command receipts, stdout/stderr, equality checks and all 12 measurements +are retained in the local maintenance evidence directory; generated runtime, +large response files and local planning artifacts are not committed. + +## Current-main integration follow-up + +Integration `8f7b403a0b1349700a819eda373d3dcac0e6133f` includes main +`85e38ac199dff15e8c43343d9170db7e0e46d284`. Main independently corrected the +old discovery assertion and added mock-isolation path overrides. The merge +retains main's SDK JSON Schema validator and tool-name discovery check, updates +the isolation policy's two path assertions to the moved fixtures, and removes +now-obsolete project includes/excludes. No test is assigned twice. + +The full static gate passed (63.836 s elapsed, 74.452 s waited-child CPU). +The expanded selection passed 526 checks, including all assignment/ownership, +worker, cancellation, 22 MCP view, headroom and 14 policy cases. The formerly +failing source/CLI baseline cases pass on this newer main integration. +One public assignment CLI case failed at process exit while this operator had +incorrectly allowed the static gate's runtime compilation and built tests to +overlap: its start was 34.664 s before gate completion. Its output did not retain +the subprocess diagnostic, so the specific subprocess failure is unknown; this +is not evidence of a main regression. After build completion, the complete +five-fixture public assignment CLI case passed in 34.220 s (41.792 s CPU). +The fixture now includes subprocess stderr and the fixture name in all exit +assertions. The failed attempt is retained and is not counted as a passing run. + +Earlier head `4abe5509` CI run 40048477593 finished with 11 successful, 4 failed, +19 skipped and 3 cancelled jobs. All 7 failed/cancelled logs, including aggregation +gates, were inspected: 62 visible assertion markers were covered by exact-main +controls; no unknown markers or failures of the repaired fixtures remained. +The exact CI merge `3c8ed5cc` and its main parent `d81d6b67` reproduced both CLI +failures with identical failure-message multisets. The new public assignment CLI +passed on that CI merge. These controls apply to that executed revision; current +head CI must be assessed separately, especially because main has since repaired +several baseline cases. Cancelled jobs do not establish unexecuted coverage. diff --git a/scripts/lib/test-projects.mjs b/scripts/lib/test-projects.mjs index 3275860297..13026a798f 100644 --- a/scripts/lib/test-projects.mjs +++ b/scripts/lib/test-projects.mjs @@ -11,15 +11,12 @@ export const SOURCE_TEST_PROJECTS = [ name: "services", group: "source", include: ["src/application/**/*.test.ts"], - exclude: ["src/application/EvidenceBundleFiles.test.ts"], }, { name: "adapters", group: "source", include: [ "src/*.test.ts", - // This application fixture replaces a module and needs a fresh graph. - "src/application/EvidenceBundleFiles.test.ts", "src/{android,apkSignature,apktool,jeb,adb,artifacts,browser,config,cutter,dotnet,electron,erlang,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,main,native,process,python,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", ], }, @@ -69,7 +66,6 @@ export const BUILT_TEST_PROJECTS = [ name: "mcp-boundary", group: "built", include: ["tests/boundary/mcp/**/*.test.ts"], - exclude: ["tests/boundary/mcp/javascriptAnalysisCancellation.test.ts"], }, { name: "acceptance", @@ -79,10 +75,7 @@ export const BUILT_TEST_PROJECTS = [ { name: "process-global", group: "built", - include: [ - "tests/process-global/**/*.test.ts", - "tests/boundary/mcp/javascriptAnalysisCancellation.test.ts", - ], + include: ["tests/process-global/**/*.test.ts"], }, ]; diff --git a/src/application/javascript/JavaScriptSemanticGraphAssignmentProjection.ts b/src/application/javascript/JavaScriptSemanticGraphAssignmentProjection.ts new file mode 100644 index 0000000000..8c7d48ff47 --- /dev/null +++ b/src/application/javascript/JavaScriptSemanticGraphAssignmentProjection.ts @@ -0,0 +1,63 @@ +import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; +import { createSemanticNodeRangeLookup } from "./JavaScriptSemanticGraphProjection.js"; +import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; +import { + addSemanticGraphRelation, + addSemanticGraphUnknown, +} from "./JavaScriptSemanticGraphConstruction.js"; +import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; + +/** Connect retained reads to writes without claiming assignment values or order. */ +export function* projectSemanticAssignments( + context: SemanticFlowProjectionContext, +): Generator { + const readsWithin = createSemanticNodeRangeLookup(context.readNodes); + const writesWithin = createSemanticNodeRangeLookup(context.writeNodes); + for (const [index, flow] of context.ir.assignmentFlows.entries()) { + if (index % 64 === 0) yield; + const owner = + flow.ownerCallableId === null + ? null + : context.callableNodes.get(flow.ownerCallableId)?.node_id; + const writes = writesWithin(flow.target).filter((node) => + sourceRangesEqual(node.identity.source_range, flow.target), + ); + const reads = readsWithin(flow.source).filter( + (node) => node.function_node_id === owner, + ); + // Compound assignments consume the previous value as well as the RHS. + if (flow.operator !== "=") + reads.push( + ...readsWithin(flow.target).filter((node) => + sourceRangesEqual(node.identity.source_range, flow.target), + ), + ); + for (const write of writes) { + for (const read of reads) + addSemanticGraphRelation(context.state, { + source: read, + target: write, + relation: "defines", + resolution: + sourceRangesEqual(read.identity.source_range, flow.target) || + (flow.sourceIsReference && + sourceRangesEqual(read.identity.source_range, flow.source) && + !["&&=", "||=", "??="].includes(flow.operator)) + ? "resolved" + : "candidate", + properties: { assignment_operator: flow.operator }, + }); + if (reads.length > 0) + addSemanticGraphUnknown(context.state, { + node_id: write.node_id, + family: "data-flow", + relation_kinds: ["defines"], + reason: "unknown-value", + detail: + "Assignment inputs may influence this write; execution order, branch selection and the resulting value are not established.", + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, flow.target), + }); + } + } +} diff --git a/src/application/javascript/JavaScriptSemanticGraphAssignments.test.ts b/src/application/javascript/JavaScriptSemanticGraphAssignments.test.ts new file mode 100644 index 0000000000..ccf33cdff7 --- /dev/null +++ b/src/application/javascript/JavaScriptSemanticGraphAssignments.test.ts @@ -0,0 +1,184 @@ +import { expect, it } from "vitest"; +import { graphForJavaScript } from "../../../tests/fixtures/javascriptSemanticGraph.js"; +import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js"; + +it.each([ + ["binding", "speed = accel; return speed;", "speed"], + ["property", "state.speed = accel; return state.speed;", "read:/speed"], + ["compound", "state.speed += accel; return state.speed;", "read:/speed"], + ["logical", "state.speed ||= accel; return state.speed;", "read:/speed"], + ["nested", "speed = (state.speed = accel); return speed;", "speed"], + ["property input", "speed = input.accel; return speed;", "speed"], +])("traces assignment influence through %s", (_name, body, reached) => { + const graph = graphForJavaScript(` + const accel = 0.001; + const input = { accel: 0.001 }; + let speed = 6; + const state = { speed: 6 }; + export function update() { ${body} } + `); + const trace = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "literal", value: 0.001 }, + direction: "forward-influence", + include_ambiguous_dynamic_edges: true, + }); + expect(trace.nodes.map((node) => node.label)).toContain(reached); + expect(trace.nodes.map((node) => node.label)).toContain("return"); + expect( + trace.relations.some( + (edge) => edge.properties.assignment_operator !== undefined, + ), + ).toBe(true); +}); + +it("does not attribute deferred callback reads to an enclosing assignment", () => { + const graph = graphForJavaScript(` + const accel = 0.001; + let callback; + callback = () => accel; + `); + const trace = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "literal", value: 0.001 }, + direction: "forward-influence", + include_ambiguous_dynamic_edges: true, + }); + expect( + graph.relations.filter( + (edge) => edge.properties.assignment_operator !== undefined, + ), + ).toEqual([]); + expect( + trace.nodes + .filter((node) => node.kind === "binding") + .map((node) => node.label), + ).not.toContain("callback"); +}); + +it("retains both compound operands without assigning the RHS value to the destination", () => { + const graph = graphForJavaScript(` + const accel = 0.001; + let speed = 6; + speed += accel; + `); + const edges = graph.relations.filter( + (edge) => edge.properties.assignment_operator === "+=", + ); + expect(edges).toHaveLength(2); + expect( + edges + .map( + (edge) => + graph.nodes.find((node) => node.node_id === edge.source_node_id) + ?.label, + ) + .sort(), + ).toEqual(["accel", "speed"]); + const speed = graph.nodes.find( + (node) => node.kind === "binding" && node.label === "speed", + ); + expect( + graph.unknowns.some( + (item) => + item.node_id === speed?.node_id && item.reason === "unknown-value", + ), + ).toBe(true); +}); + +it("reaches a property write by default and keeps uncertain later reads opt-in", () => { + const graph = graphForJavaScript( + "const accel = 0.001; const state = {speed: 6}; function update() { state.speed += accel; return state.speed; }", + ); + const trace = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "literal", value: 0.001 }, + direction: "forward-influence", + }); + expect( + trace.nodes.some( + (node) => node.kind === "property-slot" && node.label === "speed", + ), + ).toBe(true); + expect(trace.nodes.map((node) => node.label)).not.toContain("read:/speed"); + expect(trace.unknowns.length).toBeGreaterThan(0); +}); + +it.each([ + [ + "object", + "const obj = { [speed = accel]() {} }; export function current() { return speed; }", + ], + [ + "class", + "class Widget { [speed = accel]() {} } export function current() { return speed; }", + ], + [ + "enclosing function", + "export function update() { const obj = { [speed = accel]() {} }; return speed; }", + ], +])( + "retains assignments in a computed method key in its %s owner", + (_kind, body) => { + const graph = graphForJavaScript( + `const accel = 0.001; let speed = 6; ${body}`, + ); + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + const assignment = graph.relations.find( + (edge) => + edge.properties.assignment_operator === "=" && + nodes.get(edge.source_node_id)?.label === "accel" && + nodes.get(edge.target_node_id)?.label === "speed", + ); + expect(assignment).toBeDefined(); + const trace = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "literal", value: 0.001 }, + direction: "forward-influence", + include_ambiguous_dynamic_edges: true, + }); + expect(trace.nodes.map((node) => node.label)).toContain("return"); + }, +); + +it("keeps assignments in computed methods' parameter defaults in the method", () => { + const graph = graphForJavaScript(` + const accel = 0.001; let speed = 6; + const obj = { ["tick"](value = (speed = accel)) { return speed; } }; + `); + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + const assignment = graph.relations.find( + (edge) => edge.properties.assignment_operator === "=", + ); + expect(assignment).toBeDefined(); + const source = nodes.get(assignment!.source_node_id); + const target = nodes.get(assignment!.target_node_id); + expect(source?.function_node_id).toBeTypeOf("string"); + expect(source?.function_node_id).toBe(target?.function_node_id); +}); + +it("does not treat a callback body nested in a computed key as a key-time assignment input", () => { + const graph = graphForJavaScript(` + const accel = 0.001; let callback; + const obj = { [(callback = () => accel)]() {} }; + `); + expect( + graph.relations.filter( + (edge) => edge.properties.assignment_operator !== undefined, + ), + ).toEqual([]); +}); + +it("retains an assignment owned by a callback inside a computed key", () => { + const graph = graphForJavaScript(` + const accel = 0.001; let speed = 6; + const obj = { [(() => { speed = accel; return "tick"; })()]() {} }; + `); + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + const assignment = graph.relations.find( + (edge) => edge.properties.assignment_operator === "=", + ); + expect(assignment).toBeDefined(); + const source = nodes.get(assignment!.source_node_id); + const target = nodes.get(assignment!.target_node_id); + expect(source?.label).toBe("accel"); + expect(target?.label).toBe("speed"); + expect(source?.function_node_id).toBeTypeOf("string"); + expect(source?.function_node_id).toBe(target?.function_node_id); +}); diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index 115f32a75d..94fe00bad3 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -34,6 +34,7 @@ import { projectSemanticRequests, } from "./JavaScriptSemanticGraphDataProjection.js"; import { projectSemanticResources } from "./JavaScriptSemanticGraphResourceProjection.js"; +import { projectSemanticAssignments } from "./JavaScriptSemanticGraphAssignmentProjection.js"; import { projectSemanticObjects } from "./JavaScriptSemanticGraphObjectProjection.js"; import { projectSemanticValues } from "./JavaScriptSemanticGraphValueProjection.js"; import { @@ -712,6 +713,8 @@ function* projectFileSteps( returnSiteNodes, argumentNodes: new Map(), referenceNodes: [], + readNodes: [], + writeNodes: [], callResolutions: new Map( ir.callSites.map((call) => [ call.callSiteId, @@ -858,7 +861,11 @@ function* projectDefinitionsAndReferences( reference.bindingId === null ? undefined : context.bindingNodes.get(reference.bindingId); - if (expression !== null) context.referenceNodes.push(expression); + if (expression !== null) { + context.referenceNodes.push(expression); + if (reference.role === "read") context.readNodes.push(expression); + if (reference.role === "write") context.writeNodes.push(expression); + } if (binding === undefined || expression === null) continue; if (reference.role === "read") addRelation(context.state, { @@ -977,6 +984,7 @@ const FILE_PROJECTION_PASSES: readonly (( ) => Iterator | void)[] = [ projectSemanticValues, projectSemanticObjects, + projectSemanticAssignments, projectCalls, projectSemanticPromises, projectSemanticEvents, diff --git a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts index cc28a23a52..619670ac9b 100644 --- a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts @@ -29,6 +29,8 @@ export interface SemanticFlowProjectionContext { readonly callableNodes: ReadonlyMap; readonly callSiteNodes: ReadonlyMap; readonly returnSiteNodes: ReadonlyMap; + readonly readNodes: JavaScriptSemanticGraphNode[]; + readonly writeNodes: JavaScriptSemanticGraphNode[]; readonly referenceNodes: readonly JavaScriptSemanticGraphNode[]; readonly referenceNodesWithin: ( range: JavaScriptSourceRange, diff --git a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts index ad5f25cb66..d8cdb54ec9 100644 --- a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts @@ -33,6 +33,10 @@ export function* projectSemanticObjects( operation, target.slotApplicability, ); + if (occurrence !== null) { + if (operation.kind === "read") context.readNodes.push(occurrence); + if (operation.kind === "write") context.writeNodes.push(occurrence); + } const ends = objectRelationEnds(context, operation, occurrence, target); const resolved = operation.resolution === "complete" && diff --git a/src/application/javascript/JavaScriptSemanticGraphProjection.ts b/src/application/javascript/JavaScriptSemanticGraphProjection.ts index a3612e3969..a46182fdd5 100644 --- a/src/application/javascript/JavaScriptSemanticGraphProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphProjection.ts @@ -17,6 +17,7 @@ interface SourceInterval { interface CallableInterval extends SourceInterval { readonly callableId: string; readonly priority: string; + readonly computedKeyRange: JavaScriptSourceRange | null; } interface SourceIntervalTree { @@ -40,6 +41,7 @@ export const createSemanticCallableOwnerLookup = ( .map((callable, order): CallableInterval => ({ callableId: callable.callableId, range: callable.location, + computedKeyRange: callable.computedKeyLocation, order, priority: positionKey(callable.location), })) @@ -65,7 +67,11 @@ export const createSemanticCallableOwnerLookup = ( ) continue; const candidate = current.entry; - if (javascriptSourceRangeContains(candidate.range, location)) { + if ( + javascriptSourceRangeContains(candidate.range, location) && + (candidate.computedKeyRange === null || + !javascriptSourceRangeContains(candidate.computedKeyRange, location)) + ) { const node = nodes.get(candidate.callableId); const rank = best === undefined diff --git a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts index 84605b6295..4b121309c1 100644 --- a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts @@ -1,4 +1,4 @@ -import type * as t from "@babel/types"; +import * as t from "@babel/types"; import { collectJavaScriptSemanticAsyncEffects } from "./javascriptSemanticAsyncEffects.js"; import { collectJavaScriptSemanticCalls } from "./javascriptSemanticCalls.js"; @@ -15,8 +15,14 @@ import { collectJavaScriptSemanticObjects } from "./javascriptSemanticObjects.js import { collectJavaScriptSemanticResources } from "./javascriptSemanticResources.js"; import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; +import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; +import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; +import { range } from "./javascriptStaticAnalysisHelpers.js"; +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; + type DerivedSemanticAnalysis = Pick< JavaScriptSemanticIr, + | "assignmentFlows" | "argumentFlows" | "callResultFlows" | "callReturnFlows" @@ -86,6 +92,10 @@ export function* collectJavaScriptDerivedSemanticsSteps( callables, ); yield; + const assignmentFlows = yield* collectAssignmentFlowsSteps( + program, + callables, + ); const functionFingerprints = yield* collectJavaScriptSemanticFingerprintsSteps({ state, @@ -101,6 +111,7 @@ export function* collectJavaScriptDerivedSemanticsSteps( parserPartial, }); return { + assignmentFlows, ...calls, promiseOperations, ...asyncEffects, @@ -111,3 +122,34 @@ export function* collectJavaScriptDerivedSemanticsSteps( functionFingerprints, }; } + +/** Keep assignment syntax in the IR so graph consumers never reparse source. */ +function* collectAssignmentFlowsSteps( + program: t.Program, + callables: readonly JavaScriptSemanticCallable[], +): Generator { + const flows: JavaScriptSemanticIr["assignmentFlows"][number][] = []; + const stack = createSemanticCallableIdStack( + new Set(callables.map((item) => item.callableId)), + ); + yield* traverseJavaScriptAstSteps(program, { + enter: (node, parent) => { + stack.enter(node, parent); + if (!t.isAssignmentExpression(node)) return; + const target = unwrapJavaScriptExpression(node.left).node; + // Destructuring has its own property-selection owner. + if (!t.isIdentifier(target) && !t.isMemberExpression(target)) return; + const source = unwrapJavaScriptExpression(node.right).node; + flows.push({ + target: range(target), + source: range(source), + sourceIsReference: + t.isIdentifier(source) || t.isMemberExpression(source), + operator: node.operator, + ownerCallableId: stack.callables.at(-1) ?? null, + }); + }, + exit: (node) => stack.exit(node), + }); + return flows; +} diff --git a/src/domain/javascript/javascriptSemanticIr.ts b/src/domain/javascript/javascriptSemanticIr.ts index f153ea39ad..63a2fba66b 100644 --- a/src/domain/javascript/javascriptSemanticIr.ts +++ b/src/domain/javascript/javascriptSemanticIr.ts @@ -100,6 +100,8 @@ export interface JavaScriptSemanticCallable { readonly containerScopeId: string; readonly bodyScopeId: string | null; readonly location: JavaScriptSourceRange; + /** Evaluated by the enclosing callable, before this method is invoked. */ + readonly computedKeyLocation: JavaScriptSourceRange | null; readonly returnSites: readonly JavaScriptSemanticReturnSite[]; readonly returnCoverage: JavaScriptSemanticReturnCoverage; } @@ -424,6 +426,15 @@ export interface JavaScriptSemanticModuleLink { readonly location: JavaScriptSourceRange; } +/** Syntactic influence into one simple assignment target; no value evaluation. */ +export interface JavaScriptSemanticAssignmentFlow { + readonly target: JavaScriptSourceRange; + readonly source: JavaScriptSourceRange; + readonly sourceIsReference: boolean; + readonly operator: string; + readonly ownerCallableId: string | null; +} + /** Provider-neutral JavaScript semantic IR. */ export interface JavaScriptSemanticIr { readonly schema: "JavaScriptSemanticIR"; @@ -446,6 +457,7 @@ export interface JavaScriptSemanticIr { readonly requestOperations: readonly JavaScriptSemanticRequestOperation[]; readonly boundaryOperations: readonly JavaScriptSemanticBoundaryOperation[]; readonly resourceOperations: readonly JavaScriptSemanticResourceOperation[]; + readonly assignmentFlows: readonly JavaScriptSemanticAssignmentFlow[]; readonly objectOperations: readonly JavaScriptSemanticObjectOperation[]; readonly functionFingerprints: readonly JavaScriptSemanticFunctionFingerprint[]; readonly frontiers: readonly JavaScriptSemanticFrontier[]; @@ -475,6 +487,7 @@ export const failedJavaScriptSemanticIr = (): JavaScriptSemanticIr => ({ requestOperations: [], boundaryOperations: [], resourceOperations: [], + assignmentFlows: [], objectOperations: [], functionFingerprints: [], frontiers: [], diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index da6264f77c..0a27bacf24 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -45,6 +45,7 @@ import { type CommonJsExportSite, } from "./javascriptCommonJsExportReceivers.js"; import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; +import { semanticComputedMethodKey } from "./javascriptSemanticEvaluationOrder.js"; interface CollectCallableInput { readonly node: t.Node; @@ -71,6 +72,12 @@ export const collectSemanticCallable = (input: CollectCallableInput): void => { ? null : bodyScope.scopeId, location: range(node), + computedKeyLocation: + "key" in node && + t.isNode(node.key) && + semanticComputedMethodKey(node, node.key) + ? range(node.key) + : null, returnSites: [], returnCoverage: { status: "partial", diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 6d60d651a4..a647147225 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -629,6 +629,7 @@ const callable = z.strictObject({ containerScopeId: z.string(), bodyScopeId: z.string().nullable(), location: range, + computedKeyLocation: range.nullable(), returnSites: z.array( z.strictObject({ returnSiteId: z.string(), diff --git a/tests/boundary/cli/javascriptAssignmentInfluence.test.ts b/tests/boundary/cli/javascriptAssignmentInfluence.test.ts new file mode 100644 index 0000000000..e264777c6c --- /dev/null +++ b/tests/boundary/cli/javascriptAssignmentInfluence.test.ts @@ -0,0 +1,87 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; +import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "traces reassignment inputs through the public analysis and trace commands", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-assignment-influence-"); + for (const [name, body, label] of [ + ["declaration", "const next = accel; return next;", "next"], + ["variable", "speed = accel; return speed;", "speed"], + ["property", "state.speed = accel; return state.speed;", "speed"], + ["compound", "state.speed += accel; return state.speed;", "speed"], + [ + "computed-key", + "const obj = { [speed = accel]() {} }; return speed;", + "speed", + ], + ] as const) { + const directory = join(root, name); + await mkdir(directory); + await writeFile( + join(directory, "main.js"), + `const accel = 0.001; let speed = 6; const state = { speed: 6 }; export function update() { ${body} }`, + ); + // The compiled CLI prepares Darwin ownership helpers; hosts that select + // a matching SDK explicitly must pass that selection through, as the + // other compiled-CLI lifecycle tests do. + const environment: { readonly SDKROOT: string } | Record = + process.env.SDKROOT === undefined + ? {} + : { SDKROOT: process.env.SDKROOT }; + const analyzed = await cli.run({ + arguments: ["analyze-javascript-application", directory, "--json"], + environment, + }); + expect(analyzed.exitCode, `${name} analyzed: ${analyzed.stderr}`).toBe(0); + const queryFile = join(root, `${name}.json`); + const query = { + application: analyzed.json, + query: { + seed: { kind: "literal", value: 0.001 }, + direction: "forward-influence", + }, + }; + await writeFile(queryFile, JSON.stringify(query)); + const traced = await cli.run({ + arguments: ["trace-javascript-semantics", queryFile, "--json"], + }); + expect(traced.exitCode, `${name} traced: ${traced.stderr}`).toBe(0); + const { normalized_result: result } = z + .object({ normalized_result: javaScriptSemanticTraceResultSchema }) + .parse(traced.json); + expect( + result.nodes.some( + (node) => + ["binding", "property-slot"].includes(node.kind) && + node.label === label, + ), + ).toBe(true); + // Later property reads remain uncertain after a mutable write. Follow + // candidates explicitly rather than upgrading their pre-existing resolution. + await writeFile( + queryFile, + JSON.stringify({ + ...query, + query: { ...query.query, include_ambiguous_dynamic_edges: true }, + }), + ); + const expanded = await cli.run({ + arguments: ["trace-javascript-semantics", queryFile, "--json"], + }); + expect(expanded.exitCode, `${name} expanded: ${expanded.stderr}`).toBe(0); + const expandedResult = z + .object({ normalized_result: javaScriptSemanticTraceResultSchema }) + .parse(expanded.json).normalized_result; + expect(expandedResult.nodes.map((node) => node.label)).toContain( + "return", + ); + } + }, + 120_000, +); diff --git a/tests/boundary/filesystem/ciTestProjects.test.ts b/tests/boundary/filesystem/ciTestProjects.test.ts index fab5017bd5..5641f700de 100644 --- a/tests/boundary/filesystem/ciTestProjects.test.ts +++ b/tests/boundary/filesystem/ciTestProjects.test.ts @@ -144,18 +144,19 @@ describe("canonical test-project ownership", () => { describe("test module isolation", () => { it.each([ - ["src/application/EvidenceBundleFiles.test.ts", "adapters", "source"], [ - "tests/boundary/mcp/javascriptAnalysisCancellation.test.ts", + "tests/process-global/evidenceBundleHeadroom.test.ts", "process-global", "built", ], - ])( - "keeps %s isolated without changing its preparation group", - (path, name, group) => { - expect(projectForTest(path)).toMatchObject({ name, group }); - }, - ); + [ + "tests/process-global/javascriptAnalysisCancellation.test.ts", + "process-global", + "built", + ], + ])("keeps %s in its isolated preparation group", (path, name, group) => { + expect(projectForTest(path)).toMatchObject({ name, group }); + }); it("keeps module mocks out of projects that share one module graph", async () => { // A mock registered after another file in the same worker loaded the diff --git a/src/application/EvidenceBundleFiles.test.ts b/tests/process-global/evidenceBundleHeadroom.test.ts similarity index 78% rename from src/application/EvidenceBundleFiles.test.ts rename to tests/process-global/evidenceBundleHeadroom.test.ts index 26e7ae1d2c..e94853cee0 100644 --- a/src/application/EvidenceBundleFiles.test.ts +++ b/tests/process-global/evidenceBundleHeadroom.test.ts @@ -3,11 +3,11 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; -import { createEvidence } from "../domain/evidence.js"; -import { createEvidenceBundle } from "../domain/evidenceBundle.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { createEvidence } from "../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../src/domain/evidenceBundle.js"; -const HEADROOM_MODULE = "./JsonAuthenticationHeadroom.js"; +const HEADROOM_MODULE = "../../src/application/JsonAuthenticationHeadroom.js"; afterEach(() => { vi.doUnmock(HEADROOM_MODULE); @@ -19,7 +19,9 @@ const readerUnderStarvedHeap = async () => { vi.resetModules(); vi.doMock(HEADROOM_MODULE, async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal< + typeof import("../../src/application/JsonAuthenticationHeadroom.js") + >(); return { ...actual, requireAuthenticationHeadroom: ( @@ -32,7 +34,7 @@ const readerUnderStarvedHeap = async () => { }), }; }); - return import("./EvidenceBundleFiles.js"); + return import("../../src/application/EvidenceBundleFiles.js"); }; describe("evidence bundle import headroom", () => { diff --git a/tests/boundary/mcp/javascriptAnalysisCancellation.test.ts b/tests/process-global/javascriptAnalysisCancellation.test.ts similarity index 70% rename from tests/boundary/mcp/javascriptAnalysisCancellation.test.ts rename to tests/process-global/javascriptAnalysisCancellation.test.ts index 12e6c3bb90..c2156514e7 100644 --- a/tests/boundary/mcp/javascriptAnalysisCancellation.test.ts +++ b/tests/process-global/javascriptAnalysisCancellation.test.ts @@ -4,33 +4,80 @@ import { join } from "node:path"; import pino from "pino"; import { expect, it, onTestFinished, vi } from "vitest"; import { z } from "zod"; -import { createEvidence } from "../../../src/domain/evidence.js"; -import { createServer } from "../../../src/server/createServer.js"; +import { createEvidence } from "../../src/domain/evidence.js"; +import { createServer } from "../../src/server/createServer.js"; import { createDeferred, createTestBinarySession, -} from "../../fixtures/binarySession.js"; -import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +} from "../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import type { ProgressUpdate } from "../../src/application/ProgressReporter.js"; + +const progressBoundary = vi.hoisted(() => ({ + observe: undefined as ((update: ProgressUpdate) => Promise) | undefined, +})); // Observe each application boundary deterministically; transport rate limiting // is covered by reporter tests and must not select the cancellation site. -vi.mock("../../../src/application/ProgressReporter.js", async (original) => { +vi.mock("../../src/application/ProgressReporter.js", async (original) => { const actual = await original< - typeof import("../../../src/application/ProgressReporter.js") + typeof import("../../src/application/ProgressReporter.js") >(); return { ...actual, createProgressReporter: ( ...args: Parameters ) => - actual.createProgressReporter(args[0], { - ...args[1], - minimumIntervalMs: 0, - }), + actual.createProgressReporter( + async (update) => { + await args[0](update); + await progressBoundary.observe?.(update); + }, + { + ...args[1], + minimumIntervalMs: 0, + }, + ), }; }); +const createCancellationBoundary = (phase: string, message: string) => { + const released = createDeferred(); + let heldAtBoundary = false; + let acknowledgedCancellation = false; + let acknowledgementError: unknown; + const release = () => released.resolve(undefined); + return { + release, + async observe(update: ProgressUpdate) { + if (update.phase === phase && update.message.includes(message)) { + heldAtBoundary = true; + await released.promise; + } + }, + acknowledge(client: Client) { + // Ordered in-memory messages make the ping acknowledge the cancellation + // before the producer crosses the held boundary. + void client.ping().then( + () => { + acknowledgedCancellation = true; + release(); + }, + (error: unknown) => { + acknowledgementError = error; + release(); + }, + ); + }, + expectAcknowledged() { + expect(heldAtBoundary).toBe(true); + expect(acknowledgedCancellation).toBe(true); + expect(acknowledgementError).toBeUndefined(); + }, + }; +}; + it.each([ { phase: "parse_javascript_source", message: "second.js", parsedFiles: 1 }, { @@ -41,6 +88,8 @@ it.each([ ])( "retains completed facts and prior Evidence after SDK cancellation during $phase, then accepts subsequent work", async ({ phase, message, parsedFiles }) => { + const boundary = createCancellationBoundary(phase, message); + progressBoundary.observe = boundary.observe; const root = await createTestTempDirectory("rea-mcp-js-cancellation-"); await writeFile(join(root, "main.js"), "export const observed = 1;\n"); await writeFile(join(root, "second.js"), "export const pending = 2;\n"); @@ -86,6 +135,8 @@ it.each([ version: "1", }); onTestFinished(async () => { + boundary.release(); + progressBoundary.observe = undefined; await client.close(); await server.close(); await session.close(); @@ -114,6 +165,7 @@ it.each([ return; requested = true; controller.abort(new Error("SDK cancellation verification")); + boundary.acknowledge(client); }, }, ); @@ -121,6 +173,7 @@ it.each([ expect(requested).toBe(true); // A rejected client promise alone does not prove that the server stopped. expect(await completed.promise).toBe("error"); + boundary.expectAcknowledged(); await client.ping(); const bundle = await client.callTool({ name: "get_evidence_bundle",