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

@@ -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.