fix(javascript): ignore async exec stdio options (#7087)

Respect Node asynchronous exec/execFile method-default stdio instead of interpreting an ignored option. Remove the superseded callback-shape query while preserving caller input, Evidence, uncertainty, argv and fork controls. Independently validated on current main through actual Node pipe capture, matching real-app CLI/MCP facts, 81 targeted tests and static checking. Closes #7217.
This commit is contained in:
Wenyu Chiou authored and GitHub committed 2026-10-26 23:16:28 +08:00
1 parent a84e0cfcfe
commit 625e32744e
4 files changed
+76 -89

No files matched your search

@@ -416,14 +416,11 @@ const stdioMode = (
method: JavaScriptSemanticChildProcessSpawn["method"],
context: ChildSpawnContext,
): string => {
// Async exec/execFile do not forward options.stdio to their underlying spawn.
if (method === "exec" || method === "execFile") return "default";
if (options === undefined) return "default";
const expression = unwrapJavaScriptExpression(options).node;
if (
context.isNullish(expression) ||
((method === "exec" || method === "execFile") &&
context.isCallback(expression))
)
return "default";
if (context.isNullish(expression)) return "default";
const lookup = staticObjectProperty(expression, "stdio", context.isNullish);
if (lookup.status === "missing")
return unsetStdioMode(expression, method, context);
@@ -13,9 +13,7 @@ import {
} from "./javascriptSemanticTraversal.js";
it.each([
["function f(){read(cb);function cb(){}}", ["callback"]],
["function f(){const cb=()=>{};const alias=cb;read(alias)}", ["callback"]],
["function f(cb){cb=()=>{};arguments[0]={};read(cb)}", ["unknown"]],
["function f(a){a=[];arguments[0]={};read(a)}", ["unknown"]],
["declare let erased:any;function f(){const a=[];read(a)}", ["unknown"]],
["namespace X{};function f(){const a=[];read(a)}", ["unknown"]],
["var a=[];(()=>{}).constructor('a={}')();read(a);", ["unknown"]],
@@ -60,11 +58,9 @@ it.each([
shapes.push(
resolver.isArray(arg)
? "array"
: resolver.isCallback(arg)
? "callback"
: resolver.isNullish(arg)
? "nullish"
: "unknown",
: resolver.isNullish(arg)
? "nullish"
: "unknown",
);
},
});
@@ -72,10 +68,10 @@ it.each([
},
);
it("bounds long callback aliases without invoking the shared callable resolver", () => {
const source = `export function run(){const cb=()=>{};
${Array.from({ length: 4096 }, (_, i) => `const a${i}=${i === 0 ? "cb" : `a${i - 1}`};`).join("\n")}
read(a4095);read(a10);read(cb);}`;
it("bounds long array aliases without invoking the shared callable resolver", () => {
const source = `export function run(){const argv=[];
${Array.from({ length: 4096 }, (_, i) => `const a${i}=${i === 0 ? "argv" : `a${i - 1}`};`).join("\n")}
read(a4095);read(a10);read(argv);}`;
const file = parseJavaScriptSource(source, "deep.mjs");
if (file === null) throw new Error("Expected valid deep source");
const state = completeSemanticSteps(
@@ -89,7 +85,7 @@ it("bounds long callback aliases without invoking the shared callable resolver",
t.isCallExpression(node) &&
t.isIdentifier(node.callee, { name: "read" })
)
results.push(resolver.isCallback(node.arguments[0]!));
results.push(resolver.isArray(node.arguments[0]!));
},
});
expect(results).toEqual([false, true, true]);
@@ -97,55 +93,30 @@ it("bounds long callback aliases without invoking the shared callable resolver",
});
it.each([
["fixture.cjs", "const argv=[];const done=()=>{};", ["inherit", "default"]],
["fixture.cts", "const argv=[];const done=()=>{};", ["inherit", "default"]],
["fixture.cjs", "const argv=[];", ["inherit"]],
["fixture.cts", "const argv=[];", ["inherit"]],
["fixture.cjs", "var argv=[];{function argv(){}}", ["dynamic"]],
["fixture.mjs", "var argv=[];{function argv(){}}", ["inherit"]],
["fixture.js", "const argv=[];", ["dynamic"]],
["fixture.cjs", "let argv=[];argv=input;", ["dynamic"]],
["fixture.cts", "const argv=[];eval(input);", ["inherit"]],
["fixture.cts", "namespace N{};const argv=[];", ["dynamic"]],
[
"fixture.cjs",
"var argv=[];{function argv(){}}const done=()=>{};",
["dynamic", "default"],
],
[
"fixture.mjs",
"var argv=[];{function argv(){}}const done=()=>{};",
["inherit", "default"],
],
["fixture.js", "const argv=[];const done=()=>{};", ["dynamic", "dynamic"]],
[
"fixture.cjs",
"let argv=[];argv=input;const done=()=>{};",
["dynamic", "default"],
],
[
"fixture.cts",
"const argv=[];const done=()=>{};eval(input);",
["inherit", "default"],
],
[
"fixture.cts",
"namespace N{};const argv=[];const done=()=>{};",
["dynamic", "dynamic"],
],
[
"fixture.cjs",
"var module=[];const argv=module;const done=()=>{};const args=arguments;args[2]={};",
["dynamic", "default"],
],
[
"fixture.cts",
"const argv=[];function exports(){};const args=arguments;args[0]={};const done=exports;",
["inherit", "dynamic"],
"var module=[];const argv=module;const args=arguments;args[2]={};",
["dynamic"],
],
] as const)(
"distinguishes wrapper-local child identities and uncertain bindings: %s (%s)",
(path, declarations, expected) => {
const loader = path.endsWith(".mjs")
? 'import {spawn,exec} from "node:child_process";'
: 'const {spawn,exec}=require("node:child_process");';
? 'import {spawn} from "node:child_process";'
: 'const {spawn}=require("node:child_process");';
const file = parseJavaScriptSource(
`
${loader}
${declarations}
spawn("argv",argv,{stdio:"inherit"}); exec("callback",done);
spawn("argv",argv,{stdio:"inherit"});
`,
path,
);
@@ -23,7 +23,7 @@ import {
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { javaScriptRuntimeContext } from "./javascriptSourceParser.js";
type Shape = "array" | "callback" | "unknown";
type Shape = "array" | "unknown";
/** Bounded argument-shape queries shared by call-signature owners. */
export type JavaScriptExpressionShapeResolver = ReturnType<
@@ -41,15 +41,6 @@ const commonJsWrapperParameterNames = new Set([
export const createSemanticExpressionShapeResolver = (
state: JavaScriptSemanticAnalysisState,
) => {
const hoistedFunction = (node: t.Node): boolean => {
const parent = state.parentsByNode.get(node);
return (
t.isFunctionDeclaration(node) &&
(t.isProgram(parent) ||
(t.isBlockStatement(parent) &&
t.isFunction(state.parentsByNode.get(parent))))
);
};
// Most modules have no named child arguments. Collect source-wide blockers
// only when a query actually needs a named binding or ambient undefined.
let blocked: boolean | undefined;
@@ -80,8 +71,6 @@ export const createSemanticExpressionShapeResolver = (
if (seen.has(node)) return "unknown";
seen.add(node);
if (t.isArrayExpression(node)) return "array";
if (t.isFunction(node) && !("declare" in node && node.declare))
return "callback";
if (!t.isIdentifier(node)) return "unknown";
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined || binding.referenceInitializers.length > 0)
@@ -106,11 +95,7 @@ export const createSemanticExpressionShapeResolver = (
if (initializer === undefined || initializer.projection.length > 0)
return "unknown";
const read = semanticBindingAtRead(binding, node, state.parentsByNode);
// Hoisted function declarations are the only accepted unordered source.
if (
!hoistedFunction(initializer.node) &&
(read.status !== "ordered" || !read.hasDefiniteInitializer)
)
if (read.status !== "ordered" || !read.hasDefiniteInitializer)
return "unknown";
node = initializer.node;
depth++;
@@ -118,7 +103,6 @@ export const createSemanticExpressionShapeResolver = (
};
return {
isArray: (node: t.Node) => shape(node) === "array",
isCallback: (node: t.Node) => shape(node) === "callback",
isNullish: (input: t.Node) => {
const { node } = unwrapJavaScriptExpression(input);
return (
@@ -102,6 +102,33 @@ const cases = [
0,
],
['spawn("missing-stdio", [])', "missing-stdio", "default", 0],
// Async exec/execFile ignore stdio even when the supplied value is dynamic.
['exec("exec-inherit", {stdio:"inherit"})', "exec-inherit", "default", null],
[
'execFile("execfile-ignore", {stdio:"ignore"})',
"execfile-ignore",
"default",
null,
],
[
'execFile("execfile-array", [], {stdio:["inherit","inherit","inherit"]})',
"execfile-array",
"default",
0,
],
[
'execFile("execfile-dynamic", ["one"], {stdio:input,env:{A:"1"}})',
"execfile-dynamic",
"default",
1,
],
['fork("fork-silent", [], {silent:true})', "fork-silent", "pipe", 0],
[
'fork("fork-explicit", [], {silent:true,stdio:"inherit"})',
"fork-explicit",
"inherit",
0,
],
['exec("null-stdio", {stdio:null})', "null-stdio", "default", null],
[
'execFile("undefined-options", [], undefined)',
@@ -162,11 +189,11 @@ const cases = [
['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("imported-callback", imported)', "imported-callback", "default", null],
[
'exec("reassigned-callback", changed)',
"reassigned-callback",
"dynamic",
"default",
null,
],
['spawn("named-nullish", [], {stdio:absent})', "named-nullish", "dynamic", 0],
@@ -179,15 +206,15 @@ const cases = [
] as const;
const annexBSource = `
const {execFile}=require("node:child_process");
const {spawn,execFile}=require("node:child_process");
var argv=[]; {function argv(){}}
execFile("annex-b-argv",argv,{stdio:"inherit"});
spawn("annex-b-argv",argv,{stdio:"inherit"});
function run(){
var args=[]; switch(input){case 0:function args(){}}
execFile("annex-b-local",args,{stdio:"inherit"});
spawn("annex-b-local",args,{stdio:"inherit"});
}
var stable=[]; function done(){}
execFile("cjs-var-argv",stable,{stdio:"inherit"});
spawn("cjs-var-argv",stable,{stdio:"inherit"});
execFile("cjs-hoisted-callback",done);
`;
@@ -199,13 +226,21 @@ const importEqualsSource = `
`;
const exportAssignmentSource = `
const {spawn,execFile}=require("node:child_process");
const {spawn}=require("node:child_process");
const argv=[]; spawn("export-assignment-argv",argv,{stdio:"inherit"});
var args=[]; {function args(){}}
execFile("export-assignment-annex",args,{stdio:"inherit"});
spawn("export-assignment-annex",args,{stdio:"inherit"});
export = argv;
`;
// Callback/options ambiguity cannot change exec's effective stdio.
const opaqueExecCallbackCommands = [
"eval-callback",
"constructed-callback",
"mapped-callback",
"cjs-wrapper-callback",
];
cliTest.for(["CLI", "stdio MCP"] as const)(
"preserves effective stdio, overloads and explicit unknowns through %s",
async (surface, { cli }) => {
@@ -277,17 +312,15 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
expected.set("import-equals-argv", ["inherit", null]);
expected.set("import-equals-callback", ["default", null]);
expected.set("export-assignment-argv", ["inherit", null]);
for (const command of opaqueExecCallbackCommands)
expected.set(command, ["default", 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],
@@ -309,7 +342,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
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",
["named-argv", "execfile-dynamic"].includes(String(command)),
);
expect(child.identity.source_range).not.toBeNull();
const stdio = graph.relations.filter(
@@ -342,7 +375,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
relation.relation === "supplies-env" &&
relation.target_node_id === child.node_id,
),
).toHaveLength(command === "named-argv" ? 1 : 0);
).toHaveLength(
["named-argv", "execfile-dynamic"].includes(String(command)) ? 1 : 0,
);
expect(
graph.relations.filter(
(relation) =>