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>
This commit is contained in:
Iven Koo
2026-10-24 10:04:38 +08:00
committed by GitHub
parent 2968d1e932
commit 9df94a62cd
17 changed files with 702 additions and 35 deletions
+8
View File
@@ -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
@@ -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 <same-folder> --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 <query.json> --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.
+1 -8
View File
@@ -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"],
},
];
@@ -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<void, void> {
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),
});
}
}
}
@@ -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);
});
@@ -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, void> | void)[] = [
projectSemanticValues,
projectSemanticObjects,
projectSemanticAssignments,
projectCalls,
projectSemanticPromises,
projectSemanticEvents,
@@ -29,6 +29,8 @@ export interface SemanticFlowProjectionContext {
readonly callableNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly callSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly returnSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly readNodes: JavaScriptSemanticGraphNode[];
readonly writeNodes: JavaScriptSemanticGraphNode[];
readonly referenceNodes: readonly JavaScriptSemanticGraphNode[];
readonly referenceNodesWithin: (
range: JavaScriptSourceRange,
@@ -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" &&
@@ -17,6 +17,7 @@ interface SourceInterval {
interface CallableInterval extends SourceInterval {
readonly callableId: string;
readonly priority: string;
readonly computedKeyRange: JavaScriptSourceRange | null;
}
interface SourceIntervalTree<Interval extends SourceInterval> {
@@ -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
@@ -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<void, JavaScriptSemanticIr["assignmentFlows"]> {
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;
}
@@ -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: [],
@@ -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",
@@ -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(),
@@ -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<string, never> =
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,
);
@@ -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
@@ -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<typeof import("./JsonAuthenticationHeadroom.js")>();
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", () => {
@@ -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<void>) | 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<typeof actual.createProgressReporter>
) =>
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<void>();
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",