fix(javascript): preserve builtin identity and share alias traversal
Track module writes and escapes at their owner, preserve capture and reassignment ordering, and share CommonJS/default namespace identity without erasing parent-slot changes. Carry write sites into ambient alias resolution and use parsed write targets across consumers. Deduplicate returned-reference work with cycle context; retain unchanged temporal selection identity so provenance memoization stays effective. Reviewed fixes include #2041, #2465, #2830, #2881, and #2948. Remove superseded mutation representations and the ineffective per-markValue dedup layer. Validation: 380 focused JavaScript tests passed under a 384 MiB heap cap, including the previously aborting 30-level/128-call alias regression. Six public CLI/MCP builtin tests passed. Exact pdf.js completed-workflow performance remains unmeasured.
This commit is contained in:
parent
0f4c484be5
commit
04672f2b91
|
|
@ -65,6 +65,20 @@ it("projects closure and direct interprocedural flow without execution", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("projects a destructuring assignment member target as a property write", () => {
|
||||
const graph = graphFor(`
|
||||
const source = { count: 1 };
|
||||
[source.count] = [2];
|
||||
`);
|
||||
const operations = graph.nodes.filter(
|
||||
({ properties }) => properties.operation_kind !== undefined,
|
||||
);
|
||||
expect(operations.map(({ label }) => label)).toEqual(["write:/count"]);
|
||||
expect(graph.relations).not.toContainEqual(
|
||||
expect.objectContaining({ relation: "reads-property" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps dynamic calls explicit and returns complete deterministic results", () => {
|
||||
const graph = graphFor(`
|
||||
const handlers = { ready() { return 1; } };
|
||||
|
|
|
|||
|
|
@ -351,6 +351,7 @@ const emptyAnalysis = (): JavaScriptArtifactAnalysis => ({
|
|||
packages: [],
|
||||
json_modules: [],
|
||||
html_scripts: [],
|
||||
html_inline_scripts: [],
|
||||
source_maps: [],
|
||||
visited_ast_nodes: 0,
|
||||
findings: 0,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
// Webpack 5 standalone bootstrap with an Electron external, as emitted for
|
||||
// `target: "electron-main"`.
|
||||
const bootstrap = (external: string, consumer: string, entry = "n(100);") =>
|
||||
`(()=>{var m={45840:${external},100:(e,t,n)=>{"use strict";${consumer}}},c={};` +
|
||||
`function n(t){var r=c[t];if(void 0!==r)return r.exports;var o=c[t]={exports:{}};` +
|
||||
`return m[t](o,o.exports,n),o.exports}${entry}})();`;
|
||||
|
||||
const EXTERNAL = 'e=>{"use strict";e.exports=require("electron")}';
|
||||
const CONSUMER =
|
||||
'const s=n(45840);new s.BrowserWindow({webPreferences:{preload:"./preload.js",contextIsolation:!1}});' +
|
||||
's.ipcMain.handle("get-connections",async()=>[]);';
|
||||
|
||||
const electronOf = (source: string) =>
|
||||
analyzeJavaScriptStaticSource(source).electron;
|
||||
|
||||
describe("Electron identity through Webpack externals", () => {
|
||||
it("follows the loader passed to a module factory", () => {
|
||||
const electron = electronOf(bootstrap(EXTERNAL, CONSUMER));
|
||||
expect(electron.browser_windows).toMatchObject([
|
||||
{ preload_path: "./preload.js" },
|
||||
]);
|
||||
expect(electron.ipc).toMatchObject([
|
||||
{ channel: "get-connections", side: "main" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("follows a direct member read from the loader call", () => {
|
||||
expect(
|
||||
electronOf(
|
||||
bootstrap(
|
||||
EXTERNAL,
|
||||
'new(n(45840).BrowserWindow)({webPreferences:{preload:"./p.js"}});',
|
||||
),
|
||||
).browser_windows,
|
||||
).toMatchObject([{ preload_path: "./p.js" }]);
|
||||
});
|
||||
|
||||
it("follows the runtime loader called from the bootstrap body", () => {
|
||||
expect(
|
||||
electronOf(
|
||||
bootstrap(
|
||||
EXTERNAL,
|
||||
"",
|
||||
'const s=n(45840);new s.BrowserWindow({webPreferences:{preload:"./p.js"}});',
|
||||
),
|
||||
).browser_windows,
|
||||
).toMatchObject([{ preload_path: "./p.js" }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an external overwritten after the require",
|
||||
bootstrap('e=>{e.exports=require("electron");e.exports=other}', CONSUMER),
|
||||
],
|
||||
[
|
||||
"a local module table entry",
|
||||
bootstrap("e=>{e.exports={BrowserWindow:class{}}}", CONSUMER),
|
||||
],
|
||||
[
|
||||
"a shadowed require inside the external",
|
||||
bootstrap(
|
||||
'e=>{const require=x=>x;e.exports=require("electron")}',
|
||||
CONSUMER,
|
||||
),
|
||||
],
|
||||
[
|
||||
"a reassigned loader parameter",
|
||||
bootstrap(EXTERNAL, `n=other;${CONSUMER}`),
|
||||
],
|
||||
[
|
||||
"a loader-shaped call outside a proven bootstrap",
|
||||
`var m={45840:${EXTERNAL}};function n(t){return other(t)}${CONSUMER}`,
|
||||
],
|
||||
[
|
||||
"a different table key",
|
||||
bootstrap(EXTERNAL, CONSUMER.replace("n(45840)", "n(45841)")),
|
||||
],
|
||||
])("keeps %s unresolved", (_name, source) => {
|
||||
const electron = electronOf(source);
|
||||
expect(electron.browser_windows).toEqual([]);
|
||||
// Only direct observations come from this identity; the separate
|
||||
// wrapper-substitution path keeps its own resolution basis.
|
||||
expect(
|
||||
electron.ipc.filter(
|
||||
({ resolution_basis: basis }) => basis !== "wrapper-substitution",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webpack externals with a tampered runtime", () => {
|
||||
it.each([
|
||||
[
|
||||
"a replaced bootstrap loader",
|
||||
bootstrap(
|
||||
EXTERNAL,
|
||||
CONSUMER,
|
||||
"const o=n;n=k=>k===100?o(k):local;n(100);",
|
||||
),
|
||||
],
|
||||
[
|
||||
"a generator external",
|
||||
bootstrap('function*(e){e.exports=require("electron")}', CONSUMER),
|
||||
],
|
||||
[
|
||||
"an async external",
|
||||
bootstrap('async e=>{e.exports=require("electron")}', CONSUMER),
|
||||
],
|
||||
[
|
||||
"a loader that returns something else",
|
||||
bootstrap(EXTERNAL, CONSUMER).replace(
|
||||
"return m[t](o,o.exports,n),o.exports}",
|
||||
"return m[t](o,o.exports,n),{BrowserWindow:class{}}}",
|
||||
),
|
||||
],
|
||||
[
|
||||
"a table slot replaced before loading",
|
||||
bootstrap(
|
||||
EXTERNAL,
|
||||
CONSUMER,
|
||||
"m[45840]=e=>{e.exports={BrowserWindow:class{}}};n(100);",
|
||||
),
|
||||
],
|
||||
[
|
||||
"a table slot replaced through an alias",
|
||||
bootstrap(EXTERNAL, CONSUMER, "const a=m;a[45840]=local;n(100);"),
|
||||
],
|
||||
[
|
||||
"a slot replaced through the runtime exposure",
|
||||
bootstrap(EXTERNAL, CONSUMER, "n.m=m;n.m[45840]=local;n(100);"),
|
||||
],
|
||||
[
|
||||
"a cache entry planted before loading",
|
||||
bootstrap(EXTERNAL, CONSUMER, "c[45840]={exports:local};n(100);"),
|
||||
],
|
||||
[
|
||||
"a computed table key that may collide",
|
||||
bootstrap(EXTERNAL, CONSUMER).replace(
|
||||
"c={};",
|
||||
"c={};m={...m,[key]:e=>{e.exports=local}};",
|
||||
),
|
||||
],
|
||||
[
|
||||
"an unresolved computed key in the table",
|
||||
bootstrap(EXTERNAL, CONSUMER).replace(
|
||||
`45840:${EXTERNAL},`,
|
||||
`45840:${EXTERNAL},[key]:e=>{e.exports=local},`,
|
||||
),
|
||||
],
|
||||
])("keeps %s unresolved", (_name, source) => {
|
||||
const electron = electronOf(source);
|
||||
expect(electron.browser_windows).toEqual([]);
|
||||
// Only direct observations come from this identity; the separate
|
||||
// wrapper-substitution path keeps its own resolution basis.
|
||||
expect(
|
||||
electron.ipc.filter(
|
||||
({ resolution_basis: basis }) => basis !== "wrapper-substitution",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webpack externals with a tampered loader or module object", () => {
|
||||
it.each([
|
||||
[
|
||||
"cached exports rewritten through the runtime exposure",
|
||||
bootstrap(
|
||||
EXTERNAL,
|
||||
CONSUMER,
|
||||
"n.c=c;n(45840);n.c[45840].exports=local;n(100);",
|
||||
),
|
||||
],
|
||||
[
|
||||
"a pre-populated cache",
|
||||
bootstrap(EXTERNAL, CONSUMER).replace(
|
||||
"c={};",
|
||||
"c={45840:{exports:local}};",
|
||||
),
|
||||
],
|
||||
[
|
||||
"exports rewritten in the returned sequence",
|
||||
bootstrap(EXTERNAL, CONSUMER).replace(
|
||||
"return m[t](o,o.exports,n),o.exports}",
|
||||
"return m[t](o,o.exports,n),o.exports=local,o.exports}",
|
||||
),
|
||||
],
|
||||
[
|
||||
"exports rewritten before the return",
|
||||
bootstrap(EXTERNAL, CONSUMER).replace(
|
||||
"return m[t](o,o.exports,n),o.exports}",
|
||||
"m[t](o,o.exports,n);o.exports=local;return o.exports}",
|
||||
),
|
||||
],
|
||||
[
|
||||
"an alias of the exposed table",
|
||||
bootstrap(EXTERNAL, CONSUMER, "n.m=m;const a=n.m;a[45840]=local;n(100);"),
|
||||
],
|
||||
[
|
||||
"a rewritten module key",
|
||||
bootstrap(EXTERNAL, CONSUMER).replace(
|
||||
"function n(t){",
|
||||
"function n(t){if(t===45840)t=100;",
|
||||
),
|
||||
],
|
||||
[
|
||||
"a dispatch behind a guard",
|
||||
bootstrap(EXTERNAL, CONSUMER).replace(
|
||||
"return m[t](o,o.exports,n),o.exports}",
|
||||
"if(t===100)m[t](o,o.exports,n);return o.exports}",
|
||||
),
|
||||
],
|
||||
[
|
||||
"duplicate factory parameter names",
|
||||
bootstrap('function(e,e,n){e.exports=require("electron")}', CONSUMER),
|
||||
],
|
||||
[
|
||||
"an accessor for module exports",
|
||||
bootstrap(EXTERNAL, CONSUMER).replace(
|
||||
"var o=c[t]={exports:{}}",
|
||||
"var o=c[t]={get exports(){return local},set exports(v){}}",
|
||||
),
|
||||
],
|
||||
])("keeps %s unresolved", (_name, source) => {
|
||||
const electron = electronOf(source);
|
||||
expect(electron.browser_windows).toEqual([]);
|
||||
// Only direct observations come from this identity; the separate
|
||||
// wrapper-substitution path keeps its own resolution basis.
|
||||
expect(
|
||||
electron.ipc.filter(
|
||||
({ resolution_basis: basis }) => basis !== "wrapper-substitution",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a cache cleared when the factory throws",
|
||||
bootstrap(EXTERNAL, CONSUMER).replace(
|
||||
"return m[t](o,o.exports,n),o.exports}",
|
||||
"var i=!0;try{m[t].call(o.exports,o,o.exports,n),i=!1}finally{i&&delete c[t]}return o.exports}",
|
||||
),
|
||||
],
|
||||
[
|
||||
"the runtime exposing its cache",
|
||||
bootstrap(EXTERNAL, CONSUMER, "n.c=c;n(100);"),
|
||||
],
|
||||
[
|
||||
"chunk installation through the runtime table",
|
||||
bootstrap(
|
||||
EXTERNAL,
|
||||
CONSUMER,
|
||||
"n.m=m;n.i=e=>{for(var k in e)n.m[k]=e[k]};n(100);",
|
||||
),
|
||||
],
|
||||
[
|
||||
"a loaded flag set before returning",
|
||||
bootstrap(EXTERNAL, CONSUMER).replace(
|
||||
"return m[t](o,o.exports,n),o.exports}",
|
||||
"return m[t].call(o.exports,o,o.exports,n),o.loaded=!0,o.exports}",
|
||||
),
|
||||
],
|
||||
])("still resolves with %s", (_name, source) => {
|
||||
expect(electronOf(source).browser_windows).toMatchObject([
|
||||
{ preload_path: "./preload.js" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -94,6 +94,41 @@ export const semanticObjectPatternKeys = (
|
|||
return key === null ? [] : [key];
|
||||
});
|
||||
|
||||
/** Return the syntactic node whose target this construct writes. */
|
||||
export const semanticWriteSiteTarget = (node: t.Node): t.Node | null => {
|
||||
if (t.isAssignmentExpression(node)) return node.left;
|
||||
if (t.isUpdateExpression(node)) return node.argument;
|
||||
if (t.isUnaryExpression(node, { operator: "delete" })) return node.argument;
|
||||
if (t.isForOfStatement(node) || t.isForInStatement(node)) return node.left;
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Whether a write site reads the previous target value before writing. */
|
||||
export const semanticWriteSiteReadsFirst = (site: t.Node): boolean =>
|
||||
(t.isAssignmentExpression(site) && site.operator !== "=") ||
|
||||
t.isUpdateExpression(site);
|
||||
|
||||
/** Find the owning write site through inert TypeScript and parenthesis wrappers. */
|
||||
export const semanticWrappedWriteSite = (
|
||||
node: t.Node,
|
||||
parent: t.Node,
|
||||
ancestors: readonly t.Node[],
|
||||
): t.Node | null => {
|
||||
const chain =
|
||||
ancestors.at(-1) === parent ? ancestors : [...ancestors, parent];
|
||||
for (let index = chain.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = chain[index];
|
||||
if (candidate === undefined) return null;
|
||||
const target = semanticWriteSiteTarget(candidate);
|
||||
if (target !== null)
|
||||
return unwrapJavaScriptExpression(target).node === node
|
||||
? candidate
|
||||
: null;
|
||||
if (unwrapJavaScriptExpression(candidate).depth === 0) return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Visit binding and member leaves in direct and destructuring write targets. */
|
||||
export function* semanticWriteTargets(
|
||||
root: t.Node,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,527 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import type { javascriptBootstrapAt } from "./javascriptBootstrap.js";
|
||||
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
semanticStableInitializer,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import { argumentValue } from "./javascriptStaticAnalysisHelpers.js";
|
||||
|
||||
type Bootstrap = NonNullable<ReturnType<typeof javascriptBootstrapAt>>;
|
||||
|
||||
/** Slots a visible literal write may have replaced; the rest stay as declared. */
|
||||
export interface JavaScriptBootstrapIntegrity {
|
||||
readonly replacedKeys: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface LoaderShape {
|
||||
readonly parameterId: string;
|
||||
/** The single unconditional `table[key](...)` call. */
|
||||
readonly dispatch: t.CallExpression;
|
||||
readonly dispatches: ReadonlySet<t.Node>;
|
||||
readonly moduleId: string;
|
||||
readonly cacheId: string | undefined;
|
||||
/** Cache reads, cache writes and deletes the loader itself performs. */
|
||||
readonly cacheSites: ReadonlySet<t.Node>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove that `loader(key)` returns the exports of the factory declared at
|
||||
* `table[key]`: the loader returns only the dispatched module's exports or a
|
||||
* cache entry it created for that module, the table has only literal keys,
|
||||
* and the table and cache are referenced only by the loader or through the
|
||||
* runtime's `loader.m`/`loader.c` exposure. Literal-key writes through that
|
||||
* exposure replace their slot. Returns undefined when any of this is unproven.
|
||||
*/
|
||||
export const proveJavaScriptBootstrapIntegrity = (
|
||||
call: t.CallExpression,
|
||||
bootstrap: Bootstrap,
|
||||
loaderIds: ReadonlySet<string>,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptBootstrapIntegrity | undefined => {
|
||||
if (
|
||||
bootstrap.table.properties.some((property) =>
|
||||
t.isSpreadElement(property)
|
||||
? true
|
||||
: semanticStaticPropertyKey(property.key, property.computed) === null,
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
const loader = bootstrap.body.body.find(
|
||||
(statement): statement is t.FunctionDeclaration =>
|
||||
t.isFunctionDeclaration(statement) &&
|
||||
statement.id !== null &&
|
||||
statement.id !== undefined &&
|
||||
bindingOf(state, statement.id) === bootstrap.loaderBindingId,
|
||||
);
|
||||
const shape =
|
||||
loader === undefined
|
||||
? undefined
|
||||
: loaderShape(loader, bootstrap.tableBindingId, state);
|
||||
if (shape === undefined) return undefined;
|
||||
return referencesAreContained(call, { bootstrap, shape, loaderIds }, state);
|
||||
};
|
||||
|
||||
const loaderShape = (
|
||||
loader: t.FunctionDeclaration,
|
||||
tableId: string,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): LoaderShape | undefined => {
|
||||
const [parameter] = loader.params;
|
||||
const parameterId = t.isIdentifier(parameter)
|
||||
? bindingOf(state, parameter)
|
||||
: undefined;
|
||||
// A rewritten key would dispatch a different slot than the caller named.
|
||||
if (
|
||||
parameterId === undefined ||
|
||||
state.bindingsById.get(parameterId)?.definitions.length !== 1 ||
|
||||
loader.async ||
|
||||
loader.generator
|
||||
)
|
||||
return undefined;
|
||||
const dispatches = new Set<t.Node>();
|
||||
const calls: t.CallExpression[] = [];
|
||||
const modules = new Set<string>();
|
||||
const returns: (t.Expression | null | undefined)[] = [];
|
||||
traverseJavaScriptAst(loader.body, {
|
||||
skip: (node) => t.isFunction(node),
|
||||
enter: (node) => {
|
||||
if (t.isReturnStatement(node)) returns.push(node.argument);
|
||||
if (!t.isCallExpression(node)) return;
|
||||
const dispatch = tableDispatch(node, tableId, parameterId, state);
|
||||
if (dispatch === undefined) return;
|
||||
dispatches.add(dispatch.table);
|
||||
calls.push(node);
|
||||
if (dispatch.moduleId !== undefined) modules.add(dispatch.moduleId);
|
||||
},
|
||||
});
|
||||
const [moduleId] = modules;
|
||||
const [dispatch] = calls;
|
||||
if (
|
||||
calls.length !== 1 ||
|
||||
dispatch === undefined ||
|
||||
modules.size !== 1 ||
|
||||
moduleId === undefined ||
|
||||
!runsUnconditionally(dispatch, loader, state)
|
||||
)
|
||||
return undefined;
|
||||
const cache = moduleCache(moduleId, parameterId, state);
|
||||
if (
|
||||
cache === null ||
|
||||
!moduleObjectContained(loader, moduleId, dispatch, state)
|
||||
)
|
||||
return undefined;
|
||||
const shape: LoaderShape = {
|
||||
parameterId,
|
||||
dispatch,
|
||||
dispatches,
|
||||
moduleId,
|
||||
cacheId: cache?.cacheId,
|
||||
cacheSites: new Set(cache === undefined ? [] : [cache.site]),
|
||||
};
|
||||
return returns.every((argument) =>
|
||||
returnsModuleExports(argument, shape, state),
|
||||
)
|
||||
? withCacheReads(loader, shape, state)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** `table[key](module, ...)` or `table[key].call(thisArg, module, ...)`. */
|
||||
const tableDispatch = (
|
||||
call: t.CallExpression,
|
||||
tableId: string,
|
||||
parameterId: string,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): { readonly table: t.Node; readonly moduleId?: string } | undefined => {
|
||||
const viaCall =
|
||||
t.isMemberExpression(call.callee) &&
|
||||
semanticStaticPropertyKey(call.callee.property, call.callee.computed) ===
|
||||
"call";
|
||||
const slot =
|
||||
viaCall && t.isMemberExpression(call.callee)
|
||||
? call.callee.object
|
||||
: call.callee;
|
||||
if (
|
||||
!t.isMemberExpression(slot) ||
|
||||
!slot.computed ||
|
||||
!t.isIdentifier(slot.object) ||
|
||||
!t.isIdentifier(slot.property) ||
|
||||
bindingOf(state, slot.object) !== tableId ||
|
||||
bindingOf(state, slot.property) !== parameterId
|
||||
)
|
||||
return undefined;
|
||||
const module = call.arguments[viaCall ? 1 : 0];
|
||||
const moduleId = t.isIdentifier(module)
|
||||
? bindingOf(state, module)
|
||||
: undefined;
|
||||
return moduleId === undefined
|
||||
? { table: slot.object }
|
||||
: { table: slot.object, moduleId };
|
||||
};
|
||||
|
||||
/**
|
||||
* The module object is a fresh literal, optionally stored as `cache[key]`.
|
||||
* Undefined means no cache; null means the module binding is not proven.
|
||||
*/
|
||||
const moduleCache = (
|
||||
moduleId: string,
|
||||
parameterId: string,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): { readonly cacheId: string; readonly site: t.Node } | undefined | null => {
|
||||
const binding = state.bindingsById.get(moduleId);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (
|
||||
binding?.definitions.length !== 1 ||
|
||||
initializer === undefined ||
|
||||
initializer.projection.length !== 0
|
||||
)
|
||||
return null;
|
||||
const value = initializer.node;
|
||||
if (isPlainObject(value)) return undefined;
|
||||
if (
|
||||
!t.isAssignmentExpression(value, { operator: "=" }) ||
|
||||
!isPlainObject(value.right)
|
||||
)
|
||||
return null;
|
||||
const cacheId = keyedSlot(value.left, parameterId, state);
|
||||
return cacheId !== undefined && startsEmpty(cacheId, state)
|
||||
? { cacheId, site: value.left }
|
||||
: null;
|
||||
};
|
||||
|
||||
/** A pre-populated cache could answer the early return without a dispatch. */
|
||||
const startsEmpty = (
|
||||
cacheId: string,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
const binding = state.bindingsById.get(cacheId);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
return (
|
||||
binding?.definitions.length === 1 &&
|
||||
initializer?.projection.length === 0 &&
|
||||
t.isObjectExpression(initializer.node) &&
|
||||
initializer.node.properties.length === 0
|
||||
);
|
||||
};
|
||||
|
||||
/** An object literal of ordinary data properties, so `exports` stores plainly. */
|
||||
const isPlainObject = (node: t.Node): boolean =>
|
||||
t.isObjectExpression(node) &&
|
||||
node.properties.every(
|
||||
(property) =>
|
||||
t.isObjectProperty(property) &&
|
||||
!property.computed &&
|
||||
semanticStaticPropertyKey(property.key, false) !== "__proto__",
|
||||
);
|
||||
|
||||
/** Every ancestor up to the loader body executes whenever the loader runs. */
|
||||
const runsUnconditionally = (
|
||||
dispatch: t.Node,
|
||||
loader: t.FunctionDeclaration,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
let child: t.Node = dispatch;
|
||||
let parent = state.parentsByNode.get(child);
|
||||
while (parent !== undefined && child !== loader.body) {
|
||||
const ordered =
|
||||
t.isSequenceExpression(parent) ||
|
||||
t.isExpressionStatement(parent) ||
|
||||
t.isReturnStatement(parent) ||
|
||||
t.isBlockStatement(parent) ||
|
||||
(t.isMemberExpression(parent) && parent.object === child) ||
|
||||
(t.isCallExpression(parent) && parent.callee === child) ||
|
||||
(t.isTryStatement(parent) && parent.block === child);
|
||||
if (!ordered) return false;
|
||||
child = parent;
|
||||
parent = state.parentsByNode.get(parent);
|
||||
}
|
||||
return child === loader.body;
|
||||
};
|
||||
|
||||
/**
|
||||
* The module object reaches only the dispatch, `.exports` reads after it, and
|
||||
* writes to members other than `exports`, so nothing swaps its exports.
|
||||
*/
|
||||
const moduleObjectContained = (
|
||||
loader: t.FunctionDeclaration,
|
||||
moduleId: string,
|
||||
dispatch: t.CallExpression,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
let contained = true;
|
||||
traverseJavaScriptAst(loader.body, {
|
||||
enter: (node, parent, readAncestors) => {
|
||||
if (!contained || !t.isIdentifier(node) || parent === null) return;
|
||||
const ancestors = readAncestors();
|
||||
if (!t.isReferenced(node, parent, ancestors.at(-2))) return;
|
||||
if (bindingOf(state, node) !== moduleId) return;
|
||||
contained =
|
||||
!ancestors.some((ancestor) => t.isFunction(ancestor)) &&
|
||||
moduleUse(node, [parent, ancestors.at(-2)], dispatch);
|
||||
},
|
||||
});
|
||||
return contained;
|
||||
};
|
||||
|
||||
const moduleUse = (
|
||||
node: t.Identifier,
|
||||
[parent, grandparent]: readonly [t.Node, t.Node | undefined],
|
||||
dispatch: t.CallExpression,
|
||||
): boolean => {
|
||||
if (parent === dispatch) return dispatch.arguments.includes(node);
|
||||
if (!t.isMemberExpression(parent) || parent.object !== node) return false;
|
||||
const key = semanticStaticPropertyKey(parent.property, parent.computed);
|
||||
const written =
|
||||
(t.isAssignmentExpression(grandparent) && grandparent.left === parent) ||
|
||||
t.isUpdateExpression(grandparent) ||
|
||||
t.isUnaryExpression(grandparent, { operator: "delete" });
|
||||
if (written) return key !== null && key !== "exports";
|
||||
if (key !== "exports") return false;
|
||||
// `module.exports` passed to the factory, or read after the dispatch ran.
|
||||
return (
|
||||
grandparent === dispatch ||
|
||||
(parent.start ?? -1) > (dispatch.end ?? Number.POSITIVE_INFINITY)
|
||||
);
|
||||
};
|
||||
|
||||
/** Record `cache[key]` reads that initialize the loader's early-return binding. */
|
||||
const withCacheReads = (
|
||||
loader: t.FunctionDeclaration,
|
||||
shape: LoaderShape,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): LoaderShape | undefined => {
|
||||
if (shape.cacheId === undefined) return shape;
|
||||
const sites = new Set(shape.cacheSites);
|
||||
let contained = true;
|
||||
traverseJavaScriptAst(loader.body, {
|
||||
skip: (node) => t.isFunction(node),
|
||||
enter: (node, parent) => {
|
||||
if (
|
||||
!t.isMemberExpression(node) ||
|
||||
keyedSlot(node, shape.parameterId, state) !== shape.cacheId
|
||||
)
|
||||
return;
|
||||
const read = t.isVariableDeclarator(parent) && parent.init === node;
|
||||
const deleted = t.isUnaryExpression(parent, { operator: "delete" });
|
||||
if (read || deleted || sites.has(node)) sites.add(node);
|
||||
else contained = false;
|
||||
},
|
||||
});
|
||||
return contained ? { ...shape, cacheSites: sites } : undefined;
|
||||
};
|
||||
|
||||
/** Accept `module.exports` or `cache[key].exports` read through a stable binding. */
|
||||
const returnsModuleExports = (
|
||||
argument: t.Expression | null | undefined,
|
||||
shape: LoaderShape,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
const value = t.isSequenceExpression(argument)
|
||||
? argument.expressions.at(-1)
|
||||
: argument;
|
||||
const before = t.isSequenceExpression(argument)
|
||||
? argument.expressions.slice(0, -1)
|
||||
: [];
|
||||
if (
|
||||
!before.every((expression) =>
|
||||
sideEffectBeforeReturn(expression, shape, state),
|
||||
)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
!t.isMemberExpression(value) ||
|
||||
!t.isIdentifier(value.object) ||
|
||||
semanticStaticPropertyKey(value.property, value.computed) !== "exports"
|
||||
)
|
||||
return false;
|
||||
const id = bindingOf(state, value.object);
|
||||
if (id === shape.moduleId) return true;
|
||||
if (id === undefined || shape.cacheId === undefined) return false;
|
||||
const binding = state.bindingsById.get(id);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
return (
|
||||
binding?.definitions.length === 1 &&
|
||||
initializer?.projection.length === 0 &&
|
||||
keyedSlot(initializer.node, shape.parameterId, state) === shape.cacheId
|
||||
);
|
||||
};
|
||||
|
||||
/** The dispatch itself, or a write to a module member other than `exports`. */
|
||||
const sideEffectBeforeReturn = (
|
||||
expression: t.Expression,
|
||||
shape: LoaderShape,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
if (expression === shape.dispatch) return true;
|
||||
if (
|
||||
!t.isAssignmentExpression(expression) ||
|
||||
!t.isMemberExpression(expression.left) ||
|
||||
!t.isIdentifier(expression.left.object) ||
|
||||
bindingOf(state, expression.left.object) !== shape.moduleId
|
||||
)
|
||||
return false;
|
||||
const key = semanticStaticPropertyKey(
|
||||
expression.left.property,
|
||||
expression.left.computed,
|
||||
);
|
||||
return key !== null && key !== "exports";
|
||||
};
|
||||
|
||||
/** The binding of `owner` in `owner[key]` when `key` is the loader parameter. */
|
||||
const keyedSlot = (
|
||||
node: t.Node,
|
||||
parameterId: string,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): string | undefined =>
|
||||
t.isMemberExpression(node) &&
|
||||
node.computed &&
|
||||
t.isIdentifier(node.object) &&
|
||||
t.isIdentifier(node.property) &&
|
||||
bindingOf(state, node.property) === parameterId
|
||||
? bindingOf(state, node.object)
|
||||
: undefined;
|
||||
|
||||
const referencesAreContained = (
|
||||
call: t.CallExpression,
|
||||
{
|
||||
bootstrap,
|
||||
shape,
|
||||
loaderIds,
|
||||
}: {
|
||||
readonly bootstrap: Bootstrap;
|
||||
readonly shape: LoaderShape;
|
||||
readonly loaderIds: ReadonlySet<string>;
|
||||
},
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptBootstrapIntegrity | undefined => {
|
||||
const tracked = new Map<string, "table" | "cache" | "loader">([
|
||||
[bootstrap.tableBindingId, "table"],
|
||||
...(shape.cacheId === undefined ? [] : [[shape.cacheId, "cache"] as const]),
|
||||
...[...loaderIds].map((id) => [id, "loader"] as const),
|
||||
]);
|
||||
const names = new Set(
|
||||
[...tracked.keys()].flatMap((id) => state.bindingsById.get(id)?.name ?? []),
|
||||
);
|
||||
const replacedKeys = new Set<string>();
|
||||
let contained = true;
|
||||
traverseJavaScriptAst(call, {
|
||||
skip: () => !contained,
|
||||
enter: (node, parent, readAncestors) => {
|
||||
if (!t.isIdentifier(node) || !names.has(node.name) || parent === null)
|
||||
return;
|
||||
const ancestors = readAncestors();
|
||||
if (!t.isReferenced(node, parent, ancestors.at(-2))) return;
|
||||
const id = bindingOf(state, node);
|
||||
const role = id === undefined ? undefined : tracked.get(id);
|
||||
if (role === undefined) return;
|
||||
const use: Use = { node, parent, ancestors, shape, loaderIds, state };
|
||||
if (role !== "loader") contained = containedUse(role, use);
|
||||
else if (exposedTableUse(use) === "escaped") contained = false;
|
||||
else exposedSlotWrite(use, replacedKeys);
|
||||
},
|
||||
});
|
||||
return contained ? { replacedKeys } : undefined;
|
||||
};
|
||||
|
||||
interface Use {
|
||||
readonly node: t.Identifier;
|
||||
readonly parent: t.Node;
|
||||
readonly ancestors: readonly t.Node[];
|
||||
readonly shape: LoaderShape;
|
||||
readonly loaderIds: ReadonlySet<string>;
|
||||
readonly state: JavaScriptSemanticAnalysisState;
|
||||
}
|
||||
|
||||
/** The table is read only by the dispatch; the cache only by the loader's own slot operations. */
|
||||
const containedUse = (role: "table" | "cache", use: Use): boolean => {
|
||||
if (role === "table" && use.shape.dispatches.has(use.node)) return true;
|
||||
if (role === "cache" && use.shape.cacheSites.has(use.parent)) return true;
|
||||
return isRuntimeExposure(use, role === "table" ? "m" : "c");
|
||||
};
|
||||
|
||||
/** `loader.m = table` or `loader.c = cache`, the runtime's own exposure. */
|
||||
const isRuntimeExposure = (use: Use, key: "m" | "c"): boolean => {
|
||||
const { parent, node, loaderIds, state } = use;
|
||||
return (
|
||||
t.isAssignmentExpression(parent, { operator: "=" }) &&
|
||||
parent.right === node &&
|
||||
t.isMemberExpression(parent.left) &&
|
||||
t.isIdentifier(parent.left.object) &&
|
||||
semanticStaticPropertyKey(parent.left.property, parent.left.computed) ===
|
||||
key &&
|
||||
loaderIds.has(bindingOf(state, parent.left.object) ?? "")
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* `loader.m`/`loader.c` may only be assigned (the exposure) or indexed
|
||||
* directly; any other use, such as `const a = loader.m`, lets later writes
|
||||
* escape this proof. Writing a member of an indexed entry, such as
|
||||
* `loader.c[id].exports = value`, can replace what the loader returns.
|
||||
*/
|
||||
const exposedTableUse = (use: Use): "other" | "contained" | "escaped" => {
|
||||
const { parent, ancestors } = use;
|
||||
if (
|
||||
!t.isMemberExpression(parent) ||
|
||||
parent.object !== use.node ||
|
||||
!["m", "c"].includes(
|
||||
semanticStaticPropertyKey(parent.property, parent.computed) ?? "",
|
||||
)
|
||||
)
|
||||
return "other";
|
||||
const outer = ancestors.at(-2);
|
||||
const assigned =
|
||||
t.isAssignmentExpression(outer, { operator: "=" }) && outer.left === parent;
|
||||
const indexed =
|
||||
t.isMemberExpression(outer) && outer.object === parent && outer.computed;
|
||||
if (assigned) return "contained";
|
||||
if (!indexed) return "escaped";
|
||||
const member = ancestors.at(-3);
|
||||
const target = ancestors.at(-4);
|
||||
const entryWritten =
|
||||
t.isMemberExpression(member) &&
|
||||
member.object === outer &&
|
||||
((t.isAssignmentExpression(target) && target.left === member) ||
|
||||
t.isUpdateExpression(target) ||
|
||||
t.isUnaryExpression(target, { operator: "delete" }));
|
||||
return entryWritten ? "escaped" : "contained";
|
||||
};
|
||||
|
||||
/**
|
||||
* `loader.m[key] = factory` or `delete loader.c[key]` replaces a literal slot.
|
||||
* Writes with runtime keys are chunk installation, which adds modules under
|
||||
* their own ids; they are not treated as replacing a declared slot.
|
||||
*/
|
||||
const exposedSlotWrite = (use: Use, replacedKeys: Set<string>): void => {
|
||||
const { parent, ancestors } = use;
|
||||
const slot = ancestors.at(-2);
|
||||
const write = ancestors.at(-3);
|
||||
if (
|
||||
!t.isMemberExpression(parent) ||
|
||||
parent.object !== use.node ||
|
||||
!["m", "c"].includes(
|
||||
semanticStaticPropertyKey(parent.property, parent.computed) ?? "",
|
||||
) ||
|
||||
!t.isMemberExpression(slot) ||
|
||||
slot.object !== parent ||
|
||||
!(
|
||||
(t.isAssignmentExpression(write) && write.left === slot) ||
|
||||
t.isUnaryExpression(write, { operator: "delete" }) ||
|
||||
t.isUpdateExpression(write)
|
||||
)
|
||||
)
|
||||
return;
|
||||
const key = slot.computed
|
||||
? argumentValue(slot.property)
|
||||
: semanticStaticPropertyKey(slot.property, false);
|
||||
if (key !== undefined && key !== null) replacedKeys.add(key);
|
||||
};
|
||||
|
||||
const bindingOf = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
identifier: t.Identifier,
|
||||
): string | undefined =>
|
||||
resolveSemanticBindingState(state, identifier, identifier.name)?.bindingId;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/** Supported native resource receivers, shared by acquisition and validity owners. */
|
||||
export const BUILTIN_RESOURCE_METHODS = {
|
||||
fs: ["open", "openSync", "createReadStream", "createWriteStream"],
|
||||
"fs/promises": ["open"],
|
||||
net: ["connect", "createConnection"],
|
||||
tls: ["connect", "createConnection"],
|
||||
} as const;
|
||||
|
||||
export const BUILTIN_RESOURCE_SPECIFIERS = Object.keys(
|
||||
BUILTIN_RESOURCE_METHODS,
|
||||
);
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import { javascriptBootstrapAt } from "./javascriptBootstrap.js";
|
||||
import { proveJavaScriptBootstrapIntegrity } from "./javascriptBootstrapIntegrity.js";
|
||||
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
|
||||
import {
|
||||
semanticMemberChain,
|
||||
semanticRequireOrigin,
|
||||
type JavaScriptRequireOrigin,
|
||||
} from "./javascriptSemanticRequireOrigin.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
semanticResolutionBlocked,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
argumentValue,
|
||||
moduleFactory,
|
||||
modulePropertyName,
|
||||
} from "./javascriptStaticAnalysisHelpers.js";
|
||||
|
||||
/** Loader bindings and literal externals proven for one standalone bootstrap. */
|
||||
interface BootstrapExternals {
|
||||
/** The runtime loader and every factory parameter it is passed as. */
|
||||
readonly loaderBindingIds: ReadonlySet<string>;
|
||||
/** Table keys whose factory only re-exports one unshadowed literal require; null when a key repeats. */
|
||||
readonly externals: ReadonlyMap<string, string | null>;
|
||||
}
|
||||
|
||||
const proofs = new WeakMap<
|
||||
JavaScriptSemanticAnalysisState,
|
||||
WeakMap<t.CallExpression, BootstrapExternals | null>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Recover a literal require reached through a proven Webpack bootstrap.
|
||||
*
|
||||
* Webpack emits externals as `{45840: e => { e.exports = require("electron") }}`
|
||||
* and consumers call the runtime loader, `n(45840)`, instead of `require`. The
|
||||
* origin is accepted only when the loader binding is the bootstrap's own loader
|
||||
* or a factory parameter that the bootstrap proves it passes the loader as, and
|
||||
* the keyed factory consists solely of that re-export.
|
||||
*/
|
||||
export const semanticBundledRequireOrigin = (
|
||||
node: t.Node | null | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptRequireOrigin | undefined => {
|
||||
const chain = semanticMemberChain(node);
|
||||
const current = chain?.root;
|
||||
if (
|
||||
chain === undefined ||
|
||||
!t.isCallExpression(current) ||
|
||||
!t.isIdentifier(current.callee) ||
|
||||
current.arguments.length !== 1
|
||||
)
|
||||
return undefined;
|
||||
const key = argumentValue(current.arguments[0]);
|
||||
if (key === undefined) return undefined;
|
||||
const loader = current.callee;
|
||||
if (semanticResolutionBlocked(state, loader, loader.name)) return undefined;
|
||||
const binding = resolveSemanticBindingState(state, loader, loader.name);
|
||||
if (
|
||||
binding === undefined ||
|
||||
binding.definitions.length !== 1 ||
|
||||
binding.definitions[0]?.kind === "assignment"
|
||||
)
|
||||
return undefined;
|
||||
for (
|
||||
let ancestor = state.parentsByNode.get(current);
|
||||
ancestor !== undefined;
|
||||
ancestor = state.parentsByNode.get(ancestor)
|
||||
) {
|
||||
if (!t.isCallExpression(ancestor)) continue;
|
||||
const proof = bootstrapExternals(ancestor, state);
|
||||
if (proof === null || !proof.loaderBindingIds.has(binding.bindingId))
|
||||
continue;
|
||||
const specifier = proof.externals.get(key);
|
||||
return typeof specifier === "string"
|
||||
? {
|
||||
origin: {
|
||||
specifier,
|
||||
importedPath: chain.members,
|
||||
resolutionMode: "require",
|
||||
},
|
||||
call: current,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const bootstrapExternals = (
|
||||
call: t.CallExpression,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): BootstrapExternals | null => {
|
||||
let byCall = proofs.get(state);
|
||||
if (byCall === undefined) {
|
||||
byCall = new WeakMap();
|
||||
proofs.set(state, byCall);
|
||||
}
|
||||
const cached = byCall.get(call);
|
||||
if (cached !== undefined) return cached;
|
||||
const proof = proveBootstrapExternals(call, state);
|
||||
byCall.set(call, proof);
|
||||
return proof;
|
||||
};
|
||||
|
||||
const proveBootstrapExternals = (
|
||||
call: t.CallExpression,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): BootstrapExternals | null => {
|
||||
const bootstrap = javascriptBootstrapAt(call, state);
|
||||
// A replaced loader no longer dispatches the declared table.
|
||||
if (
|
||||
bootstrap === undefined ||
|
||||
!singleDefinition(state, bootstrap.loaderBindingId, "function")
|
||||
)
|
||||
return null;
|
||||
const loaderBindingIds = new Set([bootstrap.loaderBindingId]);
|
||||
const factories = bootstrap.table.properties.flatMap((property) => {
|
||||
const factory = moduleFactory(property);
|
||||
return factory === undefined ? [] : [{ property, factory }];
|
||||
});
|
||||
for (const { factory } of factories) {
|
||||
// The bootstrap proves `table[key](module, module.exports, loader)`.
|
||||
const loaderParameter = factory.params[2];
|
||||
const bindingId = t.isIdentifier(loaderParameter)
|
||||
? resolveSemanticBindingState(
|
||||
state,
|
||||
loaderParameter,
|
||||
loaderParameter.name,
|
||||
)?.bindingId
|
||||
: undefined;
|
||||
if (
|
||||
bindingId !== undefined &&
|
||||
singleDefinition(state, bindingId, "parameter")
|
||||
)
|
||||
loaderBindingIds.add(bindingId);
|
||||
}
|
||||
const integrity = proveJavaScriptBootstrapIntegrity(
|
||||
call,
|
||||
bootstrap,
|
||||
loaderBindingIds,
|
||||
state,
|
||||
);
|
||||
if (integrity === undefined) return null;
|
||||
const externals = new Map<string, string | null>();
|
||||
for (const { property, factory } of factories) {
|
||||
const key = modulePropertyName(property);
|
||||
const specifier = integrity.replacedKeys.has(key)
|
||||
? undefined
|
||||
: externalSpecifier(factory, state);
|
||||
if (externals.has(key)) externals.set(key, null);
|
||||
else externals.set(key, specifier ?? null);
|
||||
}
|
||||
return { loaderBindingIds, externals };
|
||||
};
|
||||
|
||||
/**
|
||||
* Accept only `module.exports = require("specifier")` as the whole body of an
|
||||
* ordinary factory; generator, async and accessor factories do not run it.
|
||||
*/
|
||||
const externalSpecifier = (
|
||||
factory: t.FunctionExpression | t.ArrowFunctionExpression | t.ObjectMethod,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): string | undefined => {
|
||||
const moduleParameter = factory.params[0];
|
||||
const names = factory.params.map((parameter) =>
|
||||
t.isIdentifier(parameter) ? parameter.name : null,
|
||||
);
|
||||
if (
|
||||
!t.isIdentifier(moduleParameter) ||
|
||||
// A duplicated name binds the later argument, not the module object.
|
||||
new Set(names).size !== names.length ||
|
||||
factory.generator ||
|
||||
factory.async ||
|
||||
(t.isObjectMethod(factory) && factory.kind !== "method")
|
||||
)
|
||||
return undefined;
|
||||
let expression: t.Node | undefined;
|
||||
if (t.isBlockStatement(factory.body)) {
|
||||
const [statement] = factory.body.body;
|
||||
if (factory.body.body.length !== 1 || !t.isExpressionStatement(statement))
|
||||
return undefined;
|
||||
expression = statement.expression;
|
||||
} else expression = factory.body;
|
||||
if (
|
||||
!t.isAssignmentExpression(expression, { operator: "=" }) ||
|
||||
!t.isMemberExpression(expression.left) ||
|
||||
!t.isIdentifier(expression.left.object) ||
|
||||
semanticStaticPropertyKey(
|
||||
expression.left.property,
|
||||
expression.left.computed,
|
||||
) !== "exports"
|
||||
)
|
||||
return undefined;
|
||||
const moduleBinding = resolveSemanticBindingState(
|
||||
state,
|
||||
moduleParameter,
|
||||
moduleParameter.name,
|
||||
);
|
||||
const target = resolveSemanticBindingState(
|
||||
state,
|
||||
expression.left.object,
|
||||
expression.left.object.name,
|
||||
);
|
||||
if (
|
||||
moduleBinding === undefined ||
|
||||
target?.bindingId !== moduleBinding.bindingId
|
||||
)
|
||||
return undefined;
|
||||
const required = semanticRequireOrigin(expression.right, state)?.origin;
|
||||
return required !== undefined && required.importedPath.length === 0
|
||||
? required.specifier
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const singleDefinition = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
bindingId: string,
|
||||
kind: "function" | "parameter",
|
||||
): boolean => {
|
||||
const definitions = state.bindingsById.get(bindingId)?.definitions;
|
||||
return definitions?.length === 1 && definitions[0]?.kind === kind;
|
||||
};
|
||||
|
|
@ -2,6 +2,7 @@ import * as t from "@babel/types";
|
|||
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
semanticWriteSiteTarget,
|
||||
semanticWriteTargets,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
|
|
@ -62,6 +63,7 @@ export function* collectJavaScriptIdentityWritesSteps(
|
|||
origin: (
|
||||
root: t.Node,
|
||||
kind: "binding" | "member" | "opaque-call",
|
||||
writeSite: t.Node,
|
||||
) => readonly (readonly (string | null)[])[],
|
||||
intrinsicInvocation: (
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
|
|
@ -88,7 +90,8 @@ export function* collectJavaScriptIdentityWritesSteps(
|
|||
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
|
||||
const invocation = intrinsicInvocation(node);
|
||||
if (invocation?.kind === "opaque") {
|
||||
for (const scope of origin(node, "opaque-call")) retain(node, scope);
|
||||
for (const scope of origin(node, "opaque-call", node))
|
||||
retain(node, scope);
|
||||
return;
|
||||
}
|
||||
const callee = invocation?.path;
|
||||
|
|
@ -117,11 +120,11 @@ export function* collectJavaScriptIdentityWritesSteps(
|
|||
// A known mutator with an opaque argument list can overwrite any
|
||||
// captured intrinsic. Do not turn an unexamined target into proof.
|
||||
if (target === undefined || t.isSpreadElement(target)) {
|
||||
for (const scope of origin(node, "opaque-call"))
|
||||
for (const scope of origin(node, "opaque-call", node))
|
||||
retain(node, scope);
|
||||
}
|
||||
if (t.isNode(target) && !t.isSpreadElement(target)) {
|
||||
const identities = origin(target, "member");
|
||||
const identities = origin(target, "member", node);
|
||||
if (identities.length > 0) {
|
||||
const key = keyedMutators.has(method ?? "")
|
||||
? [staticString(arguments_?.[1]) ?? null]
|
||||
|
|
@ -129,26 +132,18 @@ export function* collectJavaScriptIdentityWritesSteps(
|
|||
for (const identity of identities)
|
||||
retain(node, [...identity, ...key]);
|
||||
} else if (!freshObject(target)) {
|
||||
for (const scope of origin(node, "opaque-call"))
|
||||
for (const scope of origin(node, "opaque-call", node))
|
||||
retain(node, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const target = t.isAssignmentExpression(node)
|
||||
? node.left
|
||||
: t.isUpdateExpression(node)
|
||||
? node.argument
|
||||
: t.isUnaryExpression(node, { operator: "delete" })
|
||||
? node.argument
|
||||
: t.isForOfStatement(node) || t.isForInStatement(node)
|
||||
? node.left
|
||||
: undefined;
|
||||
if (target === undefined) return;
|
||||
const target = semanticWriteSiteTarget(node);
|
||||
if (target === null) return;
|
||||
for (const leaf of semanticWriteTargets(target)) {
|
||||
if (t.isIdentifier(leaf)) {
|
||||
for (const identity of origin(leaf, "binding"))
|
||||
for (const identity of origin(leaf, "binding", node))
|
||||
retain(node, identity);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -159,7 +154,7 @@ export function* collectJavaScriptIdentityWritesSteps(
|
|||
)
|
||||
continue;
|
||||
const key = semanticStaticPropertyKey(member.property, member.computed);
|
||||
for (const identity of origin(member.object, "member"))
|
||||
for (const identity of origin(member.object, "member", node))
|
||||
retain(node, [...identity, key]);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { expect, it } from "vitest";
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
|
||||
import {
|
||||
completeSemanticSteps,
|
||||
traverseJavaScriptAst,
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
|
||||
const invocationKinds = (source: string) => {
|
||||
const parsed = parseJavaScriptSource(source);
|
||||
if (parsed === null) throw new Error("invocation fixture did not parse");
|
||||
const state = completeSemanticSteps(
|
||||
indexJavaScriptBindingsSteps(parsed.program),
|
||||
);
|
||||
const interop = createSemanticModuleInterop(state);
|
||||
const invocations: (ReturnType<typeof interop.intrinsicInvocation> | null)[] =
|
||||
[];
|
||||
traverseJavaScriptAst(parsed.program, {
|
||||
enter: (node) => {
|
||||
if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
|
||||
invocations.push(interop.intrinsicInvocation(node) ?? null);
|
||||
},
|
||||
});
|
||||
return invocations;
|
||||
};
|
||||
|
||||
it.each([
|
||||
'const change = Object.defineProperty.call; change(null,Object,"create",{value:customCreate});',
|
||||
'const {apply:change} = Object.defineProperty; change(null,[Object,"create",{value:customCreate}]);',
|
||||
'const change = Reflect.apply.call; change(null,Object.defineProperty,null,[Object,"create",{value:customCreate}]);',
|
||||
])(
|
||||
"keeps a bare invocation member alias unknown, not effect-free: %s",
|
||||
(source) => {
|
||||
expect(invocationKinds(source)).toEqual([
|
||||
expect.objectContaining({ kind: "opaque" }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a comma-expression intrinsic call unknown when its receiver is erased", () => {
|
||||
expect(
|
||||
invocationKinds(
|
||||
'(0, Object.defineProperty.call)(null, Object, "create", {value: customCreate});',
|
||||
),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "opaque",
|
||||
path: ["intrinsic", "Object", "defineProperty", "call"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
|
@ -81,6 +81,15 @@ export const resolveJavaScriptIntrinsicInvocation = (
|
|||
let explicitReceiver = false;
|
||||
for (;;) {
|
||||
callee = unwrapJavaScriptExpression(callee).node;
|
||||
if (t.isSequenceExpression(callee)) {
|
||||
const finalExpression = callee.expressions.at(-1);
|
||||
if (finalExpression === undefined) return undefined;
|
||||
// The comma operator returns the final value without its member receiver.
|
||||
// Keep that lost receiver explicit so intrinsic call/apply stays unknown.
|
||||
explicitReceiver = true;
|
||||
callee = finalExpression;
|
||||
continue;
|
||||
}
|
||||
if (visited.has(callee)) return undefined;
|
||||
visited.add(callee);
|
||||
let path = intrinsicPath(callee);
|
||||
|
|
@ -129,7 +138,8 @@ export const resolveJavaScriptIntrinsicInvocation = (
|
|||
: t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)
|
||||
? callee.object
|
||||
: undefined;
|
||||
if (!t.isExpression(callable)) return undefined;
|
||||
if (!t.isExpression(callable))
|
||||
return { kind: "opaque", path, arguments: undefined };
|
||||
callee = callable;
|
||||
receiver = arguments_?.[0];
|
||||
explicitReceiver = true;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { isImportMetaUrl } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js";
|
||||
|
||||
const GLOBAL_OBJECT_NAMES = ["globalThis", "global", "window", "self"];
|
||||
|
||||
|
|
@ -51,8 +52,8 @@ const createAmbientPathResolver = (
|
|||
possibleWrites: boolean,
|
||||
) => {
|
||||
const cached = new WeakMap<t.Node, readonly AmbientPath[]>();
|
||||
return (node: t.Node): readonly AmbientPath[] => {
|
||||
const prior = cached.get(node);
|
||||
return (node: t.Node, capture?: t.Node): readonly AmbientPath[] => {
|
||||
const prior = possibleWrites ? undefined : cached.get(node);
|
||||
if (prior !== undefined) return prior;
|
||||
const result = new Map<string, AmbientPath>();
|
||||
const pending: { node: t.Node; suffix: AmbientProjection }[] = [
|
||||
|
|
@ -126,7 +127,13 @@ const createAmbientPathResolver = (
|
|||
if (binding === undefined) continue;
|
||||
const stable = semanticStableInitializer(binding, state);
|
||||
const initializers = possibleWrites
|
||||
? binding.initializers
|
||||
? capture === undefined
|
||||
? binding.initializers
|
||||
: semanticMutationInitializers(
|
||||
binding,
|
||||
capture,
|
||||
state.parentsByNode,
|
||||
).initializers
|
||||
: stable === undefined
|
||||
? []
|
||||
: [stable];
|
||||
|
|
@ -221,7 +228,7 @@ const createAmbientPathResolver = (
|
|||
}
|
||||
}
|
||||
const paths = [...result.values()];
|
||||
cached.set(node, paths);
|
||||
if (!possibleWrites) cached.set(node, paths);
|
||||
return paths;
|
||||
};
|
||||
};
|
||||
|
|
@ -248,7 +255,7 @@ export function* indexJavaScriptAmbientWritesSteps(
|
|||
});
|
||||
yield* collectJavaScriptIdentityWritesSteps(
|
||||
program,
|
||||
(root, kind) => {
|
||||
(root, kind, writeSite) => {
|
||||
if (kind === "opaque-call") return [];
|
||||
// Reassigning a local alias changes the binding, not the referenced object.
|
||||
if (kind === "binding")
|
||||
|
|
@ -256,13 +263,14 @@ export function* indexJavaScriptAmbientWritesSteps(
|
|||
isUnshadowedGlobal(root, state, root.name)
|
||||
? [["global", root.name]]
|
||||
: [];
|
||||
return ambientWrites(root);
|
||||
return ambientWrites(root, writeSite);
|
||||
},
|
||||
interop.intrinsicInvocation,
|
||||
interop.staticString,
|
||||
interop.isFreshObject,
|
||||
(_node, path) => {
|
||||
(node, path) => {
|
||||
state.ambientWrites.retain(path);
|
||||
state.ambientWriteEffects.push({ path, node });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,428 @@ import { expect, it } from "vitest";
|
|||
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
|
||||
it("shares required process writes with globals while keeping earlier observations", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`const proc=require('process');
|
||||
process.cwd(); const before=proc.env.BEFORE;
|
||||
proc.env={}; const after=process.env.AFTER;`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["BEFORE"]);
|
||||
});
|
||||
|
||||
it("does not propagate replacement-object writes from import-equals direct origins", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`import fs = require('fs');
|
||||
const other=require('fs'); fs={}; fs.readFileSync=custom; other.readFileSync('kept');`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]);
|
||||
});
|
||||
|
||||
it("retains original import-equals aliases captured before reassignment", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`import fs = require('fs');
|
||||
const alias=fs; const other=require('fs'); fs={}; alias.readFileSync=custom; other.readFileSync('wrong');`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps computed namespace calls as unknown receiver escapes", () => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`const fs=require('fs'); fs.hook=custom; fs[key](); fs.readFileSync('wrong');`,
|
||||
);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["(0, fs.readFileSync)", "(assigned = fs.readFileSync)"])(
|
||||
"checks captured function validity through %s",
|
||||
(value) => {
|
||||
const ir = analyzeJavaScriptSemantics(`const fs=require('fs'); let assigned;
|
||||
const saved=${value}; fs.readFileSync=custom; const late=${value}; saved('kept'); late('wrong');`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['process.env={MODE:"local"};', "process.argv=[];", "patch(process);"])(
|
||||
"shares unbound process object effects with required aliases: %s",
|
||||
(write) => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`${write} const proc=require('process');
|
||||
${write.includes("argv") ? "const arg=proc.argv[2];" : "const mode=proc.env.MODE;"}`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"function local(process) {process.env={};}",
|
||||
"process={}; process.env={};",
|
||||
])("keeps unrelated process replacements separate: %s", (write) => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`${write} const proc=require('process'); const mode=proc.env.MODE;`,
|
||||
);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["MODE"]);
|
||||
});
|
||||
|
||||
it("preserves exports captured before a later loader overwrite", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const fs=require('node:fs'); fs.readFileSync('before');
|
||||
const read=fs.readFileSync;
|
||||
require('fs').readFileSync('direct-before');
|
||||
require=replacement;
|
||||
fs.readFileSync('after'); read('saved');
|
||||
const late=require('fs'); late.readFileSync('wrong');
|
||||
require('fs').readFileSync('also-wrong');
|
||||
`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual([
|
||||
"before",
|
||||
"direct-before",
|
||||
"after",
|
||||
"saved",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`for (;;) { require('fs').readFileSync('wrong'); require=replacement; }`,
|
||||
`function run() { require('fs').readFileSync('wrong'); require=replacement; }`,
|
||||
`require=replacement; const fs=require('fs'); fs.readFileSync('wrong'); require=again;`,
|
||||
])(
|
||||
"keeps repeated or already replaced loader captures uncertain: %s",
|
||||
(source) => {
|
||||
expect(analyzeJavaScriptSemantics(source).configurationOperations).toEqual(
|
||||
[],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["fs.readFileSync = custom;", "other.readFileSync('wrong');"],
|
||||
["other.readFileSync = custom;", "fs.readFileSync('wrong');"],
|
||||
["patch(fs);", "other.readFileSync('wrong');"],
|
||||
])(
|
||||
"shares require-mode import-equals effects in both directions: %s",
|
||||
(write, call) => {
|
||||
const ir = analyzeJavaScriptSemantics(`import fs = require('node:fs');
|
||||
const other = require('fs'); other.readFileSync('before'); ${write} ${call}`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual([
|
||||
"before",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"import {readFileSync as read} from 'node:fs';",
|
||||
"const {readFileSync:read}=require('node:fs');",
|
||||
"const fs=require('fs'); const read=fs.readFileSync;",
|
||||
])(
|
||||
"retains captured native function calls after argument escape: %s",
|
||||
(capture) => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`${capture} observe(read); read('kept');`,
|
||||
);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("distinguishes escaping a method value from escaping its namespace", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`const fs=require('fs');
|
||||
observe(fs.readFileSync); fs.readFileSync('kept');
|
||||
observe(fs); fs.readFileSync('wrong');`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]);
|
||||
});
|
||||
|
||||
it("still invalidates escaped process configuration containers", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`const proc=require('process');
|
||||
observe(proc.env); const mode=proc.env.MODE;`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains direct require effects without poisoning later cached receivers", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
require('node:fs').readFileSync('first');
|
||||
const fs = require('fs'); fs.readFileSync('second');
|
||||
require('node:fs').createReadStream('stream');
|
||||
require('node:child_process').spawn('tool', []);
|
||||
require('node:timers').setTimeout(callback, 1);
|
||||
require('node:http').get('http://example.test');
|
||||
require('fs').readFileSync = custom;
|
||||
require('node:fs').readFileSync('wrong'); fs.readFileSync('wrong');
|
||||
`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual([
|
||||
"first",
|
||||
"second",
|
||||
]);
|
||||
expect(ir.resourceOperations).toHaveLength(1);
|
||||
expect(ir.childProcessSpawns).toHaveLength(1);
|
||||
expect(ir.timerOperations).toHaveLength(1);
|
||||
expect(ir.requestOperations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"const fs = require('fs'); fs.promises.open = custom;",
|
||||
"require('node:fs').promises.open = custom;",
|
||||
"const fsp = require('fs/promises'); fsp.open = custom;",
|
||||
])("shares fs.promises object writes with fs/promises: %s", (write) => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const before = require('node:fs/promises'); before.open('before');
|
||||
${write}
|
||||
const after = require('node:fs/promises'); after.open('wrong');
|
||||
`);
|
||||
expect(ir.resourceOperations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"require = () => ({ readFileSync() {} });",
|
||||
"({ loader: require } = other);",
|
||||
"for (require of loaders) {}",
|
||||
])("rejects provenance from an overwritten ambient loader: %s", (write) => {
|
||||
const ir = analyzeJavaScriptSemantics(`${write}
|
||||
const fs = require('node:fs'); fs.readFileSync('wrong');
|
||||
require('node:fs').readFileSync('also-wrong');
|
||||
`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
expect(
|
||||
ir.bindings.find(({ name }) => name === "fs")?.provenance.status,
|
||||
).not.toBe("module");
|
||||
});
|
||||
|
||||
it("keeps local loader reassignment separate from the ambient loader", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
function local(require) { require = replacement; }
|
||||
const fs = require('node:fs'); fs.readFileSync('kept');
|
||||
`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]);
|
||||
});
|
||||
|
||||
it("keeps wrapper require separate from global-object property writes", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
globalThis.require = replacement;
|
||||
function polyfill(key) { globalThis[key] = replacement; }
|
||||
require('node:fs').readFileSync('kept');
|
||||
`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]);
|
||||
});
|
||||
|
||||
it.each(["import fs from 'node:fs';", "const fs = require('node:fs');"])(
|
||||
"retains reads and saved methods before a later write: %s",
|
||||
(declaration) => {
|
||||
const ir = analyzeJavaScriptSemantics(`${declaration}
|
||||
fs.readFileSync('before');
|
||||
const saved = fs.readFileSync;
|
||||
const { readFileSync: destructured } = fs;
|
||||
const namespace = fs;
|
||||
fs.readFileSync = custom;
|
||||
saved('saved'); destructured('destructured');
|
||||
namespace.readFileSync('wrong'); fs.readFileSync('wrong');
|
||||
`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual([
|
||||
"before",
|
||||
"saved",
|
||||
"destructured",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"const { readFileSync: read } = require('fs');",
|
||||
"const read = require('fs').readFileSync;",
|
||||
])("checks cached module writes at the projected capture: %s", (capture) => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const fs = require('node:fs');
|
||||
const saved = require('fs').readFileSync;
|
||||
fs.readFileSync = custom;
|
||||
${capture}
|
||||
read('wrong'); saved('saved');
|
||||
`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["saved"]);
|
||||
});
|
||||
|
||||
it("retains captured methods across later escapes without freezing a namespace alias", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const fs = require('node:fs'); const saved = require('fs').readFileSync;
|
||||
const namespace = require('fs'); patch(fs);
|
||||
const late = require('fs').readFileSync;
|
||||
saved('saved'); late('wrong'); namespace.readFileSync('wrong');
|
||||
`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["saved"]);
|
||||
});
|
||||
|
||||
it("keeps writes in repeated callable and loop activations conservative", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const fs = require('node:fs');
|
||||
function run() { fs.readFileSync('wrong'); fs.readFileSync = custom; }
|
||||
for (;;) { const read = require('fs').readFileSync; read('wrong'); fs.readFileSync = custom; }
|
||||
`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not propagate a reassigned local object's writes into cached exports", () => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`let a = require('node:fs'); const b = require('node:fs'); a = {}; a.readFileSync = custom; b.readFileSync('kept');`,
|
||||
);
|
||||
expect(ir.configurationOperations).toMatchObject([{ key: "kept" }]);
|
||||
});
|
||||
|
||||
it("shares repeated require mutations for every built-in effect family", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const fs = require('fs'); const otherFs = require('node:fs'); fs.createReadStream = custom; otherFs.createReadStream('wrong');
|
||||
const cp = require('child_process'); const otherCp = require('node:child_process'); cp.spawn = custom; otherCp.spawn('wrong', []);
|
||||
const timers = require('timers'); const otherTimers = require('node:timers'); timers.setTimeout = custom; otherTimers.setTimeout(callback, 1);
|
||||
const http = require('http'); const otherHttp = require('node:http'); http.get = custom; otherHttp.get('http://wrong.test');
|
||||
const proc = require('process'); const otherProc = require('node:process'); proc.env = replacement; const mode = otherProc.env.MODE;
|
||||
`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
expect(ir.resourceOperations).toEqual([]);
|
||||
expect(ir.childProcessSpawns).toEqual([]);
|
||||
expect(ir.timerOperations).toEqual([]);
|
||||
expect(ir.requestOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["node:fs", "fs"])(
|
||||
"shares repeated built-in require identity across %s",
|
||||
(specifier) => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`const a = require('node:fs'); const b = require('${specifier}'); const alias = b; a.readFileSync = custom; b.createReadStream('kept'); alias.readFileSync('wrong');`,
|
||||
);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
expect(ir.resourceOperations).toMatchObject([
|
||||
{ method: "createReadStream" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("shares unknown escapes across repeated requires without losing earlier effects", () => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`const a = require('node:fs'); const b = require('fs'); b.readFileSync('before'); patch(a); b.readFileSync('after');`,
|
||||
);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["before"]);
|
||||
});
|
||||
|
||||
it("preserves exact default ESM import mutation paths", () => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`import fs from 'node:fs'; fs.writeFileSync = custom; fs.readFileSync('kept'); fs.writeFileSync('replaced');`,
|
||||
);
|
||||
expect(ir.configurationOperations).toMatchObject([
|
||||
{ kind: "file", key: "kept" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains built-in effects before later top-level escapes", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const fs = require('node:fs'); const cp = require('node:child_process'); const timers = require('node:timers'); const http = require('node:http');
|
||||
fs.readFileSync('before'); fs.createReadStream('before'); cp.spawn('tool', []); timers.setTimeout(callback, 1); http.get('http://example.test');
|
||||
patch(fs); patch(cp); patch(timers); patch(http);
|
||||
fs.readFileSync('after'); fs.createReadStream('after'); cp.spawn('wrong', []); timers.setTimeout(callback, 1); http.get('http://wrong.test');
|
||||
`);
|
||||
expect(ir.configurationOperations).toMatchObject([{ key: "before" }]);
|
||||
expect(ir.resourceOperations).toHaveLength(1);
|
||||
expect(ir.childProcessSpawns).toMatchObject([{ command: "tool" }]);
|
||||
expect(ir.timerOperations).toHaveLength(1);
|
||||
expect(ir.requestOperations).toMatchObject([
|
||||
{ endpoint: "http://example.test" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["env", "argv"])(
|
||||
"does not treat a replaced process %s as configuration",
|
||||
(family) => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`const proc = require('node:process'); proc.${family} = replacement; const value = proc.${family}[${family === "env" ? "'MODE'" : "2"}];`,
|
||||
);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not infer temporal safety across repeated callable or loop activations", () => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`const fs = require('node:fs'); function run() { fs.readFileSync('callable'); patch(fs); } for (;;) { fs.readFileSync('loop'); patch(fs); }`,
|
||||
);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not recover a replaced method through a subsequent alias", () => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`const fs = require('node:fs'); fs.readFileSync = custom; const read = fs.readFileSync; read('config.json');`,
|
||||
);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"fs.readFileSync = custom; fs.createReadStream = custom; cp.spawn = custom; timers.setTimeout = custom; http.get = custom;",
|
||||
"patch(fs); patch(cp); patch(timers); patch(http);",
|
||||
"const fsAlias = fs; fsAlias.readFileSync = custom; fsAlias.createReadStream = custom; const cpAlias = cp; cpAlias.spawn = custom; timers[key] = custom; delete http.get;",
|
||||
])("excludes replaced or escaped CommonJS methods: %s", (mutation) => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const fs = require('node:fs'); const cp = require('node:child_process');
|
||||
const timers = require('node:timers'); const http = require('node:http');
|
||||
${mutation}
|
||||
fs.readFileSync('config.json'); fs.createReadStream('config.json');
|
||||
cp.spawn('tool', []); timers.setTimeout(callback, 1); http.get('http://example.test');
|
||||
`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
expect(ir.resourceOperations).toEqual([]);
|
||||
expect(ir.childProcessSpawns).toEqual([]);
|
||||
expect(ir.timerOperations).toEqual([]);
|
||||
expect(ir.requestOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves unaffected CommonJS methods after sibling property mutation", () => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`const fs = require('node:fs'); fs.writeFileSync = custom; fs.readFileSync('config.json');`,
|
||||
);
|
||||
expect(ir.configurationOperations).toMatchObject([
|
||||
{ kind: "file", key: "config.json" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`const fs = require("node:fs"); const cp = require("node:child_process");
|
||||
const timers = require("node:timers"); const http = require("node:http");
|
||||
const read = fs.readFileSync; const launch = cp.spawn;
|
||||
const schedule = timers.setTimeout; const get = http.get;`,
|
||||
`const fs = require("fs"); const { readFileSync: read } = fs;
|
||||
const { spawn: launch } = require("child_process");
|
||||
const { setTimeout: schedule } = require("timers");
|
||||
const { get } = require("http");`,
|
||||
])("recovers builtin effects from CommonJS provenance: %s", (declarations) => {
|
||||
const ir = analyzeJavaScriptSemantics(`${declarations}
|
||||
const text = read("config.json", "utf8");
|
||||
const stream = fs.createReadStream("config.json"); stream.destroy();
|
||||
const child = launch("tool", []); child.kill("SIGTERM");
|
||||
const timer = schedule(callback, 10); clearTimeout(timer);
|
||||
get("http://example.test");
|
||||
`);
|
||||
expect(ir.configurationOperations).toMatchObject([
|
||||
{ kind: "file", key: "config.json" },
|
||||
]);
|
||||
expect(
|
||||
ir.resourceOperations.map(({ kind, method }) => ({ kind, method })),
|
||||
).toEqual([
|
||||
{ kind: "acquire", method: "createReadStream" },
|
||||
{ kind: "release", method: "destroy" },
|
||||
]);
|
||||
expect(ir.childProcessSpawns).toMatchObject([
|
||||
{ method: "spawn", command: "tool" },
|
||||
]);
|
||||
expect(ir.childProcessInteractions).toMatchObject([
|
||||
{ kind: "signal", signalName: "SIGTERM" },
|
||||
]);
|
||||
expect(
|
||||
ir.timerOperations.map(({ kind, method }) => ({ kind, method })),
|
||||
).toEqual([
|
||||
{ kind: "schedule", method: "setTimeout" },
|
||||
{ kind: "cancel", method: "clearTimeout" },
|
||||
]);
|
||||
expect(ir.requestOperations).toMatchObject([
|
||||
{ method: "get", endpoint: "http://example.test" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not promote ambiguous or non-namespace CommonJS origins to builtin effects", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const fake = require("./fs.js"); fake.readFileSync("external");
|
||||
const absentDefault = require("node:fs").default; absentDefault.readFileSync("default");
|
||||
const { constants } = require("node:fs"); constants.createReadStream("nested");
|
||||
let replaced = require("node:fs"); replaced = other; replaced.readFileSync("replaced");
|
||||
const mixed = condition ? require("node:fs") : other; mixed.readFileSync("mixed");
|
||||
function shadowed(require) { const fs = require("node:fs"); fs.readFileSync("shadowed"); }
|
||||
`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
expect(ir.resourceOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not assign built-in APIs to unrelated or shadowed bindings", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
import fs from "./fs.js";
|
||||
|
|
@ -13,3 +435,244 @@ it("does not assign built-in APIs to unrelated or shadowed bindings", () => {
|
|||
|
||||
expect(ir.resourceOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`const fs = require('fs'); class Reader { text = fs.readFileSync('wrong'); } fs.readFileSync = custom; new Reader();`,
|
||||
`const fs = require('fs'); class Reader { #text = fs.readFileSync('wrong'); } fs.readFileSync = custom; new Reader();`,
|
||||
`const fs = require('fs'); fs.__defineGetter__('readFileSync', () => custom); fs.readFileSync('wrong');`,
|
||||
`import proc from 'node:process'; proc.env = {}; const value = process.env.MODE;`,
|
||||
`import fs from 'node:fs'; fs.readFileSync = custom; const other = require('fs'); other.readFileSync('wrong');`,
|
||||
])("keeps delayed or replaced built-in identities unknown: %s", (source) => {
|
||||
expect(analyzeJavaScriptSemantics(source).configurationOperations).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`const fs = require('fs'); const before = fs.readFileSync('kept'), patch = (fs.readFileSync = custom);`,
|
||||
`globalThis.process = {env: {}}; process.env = {}; const proc = require('process'); const value = proc.env.kept;`,
|
||||
`import fs from 'node:fs'; fs.writeFileSync = custom; const other = require('fs'); other.readFileSync('kept');`,
|
||||
])("keeps earlier and independent built-in observations: %s", (source) => {
|
||||
expect(
|
||||
analyzeJavaScriptSemantics(source).configurationOperations.map(
|
||||
({ key }) => key,
|
||||
),
|
||||
).toEqual(["kept"]);
|
||||
});
|
||||
|
||||
it("shares equivalent alias subproblems and repeated intact receiver facts", () => {
|
||||
const aliases = Array.from({ length: 30 }, (_, index) => {
|
||||
const previous = index === 0 ? "fs" : `a${index - 1}`;
|
||||
return `const a${index} = flag ? ${previous} : ${previous};`;
|
||||
});
|
||||
const calls = Array.from(
|
||||
{ length: 128 },
|
||||
(_, index) => `a29.readFileSync('file-${index}');`,
|
||||
);
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
["const fs = require('fs');", ...aliases, ...calls].join("\n"),
|
||||
);
|
||||
expect(ir.configurationOperations).toHaveLength(128);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`import tls from 'node:tls'; const socket = tls.connect(443, 'example.test'); socket.end();`,
|
||||
`import * as tls from 'node:tls'; const socket = tls.connect(443, 'example.test'); socket.end();`,
|
||||
`const tls = require('tls'); const socket = tls.connect(443, 'example.test'); socket.end();`,
|
||||
])("retains TLS acquisitions and their release: %s", (source) => {
|
||||
expect(
|
||||
analyzeJavaScriptSemantics(source).resourceOperations.map(
|
||||
({ kind, method }) => ({ kind, method }),
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "acquire", method: "connect" },
|
||||
{ kind: "release", method: "end" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`globalThis.process.env = {}; const proc = require('process'); const value = proc.env.MODE;`,
|
||||
`const host = globalThis; host.process.env = {}; const proc = require('process'); const value = proc.env.MODE;`,
|
||||
`import * as procNs from 'node:process'; procNs.default.env = {}; const value = process.env.MODE;`,
|
||||
`globalThis.process.env = { MODE: 'local' }; const proc = require('process'); const value = proc.env.MODE;`,
|
||||
`const proc = require('process'); proc.env.MODE = 'local'; const value = proc.env.MODE;`,
|
||||
])("shares writes through the original process object: %s", (source) => {
|
||||
expect(analyzeJavaScriptSemantics(source).configurationOperations).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
`const fs = require('fs'); const result = (fs.readFileSync('kept'), fs.readFileSync = custom);`,
|
||||
"kept",
|
||||
],
|
||||
[
|
||||
`const fs = require('fs'); const read = fs.readFileSync; const result = (read('kept'), fs.readFileSync = custom);`,
|
||||
"kept",
|
||||
],
|
||||
[
|
||||
`const fs = require('fs'); consume(fs.readFileSync('before'), fs.readFileSync = custom);`,
|
||||
"before",
|
||||
],
|
||||
])(
|
||||
"retains reads before a later sequence operand: %s",
|
||||
(source, expectedKey) => {
|
||||
expect(
|
||||
analyzeJavaScriptSemantics(source).configurationOperations.map(
|
||||
({ key }) => key,
|
||||
),
|
||||
).toEqual([expectedKey]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not use source offsets for destructuring assignment evaluation order", () => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`const fs = require('fs'); const target = {};
|
||||
[target[fs.readFileSync('wrong')]] = (fs.readFileSync = custom, []);`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps process reads after an alias is reassigned away from the global", () => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`let host = globalThis; host = {process: {env: {}}};
|
||||
host.process.env = {}; const proc = require('process'); const value = proc.env.MODE;`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["MODE"]);
|
||||
});
|
||||
|
||||
it("rejects CommonJS require after the wrapper module replaces its delegate", () => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`module.require = () => ({ readFileSync() {} });
|
||||
const fs = require('node:fs'); fs.readFileSync('unknown');`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`module.require.extra = true; const fs = require('fs'); fs.readFileSync('kept');`,
|
||||
`const owner = module; owner.require = () => ({}); const fs = require('fs'); fs.readFileSync('wrong');`,
|
||||
`Object.defineProperty(module, 'require', {value: () => ({})}); const fs = require('fs'); fs.readFileSync('wrong');`,
|
||||
])("tracks only actual wrapper require replacements: %s", (source) => {
|
||||
const ir = analyzeJavaScriptSemantics(source);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(
|
||||
source.includes("extra") ? ["kept"] : [],
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a saved promises object after deleting its parent export", () => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`const fs = require('fs'); const fsp = fs.promises;
|
||||
delete fs.promises; const handle = fsp.open('kept'); handle.close();`);
|
||||
expect(
|
||||
ir.resourceOperations.map(({ kind, method }) => ({ kind, method })),
|
||||
).toEqual([
|
||||
{ kind: "acquire", method: "open" },
|
||||
{ kind: "release", method: "close" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not infer network acquisition methods from another built-in", () => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`const { open } = require('node:net'); open();`,
|
||||
);
|
||||
expect(ir.resourceOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("recognizes nested ESM namespace receivers as original built-ins", () => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`import * as ns from 'node:fs'; import fs from 'node:fs';
|
||||
ns.default.readFileSync('first'); fs.readFileSync('second');`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual([
|
||||
"first",
|
||||
"second",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps intact fs/promises identity after deleting the fs.promises slot", () => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`const fs=require('fs'); delete fs.promises;
|
||||
const fsp=require('fs/promises'); const handle=fsp.open('kept'); handle.close();`);
|
||||
expect(
|
||||
ir.resourceOperations.map(({ kind, method }) => ({ kind, method })),
|
||||
).toEqual([
|
||||
{ kind: "acquire", method: "open" },
|
||||
{ kind: "release", method: "close" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`import fs from 'node:fs'; fs.promises = { open() {} }; fs.promises.open('wrong');`,
|
||||
`const fs = require('fs'); patch(fs.promises); require('fs/promises').open('wrong');`,
|
||||
`const fs = require('fs'); patch(fs.promises); require('node:fs/promises').open('wrong');`,
|
||||
])("keeps replaced or escaped promises receivers unknown: %s", (source) => {
|
||||
expect(analyzeJavaScriptSemantics(source).resourceOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("follows final sequence expressions for direct built-in method calls", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`const fs = require('fs');
|
||||
(0, fs.readFileSync)('kept'); fs.readFileSync = custom;
|
||||
(0, fs.readFileSync)('wrong');`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`import { env } from 'node:process'; patch(env); process.env.MODE;`,
|
||||
`import { argv } from 'node:process'; patch(argv); process.argv[2];`,
|
||||
])(
|
||||
"shares named process containers with their namespace aliases: %s",
|
||||
(source) => {
|
||||
expect(analyzeJavaScriptSemantics(source).configurationOperations).toEqual(
|
||||
[],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains uncertainty after a computed write to module's require delegate", () => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`const key = 'require'; module[key] = replacement;
|
||||
const fs = require('fs'); fs.readFileSync('wrong');`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains a module namespace captured before its binding is reassigned", () => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`let fs = require('fs'); const saved = fs;
|
||||
fs = {}; saved.readFileSync('kept');`);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]);
|
||||
});
|
||||
|
||||
it("does not let own fs probe calls hide later builtin reads", () => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`const fs=require('fs'); fs.existsSync('probe'); fs.readFileSync('kept');`,
|
||||
);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]);
|
||||
});
|
||||
|
||||
it("resolves nested ESM default namespace methods", () => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`import * as ns from 'node:fs'; ns.default.readFileSync('kept');`,
|
||||
);
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]);
|
||||
});
|
||||
|
||||
it("tracks process objects passed through qualified global references", () => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`patch(globalThis.process); const proc = require('process');
|
||||
const mode = proc.env.MODE;`);
|
||||
expect(ir.configurationOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks a saved child object's method when that function is selected", () => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`const fs = require('fs'); const fsp = fs.promises;
|
||||
fs.promises.open = custom; const open = fsp.open; open('wrong');`);
|
||||
expect(ir.resourceOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains an earlier captured function after its object method is replaced", () => {
|
||||
const ir =
|
||||
analyzeJavaScriptSemantics(`const fs = require('fs'); const fsp = fs.promises;
|
||||
const open = fsp.open; fs.promises.open = custom; open('kept');`);
|
||||
expect(ir.resourceOperations).toMatchObject([
|
||||
{ kind: "acquire", method: "open" },
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -210,6 +210,64 @@ describe("JavaScript semantic analysis: read-modify-write", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["destructuring assignment", "[source.count] = [2];"],
|
||||
["destructuring assignment with default", "[source.count = 2] = [];"],
|
||||
[
|
||||
"nested destructuring assignment",
|
||||
"({ value: { count: source.count } } = {});",
|
||||
],
|
||||
["rest destructuring assignment", "[...source.items] = [];"],
|
||||
["for-of head", "for (source.count of [4]) {}"],
|
||||
["for-in head", "for (source.count in { key: true }) {}"],
|
||||
["delete", "delete source.count;"],
|
||||
])("records a member target in %s as a write", (_name, statement) => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const source = { count: 1, items: [] };
|
||||
${statement}
|
||||
`);
|
||||
expect(ir.objectOperations.map(({ kind }) => kind)).toEqual(["write"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["non-null update", "source.count!++;"],
|
||||
["non-null compound assignment", "source.count! += 1;"],
|
||||
["cast compound assignment", "(source.count as number) += 1;"],
|
||||
["parenthesized update", "(source.count)++;"],
|
||||
])(
|
||||
"retains the property read in a wrapped read-modify-write: %s",
|
||||
(_name, statement) => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const source = { count: 1 };
|
||||
${statement}
|
||||
`);
|
||||
expect(ir.objectOperations.map(({ kind }) => kind)).toEqual([
|
||||
"read",
|
||||
"write",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["cast plain assignment", "(source.count as number) = 2;"],
|
||||
["non-null delete", "delete source.count!;"],
|
||||
])("keeps a wrapped %s write-only", (_name, statement) => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const source = { count: 1 };
|
||||
${statement}
|
||||
`);
|
||||
expect(ir.objectOperations.map(({ kind }) => kind)).toEqual(["write"]);
|
||||
});
|
||||
|
||||
it("keeps a member in a destructuring default value a read", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const source = { count: 1 };
|
||||
let value;
|
||||
[value = source.count] = [];
|
||||
`);
|
||||
expect(ir.objectOperations.map(({ kind }) => kind)).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("does not model a compound binding write as a competing replacement", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
let count = 1;
|
||||
|
|
@ -222,6 +280,37 @@ describe("JavaScript semantic analysis: read-modify-write", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("JavaScript semantic analysis: wrapped binding targets", () => {
|
||||
it("records a wrapped identifier update as a read before a write", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
let count = 1;
|
||||
count! += 1;
|
||||
(count as number)++;
|
||||
const copy = count!;
|
||||
`);
|
||||
expect(
|
||||
ir.references
|
||||
.filter(({ name }) => name === "count")
|
||||
.map(({ role }) => role),
|
||||
).toEqual(["read", "write", "read", "write", "read"]);
|
||||
expect(topLevelBinding(ir, "count").value).toMatchObject({
|
||||
status: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("records a wrapped identifier assignment as a write of its value", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
let count = 1;
|
||||
count! = 2;
|
||||
`);
|
||||
expect(
|
||||
ir.references
|
||||
.filter(({ name }) => name === "count")
|
||||
.map(({ role }) => role),
|
||||
).toEqual(["write"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JavaScript semantic analysis: dataflow 2", () => {
|
||||
it("retains partial property coverage and empty returns", () => {
|
||||
const partial = analyzeJavaScriptSemantics(`
|
||||
|
|
|
|||
|
|
@ -115,3 +115,18 @@ it("retains exact empty request field names without dynamic fields", () => {
|
|||
),
|
||||
).toEqual(["", "body"]);
|
||||
});
|
||||
|
||||
it("retains the exact empty CommonJS export name", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
exports[''] = 1;
|
||||
module.exports[''] = 2;
|
||||
exports['ok'] = 3;
|
||||
exports[key] = 4;
|
||||
`);
|
||||
expect(ir.moduleLinks.map(({ exportedName }) => exportedName)).toEqual([
|
||||
"",
|
||||
"",
|
||||
"ok",
|
||||
"*",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -170,3 +170,39 @@ describe("JavaScript semantic analysis: native bindings 2", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JavaScript semantic analysis: child-process signals", () => {
|
||||
it("keeps unresolved kill signals distinct from default and literal signals", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
import { spawn } from "node:child_process";
|
||||
function run(signal) {
|
||||
const child = spawn("/bin/tool");
|
||||
child.kill(signal);
|
||||
child.kill();
|
||||
child.kill("SIGINT");
|
||||
child.kill(undefined);
|
||||
child.kill(undefined as NodeJS.Signals | undefined);
|
||||
child.kill(void 0);
|
||||
child.kill(void signal);
|
||||
}
|
||||
function shadowed(undefined) {
|
||||
const child = spawn("/bin/tool");
|
||||
child.kill(undefined);
|
||||
}
|
||||
`);
|
||||
|
||||
expect(ir.childProcessSpawns).toHaveLength(2);
|
||||
expect(
|
||||
ir.childProcessInteractions.map(({ signalName }) => signalName),
|
||||
).toEqual([
|
||||
null,
|
||||
"SIGTERM",
|
||||
"SIGINT",
|
||||
"SIGTERM",
|
||||
"SIGTERM",
|
||||
"SIGTERM",
|
||||
"SIGTERM",
|
||||
null,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
runtimeFunctionParameters,
|
||||
semanticStaticPropertyKey,
|
||||
semanticWriteTargets,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import type {
|
||||
JavaScriptSemanticAnalysisState,
|
||||
|
|
@ -42,6 +43,7 @@ import {
|
|||
traverseJavaScriptAstSteps,
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
|
||||
import { semanticBundledRequireOrigin } from "./javascriptBundledRequireOrigin.js";
|
||||
import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js";
|
||||
import { indexJavaScriptAmbientWritesSteps } from "./javascriptSemanticAmbientWrites.js";
|
||||
import { hasUncertainTypeOnlyModuleSideEffect } from "./javascriptModuleDeclaration.js";
|
||||
|
|
@ -157,6 +159,7 @@ export function* analyzeParsedJavaScriptSemanticsSteps(
|
|||
): Generator<void, JavaScriptSemanticIr> {
|
||||
const state = createState(file.program, staticImportResolutionMode);
|
||||
yield* collectDefinitionsSteps(file.program, state);
|
||||
yield* indexJavaScriptAmbientWritesSteps(file.program, state);
|
||||
yield* collectSemanticMemberMutationsSteps(file.program, state);
|
||||
yield* traverseJavaScriptAstSteps(file.program, {
|
||||
enter: (node) => collectSemanticModuleLink(node, state),
|
||||
|
|
@ -258,7 +261,8 @@ export type JavaScriptOpenReceiverFact =
|
|||
| "local"
|
||||
| "local-indexed-db"
|
||||
| "local-cache-storage"
|
||||
| "configuration";
|
||||
| "configuration"
|
||||
| "extension-state";
|
||||
|
||||
/**
|
||||
* Resolve only the lexical receiver identity needed to distinguish browser
|
||||
|
|
@ -278,6 +282,7 @@ export function* classifyParsedJavaScriptOpenReceiversSteps(
|
|||
const state = createState(file.program);
|
||||
yield* collectDefinitionsSteps(file.program, state);
|
||||
const facts = new Map<number, JavaScriptOpenReceiverFact>();
|
||||
const extensionStateReads: number[] = [];
|
||||
yield* traverseJavaScriptAstSteps(file.program, {
|
||||
enter: (node) => {
|
||||
if (
|
||||
|
|
@ -293,6 +298,8 @@ export function* classifyParsedJavaScriptOpenReceiversSteps(
|
|||
if (method === "get") {
|
||||
if (isConfigurationReceiver(state, node.callee.object))
|
||||
facts.set(node.start ?? -1, "configuration");
|
||||
else if (isExtensionStateMember(node.callee.object))
|
||||
extensionStateReads.push(node.start ?? -1);
|
||||
return;
|
||||
}
|
||||
if (method !== "open" || !t.isIdentifier(node.callee.object)) return;
|
||||
|
|
@ -358,9 +365,31 @@ export function* classifyParsedJavaScriptOpenReceiversSteps(
|
|||
if (aliasFact !== undefined) facts.set(node.start ?? -1, aliasFact);
|
||||
},
|
||||
});
|
||||
if (extensionStateReads.length > 0 && importsVsCodeModule(state))
|
||||
for (const start of extensionStateReads)
|
||||
facts.set(start, "extension-state");
|
||||
return facts;
|
||||
}
|
||||
|
||||
/** VS Code passes extension state through ExtensionContext, not an import. */
|
||||
const isExtensionStateMember = (receiver: t.Node): boolean =>
|
||||
(t.isMemberExpression(receiver) || t.isOptionalMemberExpression(receiver)) &&
|
||||
(() => {
|
||||
const key = semanticStaticPropertyKey(receiver.property, receiver.computed);
|
||||
return key === "globalState" || key === "workspaceState";
|
||||
})();
|
||||
|
||||
/** Require module evidence before treating a familiar member as VS Code state. */
|
||||
const importsVsCodeModule = (state: JavaScriptSemanticAnalysisState): boolean =>
|
||||
[...state.bindingsById.values()].some(
|
||||
(binding) =>
|
||||
binding.directOrigins.some(({ specifier }) => specifier === "vscode") ||
|
||||
semanticRequireOrigin(
|
||||
semanticStableInitializer(binding, state)?.node,
|
||||
state,
|
||||
)?.origin.specifier === "vscode",
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether a call spells VS Code's `workspace.getConfiguration(section)`, whose
|
||||
* result `.get` reads setting keys rather than request paths. The `workspace`
|
||||
|
|
@ -522,7 +551,7 @@ export function* indexJavaScriptElectronIdentitySteps(
|
|||
const wrapped = interop.callOrigin(root);
|
||||
const origin =
|
||||
wrapped === undefined
|
||||
? semanticRequireOrigin(root, state)?.origin
|
||||
? electronRequireOrigin(root, state)?.origin
|
||||
: collectedWrites && interop.invalidatedCall(root)
|
||||
? undefined
|
||||
: wrapped.origin;
|
||||
|
|
@ -606,6 +635,13 @@ const electronExport = (
|
|||
: null;
|
||||
};
|
||||
|
||||
const electronRequireOrigin = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
) =>
|
||||
semanticRequireOrigin(node, state) ??
|
||||
semanticBundledRequireOrigin(node, state);
|
||||
|
||||
const bindingOrigin = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
|
|
@ -616,7 +652,7 @@ const bindingOrigin = (
|
|||
: undefined;
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (initializer === undefined) return undefined;
|
||||
const origin = semanticRequireOrigin(initializer.node, state)?.origin;
|
||||
const origin = electronRequireOrigin(initializer.node, state)?.origin;
|
||||
const projection = initializer.projection;
|
||||
return origin === undefined ||
|
||||
!projection.every((key): key is string => typeof key === "string")
|
||||
|
|
@ -637,6 +673,7 @@ const createState = (
|
|||
bindings: new Map(),
|
||||
};
|
||||
return {
|
||||
program,
|
||||
staticImportResolutionMode,
|
||||
parentsByNode: new WeakMap(),
|
||||
scopes: [root],
|
||||
|
|
@ -649,7 +686,10 @@ const createState = (
|
|||
linkedRequireCalls: new WeakSet(),
|
||||
moduleLinkBindings: new WeakMap(),
|
||||
conditionalInitializers: new WeakSet(),
|
||||
requiredModuleEffects: new Map(),
|
||||
ambientBindingWrites: new Map(),
|
||||
ambientWrites: new SemanticPropertyPathCoverage(),
|
||||
ambientWriteEffects: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -940,12 +980,13 @@ const bindAssignment = (
|
|||
state: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
if (t.isAssignmentExpression(node)) {
|
||||
if (t.isIdentifier(node.left))
|
||||
const left = unwrapJavaScriptExpression(node.left).node;
|
||||
if (t.isIdentifier(left))
|
||||
addAssignment(
|
||||
node.left,
|
||||
left,
|
||||
node.operator === "=" ||
|
||||
((node.operator === "||=" || node.operator === "??=") &&
|
||||
!hasIncomingValue(node.left, scope, state))
|
||||
!hasIncomingValue(left, scope, state))
|
||||
? node.right
|
||||
: node,
|
||||
scope,
|
||||
|
|
@ -955,9 +996,10 @@ const bindAssignment = (
|
|||
for (const identifier of semanticWriteTargets(node.left))
|
||||
if (t.isIdentifier(identifier))
|
||||
addAssignment(identifier, node, scope, state);
|
||||
} else if (t.isUpdateExpression(node) && t.isIdentifier(node.argument))
|
||||
addAssignment(node.argument, node, scope, state);
|
||||
else if (t.isForOfStatement(node) || t.isForInStatement(node))
|
||||
} else if (t.isUpdateExpression(node)) {
|
||||
const argument = unwrapJavaScriptExpression(node.argument).node;
|
||||
if (t.isIdentifier(argument)) addAssignment(argument, node, scope, state);
|
||||
} else if (t.isForOfStatement(node) || t.isForInStatement(node))
|
||||
for (const pattern of t.isVariableDeclaration(node.left)
|
||||
? node.left.declarations.map(({ id }) => id)
|
||||
: [node.left]) {
|
||||
|
|
@ -1339,7 +1381,7 @@ const createBinding = (
|
|||
name,
|
||||
kind,
|
||||
mutable,
|
||||
mutatedPaths: [],
|
||||
memberWrites: [],
|
||||
escapedPaths: [],
|
||||
definitions: [],
|
||||
initializers: [],
|
||||
|
|
@ -1359,7 +1401,14 @@ const addAssignment = (
|
|||
identifier.name,
|
||||
state,
|
||||
);
|
||||
if (binding === undefined) return;
|
||||
if (binding === undefined) {
|
||||
// Unbound identifiers can denote host wrapper bindings (such as require),
|
||||
// which are separate from properties written through the global object.
|
||||
const writes = state.ambientBindingWrites.get(identifier.name) ?? [];
|
||||
writes.push(identifier);
|
||||
state.ambientBindingWrites.set(identifier.name, writes);
|
||||
return;
|
||||
}
|
||||
binding.mutable = true;
|
||||
binding.definitions.push({ kind: "assignment", location: range(identifier) });
|
||||
binding.initializers.push({ node: initializer, projection: [] });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js";
|
||||
|
||||
import type {
|
||||
JavaScriptSemanticCallable,
|
||||
JavaScriptSemanticEventOperation,
|
||||
|
|
@ -18,6 +16,7 @@ import {
|
|||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import {
|
||||
builtinDataEffectMethod,
|
||||
dataEffectLiteralString,
|
||||
dataEffectMemberCallee,
|
||||
assignedSemanticResultBindings,
|
||||
|
|
@ -308,22 +307,11 @@ const timerMethod = (
|
|||
? null
|
||||
: method;
|
||||
}
|
||||
return timerMethodFromBinding(binding);
|
||||
}
|
||||
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
|
||||
return null;
|
||||
const method = admittedTimerMethod(
|
||||
semanticStaticPropertyName(callee.property, callee.computed),
|
||||
return admittedTimerMethod(
|
||||
builtinDataEffectMethod(callee, state, ["timers", "node:timers"]) ?? "",
|
||||
);
|
||||
return method !== null &&
|
||||
t.isIdentifier(callee.object) &&
|
||||
timerNamespaceBinding(
|
||||
resolveSemanticBindingState(state, callee.object, callee.object.name),
|
||||
)
|
||||
? method
|
||||
: null;
|
||||
};
|
||||
|
||||
const admittedTimerMethod = (
|
||||
value: string,
|
||||
): JavaScriptSemanticTimerOperation["method"] | null =>
|
||||
|
|
@ -331,28 +319,6 @@ const admittedTimerMethod = (
|
|||
(candidate) => candidate === value,
|
||||
) ?? null;
|
||||
|
||||
const timerMethodFromBinding = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
): JavaScriptSemanticTimerOperation["method"] | null => {
|
||||
for (const { specifier, importedPath } of binding.directOrigins) {
|
||||
if (!timerModule(specifier)) continue;
|
||||
const method = admittedTimerMethod(importedPath.at(-1) ?? "");
|
||||
if (method !== null) return method;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const timerNamespaceBinding = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
): boolean =>
|
||||
binding?.directOrigins.some(
|
||||
({ specifier, importedPath }) =>
|
||||
timerModule(specifier) && semanticBuiltinNamespacePath(importedPath),
|
||||
) ?? false;
|
||||
|
||||
const timerModule = (specifier: string): boolean =>
|
||||
specifier === "timers" || specifier === "node:timers";
|
||||
|
||||
const bindingId = (
|
||||
node:
|
||||
| t.Expression
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js";
|
||||
|
||||
import type {
|
||||
JavaScriptSemanticCallable,
|
||||
JavaScriptSemanticChildProcessInteraction,
|
||||
JavaScriptSemanticChildProcessSpawn,
|
||||
} from "./javascriptSemanticIr.js";
|
||||
import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js";
|
||||
import { semanticStaticPropertyName } from "./javascriptAstValues.js";
|
||||
import {
|
||||
semanticStaticPropertyName,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import {
|
||||
isUnshadowedGlobal,
|
||||
resolveSemanticBindingState,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
|
|
@ -18,6 +20,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
|||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import {
|
||||
builtinDataEffectMethod,
|
||||
dataEffectLiteralString,
|
||||
dataEffectMemberCallee,
|
||||
assignedSemanticResultBindings,
|
||||
|
|
@ -222,9 +225,7 @@ const childInteraction = (
|
|||
.sort(compareUnicodeCodePoints),
|
||||
eventName: kind === "listener" ? eventName : null,
|
||||
signalName:
|
||||
kind === "signal"
|
||||
? (dataEffectLiteralString(node.arguments[0]) ?? "SIGTERM")
|
||||
: null,
|
||||
kind === "signal" ? killSignalName(node.arguments, state) : null,
|
||||
listenerLocation:
|
||||
kind === "listener" && t.isNode(listener) ? range(listener) : null,
|
||||
resolution:
|
||||
|
|
@ -237,6 +238,23 @@ const childInteraction = (
|
|||
};
|
||||
};
|
||||
|
||||
// Only an omitted argument or the unshadowed global undefined selects the
|
||||
// SIGTERM default; an unresolved argument leaves the signal unknown.
|
||||
const killSignalName = (
|
||||
args: t.CallExpression["arguments"],
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): string | null => {
|
||||
const [signal] = args;
|
||||
if (signal === undefined) return "SIGTERM";
|
||||
const expression = unwrapJavaScriptExpression(signal).node;
|
||||
if (
|
||||
t.isUnaryExpression(expression, { operator: "void" }) ||
|
||||
isUnshadowedGlobal(expression, state, "undefined")
|
||||
)
|
||||
return "SIGTERM";
|
||||
return dataEffectLiteralString(signal);
|
||||
};
|
||||
|
||||
const classifyInteraction = (
|
||||
method: string,
|
||||
argument:
|
||||
|
|
@ -266,52 +284,15 @@ const childProcessMethod = (
|
|||
callee: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticChildProcessSpawn["method"] | null => {
|
||||
if (t.isIdentifier(callee)) {
|
||||
const binding = resolveSemanticBindingState(state, callee, callee.name);
|
||||
return binding === undefined ? null : methodFromBinding(binding);
|
||||
}
|
||||
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
|
||||
return null;
|
||||
const method = admittedChildMethod(
|
||||
semanticStaticPropertyName(callee.property, callee.computed),
|
||||
const method = builtinDataEffectMethod(callee, state, [
|
||||
"child_process",
|
||||
"node:child_process",
|
||||
]);
|
||||
return (
|
||||
CHILD_PROCESS_METHODS.find((candidate) => candidate === method) ?? null
|
||||
);
|
||||
if (method === null || !t.isIdentifier(callee.object)) return null;
|
||||
const binding = resolveSemanticBindingState(
|
||||
state,
|
||||
callee.object,
|
||||
callee.object.name,
|
||||
);
|
||||
return childProcessNamespace(binding) ? method : null;
|
||||
};
|
||||
|
||||
const methodFromBinding = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
): JavaScriptSemanticChildProcessSpawn["method"] | null => {
|
||||
for (const { specifier, importedPath } of binding.directOrigins) {
|
||||
if (!childProcessModule(specifier)) continue;
|
||||
const method = admittedChildMethod(importedPath.at(-1) ?? "");
|
||||
if (method !== null) return method;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const admittedChildMethod = (
|
||||
value: string,
|
||||
): JavaScriptSemanticChildProcessSpawn["method"] | null =>
|
||||
CHILD_PROCESS_METHODS.find((candidate) => candidate === value) ?? null;
|
||||
|
||||
const childProcessNamespace = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
): boolean =>
|
||||
binding?.directOrigins.some(
|
||||
({ specifier, importedPath }) =>
|
||||
childProcessModule(specifier) &&
|
||||
semanticBuiltinNamespacePath(importedPath),
|
||||
) ?? false;
|
||||
|
||||
const childProcessModule = (specifier: string): boolean =>
|
||||
specifier === "child_process" || specifier === "node:child_process";
|
||||
|
||||
const resolveChildCandidates = (
|
||||
node: t.Node,
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,36 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import { BUILTIN_RESOURCE_METHODS } from "./javascriptBuiltinResourceMethods.js";
|
||||
import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js";
|
||||
import {
|
||||
semanticRequireOrigin,
|
||||
semanticRequireExportsIdentity,
|
||||
semanticGlobalProcessOrigin,
|
||||
semanticMutableBuiltinOrigin,
|
||||
} from "./javascriptSemanticRequireOrigin.js";
|
||||
import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js";
|
||||
import {
|
||||
evaluateSemanticProvenance,
|
||||
evaluateSemanticProvenanceAtRead,
|
||||
} from "./javascriptSemanticValues.js";
|
||||
import {
|
||||
semanticPropertyPathKeyMatches,
|
||||
type JavaScriptSemanticPropertyPath,
|
||||
} from "./javascriptSemanticPropertyPaths.js";
|
||||
|
||||
import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js";
|
||||
import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
semanticResolutionBlocked,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
type JavaScriptSemanticMemberEffect,
|
||||
type JavaScriptSemanticRequiredModuleEffect,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
readExactJavaScriptLiteral,
|
||||
semanticStaticPropertyKey,
|
||||
semanticStaticPropertyName,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
|
||||
|
|
@ -137,59 +154,715 @@ export interface DataEffectTraversalContext {
|
|||
readonly ancestors: t.Node[];
|
||||
}
|
||||
|
||||
/** Resolve a direct or namespace-imported built-in method. */
|
||||
/** Resolve built-in methods from the shared, unambiguous binding provenance. */
|
||||
export const builtinDataEffectMethod = (
|
||||
callee: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
specifiers: readonly string[],
|
||||
): string | null => {
|
||||
const expression = unwrapJavaScriptExpression(callee).node;
|
||||
if (expression !== callee)
|
||||
return builtinDataEffectMethod(expression, state, specifiers);
|
||||
if (t.isSequenceExpression(expression)) {
|
||||
const final = expression.expressions.at(-1);
|
||||
return final === undefined
|
||||
? null
|
||||
: builtinDataEffectMethod(final, state, specifiers);
|
||||
}
|
||||
const required = semanticRequireOrigin(callee, state);
|
||||
if (required !== undefined) {
|
||||
const origin = builtinNamespaceOrigin(required.origin);
|
||||
return origin.importedPath.length === 1 &&
|
||||
specifiers.some(
|
||||
(specifier) => specifier.replace(/^node:/u, "") === origin.specifier,
|
||||
) &&
|
||||
!requiredModuleChanged(
|
||||
required.origin.specifier,
|
||||
required.origin.importedPath,
|
||||
callee,
|
||||
state,
|
||||
true,
|
||||
)
|
||||
? (origin.importedPath[0] ?? null)
|
||||
: null;
|
||||
}
|
||||
if (t.isIdentifier(callee)) {
|
||||
const binding = resolveSemanticBindingState(state, callee, callee.name);
|
||||
return moduleMethod(binding, specifiers);
|
||||
const path = builtinBindingPath(
|
||||
binding,
|
||||
state,
|
||||
specifiers,
|
||||
callee,
|
||||
[],
|
||||
true,
|
||||
);
|
||||
return path?.length === 1 ? (path[0] ?? null) : null;
|
||||
}
|
||||
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
|
||||
return null;
|
||||
if (!t.isIdentifier(callee.object)) return null;
|
||||
if (!t.isIdentifier(callee.object)) {
|
||||
const method = semanticStaticPropertyKey(callee.property, callee.computed);
|
||||
const receiver = semanticBoundMemberPath(callee.object, state);
|
||||
if (method !== null && receiver !== undefined) {
|
||||
const provenance = evaluateSemanticProvenance(receiver.binding, state);
|
||||
if (provenance.status === "module") {
|
||||
const origin = builtinNamespaceOrigin({
|
||||
...provenance.origins[0],
|
||||
importedPath: [
|
||||
...provenance.origins[0].importedPath,
|
||||
...receiver.path,
|
||||
],
|
||||
});
|
||||
if (
|
||||
specifiers.some(
|
||||
(specifier) =>
|
||||
specifier.replace(/^node:/u, "") === origin.specifier,
|
||||
)
|
||||
)
|
||||
return builtinBindingPath(
|
||||
receiver.binding,
|
||||
state,
|
||||
specifiers,
|
||||
callee,
|
||||
[...receiver.path, method],
|
||||
true,
|
||||
)?.length === 0
|
||||
? method
|
||||
: null;
|
||||
}
|
||||
}
|
||||
const source = semanticExpressionModuleOrigin(callee.object, state);
|
||||
if (method === null || source === undefined) return null;
|
||||
const origin = builtinNamespaceOrigin(source);
|
||||
return origin.importedPath.length === 0 &&
|
||||
specifiers.some(
|
||||
(specifier) => specifier.replace(/^node:/u, "") === origin.specifier,
|
||||
) &&
|
||||
!requiredModuleChanged(
|
||||
origin.specifier,
|
||||
[...origin.importedPath, method],
|
||||
callee,
|
||||
state,
|
||||
true,
|
||||
callee.object,
|
||||
)
|
||||
? method
|
||||
: null;
|
||||
}
|
||||
const binding = resolveSemanticBindingState(
|
||||
state,
|
||||
callee.object,
|
||||
callee.object.name,
|
||||
);
|
||||
const namespace = binding?.directOrigins.some(
|
||||
({ specifier, importedPath }) =>
|
||||
specifiers.includes(specifier) &&
|
||||
semanticBuiltinNamespacePath(importedPath),
|
||||
const method = semanticStaticPropertyKey(callee.property, callee.computed);
|
||||
if (method === null) return null;
|
||||
const resolvedPath = builtinBindingPath(
|
||||
binding,
|
||||
state,
|
||||
specifiers,
|
||||
callee,
|
||||
[method],
|
||||
true,
|
||||
);
|
||||
return namespace
|
||||
? semanticStaticPropertyName(callee.property, callee.computed)
|
||||
if (resolvedPath?.length === 0) return method;
|
||||
const initializer = binding?.initializers[0];
|
||||
if (
|
||||
initializer === undefined ||
|
||||
binding?.initializers.length !== 1 ||
|
||||
(!t.isMemberExpression(initializer.node) &&
|
||||
!t.isOptionalMemberExpression(initializer.node))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const provenance = evaluateSemanticProvenance(binding, state);
|
||||
if (provenance.status !== "module") return null;
|
||||
const origin = builtinNamespaceOrigin(provenance.origins[0]);
|
||||
const selected = [...origin.importedPath, method];
|
||||
return specifiers.some(
|
||||
(specifier) => specifier.replace(/^node:/u, "") === origin.specifier,
|
||||
) &&
|
||||
builtinBindingPath(
|
||||
binding,
|
||||
state,
|
||||
specifiers,
|
||||
initializer.node,
|
||||
[method],
|
||||
true,
|
||||
)?.length === 0 &&
|
||||
!requiredModuleChanged(
|
||||
provenance.origins[0].specifier,
|
||||
selected,
|
||||
callee,
|
||||
state,
|
||||
true,
|
||||
initializer.node,
|
||||
)
|
||||
? method
|
||||
: null;
|
||||
};
|
||||
|
||||
const moduleMethod = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
specifiers: readonly string[],
|
||||
): string | null => {
|
||||
for (const { specifier, importedPath } of binding?.directOrigins ?? []) {
|
||||
if (!specifiers.includes(specifier)) continue;
|
||||
return importedPath.at(-1) ?? null;
|
||||
const semanticBoundMemberPath = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
):
|
||||
| {
|
||||
readonly binding: JavaScriptSemanticBindingState;
|
||||
readonly path: string[];
|
||||
}
|
||||
| undefined => {
|
||||
const path: string[] = [];
|
||||
let current = unwrapJavaScriptExpression(node).node;
|
||||
while (
|
||||
t.isMemberExpression(current) ||
|
||||
t.isOptionalMemberExpression(current)
|
||||
) {
|
||||
const key = semanticStaticPropertyKey(current.property, current.computed);
|
||||
if (key === null || !t.isNode(current.object)) return undefined;
|
||||
path.unshift(key);
|
||||
current = unwrapJavaScriptExpression(current.object).node;
|
||||
}
|
||||
return null;
|
||||
if (!t.isIdentifier(current)) return undefined;
|
||||
const binding = resolveSemanticBindingState(state, current, current.name);
|
||||
return binding === undefined ? undefined : { binding, path };
|
||||
};
|
||||
|
||||
type BuiltinBindingMemo = Map<t.Node, Map<string, readonly string[] | null>>;
|
||||
|
||||
const builtinBindingPath = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
specifiers: readonly string[],
|
||||
capture: t.Node,
|
||||
selectedPath: readonly string[] = [],
|
||||
callableValue = false,
|
||||
visited: ReadonlySet<string> = new Set(),
|
||||
memo: BuiltinBindingMemo = new Map(),
|
||||
provenanceCapture?: t.Identifier,
|
||||
savedObjectCapture?: t.Node,
|
||||
): readonly string[] | null => {
|
||||
if (binding === undefined) return null;
|
||||
const key = JSON.stringify([
|
||||
binding.bindingId,
|
||||
selectedPath,
|
||||
callableValue,
|
||||
provenanceCapture?.start,
|
||||
provenanceCapture?.end,
|
||||
savedObjectCapture?.start,
|
||||
savedObjectCapture?.end,
|
||||
]);
|
||||
let captured = memo.get(capture);
|
||||
if (captured?.has(key)) return captured.get(key) ?? null;
|
||||
if (visited.has(binding.bindingId)) return null;
|
||||
if (captured === undefined) {
|
||||
captured = new Map();
|
||||
memo.set(capture, captured);
|
||||
}
|
||||
const result = computeBuiltinBindingPath(
|
||||
binding,
|
||||
state,
|
||||
specifiers,
|
||||
capture,
|
||||
selectedPath,
|
||||
callableValue,
|
||||
visited,
|
||||
memo,
|
||||
provenanceCapture,
|
||||
savedObjectCapture,
|
||||
);
|
||||
captured.set(key, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const computeBuiltinBindingPath = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
specifiers: readonly string[],
|
||||
capture: t.Node,
|
||||
selectedPath: readonly string[] = [],
|
||||
callableValue = false,
|
||||
visited: ReadonlySet<string>,
|
||||
memo: BuiltinBindingMemo,
|
||||
provenanceCapture?: t.Identifier,
|
||||
savedObjectCapture?: t.Node,
|
||||
): readonly string[] | null => {
|
||||
if (binding === undefined || visited.has(binding.bindingId)) return null;
|
||||
// Provenance identifies the module, independently of whether a selected slot
|
||||
// still refers to its original built-in after member mutation or escape.
|
||||
if (
|
||||
binding.memberWrites.some(
|
||||
({ path, node }) =>
|
||||
affectsBuiltinPath(path, selectedPath) &&
|
||||
!(
|
||||
semanticTopLevelEffectFollowsCapture(
|
||||
path.length === 0 && provenanceCapture !== undefined
|
||||
? provenanceCapture
|
||||
: capture,
|
||||
node,
|
||||
state.parentsByNode,
|
||||
) ||
|
||||
(savedObjectCapture !== undefined &&
|
||||
path.length < selectedPath.length &&
|
||||
semanticTopLevelEffectFollowsCapture(
|
||||
savedObjectCapture,
|
||||
node,
|
||||
state.parentsByNode,
|
||||
))
|
||||
),
|
||||
) ||
|
||||
builtinEscapes(binding, state).some(
|
||||
({ path, node }) =>
|
||||
affectsBuiltinPath(path, selectedPath) &&
|
||||
// A function-value escape cannot replace its native [[Call]]. An
|
||||
// escaped containing object can still replace the selected method.
|
||||
(!callableValue || path.length < selectedPath.length) &&
|
||||
!semanticTopLevelEffectFollowsCapture(
|
||||
capture,
|
||||
node,
|
||||
state.parentsByNode,
|
||||
),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const provenance =
|
||||
provenanceCapture === undefined
|
||||
? evaluateSemanticProvenance(binding, state)
|
||||
: evaluateSemanticProvenanceAtRead(binding, provenanceCapture, state);
|
||||
if (provenance.status !== "module") return null;
|
||||
const { importedPath } = provenance.origins[0];
|
||||
const namespace = builtinNamespaceOrigin(provenance.origins[0]);
|
||||
if (
|
||||
!specifiers.some((item) => {
|
||||
const name = item.replace(/^node:/u, "");
|
||||
return (
|
||||
name === namespace.specifier ||
|
||||
(namespace.specifier === "fs" &&
|
||||
selectedPath[0] === "promises" &&
|
||||
name === "fs/promises")
|
||||
);
|
||||
})
|
||||
)
|
||||
return null;
|
||||
let resolvedPath: readonly string[] = importedPath;
|
||||
for (const origin of binding.directOrigins) {
|
||||
const shared = semanticMutableBuiltinOrigin(origin, selectedPath);
|
||||
if (
|
||||
shared !== undefined &&
|
||||
requiredModuleChanged(
|
||||
shared.specifier,
|
||||
shared.path,
|
||||
capture,
|
||||
state,
|
||||
callableValue,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
}
|
||||
const ancestors = new Set([...visited, binding.bindingId]);
|
||||
for (const initializer of binding.initializers) {
|
||||
const pending = [
|
||||
{
|
||||
node: initializer.node,
|
||||
path: [...initializer.projection.map(String), ...selectedPath],
|
||||
savedObjectCapture:
|
||||
selectedPath.length > 0 &&
|
||||
(t.isMemberExpression(initializer.node) ||
|
||||
t.isOptionalMemberExpression(initializer.node))
|
||||
? initializer.node
|
||||
: savedObjectCapture,
|
||||
capture:
|
||||
callableValue &&
|
||||
selectedPath.length === 0 &&
|
||||
initializer.projection.length > 0
|
||||
? initializer.node
|
||||
: capture,
|
||||
},
|
||||
];
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop()!;
|
||||
const node = unwrapJavaScriptExpression(current.node).node;
|
||||
if (t.isIdentifier(node)) {
|
||||
const source = resolveSemanticBindingState(state, node, node.name);
|
||||
const sourcePath = builtinBindingPath(
|
||||
source,
|
||||
state,
|
||||
specifiers,
|
||||
current.capture,
|
||||
current.path,
|
||||
callableValue,
|
||||
ancestors,
|
||||
memo,
|
||||
node,
|
||||
current.savedObjectCapture,
|
||||
);
|
||||
if (sourcePath === null) return null;
|
||||
// Normalize ESM default namespaces at their owner before projecting an alias.
|
||||
resolvedPath = [...sourcePath, ...current.path].slice(
|
||||
0,
|
||||
sourcePath.length + current.path.length - selectedPath.length,
|
||||
);
|
||||
} else if (
|
||||
t.isMemberExpression(node) ||
|
||||
t.isOptionalMemberExpression(node)
|
||||
) {
|
||||
const key = semanticStaticPropertyKey(node.property, node.computed);
|
||||
if (key === null) return null;
|
||||
pending.push({
|
||||
node: node.object,
|
||||
path: [key, ...current.path],
|
||||
savedObjectCapture: current.savedObjectCapture,
|
||||
// Freeze a selected function, not later member reads through a saved object.
|
||||
capture:
|
||||
callableValue && selectedPath.length === 0 ? node : current.capture,
|
||||
});
|
||||
} else if (t.isSequenceExpression(node)) {
|
||||
const final = node.expressions.at(-1);
|
||||
if (final === undefined) return null;
|
||||
pending.push({ ...current, node: final });
|
||||
} else if (t.isAssignmentExpression(node) && node.operator === "=") {
|
||||
pending.push({ ...current, node: node.right });
|
||||
} else if (t.isConditionalExpression(node)) {
|
||||
pending.push(
|
||||
{ ...current, node: node.consequent },
|
||||
{ ...current, node: node.alternate },
|
||||
);
|
||||
} else if (t.isLogicalExpression(node)) {
|
||||
pending.push(
|
||||
{ ...current, node: node.left },
|
||||
{ ...current, node: node.right },
|
||||
);
|
||||
} else {
|
||||
const required = semanticRequireOrigin(node, state);
|
||||
if (
|
||||
required !== undefined &&
|
||||
requiredModuleChanged(
|
||||
required.origin.specifier,
|
||||
[...required.origin.importedPath, ...current.path],
|
||||
current.capture,
|
||||
state,
|
||||
callableValue,
|
||||
current.savedObjectCapture,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only a direct ESM default import denotes the built-in namespace;
|
||||
// require("node:fs").default is a literal property, not interop syntax.
|
||||
return binding.directOrigins.length > 0 &&
|
||||
semanticBuiltinNamespacePath(importedPath)
|
||||
? []
|
||||
: builtinNamespaceOrigin({
|
||||
...provenance.origins[0],
|
||||
importedPath: resolvedPath,
|
||||
}).importedPath;
|
||||
};
|
||||
|
||||
/** Interpret known Node namespace aliases without changing source provenance. */
|
||||
const builtinNamespaceOrigin = (
|
||||
origin: JavaScriptModuleOrigin,
|
||||
): JavaScriptModuleOrigin => {
|
||||
const specifier = origin.specifier.replace(/^node:/u, "");
|
||||
const importedPath =
|
||||
origin.resolutionMode !== "require" && origin.importedPath[0] === "default"
|
||||
? origin.importedPath.slice(1)
|
||||
: origin.importedPath;
|
||||
return specifier === "fs" && importedPath[0] === "promises"
|
||||
? {
|
||||
...origin,
|
||||
specifier: "fs/promises",
|
||||
importedPath: importedPath.slice(1),
|
||||
}
|
||||
: { ...origin, specifier, importedPath };
|
||||
};
|
||||
|
||||
const affectsBuiltinPath = (
|
||||
path: JavaScriptSemanticPropertyPath,
|
||||
selected: readonly string[],
|
||||
): boolean =>
|
||||
path.length <= selected.length &&
|
||||
path.every((key, index) =>
|
||||
semanticPropertyPathKeyMatches(key, selected[index]!),
|
||||
);
|
||||
|
||||
const BUILTIN_RECEIVER_METHODS: Readonly<Record<string, ReadonlySet<string>>> =
|
||||
{
|
||||
fs: new Set([
|
||||
"readFile",
|
||||
"readFileSync",
|
||||
"writeFile",
|
||||
"writeFileSync",
|
||||
"close",
|
||||
"closeSync",
|
||||
"existsSync",
|
||||
...BUILTIN_RESOURCE_METHODS.fs,
|
||||
]),
|
||||
"fs/promises": new Set([
|
||||
"readFile",
|
||||
"writeFile",
|
||||
...BUILTIN_RESOURCE_METHODS["fs/promises"],
|
||||
]),
|
||||
net: new Set(BUILTIN_RESOURCE_METHODS.net),
|
||||
tls: new Set(BUILTIN_RESOURCE_METHODS.tls),
|
||||
http: new Set(["request", "get"]),
|
||||
https: new Set(["request", "get"]),
|
||||
child_process: new Set(["spawn", "exec", "execFile", "fork"]),
|
||||
process: new Set(["cwd", "uptime", "hrtime", "nextTick"]),
|
||||
timers: new Set([
|
||||
"setTimeout",
|
||||
"setInterval",
|
||||
"setImmediate",
|
||||
"clearTimeout",
|
||||
"clearInterval",
|
||||
"clearImmediate",
|
||||
]),
|
||||
};
|
||||
|
||||
interface BuiltinEffectIndex {
|
||||
readonly writesByModule: Map<
|
||||
string,
|
||||
readonly JavaScriptSemanticRequiredModuleEffect[]
|
||||
>;
|
||||
readonly moduleEffects: Map<
|
||||
string,
|
||||
readonly JavaScriptSemanticRequiredModuleEffect[]
|
||||
>;
|
||||
readonly bindingEscapes: Map<
|
||||
string,
|
||||
readonly JavaScriptSemanticMemberEffect[]
|
||||
>;
|
||||
readonly receivers: WeakMap<t.Node, boolean>;
|
||||
}
|
||||
|
||||
// Derived passes run after mutation collection. These indexes share that state's
|
||||
// lifetime and never cache facts while its mutation owner is still producing them.
|
||||
const builtinEffectIndexes = new WeakMap<
|
||||
JavaScriptSemanticAnalysisState,
|
||||
BuiltinEffectIndex
|
||||
>();
|
||||
|
||||
const builtinEffectIndex = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): BuiltinEffectIndex => {
|
||||
let index = builtinEffectIndexes.get(state);
|
||||
if (index === undefined) {
|
||||
index = {
|
||||
writesByModule: new Map(
|
||||
[...state.requiredModuleEffects].map(([name, effects]) => [
|
||||
name,
|
||||
effects.filter(({ kind }) => kind === "write"),
|
||||
]),
|
||||
),
|
||||
moduleEffects: new Map(),
|
||||
bindingEscapes: new Map(),
|
||||
receivers: new WeakMap(),
|
||||
};
|
||||
builtinEffectIndexes.set(state, index);
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const builtinEscapes = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): readonly JavaScriptSemanticMemberEffect[] => {
|
||||
const cache = builtinEffectIndex(state).bindingEscapes;
|
||||
let effects = cache.get(binding.bindingId);
|
||||
if (effects === undefined) {
|
||||
effects = binding.escapedPaths.filter(
|
||||
({ node }) => !originalBuiltinReceiver(node, state),
|
||||
);
|
||||
cache.set(binding.bindingId, effects);
|
||||
}
|
||||
return effects;
|
||||
};
|
||||
|
||||
const builtinModuleEffects = (
|
||||
specifier: string,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): readonly JavaScriptSemanticRequiredModuleEffect[] => {
|
||||
const cache = builtinEffectIndex(state).moduleEffects;
|
||||
let effects = cache.get(specifier);
|
||||
if (effects === undefined) {
|
||||
effects = (state.requiredModuleEffects.get(specifier) ?? []).filter(
|
||||
({ kind, node }) =>
|
||||
kind === "write" || !originalBuiltinReceiver(node, state),
|
||||
);
|
||||
if (specifier === "fs/promises") {
|
||||
const sharedFsEscapes = (state.requiredModuleEffects.get("fs") ?? [])
|
||||
.filter(
|
||||
({ kind, path, node }) =>
|
||||
kind === "escape" &&
|
||||
(path.length === 0 || path[0] === "promises") &&
|
||||
!originalBuiltinReceiver(node, state),
|
||||
)
|
||||
.map((effect) => ({
|
||||
...effect,
|
||||
path:
|
||||
effect.path[0] === "promises" ? effect.path.slice(1) : effect.path,
|
||||
}));
|
||||
effects = [...effects, ...sharedFsEscapes];
|
||||
}
|
||||
cache.set(specifier, effects);
|
||||
}
|
||||
return effects;
|
||||
};
|
||||
|
||||
/** Check shared exports at the use or method-capture point, not at every later call. */
|
||||
const requiredModuleChanged = (
|
||||
specifier: string,
|
||||
selected: readonly string[],
|
||||
capture: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
callableValue = false,
|
||||
savedObjectCapture?: t.Node,
|
||||
): boolean => {
|
||||
const identity = semanticRequireExportsIdentity(specifier, selected);
|
||||
return builtinModuleEffects(identity.specifier, state).some(
|
||||
({ path, node, kind }) =>
|
||||
!(
|
||||
savedObjectCapture !== undefined &&
|
||||
path.length < identity.path.length &&
|
||||
semanticTopLevelEffectFollowsCapture(
|
||||
savedObjectCapture,
|
||||
node,
|
||||
state.parentsByNode,
|
||||
)
|
||||
) &&
|
||||
affectsBuiltinPath(path, identity.path) &&
|
||||
!semanticTopLevelEffectFollowsCapture(
|
||||
capture,
|
||||
node,
|
||||
state.parentsByNode,
|
||||
) &&
|
||||
(kind === "write" ||
|
||||
!callableValue ||
|
||||
path.length < identity.path.length),
|
||||
);
|
||||
};
|
||||
|
||||
/** Calling an unchanged built-in as a member is not an escape to unknown code. */
|
||||
const originalBuiltinReceiver = (
|
||||
node: t.Node | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
if (node === undefined) return false;
|
||||
const receivers = builtinEffectIndex(state).receivers;
|
||||
const previous = receivers.get(node);
|
||||
if (previous !== undefined) return previous;
|
||||
const result = computeOriginalBuiltinReceiver(node, state);
|
||||
receivers.set(node, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const computeOriginalBuiltinReceiver = (
|
||||
node: t.Node | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
|
||||
return false;
|
||||
const callee = node.callee;
|
||||
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
|
||||
return false;
|
||||
const receiver = t.isIdentifier(callee.object)
|
||||
? resolveSemanticBindingState(state, callee.object, callee.object.name)
|
||||
: undefined;
|
||||
const provenance =
|
||||
receiver === undefined
|
||||
? undefined
|
||||
: evaluateSemanticProvenance(receiver, state);
|
||||
const sourceOrigin =
|
||||
provenance?.status === "module"
|
||||
? provenance.origins[0]
|
||||
: (semanticExpressionModuleOrigin(callee.object, state) ??
|
||||
semanticRequireOrigin(callee.object, state)?.origin ??
|
||||
semanticGlobalProcessOrigin(callee.object, state));
|
||||
if (sourceOrigin === undefined) return false;
|
||||
const origin = builtinNamespaceOrigin(sourceOrigin);
|
||||
if (origin.importedPath.length !== 0) return false;
|
||||
const method = semanticStaticPropertyKey(callee.property, callee.computed);
|
||||
const identity = semanticRequireExportsIdentity(origin.specifier, [
|
||||
method ?? "",
|
||||
]);
|
||||
if (
|
||||
method === null ||
|
||||
!BUILTIN_RECEIVER_METHODS[origin.specifier]?.has(method) ||
|
||||
receiver?.memberWrites.some(
|
||||
({ path, node: write }) =>
|
||||
affectsBuiltinPath(path, [method]) &&
|
||||
!semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode),
|
||||
) ||
|
||||
(
|
||||
builtinEffectIndex(state).writesByModule.get(identity.specifier) ?? []
|
||||
).some(
|
||||
({ path, node: write }) =>
|
||||
affectsBuiltinPath(path, identity.path) &&
|
||||
!semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode),
|
||||
)
|
||||
)
|
||||
return false;
|
||||
return !node.arguments.some((argument) => {
|
||||
if (!t.isIdentifier(argument)) return false;
|
||||
const passed = resolveSemanticBindingState(state, argument, argument.name);
|
||||
if (passed === undefined) return false;
|
||||
const passedOrigin = evaluateSemanticProvenance(passed, state);
|
||||
return (
|
||||
passedOrigin.status === "module" &&
|
||||
builtinNamespaceOrigin(passedOrigin.origins[0]).specifier ===
|
||||
origin.specifier &&
|
||||
builtinNamespaceOrigin(passedOrigin.origins[0]).importedPath.length === 0
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const semanticExpressionModuleOrigin = (
|
||||
expression: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
visited = new Set<string>(),
|
||||
): JavaScriptModuleOrigin | undefined => {
|
||||
const node = unwrapJavaScriptExpression(expression).node;
|
||||
const required = semanticRequireOrigin(node, state);
|
||||
if (required !== undefined) return required.origin;
|
||||
if (t.isIdentifier(node)) {
|
||||
const binding = resolveSemanticBindingState(state, node, node.name);
|
||||
if (binding === undefined || visited.has(binding.bindingId))
|
||||
return undefined;
|
||||
const provenance = evaluateSemanticProvenance(binding, state);
|
||||
return provenance.status === "module" ? provenance.origins[0] : undefined;
|
||||
}
|
||||
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
||||
const base = semanticExpressionModuleOrigin(node.object, state, visited);
|
||||
const key = semanticStaticPropertyKey(node.property, node.computed);
|
||||
return base === undefined || key === null
|
||||
? undefined
|
||||
: { ...base, importedPath: [...base.importedPath, key] };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Recognize global or node:process aliases without accepting shadowing. */
|
||||
export const isSemanticProcessObject = (
|
||||
node: t.Expression | t.Super,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
family: "env" | "argv",
|
||||
key?: string | null,
|
||||
): boolean => {
|
||||
if (!t.isIdentifier(node)) return false;
|
||||
const binding = resolveSemanticBindingState(state, node, node.name);
|
||||
const selected = key === undefined || key === null ? [family] : [family, key];
|
||||
if (binding === undefined)
|
||||
return (
|
||||
node.name === "process" &&
|
||||
!semanticResolutionBlocked(state, node, node.name)
|
||||
semanticGlobalProcessOrigin(node, state) !== undefined &&
|
||||
!requiredModuleChanged("process", selected, node, state)
|
||||
);
|
||||
return binding.directOrigins.some(
|
||||
({ specifier }) => specifier === "process" || specifier === "node:process",
|
||||
return (
|
||||
builtinBindingPath(
|
||||
binding,
|
||||
state,
|
||||
["process", "node:process"],
|
||||
node,
|
||||
selected,
|
||||
)?.length === 0
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js";
|
||||
|
||||
import type {
|
||||
JavaScriptSemanticBoundaryOperation,
|
||||
JavaScriptSemanticCallable,
|
||||
|
|
@ -140,12 +138,12 @@ const processConfigurationMember = (
|
|||
const object = node.object;
|
||||
if (!t.isMemberExpression(object) && !t.isOptionalMemberExpression(object))
|
||||
return null;
|
||||
if (!isProcessObject(object.object, state)) return null;
|
||||
const family = semanticStaticPropertyName(object.property, object.computed);
|
||||
if (family !== "env" && family !== "argv") return null;
|
||||
// `process.env[""]` names an exact (empty) key; only a dynamic key is unknown.
|
||||
const key = semanticStaticPropertyKey(node.property, node.computed);
|
||||
if (family === "argv" && key !== null && !isArrayIndexKey(key)) return null;
|
||||
if (!isProcessObject(object.object, state, family, key)) return null;
|
||||
return {
|
||||
kind: family === "env" ? "environment" : "argv",
|
||||
key,
|
||||
|
|
@ -375,22 +373,13 @@ const requestMethod = (
|
|||
if (t.isNewExpression(node) && unbound && name === "WebSocket")
|
||||
return "WebSocket";
|
||||
}
|
||||
const member = memberCallee(node);
|
||||
if (member === null || !t.isIdentifier(member.object)) return null;
|
||||
const method = semanticStaticPropertyName(member.property, member.computed);
|
||||
if (method !== "request" && method !== "get") return null;
|
||||
const binding = resolveSemanticBindingState(
|
||||
state,
|
||||
member.object,
|
||||
member.object.name,
|
||||
);
|
||||
return binding?.directOrigins.some(
|
||||
({ specifier, importedPath }) =>
|
||||
["http", "https", "node:http", "node:https"].includes(specifier) &&
|
||||
semanticBuiltinNamespacePath(importedPath),
|
||||
)
|
||||
? method
|
||||
: null;
|
||||
const method = builtinMethod(node.callee, state, [
|
||||
"http",
|
||||
"https",
|
||||
"node:http",
|
||||
"node:https",
|
||||
]);
|
||||
return method === "request" || method === "get" ? method : null;
|
||||
};
|
||||
|
||||
const requestFields = (
|
||||
|
|
|
|||
|
|
@ -184,7 +184,6 @@ const relativePathSchema = z
|
|||
.refine(
|
||||
(path) =>
|
||||
!path.startsWith("/") &&
|
||||
!path.includes("\\") &&
|
||||
path.split("/").every((part) => !["", ".", ".."].includes(part)),
|
||||
"Expected a normalized relative POSIX path without traversal",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import {
|
||||
semanticGlobalProcessOrigin,
|
||||
semanticGlobalProcessPathOrigin,
|
||||
semanticOriginalGlobalProcessAt,
|
||||
semanticMutableBuiltinOrigin,
|
||||
semanticRequireExportsIdentity,
|
||||
semanticRequireOrigin,
|
||||
} from "./javascriptSemanticRequireOrigin.js";
|
||||
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
|
|
@ -35,6 +44,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
|||
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
semanticWriteSiteTarget,
|
||||
semanticWriteTargets,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
|
|
@ -50,7 +60,41 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
const parents = state.parentsByNode;
|
||||
const observablePaths = observableMutationPaths(state);
|
||||
const returnedReferences = createSemanticReturnedReferences(state);
|
||||
const recordedEffects = new Set<string>();
|
||||
const recordedRequiredEffects = new Set<string>();
|
||||
let recordedEffectCount = 0;
|
||||
const recordRequiredEffect = (
|
||||
specifier: string,
|
||||
path: PropertyPath,
|
||||
effect: ValueEffect,
|
||||
node: t.Node | undefined,
|
||||
): void => {
|
||||
const identity = semanticRequireExportsIdentity(specifier, path);
|
||||
const key = JSON.stringify([
|
||||
"required",
|
||||
identity.specifier,
|
||||
effect,
|
||||
identity.path,
|
||||
node?.start,
|
||||
node?.end,
|
||||
]);
|
||||
if (recordedRequiredEffects.has(key)) return;
|
||||
recordedRequiredEffects.add(key);
|
||||
recordedEffectCount += 1;
|
||||
const effects = state.requiredModuleEffects.get(identity.specifier) ?? [];
|
||||
effects.push({ path: identity.path, node, kind: effect });
|
||||
state.requiredModuleEffects.set(identity.specifier, effects);
|
||||
};
|
||||
// Ambient indexing owns aliases of global objects. Consume its canonical
|
||||
// writes instead of reparsing qualified or aliased process assignments.
|
||||
for (const { path, node } of state.ambientWriteEffects)
|
||||
if (
|
||||
path[0] === "global" &&
|
||||
path[1] === "process" &&
|
||||
path.length > 2 &&
|
||||
node !== undefined &&
|
||||
semanticOriginalGlobalProcessAt(node, state)
|
||||
)
|
||||
recordRequiredEffect("node:process", path.slice(2), "write", node);
|
||||
const selections = new Map<string, readonly string[]>();
|
||||
const coveringPaths = new Map<string, SemanticPropertyPathCoverage>();
|
||||
let arrayIterationUnknown = false;
|
||||
|
|
@ -171,9 +215,37 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
const current = pending[cursor];
|
||||
if (current === undefined) break;
|
||||
const expression = unwrapJavaScriptExpression(current.node).node;
|
||||
const required = semanticRequireOrigin(expression, state);
|
||||
if (required !== undefined) {
|
||||
recordRequiredEffect(
|
||||
required.origin.specifier,
|
||||
[...required.origin.importedPath, ...current.path],
|
||||
effect,
|
||||
mutation,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const globalProcess = semanticGlobalProcessPathOrigin(expression, state);
|
||||
if (globalProcess !== undefined) {
|
||||
recordRequiredEffect(
|
||||
globalProcess.specifier,
|
||||
[...globalProcess.importedPath, ...current.path],
|
||||
effect,
|
||||
mutation,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (t.isIdentifier(expression)) {
|
||||
const binding = resolveBinding(expression, expression.name);
|
||||
if (binding === undefined) {
|
||||
const processOrigin = semanticGlobalProcessOrigin(expression, state);
|
||||
if (processOrigin !== undefined)
|
||||
recordRequiredEffect(
|
||||
processOrigin.specifier,
|
||||
current.path,
|
||||
effect,
|
||||
mutation,
|
||||
);
|
||||
if (
|
||||
(expression.name === "Array" &&
|
||||
(current.path[0] === "prototype" || current.path[0] === null)) ||
|
||||
|
|
@ -270,52 +342,6 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
index === selectorIndex ? { ...selector, excludedKeys } : key,
|
||||
);
|
||||
}
|
||||
// Keep an antichain of exact paths. Widening distinct member keys would
|
||||
// also invalidate unrelated siblings that never reach this effect.
|
||||
const pathState = JSON.stringify([
|
||||
binding.bindingId,
|
||||
effect,
|
||||
// Short writes stop at owned literal slots, whereas longer writes
|
||||
// reach shared children. Only equal-depth writes cover one another.
|
||||
effect === "write" ? path.length : null,
|
||||
mutation?.start,
|
||||
mutation?.end,
|
||||
current.originAt?.start,
|
||||
current.originAt?.end,
|
||||
current.ownerInitializer?.start,
|
||||
current.ownerInitializer?.end,
|
||||
]);
|
||||
let covering = coveringPaths.get(pathState);
|
||||
if (covering === undefined) {
|
||||
covering = new SemanticPropertyPathCoverage();
|
||||
coveringPaths.set(pathState, covering);
|
||||
}
|
||||
if (!covering.retain(path)) continue;
|
||||
const identity = JSON.stringify([
|
||||
binding.bindingId,
|
||||
effect,
|
||||
path,
|
||||
mutation?.start,
|
||||
mutation?.end,
|
||||
current.originAt?.start,
|
||||
current.originAt?.end,
|
||||
current.ownerInitializer?.start,
|
||||
current.ownerInitializer?.end,
|
||||
]);
|
||||
if (recordedEffects.has(identity)) continue;
|
||||
// A recorded write invalidates the binding to a non-primitive value, so
|
||||
// only its first write can target a primitive. Re-evaluating it would
|
||||
// replay every earlier write and make repeated writes superlinear.
|
||||
const value =
|
||||
effect === "write" && binding.mutatedPaths.length === 0
|
||||
? evaluateSemanticBinding(binding, state)
|
||||
: undefined;
|
||||
const primitiveWrite =
|
||||
value?.status === "literal" || value?.status === "union";
|
||||
recordedEffects.add(identity);
|
||||
const nested = new Set([...current.bindings, lifetime, captureKey]);
|
||||
// Returning a literal's receiver reaches that allocation, not the
|
||||
// binding's older values read while evaluating its initializer.
|
||||
const origins =
|
||||
current.ownerInitializer !== undefined
|
||||
? {
|
||||
|
|
@ -323,6 +349,7 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
({ node }) => node === current.ownerInitializer,
|
||||
),
|
||||
referenceInitializers: [],
|
||||
directOrigins: [],
|
||||
}
|
||||
: current.originAt === undefined
|
||||
? binding
|
||||
|
|
@ -331,10 +358,57 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
current.originAt,
|
||||
parents,
|
||||
);
|
||||
// Keep an antichain of exact paths. Widening distinct member keys would
|
||||
// also invalidate unrelated siblings that never reach this effect.
|
||||
// This map is local to the file scan. Escaping the binding id keeps a
|
||||
// field separator injective while avoiding a fresh JSON array encode.
|
||||
const pathState = `${JSON.stringify(binding.bindingId)}\u0001${
|
||||
effect
|
||||
}\u0001${
|
||||
// Short writes stop at owned literal slots, whereas longer writes
|
||||
// reach shared children. Only equal-depth writes cover one another.
|
||||
effect === "write" ? path.length : -1
|
||||
}\u0001${mutation?.start ?? -1}\u0001${mutation?.end ?? -1}\u0001${
|
||||
current.originAt?.start ?? -1
|
||||
}\u0001${current.originAt?.end ?? -1}\u0001${
|
||||
current.ownerInitializer?.start ?? -1
|
||||
}\u0001${current.ownerInitializer?.end ?? -1}`;
|
||||
let covering = coveringPaths.get(pathState);
|
||||
if (covering === undefined) {
|
||||
covering = new SemanticPropertyPathCoverage();
|
||||
coveringPaths.set(pathState, covering);
|
||||
}
|
||||
if (!covering.retain(path)) continue;
|
||||
// A recorded write invalidates the binding to a non-primitive value, so
|
||||
// only its first write can target a primitive. Re-evaluating it would
|
||||
// replay every earlier write and make repeated writes superlinear.
|
||||
const value =
|
||||
effect === "write" && binding.memberWrites.length === 0
|
||||
? evaluateSemanticBinding(binding, state)
|
||||
: undefined;
|
||||
const primitiveWrite =
|
||||
value?.status === "literal" || value?.status === "union";
|
||||
// The coverage index already owns binding-effect deduplication.
|
||||
recordedEffectCount += 1;
|
||||
const nested = new Set(current.bindings);
|
||||
nested.add(lifetime);
|
||||
nested.add(captureKey);
|
||||
// Returning a literal's receiver reaches that allocation, not the
|
||||
// binding's older values read while evaluating its initializer.
|
||||
if (!primitiveWrite) {
|
||||
for (const origin of origins.directOrigins) {
|
||||
const shared = semanticMutableBuiltinOrigin(origin, path);
|
||||
if (shared !== undefined)
|
||||
recordRequiredEffect(
|
||||
shared.specifier,
|
||||
shared.path,
|
||||
effect,
|
||||
mutation,
|
||||
);
|
||||
}
|
||||
if (effect === "escape")
|
||||
binding.escapedPaths.push({ path, node: mutation });
|
||||
else binding.mutatedPaths.push(path);
|
||||
else binding.memberWrites.push({ path, node: mutation });
|
||||
clearSemanticValueCaches(state);
|
||||
for (const initializer of origins.initializers)
|
||||
pending.push({
|
||||
|
|
@ -514,12 +588,8 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
originAt: source.mutation,
|
||||
iterationOnly: source.kind === "iteration",
|
||||
});
|
||||
if (t.isAssignmentExpression(node)) markTarget(node.left, node);
|
||||
else if (t.isUpdateExpression(node)) markTarget(node.argument, node);
|
||||
else if (t.isUnaryExpression(node, { operator: "delete" }))
|
||||
markTarget(node.argument, node);
|
||||
else if (t.isForOfStatement(node) || t.isForInStatement(node))
|
||||
markTarget(node.left, node);
|
||||
const writeTarget = semanticWriteSiteTarget(node);
|
||||
if (writeTarget !== null) markTarget(writeTarget, node);
|
||||
else if (
|
||||
t.isCallExpression(node) ||
|
||||
t.isOptionalCallExpression(node) ||
|
||||
|
|
@ -552,7 +622,7 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
// A later effect can make a previously defined destructuring source uncertain.
|
||||
// Reconsider defaults and iterable origins until their effects are stable.
|
||||
while (pendingReferences.length > 0 || iterableReferences.length > 0) {
|
||||
const effectsBefore = recordedEffects.size;
|
||||
const effectsBefore = recordedEffectCount;
|
||||
const iterablesBefore = iterableReferences.length;
|
||||
const iterationBefore: boolean = arrayIterationUnknown;
|
||||
for (const [index, reference] of pendingReferences.splice(0).entries()) {
|
||||
|
|
@ -631,7 +701,7 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
);
|
||||
}
|
||||
if (
|
||||
recordedEffects.size === effectsBefore &&
|
||||
recordedEffectCount === effectsBefore &&
|
||||
iterableReferences.length === iterablesBefore &&
|
||||
arrayIterationUnknown === iterationBefore
|
||||
)
|
||||
|
|
@ -881,6 +951,15 @@ const observableMutationPaths = (
|
|||
for (const binding of state.bindingsById.values()) {
|
||||
const observer = bindingNodes.get(binding.bindingId);
|
||||
if (observer === undefined) continue;
|
||||
// Imported values are external objects with observable member slots even
|
||||
// when their syntax has no local object-allocation node (for example a
|
||||
// CommonJS namespace from `require("node:fs")`). Keep those writes at their
|
||||
// exact member paths instead of widening the first path to a wildcard.
|
||||
const hasParsedRequireOrigin = binding.initializers.some(
|
||||
({ node }) => semanticRequireOrigin(node, state) !== undefined,
|
||||
);
|
||||
if (binding.directOrigins.length > 0 || hasParsedRequireOrigin)
|
||||
(observer.origins ??= new Set()).add("opaque");
|
||||
for (const initializer of binding.initializers) {
|
||||
link(
|
||||
project(expressionNode(initializer.node), initializer.projection),
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ describe("parameter references copied into function body bindings", () => {
|
|||
name: "arg",
|
||||
kind: "variable",
|
||||
mutable: true,
|
||||
mutatedPaths: [],
|
||||
memberWrites: [],
|
||||
escapedPaths: [],
|
||||
definitions: [],
|
||||
initializers: [entry, assigned],
|
||||
|
|
|
|||
|
|
@ -73,6 +73,94 @@ const positionPrecedes = (
|
|||
left.declaratorIndex >= 0 &&
|
||||
left.declaratorIndex < right.declaratorIndex));
|
||||
|
||||
/** Order effects only in a non-repeating top-level activation. */
|
||||
export const semanticTopLevelEffectFollowsCapture = (
|
||||
capture: t.Node,
|
||||
effect: t.Node | undefined,
|
||||
parents: WeakMap<t.Node, t.Node>,
|
||||
): boolean => {
|
||||
const position = (node: t.Node | undefined): StatementPosition | null => {
|
||||
let selected: StatementPosition | null = null;
|
||||
let declarator: t.VariableDeclarator | undefined;
|
||||
let child: t.Node | undefined;
|
||||
while (node !== undefined) {
|
||||
if (t.isFunction(node) || t.isLoop(node)) return null;
|
||||
if (
|
||||
(t.isClassProperty(node) ||
|
||||
t.isClassPrivateProperty(node) ||
|
||||
t.isClassAccessorProperty(node)) &&
|
||||
!node.static &&
|
||||
node.value === child
|
||||
)
|
||||
return null;
|
||||
if (t.isVariableDeclarator(node)) declarator = node;
|
||||
if (selected === null && t.isStatement(node))
|
||||
selected = statementPosition(node, parents, declarator);
|
||||
if (t.isProgram(node)) return selected;
|
||||
child = node;
|
||||
node = parents.get(node);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const before = position(capture);
|
||||
const after = position(effect);
|
||||
if (before === null || after === null) return false;
|
||||
if (positionPrecedes(before, after)) return true;
|
||||
if (
|
||||
before.body !== after.body ||
|
||||
before.index !== after.index ||
|
||||
before.declaratorIndex !== after.declaratorIndex
|
||||
)
|
||||
return false;
|
||||
return expressionEvaluationPrecedes(capture, effect, parents);
|
||||
};
|
||||
|
||||
/** Prove ordering only where JavaScript evaluates sibling expressions left-to-right. */
|
||||
const expressionEvaluationPrecedes = (
|
||||
earlier: t.Node,
|
||||
later: t.Node | undefined,
|
||||
parents: WeakMap<t.Node, t.Node>,
|
||||
): boolean => {
|
||||
if (later === undefined) return false;
|
||||
const earlierAncestors = new Set<t.Node>();
|
||||
let current: t.Node | undefined = earlier;
|
||||
while (current !== undefined) {
|
||||
earlierAncestors.add(current);
|
||||
current = parents.get(current);
|
||||
}
|
||||
const laterPath: t.Node[] = [];
|
||||
current = later;
|
||||
while (current !== undefined && !earlierAncestors.has(current)) {
|
||||
laterPath.push(current);
|
||||
current = parents.get(current);
|
||||
}
|
||||
const common = current;
|
||||
if (common === undefined || common === earlier || common === later)
|
||||
return false;
|
||||
let earlierChild: t.Node = earlier;
|
||||
while (parents.get(earlierChild) !== common) {
|
||||
const parent = parents.get(earlierChild);
|
||||
if (parent === undefined) return false;
|
||||
earlierChild = parent;
|
||||
}
|
||||
const laterChild = laterPath.at(-1);
|
||||
if (laterChild === undefined) return false;
|
||||
const orderedChildren = ((): readonly t.Node[] | null => {
|
||||
if (t.isSequenceExpression(common)) return common.expressions;
|
||||
if (t.isCallExpression(common) || t.isOptionalCallExpression(common))
|
||||
return [common.callee, ...common.arguments];
|
||||
if (t.isNewExpression(common)) return [common.callee, ...common.arguments];
|
||||
if (t.isBinaryExpression(common) || t.isLogicalExpression(common))
|
||||
return [common.left, common.right];
|
||||
if (t.isConditionalExpression(common)) return [common.test];
|
||||
return null;
|
||||
})();
|
||||
if (orderedChildren === null) return false;
|
||||
const earlierIndex = orderedChildren.indexOf(earlierChild);
|
||||
const laterIndex = orderedChildren.indexOf(laterChild);
|
||||
return earlierIndex >= 0 && laterIndex > earlierIndex;
|
||||
};
|
||||
|
||||
const computeDirectInitializerPosition = (
|
||||
node: t.Node,
|
||||
parents: WeakMap<t.Node, t.Node>,
|
||||
|
|
@ -211,12 +299,64 @@ export const semanticMutationInitializers = (
|
|||
parents: WeakMap<t.Node, t.Node>,
|
||||
): Pick<
|
||||
JavaScriptSemanticBindingState,
|
||||
"initializers" | "referenceInitializers"
|
||||
"initializers" | "referenceInitializers" | "directOrigins"
|
||||
> => {
|
||||
const mutationPosition =
|
||||
directMutationPosition(mutation, parents) ??
|
||||
referenceInitializerPosition(mutation, parents);
|
||||
return selectInitializers(binding, mutationPosition, parents);
|
||||
if (mutationPosition === null) return binding;
|
||||
const origins = [
|
||||
...binding.initializers.map((initializer) => ({
|
||||
initializer,
|
||||
position:
|
||||
initializer.entryBody === undefined
|
||||
? directInitializerPosition(initializer.node, parents)
|
||||
: {
|
||||
body: initializer.entryBody.body,
|
||||
index: -1,
|
||||
declaratorIndex: -1,
|
||||
},
|
||||
})),
|
||||
...binding.referenceInitializers.map((initializer) => ({
|
||||
initializer,
|
||||
position: referenceInitializerPosition(initializer.node, parents),
|
||||
})),
|
||||
];
|
||||
if (
|
||||
origins.some(
|
||||
({ position }) =>
|
||||
position === null || !positionPrecedes(position, mutationPosition),
|
||||
)
|
||||
)
|
||||
return binding;
|
||||
const latest = origins.reduce<StatementPosition | null>(
|
||||
(latest, { position }) =>
|
||||
position !== null &&
|
||||
(latest === null || positionPrecedes(latest, position))
|
||||
? position
|
||||
: latest,
|
||||
null,
|
||||
);
|
||||
const retained = new Set(
|
||||
origins
|
||||
.filter(
|
||||
({ position }) =>
|
||||
position?.index === latest?.index &&
|
||||
position?.declaratorIndex === latest?.declaratorIndex,
|
||||
)
|
||||
.map(({ initializer }) => initializer),
|
||||
);
|
||||
return {
|
||||
// A proven earlier assignment replaces even an import-equals value. A
|
||||
// capture before or inside an uncertain assignment retains the full binding.
|
||||
directOrigins: origins.length === 0 ? binding.directOrigins : [],
|
||||
initializers: binding.initializers.filter((initializer) =>
|
||||
retained.has(initializer),
|
||||
),
|
||||
referenceInitializers: binding.referenceInitializers.filter((initializer) =>
|
||||
retained.has(initializer),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** Select a local alias at its read, before any later straight-line reassignment. */
|
||||
|
|
@ -309,6 +449,16 @@ const selectInitializers = (
|
|||
)
|
||||
.map(({ initializer }) => initializer),
|
||||
);
|
||||
// Preserve the owner's identity when selection removes no facts. Consumers
|
||||
// can then reuse the binding's dependency-aware provenance memo rather than
|
||||
// treating every equivalent read as a new temporal snapshot.
|
||||
if (
|
||||
binding.initializers.every((initializer) => retained.has(initializer)) &&
|
||||
binding.referenceInitializers.every((initializer) =>
|
||||
retained.has(initializer),
|
||||
)
|
||||
)
|
||||
return binding;
|
||||
return {
|
||||
initializers: binding.initializers.filter((initializer) =>
|
||||
retained.has(initializer),
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import type {
|
|||
JavaScriptSemanticCallable,
|
||||
JavaScriptSemanticObjectOperation,
|
||||
} from "./javascriptSemanticIr.js";
|
||||
import {
|
||||
semanticCallableIdForNode,
|
||||
semanticReadsBeforeWrite,
|
||||
} from "./javascriptSemanticProjection.js";
|
||||
import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js";
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
semanticWriteSiteReadsFirst,
|
||||
semanticWriteSiteTarget,
|
||||
semanticWriteTargets,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import {
|
||||
|
|
@ -28,13 +28,21 @@ export const collectJavaScriptSemanticObjects = (
|
|||
const output: JavaScriptSemanticObjectOperation[] = [];
|
||||
const callableIds = new Set(callables.map(({ callableId }) => callableId));
|
||||
const callableStack: string[] = [];
|
||||
const context: ObjectCollectionContext = { state, output };
|
||||
const context: ObjectCollectionContext = {
|
||||
state,
|
||||
output,
|
||||
writeTargets: new Map(),
|
||||
};
|
||||
traverseJavaScriptAst(program, {
|
||||
enter: (node, parent) => {
|
||||
const callableId = semanticCallableIdForNode(node);
|
||||
if (callableId !== null && callableIds.has(callableId))
|
||||
callableStack.push(callableId);
|
||||
const ownerCallableId = callableStack.at(-1) ?? null;
|
||||
const writeTarget = semanticWriteSiteTarget(node);
|
||||
if (writeTarget !== null)
|
||||
for (const leaf of semanticWriteTargets(writeTarget))
|
||||
if (!t.isIdentifier(leaf)) context.writeTargets.set(leaf, node);
|
||||
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node))
|
||||
collectMember(node, parent, ownerCallableId, context);
|
||||
else if (
|
||||
|
|
@ -73,6 +81,7 @@ export const collectJavaScriptSemanticObjects = (
|
|||
interface ObjectCollectionContext {
|
||||
readonly state: JavaScriptSemanticAnalysisState;
|
||||
readonly output: JavaScriptSemanticObjectOperation[];
|
||||
readonly writeTargets: Map<t.Node, t.Node>;
|
||||
}
|
||||
|
||||
const collectMember = (
|
||||
|
|
@ -90,10 +99,11 @@ const collectMember = (
|
|||
)
|
||||
return;
|
||||
const identity = expressionObjectIdentity(node, state);
|
||||
const write =
|
||||
(t.isAssignmentExpression(parent) && parent.left === node) ||
|
||||
(t.isUpdateExpression(parent) && parent.argument === node);
|
||||
const readsBeforeWrite = write && semanticReadsBeforeWrite(node, parent);
|
||||
const writeSite = context.writeTargets.get(node);
|
||||
context.writeTargets.delete(node);
|
||||
const write = writeSite !== undefined;
|
||||
const readsBeforeWrite =
|
||||
writeSite !== undefined && semanticWriteSiteReadsFirst(writeSite);
|
||||
if (readsBeforeWrite)
|
||||
addObjectOperation(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ import { range } from "./javascriptStaticAnalysisHelpers.js";
|
|||
import {
|
||||
propertyName,
|
||||
semanticStaticPropertyKey,
|
||||
semanticWrappedWriteSite,
|
||||
semanticWriteSiteReadsFirst,
|
||||
semanticWriteSiteTarget,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import {
|
||||
isTypeOnlyExportDeclaration,
|
||||
|
|
@ -175,12 +179,17 @@ export function* collectSemanticReferencesSteps(
|
|||
!patterns.some((pattern) => bindsIdentifier(pattern, parent, node)))
|
||||
)
|
||||
return;
|
||||
const role = semanticIdentifierRole(node, parent, readAncestors());
|
||||
const ancestors = readAncestors();
|
||||
const role = semanticIdentifierRole(node, parent, ancestors);
|
||||
if (role === null) return;
|
||||
// A compound assignment or update reads before it writes; emit both so
|
||||
// reference consumers see the read they actually execute.
|
||||
const roles =
|
||||
role === "write" && semanticReadsBeforeWrite(node, parent)
|
||||
role === "write" &&
|
||||
semanticReadsBeforeWrite(
|
||||
node,
|
||||
semanticWrappedWriteSite(node, parent, ancestors) ?? parent,
|
||||
)
|
||||
? (["read", "write"] as const)
|
||||
: ([role] as const);
|
||||
for (const resolvedRole of roles) {
|
||||
|
|
@ -594,12 +603,15 @@ const declarationCallableId = (
|
|||
*/
|
||||
export const semanticReadsBeforeWrite = (
|
||||
node: t.Node,
|
||||
parent: t.Node,
|
||||
): boolean =>
|
||||
(t.isAssignmentExpression(parent) &&
|
||||
parent.left === node &&
|
||||
parent.operator !== "=") ||
|
||||
(t.isUpdateExpression(parent) && parent.argument === node);
|
||||
site: t.Node,
|
||||
): boolean => {
|
||||
const target = semanticWriteSiteTarget(site);
|
||||
return (
|
||||
target !== null &&
|
||||
unwrapJavaScriptExpression(target).node === node &&
|
||||
semanticWriteSiteReadsFirst(site)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Role of one identifier occurrence, given the node's ancestry.
|
||||
|
|
@ -620,6 +632,8 @@ export const semanticIdentifierRole = (
|
|||
if (isPatternOwnerDeclaration(patternAncestor)) return null;
|
||||
return "write";
|
||||
}
|
||||
const site = semanticWrappedWriteSite(node, parent, ancestors);
|
||||
if (site !== null && site !== parent && !isDeleteSite(site)) return "write";
|
||||
return semanticReferenceRole(node, parent);
|
||||
};
|
||||
|
||||
|
|
@ -684,6 +698,9 @@ const isPatternNode = (node: t.Node): boolean =>
|
|||
t.isAssignmentPattern(node) ||
|
||||
t.isRestElement(node);
|
||||
|
||||
const isDeleteSite = (site: t.Node): boolean =>
|
||||
t.isUnaryExpression(site, { operator: "delete" });
|
||||
|
||||
const isPatternOwnerDeclaration = (node: t.Node): boolean =>
|
||||
(t.isVariableDeclarator(node) && t.isPattern(node.id)) ||
|
||||
(t.isFunction(node) &&
|
||||
|
|
@ -815,16 +832,15 @@ const commonJsExportName = (
|
|||
if (!t.isMemberExpression(node) && !t.isOptionalMemberExpression(node))
|
||||
return undefined;
|
||||
const key = semanticStaticPropertyKey(node.property, node.computed);
|
||||
// `exports[key]`/`module.exports[key]` assign an export whose name is not
|
||||
// knowable. Report the wildcard rather than the variable name, and never
|
||||
// collapse it into `default`, which would claim a real default export.
|
||||
// A computed nonliteral key is unknown, but the empty string is a real exact
|
||||
// CommonJS export name and must not collapse into the wildcard/default.
|
||||
if (isUnshadowedGlobal(node.object, state, "exports"))
|
||||
return key === null ? "*" : key || "*";
|
||||
return key === null ? "*" : key;
|
||||
if (
|
||||
t.isMemberExpression(node.object) &&
|
||||
isCommonJsModuleExports(node.object, state)
|
||||
)
|
||||
return key === null ? "*" : key || "default";
|
||||
return key === null ? "*" : key;
|
||||
if (isCommonJsModuleExports(node, state)) return "default";
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import { BUILTIN_RESOURCE_METHODS } from "./javascriptBuiltinResourceMethods.js";
|
||||
import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
|
|
@ -10,18 +11,243 @@ import {
|
|||
semanticStaticPropertyKey,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js";
|
||||
import { stringValue } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js";
|
||||
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
|
||||
|
||||
interface JavaScriptRequireOrigin {
|
||||
export interface JavaScriptRequireOrigin {
|
||||
readonly origin: JavaScriptModuleOrigin;
|
||||
readonly call: t.CallExpression;
|
||||
}
|
||||
|
||||
/** Recover an unshadowed literal require origin and its exact member path. */
|
||||
export const semanticRequireOrigin = (
|
||||
node: t.Node | null | undefined,
|
||||
// Ambient indexing finishes before these derived facts are consumed. Keep only
|
||||
// replacement sites, rather than rescanning unrelated global member writes.
|
||||
const globalProcessReplacements = new WeakMap<
|
||||
JavaScriptSemanticAnalysisState,
|
||||
readonly (t.Node | undefined)[]
|
||||
>();
|
||||
const moduleRequireWrites = new WeakMap<
|
||||
JavaScriptSemanticAnalysisState,
|
||||
readonly t.Node[]
|
||||
>();
|
||||
|
||||
const writesModuleRequire = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptRequireOrigin | undefined => {
|
||||
): readonly t.Node[] => {
|
||||
const cached = moduleRequireWrites.get(state);
|
||||
if (cached !== undefined) return cached;
|
||||
// Interop invocation resolution may ask for require provenance while this
|
||||
// index is being built. Publish a temporary empty result to break that cycle.
|
||||
moduleRequireWrites.set(state, []);
|
||||
const writes: t.Node[] = [];
|
||||
const interop = createSemanticModuleInterop(state);
|
||||
const isModuleObject = (
|
||||
node: t.Node,
|
||||
visited = new Set<string>(),
|
||||
): boolean => {
|
||||
const value = unwrapJavaScriptExpression(node).node;
|
||||
if (!t.isIdentifier(value)) return false;
|
||||
if (value.name === "module")
|
||||
return (
|
||||
resolveSemanticBindingState(state, value, "module") === undefined &&
|
||||
!semanticResolutionBlocked(state, value, "module")
|
||||
);
|
||||
const binding = resolveSemanticBindingState(state, value, value.name);
|
||||
if (binding === undefined || visited.has(binding.bindingId)) return false;
|
||||
const next = new Set(visited).add(binding.bindingId);
|
||||
return (
|
||||
binding.initializers.length > 0 &&
|
||||
binding.initializers.every(({ node: init }) => isModuleObject(init, next))
|
||||
);
|
||||
};
|
||||
const pending: t.Node[] = [state.program];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
if (node === undefined) break;
|
||||
const target = t.isAssignmentExpression(node)
|
||||
? node.left
|
||||
: t.isUpdateExpression(node) ||
|
||||
t.isUnaryExpression(node, { operator: "delete" })
|
||||
? node.argument
|
||||
: undefined;
|
||||
if (target !== undefined) {
|
||||
const members: string[] = [];
|
||||
let root: t.Node = target;
|
||||
while (t.isMemberExpression(root) || t.isOptionalMemberExpression(root)) {
|
||||
const key = semanticStaticPropertyKey(root.property, root.computed);
|
||||
if (key === null) break;
|
||||
members.unshift(key);
|
||||
root = root.object;
|
||||
}
|
||||
if (
|
||||
members.length === 1 &&
|
||||
members[0] === "require" &&
|
||||
isModuleObject(root)
|
||||
)
|
||||
writes.push(node);
|
||||
else if (
|
||||
members.length === 0 &&
|
||||
(t.isMemberExpression(target) ||
|
||||
t.isOptionalMemberExpression(target)) &&
|
||||
target.computed &&
|
||||
semanticStaticPropertyKey(target.property, true) === null &&
|
||||
isModuleObject(target.object)
|
||||
)
|
||||
// An unknown computed key may be "require"; retain uncertainty rather
|
||||
// than treating this direct delegate write as a proven unrelated slot.
|
||||
writes.push(node);
|
||||
}
|
||||
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
|
||||
const invocation = interop.intrinsicInvocation(node);
|
||||
const owner =
|
||||
invocation?.path?.length === 3 ? invocation.path[1] : undefined;
|
||||
const method = invocation?.path?.at(-1);
|
||||
const args = invocation?.arguments;
|
||||
if (
|
||||
(owner === "Object" || owner === "Reflect") &&
|
||||
method === "defineProperty" &&
|
||||
isModuleObject(args?.[0] ?? t.nullLiteral())
|
||||
) {
|
||||
const key = interop.staticString(args?.[1]);
|
||||
if (key === "require" || key === undefined) writes.push(node);
|
||||
} else if (
|
||||
owner === "Object" &&
|
||||
method === "defineProperties" &&
|
||||
isModuleObject(args?.[0] ?? t.nullLiteral())
|
||||
) {
|
||||
const descriptors = args?.[1];
|
||||
if (
|
||||
!t.isObjectExpression(descriptors) ||
|
||||
descriptors.properties.some(
|
||||
(property) =>
|
||||
t.isSpreadElement(property) ||
|
||||
(t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(property.key, property.computed) ===
|
||||
"require"),
|
||||
)
|
||||
)
|
||||
writes.push(node);
|
||||
} else if (
|
||||
owner === "Object" &&
|
||||
method === "assign" &&
|
||||
args?.some((arg) => isModuleObject(arg))
|
||||
) {
|
||||
const target = args?.[0];
|
||||
if (target !== undefined && isModuleObject(target)) {
|
||||
const sourceHasRequire = args
|
||||
?.slice(1)
|
||||
.some(
|
||||
(arg) =>
|
||||
t.isObjectExpression(arg) &&
|
||||
arg.properties.some(
|
||||
(property) =>
|
||||
t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(
|
||||
property.key,
|
||||
property.computed,
|
||||
) === "require",
|
||||
),
|
||||
);
|
||||
if (
|
||||
sourceHasRequire ||
|
||||
args?.slice(1).some((arg) => !t.isObjectExpression(arg))
|
||||
)
|
||||
writes.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of t.VISITOR_KEYS[node.type] ?? []) {
|
||||
const value = (node as unknown as Record<string, unknown>)[key];
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) if (t.isNode(child)) pending.push(child);
|
||||
} else if (t.isNode(value)) pending.push(value);
|
||||
}
|
||||
}
|
||||
moduleRequireWrites.set(state, writes);
|
||||
return writes;
|
||||
};
|
||||
|
||||
/** Whether the original global process object can still own effects at this source site. */
|
||||
export const semanticOriginalGlobalProcessAt = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
let replacements = globalProcessReplacements.get(state);
|
||||
if (replacements === undefined) {
|
||||
replacements = [
|
||||
...state.ambientWriteEffects
|
||||
.filter(
|
||||
({ path }) =>
|
||||
path.length <= 2 &&
|
||||
path.every((key, index) =>
|
||||
semanticPropertyPathKeyMatches(
|
||||
key,
|
||||
["global", "process"][index]!,
|
||||
),
|
||||
),
|
||||
)
|
||||
.map(({ node: write }) => write),
|
||||
...(state.ambientBindingWrites.get("process") ?? []),
|
||||
];
|
||||
globalProcessReplacements.set(state, replacements);
|
||||
}
|
||||
return !replacements.some(
|
||||
(write) =>
|
||||
!semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode),
|
||||
);
|
||||
};
|
||||
|
||||
/** Node's unbound process reference shares the cached process exports object. */
|
||||
export const semanticGlobalProcessOrigin = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptModuleOrigin | undefined =>
|
||||
t.isIdentifier(node, { name: "process" }) &&
|
||||
resolveSemanticBindingState(state, node, "process") === undefined &&
|
||||
!semanticResolutionBlocked(state, node, "process") &&
|
||||
semanticOriginalGlobalProcessAt(node, state)
|
||||
? { specifier: "node:process", importedPath: [], resolutionMode: "require" }
|
||||
: undefined;
|
||||
|
||||
/** Resolve qualified global-object process references, preserving their selected path. */
|
||||
export const semanticGlobalProcessPathOrigin = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptModuleOrigin | undefined => {
|
||||
const members: string[] = [];
|
||||
let current = unwrapJavaScriptExpression(node).node;
|
||||
while (
|
||||
t.isMemberExpression(current) ||
|
||||
t.isOptionalMemberExpression(current)
|
||||
) {
|
||||
const key = semanticStaticPropertyKey(current.property, current.computed);
|
||||
if (key === null) return undefined;
|
||||
members.unshift(key);
|
||||
current = unwrapJavaScriptExpression(current.object).node;
|
||||
}
|
||||
if (
|
||||
!t.isIdentifier(current) ||
|
||||
!["globalThis", "global", "window", "self"].includes(current.name) ||
|
||||
resolveSemanticBindingState(state, current, current.name) !== undefined ||
|
||||
semanticResolutionBlocked(state, current, current.name) ||
|
||||
members[0] !== "process" ||
|
||||
!semanticOriginalGlobalProcessAt(node, state)
|
||||
)
|
||||
return undefined;
|
||||
return {
|
||||
specifier: "node:process",
|
||||
importedPath: members.slice(1),
|
||||
resolutionMode: "require",
|
||||
};
|
||||
};
|
||||
|
||||
/** Strip a static member chain, returning its root expression and member path. */
|
||||
export const semanticMemberChain = (
|
||||
node: t.Node | null | undefined,
|
||||
):
|
||||
| { readonly root: t.Node | null | undefined; readonly members: string[] }
|
||||
| undefined => {
|
||||
const members: string[] = [];
|
||||
let current = node;
|
||||
while (current !== null && current !== undefined) {
|
||||
|
|
@ -39,15 +265,45 @@ export const semanticRequireOrigin = (
|
|||
members.push(member);
|
||||
current = current.object;
|
||||
}
|
||||
return { root: current, members: members.reverse() };
|
||||
};
|
||||
|
||||
/** Recover an unshadowed literal require origin and its exact member path. */
|
||||
export const semanticRequireOrigin = (
|
||||
node: t.Node | null | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptRequireOrigin | undefined => {
|
||||
const chain = semanticMemberChain(node);
|
||||
const current = chain?.root;
|
||||
if (
|
||||
chain === undefined ||
|
||||
!t.isCallExpression(current) ||
|
||||
!t.isIdentifier(current.callee, { name: "require" })
|
||||
)
|
||||
return undefined;
|
||||
if (
|
||||
writesModuleRequire(state).some(
|
||||
(write) =>
|
||||
!semanticTopLevelEffectFollowsCapture(
|
||||
current,
|
||||
write,
|
||||
state.parentsByNode,
|
||||
),
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
if (
|
||||
resolveSemanticBindingState(state, current.callee, "require") !==
|
||||
undefined ||
|
||||
semanticResolutionBlocked(state, current.callee, "require")
|
||||
semanticResolutionBlocked(state, current.callee, "require") ||
|
||||
(state.ambientBindingWrites.get("require") ?? []).some(
|
||||
(write) =>
|
||||
!semanticTopLevelEffectFollowsCapture(
|
||||
current,
|
||||
write,
|
||||
state.parentsByNode,
|
||||
),
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
const specifier = stringValue(current.arguments[0]);
|
||||
|
|
@ -56,9 +312,50 @@ export const semanticRequireOrigin = (
|
|||
: {
|
||||
origin: {
|
||||
specifier,
|
||||
importedPath: members.reverse(),
|
||||
importedPath: chain.members,
|
||||
resolutionMode: "require",
|
||||
},
|
||||
call: current,
|
||||
};
|
||||
};
|
||||
|
||||
/** Node's fs.promises export and fs/promises require share one object. */
|
||||
export const semanticRequireExportsIdentity = <Key>(
|
||||
specifier: string,
|
||||
path: readonly Key[],
|
||||
): { readonly specifier: string; readonly path: readonly (Key | string)[] } => {
|
||||
const name = specifier.replace(/^node:/u, "");
|
||||
if (name === "fs/promises") return { specifier: name, path };
|
||||
return name === "fs" && path[0] === "promises" && path.length > 1
|
||||
? { specifier: "fs/promises", path: path.slice(1) }
|
||||
: { specifier: name, path };
|
||||
};
|
||||
|
||||
const MUTABLE_BUILTIN_DEFAULTS = new Set([
|
||||
...Object.keys(BUILTIN_RESOURCE_METHODS),
|
||||
"http",
|
||||
"https",
|
||||
"child_process",
|
||||
"timers",
|
||||
"process",
|
||||
]);
|
||||
|
||||
/** Mutable default/object selections share CommonJS identity; namespace slots do not. */
|
||||
export const semanticMutableBuiltinOrigin = <Key = never>(
|
||||
origin: JavaScriptModuleOrigin,
|
||||
selected: readonly Key[] = [],
|
||||
):
|
||||
| { readonly specifier: string; readonly path: readonly (Key | string)[] }
|
||||
| undefined => {
|
||||
const path = [...origin.importedPath, ...selected];
|
||||
if (origin.resolutionMode === "require")
|
||||
return { specifier: origin.specifier, path };
|
||||
const specifier = origin.specifier.replace(/^node:/u, "");
|
||||
if (!MUTABLE_BUILTIN_DEFAULTS.has(specifier)) return undefined;
|
||||
if (path[0] === "default") return { specifier, path: path.slice(1) };
|
||||
if (specifier === "process" && (path[0] === "env" || path[0] === "argv"))
|
||||
return { specifier, path };
|
||||
return specifier === "fs" && path[0] === "promises"
|
||||
? { specifier, path }
|
||||
: undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js";
|
||||
|
||||
import {
|
||||
BUILTIN_RESOURCE_SPECIFIERS,
|
||||
BUILTIN_RESOURCE_METHODS,
|
||||
} from "./javascriptBuiltinResourceMethods.js";
|
||||
import type {
|
||||
JavaScriptSemanticCallable,
|
||||
JavaScriptSemanticResourceOperation,
|
||||
|
|
@ -13,6 +15,7 @@ import {
|
|||
type JavaScriptSemanticBindingState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
builtinDataEffectMethod,
|
||||
dataEffectMemberCallee,
|
||||
outerDataEffectBinding,
|
||||
traverseDataEffects,
|
||||
|
|
@ -20,14 +23,6 @@ import {
|
|||
} from "./javascriptSemanticDataEffectHelpers.js";
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
|
||||
const ACQUIRE_METHODS = [
|
||||
"open",
|
||||
"openSync",
|
||||
"createReadStream",
|
||||
"createWriteStream",
|
||||
"connect",
|
||||
"createConnection",
|
||||
] as const;
|
||||
const RELEASE_METHODS = ["close", "destroy", "end"] as const;
|
||||
|
||||
interface ResourceCandidate {
|
||||
|
|
@ -137,58 +132,19 @@ const acquisitionMethod = (
|
|||
callee: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticResourceOperation["method"] | null => {
|
||||
if (t.isIdentifier(callee)) {
|
||||
const binding = resolveSemanticBindingState(state, callee, callee.name);
|
||||
return methodFromBinding(binding);
|
||||
}
|
||||
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
|
||||
return null;
|
||||
const method = ACQUIRE_METHODS.find(
|
||||
(candidate) =>
|
||||
candidate ===
|
||||
semanticStaticPropertyName(callee.property, callee.computed),
|
||||
);
|
||||
if (method === undefined || !t.isIdentifier(callee.object)) return null;
|
||||
const binding = resolveSemanticBindingState(
|
||||
state,
|
||||
callee.object,
|
||||
callee.object.name,
|
||||
);
|
||||
return resourceNamespace(binding) ? method : null;
|
||||
};
|
||||
|
||||
const methodFromBinding = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
): JavaScriptSemanticResourceOperation["method"] | null => {
|
||||
for (const { specifier, importedPath } of binding?.directOrigins ?? []) {
|
||||
if (!resourceModule(specifier)) continue;
|
||||
const method = ACQUIRE_METHODS.find(
|
||||
(candidate) => candidate === importedPath.at(-1),
|
||||
);
|
||||
if (method !== undefined) return method;
|
||||
for (const specifier of BUILTIN_RESOURCE_SPECIFIERS) {
|
||||
const method = builtinDataEffectMethod(callee, state, [specifier]);
|
||||
if (
|
||||
method !== null &&
|
||||
(
|
||||
BUILTIN_RESOURCE_METHODS[
|
||||
specifier as keyof typeof BUILTIN_RESOURCE_METHODS
|
||||
] as readonly string[]
|
||||
).includes(method)
|
||||
)
|
||||
return method as JavaScriptSemanticResourceOperation["method"];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resourceNamespace = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
): boolean =>
|
||||
binding?.directOrigins.some(
|
||||
({ specifier, importedPath }) =>
|
||||
resourceModule(specifier) && semanticBuiltinNamespacePath(importedPath),
|
||||
) ?? false;
|
||||
|
||||
const resourceModule = (specifier: string): boolean =>
|
||||
[
|
||||
"fs",
|
||||
"fs/promises",
|
||||
"net",
|
||||
"node:fs",
|
||||
"node:fs/promises",
|
||||
"node:net",
|
||||
"node:tls",
|
||||
"tls",
|
||||
].includes(specifier);
|
||||
|
||||
const resourceCandidateId = (candidate: ResourceCandidate): string =>
|
||||
`resource:acquire:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`;
|
||||
|
|
|
|||
|
|
@ -74,10 +74,35 @@ export const createSemanticReturnedReferences = (
|
|||
const pending: Candidate[] = [
|
||||
{ node: callee, path: [], bindings: new Set() },
|
||||
];
|
||||
// A shared initializer can be reached through both arms of every alias
|
||||
// diamond. Expand an expression/path again only when its cycle context
|
||||
// permits a binding that an earlier traversal blocked.
|
||||
const expanded = new WeakMap<
|
||||
t.Node,
|
||||
Map<string, readonly ReadonlySet<string>[]>
|
||||
>();
|
||||
for (let cursor = 0; cursor < pending.length; cursor++) {
|
||||
const item = pending[cursor];
|
||||
if (item === undefined) break;
|
||||
const node = unwrapJavaScriptExpression(item.node).node;
|
||||
const pathKey = JSON.stringify(item.path);
|
||||
const paths =
|
||||
expanded.get(node) ?? new Map<string, readonly ReadonlySet<string>[]>();
|
||||
const contexts = paths.get(pathKey) ?? [];
|
||||
if (
|
||||
contexts.some((context) =>
|
||||
[...context].every((binding) => item.bindings.has(binding)),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
paths.set(pathKey, [
|
||||
...contexts.filter(
|
||||
(context) =>
|
||||
![...item.bindings].every((binding) => context.has(binding)),
|
||||
),
|
||||
item.bindings,
|
||||
]);
|
||||
expanded.set(node, paths);
|
||||
if (t.isIdentifier(node)) {
|
||||
const binding = resolveSemanticBindingState(state, node, node.name);
|
||||
if (binding === undefined || item.bindings.has(binding.bindingId))
|
||||
|
|
|
|||
|
|
@ -32,17 +32,23 @@ interface JavaScriptSemanticReferenceInitializer extends JavaScriptSemanticIniti
|
|||
}
|
||||
|
||||
/** Mutable binding state used only while constructing the immutable IR. */
|
||||
export interface JavaScriptSemanticMemberEffect {
|
||||
readonly path: JavaScriptSemanticPropertyPath;
|
||||
readonly node: t.Node | undefined;
|
||||
}
|
||||
|
||||
export interface JavaScriptSemanticRequiredModuleEffect extends JavaScriptSemanticMemberEffect {
|
||||
readonly kind: "write" | "escape";
|
||||
}
|
||||
|
||||
export interface JavaScriptSemanticBindingState {
|
||||
readonly bindingId: string;
|
||||
readonly scopeId: string;
|
||||
readonly name: string;
|
||||
kind: JavaScriptSemanticDefinition["kind"];
|
||||
mutable: boolean;
|
||||
readonly mutatedPaths: JavaScriptSemanticPropertyPath[];
|
||||
readonly escapedPaths: {
|
||||
readonly path: JavaScriptSemanticPropertyPath;
|
||||
readonly node: t.Node | undefined;
|
||||
}[];
|
||||
readonly memberWrites: JavaScriptSemanticMemberEffect[];
|
||||
readonly escapedPaths: JavaScriptSemanticMemberEffect[];
|
||||
readonly definitions: JavaScriptSemanticDefinition[];
|
||||
readonly initializers: JavaScriptSemanticInitializer[];
|
||||
readonly referenceInitializers: JavaScriptSemanticReferenceInitializer[];
|
||||
|
|
@ -63,6 +69,7 @@ export interface JavaScriptSemanticScopeState {
|
|||
|
||||
/** Shared state for semantic collection and evaluation. */
|
||||
export interface JavaScriptSemanticAnalysisState {
|
||||
readonly program: t.Program;
|
||||
/** Effective loader for static imports in this source artifact. */
|
||||
readonly staticImportResolutionMode: JavaScriptModuleResolutionMode;
|
||||
readonly parentsByNode: WeakMap<t.Node, t.Node>;
|
||||
|
|
@ -71,6 +78,11 @@ export interface JavaScriptSemanticAnalysisState {
|
|||
/** Scope boundaries and descendant scopes resolved on demand. */
|
||||
readonly scopeByNode: WeakMap<t.Node, JavaScriptSemanticScopeState>;
|
||||
readonly bindingsById: Map<string, JavaScriptSemanticBindingState>;
|
||||
/** Effects on a cached require exports object, independent of local projections. */
|
||||
readonly requiredModuleEffects: Map<
|
||||
string,
|
||||
JavaScriptSemanticRequiredModuleEffect[]
|
||||
>;
|
||||
readonly callables: JavaScriptSemanticCallable[];
|
||||
readonly callableNodesById: Map<string, t.Node>;
|
||||
readonly moduleLinks: JavaScriptSemanticModuleLink[];
|
||||
|
|
@ -81,8 +93,12 @@ export interface JavaScriptSemanticAnalysisState {
|
|||
JavaScriptSemanticBindingState
|
||||
>;
|
||||
readonly conditionalInitializers: WeakSet<t.Node>;
|
||||
/** Source-local writes, tagged by global-object or import.meta identity. */
|
||||
/** Unbound identifier writes retain their source sites for capture ordering. */
|
||||
readonly ambientBindingWrites: Map<string, t.Node[]>;
|
||||
/** Source-local writes, tagged by global object or import.meta identity. */
|
||||
readonly ambientWrites: SemanticPropertyPathCoverage;
|
||||
/** Source sites distinguish replacement before an ambient identity capture from later writes. */
|
||||
readonly ambientWriteEffects: JavaScriptSemanticMemberEffect[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ it("invalidates retained alias values at the producing mutation boundary", () =>
|
|||
expect(
|
||||
property(property(evaluateSemanticBinding(root, state), "alias"), "value"),
|
||||
).toEqual({ status: "literal", value: 1 });
|
||||
target.mutatedPaths.push(["value"]);
|
||||
target.memberWrites.push({ path: ["value"], node: undefined });
|
||||
clearSemanticValueCaches(state);
|
||||
expect(
|
||||
property(property(evaluateSemanticBinding(root, state), "alias"), "value")
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
semanticCapturePosition,
|
||||
semanticEscapeFollowsCapture,
|
||||
semanticBindingPrecedesCapture,
|
||||
semanticReadInitializers,
|
||||
} from "./javascriptSemanticMutationInitializers.js";
|
||||
|
||||
import { semanticSlotAtPath } from "./javascriptSemanticSlots.js";
|
||||
|
|
@ -228,6 +229,21 @@ export const evaluateSemanticProvenance = (
|
|||
memo: createEvaluationMemo(state),
|
||||
});
|
||||
|
||||
/** Follow a namespace binding as it was read at a specific source position. */
|
||||
export const evaluateSemanticProvenanceAtRead = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
read: t.Identifier,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptBindingProvenance =>
|
||||
provenanceForBindingAtRead(binding, read, {
|
||||
state,
|
||||
bindings: new Map(),
|
||||
expressionDepth: 0,
|
||||
primitiveBindingValues: primitiveBindingValuesFor(state),
|
||||
dependence: { lowest: CALLER_INDEPENDENT },
|
||||
memo: createEvaluationMemo(state),
|
||||
});
|
||||
|
||||
const readMemoizedEvaluation = <T>(
|
||||
entry: MemoizedEvaluation<T> | undefined,
|
||||
context: EvaluationContext,
|
||||
|
|
@ -383,7 +399,10 @@ const evaluateBinding = (
|
|||
? callerDependence
|
||||
: Math.min(callerDependence, context.dependence.lowest);
|
||||
}
|
||||
let projected = invalidateSemanticMutationPaths(value, binding.mutatedPaths);
|
||||
let projected = invalidateSemanticMutationPaths(
|
||||
value,
|
||||
binding.memberWrites.map(({ path }) => path),
|
||||
);
|
||||
for (const escape of binding.escapedPaths) {
|
||||
// Escapes only invalidate object references; other lattice values survive.
|
||||
if (projected.status !== "object" && projected.status !== "array") break;
|
||||
|
|
@ -912,7 +931,7 @@ const provenanceForExpression = (
|
|||
"unknown",
|
||||
`Unbound identifier ${node.name}.`,
|
||||
)
|
||||
: provenanceForBinding(binding, nestedContext(context));
|
||||
: provenanceForBindingAtRead(binding, node, nestedContext(context));
|
||||
}
|
||||
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
||||
const members: string[] = [];
|
||||
|
|
@ -983,6 +1002,40 @@ const provenanceForExpression = (
|
|||
);
|
||||
};
|
||||
|
||||
/** Resolve an alias from the value visible at this read, before later rebinds. */
|
||||
const provenanceForBindingAtRead = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
read: t.Identifier,
|
||||
context: EvaluationContext,
|
||||
): JavaScriptBindingProvenance => {
|
||||
const selected = semanticReadInitializers(
|
||||
binding,
|
||||
read,
|
||||
context.state.parentsByNode,
|
||||
);
|
||||
if (selected.initializers === binding.initializers)
|
||||
return provenanceForBinding(binding, context);
|
||||
if (context.bindings.has(binding.bindingId))
|
||||
return semanticUnresolvedProvenance(
|
||||
"cycle",
|
||||
`Alias cycle at ${binding.name}.`,
|
||||
);
|
||||
const snapshot: JavaScriptSemanticBindingState = {
|
||||
...binding,
|
||||
...selected,
|
||||
// Assignment facts after the read cannot change the captured value.
|
||||
definitions: binding.definitions.filter(
|
||||
({ kind }) => kind !== "assignment",
|
||||
),
|
||||
};
|
||||
context.bindings.set(binding.bindingId, context.bindings.size);
|
||||
try {
|
||||
return provenanceForBindingValue(snapshot, context);
|
||||
} finally {
|
||||
context.bindings.delete(binding.bindingId);
|
||||
}
|
||||
};
|
||||
|
||||
const isLocallyCreatedValue = (node: t.Node): boolean =>
|
||||
readExactJavaScriptLiteral(node).found ||
|
||||
t.isObjectExpression(node) ||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,252 @@ import { expect, it } from "vitest";
|
|||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
it("preserves earlier and sibling effects without inventing replaced cached-module or process effects", async () => {
|
||||
const root = await createTestTempDirectory("rea-builtin-identity-");
|
||||
await writeFile(
|
||||
join(root, "app.cjs"),
|
||||
`
|
||||
throw new Error('parse only');
|
||||
const a = require('node:fs'); const b = require('fs');
|
||||
b.readFileSync('before.json'); const stream = b.createReadStream('before.json'); stream.destroy();
|
||||
patch(a); b.readFileSync('wrong-after.json');
|
||||
const proc = require('node:process'); proc.env = {}; proc.argv = []; const mode = proc.env.MODE; const arg = proc.argv[2];
|
||||
`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "sibling.mjs"),
|
||||
`import fs from 'node:fs'; fs.writeFileSync = custom; fs.readFileSync('sibling.json'); fs.readFileSync = custom;`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "captures.cjs"),
|
||||
`const fs = require('node:fs');
|
||||
const saved = require('fs').readFileSync;
|
||||
const { readFileSync: savedDestructured } = fs;
|
||||
fs.readFileSync('before-write.json'); fs.readFileSync = custom;
|
||||
const { readFileSync: late } = require('fs');
|
||||
const lateMember = require('fs').readFileSync;
|
||||
saved('saved.json'); savedDestructured('saved-destructured.json');
|
||||
late('wrong-late.json'); lateMember('wrong-member.json');`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "direct.cjs"),
|
||||
`require('node:fs').readFileSync('direct.json');
|
||||
const fs = require('fs'); fs.readFileSync('later.json');
|
||||
require('fs/promises').open('before');
|
||||
fs.promises.open = custom; require('node:fs/promises').open('wrong');`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "loader.cjs"),
|
||||
`require = () => ({ readFileSync() {} });
|
||||
const fs = require('node:fs'); fs.readFileSync('wrong-loader.json');
|
||||
require('node:fs').readFileSync('wrong-direct-loader.json');`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "imports.ts"),
|
||||
`import fs = require('node:fs'); const other = require('fs');
|
||||
const read = other.readFileSync; observe(read); read('escaped.json');
|
||||
fs.readFileSync = custom; other.readFileSync('wrong-import-equals.json');`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "late-loader.cjs"),
|
||||
`const fs=require('fs'); fs.readFileSync('loader-before.json');
|
||||
require=replacement; fs.readFileSync('loader-after.json');
|
||||
require('fs').readFileSync('wrong-late-loader.json');`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "replacement.ts"),
|
||||
`import fs = require('fs'); const other=require('fs'); fs={}; fs.readFileSync=custom; other.readFileSync('replacement-kept.json');`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "dynamic.cjs"),
|
||||
`const fs=require('fs'); fs.hook=custom; fs[key](); fs.readFileSync('wrong-dynamic.json');`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "wrapped.cjs"),
|
||||
`const fs=require('fs'); let assigned; const saved=(0, fs.readFileSync); fs.readFileSync=custom; const late=(assigned=fs.readFileSync); saved('wrapped-kept.json'); late('wrong-wrapped.json');`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "global-process.cjs"),
|
||||
`process.env={MODE:'local'}; const proc=require('process'); const mode=proc.env.MODE;`,
|
||||
);
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "analyze-javascript-application", root, "--json"],
|
||||
{ cwd: process.cwd(), maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const evidence = parseEvidence(JSON.parse(stdout));
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(
|
||||
result.semantic_graph.nodes
|
||||
.filter(({ kind }) => kind === "config-source")
|
||||
.map(({ properties }) => properties.key)
|
||||
.sort(),
|
||||
).toEqual([
|
||||
"before-write.json",
|
||||
"before.json",
|
||||
"direct.json",
|
||||
"escaped.json",
|
||||
"later.json",
|
||||
"loader-after.json",
|
||||
"loader-before.json",
|
||||
"replacement-kept.json",
|
||||
"saved-destructured.json",
|
||||
"saved.json",
|
||||
"sibling.json",
|
||||
"wrapped-kept.json",
|
||||
]);
|
||||
expect(
|
||||
result.semantic_graph.nodes.filter(({ kind }) => kind === "resource"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
result.semantic_graph.relations.filter(
|
||||
({ relation }) => relation === "releases",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
const response = await call("analyze_javascript_application", {
|
||||
input_path: root,
|
||||
format: "auto",
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
expect(response.structuredContent).toEqual(evidence);
|
||||
await client.ping();
|
||||
}, 60000);
|
||||
|
||||
it("excludes monkey-patched CommonJS effects from CLI and MCP evidence", async () => {
|
||||
const root = await createTestTempDirectory("rea-builtin-mutated-");
|
||||
await writeFile(
|
||||
join(root, "app.cjs"),
|
||||
`
|
||||
throw new Error("parse only");
|
||||
const fs = require("node:fs"); fs.readFileSync = custom; fs.createReadStream = custom;
|
||||
const cp = require("node:child_process"); cp.spawn = custom;
|
||||
const timers = require("node:timers"); patch(timers);
|
||||
const http = require("node:http"); delete http.get;
|
||||
fs.readFileSync("config.json"); const stream = fs.createReadStream("config.json"); stream.destroy();
|
||||
const child = cp.spawn("tool", []); child.kill();
|
||||
const timer = timers.setTimeout(callback, 1); clearTimeout(timer);
|
||||
http.get("http://example.test");
|
||||
`,
|
||||
);
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "analyze-javascript-application", root, "--json"],
|
||||
{ cwd: process.cwd(), maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const evidence = parseEvidence(JSON.parse(stdout));
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(
|
||||
result.semantic_graph.nodes.filter(({ kind }) =>
|
||||
[
|
||||
"config-source",
|
||||
"resource",
|
||||
"child-process",
|
||||
"timer",
|
||||
"request",
|
||||
].includes(kind),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
result.semantic_graph.relations.filter(({ relation }) =>
|
||||
["releases", "forwards-signal", "cancels-timer"].includes(relation),
|
||||
),
|
||||
).toEqual([]);
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
const response = await call("analyze_javascript_application", {
|
||||
input_path: root,
|
||||
format: "auto",
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
expect(response.structuredContent).toEqual(evidence);
|
||||
await client.ping();
|
||||
}, 60000);
|
||||
|
||||
it("retains CommonJS builtin effects and handle links through CLI and MCP", async () => {
|
||||
const root = await createTestTempDirectory("rea-builtin-commonjs-");
|
||||
await writeFile(
|
||||
join(root, "app.cjs"),
|
||||
`
|
||||
throw new Error("This source must only be parsed, never executed");
|
||||
const fs = require("node:fs");
|
||||
const {spawn: launch} = require("node:child_process");
|
||||
const timers = require("timers");
|
||||
const {get} = require("http");
|
||||
const proc = require("node:process");
|
||||
const text = fs.readFileSync("config.json", "utf8");
|
||||
const stream = fs.createReadStream("config.json"); stream.destroy();
|
||||
const child = launch("tool", []); child.kill("SIGTERM");
|
||||
const timer = timers.setTimeout(callback, 10); timers.clearTimeout(timer);
|
||||
get("http://example.test");
|
||||
const mode = proc.env.MODE;
|
||||
const absent = require("node:fs").default; absent.readFileSync("wrong-default");
|
||||
const external = require("./fs.js"); external.readFileSync("wrong-external");
|
||||
function shadowed(require) { const fs = require("node:fs"); fs.readFileSync("wrong"); }
|
||||
`,
|
||||
);
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "analyze-javascript-application", root, "--json"],
|
||||
{ cwd: process.cwd(), maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const evidence = parseEvidence(JSON.parse(stdout));
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
const nodes = result.semantic_graph.nodes;
|
||||
expect(
|
||||
nodes.filter(({ kind }) =>
|
||||
[
|
||||
"config-source",
|
||||
"resource",
|
||||
"child-process",
|
||||
"timer",
|
||||
"request",
|
||||
].includes(kind),
|
||||
),
|
||||
).toHaveLength(6);
|
||||
expect(nodes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "config-source",
|
||||
properties: expect.objectContaining({ key: "config.json" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "config-source",
|
||||
properties: expect.objectContaining({ key: "MODE" }),
|
||||
}),
|
||||
...["createReadStream", "spawn", "setTimeout", "get"].map((method) =>
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({ method }),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
);
|
||||
const relations = result.semantic_graph.relations;
|
||||
for (const relation of ["releases", "forwards-signal", "cancels-timer"])
|
||||
expect(relations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ relation, resolution: "resolved" }),
|
||||
]),
|
||||
);
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
const response = await call("analyze_javascript_application", {
|
||||
input_path: root,
|
||||
format: "auto",
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
expect(response.structuredContent).toEqual(evidence);
|
||||
await client.ping();
|
||||
}, 60000);
|
||||
|
||||
it.each([
|
||||
"import fs from 'node:fs';",
|
||||
"import {default as fs} from 'node:fs';",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const graphForJavaScript = (
|
|||
packages: [],
|
||||
json_modules: [],
|
||||
html_scripts: [],
|
||||
html_inline_scripts: [],
|
||||
source_maps: [],
|
||||
visited_ast_nodes: 0,
|
||||
findings: 0,
|
||||
|
|
|
|||
Loading…
Reference in New Issue