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
This commit is contained in:
星闌 authored and GitHub committed 2026-10-17 15:51:55 +08:00
1 parent 0cd4eafd61
commit de9371b85d
18 files changed
+1114 -93

No files matched your search

@@ -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),
@@ -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)
@@ -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"
@@ -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");
});
+84 -15
View File
@@ -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 = (
@@ -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,
@@ -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" },
]);
});
@@ -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[''];",
@@ -345,6 +345,7 @@ export function* analyzeParsedJavaScriptSemanticsSteps(
file.program,
state,
callables,
references,
parserPartial,
);
return {
@@ -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();
}
});
@@ -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,
@@ -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<void, DerivedSemanticAnalysis> {
const calls = collectJavaScriptSemanticCalls(program, state, callables);
@@ -64,6 +66,7 @@ export function* collectJavaScriptDerivedSemanticsSteps(
program,
state,
callables,
references,
);
yield;
const dataEffects = collectJavaScriptSemanticDataEffects(
@@ -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);
},
);
@@ -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<string>();
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<t.Node>();
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())
);
},
};
};
@@ -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
@@ -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<typeof parse>;
// 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.
@@ -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<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);
}
},
);
@@ -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<string, string> = {
"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"],
]);