mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
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");
|
||||
});
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
Reference in new issue
Block a user