diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.test.ts b/src/domain/javascript/javascriptSemanticChildProcesses.test.ts new file mode 100644 index 000000000..6e0c64aec --- /dev/null +++ b/src/domain/javascript/javascriptSemanticChildProcesses.test.ts @@ -0,0 +1,263 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const analyze = (source: string) => + analyzeJavaScriptSemantics( + `import { spawn } from 'node:child_process';\n${source}`, + ); + +it("keeps interactions before replacement resolved and later ownership uncertain", () => { + const ir = analyze(` + let child = spawn('old'); + child.kill(); + child = { kill() {}, on() {} }; + child.kill(); + child.on('exit', done); + const stable = spawn('stable'); stable.kill(); + `); + const [old, stable] = ir.childProcessSpawns; + expect(ir.childProcessInteractions).toMatchObject([ + { linkedProcessIds: [old!.processId], resolution: "complete" }, + { linkedProcessIds: [old!.processId], resolution: "partial" }, + { linkedProcessIds: [old!.processId], resolution: "partial" }, + { linkedProcessIds: [stable!.processId], resolution: "complete" }, + ]); +}); + +it("selects a new spawned child at each straight-line receiver read", () => { + const ir = analyze(` + let child = spawn('old'); child.kill(); + child = spawn('new'); child.once('exit', done); child.kill(); + `); + const [old, replacement] = ir.childProcessSpawns; + expect(ir.childProcessInteractions).toMatchObject([ + { linkedProcessIds: [old!.processId], resolution: "complete" }, + { linkedProcessIds: [replacement!.processId], resolution: "complete" }, + { linkedProcessIds: [replacement!.processId], resolution: "complete" }, + ]); +}); + +it("selects child ownership before and after a replacement in one expression", () => { + const ir = analyze(` + let child = spawn('old'); + (child.kill(), child = spawn('new')); + child.kill(); + `); + expect(ir.childProcessInteractions).toMatchObject( + ir.childProcessSpawns.map(({ processId }) => ({ + linkedProcessIds: [processId], + resolution: "complete", + })), + ); +}); + +it.each([ + "class Stop { field = child.kill(); }", + "class Stop { #field = child.kill(); }", + "class Stop { ['field'] = child.kill(); }", + "const Stop = class { field = (0, child.kill()); };", + "class Stop { field = class { [child.kill()] = 0; }; }", + "class Stop { stop() { child.kill(); } }", + "class Stop { field = () => child.kill(); }", +])( + "retains both child candidates across a deferred read: %s", + (declaration) => { + const ir = analyze(` + let child = spawn('old'); + ${declaration} + child = spawn('new'); + new Stop(); + `); + expect(ir.childProcessSpawns).toHaveLength(2); + expect(ir.childProcessInteractions[0]?.linkedProcessIds).toHaveLength(2); + expect(ir.childProcessInteractions).toEqual([ + expect.objectContaining({ + linkedProcessIds: expect.arrayContaining( + ir.childProcessSpawns.map(({ processId }) => processId), + ), + resolution: "partial", + }), + ]); + }, +); + +it("does not prove an assignment-only child precedes an instance field read", () => { + const ir = analyze(` + let child; + child = spawn('assigned'); + class Stop { field = child.kill(); } + new Stop(); + `); + expect(ir.childProcessInteractions).toMatchObject([ + { + linkedProcessIds: [ir.childProcessSpawns[0]!.processId], + resolution: "partial", + }, + ]); +}); + +it.each([ + "class Stop { static field = child.kill(); }", + "class Stop { static #field = child.kill(); }", + "class Stop { [child.kill()] = 0; }", +])("resolves reads evaluated at class definition: %s", (declaration) => { + const ir = analyze(` + let child = spawn('old'); + ${declaration} + child = spawn('new'); + new Stop(); + `); + expect(ir.childProcessInteractions).toMatchObject([ + { + linkedProcessIds: [ir.childProcessSpawns[0]!.processId], + resolution: "complete", + }, + ]); +}); + +it.each(["const", "let"])( + "preserves a stable %s child across an instance field read", + (kind) => { + const ir = analyze(` + ${kind} child = spawn('stable'); + class Stop { field = child.kill(); } + new Stop(); + `); + expect(ir.childProcessInteractions).toMatchObject([ + { + linkedProcessIds: [ir.childProcessSpawns[0]!.processId], + resolution: "complete", + }, + ]); + }, +); + +it("retains a saved alias at its capture and marks a late alias uncertain", () => { + const ir = analyze(` + let child = spawn('old'); + const saved = child; const savedAgain = saved; + child = { kill() {}, on() {} }; + const late = child; + savedAgain.kill(); saved.on('exit', done); late.kill(); + `); + const child = ir.childProcessSpawns[0]!; + expect(ir.childProcessInteractions).toMatchObject([ + { linkedProcessIds: [child.processId], resolution: "complete" }, + { linkedProcessIds: [child.processId], resolution: "complete" }, + { linkedProcessIds: [child.processId], resolution: "partial" }, + ]); +}); + +it.each([ + "if (flag) child = replacement; child.kill();", + "function replace() { child = replacement; } replace(); child.kill();", + "function use() { child.kill(); } child = replacement; use();", + "child &&= replacement; child.kill();", + "child += replacement; child.kill();", + "child++; child.kill();", + "({ child } = replacement); child.kill();", + "for (child of replacements) {} child.kill();", + "while (flag) { child.kill(); child = replacement; }", + "(child = replacement, child.kill());", +])("keeps uncertain child replacement explicit: %s", (body) => { + const ir = analyze(`let child = spawn('old'); ${body}`); + expect(ir.childProcessInteractions).toHaveLength(1); + expect(ir.childProcessInteractions[0]).toMatchObject({ + linkedProcessIds: [ir.childProcessSpawns[0]!.processId], + resolution: "partial", + }); +}); + +it("does not resolve an interaction before its only spawn assignment", () => { + const ir = analyze(`let child; child.kill(); child = spawn('later');`); + expect(ir.childProcessInteractions).toMatchObject([ + { resolution: "partial" }, + ]); +}); + +it("keeps conditional spawn assignment and alias cycles uncertain", () => { + const ir = analyze(` + let child; + if (flag) child = spawn('possible'); + let alias = child; child = alias; + alias.kill(); + `); + expect(ir.childProcessInteractions).toHaveLength(1); + expect(ir.childProcessInteractions[0]).toMatchObject({ + linkedProcessIds: [ir.childProcessSpawns[0]!.processId], + resolution: "partial", + }); +}); + +it("preserves direct and stable captured children without admitting unrelated receivers", () => { + const ir = analyze(` + spawn('direct').kill(); + const child = spawn('captured'); function stop() { child.kill(); } + { const child = { kill() {} }; child.kill(); } + `); + expect(ir.childProcessInteractions).toHaveLength(2); + expect( + ir.childProcessInteractions.every( + ({ resolution }) => resolution === "complete", + ), + ).toBe(true); +}); + +it("keeps a default parameter child uncertain when a caller can supply another handle", () => { + const ir = analyze(` + function stop(child = spawn('fallback')) { + child.kill(); child.on('exit', done); + } + stop({ kill() {}, on() {} }); + `); + expect(ir.childProcessInteractions).toMatchObject([ + { + linkedProcessIds: [ir.childProcessSpawns[0]!.processId], + resolution: "partial", + }, + { + linkedProcessIds: [ir.childProcessSpawns[0]!.processId], + resolution: "partial", + }, + ]); +}); + +it("preserves the admitted scalar identity of an awaited spawn result", () => { + const ir = analyze(` + const child = await spawn('stable'); child.kill(); + `); + expect(ir.childProcessInteractions).toMatchObject([ + { + linkedProcessIds: [ir.childProcessSpawns[0]!.processId], + resolution: "complete", + }, + ]); +}); + +it.each([ + "let child; function launch() { child = spawn('later'); } const alias = child; alias.kill();", + "let child; { const alias = child; alias.kill(); } child = spawn('later');", + "{ const alias = child; alias.kill(); } var child = spawn('later');", +])("keeps initialization across different bodies uncertain: %s", (source) => { + const ir = analyze(source); + expect(ir.childProcessInteractions).toMatchObject([ + { + linkedProcessIds: [ir.childProcessSpawns[0]!.processId], + resolution: "partial", + }, + ]); +}); + +it("resolves an unconditional assignment before the read in the same function body", () => { + const ir = analyze(`function stop() { + let child; child = spawn('first'); child.kill(); + child = spawn('second'); child.kill(); + }`); + expect(ir.childProcessInteractions).toMatchObject( + ir.childProcessSpawns.map(({ processId }) => ({ + linkedProcessIds: [processId], + resolution: "complete", + })), + ); +}); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 45d15f571..9e3c0489a 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -13,12 +13,14 @@ import { import { isUnshadowedGlobal, resolveSemanticBindingState, + semanticStableInitializer, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { builtinDataEffectMethod, dataEffectLiteralString, @@ -193,20 +195,18 @@ const childInteraction = ( ownerCallableId: string | null, context: ChildInteractionContext, ): JavaScriptSemanticChildProcessInteraction | null => { - const { state, spawnByNode, spawnsByBinding } = context; + const { state } = context; const member = dataEffectMemberCallee(node); if (member === null) return null; const method = semanticStaticPropertyName(member.property, member.computed); - const binding = objectBinding(member.object, state); - const linked = resolveChildCandidates( - member.object, - binding, - spawnByNode, - spawnsByBinding, - ); - if (linked.length === 0) return null; const classification = classifyInteraction(method, node.arguments[0]); if (classification === null) return null; + const binding = objectBinding(member.object, state); + const { candidates: linked, complete } = resolveChildCandidates( + member.object, + context, + ); + if (linked.length === 0) return null; const { kind, interactionMethod, eventName } = classification; const listener = node.arguments[1]; return { @@ -224,13 +224,7 @@ const childInteraction = ( kind === "signal" ? killSignalName(node.arguments, state) : null, listenerLocation: kind === "listener" && t.isNode(listener) ? range(listener) : null, - resolution: - linked.length === 1 && - !linked[0]?.projectedResultBindings.some( - ({ bindingId }) => bindingId === binding?.bindingId, - ) - ? "complete" - : "partial", + resolution: complete ? "complete" : "partial", }; }; @@ -291,15 +285,86 @@ const childProcessMethod = ( const resolveChildCandidates = ( node: t.Node, - binding: JavaScriptSemanticBindingState | undefined, - spawnByNode: WeakMap, - spawnsByBinding: ReadonlyMap, -): readonly SpawnCandidate[] => { - const direct = spawnByNode.get(node); - if (direct !== undefined) return [direct]; - return binding === undefined - ? [] - : (spawnsByBinding.get(binding.bindingId) ?? []); + context: ChildInteractionContext, +): { + readonly candidates: readonly SpawnCandidate[]; + readonly complete: boolean; +} => { + const { state, spawnByNode, spawnsByBinding } = context; + const candidates = new Set(); + const fallback = new Set(); + const pending = [node]; + const visited = new Set(); + let complete = true; + while (pending.length > 0) { + const current = unwrapJavaScriptExpression(pending.pop()!).node; + if (visited.has(current)) { + complete = false; + continue; + } + visited.add(current); + // Spawn-result assignment already admits await as an identity wrapper. + if (t.isAwaitExpression(current)) { + pending.push(current.argument); + continue; + } + const direct = spawnByNode.get(current); + if (direct !== undefined) { + candidates.add(direct); + continue; + } + const binding = t.isIdentifier(current) + ? resolveSemanticBindingState(state, current, current.name) + : undefined; + if (binding === undefined) { + complete = false; + continue; + } + for (const candidate of spawnsByBinding.get(binding.bindingId) ?? []) + fallback.add(candidate); + // An alias captures its source at its initializer read, not at the later + // interaction. The shared selector retains all sources across uncertain + // control flow, loops and writes in other activations. + const snapshot = semanticBindingAtRead( + binding, + current, + state.parentsByNode, + ); + const selected = snapshot.binding; + const initializers = [ + ...selected.initializers, + ...selected.referenceInitializers, + ]; + // Defaults and other reference sources do not exclude a caller-supplied + // value, even when they are the binding's only retained source. + if (initializers.length !== 1 || selected.referenceInitializers.length > 0) + complete = false; + const stable = semanticStableInitializer(binding, state); + for (const initializer of initializers) { + if (state.conditionallySkippedExpressions.has(initializer.node)) + complete = false; + if (initializer.projection.length > 0) { + complete = false; + continue; + } + // A lone assignment can still be later or belong to another activation. + // Only initialized lexical declarations exclude an earlier undefined value + // without a same-body ordering proof. + if ( + !(stable === initializer && !binding.declarationKinds.has("var")) && + snapshot.status !== "ordered" + ) + complete = false; + pending.push(initializer.node); + } + } + // Preserve historical candidates with explicit uncertainty when the selected + // value cannot be established; a binding inventory alone never proves identity. + if (!complete) for (const candidate of fallback) candidates.add(candidate); + return { + candidates: [...candidates], + complete: complete && candidates.size === 1, + }; }; const spawnOptions = (candidate: SpawnCandidate): t.ObjectExpression | null => { const args = candidate.node.arguments; diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts index 8a6179243..d1b89be7c 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts @@ -1,11 +1,74 @@ import * as t from "@babel/types"; import { describe, expect, it } from "vitest"; -import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticBindingAtRead, + semanticMutationInitializers, + semanticTopLevelEffectFollowsCapture, +} from "./javascriptSemanticMutationInitializers.js"; import type { JavaScriptSemanticBindingState } from "./javascriptSemanticState.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +it.each([ + ["public value", false, false], + ["private value", false, false], + ["accessor value", false, false], + ["public value", true, true], + ["private value", true, true], + ["accessor value", true, true], + ["public key", false, true], + ["accessor key", false, true], +] as const)( + "orders %s reads with static=%s only when evaluated at class definition", + (kind, isStatic, ordered) => { + const initializer = t.numericLiteral(1); + const read = t.identifier("child"); + const inKey = kind.endsWith("key"); + const key = inKey ? read : t.identifier("field"); + const value = inKey ? t.numericLiteral(0) : read; + const field = kind.startsWith("private") + ? t.classPrivateProperty(t.privateName(t.identifier("field")), value) + : kind.startsWith("accessor") + ? t.classAccessorProperty(key, value, null, null, inKey) + : t.classProperty(key, value, null, null, inKey); + field.static = isStatic; + const program = t.program([ + t.variableDeclaration("let", [ + t.variableDeclarator(t.identifier("child"), initializer), + ]), + t.classDeclaration(t.identifier("Stop"), null, t.classBody([field])), + ]); + const parents = new WeakMap(); + traverseJavaScriptAst(program, { + enter: (node, parent) => { + if (parent !== null) parents.set(node, parent); + }, + }); + const binding: JavaScriptSemanticBindingState = { + bindingId: "program:child", + scopeId: "program", + name: "child", + kind: "variable", + declarationKinds: new Set(["let"]), + mutable: true, + memberWrites: [], + escapedPaths: [], + definitions: [], + initializers: [{ node: initializer, projection: [] }], + referenceInitializers: [], + directOrigins: [], + runtimeImport: false, + }; + expect(semanticBindingAtRead(binding, read, parents).status).toBe( + ordered ? "ordered" : "unresolved", + ); + expect( + semanticTopLevelEffectFollowsCapture(initializer, read, parents), + ).toBe(ordered); + }, +); + describe("parameter references copied into function body bindings", () => { it.each([ ["var arg = []; mutate(arg);", false], diff --git a/tests/acceptance/applications/javascriptChildHandleOwnership.test.ts b/tests/acceptance/applications/javascriptChildHandleOwnership.test.ts new file mode 100644 index 000000000..19fc88ab9 --- /dev/null +++ b/tests/acceptance/applications/javascriptChildHandleOwnership.test.ts @@ -0,0 +1,143 @@ +import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; + +const analyzeThroughCliAndMcp = async (source: string) => { + const root = await createTestTempDirectory("rea-child-ownership-"); + const app = join(root, "app"); + const home = join(root, "home"); + await mkdir(app); + await mkdir(home); + await writeFile(join(app, "main.mjs"), source); + const { stdout } = await promisify(execFile)( + process.execPath, + [ + "scripts/rea.mjs", + "analyze-javascript-application", + app, + "--format", + "json", + ], + { + cwd: process.cwd(), + env: { ...process.env, HOME: home }, + maxBuffer: 16 * 1024 * 1024, + }, + ); + const evidence = parseEvidence(JSON.parse(stdout)); + const { client, call } = await connectLocalToolsMcp({ HOME: home }); + const response = await call("analyze_javascript_application", { + input_path: app, + format: "auto", + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(evidence); + await client.ping(); + return evidence; +}; + +it("preserves child ownership uncertainty after replacement through CLI and MCP", async () => { + const evidence = await analyzeThroughCliAndMcp( + `import { spawn } from 'node:child_process'; +let child = spawn('program'); +child = { kill() {}, on() {} }; +child.kill(); +child.on('exit', () => {}); +const stable = spawn('program'); +stable.kill(); +`, + ); + const { semantic_graph: graph } = + javascriptApplicationAnalysisResultSchema.parse(evidence.normalized_result); + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + const ownership = graph.relations.filter(({ relation }) => + ["forwards-signal", "listens-exit"].includes(relation), + ); + expect( + ownership.map((relation) => ({ + relation: relation.relation, + resolution: relation.resolution, + sourceLine: nodes.get(relation.source_node_id)?.identity.source_range + ?.start.line, + targetLine: nodes.get(relation.target_node_id)?.identity.source_range + ?.start.line, + })), + ).toEqual( + expect.arrayContaining([ + { + relation: "forwards-signal", + resolution: "candidate", + sourceLine: 4, + targetLine: 2, + }, + { + relation: "listens-exit", + resolution: "candidate", + sourceLine: 2, + targetLine: 5, + }, + { + relation: "forwards-signal", + resolution: "resolved", + sourceLine: 7, + targetLine: 6, + }, + ]), + ); + expect(ownership).toHaveLength(3); + expect( + graph.unknowns.filter( + ({ family, reason }) => + family === "child-process" && reason === "ambiguous-target", + ), + ).toHaveLength(2); +}, 60000); + +it("keeps deferred instance field child ownership uncertain through CLI and MCP", async () => { + const evidence = await analyzeThroughCliAndMcp( + `import { spawn } from 'node:child_process'; +let child = spawn('old'); +class Stop { field = child.kill(); } +child = spawn('new'); +new Stop(); +const stable = spawn('stable'); +stable.kill(); +`, + ); + const { semantic_graph: graph } = + javascriptApplicationAnalysisResultSchema.parse(evidence.normalized_result); + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + const ownership = graph.relations.filter( + ({ relation }) => relation === "forwards-signal", + ); + expect(ownership).toHaveLength(3); + expect( + ownership.map((relation) => ({ + resolution: relation.resolution, + sourceLine: nodes.get(relation.source_node_id)?.identity.source_range + ?.start.line, + targetLine: nodes.get(relation.target_node_id)?.identity.source_range + ?.start.line, + })), + ).toEqual( + expect.arrayContaining([ + { resolution: "candidate", sourceLine: 3, targetLine: 2 }, + { resolution: "candidate", sourceLine: 3, targetLine: 4 }, + { resolution: "resolved", sourceLine: 7, targetLine: 6 }, + ]), + ); + expect( + graph.unknowns.filter( + ({ family, reason }) => + family === "child-process" && reason === "ambiguous-target", + ), + ).toHaveLength(1); +}, 60000);