mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+8
-6
@@ -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", () => {
|
||||
+63
-10
@@ -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",
|
||||
Reference in New Issue
Block a user