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:
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);
|
||||
Reference in new issue
Block a user