Files
rea/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts
星闌 de9371b85d fix(javascript): preserve effective child-process stdio options (#4336)
* fix(javascript): preserve effective child-process stdio options

* fix(javascript): retain proven callbacks and primitive stdio keys

* fix(javascript): preserve argv shapes and nullish spreads

* fix(javascript): bound callable proofs and retain exposed binding uncertainty

* test(javascript): keep callback acceptance within transport budget

* test(javascript): inspect application coverage for callback proof limits

* fix(javascript): keep spread command argument slots uncertain

* fix(javascript): bound mapped argument content proofs

* fix(javascript): distinguish erased declarations from runtime shadows

* fix(javascript): prove runtime module context before binding isolation

* fix(javascript): share erased declaration and opaque scope proofs

* fix(javascript): keep lowered enum scopes opaque

* fix(javascript): narrow stdio proofs and consolidate regressions

* perf(javascript): reuse lexical references for child shapes

* fix(javascript): retain CommonJS child argument context

* fix(javascript): keep Annex B affected child arguments unknown

* fix(javascript): recognize value import-equals runtime modules

* fix(javascript): retain exact stdio through module and primitive syntax

* fix(javascript): bound inline spread property resolution

* fix(javascript): distinguish copied methods from getters
2026-10-17 15:51:55 +08:00

360 lines
12 KiB
TypeScript

import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
// Each row covers one ownership, overload or uncertainty boundary. Both public
// adapters read the same inert fixture; no Cartesian product of equivalent calls.
const cases = [
[
'spawn("duplicate", [], {stdio:"pipe",stdio:"inherit"})',
"duplicate",
"inherit",
0,
],
['spawn("control", [], {stdio:"inherit"})', "control", "inherit", 0],
[
'spawn("spread-after", [], {stdio:"pipe",...input})',
"spread-after",
"dynamic",
0,
],
['spawn("unknown-options", [], input)', "unknown-options", "dynamic", 0],
[
'spawn("spread-before", [], {...input,stdio:"inherit"})',
"spread-before",
"inherit",
0,
],
[
'spawn("null-spreads", [], {stdio:"inherit",...null,...undefined,...void effect()})',
"null-spreads",
"inherit",
0,
],
[
'spawn("computed", [], {stdio:"inherit",[key]:1})',
"computed",
"dynamic",
0,
],
[
'spawn("coerced-keys", [], {stdio:"inherit",[true]:1,[null]:1,[-1]:1,[+(1 as number)]:1})',
"coerced-keys",
"inherit",
0,
],
[
'spawn("void-key", [], {stdio:"inherit",[void effect()]:1})',
"void-key",
"inherit",
0,
],
[
'spawn("primitive-spreads", [], {stdio:"inherit",...false,...42,...1n})',
"primitive-spreads",
"inherit",
0,
],
[
'spawn("inline-empty", [], {stdio:"inherit",...{}})',
"inline-empty",
"inherit",
0,
],
[
'spawn("inline-final", [], {stdio:"pipe",...{...{stdio:"ignore",stdio:"inherit"}}})',
"inline-final",
"inherit",
0,
],
[
'spawn("inline-getter", [], {stdio:"ignore",...{get other(){this.stdio="pipe";return 0},stdio:"inherit"}})',
"inline-getter",
"dynamic",
0,
],
[
'spawn("inline-helper", [], {stdio:"inherit",...{...{helper(){}}}})',
"inline-helper",
"inherit",
0,
],
[
'spawn("inline-unknown", [], {stdio:"inherit",...{...input}})',
"inline-unknown",
"dynamic",
0,
],
[
'spawn("getter", [], {stdio:"inherit",get stdio(){return input}})',
"getter",
"dynamic",
0,
],
['spawn("method", [], {stdio:"inherit",stdio(){}})', "method", "dynamic", 0],
[
'spawn("array-stdio", [], {stdio:["pipe",input]})',
"array-stdio",
"array",
0,
],
['spawn("missing-stdio", [])', "missing-stdio", "default", 0],
['exec("null-stdio", {stdio:null})', "null-stdio", "default", null],
[
'execFile("undefined-options", [], undefined)',
"undefined-options",
"default",
0,
],
['fork("void-options", [], void effect())', "void-options", "default", 0],
[
'spawn("options-only", {stdio:"inherit"}, {stdio:null})',
"options-only",
"inherit",
null,
],
[
'spawn("ambiguous-slot", input, {stdio:"inherit"})',
"ambiguous-slot",
"dynamic",
null,
],
[
'fork("nullish-slot", null, {stdio:"inherit"})',
"nullish-slot",
"inherit",
null,
],
[
'spawn("wrapped-argv", ([] as string[])!, {stdio:"inherit"})',
"wrapped-argv",
"inherit",
0,
],
[
'spawn("named-argv", argv, {env:{A:"1"},stdio:"inherit"})',
"named-argv",
"inherit",
null,
],
[
'spawn("captured-argv", captured, {stdio:"inherit"})',
"captured-argv",
"dynamic",
null,
],
[
'spawn("spread-argv", ["a",...input], {stdio:"inherit"})',
"spread-argv",
"inherit",
null,
],
['spawn("fixed-argv", ["a"], {stdio:"inherit"})', "fixed-argv", "inherit", 1],
[
'spawn("spread-slot", ...input, {stdio:"inherit"})',
"spread-slot",
"dynamic",
null,
],
['spawn(...input, [], {stdio:"inherit"})', null, "dynamic", null],
['exec("named-callback", done)', "named-callback", "default", null],
['execFile("callback-alias", [], callback)', "callback-alias", "default", 0],
['exec("imported-callback", imported)', "imported-callback", "dynamic", null],
[
'exec("reassigned-callback", changed)',
"reassigned-callback",
"dynamic",
null,
],
['spawn("named-nullish", [], {stdio:absent})', "named-nullish", "dynamic", 0],
[
'spawn("named-null-spread", [], {stdio:"inherit",...absent})',
"named-null-spread",
"dynamic",
0,
],
] as const;
const annexBSource = `
const {execFile}=require("node:child_process");
var argv=[]; {function argv(){}}
execFile("annex-b-argv",argv,{stdio:"inherit"});
function run(){
var args=[]; switch(input){case 0:function args(){}}
execFile("annex-b-local",args,{stdio:"inherit"});
}
var stable=[]; function done(){}
execFile("cjs-var-argv",stable,{stdio:"inherit"});
execFile("cjs-hoisted-callback",done);
`;
const importEqualsSource = `
import cp = require("node:child_process");
const argv=[]; function done(){}
cp.spawn("import-equals-argv",argv,{stdio:"inherit"});
cp.exec("import-equals-callback",done);
`;
const exportAssignmentSource = `
const {spawn,execFile}=require("node:child_process");
const argv=[]; spawn("export-assignment-argv",argv,{stdio:"inherit"});
var args=[]; {function args(){}}
execFile("export-assignment-annex",args,{stdio:"inherit"});
export = argv;
`;
cliTest.for(["CLI", "stdio MCP"] as const)(
"preserves effective stdio, overloads and explicit unknowns through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-child-stdio-");
await writeFixtureFiles(root, {
"entry.ts": `
import {spawn,exec,execFile,fork} from "node:child_process";
import {done as imported} from "./external.mjs";
throw new Error("static fixture must never execute");
function done(){}
const captured=[]; const absent=null;
function run(input,key){
const callback=done; const argv=[];
let changed=()=>{}; changed=input;
${cases.map(([source]) => `${source};`).join("\n")}
}
`,
"external.mjs": "export function done(){}",
"globals.js": `
const {spawn,exec}=require("node:child_process");
var argv=[];(()=>{}).constructor("argv={stdio:'inherit'}")();
spawn("constructed-argv",argv,{stdio:"pipe"});
let done=()=>{};(()=>{}).constructor("done={stdio:'inherit'}")();
exec("constructed-callback",done);
`,
"wrapper.cjs": `
const {spawn,exec}=require("node:child_process");
const argv=[]; const done=()=>{};
spawn("cjs-argv",argv,{stdio:"inherit"});exec("cjs-callback",done);
var module=[]; const args=arguments; args[2]={stdio:"inherit"};
spawn("cjs-wrapper-argv",module,{stdio:"pipe"});
function exports(){}; args[0]={stdio:"inherit"};
exec("cjs-wrapper-callback",exports);
`,
"wrapper.cts": `
const {spawn,exec}=require("node:child_process");
const argv: string[]=[]; const done=()=>{};
spawn("cts-argv",argv,{stdio:"inherit"});exec("cts-callback",done);
`,
"annex-b.cjs": annexBSource,
"import-equals.ts": importEqualsSource,
"export-assignment.ts": exportAssignmentSource,
"parameters.cjs": `
const {exec}=require("node:child_process");
function mapped(cb){cb=()=>{};arguments[0]={stdio:'inherit'};exec("mapped-callback",cb)}
`,
"exposed.cjs": `
const {spawn,exec}=require("node:child_process");
var mode=null; Function("mode='inherit'")();
spawn("Function-global",[],{stdio:mode});
var frameMode=null; frames.frameMode='inherit';
spawn("frames-global",[],{stdio:frameMode});
function mapped(mode){mode=null;arguments[0]='inherit';spawn("mapped-arguments",[],{stdio:mode})}
function exposed(options){let done=()=>{};eval("done=options");exec("eval-callback",done)}
function shadowed(undefined){spawn("shadowed-undefined",[],{stdio:"inherit",...undefined})}
`,
});
const graph = (await analyzeJavaScriptDirectory(surface, cli, root))
.semantic_graph;
const expected = new Map<string | null, readonly [string, number | null]>(
cases.map(([, command, mode, count]) => [command, [mode, count]]),
);
for (const extension of ["cjs", "cts"]) {
expected.set(`${extension}-argv`, ["inherit", null]);
expected.set(`${extension}-callback`, ["default", null]);
}
expected.set("cjs-var-argv", ["inherit", null]);
expected.set("cjs-hoisted-callback", ["default", null]);
expected.set("import-equals-argv", ["inherit", null]);
expected.set("import-equals-callback", ["default", null]);
expected.set("export-assignment-argv", ["inherit", null]);
for (const [command, count] of [
["Function-global", 0],
["frames-global", 0],
["mapped-arguments", 0],
["eval-callback", null],
["shadowed-undefined", 0],
["constructed-argv", null],
["constructed-callback", null],
["mapped-callback", null],
["cjs-wrapper-argv", null],
["cjs-wrapper-callback", null],
["annex-b-argv", null],
["annex-b-local", null],
["export-assignment-annex", null],
] as const)
expected.set(command, ["dynamic", count]);
const children = graph.nodes.filter(
(node) => node.kind === "child-process",
);
expect(children).toHaveLength(expected.size);
expect(children.map((child) => child.properties.command).sort()).toEqual(
[...expected.keys()].sort(),
);
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
for (const child of children) {
const command = child.properties.command as string | null;
const entry = expected.get(command);
if (entry === undefined) throw new Error(`Unexpected child ${command}`);
const [mode, count] = entry;
expect(child.properties.stdio_mode, String(command)).toBe(mode);
expect(child.properties.argv_count, String(command)).toBe(count);
expect(child.properties.environment_supplied, String(command)).toBe(
command === "named-argv",
);
expect(child.identity.source_range).not.toBeNull();
const stdio = graph.relations.filter(
(relation) =>
relation.relation === "connects-stdio" &&
relation.source_node_id === child.node_id,
);
expect(stdio, String(command)).toHaveLength(1);
expect(stdio[0]?.resolution).toBe(
mode === "dynamic" ? "candidate" : "resolved",
);
expect(nodes.get(stdio[0]!.target_node_id)).toMatchObject({
kind: "stdio",
properties: { mode },
});
const unknowns = graph.unknowns.filter(
(unknown) =>
unknown.node_id === child.node_id &&
unknown.family === "child-process" &&
unknown.relation_kinds.includes("connects-stdio"),
);
expect(unknowns, String(command)).toHaveLength(
mode === "dynamic" ? 1 : 0,
);
if (mode === "dynamic")
expect(unknowns[0]).toMatchObject({ reason: "unknown-value" });
expect(
graph.relations.filter(
(relation) =>
relation.relation === "supplies-env" &&
relation.target_node_id === child.node_id,
),
).toHaveLength(command === "named-argv" ? 1 : 0);
expect(
graph.relations.filter(
(relation) =>
relation.relation === "supplies-argv" &&
relation.target_node_id === child.node_id,
),
).toHaveLength(count === null ? 0 : 1);
}
for (const relation of graph.relations) {
expect(nodes.has(relation.source_node_id)).toBe(true);
expect(nodes.has(relation.target_node_id)).toBe(true);
}
},
);