diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 5a873429e..1cf53fb31 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -565,9 +565,21 @@ const bindAssignment = ( else if (t.isForOfStatement(node) || t.isForInStatement(node)) for (const pattern of t.isVariableDeclaration(node.left) ? node.left.declarations.map(({ id }) => id) - : [node.left]) + : [node.left]) { for (const identifier of assignedPatternIdentifiers(pattern)) addAssignment(identifier, node, scope, state); + if (t.isForOfStatement(node)) + // Keep the loop value unknown, but preserve references yielded into + // each bound slot so writes/escapes can reach their iterable origins. + bindPattern({ + pattern, + initializer: node, + scope, + state, + mutable: true, + referenceOnly: true, + }); + } }; const assignedPatternIdentifiers = ( @@ -780,7 +792,11 @@ const bindPattern = (input: BindPatternInput): void => { } if (t.isIdentifier(pattern)) { if (referenceOnly) { - const binding = scope.bindings.get(pattern.name); + const binding = resolveSemanticBindingFromScope( + scope, + pattern.name, + state, + ); if (binding !== undefined && initializer !== null) binding.referenceInitializers.push({ node: initializer, diff --git a/src/domain/javascript/javascriptSemanticIterationSources.ts b/src/domain/javascript/javascriptSemanticIterationSources.ts index 1b9e7d714..c83eb94ad 100644 --- a/src/domain/javascript/javascriptSemanticIterationSources.ts +++ b/src/domain/javascript/javascriptSemanticIterationSources.ts @@ -8,8 +8,9 @@ interface IterationSourceValue { readonly projection: readonly (string | number | null)[]; } -/** An implicit iterator receiver, with guards for destructuring defaults. */ +/** An iterator receiver or yielded reference store, with default guards. */ export interface SemanticIterationSource extends IterationSourceValue { + readonly kind: "iteration" | "reference-store"; readonly mutation: t.Node; readonly fallbackSources?: readonly IterationSourceValue[]; readonly requiredSources?: readonly IterationSourceValue[]; @@ -30,8 +31,10 @@ const add = ( source: IterationSourceValue, mutation: t.Node, guards: SourceGuards, + kind: SemanticIterationSource["kind"] = "iteration", ): void => { sources.push({ + kind, node: source.node, projection: source.projection, mutation, @@ -66,6 +69,13 @@ const visit = ( guards: SourceGuards = {}, ): void => { const { sources, mutation } = context; + if (t.isForOfStatement(mutation) && t.isMemberExpression(pattern)) { + // Storing a yielded reference in a property has no lexical loop binding. + // Preserve its escape rather than assuming later writes cannot reach it. + if (source !== undefined) + add(sources, source, mutation, guards, "reference-store"); + return; + } if (t.isTSParameterProperty(pattern)) { visit(context, pattern.parameter, source, guards); return; diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 21a6aded0..4e4ea7b8b 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -324,7 +324,7 @@ export const collectSemanticMemberMutations = ( bindings: new Set(), effect: "escape", originAt: source.mutation, - iterationOnly: true, + iterationOnly: source.kind === "iteration", }); if (t.isAssignmentExpression(node)) markTarget(node.left, node); else if (t.isUpdateExpression(node)) markTarget(node.argument, node); @@ -369,6 +369,19 @@ export const collectSemanticMemberMutations = ( const { initializer } = reference; if (referenceSourcesInactive(initializer, state)) pendingReferences.push(reference); + else if (t.isForOfStatement(initializer.node)) + deferIterable({ + node: initializer.node.right, + projection: [], + path: [null, ...reference.path], + fallbackPath: [], + bindings: reference.bindings, + effect: reference.effect, + originAt: initializer.node, + ...(reference.mutation === undefined + ? {} + : { mutation: reference.mutation }), + }); else if ( initializer.copyKind === "array-rest" && initializer.copyProjectionOffset !== undefined diff --git a/tests/acceptance/applications/javascriptIterationAliases.test.ts b/tests/acceptance/applications/javascriptIterationAliases.test.ts new file mode 100644 index 000000000..103995480 --- /dev/null +++ b/tests/acceptance/applications/javascriptIterationAliases.test.ts @@ -0,0 +1,145 @@ +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { projectedExportReturnShapesSchema } from "../../../src/domain/javascript/javascriptExportShapeComparisonSchemas.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const source = [ + "export function inline() { const shared = { x: 1, keep: 7 }; for (const t of [shared]) t.x = 2; return { x: shared.x, keep: shared.keep }; }", + "export function named() { const shared = { x: 1 }; const arr = [shared]; for (const t of arr) t.x = 2; return shared.x; }", + "export function declaredVar() { const shared = { x: 1 }; for (var t of [shared]) t.x = 2; return shared.x; }", + "export function assigned() { const shared = { x: 1 }; let t; for (t of [shared]) t.x = 2; return shared.x; }", + "export function assignedDestructured() { const shared = { x: 1 }; let t; for ({child: t} of [{child: shared}]) t.x = 2; return shared.x; }", + "export function declaredVarDestructured() { const shared = { x: 1 }; for (var {child: t} of [{child: shared}]) t.x = 2; return shared.x; }", + "export function memberTarget() { const shared = { x: 1 }; const holder = {}; for (holder.item of [shared]) holder.item.x = 2; return shared.x; }", + "export function memberPattern() { const shared = { x: 1 }; const holder = {}; for ({child: holder.item} of [{child: shared}]) holder.item.x = 2; return shared.x; }", + "export function destructured() { const shared = { x: 1 }; for (const {child: t} of [{child: shared}]) t.x = 2; return shared.x; }", + "export function arrayDestructured() { const shared = { x: 1 }; for (const [t] of [[shared]]) t.x = 2; return shared.x; }", + "export function spread() { const shared = { x: 1 }; const arr = [shared]; for (const t of [...arr]) t.x = 2; return shared.x; }", + "export function objectRestChild() { const shared = { x: 1 }; for (const {...t} of [{child: shared}]) t.child.x = 2; return shared.x; }", + "export function arrayRestChild() { const shared = { x: 1 }; for (const [...t] of [[shared]]) t[0].x = 2; return shared.x; }", + "export function defaultChild() { const shared = { x: 1 }; for (const {child: t = shared} of [{}]) t.x = 2; return shared.x; }", + "export function updated() { const shared = { x: 1 }; for (const t of [shared]) t.x++; return shared.x; }", + "export function deleted() { const shared = { x: 1 }; for (const t of [shared]) delete t.x; return shared.x; }", + "export function escaped() { const shared = { x: 1 }; for (const t of [shared]) consume(t); return shared.x; }", + "export async function awaited() { const shared = { x: 1 }; for await (const t of [shared]) t.x = 2; return shared.x; }", + "export function indexed() { const shared = { x: 1 }; const arr = [shared]; for (let i = 0; i < arr.length; i++) { const t = arr[i]; t.x = 2; } return shared.x; }", + "export function copyWrite() { const shared = { x: 1 }; for (const {...t} of [shared]) t.x = 2; return shared.x; }", + "export function noWrite() { const shared = { x: 1 }; for (const t of [shared]) { const read = t.x; } return shared.x; }", + "export function empty() { const shared = { x: 1 }; for (const t of []) t.x = 2; return shared.x; }", + "export function keys() { const shared = { x: 1 }; for (const key in [shared]) { const read = key; } return shared.x; }", + "export function shadowed() { const t = { x: 1 }; const shared = { x: 3 }; for (const t of [shared]) t.x = 2; return t.x; }", +].join("\n"); + +const assertReturns = (value: unknown): void => { + const analysis = javascriptApplicationAnalysisResultSchema.parse(value); + const projections = analysis.graph.nodes + .flatMap(({ observations }) => observations) + .filter( + ({ properties }) => properties.semantic_role === "export-return-shapes", + ) + .map(({ properties }) => + projectedExportReturnShapesSchema.parse(properties), + ); + const fields = (name: string) => { + const projection = projections.find( + ({ exported_name }) => exported_name === name, + ); + if (projection === undefined) throw new Error(`Missing export ${name}`); + expect(projection.return_shape_coverage).toMatchObject({ + status: "complete", + projection_complete: true, + }); + return projection.static_return_shapes.flatMap(({ fields }) => fields); + }; + expect(fields("inline")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "/x", state: "unknown" }), + expect.objectContaining({ path: "/keep", state: "literal", value: 7 }), + ]), + ); + for (const name of [ + "named", + "declaredVar", + "assigned", + "assignedDestructured", + "declaredVarDestructured", + "memberTarget", + "memberPattern", + "destructured", + "arrayDestructured", + "spread", + "objectRestChild", + "arrayRestChild", + "defaultChild", + "updated", + "deleted", + "escaped", + "awaited", + "indexed", + ]) + expect(fields(name), name).toContainEqual( + expect.objectContaining({ path: "", state: "unknown" }), + ); + for (const name of ["copyWrite", "noWrite", "empty", "keys", "shadowed"]) + expect(fields(name), name).toContainEqual( + expect.objectContaining({ path: "", state: "literal", value: 1 }), + ); +}; + +cliTest( + "preserves yielded-object mutation uncertainty through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-iteration-aliases-"); + await writeFile(join(root, "app.js"), source); + const cliResponse = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--artifact-format", + "directory", + "--json", + ], + }); + expect(cliResponse.exitCode).toBe(0); + const contract = toolContract("analyze_javascript_application"); + const cliEvidence = contract.outputSchema.parse(cliResponse.json); + assertReturns(cliEvidence.normalized_result); + const client = new Client({ name: "iteration-aliases-e2e", version: "1" }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + env: { PATH: process.env.PATH ?? "" }, + stderr: "pipe", + }); + transport.stderr?.on("data", () => undefined); + try { + await client.connect(transport); + const response = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: root, format: "directory" }, + }); + expect(response.isError).not.toBe(true); + const evidence = contract.outputSchema.parse(response.structuredContent); + expect(evidence.evidence_id).toBe(cliEvidence.evidence_id); + assertReturns(evidence.normalized_result); + const text = response.content.find((item) => item.type === "text"); + if (text?.type !== "text") throw new Error("Missing MCP text result"); + assertReturns( + contract.outputSchema.parse(JSON.parse(text.text)).normalized_result, + ); + await client.ping(); + } finally { + await client.close(); + await transport.close(); + } + }, + 120_000, +);