diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 61feb46e24..10722d67e5 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -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); diff --git a/src/domain/javascript/javascriptSemanticExpressionShapes.test.ts b/src/domain/javascript/javascriptSemanticExpressionShapes.test.ts index 45a1367a3d..e8e37d3079 100644 --- a/src/domain/javascript/javascriptSemanticExpressionShapes.test.ts +++ b/src/domain/javascript/javascriptSemanticExpressionShapes.test.ts @@ -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, ); diff --git a/src/domain/javascript/javascriptSemanticExpressionShapes.ts b/src/domain/javascript/javascriptSemanticExpressionShapes.ts index db2ce9d067..f31ec62430 100644 --- a/src/domain/javascript/javascriptSemanticExpressionShapes.ts +++ b/src/domain/javascript/javascriptSemanticExpressionShapes.ts @@ -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 ( diff --git a/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts b/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts index 27c722df49..3099f0f209 100644 --- a/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts +++ b/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts @@ -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) =>