From de9371b85d4f2ecda2a8d4a97e59def7d8120462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E9=97=8C?= <130824939+Xinglan233@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:51:55 +0800 Subject: [PATCH] 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 --- ...riptSemanticGraphChildProcessProjection.ts | 14 +- .../electronStaticAnalysisBrowser.ts | 12 +- .../electronStaticAnalysisValues.ts | 21 - .../javascript/javascriptAstValues.test.ts | 72 ++++ src/domain/javascript/javascriptAstValues.ts | 99 ++++- .../javascript/javascriptModuleDeclaration.ts | 15 + .../javascript/javascriptRpcEndpoints.test.ts | 23 ++ ...ascriptSemanticAnalysis.empty_keys.test.ts | 16 + .../javascript/javascriptSemanticAnalysis.ts | 1 + .../javascriptSemanticAnalysisSteps.test.ts | 25 +- .../javascriptSemanticChildProcesses.ts | 117 +++--- .../javascriptSemanticDerivedAnalysis.ts | 3 + ...javascriptSemanticExpressionShapes.test.ts | 161 ++++++++ .../javascriptSemanticExpressionShapes.ts | 135 +++++++ .../javascript/javascriptSourceParser.test.ts | 47 ++- .../javascript/javascriptSourceParser.ts | 84 +++- .../javascriptChildProcessStdioCli.test.ts | 359 ++++++++++++++++++ .../referenceSourceShadowedRequire.test.ts | 3 + 18 files changed, 1114 insertions(+), 93 deletions(-) create mode 100644 src/domain/javascript/javascriptAstValues.test.ts create mode 100644 src/domain/javascript/javascriptSemanticExpressionShapes.test.ts create mode 100644 src/domain/javascript/javascriptSemanticExpressionShapes.ts create mode 100644 tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts diff --git a/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts b/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts index d7f44faaad..f314080cc9 100644 --- a/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts @@ -21,7 +21,9 @@ interface ChildUnknownInput { | "forwards-signal" | "listens-exit" | "listens-error" + | "connects-stdio" )[]; + readonly reason?: "unknown-value"; readonly detail: string; readonly candidateNodeIds: readonly string[]; } @@ -113,6 +115,16 @@ const projectSpawn = ( relation: "connects-stdio", resolution: spawn.stdioMode === "dynamic" ? "candidate" : "resolved", }); + if (spawn.stdioMode === "dynamic") + addChildUnknown({ + context, + node: child, + location: spawn.location, + relationKinds: ["connects-stdio"], + reason: "unknown-value", + detail: `Static ${spawn.method} stdio options remain unresolved.`, + candidateNodeIds: [], + }); if (spawn.resolution !== "complete") addChildUnknown({ context, @@ -228,7 +240,7 @@ const addChildUnknown = (input: ChildUnknownInput): void => { node_id: node?.node_id ?? null, family: "child-process", relation_kinds: relationKinds, - reason: "ambiguous-target", + reason: input.reason ?? "ambiguous-target", detail, candidate_node_ids: candidateNodeIds, evidence: unknownSemanticEvidence(context.file, location), diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index 9ce9911d42..c4a2ba193e 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -15,7 +15,6 @@ import { collectContextBridgeMembers, electronCalleePath, electronStaticValue, - objectProperty, } from "./electronStaticAnalysisValues.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js"; @@ -27,6 +26,7 @@ import { } from "./javascriptStaticAnalysisHelpers.js"; import { semanticStaticPropertyKey, + staticObjectProperty, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; @@ -58,7 +58,7 @@ const needsConstantOptions = (node: t.NewExpression): boolean => { const argument = argumentNode(node.arguments[0]); const options = argument && unwrapJavaScriptExpression(argument).node; if (t.isIdentifier(options)) return true; - const lookup = objectProperty(options, "webPreferences"); + const lookup = staticObjectProperty(options, "webPreferences"); return ( lookup.status === "explicit" && t.isIdentifier(unwrapJavaScriptExpression(lookup.property.value).node) @@ -78,7 +78,7 @@ export function* finishElectronBrowserWindowsSteps( const options = argument && unwrapJavaScriptExpression(argument).node; if (t.isIdentifier(options)) return hasElectronOptionObjectCandidate(options, index); - const lookup = objectProperty(options, "webPreferences"); + const lookup = staticObjectProperty(options, "webPreferences"); return ( lookup.status === "explicit" && hasElectronOptionObjectCandidate(lookup.property.value, index) @@ -112,7 +112,7 @@ const inspectBrowserWindow = ( (index && resolveElectronOptionObject(options, node, index, ["webPreferences"])) ?? options; - const lookup = objectProperty(resolvedOptions, "webPreferences"); + const lookup = staticObjectProperty(resolvedOptions, "webPreferences"); const declaredPreferences = lookup.status === "explicit" ? unwrapJavaScriptExpression(lookup.property.value).node @@ -190,7 +190,7 @@ const collectWebPreferences = ( } => { const preferences: ElectronWebPreference[] = []; let unknown = 0; - const effectivePreload = objectProperty(object, "preload"); + const effectivePreload = staticObjectProperty(object, "preload"); const preloadPath = effectivePreload.status === "explicit" ? staticPath(effectivePreload.property.value) @@ -307,7 +307,7 @@ const inspectUtilityProcess = ( const modulePath = moduleNode === undefined ? undefined : staticPath(moduleNode); const options = argumentNode(node.arguments[2]); - const serviceNameProperty = objectProperty(options, "serviceName"); + const serviceNameProperty = staticObjectProperty(options, "serviceName"); const serviceName = serviceNameProperty.status === "explicit" ? staticPath(serviceNameProperty.property.value) diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index d1c1adcedc..5f987b3672 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -54,27 +54,6 @@ export const boundedExpression = ( return expression === "" ? `[${node.type}]` : expression; }; -/** Find the effective explicit property, preserving unresolved overrides. */ -export const objectProperty = ( - object: t.Node | undefined, - name: string, -): - | { readonly status: "missing" | "dynamic" } - | { readonly status: "explicit"; readonly property: t.ObjectProperty } => { - if (object === undefined) return { status: "missing" }; - if (!t.isObjectExpression(object)) return { status: "dynamic" }; - for (const property of object.properties.toReversed()) { - if (t.isSpreadElement(property)) return { status: "dynamic" }; - const key = semanticStaticPropertyKey(property.key, property.computed); - if (key === null) return { status: "dynamic" }; - if (key !== name) continue; - return t.isObjectProperty(property) - ? { status: "explicit", property } - : { status: "dynamic" }; - } - return { status: "missing" }; -}; - type PresentHandlerKind = | "inline-function" | "identifier" diff --git a/src/domain/javascript/javascriptAstValues.test.ts b/src/domain/javascript/javascriptAstValues.test.ts new file mode 100644 index 0000000000..457cbc3258 --- /dev/null +++ b/src/domain/javascript/javascriptAstValues.test.ts @@ -0,0 +1,72 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { + semanticStaticPropertyKey, + staticObjectProperty, +} from "./javascriptAstValues.js"; +import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; + +it("copies ordinary members without proving requested methods or getter effects", () => { + const method = (name: string, computed = false) => + t.objectMethod( + "method", + t.identifier(name), + [], + t.blockStatement([]), + computed, + ); + const getter = t.objectMethod( + "get", + t.identifier("helper"), + [], + t.blockStatement([]), + ); + const setter = t.objectMethod( + "set", + t.identifier("helper"), + [t.identifier("value")], + t.blockStatement([]), + ); + for (const [member, status] of [ + [method("helper"), "explicit"], + [setter, "explicit"], + [method("stdio"), "dynamic"], + [getter, "dynamic"], + [method("key", true), "dynamic"], + ] as const) { + const object = t.objectExpression([ + t.objectProperty(t.identifier("stdio"), t.stringLiteral("inherit")), + t.spreadElement(t.objectExpression([member])), + ]); + expect(staticObjectProperty(object, "stdio").status).toBe(status); + } +}); + +it("limits unary-plus keys to transparent numeric literals", () => { + const wrapped = t.tsAsExpression(t.numericLiteral(1), t.tsNumberKeyword()); + expect(semanticStaticPropertyKey(t.unaryExpression("+", wrapped), true)).toBe( + "1", + ); + for (const argument of [ + t.stringLiteral("1"), + t.bigIntLiteral(1n), + t.identifier("input"), + ]) + expect( + semanticStaticPropertyKey(t.unaryExpression("+", argument), true), + ).toBeNull(); +}); + +it("bounds nested inline object spreads without losing a shallow final property", () => { + let object = t.objectExpression([ + t.objectProperty(t.identifier("stdio"), t.stringLiteral("inherit")), + ]); + for (let depth = 0; depth < SEMANTIC_EXPRESSION_DEPTH_LIMIT; depth++) + object = t.objectExpression([t.spreadElement(object)]); + expect(staticObjectProperty(object, "stdio").status).toBe("explicit"); + expect( + staticObjectProperty(t.objectExpression([t.spreadElement(object)]), "stdio") + .status, + ).toBe("dynamic"); +}); diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 54edede262..f0b971100d 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -1,5 +1,6 @@ import * as t from "@babel/types"; import type { JavaScriptSemanticPrimitive } from "./javascriptSemanticValueTypes.js"; +import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; /** TypeScript receiver annotations occupy no positional runtime parameter slot. */ export const runtimeFunctionParameters = ( @@ -88,14 +89,24 @@ export const semanticStaticPropertyKey = ( computed: boolean, ): string | null => { property = unwrapJavaScriptExpression(property).node; - if (t.isStringLiteral(property) || t.isNumericLiteral(property)) - return String(property.value); - if (t.isBigIntLiteral(property)) return String(property.value); if ( - t.isUnaryExpression(property, { operator: "-" }) && - t.isBigIntLiteral(property.argument) + t.isStringLiteral(property) || + t.isNumericLiteral(property) || + t.isBooleanLiteral(property) ) - return String(-BigInt(property.argument.value)); + return String(property.value); + if (t.isNullLiteral(property)) return "null"; + if (t.isBigIntLiteral(property)) return String(property.value); + if (t.isUnaryExpression(property, { operator: "void" })) return "undefined"; + if (t.isUnaryExpression(property, { operator: "+" })) { + const argument = unwrapJavaScriptExpression(property.argument).node; + if (t.isNumericLiteral(argument)) return String(argument.value); + } + if (t.isUnaryExpression(property, { operator: "-" })) { + const argument = unwrapJavaScriptExpression(property.argument).node; + if (t.isNumericLiteral(argument)) return String(-argument.value); + if (t.isBigIntLiteral(argument)) return String(-BigInt(argument.value)); + } if ( t.isTemplateLiteral(property) && property.expressions.length === 0 && @@ -107,20 +118,78 @@ export const semanticStaticPropertyKey = ( return !computed && t.isIdentifier(property) ? property.name : null; }; +/** Syntax whose value cannot contribute own properties to an object spread. */ +export const isNullishJavaScriptSyntax = (node: t.Node): boolean => { + node = unwrapJavaScriptExpression(node).node; + return ( + t.isNullLiteral(node) || t.isUnaryExpression(node, { operator: "void" }) + ); +}; + +type StaticObjectProperty = + | { readonly status: "missing" | "dynamic" } + | { readonly status: "explicit"; readonly property: t.ObjectProperty }; + +/** Find the effective explicit property, preserving unresolved overrides. */ +export const staticObjectProperty = ( + object: t.Node | undefined, + name: string, + isNullish?: (node: t.Node) => boolean, +): StaticObjectProperty => { + if (object === undefined) return { status: "missing" }; + if (!t.isObjectExpression(object)) return { status: "dynamic" }; + const lookup = ( + current: t.ObjectExpression, + depth: number, + ): StaticObjectProperty => { + for (const property of current.properties.toReversed()) { + if (t.isSpreadElement(property)) { + const { node: spread, depth: wrappers } = unwrapJavaScriptExpression( + property.argument, + ); + if (t.isObjectExpression(spread)) { + const nextDepth = depth + wrappers + 1; + // Copying a getter can run code that changes another source key. + if ( + nextDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT || + spread.properties.some((entry) => + t.isObjectMethod(entry, { kind: "get" }), + ) + ) + return { status: "dynamic" }; + const found = lookup(spread, nextDepth); + if (found.status === "missing") continue; + return found; + } + // These primitive boxes have no own keys; strings can own index keys. + if ( + t.isBooleanLiteral(spread) || + t.isNumericLiteral(spread) || + t.isBigIntLiteral(spread) || + isNullish?.(property.argument) === true + ) + continue; + return { status: "dynamic" }; + } + const key = semanticStaticPropertyKey(property.key, property.computed); + if (key === null) return { status: "dynamic" }; + if (key !== name) continue; + return t.isObjectProperty(property) + ? { status: "explicit", property } + : { status: "dynamic" }; + } + return { status: "missing" }; + }; + return lookup(object, 0); +}; + /** Later properties own a key; an intervening dynamic write leaves it unknown. */ export const semanticFinalObjectProperty = ( node: t.ObjectExpression, key: string, ): t.Node | undefined => { - for (let index = node.properties.length - 1; index >= 0; index -= 1) { - const property = node.properties[index]; - if (property === undefined || t.isSpreadElement(property)) return undefined; - const name = semanticStaticPropertyKey(property.key, property.computed); - if (name === null) return undefined; - if (name === key) - return t.isObjectProperty(property) ? property.value : undefined; - } - return undefined; + const property = staticObjectProperty(node, key); + return property.status === "explicit" ? property.property.value : undefined; }; export const semanticFinalObjectStringProperty = ( diff --git a/src/domain/javascript/javascriptModuleDeclaration.ts b/src/domain/javascript/javascriptModuleDeclaration.ts index 30e705b085..48a8d4b9e5 100644 --- a/src/domain/javascript/javascriptModuleDeclaration.ts +++ b/src/domain/javascript/javascriptModuleDeclaration.ts @@ -1,5 +1,20 @@ import * as t from "@babel/types"; +/** Declaration syntax that establishes no runtime value, regardless of export form. */ +export const isErasedJavaScriptDeclaration = (node: t.Node): boolean => + t.isTSInterfaceDeclaration(node) || + t.isTSTypeAliasDeclaration(node) || + t.isTSDeclareFunction(node) || + ((t.isVariableDeclaration(node) || + t.isClassDeclaration(node) || + t.isTSEnumDeclaration(node) || + t.isTSModuleDeclaration(node)) && + node.declare === true); + +/** Namespace/enum lowering introduces runtime scopes absent from this source index. */ +export const isUnmodeledJavaScriptRuntimeScope = (node: t.Node): boolean => + t.isTSModuleDeclaration(node) || t.isTSEnumDeclaration(node); + /** Whether TypeScript explicitly erases this entire import declaration. */ export const isTypeOnlyImportDeclaration = ( node: t.ImportDeclaration, diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts index 104c0e1be4..bd27234417 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.test.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.test.ts @@ -18,6 +18,10 @@ describe("RPC object property ownership", () => { ['url: "/trpc/old", url: "/trpc/live"', "/trpc/live"], ['url: "/trpc/old", url: runtimeUrl', null], ['url: "/trpc/old", ...options', null], + [ + 'url: "/trpc/old", ...{helper(){},url:"/trpc/ignored",url:"/trpc/live"}', + "/trpc/live", + ], ['url: "/trpc/old", [key]: runtimeUrl', null], ['url: "/trpc/old", get url() { return runtimeUrl; }', null], ['url: "/trpc/old", url() {}', null], @@ -743,3 +747,22 @@ it("keeps existing network endpoint recognition unchanged", () => { }, ]); }); + +it("keeps named spreads unknown without proving nullish aliases", () => { + expect( + rpcEndpoints(`var p=null;const root=Function("return this")(); + root.p={url:"/changed"};httpLink({url:"/safe",...p});`), + ).toEqual([]); +}); + +it("skips propertyless literal spreads while retaining string and unknown spreads", () => { + expect( + rpcEndpoints(` + httpLink({url:"/primitives",...false,...42,...1n,[void effect()]:0}); + httpLink({url:"/string",..."ab"}); + httpLink({url:"/unknown",...options}); + `), + ).toEqual([ + { kind: "network", value: "/primitives", mechanism: "trpc:link:httpLink" }, + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts index e97b9e284f..c0727479f3 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts @@ -1,6 +1,22 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +it("resolves void keys while retaining their calls and binding writes", () => { + const ir = analyzeJavaScriptSemantics(` + import {spawn} from "node:child_process"; + const routes = {[void sideEffect()]: "HOME"}; + const root = routes[void 0]; + let argv=[]; const changed = {[void (argv={})]: 1}; + spawn("changed",argv,{stdio:"inherit"}); + `); + expect(ir.bindings.find(({ name }) => name === "root")?.value).toEqual({ + status: "literal", + value: "HOME", + }); + expect(ir.references.some(({ name }) => name === "sideEffect")).toBe(true); + expect(ir.childProcessSpawns[0]?.stdioMode).toBe("dynamic"); +}); + it.each([ "const routes = {'': 'HOME'}; const root = routes[''];", "const routes = {['']: 'HOME'}; const root = routes[''];", diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index a6266e0385..f23437394b 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -345,6 +345,7 @@ export function* analyzeParsedJavaScriptSemanticsSteps( file.program, state, callables, + references, parserPartial, ); return { diff --git a/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts index 3effa1663b..2a9a343eab 100644 --- a/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import * as t from "@babel/types"; import { @@ -8,6 +8,7 @@ import { classifyParsedJavaScriptElectronBindingsSteps, indexJavaScriptBindingsWithReceiversSteps, } from "./javascriptSemanticAnalysis.js"; +import * as semanticProjection from "./javascriptSemanticProjection.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; import { createJavaScriptGraphqlScan } from "./javascriptGraphqlOperations.js"; import { @@ -273,3 +274,25 @@ conditionalAlias.handle("conditional-alias:ping", () => "local"); [text.indexOf('require("electron")["ipcMain.handle"]'), []], ]); }); + +it("reuses the lexical references during stepped child-argument analysis", () => { + const parsed = parseJavaScriptSource( + ` + import {spawn} from "node:child_process"; + const argv=[]; spawn("tool",argv,{stdio:"inherit"}); + `, + "references.mjs", + ); + if (parsed === null) throw new Error("Expected parsed source"); + const collector = vi.spyOn(semanticProjection, "collectSemanticReferences"); + try { + const ir = completeSemanticSteps( + analyzeParsedJavaScriptSemanticsSteps(parsed), + ); + expect(ir.childProcessSpawns[0]?.stdioMode).toBe("inherit"); + expect(ir.references.some(({ name }) => name === "argv")).toBe(true); + expect(collector).not.toHaveBeenCalled(); + } finally { + collector.mockRestore(); + } +}); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 9e3c0489a6..2ffe91fe67 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -4,10 +4,13 @@ import type { JavaScriptSemanticCallable, JavaScriptSemanticChildProcessInteraction, JavaScriptSemanticChildProcessSpawn, + JavaScriptSemanticReference, } from "./javascriptSemanticIr.js"; import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; +import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; import { semanticStaticPropertyName, + staticObjectProperty, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { @@ -38,6 +41,10 @@ interface ChildProcessAnalysis { readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[]; } +type ChildSpawnContext = ReturnType< + typeof createSemanticExpressionShapeResolver +>; + interface SpawnCandidate { readonly node: t.CallExpression | t.OptionalCallExpression; readonly method: JavaScriptSemanticChildProcessSpawn["method"]; @@ -61,10 +68,12 @@ export const collectJavaScriptSemanticChildProcesses = ( program: t.Program, state: JavaScriptSemanticAnalysisState, callables: readonly JavaScriptSemanticCallable[], + references: readonly JavaScriptSemanticReference[], ): ChildProcessAnalysis => { const candidates = collectSpawns(program, state, callables); + const context = createSemanticExpressionShapeResolver(state, references); const childProcessSpawns = candidates.map((candidate) => - immutableSpawn(candidate), + immutableSpawn(candidate, context), ); return { childProcessSpawns, @@ -126,8 +135,9 @@ const collectSpawns = ( const immutableSpawn = ( candidate: SpawnCandidate, + context: ChildSpawnContext, ): JavaScriptSemanticChildProcessSpawn => { - const options = spawnOptions(candidate); + const { options, argvCount } = spawnArguments(candidate, context); const command = dataEffectLiteralString(candidate.node.arguments[0]); return { processId: spawnId(candidate), @@ -137,9 +147,9 @@ const immutableSpawn = ( resultBindingId: candidate.resultBindingId, projectedResultBindings: candidate.projectedResultBindings, command, - argvCount: spawnArgvCount(candidate), + argvCount, environmentSupplied: objectHasProperty(options, "env"), - stdioMode: stdioMode(options), + stdioMode: stdioMode(options, candidate.method, context), resolution: command === null ? "partial" : "complete", }; }; @@ -366,52 +376,73 @@ const resolveChildCandidates = ( complete: complete && candidates.size === 1, }; }; -const spawnOptions = (candidate: SpawnCandidate): t.ObjectExpression | null => { +const spawnArguments = ( + candidate: SpawnCandidate, + context: ChildSpawnContext, +): { + readonly options: t.Node | undefined; + readonly argvCount: number | null; +} => { const args = candidate.node.arguments; - - const value = - candidate.method === "exec" - ? args[1] - : t.isObjectExpression(args[1]) - ? args[1] - : args[2]; - - return t.isObjectExpression(value) ? value : null; -}; -const spawnArgvCount = (candidate: SpawnCandidate): number | null => { - if (candidate.method === "exec") return null; - const args = candidate.node.arguments[1]; - return t.isArrayExpression(args) && - !args.elements.some((element) => t.isSpreadElement(element)) - ? args.elements.length - : null; + if (t.isSpreadElement(args[0])) return { options: args[0], argvCount: null }; + const second = args[1] && unwrapJavaScriptExpression(args[1]).node; + const third = args[2] && unwrapJavaScriptExpression(args[2]).node; + const expressionShape = context; + const argv = + candidate.method !== "exec" && t.isArrayExpression(second) + ? second + : undefined; + // Only a proven argv array or nullish placeholder admits third-slot options. + // Otherwise the second argument may itself be options, ignoring the third. + const options = + candidate.method === "exec" || second === undefined + ? second + : argv !== undefined || + expressionShape.isArray(second) || + expressionShape.isNullish(second) + ? third + : second; + return { + options, + argvCount: + argv !== undefined && + !argv.elements.some((element) => t.isSpreadElement(element)) + ? argv.elements.length + : null, + }; }; -const objectHasProperty = ( - object: t.ObjectExpression | null, - name: string, -): boolean => objectProperty(object, name) !== null; +const objectHasProperty = (object: t.Node | undefined, name: string): boolean => + t.isObjectExpression(object) && + object.properties.some( + (property) => + !t.isSpreadElement(property) && + semanticStaticPropertyName(property.key, property.computed) === name, + ); -const stdioMode = (options: t.ObjectExpression | null): string => { - const property = objectProperty(options, "stdio"); - if (property === null || !t.isObjectProperty(property)) return "default"; - if (t.isStringLiteral(property.value)) return property.value.value; - if (t.isArrayExpression(property.value)) return "array"; +const stdioMode = ( + options: t.Node | undefined, + method: JavaScriptSemanticChildProcessSpawn["method"], + context: ChildSpawnContext, +): string => { + if (options === undefined) return "default"; + const expression = unwrapJavaScriptExpression(options).node; + if ( + context.isNullish(expression) || + ((method === "exec" || method === "execFile") && + context.isCallback(expression)) + ) + return "default"; + const lookup = staticObjectProperty(expression, "stdio", context.isNullish); + if (lookup.status === "missing") return "default"; + if (lookup.status !== "explicit") return "dynamic"; + const value = unwrapJavaScriptExpression(lookup.property.value).node; + if (context.isNullish(value)) return "default"; + if (t.isStringLiteral(value)) return value.value; + if (t.isArrayExpression(value)) return "array"; return "dynamic"; }; -const objectProperty = ( - object: t.ObjectExpression | null, - name: string, -): t.ObjectMethod | t.ObjectProperty | null => - object?.properties.find( - (property): property is t.ObjectMethod | t.ObjectProperty => - (t.isObjectMethod(property) || t.isObjectProperty(property)) && - // `{[env]: vars}` has a dynamic key; treating it as `env` would claim - // the caller supplied an environment when the real key is unknown. - semanticStaticPropertyName(property.key, property.computed) === name, - ) ?? null; - const objectBinding = ( node: t.Expression | t.Super, state: JavaScriptSemanticAnalysisState, diff --git a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts index b30d368592..2a36158dd6 100644 --- a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts @@ -8,6 +8,7 @@ import { collectJavaScriptSemanticDataEffects } from "./javascriptSemanticDataEf import type { JavaScriptSemanticCallable, JavaScriptSemanticIr, + JavaScriptSemanticReference, } from "./javascriptSemanticIr.js"; import { collectJavaScriptSemanticPromises } from "./javascriptSemanticPromises.js"; import { collectJavaScriptSemanticObjects } from "./javascriptSemanticObjects.js"; @@ -44,6 +45,7 @@ export function* collectJavaScriptDerivedSemanticsSteps( program: t.Program, state: JavaScriptSemanticAnalysisState, callables: readonly JavaScriptSemanticCallable[], + references: readonly JavaScriptSemanticReference[], parserPartial: boolean, ): Generator { const calls = collectJavaScriptSemanticCalls(program, state, callables); @@ -64,6 +66,7 @@ export function* collectJavaScriptDerivedSemanticsSteps( program, state, callables, + references, ); yield; const dataEffects = collectJavaScriptSemanticDataEffects( diff --git a/src/domain/javascript/javascriptSemanticExpressionShapes.test.ts b/src/domain/javascript/javascriptSemanticExpressionShapes.test.ts new file mode 100644 index 0000000000..dbb92cfff9 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticExpressionShapes.test.ts @@ -0,0 +1,161 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { + analyzeParsedJavaScriptSemantics, + indexJavaScriptBindingsSteps, +} from "./javascriptSemanticAnalysis.js"; +import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; +import { collectSemanticReferences } from "./javascriptSemanticProjection.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} 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"]], + ["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"]], + ["let a=[];(()=>{}).constructor('a={}')();read(a);", ["unknown"]], + [ + "read(null); read(void effect()); read(undefined);", + ["nullish", "nullish", "nullish"], + ], + ["function f(undefined){read(undefined)}", ["unknown"]], + ["const a=null; read(a);", ["unknown"]], + ["function f(a){a=null;arguments[0]={};read(a)}", ["unknown"]], + ["var a=null;Function('a={}');read(a);", ["unknown"]], + ["var a=null;frames.a={};read(a);", ["unknown"]], + ["function f(){const a=[];a.push(1);read(a);}", ["array"]], + ["function f(){const a=[];const saved=a;read(saved);}", ["array"]], + ["function f(){let a=[];eval('a={}');read(a)}", ["unknown"]], + ["function f(){eval('var undefined={}');read(undefined)}", ["unknown"]], + ["var a=[];Function('a={}');read(a);", ["unknown"]], + ["var a=[];frames.a={};read(a);", ["unknown"]], + ["let a; if(flag)a=[];read(a);", ["unknown"]], +] as const)( + "bounds shape proofs without refining named nullish values: %s", + (source, expected) => { + const file = parseJavaScriptSource(source, "fixture.ts"); + if (file === null) throw new Error("Expected valid source"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(file.program), + ); + const resolver = createSemanticExpressionShapeResolver( + state, + collectSemanticReferences(state.program, state), + ); + const shapes: string[] = []; + traverseJavaScriptAst(file.program, { + enter(node) { + if ( + !t.isCallExpression(node) || + !t.isIdentifier(node.callee, { name: "read" }) + ) + return; + const arg = node.arguments[0]; + if (arg === undefined) throw new Error("Expected read argument"); + shapes.push( + resolver.isArray(arg) + ? "array" + : resolver.isCallback(arg) + ? "callback" + : resolver.isNullish(arg) + ? "nullish" + : "unknown", + ); + }, + }); + expect(shapes).toEqual(expected); + }, +); + +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);}`; + const file = parseJavaScriptSource(source, "deep.mjs"); + if (file === null) throw new Error("Expected valid deep source"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(file.program), + ); + const resolver = createSemanticExpressionShapeResolver( + state, + collectSemanticReferences(state.program, state), + ); + const results: boolean[] = []; + traverseJavaScriptAst(file.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "read" }) + ) + results.push(resolver.isCallback(node.arguments[0]!)); + }, + }); + expect(results).toEqual([false, true, true]); + expect(state.valueResourceLimits).toContain("expression-depth"); +}); + +it.each([ + ["fixture.cjs", "const argv=[];const done=()=>{};", ["inherit", "default"]], + ["fixture.cts", "const argv=[];const done=()=>{};", ["inherit", "default"]], + [ + "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);", + ["dynamic", "dynamic"], + ], + [ + "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"], + ], +] as const)( + "distinguishes wrapper-local child identities and uncertain bindings: %s (%s)", + (path, declarations, expected) => { + const file = parseJavaScriptSource( + ` + const {spawn,exec}=require("node:child_process"); + ${declarations} + spawn("argv",argv,{stdio:"inherit"}); exec("callback",done); + `, + path, + ); + if (file === null) throw new Error("Expected parsed source"); + expect( + analyzeParsedJavaScriptSemantics(file).childProcessSpawns.map( + ({ stdioMode }) => stdioMode, + ), + ).toEqual(expected); + }, +); diff --git a/src/domain/javascript/javascriptSemanticExpressionShapes.ts b/src/domain/javascript/javascriptSemanticExpressionShapes.ts new file mode 100644 index 0000000000..eb44bb6259 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticExpressionShapes.ts @@ -0,0 +1,135 @@ +import * as t from "@babel/types"; + +import { + isNullishJavaScriptSyntax, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { + isErasedJavaScriptDeclaration, + isUnmodeledJavaScriptRuntimeScope, +} from "./javascriptModuleDeclaration.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import type { JavaScriptSemanticReference } from "./javascriptSemanticIr.js"; +import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; +import { + isUnshadowedGlobal, + resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; + +type Shape = "array" | "callback" | "unknown"; +const commonJsWrapperParameterNames = new Set([ + "exports", + "require", + "module", + "__filename", + "__dirname", +]); + +/** Bounded child-argument identity only; never recover named object contents. */ +export const createSemanticExpressionShapeResolver = ( + state: JavaScriptSemanticAnalysisState, + references: readonly JavaScriptSemanticReference[], +) => { + 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; + const blockFunctionNames = new Set(); + const namedProofBlocked = (): boolean => { + if (blocked !== undefined) return blocked; + blocked = references.some(({ name }) => name === "eval"); + if (!blocked) + traverseJavaScriptAst(state.program, { + enter(node) { + blocked ||= + isErasedJavaScriptDeclaration(node) || + isUnmodeledJavaScriptRuntimeScope(node); + if ( + javaScriptRuntimeContext(state.program) !== "module" && + t.isFunctionDeclaration(node) && + node.id != null && + !hoistedFunction(node) + ) + blockFunctionNames.add(node.id.name); + }, + }); + return blocked; + }; + const shape = (input: t.Node): Shape => { + const seen = new Set(); + let node = input; + let depth = 0; + while (true) { + const unwrapped = unwrapJavaScriptExpression(node); + node = unwrapped.node; + depth += unwrapped.depth; + if (depth > SEMANTIC_EXPRESSION_DEPTH_LIMIT) { + state.valueResourceLimits.add("expression-depth"); + return "unknown"; + } + 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) + return "unknown"; + if (state.scopesById.get(binding.scopeId)?.kind === "program") { + const context = javaScriptRuntimeContext(state.program); + // Unknown scripts expose globals; wrapper parameters can alias arguments. + // Neither environment has a proven stable local initializer here. + if ( + context === "unknown" || + (context === "commonjs" && + commonJsWrapperParameterNames.has(binding.name)) + ) + return "unknown"; + } + // Annex B may copy a block function into an outer var without an indexed + // assignment. Keep same-named vars unknown rather than model that copy. + if ( + namedProofBlocked() || + (binding.declarationKinds.has("var") && + blockFunctionNames.has(binding.name)) + ) + return "unknown"; + const initializer = semanticStableInitializer(binding, state); + 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) + ) + return "unknown"; + node = initializer.node; + depth++; + } + }; + return { + isArray: (node: t.Node) => shape(node) === "array", + isCallback: (node: t.Node) => shape(node) === "callback", + isNullish: (input: t.Node) => { + const { node } = unwrapJavaScriptExpression(input); + return ( + isNullishJavaScriptSyntax(node) || + (isUnshadowedGlobal(node, state, "undefined") && !namedProofBlocked()) + ); + }, + }; +}; diff --git a/src/domain/javascript/javascriptSourceParser.test.ts b/src/domain/javascript/javascriptSourceParser.test.ts index e5f829a356..ae1a9ed6fe 100644 --- a/src/domain/javascript/javascriptSourceParser.test.ts +++ b/src/domain/javascript/javascriptSourceParser.test.ts @@ -1,8 +1,53 @@ import { expect, it } from "vitest"; +import * as t from "@babel/types"; -import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { + parseJavaScriptSource, + parseRecoveredJavaScript, + parserPluginsForPath, + javaScriptRuntimeContext, +} from "./javascriptSourceParser.js"; import { analyzeParsedJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +it.each([ + ['import type {T} from "example";', "fixture.cts", "commonjs"], + ['import type {T} from "example";', "fixture.mts", "module"], + ['import type {T} from "example";', "fixture.ts", "unknown"], + ['import cp = require("node:child_process");', "fixture.ts", "module"], + ['import type cp = require("node:child_process");', "fixture.ts", "unknown"], + ["const value=[]; export = value;", "fixture.ts", "commonjs"], + ['export type {T} from "example";', "fixture.ts", "unknown"], + ['import {type T} from "example";', "fixture.ts", "unknown"], + ['export {type T} from "example";', "fixture.ts", "unknown"], + ["export declare const value: unknown;", "fixture.ts", "unknown"], + ["export default function f(): void;", "fixture.ts", "unknown"], + ["export default interface I {}", "fixture.ts", "unknown"], + ["export default function f() {}", "fixture.ts", "module"], + ["export default class C {}", "fixture.ts", "module"], + ['import {} from "example";', "fixture.js", "module"], + ['import {} from "example";', "fixture.cjs", "commonjs"], + ["export {};", "fixture.ts", "module"], + ["const value = 1;", "fixture.mjs", "module"], + ["const value = 1;", "fixture.js", "unknown"], + ["const value = 1;", "fixture.cjs", "commonjs"], + ["const value = 1;", "fixture.cts", "commonjs"], + ["declare const value: unknown;", "fixture.d.cts", "unknown"], +] as const)( + "keeps runtime context consistent across parser entries: %s (%s)", + (source, path, context) => { + const parsed = parseJavaScriptSource(source, path); + if (parsed === null) throw new Error("Expected parsed source"); + const recovered = parseRecoveredJavaScript(source, path, { + plugins: parserPluginsForPath(path), + }).file; + expect(javaScriptRuntimeContext(parsed.program)).toBe(context); + expect(javaScriptRuntimeContext(recovered.program)).toBe(context); + expect(javaScriptRuntimeContext(t.cloneNode(parsed.program, true))).toBe( + "unknown", + ); + }, +); + const analyze = (source: string, path: string) => { const parsed = parseJavaScriptSource(source, path); return parsed === null diff --git a/src/domain/javascript/javascriptSourceParser.ts b/src/domain/javascript/javascriptSourceParser.ts index d1141a2434..5038ccbcd5 100644 --- a/src/domain/javascript/javascriptSourceParser.ts +++ b/src/domain/javascript/javascriptSourceParser.ts @@ -1,8 +1,79 @@ import { parse, type ParserOptions, type ParserPlugin } from "@babel/parser"; +import * as t from "@babel/types"; +import { + hasOnlyInlineTypeExportSpecifiers, + hasOnlyInlineTypeImportSpecifiers, + isTypeOnlyExportDeclaration, + isTypeOnlyImportDeclaration, + isErasedJavaScriptDeclaration, + semanticImportEqualsSpecifier, +} from "./javascriptModuleDeclaration.js"; /** Babel AST produced by REA's inert JavaScript parser boundary. */ export type ParsedJavaScriptSource = ReturnType; +// Babel's module classification includes erased TypeScript syntax. Keep the +// runtime proof separate, including explicit filename mode, for every parser entry. +type JavaScriptRuntimeContext = "module" | "commonjs" | "unknown"; +const runtimeContextByProgram = new WeakMap< + t.Program, + JavaScriptRuntimeContext +>(); + +/** Only parser-owned facts establish module or CommonJS wrapper isolation. */ +export const javaScriptRuntimeContext = ( + program: t.Program, +): JavaScriptRuntimeContext => + runtimeContextByProgram.get(program) ?? "unknown"; + +const hasRuntimeModuleDeclaration = (program: t.Program): boolean => + program.body.some((node) => { + if (t.isTSImportEqualsDeclaration(node)) + return semanticImportEqualsSpecifier(node) !== null; + if (t.isImportDeclaration(node)) + return ( + !isTypeOnlyImportDeclaration(node) && + !hasOnlyInlineTypeImportSpecifiers(node) + ); + if (t.isExportNamedDeclaration(node) || t.isExportAllDeclaration(node)) + return ( + !isTypeOnlyExportDeclaration(node) && + (!t.isExportNamedDeclaration(node) || + (!hasOnlyInlineTypeExportSpecifiers(node) && + (node.declaration == null || + !isErasedJavaScriptDeclaration(node.declaration)))) + ); + return ( + t.isExportDefaultDeclaration(node) && + !isErasedJavaScriptDeclaration(node.declaration) + ); + }); + +const parseWithRuntimeContext = ( + source: string, + sourcePath: string | undefined, + options: ParserOptions, +): ParsedJavaScriptSource => { + const file = parse(source, options); + runtimeContextByProgram.set( + file.program, + /\.c[jt]s$/iu.test(sourcePath ?? "") + ? /\.d\.cts$/iu.test(sourcePath ?? "") + ? "unknown" + : "commonjs" + : isExplicitModuleSourcePath(sourcePath) + ? "module" + : file.program.body.some((node) => t.isTSExportAssignment(node)) + ? // CommonJS isolation does not establish strict ESM semantics. + "commonjs" + : file.program.sourceType === "module" && + hasRuntimeModuleDeclaration(file.program) + ? "module" + : "unknown", + ); + return file; +}; + /** * Babel plugins for one source path. Single owner for TypeScript/JSX * mode selection (tsc + Babel `typescript` plugin semantics). @@ -82,7 +153,7 @@ export const parseRecoveredJavaScript = ( delete normalizedOptions.allowNewTargetOutsideFunction; } return { - file: parse(source, { + file: parseWithRuntimeContext(source, sourcePath, { ...normalizedOptions, sourceType, errorRecovery: true, @@ -90,14 +161,14 @@ export const parseRecoveredJavaScript = ( moduleOnlyErrors: [], }; } - const file = parse(source, { + const file = parseWithRuntimeContext(source, sourcePath, { ...options, sourceType: "unambiguous", errorRecovery: true, }); if (file.program.sourceType !== "script" || file.errors.length === 0) return { file, moduleOnlyErrors: [] }; - const script = parse(source, { + const script = parseWithRuntimeContext(source, sourcePath, { ...options, sourceType: "script", errorRecovery: true, @@ -124,7 +195,7 @@ export const parseJavaScriptSource = ( attachComment: false, plugins: parserPluginsForPath(sourcePath), }; - const file = parse(source, { + const file = parseWithRuntimeContext(source, sourcePath, { ...options, // Inert artifact analysis retains module declarations even in .cjs assets. // Infer their syntax here; reference imports keep filename-mode diagnostics. @@ -137,7 +208,10 @@ export const parseJavaScriptSource = ( return commonjs && file.program.sourceType === "script" && file.errors.length > 0 - ? parse(source, { ...options, sourceType: "commonjs" }) + ? parseWithRuntimeContext(source, sourcePath, { + ...options, + sourceType: "commonjs", + }) : file; } catch (cause: unknown) { // Unparseable source is represented by the null return. diff --git a/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts b/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts new file mode 100644 index 0000000000..27c722df49 --- /dev/null +++ b/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts @@ -0,0 +1,359 @@ +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( + 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); + } + }, +); diff --git a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts index d89e0638e5..9975c1c679 100644 --- a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts +++ b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts @@ -11,6 +11,8 @@ import { importReferenceSource } from "../../support/referenceSourceResourceScop it("resolves require calls by lexical binding and source mode", async () => { const root = await createTestTempDirectory("rea-reference-require-scope-"); const sources: Record = { + "global-wrapper.cjs": + "globalThis; console.log(require.resolve('./loaded.cjs'));", "parameter.cjs": [ "function load(require) { return require('./throwing.cjs'); }", "console.log(load(() => 'local'));", @@ -115,6 +117,7 @@ it("resolves require calls by lexical binding and source mode", async () => { .sort((left, right) => (left[0] ?? "").localeCompare(right[0] ?? "")), ).toEqual([ ["create-require-esm.mjs", "loaded.cjs"], + ["global-wrapper.cjs", "loaded.cjs"], ["nested.cjs", "loaded.cjs"], ["top-level-var.cjs", "loaded.cjs"], ]);