fix(javascript): resolve child ownership at receiver reads (#3819)

* fix(javascript): resolve child ownership at receiver reads

* fix(javascript): preserve deferred field child candidates

---------

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
星闌andCodex authored and GitHub committed 2026-10-17 00:22:26 +08:00
1 parent 48c3d2d170
commit 6d2eeeaed0
4 files changed
+560 -26

No files matched your search

@@ -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",
})),
);
});
@@ -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<t.Node, SpawnCandidate>,
spawnsByBinding: ReadonlyMap<string, readonly SpawnCandidate[]>,
): 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<SpawnCandidate>();
const fallback = new Set<SpawnCandidate>();
const pending = [node];
const visited = new Set<t.Node>();
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;
@@ -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<t.Node, t.Node>();
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],
@@ -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);