mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 01:41:21 +00:00
fix(javascript): preserve loader authority for exact suffixes (#9477)
* fix(javascript): require exact loader suffix case * fix(javascript): share exact loader mode across import resolution * test(process): align symlink fixture with filesystem checkpoints * test(javascript): normalize the module resolution oracle path separators * test(frida): assert the CLI spawn Evidence contract * test(mcp): use an unsupported analysis-view parent * test(browser): separate source-location line bounds from parity * fix(javascript): preserve uncertain loader provenance * test(javascript): cover uncertain require resolution * fix(javascript): retain conditional Electron evidence * fix(javascript): preserve timed semantic provenance * fix(javascript): retain conditional builtin effects * test(javascript): distinguish bundled host authority * fix(javascript): preserve nested loader invocation identity * test(javascript): qualify Electron fixture authority * fix(javascript): preserve loader invocation authority * fix(javascript): respect source order for resolver escapes * fix(javascript): check nested loader callable mutations * fix(javascript): retain conditional constructor requests * fix(reference): retain constructor loader relationships * fix(reference): follow nested loader callable paths * refactor(javascript): share callable mutation interpretation * fix(javascript): require exact package loader suffixes * refactor(javascript): unify conditional relation evidence * refactor(javascript): unify conditional node evidence * fix(javascript): retain unknown authority for mixed case sources * fix(javascript): preserve loader uncertainty for dotfiles * fix(reference): classify javascript suffix dotfiles * test(reference): cover suffix dotfiles through cli * fix(javascript): retain equivalent conditional built-in origins * fix(javascript): preserve wrapped resolver operation * fix(javascript): retain nested conditional built-in receivers * test(javascript): declare CommonJS in native addon fixtures * fix(javascript): retain wrapped resolver candidates * style: format merged native command schema * fix(javascript): preserve uncertain loader authority * test(javascript): require candidate preload evidence * test(javascript): cover reference loader member writes --------- Co-authored-by: ShuaixinHuang <1836656514@qq.com>
This commit is contained in:
1 parent
78a84d75c0
commit
b46f4fcebd
112 files changed
+5156
-797
No files matched your search
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeJavaScriptStaticSource as analyzeUnknownHostSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeCommonJsStaticFixture as analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
|
||||
// Webpack 5 standalone bootstrap with an Electron external, as emitted for
|
||||
// `target: "electron-main"`.
|
||||
@@ -31,6 +32,17 @@ const expectUnresolved = (source: string) => {
|
||||
).toEqual([]);
|
||||
};
|
||||
|
||||
it("does not claim Electron host identity from an unbound require in unknown context", () => {
|
||||
const analysis = analyzeUnknownHostSource(bootstrap(EXTERNAL, CONSUMER));
|
||||
expect(analysis.electron.browser_windows).toContainEqual(
|
||||
expect.objectContaining({
|
||||
preload_path: "./preload.js",
|
||||
loader_authority: "candidate",
|
||||
}),
|
||||
);
|
||||
expect(analysis.analysis_coverage?.applicability.electron).toBe("unknown");
|
||||
});
|
||||
|
||||
describe("Electron identity through Webpack externals", () => {
|
||||
it.each([
|
||||
["const key=45840;n.m[key]=local;", 0],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeJavaScriptStaticSource as analyzeUnknownHostSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeCommonJsStaticFixture as analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
import { ROLLDOWN_MODULE_INTEROP } from "./javascriptModuleInterop.fixture.js";
|
||||
|
||||
const WINDOW =
|
||||
@@ -20,6 +21,13 @@ const BLOCK_MODULE_INTEROP = ROLLDOWN_MODULE_INTEROP.replace(
|
||||
.replace(" });", " });\n }")
|
||||
.replace(" return to;", " }\n return to;");
|
||||
|
||||
it("does not claim Electron interop identity when wrapper authority is unknown", () => {
|
||||
expect(
|
||||
analyzeUnknownHostSource(`${ROLLDOWN_MODULE_INTEROP}
|
||||
${IMPORT} ${WRAP} ${WINDOW}`).electron.browser_windows,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
describe("Electron module interop identity", () => {
|
||||
it("preserves named APIs through the released Rolldown namespace conversion", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}
|
||||
|
||||
@@ -64,6 +64,32 @@ describe("native addon type-only re-exports", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not claim a native re-export from a candidate wrapper with a proven factory loader", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
globalThis.webpackChunkApp.push([[1], {
|
||||
"factory": function (m, e, load) {
|
||||
const known = load("./known.node");
|
||||
module.exports = load("./addon.node");
|
||||
}
|
||||
}]);
|
||||
`);
|
||||
expect(analysis.electron.native_addon_bindings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
binding_kind: "require",
|
||||
specifier: "./known.node",
|
||||
}),
|
||||
);
|
||||
expect(analysis.electron.native_addon_bindings).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
binding_kind: "re-export",
|
||||
specifier: "./addon.node",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
analysis.analysis_coverage?.semantics.unknown_findings,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe("native addon loader provenance", () => {
|
||||
it("does not give ESM fixtures an ambient CommonJS loader", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
@@ -126,3 +152,28 @@ describe("native addon loader provenance", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["call", "apply"])(
|
||||
"does not prove native bindings through a replaced require.%s",
|
||||
(method) => {
|
||||
const argument =
|
||||
method === "call" ? 'null,"./addon.node"' : 'null,["./addon.node"]';
|
||||
const replaced = analyzeCommonJsStaticFixture(
|
||||
`require.${method}=custom;const addon=require.${method}(${argument});`,
|
||||
);
|
||||
expect(replaced.electron.native_addon_bindings).toEqual([]);
|
||||
const intact = analyzeCommonJsStaticFixture(
|
||||
`require.other=custom;const addon=require.${method}(${argument});`,
|
||||
);
|
||||
expect(intact.electron.native_addon_bindings).toMatchObject([
|
||||
{ specifier: "./addon.node", binding_kind: "require" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not prove native bindings through a replaced aliased loader member", () => {
|
||||
const analysis = analyzeCommonJsStaticFixture(
|
||||
'const load=require;load.call=custom;const addon=load.call(null,"./addon.node");',
|
||||
);
|
||||
expect(analysis.electron.native_addon_bindings).toEqual([]);
|
||||
});
|
||||
@@ -5,7 +5,10 @@ import { inspectElectronIpcNode } from "./electronStaticAnalysisIpc.js";
|
||||
import { inspectElectronNativeNode } from "./electronStaticAnalysisNative.js";
|
||||
import { inspectElectronProtocolNode } from "./electronStaticAnalysisProtocol.js";
|
||||
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
|
||||
import { electronCalleePath } from "./electronStaticAnalysisValues.js";
|
||||
import {
|
||||
electronCandidateAuthority,
|
||||
electronCalleePath,
|
||||
} from "./electronStaticAnalysisValues.js";
|
||||
import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js";
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
|
||||
@@ -22,6 +25,11 @@ export const inspectElectronStaticNode = (
|
||||
? (electronCalleePath(node.callee, context) ??
|
||||
context.electronInvocationPath?.(node))
|
||||
: undefined;
|
||||
const provenAppPathChange =
|
||||
context.electronAppPathChangePossible?.(node) === true;
|
||||
const candidateAppPathChange =
|
||||
!provenAppPathChange &&
|
||||
context.electronCandidateAppPathChangePossible?.(node) === true;
|
||||
if (
|
||||
(path !== undefined &&
|
||||
path[0] === "app" &&
|
||||
@@ -29,7 +37,8 @@ export const inspectElectronStaticNode = (
|
||||
path
|
||||
.slice(2)
|
||||
.every((member) => ["call", "apply", "bind"].includes(member))) ||
|
||||
context.electronAppPathChangePossible?.(node) === true
|
||||
provenAppPathChange ||
|
||||
candidateAppPathChange
|
||||
)
|
||||
// A bound or exported setter may escape this source before it changes the
|
||||
// shared app path. Keep that possibility without claiming an invocation.
|
||||
@@ -37,7 +46,16 @@ export const inspectElectronStaticNode = (
|
||||
collection: context.accumulator.appPathChanges,
|
||||
key: "electron:app-path-change",
|
||||
node,
|
||||
value: { module_key: null, location: range(node) },
|
||||
value: {
|
||||
...(candidateAppPathChange
|
||||
? { loader_authority: "candidate" as const }
|
||||
: {}),
|
||||
...(t.isCallExpression(node) || t.isOptionalCallExpression(node)
|
||||
? electronCandidateAuthority(node.callee, context)
|
||||
: {}),
|
||||
module_key: null,
|
||||
location: range(node),
|
||||
},
|
||||
});
|
||||
inspectElectronProtocolNode(node, context);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { contextBridgeExposureArguments } from "./javascriptContextBridgeExposur
|
||||
import {
|
||||
boundedExpression,
|
||||
collectContextBridgeMembers,
|
||||
electronCandidateAuthority,
|
||||
electronCalleePath,
|
||||
electronStaticValue,
|
||||
} from "./electronStaticAnalysisValues.js";
|
||||
@@ -139,6 +140,7 @@ const inspectBrowserWindow = (
|
||||
);
|
||||
context.accumulator.unknownFindings += collected.unknown;
|
||||
const finding: ElectronBrowserWindowFinding = {
|
||||
...electronCandidateAuthority(node.callee, context),
|
||||
options_status:
|
||||
resolvedOptions === undefined
|
||||
? "missing"
|
||||
@@ -155,6 +157,7 @@ const inspectBrowserWindow = (
|
||||
location: range(node),
|
||||
};
|
||||
if (
|
||||
finding.loader_authority !== "candidate" &&
|
||||
finding.preload_path !== null &&
|
||||
finding.preload_resolution_context !== null
|
||||
)
|
||||
@@ -305,6 +308,7 @@ const inspectContextBridge = (
|
||||
? { api_key: key.value, api_key_expression: null }
|
||||
: { api_key: null, api_key_expression: null };
|
||||
const finding: ElectronContextBridgeFinding = {
|
||||
...electronCandidateAuthority(node.callee, context),
|
||||
...apiKey,
|
||||
world: exposure.world,
|
||||
world_id: worldId,
|
||||
@@ -373,6 +377,7 @@ const inspectUtilityProcess = (
|
||||
module_expression: null,
|
||||
};
|
||||
const finding: ElectronUtilityProcessFinding = {
|
||||
...electronCandidateAuthority(node.callee, context),
|
||||
...moduleReference,
|
||||
service_name: serviceName ?? null,
|
||||
module_key: null,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
import { analyzeParsedJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
const analyze = (source: string, path: string) => {
|
||||
const parsed = parseJavaScriptSource(source, path);
|
||||
if (parsed === null) throw new Error("fixture did not parse");
|
||||
return analyzeParsedJavaScriptStaticSource(source, parsed);
|
||||
};
|
||||
|
||||
describe("conditional Electron identities", () => {
|
||||
const source = `
|
||||
const electron = require("electron");
|
||||
const { BrowserWindow, protocol } = electron;
|
||||
new BrowserWindow({ webPreferences: { sandbox: false } });
|
||||
protocol.handle("app", handler);
|
||||
`;
|
||||
|
||||
it("retains alias-derived findings without proving an unknown loader", () => {
|
||||
const result = analyze(source, "main.MJS");
|
||||
expect(result.analysis_coverage?.applicability.electron).toBe("unknown");
|
||||
expect(result.electron.browser_windows).toMatchObject([
|
||||
{
|
||||
loader_authority: "candidate",
|
||||
web_preferences: [
|
||||
{ name: "sandbox", value: { status: "literal", value: false } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(result.electron.protocols).toMatchObject([
|
||||
{
|
||||
loader_authority: "candidate",
|
||||
api: "handle",
|
||||
scheme: { status: "literal", value: "app" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains established CommonJS and ES module observations as proven", () => {
|
||||
const commonjs = analyze(source, "main.cjs");
|
||||
expect(commonjs.analysis_coverage?.applicability.electron).toBe("observed");
|
||||
expect(
|
||||
commonjs.electron.browser_windows[0]?.loader_authority,
|
||||
).toBeUndefined();
|
||||
const esm = analyze(
|
||||
'import { BrowserWindow } from "electron"; new BrowserWindow({});',
|
||||
"main.mjs",
|
||||
);
|
||||
expect(esm.analysis_coverage?.applicability.electron).toBe("observed");
|
||||
expect(esm.electron.browser_windows[0]?.loader_authority).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retains optional loader findings with the host's authority", () => {
|
||||
const optional =
|
||||
'const {app, BrowserWindow} = require?.("electron"); app.setAppPath("/tmp"); new BrowserWindow();';
|
||||
const candidate = analyze(optional, "main.MJS");
|
||||
expect(candidate.analysis_coverage?.applicability.electron).toBe("unknown");
|
||||
expect(candidate.electron.browser_windows).toMatchObject([
|
||||
{ loader_authority: "candidate" },
|
||||
]);
|
||||
expect(candidate.electron.app_path_changes).toMatchObject([
|
||||
{ loader_authority: "candidate" },
|
||||
]);
|
||||
const commonjs = analyze(optional, "main.cjs");
|
||||
expect(commonjs.electron.browser_windows).toHaveLength(1);
|
||||
expect(
|
||||
commonjs.electron.browser_windows[0]?.loader_authority,
|
||||
).toBeUndefined();
|
||||
expect(commonjs.electron.app_path_changes).toHaveLength(1);
|
||||
expect(
|
||||
commonjs.electron.app_path_changes?.[0]?.loader_authority,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not claim conditional identity when initialization is skipped or loader is shadowed", () => {
|
||||
const skipped = analyze(
|
||||
'while (false) { var electron = require("electron"); } new electron.BrowserWindow({});',
|
||||
"main.MJS",
|
||||
);
|
||||
expect(skipped.electron.browser_windows).toEqual([]);
|
||||
const shadowed = analyze(
|
||||
'const require = () => ({ BrowserWindow: Local }); const { BrowserWindow } = require("electron"); new BrowserWindow({});',
|
||||
"main.MJS",
|
||||
);
|
||||
expect(shadowed.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not use a later candidate initializer at an earlier read", () => {
|
||||
const early = analyze(
|
||||
'new electron.BrowserWindow({}); var electron = require("electron");',
|
||||
"main.MJS",
|
||||
);
|
||||
expect(early.electron.browser_windows).toEqual([]);
|
||||
const later = analyze(
|
||||
'var electron = require("electron"); new electron.BrowserWindow({});',
|
||||
"main.MJS",
|
||||
);
|
||||
expect(later.electron.browser_windows).toMatchObject([
|
||||
{ loader_authority: "candidate" },
|
||||
]);
|
||||
const aliasCapturedEarly = analyze(
|
||||
'var BrowserWindow = electron.BrowserWindow; var electron = require("electron"); new BrowserWindow({});',
|
||||
"main.MJS",
|
||||
);
|
||||
expect(aliasCapturedEarly.electron.browser_windows).toEqual([]);
|
||||
const aliasCapturedLater = analyze(
|
||||
'var electron = require("electron"); var BrowserWindow = electron.BrowserWindow; new BrowserWindow({});',
|
||||
"main.MJS",
|
||||
);
|
||||
expect(aliasCapturedLater.electron.browser_windows).toMatchObject([
|
||||
{ loader_authority: "candidate" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not use a later proven CommonJS initializer at an earlier read", () => {
|
||||
const early = analyze(
|
||||
'new electron.BrowserWindow({}); var electron = require("electron");',
|
||||
"main.cjs",
|
||||
);
|
||||
expect(early.electron.browser_windows).toEqual([]);
|
||||
const later = analyze(
|
||||
'var electron = require("electron"); new electron.BrowserWindow({});',
|
||||
"main.cjs",
|
||||
);
|
||||
expect(later.electron.browser_windows).toHaveLength(1);
|
||||
expect(later.electron.browser_windows[0]?.loader_authority).toBeUndefined();
|
||||
const aliasEarly = analyze(
|
||||
'var BrowserWindow = electron.BrowserWindow; var electron = require("electron"); new BrowserWindow({});',
|
||||
"main.cjs",
|
||||
);
|
||||
expect(aliasEarly.electron.browser_windows).toEqual([]);
|
||||
const aliasLater = analyze(
|
||||
'var electron = require("electron"); var BrowserWindow = electron.BrowserWindow; new BrowserWindow({});',
|
||||
"main.cjs",
|
||||
);
|
||||
expect(aliasLater.electron.browser_windows).toHaveLength(1);
|
||||
expect(
|
||||
aliasLater.electron.browser_windows[0]?.loader_authority,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,13 @@ describe("computed Electron web preference keys", () => {
|
||||
} });
|
||||
`);
|
||||
|
||||
expect(analysis.parse_status).toBe("complete");
|
||||
expect(analysis.parse_status).toBe("partial");
|
||||
expect(analysis.analysis_coverage?.syntax).toEqual({
|
||||
status: "complete",
|
||||
error_count: 0,
|
||||
});
|
||||
expect(analysis.electron.browser_windows[0]).toMatchObject({
|
||||
loader_authority: "candidate",
|
||||
preload_path: "./preload.js",
|
||||
web_preferences: [
|
||||
{ name: "", value: { status: "literal", value: false } },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
|
||||
it.each([
|
||||
"module.require = replacement;",
|
||||
@@ -20,7 +21,11 @@ it.each([
|
||||
);
|
||||
|
||||
it.each([
|
||||
['const { app } = require("electron"); app.setAppPath("./other");', true],
|
||||
[
|
||||
'const { app } = require("electron"); app.setAppPath("./other");',
|
||||
true,
|
||||
true,
|
||||
],
|
||||
[
|
||||
'import { app as runtime } from "electron/main"; runtime.setAppPath(path);',
|
||||
true,
|
||||
@@ -32,14 +37,17 @@ it.each([
|
||||
[
|
||||
'const { app } = require("electron"); const runtime = app; runtime.setAppPath("./other");',
|
||||
true,
|
||||
true,
|
||||
],
|
||||
[
|
||||
'const { app } = require("electron"); const copy = { ...{ app } }; copy.app.setAppPath("./other");',
|
||||
true,
|
||||
true,
|
||||
],
|
||||
[
|
||||
'const { app } = require("electron"); const change = app.setAppPath; change("./other");',
|
||||
true,
|
||||
true,
|
||||
],
|
||||
['const app = { setAppPath() {} }; app.setAppPath("./other");', false],
|
||||
[
|
||||
@@ -52,8 +60,10 @@ it.each([
|
||||
],
|
||||
])(
|
||||
"retains Electron application path changes by binding identity: %s",
|
||||
(source, expected) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(source);
|
||||
(source, expected, commonJs = false) => {
|
||||
const analysis = commonJs
|
||||
? analyzeCommonJsStaticFixture(source)
|
||||
: analyzeJavaScriptStaticSource(source);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.electron.app_path_changes ?? []).toEqual(
|
||||
expected
|
||||
@@ -112,7 +122,7 @@ it.each([
|
||||
])(
|
||||
"retains indirect application path changes by Electron identity: %s",
|
||||
(call, expected) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const analysis = analyzeCommonJsStaticFixture(`
|
||||
const { app } = require("electron");
|
||||
${call}
|
||||
`);
|
||||
@@ -160,7 +170,7 @@ it("recovers APIs through an ES module default import of Electron", () => {
|
||||
});
|
||||
|
||||
it("recovers APIs and sender checks written with optional chaining", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const analysis = analyzeCommonJsStaticFixture(`
|
||||
const { contextBridge, ipcMain, utilityProcess } = require("electron");
|
||||
contextBridge?.exposeInMainWorld("api", {});
|
||||
utilityProcess?.fork("./worker.js");
|
||||
@@ -202,7 +212,7 @@ it("recovers APIs and sender checks written with optional chaining", () => {
|
||||
});
|
||||
|
||||
it("preserves empty and computed literal contextBridge member keys", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const analysis = analyzeCommonJsStaticFixture(`
|
||||
const { contextBridge } = require("electron");
|
||||
const dynamicKey = getKey();
|
||||
contextBridge.exposeInMainWorld("api", {
|
||||
@@ -232,7 +242,7 @@ it("preserves empty and computed literal contextBridge member keys", () => {
|
||||
});
|
||||
|
||||
it("follows bundler namespace interop helpers without accepting other writes", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const analysis = analyzeCommonJsStaticFixture(`
|
||||
var __toESM = (mod) => mod;
|
||||
var __importStar = (mod) => mod;
|
||||
let electron = require("electron");
|
||||
@@ -279,7 +289,7 @@ it("does not trust unbound helper names or non-function helper values", () => {
|
||||
});
|
||||
|
||||
it("follows stable declaration aliases but not an assigned default parameter", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const analysis = analyzeCommonJsStaticFixture(`
|
||||
const direct = require("electron");
|
||||
const constAlias = direct;
|
||||
new constAlias.BrowserWindow({webPreferences:{preload:"./const.js"}});
|
||||
@@ -302,7 +312,7 @@ it("follows stable declaration aliases but not an assigned default parameter", (
|
||||
});
|
||||
|
||||
it("preserves one initializer across variable redeclarations without accepting writes", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const analysis = analyzeCommonJsStaticFixture(`
|
||||
var electron;
|
||||
var electron = require("electron");
|
||||
var electron;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "./electronStaticAnalysisTypes.js";
|
||||
import {
|
||||
boundedExpression,
|
||||
electronCandidateAuthority,
|
||||
electronCalleePath,
|
||||
electronStaticValue,
|
||||
handlerKind,
|
||||
@@ -91,6 +92,7 @@ const inspectIpcCall = (
|
||||
if (findingState.handler_kind === "missing")
|
||||
context.accumulator.unknownFindings += 1;
|
||||
const finding: ElectronIpcFinding = {
|
||||
...(scoped?.authority ?? electronCandidateAuthority(node.callee, context)),
|
||||
...findingState,
|
||||
...(scoped === undefined ? {} : { receiver: scoped.receiver }),
|
||||
...channelReference,
|
||||
@@ -112,6 +114,7 @@ const scopedIpcDescriptor = (
|
||||
| {
|
||||
readonly descriptor: ElectronIpcDescriptor;
|
||||
readonly receiver: ElectronIpcReceiver;
|
||||
readonly authority: { readonly loader_authority?: "candidate" };
|
||||
}
|
||||
| undefined => {
|
||||
if (
|
||||
@@ -143,6 +146,7 @@ const scopedIpcDescriptor = (
|
||||
? undefined
|
||||
: {
|
||||
descriptor,
|
||||
authority: receiver.authority,
|
||||
receiver: { kind, owner_location: range(receiver.owner) },
|
||||
};
|
||||
};
|
||||
|
||||
@@ -86,8 +86,8 @@ export function* finishElectronNativeBindingsSteps(
|
||||
: new Map<CommonJsExportSite, CommonJsExportReceiverEffect>();
|
||||
for (const node of pending) {
|
||||
if (t.isVariableDeclarator(node))
|
||||
inspectRequireBinding(node, context, index.bindings);
|
||||
else inspectReExport(node, context, index.bindings, receivers.get(node));
|
||||
inspectRequireBinding(node, context, index);
|
||||
else inspectReExport(node, context, index, receivers.get(node));
|
||||
}
|
||||
pending.length = 0;
|
||||
}
|
||||
@@ -184,9 +184,9 @@ const inspectExportAll = (
|
||||
const inspectRequireBinding = (
|
||||
node: t.VariableDeclarator,
|
||||
context: JavaScriptFindingContext,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
index: JavaScriptReceiverIndex,
|
||||
): void => {
|
||||
const required = nativeRequire(node.init, context, bindings);
|
||||
const required = nativeRequire(node.init, context, index);
|
||||
if (required === undefined) return;
|
||||
addBinding({
|
||||
context,
|
||||
@@ -201,11 +201,15 @@ const inspectRequireBinding = (
|
||||
const inspectReExport = (
|
||||
node: t.AssignmentExpression,
|
||||
context: JavaScriptFindingContext,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
index: JavaScriptReceiverIndex,
|
||||
receiver: CommonJsExportReceiverEffect | undefined,
|
||||
): void => {
|
||||
const exported = commonJsRuntimeExportValues(node, bindings)[0];
|
||||
const exported = commonJsRuntimeExportValues(node, index.bindings)[0];
|
||||
if (exported === undefined) return;
|
||||
if (exported.loaderAuthority === "candidate") {
|
||||
context.accumulator.unknownFindings += 1;
|
||||
return;
|
||||
}
|
||||
const applicability = commonJsExportApplicability(
|
||||
receiver,
|
||||
exported.exportedName,
|
||||
@@ -218,7 +222,7 @@ const inspectReExport = (
|
||||
// The export owner leaves arithmetic/logical stores without a selected RHS.
|
||||
const value = exported.value;
|
||||
if (value == null) return;
|
||||
const required = nativeRequire(value, context, bindings);
|
||||
const required = nativeRequire(value, context, index);
|
||||
if (required === undefined) return;
|
||||
addBinding({
|
||||
context,
|
||||
@@ -274,7 +278,7 @@ const nativeCallReceiver = (
|
||||
const nativeRequire = (
|
||||
node: t.Node | null | undefined,
|
||||
context: JavaScriptFindingContext,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
index: JavaScriptReceiverIndex,
|
||||
):
|
||||
| {
|
||||
readonly specifier: string;
|
||||
@@ -298,8 +302,9 @@ const nativeRequire = (
|
||||
return undefined;
|
||||
const call = semanticRequireCallAt(
|
||||
node,
|
||||
bindings,
|
||||
index.bindings,
|
||||
context.accumulator.factoryRequireBindingIds,
|
||||
index,
|
||||
);
|
||||
if (
|
||||
call === undefined ||
|
||||
|
||||
@@ -200,8 +200,18 @@ describe("Electron option captures and initialization", () => {
|
||||
expect(analysis.electron.browser_windows).toMatchObject([
|
||||
{
|
||||
options_status: "object-literal",
|
||||
preload_path: "preload.js",
|
||||
preload_resolution_context: "filesystem-expression",
|
||||
preload_path: null,
|
||||
preload_resolution_context: null,
|
||||
loader_authority: "candidate",
|
||||
web_preferences: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "preload",
|
||||
value: expect.objectContaining({
|
||||
status: "dynamic",
|
||||
expression: expect.stringContaining('require("path").join'),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -951,7 +961,11 @@ describe("Electron option evidence", () => {
|
||||
utilityProcess.fork("./worker.js", [], { serviceName: "kept", cwd: "/app" });
|
||||
`);
|
||||
|
||||
expect(analysis.parse_status).toBe("complete");
|
||||
expect(analysis.parse_status).toBe("partial");
|
||||
expect(analysis.analysis_coverage?.syntax).toEqual({
|
||||
status: "complete",
|
||||
error_count: 0,
|
||||
});
|
||||
expect(analysis.electron.browser_windows[0]).toMatchObject({
|
||||
preload_path: "./kept.js",
|
||||
preload_resolution_context: "module-specifier",
|
||||
@@ -996,7 +1010,7 @@ describe("Electron option evidence", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("selects proven Electron preloads while ignoring media attributes and unrelated properties", () => {
|
||||
it("retains conditional Electron preloads while ignoring unrelated properties", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
jsx("audio", { controls: true, preload: "metadata", src });
|
||||
(0, React.createElement)(\`video\`, { preload: "auto" });
|
||||
@@ -1006,8 +1020,12 @@ describe("Electron option evidence", () => {
|
||||
new BrowserWindow({webPreferences: {preload: "./preload.js"}});
|
||||
`);
|
||||
|
||||
expect(analysis.role_paths.map(({ path }) => path)).toEqual([
|
||||
"./preload.js",
|
||||
expect(analysis.role_paths).toEqual([]);
|
||||
expect(analysis.electron.browser_windows).toEqual([
|
||||
expect.objectContaining({
|
||||
preload_path: "./preload.js",
|
||||
loader_authority: "candidate",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1071,7 +1089,11 @@ describe("Electron empty option keys", () => {
|
||||
`);
|
||||
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.parse_status).toBe("complete");
|
||||
expect(analysis.parse_status).toBe("partial");
|
||||
expect(analysis.analysis_coverage?.syntax).toEqual({
|
||||
status: "complete",
|
||||
error_count: 0,
|
||||
});
|
||||
expect(analysis.electron.browser_windows).toMatchObject([
|
||||
{
|
||||
web_preferences_status: "object-literal",
|
||||
@@ -1097,7 +1119,11 @@ describe("Electron empty option keys", () => {
|
||||
`);
|
||||
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.parse_status).toBe("complete");
|
||||
expect(analysis.parse_status).toBe("partial");
|
||||
expect(analysis.analysis_coverage?.syntax).toEqual({
|
||||
status: "complete",
|
||||
error_count: 0,
|
||||
});
|
||||
expect(analysis.electron.browser_windows).toMatchObject([
|
||||
{
|
||||
web_preferences_status: "object-literal",
|
||||
@@ -1124,7 +1150,11 @@ describe("Electron empty option keys", () => {
|
||||
`);
|
||||
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.parse_status).toBe("complete");
|
||||
expect(analysis.parse_status).toBe("partial");
|
||||
expect(analysis.analysis_coverage?.syntax).toEqual({
|
||||
status: "complete",
|
||||
error_count: 0,
|
||||
});
|
||||
expect(analysis.electron.utility_processes).toMatchObject([
|
||||
{ module_path: "./worker.js", service_name: "kept" },
|
||||
]);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "./electronStaticAnalysisTypes.js";
|
||||
import {
|
||||
boundedExpression,
|
||||
electronCandidateAuthority,
|
||||
electronCalleePath,
|
||||
electronStaticValue,
|
||||
} from "./electronStaticAnalysisValues.js";
|
||||
@@ -269,6 +270,7 @@ const addProtocol = (
|
||||
const scheme = schemeValue(context, input.scheme, located);
|
||||
if (scheme.status === "dynamic") context.accumulator.unknownFindings += 1;
|
||||
const finding: ElectronProtocolFinding = {
|
||||
...electronCandidateAuthority(input.node.callee, context),
|
||||
api: input.api,
|
||||
role: input.role,
|
||||
scheme,
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeJavaScriptStaticSource as analyzeUnknownHostSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeCommonJsStaticFixture as analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
|
||||
it("retains an unknown-host Electron request as a candidate without claiming IPC identity", () => {
|
||||
const analysis = analyzeUnknownHostSource(
|
||||
'const { BrowserWindow } = require("electron"); new BrowserWindow().webContents.ipc.handle("x", () => {});',
|
||||
);
|
||||
expect(analysis.electron.ipc).toEqual([
|
||||
expect.objectContaining({
|
||||
channel: "x",
|
||||
operation: "handle",
|
||||
loader_authority: "candidate",
|
||||
}),
|
||||
]);
|
||||
expect(analysis.analysis_coverage?.applicability.electron).toBe("unknown");
|
||||
expect(analysis.references).toContainEqual(
|
||||
expect.objectContaining({
|
||||
specifier: "electron",
|
||||
loader_authority: "candidate",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
analysis.analysis_coverage?.semantics.unknown_findings,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["webContents", "window.webContents.ipc"],
|
||||
|
||||
@@ -40,6 +40,8 @@ export type ElectronBrowserWindowFinding = ElectronBrowserWindowPreload & {
|
||||
readonly web_preferences: readonly ElectronWebPreference[];
|
||||
/** Object syntax supporting constant substitution, separate from construction. */
|
||||
readonly resolution_sources?: readonly JavaScriptSourceRange[];
|
||||
/** Electron identity is conditional on an unproved loader. */
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly module_key: string | null;
|
||||
/** Recovered factory runtime; absent in older or unbundled findings. */
|
||||
readonly module_runtime?: string;
|
||||
@@ -60,6 +62,8 @@ export type ElectronContextBridgeFinding = ElectronContextBridgeApiKey & {
|
||||
readonly api_status: "object-literal" | "dynamic" | "missing";
|
||||
readonly members: readonly string[];
|
||||
readonly unknown_members: number;
|
||||
/** Electron identity is conditional on an unproved loader. */
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly module_key: string | null;
|
||||
/** Recovered factory runtime; absent in older or unbundled findings. */
|
||||
readonly module_runtime?: string;
|
||||
@@ -124,6 +128,8 @@ interface ElectronIpcFindingContext {
|
||||
readonly receiver?: ElectronIpcReceiver;
|
||||
/** Absent on direct syntax observations; wrappers require inferred evidence. */
|
||||
readonly resolution_basis?: "wrapper-substitution";
|
||||
/** Electron identity is conditional on an unproved loader. */
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly module_key: string | null;
|
||||
/** Recovered factory runtime; absent in older or unbundled findings. */
|
||||
readonly module_runtime?: string;
|
||||
@@ -168,6 +174,8 @@ export interface ElectronSenderValidationFinding {
|
||||
readonly mechanism: string;
|
||||
readonly expected: ElectronStaticValue;
|
||||
readonly enforcement: "unknown";
|
||||
/** Electron identity is conditional on an unproved loader. */
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly module_key: string | null;
|
||||
/** Recovered factory runtime; absent in older or unbundled findings. */
|
||||
readonly module_runtime?: string;
|
||||
@@ -176,6 +184,8 @@ export interface ElectronSenderValidationFinding {
|
||||
|
||||
interface ElectronUtilityProcessFindingState {
|
||||
readonly service_name: string | null;
|
||||
/** Electron identity is conditional on an unproved loader. */
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly module_key: string | null;
|
||||
/** Recovered factory runtime; absent in older or unbundled findings. */
|
||||
readonly module_runtime?: string;
|
||||
@@ -207,6 +217,8 @@ export interface ElectronNativeAddonBindingFinding {
|
||||
/** Omitted in legacy findings; omission means unknown, not false. */
|
||||
readonly namespace_access?: boolean;
|
||||
readonly dynamic_member_access?: boolean;
|
||||
/** Electron identity is conditional on an unproved loader. */
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly module_key: string | null;
|
||||
/** Recovered factory runtime; absent in older or unbundled findings. */
|
||||
readonly module_runtime?: string;
|
||||
@@ -237,6 +249,8 @@ export interface ElectronProtocolFinding {
|
||||
readonly session: "default-protocol" | "default-session";
|
||||
/** Privilege keys with a literal `true`; null unless `role` is `privileged-scheme` with a literal object. */
|
||||
readonly enabled_privileges: readonly string[] | null;
|
||||
/** Electron identity is conditional on an unproved loader. */
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly module_key: string | null;
|
||||
/** Recovered factory runtime; absent in older or unbundled findings. */
|
||||
readonly module_runtime?: string;
|
||||
@@ -260,6 +274,8 @@ export interface ElectronStaticFindings {
|
||||
|
||||
/** An Electron application path setter invocation or bound delegate capture. */
|
||||
export interface ElectronAppPathChangeFinding {
|
||||
/** Electron identity is conditional on an unproved loader. */
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly module_key: string | null;
|
||||
readonly module_runtime?: string;
|
||||
readonly location: JavaScriptSourceRange;
|
||||
@@ -281,6 +297,8 @@ export interface ElectronCallPathStep {
|
||||
|
||||
/** A statically substituted candidate, never a runtime reachability claim. */
|
||||
export interface ElectronCallPath {
|
||||
/** One or more Electron identities depend on an unproved loader. */
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly entry_kind: "bridge-member" | "ipc-handler";
|
||||
readonly entry: string;
|
||||
/** Exact bridge API and property segments; `entry` remains display text. */
|
||||
|
||||
@@ -18,10 +18,25 @@ export const electronCalleePath = (
|
||||
const { root, members } = electronMemberPath(callee);
|
||||
if (!members.every((member): member is string => member !== null))
|
||||
return undefined;
|
||||
const exported = context.electronBindings?.get(root.start ?? -1);
|
||||
const exported =
|
||||
context.electronBindings?.get(root.start ?? -1) ??
|
||||
context.electronCandidateBindings?.get(root.start ?? -1);
|
||||
return exported === undefined ? undefined : [...exported, ...members];
|
||||
};
|
||||
|
||||
/** Keep the identity uncertainty attached to each derived finding. */
|
||||
export const electronCandidateAuthority = (
|
||||
callee: t.Node,
|
||||
context: JavaScriptFindingContext,
|
||||
): { readonly loader_authority?: "candidate" } => {
|
||||
const { root } = electronMemberPath(callee);
|
||||
const offset = root.start ?? -1;
|
||||
return context.electronBindings?.has(offset) !== true &&
|
||||
context.electronCandidateBindings?.has(offset) === true
|
||||
? { loader_authority: "candidate" }
|
||||
: {};
|
||||
};
|
||||
|
||||
/** Preserve one literal value or the exact inert expression. */
|
||||
export const electronStaticValue = (
|
||||
source: string,
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
type JavaScriptFindingContext,
|
||||
} from "./javascriptStaticAnalysisState.js";
|
||||
|
||||
const inspect = (source: string) => {
|
||||
const file = parseJavaScriptSource(source);
|
||||
const inspect = (source: string, path?: string) => {
|
||||
const file = parseJavaScriptSource(source, path);
|
||||
if (file === null) throw new Error("Fixture must parse");
|
||||
const index = complete(
|
||||
indexJavaScriptBindingsWithReceiversSteps(file.program),
|
||||
@@ -73,8 +73,11 @@ it.each([
|
||||
});
|
||||
|
||||
it("still checks mutations for a source-proven scoped Electron receiver", () => {
|
||||
const result = inspect(`const {BrowserWindow} = require("electron");
|
||||
const window = new BrowserWindow(); window.webContents.ipc.handle("channel", () => {});`);
|
||||
const result = inspect(
|
||||
`const {BrowserWindow} = require("electron");
|
||||
const window = new BrowserWindow(); window.webContents.ipc.handle("channel", () => {});`,
|
||||
"fixture.cts",
|
||||
);
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.mutationIndexCalls).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import { electronCalleePath } from "./electronStaticAnalysisValues.js";
|
||||
import {
|
||||
electronCalleePath,
|
||||
electronCandidateAuthority,
|
||||
} from "./electronStaticAnalysisValues.js";
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
unwrapJavaScriptExpression,
|
||||
@@ -14,17 +17,17 @@ import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
|
||||
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
|
||||
|
||||
/** A renderer load belongs to an intact, source-proven BrowserWindow instance. */
|
||||
export const isElectronRendererLoad = (
|
||||
export const electronRendererLoadAuthority = (
|
||||
callee: t.Node,
|
||||
site: t.Node,
|
||||
context: JavaScriptFindingContext,
|
||||
index: JavaScriptBindingIndex,
|
||||
): boolean => {
|
||||
): { readonly loader_authority?: "candidate" } | undefined => {
|
||||
callee = unwrapJavaScriptExpression(callee).node;
|
||||
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
|
||||
return false;
|
||||
return undefined;
|
||||
const method = semanticStaticPropertyKey(callee.property, callee.computed);
|
||||
if (method !== "loadFile" && method !== "loadURL") return false;
|
||||
if (method !== "loadFile" && method !== "loadURL") return undefined;
|
||||
const receiver = resolveElectronBrowserWindowReceiver(
|
||||
callee.object,
|
||||
site,
|
||||
@@ -32,11 +35,11 @@ export const isElectronRendererLoad = (
|
||||
context,
|
||||
index,
|
||||
);
|
||||
return (
|
||||
receiver !== undefined &&
|
||||
return receiver !== undefined &&
|
||||
(receiver.path.length === 1 ||
|
||||
(receiver.path.length === 2 && receiver.path[0] === "webContents"))
|
||||
);
|
||||
? receiver.authority
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Source allocation identity, not a claim about runtime window/frame identity. */
|
||||
@@ -47,7 +50,11 @@ export const resolveElectronBrowserWindowReceiver = (
|
||||
context: JavaScriptFindingContext,
|
||||
index: JavaScriptBindingIndex,
|
||||
):
|
||||
| { readonly owner: t.NewExpression; readonly path: readonly string[] }
|
||||
| {
|
||||
readonly owner: t.NewExpression;
|
||||
readonly path: readonly string[];
|
||||
readonly authority: { readonly loader_authority?: "candidate" };
|
||||
}
|
||||
| undefined => {
|
||||
let receiver = unwrapJavaScriptExpression(node).node;
|
||||
const path = [...suffix];
|
||||
@@ -105,7 +112,11 @@ export const resolveElectronBrowserWindowReceiver = (
|
||||
for (const alias of aliases)
|
||||
if (index.referenceMayHaveChangedAt(site, alias.receiver, alias.path))
|
||||
return undefined;
|
||||
return { owner: receiver, path };
|
||||
return {
|
||||
owner: receiver,
|
||||
path,
|
||||
authority: electronCandidateAuthority(receiver.callee, context),
|
||||
};
|
||||
};
|
||||
|
||||
const supportedReceiverPath = (path: readonly string[]): boolean => {
|
||||
|
||||
@@ -147,7 +147,11 @@ it("preserves every direct observation in a long flat sibling block", () => {
|
||||
}`);
|
||||
expect(result.electron.ipc.map(({ channel }) => channel)).toEqual(channels);
|
||||
expect(result.electron.call_paths ?? []).toEqual([]);
|
||||
expect(result.parse_status).toBe("complete");
|
||||
expect(result.parse_status).toBe("partial");
|
||||
expect(result.analysis_coverage?.syntax.status).toBe("complete");
|
||||
expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps sibling guards, definite exits and nested shadowing separate", () => {
|
||||
@@ -370,7 +374,11 @@ it("preserves an observed empty contextBridge API object", () => {
|
||||
expect(result.electron.context_bridge_apis?.[0]).not.toHaveProperty(
|
||||
"resolution_basis",
|
||||
);
|
||||
expect(result.parse_status).toBe("complete");
|
||||
expect(result.parse_status).toBe("partial");
|
||||
expect(result.analysis_coverage?.syntax.status).toBe("complete");
|
||||
expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -767,7 +775,11 @@ it("resolves a site-determined constant channel in place of its dynamic observat
|
||||
expect.objectContaining({ entry: "desktop.ping", channel: "ping" }),
|
||||
);
|
||||
// The syntax pass's unknown channel is resolved, not left as partial.
|
||||
expect(result.parse_status).toBe("complete");
|
||||
expect(result.parse_status).toBe("partial");
|
||||
expect(result.analysis_coverage?.syntax.status).toBe("complete");
|
||||
expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
it("resolves a site-determined registration whose handler is a named local", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
@@ -784,7 +796,11 @@ it("resolves a site-determined registration whose handler is a named local", ()
|
||||
handler_kind: "inline-function",
|
||||
}),
|
||||
]);
|
||||
expect(result.parse_status).toBe("complete");
|
||||
expect(result.parse_status).toBe("partial");
|
||||
expect(result.analysis_coverage?.syntax.status).toBe("complete");
|
||||
expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
it("keeps an argument-supplied channel unresolved beside its resolved candidates", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
@@ -987,7 +1003,11 @@ it.each(["interface Shape { marker(): void }", "type Shape = typeof marker;"])(
|
||||
resolution_basis ?? "direct",
|
||||
]),
|
||||
).toEqual([["ch", "wrapper-substitution"]]);
|
||||
expect(result.parse_status).toBe("complete");
|
||||
expect(result.parse_status).toBe("partial");
|
||||
expect(result.analysis_coverage?.syntax.status).toBe("complete");
|
||||
expect(
|
||||
result.analysis_coverage?.semantics.unknown_findings,
|
||||
).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
it("resolves a handler a module-level factory call created once in place", () => {
|
||||
@@ -1005,7 +1025,11 @@ it("resolves a handler a module-level factory call created once in place", () =>
|
||||
resolution_basis ?? "direct",
|
||||
]),
|
||||
).toEqual([["ch", "wrapper-substitution"]]);
|
||||
expect(result.parse_status).toBe("complete");
|
||||
expect(result.parse_status).toBe("partial");
|
||||
expect(result.analysis_coverage?.syntax.status).toBe("complete");
|
||||
expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
it("keeps separate outer calls that pass one shared handler through a wrapper", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
@@ -1063,7 +1087,11 @@ it("resolves a module-level handler in place when the entry binds an unused para
|
||||
resolution_basis: "wrapper-substitution",
|
||||
}),
|
||||
]);
|
||||
expect(result.parse_status).toBe("complete");
|
||||
expect(result.parse_status).toBe("partial");
|
||||
expect(result.analysis_coverage?.syntax.status).toBe("complete");
|
||||
expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
["a named local", 'const handler = () => tag; register("same", handler);'],
|
||||
|
||||
@@ -63,6 +63,7 @@ const ipcEndpointKey = (value: ElectronIpcFinding): string =>
|
||||
value.side,
|
||||
value.operation,
|
||||
value.channel,
|
||||
value.loader_authority ?? "proven",
|
||||
javascriptSourceRangeKey(value.location),
|
||||
]);
|
||||
|
||||
@@ -205,7 +206,12 @@ interface BridgeExposure {
|
||||
}
|
||||
|
||||
const bridgeExposureKey = (value: ElectronContextBridgeFinding): string =>
|
||||
JSON.stringify([value.world, value.api_key, value.api_key_expression]);
|
||||
JSON.stringify([
|
||||
value.world,
|
||||
value.api_key,
|
||||
value.api_key_expression,
|
||||
value.loader_authority ?? "proven",
|
||||
]);
|
||||
|
||||
const bridgeContributionKey = (value: ElectronContextBridgeFinding): string =>
|
||||
JSON.stringify([value.world_id, value.members, value.unknown_members]);
|
||||
@@ -234,6 +240,7 @@ const mergeBridgeExposures = (
|
||||
});
|
||||
|
||||
interface PathContext {
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly entryNode: t.Node;
|
||||
/** Source ranges of the local calls this path made since its entry. */
|
||||
readonly calls: string[];
|
||||
@@ -423,11 +430,15 @@ export function* collectElectronWrappedCallPathsSteps(
|
||||
accumulator.unknownFindings += 1;
|
||||
return null;
|
||||
}
|
||||
const authorityContext =
|
||||
value.kind === "external" && value.loaderAuthority === "candidate"
|
||||
? { ...context, loader_authority: "candidate" as const }
|
||||
: context;
|
||||
return (
|
||||
value.conditions?.reduce(
|
||||
(current, branch) => condition(current, branch.node, branch.positive),
|
||||
context,
|
||||
) ?? context
|
||||
authorityContext,
|
||||
) ?? authorityContext
|
||||
);
|
||||
};
|
||||
// Duplicate checks consult only facts at the same endpoint and call site.
|
||||
@@ -873,6 +884,9 @@ export function* collectElectronWrappedCallPathsSteps(
|
||||
): void => {
|
||||
if (context.entryKind === null) return;
|
||||
const path: ElectronCallPath = {
|
||||
...(context.loader_authority === "candidate"
|
||||
? { loader_authority: "candidate" as const }
|
||||
: {}),
|
||||
entry_kind: context.entryKind,
|
||||
entry: context.entry,
|
||||
...(context.entryKind !== "bridge-member" ||
|
||||
@@ -958,6 +972,9 @@ export function* collectElectronWrappedCallPathsSteps(
|
||||
? undefined
|
||||
: range(context.entryNode);
|
||||
const findingContext = {
|
||||
...(context.loader_authority === "candidate"
|
||||
? { loader_authority: "candidate" as const }
|
||||
: {}),
|
||||
module_key: null,
|
||||
location,
|
||||
...(invocationLocation === undefined
|
||||
@@ -1223,6 +1240,9 @@ export function* collectElectronWrappedCallPathsSteps(
|
||||
// call contributes a separate API per key it is invoked with.
|
||||
const inventory = bridgeMemberInventory(object);
|
||||
const finding: ElectronContextBridgeFinding = {
|
||||
...(context.loader_authority === "candidate"
|
||||
? { loader_authority: "candidate" as const }
|
||||
: {}),
|
||||
world: exposure.world,
|
||||
world_id: worldId,
|
||||
api_key: key.value,
|
||||
|
||||
@@ -14,9 +14,10 @@ import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.
|
||||
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
|
||||
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
|
||||
import {
|
||||
semanticRequireOrigin,
|
||||
semanticRequireOriginCandidate,
|
||||
type JavaScriptRequireOrigin,
|
||||
} from "./javascriptSemanticRequireOrigin.js";
|
||||
import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
semanticResolutionBlocked,
|
||||
@@ -42,7 +43,7 @@ interface BootstrapExternals {
|
||||
/** Loader bindings with the canonical n.n getter installed before dispatch. */
|
||||
readonly interopGetterBindingIds: ReadonlySet<string>;
|
||||
/** Table keys whose factory only re-exports one unshadowed literal require; null when a key repeats. */
|
||||
readonly externals: ReadonlyMap<string, string | null>;
|
||||
readonly externals: ReadonlyMap<string, JavaScriptModuleOrigin | null>;
|
||||
}
|
||||
|
||||
const proofs = new WeakMap<
|
||||
@@ -62,6 +63,15 @@ const proofs = new WeakMap<
|
||||
export const semanticBundledRequireOrigin = (
|
||||
node: t.Node | null | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptRequireOrigin | undefined => {
|
||||
const result = semanticBundledRequireOriginCandidate(node, state);
|
||||
return result?.origin.loaderAuthority === "candidate" ? undefined : result;
|
||||
};
|
||||
|
||||
/** Retain conditional externals without treating the bootstrap as host proof. */
|
||||
export const semanticBundledRequireOriginCandidate = (
|
||||
node: t.Node | null | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptRequireOrigin | undefined => {
|
||||
const chain = semanticMemberChain(node);
|
||||
const current = chain?.root;
|
||||
@@ -80,7 +90,10 @@ export const semanticBundledRequireOrigin = (
|
||||
// CommonJS external, invoking that getter yields the exact exports object;
|
||||
// bind its identity to the proven registry result while retaining the
|
||||
// nested loader call as the source of provenance.
|
||||
const nested = semanticBundledRequireOrigin(current.arguments[0], state);
|
||||
const nested = semanticBundledRequireOriginCandidate(
|
||||
current.arguments[0],
|
||||
state,
|
||||
);
|
||||
const loaderBinding = resolveSemanticBindingState(
|
||||
state,
|
||||
current.callee.object,
|
||||
@@ -136,11 +149,11 @@ export const semanticBundledRequireOrigin = (
|
||||
const proof = bootstrapExternals(ancestor, state);
|
||||
if (proof === null || !proof.loaderBindingIds.has(binding.bindingId))
|
||||
continue;
|
||||
const specifier = proof.externals.get(key);
|
||||
return typeof specifier === "string"
|
||||
const origin = proof.externals.get(key);
|
||||
return origin !== null && origin !== undefined
|
||||
? {
|
||||
origin: {
|
||||
specifier,
|
||||
...origin,
|
||||
importedPath: chain.members,
|
||||
resolutionMode: "require",
|
||||
},
|
||||
@@ -299,7 +312,7 @@ const isInstalledBeforeEntryDispatch = (
|
||||
};
|
||||
|
||||
const bootstrapProofForLoaderCall = (
|
||||
call: t.CallExpression,
|
||||
call: t.CallExpression | t.OptionalCallExpression,
|
||||
loaderBindingId: string,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): BootstrapExternals | undefined => {
|
||||
@@ -403,7 +416,7 @@ const proveBootstrapExternals = (
|
||||
state,
|
||||
);
|
||||
if (integrity === undefined) return null;
|
||||
const externals = new Map<string, string | null>();
|
||||
const externals = new Map<string, JavaScriptModuleOrigin | null>();
|
||||
for (const { property, factory } of factories) {
|
||||
const key = modulePropertyName(property);
|
||||
const specifier = integrity.replacedKeys.has(key)
|
||||
@@ -430,7 +443,7 @@ const proveBootstrapExternals = (
|
||||
const externalSpecifier = (
|
||||
factory: t.FunctionExpression | t.ArrowFunctionExpression | t.ObjectMethod,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): string | undefined => {
|
||||
): JavaScriptModuleOrigin | undefined => {
|
||||
const moduleParameter = javascriptBundlerFactoryRoles(
|
||||
factory,
|
||||
BOOTSTRAP_RUNTIME,
|
||||
@@ -474,9 +487,12 @@ const externalSpecifier = (
|
||||
target?.bindingId !== moduleBinding.bindingId
|
||||
)
|
||||
return undefined;
|
||||
const required = semanticRequireOrigin(expression.right, state)?.origin;
|
||||
const required = semanticRequireOriginCandidate(
|
||||
expression.right,
|
||||
state,
|
||||
)?.origin;
|
||||
return required !== undefined && required.importedPath.length === 0
|
||||
? required.specifier
|
||||
? required
|
||||
: undefined;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import { runInNewContext } from "node:vm";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
import {
|
||||
analyzeCommonJsStaticFixture,
|
||||
analyzeCommonJsSemanticFixture,
|
||||
} from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
|
||||
const cases = [
|
||||
[
|
||||
@@ -115,7 +118,7 @@ it.each(cases)(
|
||||
module.exports,
|
||||
);
|
||||
expect(changed).toBe(possible ? 1 : 0);
|
||||
const analysis = analyzeJavaScriptStaticSource(defining);
|
||||
const analysis = analyzeCommonJsStaticFixture(defining);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(
|
||||
possible,
|
||||
@@ -140,9 +143,9 @@ it("retains native source uncertainty when an array iterator is overwritten", ()
|
||||
// The local change function is harmless, but the published app still permits
|
||||
// a caller to select the native setter. Unknown iteration retains that
|
||||
// possibility without identifying a yielded value or an exact export link.
|
||||
const analysis = analyzeJavaScriptStaticSource(defining);
|
||||
const analysis = analyzeCommonJsStaticFixture(defining);
|
||||
expect((analysis.electron.app_path_changes ?? []).length).toBeGreaterThan(0);
|
||||
const ir = analyzeJavaScriptSemantics(defining);
|
||||
const ir = analyzeCommonJsSemanticFixture(defining);
|
||||
expect(
|
||||
ir.moduleLinks.filter(({ kind }) => kind === "commonjs-export"),
|
||||
).toEqual([
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "./javascriptAstValues.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
semanticResolutionBlocked,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
|
||||
@@ -19,8 +20,10 @@ import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropert
|
||||
import { semanticProjectedSourcesAtRead } from "./javascriptSemanticProjectedSources.js";
|
||||
import {
|
||||
isUnshadowedGlobal,
|
||||
semanticBindingHasPriorValueWrite,
|
||||
semanticEffectTiming,
|
||||
} from "./javascriptSemanticMutationInitializers.js";
|
||||
import { javaScriptRuntimeContext } from "./javascriptSourceParser.js";
|
||||
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
|
||||
|
||||
const exportInvocations = new WeakMap<
|
||||
@@ -176,12 +179,37 @@ const copiedOwnFields = (
|
||||
return { definite: common ?? new Set(), complete };
|
||||
};
|
||||
|
||||
/** A possible wrapper export remains a candidate when its host is unknown. */
|
||||
const isCommonJsExportReference = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
name: string,
|
||||
): boolean => {
|
||||
const context = javaScriptRuntimeContext(state.program);
|
||||
if (context === "module") return false;
|
||||
if (isUnshadowedGlobal(node, state, name)) return true;
|
||||
if (
|
||||
context !== "unknown" ||
|
||||
!t.isIdentifier(node, { name }) ||
|
||||
semanticResolutionBlocked(state, node, name)
|
||||
)
|
||||
return false;
|
||||
const binding = resolveSemanticBindingState(state, node, name);
|
||||
return (
|
||||
binding !== undefined &&
|
||||
state.scopesById.get(binding.scopeId)?.kind === "program" &&
|
||||
binding.declarationKinds.size === 1 &&
|
||||
binding.declarationKinds.has("var") &&
|
||||
!semanticBindingHasPriorValueWrite(node, binding, state)
|
||||
);
|
||||
};
|
||||
|
||||
const isCommonJsModuleExports = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean =>
|
||||
(t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) &&
|
||||
isUnshadowedGlobal(node.object, state, "module") &&
|
||||
isCommonJsExportReference(node.object, state, "module") &&
|
||||
semanticStaticPropertyKey(node.property, node.computed) === "exports";
|
||||
|
||||
/** One syntax owner for CJS export names and their receiver identities. */
|
||||
@@ -193,7 +221,7 @@ export const commonJsExportName = (
|
||||
return undefined;
|
||||
const key = semanticStaticPropertyKey(node.property, node.computed);
|
||||
if (
|
||||
isUnshadowedGlobal(node.object, state, "exports") ||
|
||||
isCommonJsExportReference(node.object, state, "exports") ||
|
||||
isCommonJsModuleExports(node.object, state)
|
||||
)
|
||||
return key === null ? "*" : key;
|
||||
@@ -230,7 +258,7 @@ export const commonJsDefinedExport = (
|
||||
return undefined;
|
||||
const receiver = unwrapJavaScriptExpression(target).node;
|
||||
if (
|
||||
(!isUnshadowedGlobal(receiver, state, "exports") &&
|
||||
(!isCommonJsExportReference(receiver, state, "exports") &&
|
||||
!isCommonJsModuleExports(receiver, state)) ||
|
||||
!isUnmodifiedGlobalReference(node.callee, state, [
|
||||
"Object",
|
||||
@@ -272,7 +300,7 @@ const commonJsAssignedExports = (
|
||||
if (!t.isExpression(target)) return undefined;
|
||||
const receiver = unwrapJavaScriptExpression(target).node;
|
||||
if (
|
||||
!isUnshadowedGlobal(receiver, state, "exports") &&
|
||||
!isCommonJsExportReference(receiver, state, "exports") &&
|
||||
!isCommonJsModuleExports(receiver, state)
|
||||
)
|
||||
return undefined;
|
||||
@@ -289,11 +317,8 @@ const commonJsAssignedExports = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Runtime values published by the CommonJS syntax owner, before receiver applicability. */
|
||||
export const commonJsRuntimeExportValues = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): readonly {
|
||||
interface CommonJsRuntimeExportValue {
|
||||
readonly loaderAuthority?: "candidate";
|
||||
readonly exportedName: string;
|
||||
readonly value: t.Node | null;
|
||||
/** Retained input to unresolved iteration; never a selected yielded value. */
|
||||
@@ -301,7 +326,13 @@ export const commonJsRuntimeExportValues = (
|
||||
/** Own enumerable fields copied at this source, after later-source overwrites. */
|
||||
readonly projection?: JavaScriptSemanticPropertyPath;
|
||||
readonly ownEnumerable?: true;
|
||||
}[] => {
|
||||
}
|
||||
|
||||
/** Runtime values published by the CommonJS syntax owner, before receiver applicability. */
|
||||
const commonJsExportValues = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): readonly CommonJsRuntimeExportValue[] => {
|
||||
if (t.isAssignmentExpression(node)) {
|
||||
const exportedName = commonJsExportName(node.left, state);
|
||||
return exportedName === undefined
|
||||
@@ -310,8 +341,7 @@ export const commonJsRuntimeExportValues = (
|
||||
}
|
||||
const assigned = commonJsAssignedExports(node, state);
|
||||
if (assigned !== undefined) {
|
||||
const exported: ReturnType<typeof commonJsRuntimeExportValues>[number][] =
|
||||
[];
|
||||
const exported: CommonJsRuntimeExportValue[] = [];
|
||||
const overwritten = new Set<string>();
|
||||
for (let index = assigned.sources.length - 1; index >= 0; index--) {
|
||||
const { value, iterationSource } = assigned.sources[index]!;
|
||||
@@ -359,6 +389,20 @@ export const commonJsRuntimeExportValues = (
|
||||
];
|
||||
};
|
||||
|
||||
/** Preserve potential exports without asserting an unobserved CommonJS wrapper. */
|
||||
export const commonJsRuntimeExportValues = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): readonly CommonJsRuntimeExportValue[] => {
|
||||
const values = commonJsExportValues(node, state);
|
||||
return javaScriptRuntimeContext(state.program) === "unknown"
|
||||
? values.map((value) => ({
|
||||
...value,
|
||||
loaderAuthority: "candidate" as const,
|
||||
}))
|
||||
: values;
|
||||
};
|
||||
|
||||
type Identity = t.Node | null;
|
||||
type Execution = "ordered" | "conditional" | "deferred";
|
||||
|
||||
@@ -423,12 +467,12 @@ export function* indexCommonJsExportReceiversSteps(
|
||||
target = unwrapJavaScriptExpression(target).node;
|
||||
const possiblePublicWrite =
|
||||
(t.isMemberExpression(target) || t.isOptionalMemberExpression(target)) &&
|
||||
isUnshadowedGlobal(target.object, state, "module") &&
|
||||
isCommonJsExportReference(target.object, state, "module") &&
|
||||
semanticStaticPropertyKey(target.property, target.computed) === null;
|
||||
const publicWrite =
|
||||
isCommonJsModuleExports(target, state) || possiblePublicWrite;
|
||||
const exportsWrite = isUnshadowedGlobal(target, state, "exports");
|
||||
const moduleWrite = isUnshadowedGlobal(target, state, "module");
|
||||
const exportsWrite = isCommonJsExportReference(target, state, "exports");
|
||||
const moduleWrite = isCommonJsExportReference(target, state, "module");
|
||||
if (!publicWrite && !exportsWrite && !moduleWrite) return;
|
||||
if (mode === "deferred") {
|
||||
// A source offset cannot order an invocation against module completion.
|
||||
@@ -489,14 +533,14 @@ export function* indexCommonJsExportReceiversSteps(
|
||||
mode !== "ordered"
|
||||
? null
|
||||
: receiver !== null &&
|
||||
isUnshadowedGlobal(receiver, state, "exports")
|
||||
isCommonJsExportReference(receiver, state, "exports")
|
||||
? exportsIdentity
|
||||
: publicIdentity,
|
||||
);
|
||||
}
|
||||
const descriptor = commonJsDefinedExport(node, state);
|
||||
if (descriptor !== undefined) {
|
||||
const receiver = isUnshadowedGlobal(
|
||||
const receiver = isCommonJsExportReference(
|
||||
descriptor.receiver,
|
||||
state,
|
||||
"exports",
|
||||
@@ -513,7 +557,7 @@ export function* indexCommonJsExportReceiversSteps(
|
||||
node,
|
||||
mode !== "ordered"
|
||||
? null
|
||||
: isUnshadowedGlobal(defined.receiver, state, "exports")
|
||||
: isCommonJsExportReference(defined.receiver, state, "exports")
|
||||
? exportsIdentity
|
||||
: publicIdentity,
|
||||
);
|
||||
@@ -559,7 +603,7 @@ export function* indexCommonJsExportReceiversSteps(
|
||||
(t.isMemberExpression(node.argument) ||
|
||||
t.isOptionalMemberExpression(node.argument))
|
||||
) {
|
||||
const receiver = isUnshadowedGlobal(
|
||||
const receiver = isCommonJsExportReference(
|
||||
node.argument.object,
|
||||
state,
|
||||
"exports",
|
||||
@@ -575,7 +619,7 @@ export function* indexCommonJsExportReceiversSteps(
|
||||
}
|
||||
}
|
||||
write(node.argument, null, mode);
|
||||
} else if (isUnshadowedGlobal(node, state, "exports")) {
|
||||
} else if (isCommonJsExportReference(node, state, "exports")) {
|
||||
values.set(node, exportsIdentity);
|
||||
} else if (isCommonJsModuleExports(node, state)) {
|
||||
values.set(node, publicIdentity);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
applicationFeatureSeedSchema,
|
||||
type ApplicationFeatureSeed,
|
||||
} from "./javascriptFeatureTraceSchemas.js";
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
|
||||
const memberFields = [
|
||||
@@ -455,7 +455,7 @@ const propertyMatch = (node: ApplicationNode, field: string) => ({
|
||||
});
|
||||
|
||||
const contextBridgeGraph = () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(
|
||||
const analysis = analyzeCommonJsStaticFixture(
|
||||
"const { contextBridge } = require('electron');\ncontextBridge.exposeInMainWorld('desktopApi', { openProject() {}, closeProject() {} });",
|
||||
);
|
||||
expect(analysis.parse_status).toBe("complete");
|
||||
|
||||
@@ -6,13 +6,39 @@ export const isJsonModuleTarget = (
|
||||
path: string,
|
||||
kind: JavaScriptArtifactFileKind | undefined,
|
||||
): boolean =>
|
||||
path.endsWith(".json") && (kind === "json" || kind === "package-json");
|
||||
posix.extname(path) === ".json" &&
|
||||
(kind === "json" || kind === "package-json");
|
||||
|
||||
/** Native-addon dispatch likewise needs the exact `.node` loader suffix. */
|
||||
export const isNativeAddonModuleTarget = (
|
||||
path: string,
|
||||
kind: JavaScriptArtifactFileKind | undefined,
|
||||
): boolean => path.endsWith(".node") && kind === "native-addon";
|
||||
): boolean => posix.extname(path) === ".node" && kind === "native-addon";
|
||||
|
||||
/** Exact loader suffixes are separate from case-folded artifact grammar. */
|
||||
export const explicitJavaScriptLoaderMode = (
|
||||
path?: string,
|
||||
): "module" | "commonjs" | undefined => {
|
||||
switch (path === undefined ? undefined : posix.extname(path)) {
|
||||
case ".mjs":
|
||||
case ".mts":
|
||||
return "module";
|
||||
case ".cjs":
|
||||
case ".cts":
|
||||
return "commonjs";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Case variants are artifact syntax hints, not recognized loader suffixes. */
|
||||
export const hasCaseVariantJavaScriptLoaderSuffix = (
|
||||
path?: string,
|
||||
): boolean => {
|
||||
if (path === undefined) return false;
|
||||
const suffix = /\.(?:js|ts|mjs|cjs|mts|cts)$/iu.exec(path)?.[0];
|
||||
return suffix !== undefined && suffix !== suffix.toLowerCase();
|
||||
};
|
||||
|
||||
/** Loader evidence retained for a source-level module reference. */
|
||||
export type JavaScriptModuleResolutionMode =
|
||||
@@ -29,9 +55,10 @@ export const moduleResolutionModeForJavaScriptReference = (
|
||||
if (explicitMode !== undefined) return explicitMode;
|
||||
if (referenceKind === "require") return "require";
|
||||
if (referenceKind === "dynamic-import") return "import";
|
||||
const loaderMode = explicitJavaScriptLoaderMode(sourcePath);
|
||||
if (loaderMode !== undefined)
|
||||
return loaderMode === "commonjs" ? "require" : "import";
|
||||
const extension = posix.extname(sourcePath).toLowerCase();
|
||||
if (extension === ".cjs" || extension === ".cts") return "require";
|
||||
if (extension === ".mts") return "import";
|
||||
if (extension === ".ts" || extension === ".tsx") return "compiler-dependent";
|
||||
return "import";
|
||||
};
|
||||
@@ -62,6 +62,15 @@ const canonicalAmbientPath = (
|
||||
|
||||
type AmbientPath = readonly (string | null)[];
|
||||
|
||||
const ambientEffectCovers = (
|
||||
effect: JavaScriptSemanticAnalysisState["ambientWriteEffects"][number],
|
||||
target: readonly string[],
|
||||
): boolean =>
|
||||
effect.path.length <= target.length &&
|
||||
effect.path.every((key, index) =>
|
||||
semanticPropertyPathKeyMatches(key, target[index]!),
|
||||
);
|
||||
|
||||
const retainedAmbientEffects = new WeakMap<
|
||||
JavaScriptSemanticAnalysisState,
|
||||
Map<t.Node | undefined, Set<string>>
|
||||
@@ -798,8 +807,8 @@ export function* indexJavaScriptAmbientWritesSteps(
|
||||
}
|
||||
}
|
||||
// Settle other ambient effects first. A syntactic resolver call is safe only
|
||||
// if no write or opaque escape can replace the native member anywhere in
|
||||
// this analysis; otherwise retain its receiver escape conservatively.
|
||||
// if no write or opaque escape can replace its native member before this
|
||||
// invocation; ambiguous execution order remains conservative.
|
||||
const resolverReceivers: typeof escapes = [];
|
||||
for (const effect of escapes) {
|
||||
const { node, target } = effect;
|
||||
@@ -821,10 +830,32 @@ export function* indexJavaScriptAmbientWritesSteps(
|
||||
}
|
||||
for (const effect of resolverReceivers) {
|
||||
const { node } = effect;
|
||||
const selected = ["global", "require", "resolve"];
|
||||
const mayAffectResolver = (
|
||||
effects: typeof state.ambientWriteEffects,
|
||||
covered: boolean,
|
||||
): boolean => {
|
||||
if (!covered) return false;
|
||||
const matches = effects.filter((entry) =>
|
||||
ambientEffectCovers(entry, selected),
|
||||
);
|
||||
return (
|
||||
matches.length === 0 ||
|
||||
matches.some(
|
||||
(entry) => semanticEffectTiming(node, entry.node, state) !== "after",
|
||||
)
|
||||
);
|
||||
};
|
||||
if (
|
||||
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
|
||||
!state.ambientWrites.covers(["global", "require", "resolve"]) &&
|
||||
!state.ambientEscapes.covers(["global", "require", "resolve"]) &&
|
||||
!mayAffectResolver(
|
||||
state.ambientWriteEffects,
|
||||
state.ambientWrites.covers(selected),
|
||||
) &&
|
||||
!mayAffectResolver(
|
||||
state.ambientEscapeEffects,
|
||||
state.ambientEscapes.covers(selected),
|
||||
) &&
|
||||
readSemanticRequireLoader(node, state, new Set()) === "commonjs-loader"
|
||||
)
|
||||
continue;
|
||||
@@ -877,14 +908,6 @@ export const createGlobalReferenceReader = (
|
||||
string | null,
|
||||
typeof state.ambientWriteEffects
|
||||
>();
|
||||
const matches = (
|
||||
effect: (typeof state.ambientWriteEffects)[number],
|
||||
target: readonly string[],
|
||||
) =>
|
||||
effect.path.length <= target.length &&
|
||||
effect.path.every((key, index) =>
|
||||
semanticPropertyPathKeyMatches(key, target[index]!),
|
||||
);
|
||||
return (callee) => {
|
||||
const namespace = globalReferenceNamespace(callee, state, path);
|
||||
if (namespace === undefined) return { status: "unrelated" };
|
||||
@@ -901,8 +924,9 @@ export const createGlobalReferenceReader = (
|
||||
if (effects === undefined) {
|
||||
effects = state.ambientWriteEffects.filter(
|
||||
(effect) =>
|
||||
matches(effect, selected) ||
|
||||
(namespace !== null && matches(effect, ["global", namespace])),
|
||||
ambientEffectCovers(effect, selected) ||
|
||||
(namespace !== null &&
|
||||
ambientEffectCovers(effect, ["global", namespace])),
|
||||
);
|
||||
byNamespace.set(namespace, effects);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
import { analyzeCommonJsSemanticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
|
||||
const source = `const {spawn} = require('child_process');
|
||||
const timers = require('timers');
|
||||
const child = spawn('tool', []); child.kill();
|
||||
const timer = timers.setTimeout(() => {}, 1); timers.clearTimeout(timer);`;
|
||||
|
||||
it("retains conditional child and timer observations without proving a loader", () => {
|
||||
const result = analyzeJavaScriptSemantics(source);
|
||||
expect(result.childProcessSpawns).toMatchObject([
|
||||
{ command: "tool", builtinAuthority: "candidate" },
|
||||
]);
|
||||
expect(result.childProcessInteractions).toMatchObject([
|
||||
{ kind: "signal", builtinAuthority: "candidate", resolution: "partial" },
|
||||
]);
|
||||
expect(result.timerOperations).toMatchObject([
|
||||
{ kind: "schedule", builtinAuthority: "candidate" },
|
||||
{ kind: "cancel", builtinAuthority: "candidate" },
|
||||
]);
|
||||
const proven = analyzeCommonJsSemanticFixture(source);
|
||||
expect(proven.childProcessSpawns[0]).not.toHaveProperty("builtinAuthority");
|
||||
expect(proven.timerOperations[0]).not.toHaveProperty("builtinAuthority");
|
||||
});
|
||||
|
||||
it("does not treat shadowed or overwritten bindings as candidate builtins", () => {
|
||||
const result = analyzeJavaScriptSemantics(`
|
||||
const cp = require('child_process'); cp.spawn = custom; cp.spawn('bad', []);
|
||||
function local(require) { require('timers').setTimeout(() => {}, 1); }
|
||||
`);
|
||||
expect(result.childProcessSpawns).toEqual([]);
|
||||
expect(result.timerOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not promote a builtin binding read before initialization", () => {
|
||||
const result = analyzeJavaScriptSemantics(`
|
||||
spawn('early', []);
|
||||
var { spawn } = require('child_process');
|
||||
spawn('late', []);
|
||||
`);
|
||||
expect(result.childProcessSpawns).toMatchObject([
|
||||
{ command: "late", builtinAuthority: "candidate" },
|
||||
]);
|
||||
expect(result.childProcessSpawns).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tracks conditional builtin slot writes without discarding unrelated or previously captured methods", () => {
|
||||
const result = analyzeJavaScriptSemantics(`
|
||||
const saved = require('child_process').spawn;
|
||||
require('child_process').exec = custom;
|
||||
require('child_process').spawn('unrelated', []);
|
||||
require('child_process').spawn = custom;
|
||||
require('child_process').spawn('replaced', []);
|
||||
saved('captured', []);
|
||||
`);
|
||||
expect(
|
||||
result.childProcessSpawns.map(({ command, builtinAuthority }) => ({
|
||||
command,
|
||||
builtinAuthority,
|
||||
})),
|
||||
).toEqual([
|
||||
{ command: "unrelated", builtinAuthority: "candidate" },
|
||||
{ command: "captured", builtinAuthority: "candidate" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains a global process read conditionally when a candidate module may have changed it", () => {
|
||||
const result = analyzeJavaScriptSemantics(`
|
||||
require('process').env.MODE = custom;
|
||||
const mode = process.env.MODE;
|
||||
`);
|
||||
expect(result.configurationOperations).toMatchObject([
|
||||
{ key: "MODE", builtinAuthority: "candidate" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not turn a proven same-slot replacement into a conditional global process read", () => {
|
||||
const result = analyzeJavaScriptSemantics(`
|
||||
process.env.MODE = custom;
|
||||
const mode = process.env.MODE;
|
||||
`);
|
||||
expect(result.configurationOperations).toEqual([]);
|
||||
});
|
||||
|
||||
it("weakens default-import builtin effects after a conditional same-slot write", () => {
|
||||
const result = analyzeJavaScriptSemantics(`
|
||||
import fs from 'fs';
|
||||
require('fs').readFileSync = custom;
|
||||
fs.readFileSync('config.json');
|
||||
`);
|
||||
expect(result.configurationOperations).toMatchObject([
|
||||
{ key: "config.json", builtinAuthority: "candidate" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps named builtin snapshots proven while weakening mutable default namespaces", () => {
|
||||
const result = analyzeJavaScriptSemantics(`
|
||||
import {readFileSync} from 'fs';
|
||||
import http from 'http'; import timers from 'timers'; import cp from 'child_process';
|
||||
require('fs').readFileSync = custom;
|
||||
require('http').get = custom;
|
||||
require('timers').setTimeout = custom;
|
||||
require('child_process').spawn = custom;
|
||||
readFileSync('named.json'); http.get('http://example.test');
|
||||
timers.setTimeout(() => {}, 1); cp.spawn('tool', []);
|
||||
`);
|
||||
expect(result.configurationOperations).toMatchObject([{ key: "named.json" }]);
|
||||
expect(result.configurationOperations[0]).not.toHaveProperty(
|
||||
"builtinAuthority",
|
||||
);
|
||||
expect(result.requestOperations).toMatchObject([
|
||||
{ builtinAuthority: "candidate" },
|
||||
]);
|
||||
expect(result.timerOperations).toMatchObject([
|
||||
{ builtinAuthority: "candidate" },
|
||||
]);
|
||||
expect(result.childProcessSpawns).toMatchObject([
|
||||
{ builtinAuthority: "candidate" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps namespace snapshot methods proven while marking the mutable default object conditional", () => {
|
||||
const result = analyzeJavaScriptSemantics(`
|
||||
import * as fs from 'fs';
|
||||
require('fs').readFileSync = custom;
|
||||
fs.readFileSync('snapshot.json');
|
||||
fs.default.readFileSync('mutable.json');
|
||||
`);
|
||||
const snapshot = result.configurationOperations.find(
|
||||
({ key }) => key === "snapshot.json",
|
||||
);
|
||||
const mutable = result.configurationOperations.find(
|
||||
({ key }) => key === "mutable.json",
|
||||
);
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot).not.toHaveProperty("builtinAuthority");
|
||||
expect(mutable).toMatchObject({ builtinAuthority: "candidate" });
|
||||
});
|
||||
|
||||
it("preserves namespace process env snapshot across object replacement, but not nested mutation", () => {
|
||||
const replaced = analyzeJavaScriptSemantics(`
|
||||
import * as p from 'process'; require('process').env = custom; p.env.MODE;
|
||||
`).configurationOperations.find(({ key }) => key === "MODE");
|
||||
expect(replaced).toBeDefined();
|
||||
expect(replaced).not.toHaveProperty("builtinAuthority");
|
||||
const mutated = analyzeJavaScriptSemantics(`
|
||||
import * as p from 'process'; require('process').env.MODE = custom; p.env.MODE;
|
||||
`).configurationOperations.find(({ key }) => key === "MODE");
|
||||
expect(mutated).toMatchObject({ builtinAuthority: "candidate" });
|
||||
});
|
||||
|
||||
it("distinguishes proven namespace object replacement from proven slot mutation", () => {
|
||||
const replaced = analyzeJavaScriptSemantics(`
|
||||
import * as p from 'process'; process.env = custom; p.env.MODE;
|
||||
`).configurationOperations.find(({ key }) => key === "MODE");
|
||||
expect(replaced).toBeDefined();
|
||||
expect(replaced).not.toHaveProperty("builtinAuthority");
|
||||
const mutated = analyzeJavaScriptSemantics(`
|
||||
import * as p from 'process'; process.env.MODE = custom; p.env.MODE;
|
||||
`).configurationOperations.find(({ key }) => key === "MODE");
|
||||
expect(mutated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps function fingerprints partial when built-in effects depend on an unknown loader", () => {
|
||||
const source = `const { spawn } = require('child_process'); function execute() { spawn('tool', []); } function unrelated() { return 1; }`;
|
||||
const candidate = analyzeJavaScriptSemantics(source);
|
||||
const proven = analyzeCommonJsSemanticFixture(source);
|
||||
const fingerprint = (result: typeof candidate, name: string) => {
|
||||
const callable = result.callables.find((item) => item.name === name);
|
||||
return result.functionFingerprints.find(
|
||||
(item) => item.callableId === callable?.callableId,
|
||||
);
|
||||
};
|
||||
expect(fingerprint(candidate, "execute")).toMatchObject({
|
||||
status: "partial",
|
||||
components: { effects: ["child-process"] },
|
||||
});
|
||||
expect(fingerprint(candidate, "execute")?.limitations.length).toBeGreaterThan(
|
||||
1,
|
||||
);
|
||||
expect(fingerprint(candidate, "unrelated")?.status).toBe("complete");
|
||||
expect(fingerprint(proven, "execute")?.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("marks an ESM fingerprint partial only when a conditional write affects its builtin effect", () => {
|
||||
const sample = (write: string) =>
|
||||
analyzeJavaScriptSemantics(`
|
||||
import http from 'http';
|
||||
${write}
|
||||
function request() { http.get('http://example.test'); }
|
||||
`).functionFingerprints[0];
|
||||
expect(sample("")?.status).toBe("complete");
|
||||
const conditional = sample("require('http').get = custom;");
|
||||
expect(conditional).toMatchObject({
|
||||
status: "partial",
|
||||
components: { effects: ["network"] },
|
||||
});
|
||||
expect(conditional?.limitations.length).toBeGreaterThan(1);
|
||||
});
|
||||
@@ -16,6 +16,7 @@ const ordinaryMemberChain = ".next".repeat(64);
|
||||
it("preserves a 12,000-member native import through both analyzers", () => {
|
||||
const parsed = parseJavaScriptSource(
|
||||
`const value = require("./addon.node")${memberChain}.last; const ordinary = root.next.last;`,
|
||||
"fixture.cjs",
|
||||
);
|
||||
if (parsed === null) throw new Error("Expected valid JavaScript");
|
||||
|
||||
|
||||
@@ -277,14 +277,19 @@ describe("CommonJS dependency links", () => {
|
||||
});
|
||||
|
||||
it("keeps bindings and re-exports without extra dependency links", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const ir = analyzeParsedJavaScriptSemantics(
|
||||
parseJavaScriptSource(
|
||||
`
|
||||
const bound = (require("bound") as unknown);
|
||||
const { left, right } = require("destructured");
|
||||
module.exports = require("forwarded");
|
||||
exports = module.exports;
|
||||
exports.member = require("member").value;
|
||||
exports.first = exports.second = require("chained");
|
||||
`);
|
||||
`,
|
||||
"entry.cts",
|
||||
)!,
|
||||
);
|
||||
expect(
|
||||
ir.moduleLinks.map(({ kind, specifier, localName, exportedName }) => ({
|
||||
kind,
|
||||
@@ -342,31 +347,36 @@ describe("CommonJS dependency links", () => {
|
||||
{ specifier: "bound", importedPath: [], resolutionMode: "require" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'require("outer", require("inner"));',
|
||||
'const bound = require("outer", require("inner"));',
|
||||
'module.exports = require("outer", require("inner"));',
|
||||
])("retains a distinct nested call in %s", (source) => {
|
||||
const links = analyzeJavaScriptSemantics(source).moduleLinks;
|
||||
expect(links.map(({ specifier }) => specifier)).toEqual(["outer", "inner"]);
|
||||
expect(links[1]).toMatchObject({
|
||||
kind: "require",
|
||||
importedName: null,
|
||||
localName: null,
|
||||
exportedName: null,
|
||||
});
|
||||
it.each([
|
||||
'require("outer", require("inner"));',
|
||||
'const bound = require("outer", require("inner"));',
|
||||
'module.exports = require("outer", require("inner"));',
|
||||
])("retains a distinct nested call in %s", (source) => {
|
||||
const links = analyzeJavaScriptSemantics(source).moduleLinks;
|
||||
expect(links.map(({ specifier }) => specifier)).toEqual(["outer", "inner"]);
|
||||
expect(links[1]).toMatchObject({
|
||||
kind: "require",
|
||||
importedName: null,
|
||||
localName: null,
|
||||
exportedName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unshadowed require origins through aliases and assignments", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const ir = analyzeParsedJavaScriptSemantics(
|
||||
parseJavaScriptSource(
|
||||
`
|
||||
const { ipcMain: bus } = require("electron");
|
||||
const forwarded = bus;
|
||||
let assigned;
|
||||
assigned = require("electron").ipcMain;
|
||||
{ const require = localLoader; require("./local.js"); }
|
||||
`);
|
||||
`,
|
||||
"entry.cts",
|
||||
)!,
|
||||
);
|
||||
for (const name of ["bus", "forwarded"])
|
||||
expect(origin(topLevelBinding(ir, name))).toEqual({
|
||||
specifier: "electron",
|
||||
|
||||
@@ -227,13 +227,14 @@ it.each([
|
||||
);
|
||||
|
||||
it("keeps established logical operands across a long chain", () => {
|
||||
// Known-falsy operands select the right branch at every level; the value and
|
||||
// provenance must still come from the one reachable operand.
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`const tok = ${'false || null || 0 || "" || '.repeat(10)}require("electron").app;`,
|
||||
);
|
||||
expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 });
|
||||
expect(onlyBinding(ir, "tok").provenance).toEqual({
|
||||
// Known-falsy operands select the right branch at every level.
|
||||
const source = `const tok = ${'false || null || 0 || "" || '.repeat(10)}require("electron").app;`;
|
||||
const parsed = parseJavaScriptSource(source, "main.cjs");
|
||||
if (parsed === null) throw new Error("fixture did not parse");
|
||||
const proven = analyzeParsedJavaScriptSemantics(parsed);
|
||||
const candidate = analyzeJavaScriptSemantics(source);
|
||||
expect(proven.coverage).toEqual({ status: "complete", omittedCount: 0 });
|
||||
expect(onlyBinding(proven, "tok").provenance).toEqual({
|
||||
status: "module",
|
||||
origins: [
|
||||
{
|
||||
@@ -244,4 +245,14 @@ it("keeps established logical operands across a long chain", () => {
|
||||
],
|
||||
reason: null,
|
||||
});
|
||||
expect(onlyBinding(candidate, "tok").provenance).toMatchObject({
|
||||
status: "ambiguous",
|
||||
origins: [
|
||||
{
|
||||
specifier: "electron",
|
||||
importedPath: ["app"],
|
||||
loaderAuthority: "candidate",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -67,12 +67,17 @@ import {
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
semanticRequireOrigin,
|
||||
semanticRequireOriginCandidate,
|
||||
observePossibleCommonJsDelegateWrite,
|
||||
invalidateSemanticRequireDelegateWrites,
|
||||
} from "./javascriptSemanticRequireOrigin.js";
|
||||
import { semanticBundledRequireOrigin } from "./javascriptBundledRequireOrigin.js";
|
||||
import {
|
||||
semanticBundledRequireOrigin,
|
||||
semanticBundledRequireOriginCandidate,
|
||||
} from "./javascriptBundledRequireOrigin.js";
|
||||
import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js";
|
||||
import {
|
||||
semanticBindingAtRead,
|
||||
semanticEffectTiming,
|
||||
semanticEffectTimingWithinActivation,
|
||||
} from "./javascriptSemanticMutationInitializers.js";
|
||||
@@ -844,12 +849,16 @@ const resolveGlobalAliasFact = (
|
||||
export interface JavaScriptElectronIdentity {
|
||||
/** Proven export paths keyed by call/construction root offset; absence is unknown. */
|
||||
readonly bindings: ReadonlyMap<number, readonly string[]>;
|
||||
/** Conditional export paths whose require loader is not established. */
|
||||
readonly candidateBindings: ReadonlyMap<number, readonly string[]>;
|
||||
readonly intactExport: (path: readonly (string | null)[]) => boolean;
|
||||
readonly exportPath: (root: t.Node) => readonly string[] | undefined;
|
||||
readonly candidateExportPath: (root: t.Node) => readonly string[] | undefined;
|
||||
readonly invocationPath: (
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
) => readonly string[] | undefined;
|
||||
readonly mayChangeAppPath: (node: t.Node) => boolean;
|
||||
readonly candidateMayChangeAppPath: (node: t.Node) => boolean;
|
||||
}
|
||||
|
||||
export function* indexJavaScriptElectronIdentitySteps(
|
||||
@@ -857,15 +866,33 @@ export function* indexJavaScriptElectronIdentitySteps(
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): Generator<void, JavaScriptElectronIdentity> {
|
||||
const facts = new Map<number, readonly string[]>();
|
||||
const candidateFacts = new Map<number, readonly string[]>();
|
||||
let hasCandidateElectronSource = false;
|
||||
const interop = createSemanticModuleInterop(state);
|
||||
const writes = new SemanticPropertyPathCoverage();
|
||||
let collectedWrites = false;
|
||||
const exportsByBinding = new Map<string, readonly string[] | null>();
|
||||
const candidatesByBinding = new Map<string, readonly string[] | null>();
|
||||
const originAvailableAtRead = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
read: t.Node,
|
||||
): boolean => {
|
||||
if (
|
||||
binding.directOrigins.length > 0 ||
|
||||
semanticStableInitializer(binding, state) === undefined
|
||||
)
|
||||
return true;
|
||||
const access = semanticBindingAtRead(binding, read, state);
|
||||
// An unresolved activation can still carry a possible identity, while an
|
||||
// ordered read before its initializer cannot refer to that value.
|
||||
return access.status !== "ordered" || access.hasDefiniteInitializer;
|
||||
};
|
||||
const rootOrigin = (root: t.Node): readonly string[] | undefined => {
|
||||
if (t.isIdentifier(root)) {
|
||||
if (semanticResolutionBlocked(state, root, root.name)) return undefined;
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
if (binding === undefined) return undefined;
|
||||
if (binding === undefined || !originAvailableAtRead(binding, root))
|
||||
return undefined;
|
||||
let exported = exportsByBinding.get(binding.bindingId);
|
||||
if (exported === undefined) {
|
||||
exported = electronExport(binding, state, interop);
|
||||
@@ -900,13 +927,79 @@ export function* indexJavaScriptElectronIdentitySteps(
|
||||
}
|
||||
return rootOrigin(root);
|
||||
};
|
||||
const candidateRootExport = (root: t.Node): readonly string[] | undefined => {
|
||||
if (t.isIdentifier(root)) {
|
||||
if (semanticResolutionBlocked(state, root, root.name)) return undefined;
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
if (
|
||||
binding === undefined ||
|
||||
(collectedWrites && interop.invalidated(binding))
|
||||
)
|
||||
return undefined;
|
||||
if (binding.directOrigins.length === 0) {
|
||||
if (semanticStableInitializer(binding, state) === undefined)
|
||||
return undefined;
|
||||
if (!originAvailableAtRead(binding, root)) return undefined;
|
||||
}
|
||||
if (
|
||||
state.opaqueEffects.byScope.size > 0 &&
|
||||
(semanticOpaqueBindingReadMayHaveChanged(binding, root, state) ||
|
||||
interop.aliasMayHaveChanged(binding))
|
||||
)
|
||||
return undefined;
|
||||
let candidate = candidatesByBinding.get(binding.bindingId);
|
||||
if (candidate === undefined) {
|
||||
const provenance = evaluateSemanticProvenance(binding, state);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
const bundled =
|
||||
initializer === undefined
|
||||
? undefined
|
||||
: semanticBundledRequireOriginCandidate(initializer.node, state)
|
||||
?.origin;
|
||||
const origin =
|
||||
bundled?.loaderAuthority === "candidate" &&
|
||||
initializer?.projection.every(
|
||||
(key): key is string => typeof key === "string",
|
||||
)
|
||||
? {
|
||||
...bundled,
|
||||
importedPath: [
|
||||
...bundled.importedPath,
|
||||
...initializer.projection,
|
||||
],
|
||||
}
|
||||
: provenance.status === "ambiguous" &&
|
||||
provenance.origins.length === 1
|
||||
? provenance.origins[0]
|
||||
: undefined;
|
||||
candidate =
|
||||
origin?.loaderAuthority === "candidate" &&
|
||||
ELECTRON_MODULE.test(origin.specifier)
|
||||
? electronExportPath(origin.importedPath)
|
||||
: null;
|
||||
candidatesByBinding.set(binding.bindingId, candidate);
|
||||
}
|
||||
return candidate ?? undefined;
|
||||
}
|
||||
const origin = (
|
||||
semanticRequireOriginCandidate(root, state) ??
|
||||
semanticBundledRequireOriginCandidate(root, state)
|
||||
)?.origin;
|
||||
return origin?.loaderAuthority === "candidate" &&
|
||||
ELECTRON_MODULE.test(origin.specifier)
|
||||
? electronExportPath(origin.importedPath)
|
||||
: undefined;
|
||||
};
|
||||
yield* collectJavaScriptIdentityWritesSteps(
|
||||
program,
|
||||
state.erasedSyntaxNodes,
|
||||
(node, kind) => {
|
||||
if (kind === "opaque-call") return [interop.opaqueMutationPath(node)];
|
||||
const { root, members } = electronMemberPath(node);
|
||||
const exported = kind === "binding" ? undefined : rootOrigin(root);
|
||||
const exported =
|
||||
kind === "binding"
|
||||
? undefined
|
||||
: (rootOrigin(root) ?? candidateRootExport(root));
|
||||
const path =
|
||||
exported === undefined
|
||||
? (interop.intrinsicPath(root) ??
|
||||
@@ -936,6 +1029,17 @@ export function* indexJavaScriptElectronIdentitySteps(
|
||||
!t.isNewExpression(node)
|
||||
)
|
||||
return;
|
||||
if (
|
||||
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
|
||||
t.isIdentifier(node.callee, { name: "require" })
|
||||
) {
|
||||
const origin = semanticRequireOriginCandidate(node, state)?.origin;
|
||||
if (
|
||||
origin?.loaderAuthority === "candidate" &&
|
||||
ELECTRON_MODULE.test(origin.specifier)
|
||||
)
|
||||
hasCandidateElectronSource = true;
|
||||
}
|
||||
const { root, members } = electronMemberPath(node.callee);
|
||||
const exported = rootExport(root);
|
||||
if (
|
||||
@@ -943,6 +1047,14 @@ export function* indexJavaScriptElectronIdentitySteps(
|
||||
intactExport(["electron", ...exported, ...members])
|
||||
)
|
||||
facts.set(root.start ?? -1, exported);
|
||||
else {
|
||||
const candidate = candidateRootExport(root);
|
||||
if (
|
||||
candidate !== undefined &&
|
||||
intactExport(["electron", ...candidate, ...members])
|
||||
)
|
||||
candidateFacts.set(root.start ?? -1, candidate);
|
||||
}
|
||||
},
|
||||
});
|
||||
const invocationPath = (
|
||||
@@ -965,14 +1077,23 @@ export function* indexJavaScriptElectronIdentitySteps(
|
||||
};
|
||||
return {
|
||||
bindings: facts,
|
||||
candidateBindings: candidateFacts,
|
||||
intactExport,
|
||||
exportPath: rootExport,
|
||||
candidateExportPath: candidateRootExport,
|
||||
invocationPath,
|
||||
mayChangeAppPath: createSemanticElectronAppPathChangeQuery(
|
||||
state,
|
||||
rootOrigin,
|
||||
interop,
|
||||
),
|
||||
candidateMayChangeAppPath: hasCandidateElectronSource
|
||||
? createSemanticElectronAppPathChangeQuery(
|
||||
state,
|
||||
candidateRootExport,
|
||||
interop,
|
||||
)
|
||||
: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ if (globalThis.register) {
|
||||
const conditionalAlias = conditional;
|
||||
conditionalAlias.handle("conditional-alias:ping", () => "local");
|
||||
`;
|
||||
const parsed = parseJavaScriptSource(text, "aliases.js");
|
||||
const parsed = parseJavaScriptSource(text, "aliases.cjs");
|
||||
if (parsed === null) throw new Error("Expected parsed source");
|
||||
|
||||
const index = completeSemanticSteps(
|
||||
@@ -267,6 +267,21 @@ conditionalAlias.handle("conditional-alias:ping", () => "local");
|
||||
const electron = completeSemanticSteps(
|
||||
indexJavaScriptElectronIdentitySteps(parsed.program, index.bindings),
|
||||
);
|
||||
const unknown = parseJavaScriptSource(text, "aliases.js");
|
||||
if (unknown === null) throw new Error("Expected unknown-host source");
|
||||
const unknownIndex = completeSemanticSteps(
|
||||
indexJavaScriptBindingsWithReceiversSteps(unknown.program),
|
||||
);
|
||||
const candidates = completeSemanticSteps(
|
||||
indexJavaScriptElectronIdentitySteps(
|
||||
unknown.program,
|
||||
unknownIndex.bindings,
|
||||
),
|
||||
);
|
||||
expect(candidates.bindings.size).toBe(0);
|
||||
expect(candidates.candidateBindings.get(text.indexOf("Win({"))).toEqual([
|
||||
"BrowserWindow",
|
||||
]);
|
||||
expect([...electron.bindings]).toEqual([
|
||||
[text.indexOf("Win({"), ["BrowserWindow"]],
|
||||
[text.indexOf("bridge.exposeInMainWorld"), ["contextBridge"]],
|
||||
|
||||
@@ -24,7 +24,7 @@ import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.j
|
||||
import { semanticOpaqueNameMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js";
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import {
|
||||
builtinDataEffectMethod,
|
||||
builtinDataEffectIdentity,
|
||||
dataEffectArgumentBindingId,
|
||||
dataEffectLiteralString,
|
||||
dataEffectMemberCallee,
|
||||
@@ -92,6 +92,7 @@ interface AsyncEffectAnalysis {
|
||||
}
|
||||
|
||||
interface TimerCandidate {
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly node: t.CallExpression | t.OptionalCallExpression;
|
||||
readonly method: JavaScriptSemanticTimerOperation["method"];
|
||||
readonly kind: JavaScriptSemanticTimerOperation["kind"];
|
||||
@@ -306,20 +307,25 @@ const emitterIdentity = (
|
||||
};
|
||||
|
||||
/**
|
||||
* A timer proven to come from the Node `timers` module rather than a bare
|
||||
* global. Browser and Electron renderer sources reach this analysis without a
|
||||
* Node/browser distinction, and there `setTimeout("refresh()", 0)` is a real
|
||||
* A timer associated with the Node `timers` module, either proven or
|
||||
* conditional, rather than a bare global. Browser and Electron renderer sources
|
||||
* reach this analysis without a Node/browser distinction, and there `setTimeout("refresh()", 0)` is a real
|
||||
* timer: the string is evaluated by the host. Node's callback rule therefore
|
||||
* applies only when the timer is imported from `node:timers`, where that form
|
||||
* cannot exist.
|
||||
* applies when interpreting the call as `node:timers`, where that form
|
||||
* cannot create a Node timer even if the module identity is conditional.
|
||||
*/
|
||||
const nodeBuiltinTimerMethod = (
|
||||
callee: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticTimerOperation["method"] | null =>
|
||||
admittedTimerMethod(
|
||||
builtinDataEffectMethod(callee, state, ["timers", "node:timers"]) ?? "",
|
||||
): JavaScriptSemanticTimerOperation["method"] | null => {
|
||||
const identity = builtinDataEffectIdentity(callee, state, [
|
||||
"timers",
|
||||
"node:timers",
|
||||
]);
|
||||
return admittedTimerMethod(
|
||||
identity?.path.length === 1 ? (identity.path[0] ?? "") : "",
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A schedule imported from Node's `timers` module requires a function in
|
||||
@@ -370,8 +376,9 @@ const schedulesWithNonFunctionCallback = (
|
||||
|
||||
const collectTimer = (input: TimerCollectionInput): void => {
|
||||
const { node, ownerCallableId, state, output, ancestors } = input;
|
||||
const method = timerMethod(node.callee, state);
|
||||
if (method === null) return;
|
||||
const identity = timerMethod(node.callee, state);
|
||||
if (identity === null) return;
|
||||
const { method, builtinAuthority } = identity;
|
||||
if (
|
||||
TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method) &&
|
||||
nodeBuiltinTimerMethod(node.callee, state) !== null &&
|
||||
@@ -421,6 +428,7 @@ const collectTimer = (input: TimerCollectionInput): void => {
|
||||
output.push({
|
||||
node,
|
||||
method,
|
||||
...(builtinAuthority === undefined ? {} : { builtinAuthority }),
|
||||
kind,
|
||||
ownerCallableId,
|
||||
handleBindingId:
|
||||
@@ -467,6 +475,10 @@ const resolveTimers = (
|
||||
candidate.handleRead !== null &&
|
||||
candidate.handleRead.origins.length !== linked.length;
|
||||
return {
|
||||
...(candidate.builtinAuthority === "candidate" ||
|
||||
linked.some((item) => item.builtinAuthority === "candidate")
|
||||
? { builtinAuthority: "candidate" as const }
|
||||
: {}),
|
||||
timerId: timerId(candidate),
|
||||
kind: candidate.kind,
|
||||
method: candidate.method,
|
||||
@@ -499,7 +511,10 @@ const resolveTimers = (
|
||||
const timerMethod = (
|
||||
callee: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticTimerOperation["method"] | null => {
|
||||
): {
|
||||
readonly method: JavaScriptSemanticTimerOperation["method"];
|
||||
readonly builtinAuthority?: "candidate";
|
||||
} | null => {
|
||||
if (t.isIdentifier(callee)) {
|
||||
const binding = resolveSemanticBindingState(state, callee, callee.name);
|
||||
if (binding === undefined) {
|
||||
@@ -508,12 +523,24 @@ const timerMethod = (
|
||||
return semanticResolutionBlocked(state, callee, callee.name) ||
|
||||
!isUnmodifiedGlobalReference(callee, state, callee.name)
|
||||
? null
|
||||
: method;
|
||||
: { method };
|
||||
}
|
||||
}
|
||||
return admittedTimerMethod(
|
||||
builtinDataEffectMethod(callee, state, ["timers", "node:timers"]) ?? "",
|
||||
const identity = builtinDataEffectIdentity(callee, state, [
|
||||
"timers",
|
||||
"node:timers",
|
||||
]);
|
||||
const method = admittedTimerMethod(
|
||||
identity?.path.length === 1 ? (identity.path[0] ?? "") : "",
|
||||
);
|
||||
return method === null
|
||||
? null
|
||||
: {
|
||||
method,
|
||||
...(identity?.builtinAuthority === undefined
|
||||
? {}
|
||||
: { builtinAuthority: identity.builtinAuthority }),
|
||||
};
|
||||
};
|
||||
const admittedTimerMethod = (
|
||||
value: string,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeCommonJsSemanticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
import {
|
||||
analyzeJavaScriptSemantics,
|
||||
analyzeParsedJavaScriptSemantics,
|
||||
} from "./javascriptSemanticAnalysis.js";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
|
||||
const source = `
|
||||
const fs = require('fs');
|
||||
const process = require('process');
|
||||
const http = require('http');
|
||||
fs.readFileSync('config.json');
|
||||
process.env.MODE;
|
||||
const stream = fs.createReadStream('data');
|
||||
stream.close();
|
||||
http.get('http://example.test');
|
||||
`;
|
||||
|
||||
it("retains built-in effects as conditional when the loader is unknown", () => {
|
||||
const candidate = analyzeJavaScriptSemantics(source);
|
||||
const proven = analyzeCommonJsSemanticFixture(source);
|
||||
for (const ir of [candidate, proven]) {
|
||||
expect(ir.configurationOperations.map(({ key }) => key)).toEqual([
|
||||
"config.json",
|
||||
"MODE",
|
||||
]);
|
||||
expect(ir.resourceOperations.map(({ kind }) => kind)).toEqual([
|
||||
"acquire",
|
||||
"release",
|
||||
]);
|
||||
expect(ir.requestOperations.map(({ endpoint }) => endpoint)).toEqual([
|
||||
"http://example.test",
|
||||
]);
|
||||
}
|
||||
for (const operation of [
|
||||
...candidate.configurationOperations,
|
||||
...candidate.resourceOperations,
|
||||
...candidate.requestOperations,
|
||||
])
|
||||
expect(operation.builtinAuthority).toBe("candidate");
|
||||
for (const operation of [
|
||||
...proven.configurationOperations,
|
||||
...proven.resourceOperations,
|
||||
...proven.requestOperations,
|
||||
])
|
||||
expect(operation.builtinAuthority).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not infer a conditional built-in after the selected method is replaced or the loader is shadowed", () => {
|
||||
for (const source of [
|
||||
`const fs = require('fs'); fs.readFileSync = replacement; fs.readFileSync('config');`,
|
||||
`function load(require) { const fs = require('fs'); fs.readFileSync('config'); }`,
|
||||
])
|
||||
expect(analyzeJavaScriptSemantics(source).configurationOperations).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("retains conditional built-in use across imported and unknown-loader alternatives", () => {
|
||||
for (const choice of ["builtin : alternate", "alternate : builtin"]) {
|
||||
const source = `import * as builtin from "node:fs"; const alternate = require("fs"); const selected = globalThis.flag ? ${choice}; selected.readFileSync("config.json");`;
|
||||
const parsed = parseJavaScriptSource(source, "entry.MJS");
|
||||
if (parsed === null) throw new Error("fixture did not parse");
|
||||
expect(
|
||||
analyzeParsedJavaScriptSemantics(parsed).configurationOperations,
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "file",
|
||||
key: "config.json",
|
||||
builtinAuthority: "candidate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not equate mixed alternatives with different identities or replaced methods", () => {
|
||||
for (const source of [
|
||||
'import * as builtin from "node:fs"; const alternate = require("http"); const selected = globalThis.flag ? builtin : alternate; selected.readFileSync("config.json");',
|
||||
'import * as builtin from "node:fs"; const alternate = require("fs"); alternate.readFileSync = globalThis.replacement; const selected = globalThis.flag ? builtin : alternate; selected.readFileSync("config.json");',
|
||||
'import * as builtin from "node:fs"; const alternate = require("fs"); const selected = globalThis.flag ? builtin : alternate; selected.default.readFileSync("config.json");',
|
||||
]) {
|
||||
const parsed = parseJavaScriptSource(source, "entry.MJS");
|
||||
if (parsed === null) throw new Error("fixture did not parse");
|
||||
expect(
|
||||
analyzeParsedJavaScriptSemantics(parsed).configurationOperations,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a conditional built-in reached through a nested receiver", () => {
|
||||
const source =
|
||||
'import * as builtin from "node:fs"; const alternate = require("fs"); const selected = globalThis.flag ? builtin : alternate; selected.promises.readFile("config.json");';
|
||||
const parsed = parseJavaScriptSource(source, "entry.MJS");
|
||||
if (parsed === null) throw new Error("fixture did not parse");
|
||||
expect(
|
||||
analyzeParsedJavaScriptSemantics(parsed).configurationOperations,
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "file",
|
||||
key: "config.json",
|
||||
builtinAuthority: "candidate",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not retain a nested built-in after replacing either the object or its method", () => {
|
||||
for (const mutation of [
|
||||
"alternate.promises = globalThis.replacement;",
|
||||
"alternate.promises.readFile = globalThis.replacement;",
|
||||
"selected.promises.readFile = globalThis.replacement;",
|
||||
]) {
|
||||
const source = `import * as builtin from "node:fs"; const alternate = require("fs"); const selected = globalThis.flag ? builtin : alternate; ${mutation} selected.promises.readFile("config.json");`;
|
||||
const parsed = parseJavaScriptSource(source, "entry.MJS");
|
||||
if (parsed === null) throw new Error("fixture did not parse");
|
||||
expect(
|
||||
analyzeParsedJavaScriptSemantics(parsed).configurationOperations,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
@@ -33,7 +33,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js";
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import {
|
||||
builtinDataEffectMethod,
|
||||
builtinDataEffectIdentity,
|
||||
dataEffectLiteralString,
|
||||
dataEffectMemberCallee,
|
||||
assignedSemanticResultBindings,
|
||||
@@ -52,6 +52,7 @@ interface ChildProcessAnalysis {
|
||||
type ChildSpawnContext = JavaScriptExpressionShapeResolver;
|
||||
|
||||
interface SpawnCandidate {
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly node: t.CallExpression | t.OptionalCallExpression;
|
||||
readonly method: JavaScriptSemanticChildProcessSpawn["method"];
|
||||
readonly ownerCallableId: string | null;
|
||||
@@ -106,8 +107,9 @@ const collectSpawns = (
|
||||
enter: (node, parent) => {
|
||||
callableStack.enter(node, parent);
|
||||
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
|
||||
const method = childProcessMethod(node.callee, state);
|
||||
if (method !== null) {
|
||||
const identity = childProcessMethod(node.callee, state);
|
||||
if (identity !== null) {
|
||||
const { method, builtinAuthority } = identity;
|
||||
const projectedResultBindings = assignedSemanticResultBindings(
|
||||
node,
|
||||
ancestors,
|
||||
@@ -116,6 +118,7 @@ const collectSpawns = (
|
||||
output.push({
|
||||
node,
|
||||
method,
|
||||
...(builtinAuthority === undefined ? {} : { builtinAuthority }),
|
||||
ownerCallableId: callableStack.callables.at(-1) ?? null,
|
||||
resultBindingId: scalarAssignedResultBinding(
|
||||
projectedResultBindings,
|
||||
@@ -145,6 +148,9 @@ const immutableSpawn = (
|
||||
const { options, argvCount } = spawnArguments(candidate, context);
|
||||
const command = dataEffectLiteralString(candidate.node.arguments[0]);
|
||||
return {
|
||||
...(candidate.builtinAuthority === undefined
|
||||
? {}
|
||||
: { builtinAuthority: candidate.builtinAuthority }),
|
||||
processId: spawnId(candidate),
|
||||
method: candidate.method,
|
||||
location: range(candidate.node),
|
||||
@@ -225,6 +231,9 @@ const childInteraction = (
|
||||
const { kind, interactionMethod, eventName } = classification;
|
||||
const listener = node.arguments[1];
|
||||
return {
|
||||
...(linked.some((candidate) => candidate.builtinAuthority === "candidate")
|
||||
? { builtinAuthority: "candidate" as const }
|
||||
: {}),
|
||||
interactionId: `child:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
|
||||
kind,
|
||||
method: interactionMethod,
|
||||
@@ -239,7 +248,11 @@ const childInteraction = (
|
||||
kind === "signal" ? killSignalName(node.arguments, state) : null,
|
||||
listenerLocation:
|
||||
kind === "listener" && t.isNode(listener) ? range(listener) : null,
|
||||
resolution: complete ? "complete" : "partial",
|
||||
resolution:
|
||||
complete &&
|
||||
linked.every((candidate) => candidate.builtinAuthority !== "candidate")
|
||||
? "complete"
|
||||
: "partial",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -288,14 +301,28 @@ const classifyInteraction = (
|
||||
const childProcessMethod = (
|
||||
callee: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticChildProcessSpawn["method"] | null => {
|
||||
const method = builtinDataEffectMethod(callee, state, [
|
||||
): {
|
||||
readonly method: JavaScriptSemanticChildProcessSpawn["method"];
|
||||
readonly builtinAuthority?: "candidate";
|
||||
} | null => {
|
||||
const identity = builtinDataEffectIdentity(callee, state, [
|
||||
"child_process",
|
||||
"node:child_process",
|
||||
]);
|
||||
return (
|
||||
CHILD_PROCESS_METHODS.find((candidate) => candidate === method) ?? null
|
||||
);
|
||||
const method =
|
||||
identity?.path.length === 1
|
||||
? CHILD_PROCESS_METHODS.find(
|
||||
(candidate) => candidate === identity.path[0],
|
||||
)
|
||||
: undefined;
|
||||
return method === undefined
|
||||
? null
|
||||
: {
|
||||
method,
|
||||
...(identity?.builtinAuthority === undefined
|
||||
? {}
|
||||
: { builtinAuthority: identity.builtinAuthority }),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveChildCandidates = (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BUILTIN_RESOURCE_METHODS } from "./javascriptBuiltinResourceMethods.js"
|
||||
import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js";
|
||||
import {
|
||||
semanticRequireOrigin,
|
||||
semanticRequireOriginCandidate,
|
||||
semanticRequireExportsIdentity,
|
||||
semanticGlobalProcessOrigin,
|
||||
semanticMutableBuiltinOrigin,
|
||||
@@ -22,10 +23,14 @@ import {
|
||||
} from "./javascriptSemanticPropertyPaths.js";
|
||||
|
||||
import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js";
|
||||
import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js";
|
||||
import {
|
||||
semanticBindingAtRead,
|
||||
semanticTopLevelEffectFollowsCapture,
|
||||
} from "./javascriptSemanticMutationInitializers.js";
|
||||
import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
semanticStableInitializer,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
type JavaScriptSemanticMemberEffect,
|
||||
@@ -171,17 +176,70 @@ export const builtinDataEffectCallablePath = (
|
||||
callee: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
specifiers: readonly string[],
|
||||
): readonly string[] | null => {
|
||||
return builtinDataEffectCallablePathWithAuthority(
|
||||
callee,
|
||||
state,
|
||||
specifiers,
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
/** Recover a proven built-in or a conditional built-in identity. */
|
||||
export const builtinDataEffectIdentity = (
|
||||
callee: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
specifiers: readonly string[],
|
||||
): {
|
||||
readonly path: readonly string[];
|
||||
readonly builtinAuthority?: "candidate";
|
||||
} | null => {
|
||||
const proven = builtinDataEffectCallablePathWithAuthority(
|
||||
callee,
|
||||
state,
|
||||
specifiers,
|
||||
false,
|
||||
);
|
||||
if (proven !== null) return { path: proven };
|
||||
const candidate = builtinDataEffectCallablePathWithAuthority(
|
||||
callee,
|
||||
state,
|
||||
specifiers,
|
||||
true,
|
||||
);
|
||||
return candidate === null
|
||||
? null
|
||||
: { path: candidate, builtinAuthority: "candidate" };
|
||||
};
|
||||
|
||||
const builtinDataEffectCallablePathWithAuthority = (
|
||||
callee: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
specifiers: readonly string[],
|
||||
allowCandidate: boolean,
|
||||
): readonly string[] | null => {
|
||||
const expression = unwrapJavaScriptExpression(callee).node;
|
||||
if (expression !== callee)
|
||||
return builtinDataEffectCallablePath(expression, state, specifiers);
|
||||
return builtinDataEffectCallablePathWithAuthority(
|
||||
expression,
|
||||
state,
|
||||
specifiers,
|
||||
allowCandidate,
|
||||
);
|
||||
if (t.isSequenceExpression(expression)) {
|
||||
const final = expression.expressions.at(-1);
|
||||
return final === undefined
|
||||
? null
|
||||
: builtinDataEffectCallablePath(final, state, specifiers);
|
||||
: builtinDataEffectCallablePathWithAuthority(
|
||||
final,
|
||||
state,
|
||||
specifiers,
|
||||
allowCandidate,
|
||||
);
|
||||
}
|
||||
const required = semanticRequireOrigin(callee, state);
|
||||
const required = (
|
||||
allowCandidate ? semanticRequireOriginCandidate : semanticRequireOrigin
|
||||
)(callee, state);
|
||||
if (required !== undefined) {
|
||||
const origin = builtinNamespaceOrigin(required.origin);
|
||||
return specifiers.some(
|
||||
@@ -193,6 +251,9 @@ export const builtinDataEffectCallablePath = (
|
||||
callee,
|
||||
state,
|
||||
true,
|
||||
undefined,
|
||||
allowCandidate,
|
||||
allowCandidate && required.origin.loaderAuthority !== "candidate",
|
||||
)
|
||||
? origin.importedPath
|
||||
: null;
|
||||
@@ -204,6 +265,7 @@ export const builtinDataEffectCallablePath = (
|
||||
state,
|
||||
specifiers,
|
||||
callee,
|
||||
allowCandidate,
|
||||
[],
|
||||
true,
|
||||
);
|
||||
@@ -216,13 +278,14 @@ export const builtinDataEffectCallablePath = (
|
||||
const receiver = semanticBoundMemberPath(callee.object, state);
|
||||
if (method !== null && receiver !== undefined) {
|
||||
const provenance = evaluateSemanticProvenance(receiver.binding, state);
|
||||
if (provenance.status === "module") {
|
||||
const sourceOrigin = builtinProvenanceOrigin(provenance, allowCandidate, [
|
||||
...receiver.path,
|
||||
method,
|
||||
]);
|
||||
if (sourceOrigin !== undefined) {
|
||||
const origin = builtinNamespaceOrigin({
|
||||
...provenance.origins[0],
|
||||
importedPath: [
|
||||
...provenance.origins[0].importedPath,
|
||||
...receiver.path,
|
||||
],
|
||||
...sourceOrigin,
|
||||
importedPath: [...sourceOrigin.importedPath, ...receiver.path],
|
||||
});
|
||||
if (
|
||||
specifiers.some(
|
||||
@@ -235,6 +298,7 @@ export const builtinDataEffectCallablePath = (
|
||||
state,
|
||||
specifiers,
|
||||
callee,
|
||||
allowCandidate,
|
||||
[...receiver.path, method],
|
||||
true,
|
||||
) !== null
|
||||
@@ -242,7 +306,12 @@ export const builtinDataEffectCallablePath = (
|
||||
: null;
|
||||
}
|
||||
}
|
||||
const source = semanticExpressionModuleOrigin(callee.object, state);
|
||||
const source = semanticExpressionModuleOrigin(
|
||||
callee.object,
|
||||
state,
|
||||
new Set(),
|
||||
allowCandidate,
|
||||
);
|
||||
if (method === null || source === undefined) return null;
|
||||
const origin = builtinNamespaceOrigin(source);
|
||||
return specifiers.some(
|
||||
@@ -255,6 +324,8 @@ export const builtinDataEffectCallablePath = (
|
||||
state,
|
||||
true,
|
||||
callee.object,
|
||||
allowCandidate,
|
||||
allowCandidate && source.loaderAuthority !== "candidate",
|
||||
)
|
||||
? [...origin.importedPath, method]
|
||||
: null;
|
||||
@@ -271,6 +342,7 @@ export const builtinDataEffectCallablePath = (
|
||||
state,
|
||||
specifiers,
|
||||
callee,
|
||||
allowCandidate,
|
||||
[method],
|
||||
true,
|
||||
);
|
||||
@@ -285,8 +357,11 @@ export const builtinDataEffectCallablePath = (
|
||||
return null;
|
||||
}
|
||||
const provenance = evaluateSemanticProvenance(binding, state);
|
||||
if (provenance.status !== "module") return null;
|
||||
const origin = builtinNamespaceOrigin(provenance.origins[0]);
|
||||
const sourceOrigin = builtinProvenanceOrigin(provenance, allowCandidate, [
|
||||
method,
|
||||
]);
|
||||
if (sourceOrigin === undefined) return null;
|
||||
const origin = builtinNamespaceOrigin(sourceOrigin);
|
||||
const selected = [...origin.importedPath, method];
|
||||
return specifiers.some(
|
||||
(specifier) => specifier.replace(/^node:/u, "") === origin.specifier,
|
||||
@@ -296,16 +371,19 @@ export const builtinDataEffectCallablePath = (
|
||||
state,
|
||||
specifiers,
|
||||
initializer.node,
|
||||
allowCandidate,
|
||||
[method],
|
||||
true,
|
||||
)?.length === 0 &&
|
||||
!requiredModuleChanged(
|
||||
provenance.origins[0].specifier,
|
||||
sourceOrigin.specifier,
|
||||
selected,
|
||||
callee,
|
||||
state,
|
||||
true,
|
||||
initializer.node,
|
||||
allowCandidate,
|
||||
allowCandidate && sourceOrigin.loaderAuthority !== "candidate",
|
||||
)
|
||||
? [method]
|
||||
: null;
|
||||
@@ -337,6 +415,7 @@ const builtinBindingPath = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
specifiers: readonly string[],
|
||||
capture: t.Node,
|
||||
allowCandidate: boolean,
|
||||
selectedPath: readonly string[] = [],
|
||||
callableValue = false,
|
||||
visited: ReadonlySet<string> = new Set(),
|
||||
@@ -366,6 +445,7 @@ const builtinBindingPath = (
|
||||
state,
|
||||
specifiers,
|
||||
capture,
|
||||
allowCandidate,
|
||||
selectedPath,
|
||||
callableValue,
|
||||
visited,
|
||||
@@ -382,6 +462,7 @@ const computeBuiltinBindingPath = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
specifiers: readonly string[],
|
||||
capture: t.Node,
|
||||
allowCandidate: boolean,
|
||||
selectedPath: readonly string[] = [],
|
||||
callableValue = false,
|
||||
visited: ReadonlySet<string>,
|
||||
@@ -390,6 +471,19 @@ const computeBuiltinBindingPath = (
|
||||
savedObjectCapture?: t.Node,
|
||||
): readonly string[] | null => {
|
||||
if (binding === undefined || visited.has(binding.bindingId)) return null;
|
||||
// A source-owned initializer is not available before its ordered execution.
|
||||
// Hoisted imports and unresolved activation order retain their existing facts.
|
||||
if (
|
||||
binding.directOrigins.length === 0 &&
|
||||
semanticStableInitializer(binding, state) !== undefined
|
||||
) {
|
||||
const read = semanticBindingAtRead(
|
||||
binding,
|
||||
provenanceCapture ?? capture,
|
||||
state,
|
||||
);
|
||||
if (read.status === "ordered" && !read.hasDefiniteInitializer) 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 (
|
||||
@@ -413,7 +507,7 @@ const computeBuiltinBindingPath = (
|
||||
))
|
||||
),
|
||||
) ||
|
||||
builtinEscapes(binding, state).some(
|
||||
builtinEscapes(binding, state, allowCandidate).some(
|
||||
({ path, node }) =>
|
||||
affectsBuiltinPath(path, selectedPath) &&
|
||||
// A function-value escape cannot replace its native [[Call]]. An
|
||||
@@ -431,9 +525,14 @@ const computeBuiltinBindingPath = (
|
||||
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]);
|
||||
const sourceOrigin = builtinProvenanceOrigin(
|
||||
provenance,
|
||||
allowCandidate,
|
||||
selectedPath,
|
||||
);
|
||||
if (sourceOrigin === undefined) return null;
|
||||
const { importedPath } = sourceOrigin;
|
||||
const namespace = builtinNamespaceOrigin(sourceOrigin);
|
||||
if (
|
||||
!specifiers.some((item) => {
|
||||
const name = item.replace(/^node:/u, "");
|
||||
@@ -457,6 +556,10 @@ const computeBuiltinBindingPath = (
|
||||
capture,
|
||||
state,
|
||||
callableValue,
|
||||
undefined,
|
||||
allowCandidate,
|
||||
allowCandidate && origin.loaderAuthority !== "candidate",
|
||||
processSnapshotObjectRoot(origin, selectedPath),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
@@ -491,6 +594,7 @@ const computeBuiltinBindingPath = (
|
||||
state,
|
||||
specifiers,
|
||||
current.capture,
|
||||
allowCandidate,
|
||||
current.path,
|
||||
callableValue,
|
||||
ancestors,
|
||||
@@ -535,7 +639,11 @@ const computeBuiltinBindingPath = (
|
||||
{ ...current, node: node.right },
|
||||
);
|
||||
} else {
|
||||
const required = semanticRequireOrigin(node, state);
|
||||
const required = (
|
||||
allowCandidate
|
||||
? semanticRequireOriginCandidate
|
||||
: semanticRequireOrigin
|
||||
)(node, state);
|
||||
if (
|
||||
required !== undefined &&
|
||||
requiredModuleChanged(
|
||||
@@ -545,6 +653,8 @@ const computeBuiltinBindingPath = (
|
||||
state,
|
||||
callableValue,
|
||||
current.savedObjectCapture,
|
||||
allowCandidate,
|
||||
allowCandidate && required.origin.loaderAuthority !== "candidate",
|
||||
)
|
||||
)
|
||||
return null;
|
||||
@@ -557,7 +667,7 @@ const computeBuiltinBindingPath = (
|
||||
semanticBuiltinNamespacePath(importedPath)
|
||||
? []
|
||||
: builtinNamespaceOrigin({
|
||||
...provenance.origins[0],
|
||||
...sourceOrigin,
|
||||
importedPath: resolvedPath,
|
||||
}).importedPath;
|
||||
};
|
||||
@@ -580,6 +690,37 @@ const builtinNamespaceOrigin = (
|
||||
: { ...origin, specifier, importedPath };
|
||||
};
|
||||
|
||||
const builtinProvenanceOrigin = (
|
||||
provenance: ReturnType<typeof evaluateSemanticProvenance>,
|
||||
allowCandidate: boolean,
|
||||
selectedPath: readonly string[],
|
||||
): JavaScriptModuleOrigin | undefined => {
|
||||
if (provenance.status === "module") return provenance.origins[0];
|
||||
if (
|
||||
!allowCandidate ||
|
||||
provenance.status !== "ambiguous" ||
|
||||
!provenance.origins.some((origin) => origin.loaderAuthority === "candidate")
|
||||
)
|
||||
return undefined;
|
||||
const first = provenance.origins[0];
|
||||
if (first === undefined) return undefined;
|
||||
if (provenance.origins.length === 1) return first;
|
||||
if (selectedPath[0] === "default") return undefined;
|
||||
const normalized = builtinNamespaceOrigin(first);
|
||||
return provenance.origins.every((origin) => {
|
||||
const other = builtinNamespaceOrigin(origin);
|
||||
return (
|
||||
other.specifier === normalized.specifier &&
|
||||
other.importedPath.length === normalized.importedPath.length &&
|
||||
other.importedPath.every(
|
||||
(part, index) => part === normalized.importedPath[index],
|
||||
)
|
||||
);
|
||||
})
|
||||
? first
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const affectsBuiltinPath = (
|
||||
path: JavaScriptSemanticPropertyPath,
|
||||
selected: readonly string[],
|
||||
@@ -639,6 +780,7 @@ interface BuiltinEffectIndex {
|
||||
readonly JavaScriptSemanticMemberEffect[]
|
||||
>;
|
||||
readonly receivers: WeakMap<t.Node, boolean>;
|
||||
readonly candidateReceivers: WeakMap<t.Node, boolean>;
|
||||
}
|
||||
|
||||
// Derived passes run after mutation collection. These indexes share that state's
|
||||
@@ -663,6 +805,7 @@ const builtinEffectIndex = (
|
||||
moduleEffects: new Map(),
|
||||
bindingEscapes: new Map(),
|
||||
receivers: new WeakMap(),
|
||||
candidateReceivers: new WeakMap(),
|
||||
};
|
||||
builtinEffectIndexes.set(state, index);
|
||||
}
|
||||
@@ -672,14 +815,16 @@ const builtinEffectIndex = (
|
||||
const builtinEscapes = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
allowCandidate = false,
|
||||
): readonly JavaScriptSemanticMemberEffect[] => {
|
||||
const cache = builtinEffectIndex(state).bindingEscapes;
|
||||
let effects = cache.get(binding.bindingId);
|
||||
const key = `${binding.bindingId}:${allowCandidate ? "candidate" : "proven"}`;
|
||||
let effects = cache.get(key);
|
||||
if (effects === undefined) {
|
||||
effects = binding.escapedPaths.filter(
|
||||
({ node }) => !originalBuiltinReceiver(node, state),
|
||||
({ node }) => !originalBuiltinReceiver(node, state, allowCandidate),
|
||||
);
|
||||
cache.set(binding.bindingId, effects);
|
||||
cache.set(key, effects);
|
||||
}
|
||||
return effects;
|
||||
};
|
||||
@@ -687,13 +832,16 @@ const builtinEscapes = (
|
||||
const builtinModuleEffects = (
|
||||
specifier: string,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
allowCandidate = false,
|
||||
): readonly JavaScriptSemanticRequiredModuleEffect[] => {
|
||||
const cache = builtinEffectIndex(state).moduleEffects;
|
||||
let effects = cache.get(specifier);
|
||||
const key = `${specifier}:${allowCandidate ? "candidate" : "proven"}`;
|
||||
let effects = cache.get(key);
|
||||
if (effects === undefined) {
|
||||
effects = (state.requiredModuleEffects.get(specifier) ?? []).filter(
|
||||
({ kind, node }) =>
|
||||
kind === "write" || !originalBuiltinReceiver(node, state),
|
||||
kind === "write" ||
|
||||
!originalBuiltinReceiver(node, state, allowCandidate),
|
||||
);
|
||||
if (specifier === "fs/promises") {
|
||||
const sharedFsEscapes = (state.requiredModuleEffects.get("fs") ?? [])
|
||||
@@ -701,7 +849,7 @@ const builtinModuleEffects = (
|
||||
({ kind, path, node }) =>
|
||||
kind === "escape" &&
|
||||
(path.length === 0 || path[0] === "promises") &&
|
||||
!originalBuiltinReceiver(node, state),
|
||||
!originalBuiltinReceiver(node, state, allowCandidate),
|
||||
)
|
||||
.map((effect) => ({
|
||||
...effect,
|
||||
@@ -710,11 +858,29 @@ const builtinModuleEffects = (
|
||||
}));
|
||||
effects = [...effects, ...sharedFsEscapes];
|
||||
}
|
||||
cache.set(specifier, effects);
|
||||
cache.set(key, effects);
|
||||
}
|
||||
return effects;
|
||||
};
|
||||
|
||||
/** A named ESM process env/argv value snapshots the object, not later slot replacement. */
|
||||
const processSnapshotObjectRoot = (
|
||||
origin: JavaScriptModuleOrigin,
|
||||
selected: readonly string[],
|
||||
): "env" | "argv" | undefined => {
|
||||
if (
|
||||
origin.resolutionMode === "require" ||
|
||||
origin.specifier.replace(/^node:/u, "") !== "process"
|
||||
)
|
||||
return undefined;
|
||||
const [root, ...rest] = [...origin.importedPath, ...selected];
|
||||
if ((root !== "env" && root !== "argv") || rest.length === 0)
|
||||
return undefined;
|
||||
// The default export is the mutable process object, not a snapshot slot.
|
||||
if (origin.importedPath[0] === "default") return undefined;
|
||||
return root;
|
||||
};
|
||||
|
||||
/** Check shared exports at the use or method-capture point, not at every later call. */
|
||||
const requiredModuleChanged = (
|
||||
specifier: string,
|
||||
@@ -723,10 +889,20 @@ const requiredModuleChanged = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
callableValue = false,
|
||||
savedObjectCapture?: t.Node,
|
||||
allowCandidate = false,
|
||||
ignoreCandidateEffects = false,
|
||||
snapshotObjectRoot?: "env" | "argv",
|
||||
): boolean => {
|
||||
const identity = semanticRequireExportsIdentity(specifier, selected);
|
||||
return builtinModuleEffects(identity.specifier, state).some(
|
||||
({ path, node, kind }) =>
|
||||
return builtinModuleEffects(identity.specifier, state, allowCandidate).some(
|
||||
({ path, node, kind, loaderAuthority }) =>
|
||||
!(ignoreCandidateEffects && loaderAuthority === "candidate") &&
|
||||
!(
|
||||
snapshotObjectRoot !== undefined &&
|
||||
kind === "write" &&
|
||||
path.length === 1 &&
|
||||
path[0] === snapshotObjectRoot
|
||||
) &&
|
||||
!(
|
||||
savedObjectCapture !== undefined &&
|
||||
path.length < identity.path.length &&
|
||||
@@ -752,12 +928,14 @@ const requiredModuleChanged = (
|
||||
const originalBuiltinReceiver = (
|
||||
node: t.Node | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
allowCandidate = false,
|
||||
): boolean => {
|
||||
if (node === undefined) return false;
|
||||
const receivers = builtinEffectIndex(state).receivers;
|
||||
const index = builtinEffectIndex(state);
|
||||
const receivers = allowCandidate ? index.candidateReceivers : index.receivers;
|
||||
const previous = receivers.get(node);
|
||||
if (previous !== undefined) return previous;
|
||||
const result = computeOriginalBuiltinReceiver(node, state);
|
||||
const result = computeOriginalBuiltinReceiver(node, state, allowCandidate);
|
||||
receivers.set(node, result);
|
||||
return result;
|
||||
};
|
||||
@@ -765,25 +943,49 @@ const originalBuiltinReceiver = (
|
||||
const computeOriginalBuiltinReceiver = (
|
||||
node: t.Node | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
allowCandidate: boolean,
|
||||
): boolean => {
|
||||
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
|
||||
return false;
|
||||
const callee = unwrapJavaScriptExpression(node.callee).node;
|
||||
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
|
||||
return false;
|
||||
const receiver = t.isIdentifier(callee.object)
|
||||
? resolveSemanticBindingState(state, callee.object, callee.object.name)
|
||||
: undefined;
|
||||
const boundReceiver = semanticBoundMemberPath(callee.object, state);
|
||||
const receiver = boundReceiver?.binding;
|
||||
const receiverPath = boundReceiver?.path ?? [];
|
||||
const provenance =
|
||||
receiver === undefined
|
||||
? undefined
|
||||
: evaluateSemanticProvenance(receiver, state);
|
||||
const receiverMethod = semanticStaticPropertyKey(
|
||||
callee.property,
|
||||
callee.computed,
|
||||
);
|
||||
const boundOrigin =
|
||||
provenance === undefined || receiverMethod === null
|
||||
? undefined
|
||||
: builtinProvenanceOrigin(provenance, allowCandidate, [
|
||||
...receiverPath,
|
||||
receiverMethod,
|
||||
]);
|
||||
const sourceOrigin =
|
||||
provenance?.status === "module"
|
||||
? provenance.origins[0]
|
||||
: (semanticExpressionModuleOrigin(callee.object, state) ??
|
||||
semanticRequireOrigin(callee.object, state)?.origin ??
|
||||
semanticGlobalProcessOrigin(callee.object, state));
|
||||
(boundOrigin === undefined
|
||||
? undefined
|
||||
: {
|
||||
...boundOrigin,
|
||||
importedPath: [...boundOrigin.importedPath, ...receiverPath],
|
||||
}) ??
|
||||
semanticExpressionModuleOrigin(
|
||||
callee.object,
|
||||
state,
|
||||
new Set(),
|
||||
allowCandidate,
|
||||
) ??
|
||||
(allowCandidate
|
||||
? semanticRequireOriginCandidate(callee.object, state)
|
||||
: semanticRequireOrigin(callee.object, state)
|
||||
)?.origin ??
|
||||
semanticGlobalProcessOrigin(callee.object, state);
|
||||
if (sourceOrigin === undefined) return false;
|
||||
const origin = builtinNamespaceOrigin(sourceOrigin);
|
||||
const posixReceiver =
|
||||
@@ -792,10 +994,15 @@ const computeOriginalBuiltinReceiver = (
|
||||
origin.importedPath[0] === "posix";
|
||||
if (origin.importedPath.length !== 0 && !posixReceiver) return false;
|
||||
const method = semanticStaticPropertyKey(callee.property, callee.computed);
|
||||
const identity = semanticRequireExportsIdentity(origin.specifier, [
|
||||
...origin.importedPath,
|
||||
method ?? "",
|
||||
]);
|
||||
// ESM namespace/named exports are snapshots, whereas the default object and
|
||||
// CommonJS exports share mutable slots with require().
|
||||
const sources =
|
||||
provenance === undefined || boundOrigin === undefined
|
||||
? [sourceOrigin]
|
||||
: provenance.origins.map((source) => ({
|
||||
...source,
|
||||
importedPath: [...source.importedPath, ...receiverPath],
|
||||
}));
|
||||
if (
|
||||
method === null ||
|
||||
!BUILTIN_RECEIVER_METHODS[
|
||||
@@ -803,16 +1010,38 @@ const computeOriginalBuiltinReceiver = (
|
||||
]?.has(method) ||
|
||||
receiver?.memberWrites.some(
|
||||
({ path, node: write }) =>
|
||||
affectsBuiltinPath(path, [method]) &&
|
||||
affectsBuiltinPath(path, [...receiverPath, 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),
|
||||
)
|
||||
sources.some((source) => {
|
||||
const shared =
|
||||
method === null
|
||||
? undefined
|
||||
: semanticMutableBuiltinOrigin(source, [method]);
|
||||
const identity =
|
||||
shared === undefined
|
||||
? undefined
|
||||
: semanticRequireExportsIdentity(shared.specifier, shared.path);
|
||||
return (
|
||||
identity !== undefined &&
|
||||
(
|
||||
builtinEffectIndex(state).writesByModule.get(identity.specifier) ?? []
|
||||
).some(
|
||||
({ path, node: write, loaderAuthority }) =>
|
||||
!(
|
||||
allowCandidate &&
|
||||
source.loaderAuthority !== "candidate" &&
|
||||
loaderAuthority === "candidate"
|
||||
) &&
|
||||
affectsBuiltinPath(path, identity.path) &&
|
||||
!semanticTopLevelEffectFollowsCapture(
|
||||
node,
|
||||
write,
|
||||
state.parentsByNode,
|
||||
),
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
return false;
|
||||
return !node.arguments.some((argument) => {
|
||||
@@ -821,7 +1050,11 @@ const computeOriginalBuiltinReceiver = (
|
||||
if (passed === undefined) return false;
|
||||
const passedOrigin = evaluateSemanticProvenance(passed, state);
|
||||
return (
|
||||
passedOrigin.status === "module" &&
|
||||
(passedOrigin.status === "module" ||
|
||||
(allowCandidate &&
|
||||
passedOrigin.status === "ambiguous" &&
|
||||
passedOrigin.origins.length === 1 &&
|
||||
passedOrigin.origins[0]?.loaderAuthority === "candidate")) &&
|
||||
builtinNamespaceOrigin(passedOrigin.origins[0]).specifier ===
|
||||
origin.specifier &&
|
||||
builtinNamespaceOrigin(passedOrigin.origins[0]).importedPath.length === 0
|
||||
@@ -833,19 +1066,33 @@ const semanticExpressionModuleOrigin = (
|
||||
expression: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
visited = new Set<string>(),
|
||||
allowCandidate = false,
|
||||
): JavaScriptModuleOrigin | undefined => {
|
||||
const node = unwrapJavaScriptExpression(expression).node;
|
||||
const required = semanticRequireOrigin(node, state);
|
||||
const required = (
|
||||
allowCandidate ? semanticRequireOriginCandidate : 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;
|
||||
return provenance.status === "module" ||
|
||||
(allowCandidate &&
|
||||
provenance.status === "ambiguous" &&
|
||||
provenance.origins.length === 1 &&
|
||||
provenance.origins[0]?.loaderAuthority === "candidate")
|
||||
? provenance.origins[0]
|
||||
: undefined;
|
||||
}
|
||||
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
||||
const base = semanticExpressionModuleOrigin(node.object, state, visited);
|
||||
const base = semanticExpressionModuleOrigin(
|
||||
node.object,
|
||||
state,
|
||||
visited,
|
||||
allowCandidate,
|
||||
);
|
||||
const key = semanticStaticPropertyKey(node.property, node.computed);
|
||||
return base === undefined || key === null
|
||||
? undefined
|
||||
@@ -875,11 +1122,49 @@ export const isSemanticProcessObject = (
|
||||
state,
|
||||
["process", "node:process"],
|
||||
node,
|
||||
false,
|
||||
selected,
|
||||
)?.length === 0
|
||||
);
|
||||
};
|
||||
|
||||
/** Preserve conditional process identity across loader and mutation uncertainty. */
|
||||
export const semanticProcessObjectIdentity = (
|
||||
node: t.Expression | t.Super,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
family: "env" | "argv",
|
||||
key?: string | null,
|
||||
): { readonly builtinAuthority?: "candidate" } | null => {
|
||||
if (isSemanticProcessObject(node, state, family, key)) return {};
|
||||
if (!t.isIdentifier(node)) return null;
|
||||
const binding = resolveSemanticBindingState(state, node, node.name);
|
||||
const selected = key === undefined || key === null ? [family] : [family, key];
|
||||
if (binding === undefined)
|
||||
return semanticGlobalProcessOrigin(node, state) !== undefined &&
|
||||
!requiredModuleChanged(
|
||||
"process",
|
||||
selected,
|
||||
node,
|
||||
state,
|
||||
false,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
? { builtinAuthority: "candidate" }
|
||||
: null;
|
||||
return builtinBindingPath(
|
||||
binding,
|
||||
state,
|
||||
["process", "node:process"],
|
||||
node,
|
||||
true,
|
||||
selected,
|
||||
)?.length === 0
|
||||
? { builtinAuthority: "candidate" }
|
||||
: null;
|
||||
};
|
||||
|
||||
/** Resolve the nearest direct variable/assignment owner before a callable. */
|
||||
export const outerDataEffectBinding = (
|
||||
node: t.Node,
|
||||
|
||||
@@ -27,11 +27,11 @@ import {
|
||||
semanticEffectTiming,
|
||||
} from "./javascriptSemanticMutationInitializers.js";
|
||||
import {
|
||||
builtinDataEffectMethod as builtinMethod,
|
||||
builtinDataEffectIdentity,
|
||||
dataEffectArgumentBindingId as argumentBindingId,
|
||||
dataEffectLiteralString as literalString,
|
||||
dataEffectMemberCallee as memberCallee,
|
||||
isSemanticProcessObject as isProcessObject,
|
||||
semanticProcessObjectIdentity,
|
||||
outerDataEffectBinding as outerAssignedBinding,
|
||||
assignedSemanticResultBindings,
|
||||
semanticProjectedBindingFacts,
|
||||
@@ -103,6 +103,9 @@ const collectConfigurations = (
|
||||
key: details.key,
|
||||
sourceConfigId: null,
|
||||
value: null,
|
||||
...(details.builtinAuthority === undefined
|
||||
? {}
|
||||
: { builtinAuthority: details.builtinAuthority }),
|
||||
resolution: details.resolution,
|
||||
};
|
||||
output.push({ node, operation });
|
||||
@@ -116,6 +119,7 @@ const configurationDetails = (
|
||||
): {
|
||||
readonly kind: "environment" | "argv" | "file";
|
||||
readonly key: string | null;
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly resolution: "complete" | "partial";
|
||||
} | null => {
|
||||
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
||||
@@ -124,19 +128,32 @@ const configurationDetails = (
|
||||
}
|
||||
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
|
||||
return null;
|
||||
const fsMethod = builtinMethod(node.callee, context.state, ["fs", "node:fs"]);
|
||||
const fs = builtinDataEffectIdentity(node.callee, context.state, [
|
||||
"fs",
|
||||
"node:fs",
|
||||
]);
|
||||
const fsMethod = fs?.path.length === 1 ? fs.path[0] : null;
|
||||
const promises =
|
||||
fsMethod === null
|
||||
? builtinDataEffectIdentity(node.callee, context.state, [
|
||||
"fs/promises",
|
||||
"node:fs/promises",
|
||||
])
|
||||
: null;
|
||||
const isFileRead =
|
||||
fsMethod === "readFile" ||
|
||||
fsMethod === "readFileSync" ||
|
||||
(fsMethod === null &&
|
||||
builtinMethod(node.callee, context.state, [
|
||||
"fs/promises",
|
||||
"node:fs/promises",
|
||||
]) === "readFile");
|
||||
promises?.path.length === 1 &&
|
||||
promises.path[0] === "readFile");
|
||||
return isFileRead
|
||||
? {
|
||||
kind: "file",
|
||||
key: literalString(node.arguments[0]),
|
||||
...((fsMethod === null ? promises : fs)?.builtinAuthority ===
|
||||
"candidate"
|
||||
? { builtinAuthority: "candidate" as const }
|
||||
: {}),
|
||||
resolution:
|
||||
literalString(node.arguments[0]) === null ? "partial" : "complete",
|
||||
}
|
||||
@@ -149,6 +166,7 @@ const processConfigurationMember = (
|
||||
): {
|
||||
readonly kind: "environment" | "argv";
|
||||
readonly key: string | null;
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly resolution: "complete" | "partial";
|
||||
} | null => {
|
||||
const object = node.object;
|
||||
@@ -159,10 +177,19 @@ const processConfigurationMember = (
|
||||
// `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;
|
||||
const processIdentity = semanticProcessObjectIdentity(
|
||||
object.object,
|
||||
state,
|
||||
family,
|
||||
key,
|
||||
);
|
||||
if (processIdentity === null) return null;
|
||||
return {
|
||||
kind: family === "env" ? "environment" : "argv",
|
||||
key,
|
||||
...(processIdentity.builtinAuthority === undefined
|
||||
? {}
|
||||
: { builtinAuthority: processIdentity.builtinAuthority }),
|
||||
resolution: key === null ? "partial" : "complete",
|
||||
};
|
||||
};
|
||||
@@ -203,6 +230,9 @@ const collectDefaults = (
|
||||
ownerCallableId: context.callableStack.at(-1) ?? null,
|
||||
resultBindingId: outerAssignedBinding(node, context),
|
||||
key: candidate.operation.key,
|
||||
...(candidate.operation.builtinAuthority === undefined
|
||||
? {}
|
||||
: { builtinAuthority: candidate.operation.builtinAuthority }),
|
||||
sourceConfigId: candidate.operation.configId,
|
||||
value: value.status === "literal" ? value.value : null,
|
||||
...(value.status === "unknown" && value.resourceLimit !== undefined
|
||||
@@ -226,8 +256,9 @@ const collectRequests = (
|
||||
!t.isNewExpression(node)
|
||||
)
|
||||
return;
|
||||
const method = requestMethod(node, context.state);
|
||||
if (method === null) return;
|
||||
const identity = requestMethod(node, context.state);
|
||||
if (identity === null) return;
|
||||
const method = identity.method;
|
||||
const endpoint = requestEndpoint(node);
|
||||
// `http.get`/`http.request` parse a string target with `new URL` before the
|
||||
// request exists, so a literal that is not an absolute URL throws
|
||||
@@ -246,6 +277,9 @@ const collectRequests = (
|
||||
requestId: `request:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
|
||||
kind: "request",
|
||||
method,
|
||||
...(identity.builtinAuthority === undefined
|
||||
? {}
|
||||
: { builtinAuthority: identity.builtinAuthority }),
|
||||
location: range(node),
|
||||
ownerCallableId: context.callableStack.at(-1) ?? null,
|
||||
resultBindingId: outerAssignedBinding(node, context),
|
||||
@@ -478,7 +512,10 @@ const boundaryDetails = (
|
||||
const requestMethod = (
|
||||
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticRequestOperation["method"] | null => {
|
||||
): {
|
||||
readonly method: JavaScriptSemanticRequestOperation["method"];
|
||||
readonly builtinAuthority?: "candidate";
|
||||
} | null => {
|
||||
if (t.isIdentifier(node.callee)) {
|
||||
const name = node.callee.name;
|
||||
const unbound =
|
||||
@@ -490,15 +527,23 @@ const requestMethod = (
|
||||
name === global &&
|
||||
isUnmodifiedGlobalReference(node.callee, state, name)
|
||||
)
|
||||
return global;
|
||||
return { method: global };
|
||||
}
|
||||
const method = builtinMethod(node.callee, state, [
|
||||
const identity = builtinDataEffectIdentity(node.callee, state, [
|
||||
"http",
|
||||
"https",
|
||||
"node:http",
|
||||
"node:https",
|
||||
]);
|
||||
return method === "request" || method === "get" ? method : null;
|
||||
const method = identity?.path.length === 1 ? identity.path[0] : null;
|
||||
return method === "request" || method === "get"
|
||||
? {
|
||||
method,
|
||||
...(identity?.builtinAuthority === undefined
|
||||
? {}
|
||||
: { builtinAuthority: identity.builtinAuthority }),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
const requestFields = (
|
||||
|
||||
@@ -83,10 +83,25 @@ export function* collectJavaScriptSemanticFingerprintsSteps(
|
||||
const networkEffects = networkEffectsByOwner.get(callable.callableId) ?? [];
|
||||
const resourceEffects =
|
||||
resourceEffectsByOwner.get(callable.callableId) ?? [];
|
||||
const conditionalBuiltinEffects =
|
||||
timerEffects.some(
|
||||
({ builtinAuthority }) => builtinAuthority === "candidate",
|
||||
) ||
|
||||
childProcessEffects.some(
|
||||
({ builtinAuthority }) => builtinAuthority === "candidate",
|
||||
) ||
|
||||
networkEffects.some(
|
||||
({ builtinAuthority }) => builtinAuthority === "candidate",
|
||||
) ||
|
||||
resourceEffects.some(
|
||||
({ builtinAuthority }) => builtinAuthority === "candidate",
|
||||
);
|
||||
fingerprints.push({
|
||||
callableId: callable.callableId,
|
||||
status:
|
||||
parserPartial || callable.returnCoverage.status !== "complete"
|
||||
parserPartial ||
|
||||
conditionalBuiltinEffects ||
|
||||
callable.returnCoverage.status !== "complete"
|
||||
? "partial"
|
||||
: "complete",
|
||||
components: {
|
||||
@@ -121,6 +136,11 @@ export function* collectJavaScriptSemanticFingerprintsSteps(
|
||||
...(parserPartial
|
||||
? ["Incomplete semantic recovery makes this fingerprint partial."]
|
||||
: []),
|
||||
...(conditionalBuiltinEffects
|
||||
? [
|
||||
"A built-in effect has conditional loader or mutation identity, so its fingerprint contribution is partial.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { JavaScriptModuleResolutionMode } from "./javascriptModuleResolutio
|
||||
|
||||
/** One exact module origin followed through imports, requires, or aliases. */
|
||||
export interface JavaScriptModuleOrigin {
|
||||
/** An unbound require whose CommonJS wrapper is not established. */
|
||||
readonly loaderAuthority?: "candidate";
|
||||
readonly resolutionMode?: JavaScriptModuleResolutionMode;
|
||||
readonly specifier: string;
|
||||
readonly importedPath: readonly string[];
|
||||
@@ -264,6 +266,7 @@ export interface JavaScriptSemanticEventOperation {
|
||||
|
||||
/** One timer scheduling or cancellation candidate. */
|
||||
export interface JavaScriptSemanticTimerOperation {
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly timerId: string;
|
||||
readonly kind: "schedule" | "cancel";
|
||||
readonly method:
|
||||
@@ -284,6 +287,7 @@ export interface JavaScriptSemanticTimerOperation {
|
||||
|
||||
/** One explicit asynchronous node:child_process creation candidate. */
|
||||
export interface JavaScriptSemanticChildProcessSpawn {
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly processId: string;
|
||||
readonly method: "spawn" | "exec" | "execFile" | "fork";
|
||||
readonly location: JavaScriptSourceRange;
|
||||
@@ -299,6 +303,7 @@ export interface JavaScriptSemanticChildProcessSpawn {
|
||||
|
||||
/** One exit/error listener or signal operation linked to child candidates. */
|
||||
export interface JavaScriptSemanticChildProcessInteraction {
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly interactionId: string;
|
||||
readonly kind: "listener" | "signal";
|
||||
readonly method: "on" | "once" | "addListener" | "kill";
|
||||
@@ -314,6 +319,7 @@ export interface JavaScriptSemanticChildProcessInteraction {
|
||||
|
||||
/** One environment, argv, file, or default configuration source. */
|
||||
export interface JavaScriptSemanticConfigurationOperation {
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly configId: string;
|
||||
readonly kind: "environment" | "argv" | "file" | "default";
|
||||
readonly location: JavaScriptSourceRange;
|
||||
@@ -328,6 +334,7 @@ export interface JavaScriptSemanticConfigurationOperation {
|
||||
|
||||
/** One request construction or response-consumer candidate. */
|
||||
export interface JavaScriptSemanticRequestOperation {
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly requestId: string;
|
||||
readonly kind: "request" | "response-consumer";
|
||||
readonly method:
|
||||
@@ -372,6 +379,7 @@ export interface JavaScriptSemanticBoundaryOperation {
|
||||
|
||||
/** One explicit built-in resource acquisition or release candidate. */
|
||||
export interface JavaScriptSemanticResourceOperation {
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly resourceId: string;
|
||||
readonly kind: "acquire" | "release";
|
||||
readonly method:
|
||||
@@ -418,6 +426,8 @@ export interface JavaScriptSemanticFrontier {
|
||||
|
||||
/** Static import/export relationship retained for cross-file composition. */
|
||||
export interface JavaScriptSemanticModuleLink {
|
||||
/** The literal request is observed, but its CommonJS wrapper is unknown. */
|
||||
readonly loaderAuthority?: "candidate";
|
||||
readonly resolutionMode?: JavaScriptModuleResolutionMode;
|
||||
readonly kind:
|
||||
| "import"
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
semanticOriginalGlobalProcessAt,
|
||||
semanticMutableBuiltinOrigin,
|
||||
semanticRequireExportsIdentity,
|
||||
semanticRequireOrigin,
|
||||
semanticRequireOriginCandidate,
|
||||
semanticRequireLoaderAt,
|
||||
hasSemanticCreateRequireFactoryOrigin,
|
||||
} from "./javascriptSemanticRequireOrigin.js";
|
||||
@@ -483,6 +483,7 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
path: PropertyPath,
|
||||
effect: ValueEffect,
|
||||
node: t.Node | undefined,
|
||||
loaderAuthority?: "candidate",
|
||||
): void => {
|
||||
const identity = semanticRequireExportsIdentity(specifier, path);
|
||||
let effectPath = identity.path;
|
||||
@@ -508,6 +509,7 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
"required",
|
||||
identity.specifier,
|
||||
effect,
|
||||
loaderAuthority,
|
||||
effectPath,
|
||||
node?.start,
|
||||
node?.end,
|
||||
@@ -516,7 +518,12 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
recordedRequiredEffects.add(key);
|
||||
recordedEffectCount += 1;
|
||||
const effects = state.requiredModuleEffects.get(identity.specifier) ?? [];
|
||||
effects.push({ path: effectPath, node, kind: effect });
|
||||
effects.push({
|
||||
path: effectPath,
|
||||
node,
|
||||
kind: effect,
|
||||
...(loaderAuthority === undefined ? {} : { loaderAuthority }),
|
||||
});
|
||||
state.requiredModuleEffects.set(identity.specifier, effects);
|
||||
};
|
||||
// Traversal states that differ only in coverage fields a record never
|
||||
@@ -866,13 +873,14 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
)
|
||||
continue;
|
||||
const expression = unwrapJavaScriptExpression(current.node).node;
|
||||
const required = semanticRequireOrigin(expression, state);
|
||||
const required = semanticRequireOriginCandidate(expression, state, true);
|
||||
if (required !== undefined) {
|
||||
recordRequiredEffect(
|
||||
required.origin.specifier,
|
||||
[...required.origin.importedPath, ...current.path],
|
||||
effect,
|
||||
mutation,
|
||||
required.origin.loaderAuthority,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -1156,6 +1164,7 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
shared.path,
|
||||
effect,
|
||||
mutation,
|
||||
origin.loaderAuthority,
|
||||
);
|
||||
}
|
||||
const receiverInitializers = [
|
||||
@@ -2108,7 +2117,7 @@ const observableMutationPaths = (
|
||||
// exact member paths instead of widening the first path to a wildcard.
|
||||
const hasParsedRequireOrigin = binding.initializers.some(
|
||||
({ node }) =>
|
||||
semanticRequireOrigin(node, state) !== undefined ||
|
||||
semanticRequireOriginCandidate(node, state) !== undefined ||
|
||||
hasSemanticCreateRequireFactoryOrigin(node, state),
|
||||
);
|
||||
if (
|
||||
|
||||
@@ -1983,8 +1983,8 @@ export const commonJsWrapperBindingDeclaration = (
|
||||
// The index owns explicit wrapper parameters, including ones with no source
|
||||
// redeclaration. Babel's module classification does not invalidate that fact.
|
||||
if (binding.hostOrigin === "commonjs-wrapper") return binding;
|
||||
// Without an explicit host fact, preserve the legacy script-var inference.
|
||||
return state.program.sourceType === "script" &&
|
||||
// A program var shares the wrapper binding only when the host is known.
|
||||
return javaScriptRuntimeContext(state.program) === "commonjs" &&
|
||||
binding.declarationKinds.size > 0
|
||||
? binding
|
||||
: undefined;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptSemanticScopeState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
|
||||
import { semanticRequireOriginCandidate } from "./javascriptSemanticRequireOrigin.js";
|
||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
import {
|
||||
completeSemanticSteps,
|
||||
@@ -428,7 +428,7 @@ const collectRequireLink = (
|
||||
node: t.VariableDeclarator,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
const required = semanticRequireOrigin(node.init, state);
|
||||
const required = semanticRequireOriginCandidate(node.init, state);
|
||||
if (required === undefined) return;
|
||||
const { origin, call } = required;
|
||||
const bindings = requirePatternBindings(node.id, origin.importedPath);
|
||||
@@ -437,6 +437,9 @@ const collectRequireLink = (
|
||||
state,
|
||||
{
|
||||
kind: "require",
|
||||
...(origin.loaderAuthority === "candidate"
|
||||
? { loaderAuthority: "candidate" as const }
|
||||
: {}),
|
||||
specifier: origin.specifier,
|
||||
importedName: binding.importedName,
|
||||
localName: binding.localName,
|
||||
@@ -453,12 +456,15 @@ const collectUnboundRequireLink = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
if (state.linkedRequireCalls.has(node)) return;
|
||||
const required = semanticRequireOrigin(node, state);
|
||||
const required = semanticRequireOriginCandidate(node, state);
|
||||
if (required === undefined) return;
|
||||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "require",
|
||||
...(required.origin.loaderAuthority === "candidate"
|
||||
? { loaderAuthority: "candidate" as const }
|
||||
: {}),
|
||||
specifier: required.origin.specifier,
|
||||
importedName: null,
|
||||
localName: null,
|
||||
@@ -515,6 +521,7 @@ const collectCommonJsExport = (
|
||||
exported.exportedName,
|
||||
applicability === "uncertain" ? null : exported.value,
|
||||
exported.projection,
|
||||
exported.loaderAuthority,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -525,13 +532,14 @@ const addCommonJsExport = (
|
||||
exportedName: string,
|
||||
exported: t.Node | null,
|
||||
projection?: JavaScriptSemanticPropertyPath,
|
||||
loaderAuthority?: "candidate",
|
||||
): void => {
|
||||
// Simple assignment chains yield their rightmost assigned value.
|
||||
let value = exported;
|
||||
while (t.isAssignmentExpression(value, { operator: "=" }))
|
||||
value = value.right;
|
||||
const required =
|
||||
value === null ? undefined : semanticRequireOrigin(value, state);
|
||||
value === null ? undefined : semanticRequireOriginCandidate(value, state);
|
||||
// Copies retain their module provenance, but do not export the source object
|
||||
// itself. A wildcard with exclusions cannot express one module link.
|
||||
const origin = projection?.some(
|
||||
@@ -545,6 +553,10 @@ const addCommonJsExport = (
|
||||
state,
|
||||
{
|
||||
kind: "commonjs-export",
|
||||
...(origin?.loaderAuthority === "candidate" ||
|
||||
loaderAuthority === "candidate"
|
||||
? { loaderAuthority: "candidate" as const }
|
||||
: {}),
|
||||
specifier: origin?.specifier ?? null,
|
||||
importedName:
|
||||
origin === undefined
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
import {
|
||||
analyzeJavaScriptSemantics,
|
||||
analyzeParsedJavaScriptSemantics,
|
||||
} from "./javascriptSemanticAnalysis.js";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
import {
|
||||
onlyBinding,
|
||||
topLevelBinding,
|
||||
@@ -46,7 +50,7 @@ describe("JavaScript semantic binding provenance", () => {
|
||||
});
|
||||
|
||||
it("recognizes explicit local values and preserves exact module aliases", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const source = `
|
||||
const object = { value: 1 };
|
||||
const array = [1, 2];
|
||||
const template = \`feature\`;
|
||||
@@ -60,7 +64,15 @@ describe("JavaScript semantic binding provenance", () => {
|
||||
const alias = imported;
|
||||
const sequence = (0, require("sequence-feature"));
|
||||
const assignment = (slot = require("assignment-feature"));
|
||||
`);
|
||||
`;
|
||||
const parsed = parseJavaScriptSource(source, "main.cjs");
|
||||
if (parsed === null) throw new Error("fixture did not parse");
|
||||
const ir = analyzeParsedJavaScriptSemantics(parsed);
|
||||
const candidate = analyzeJavaScriptSemantics(source);
|
||||
expect(topLevelBinding(candidate, "alias").provenance).toMatchObject({
|
||||
status: "ambiguous",
|
||||
origins: [{ specifier: "feature", loaderAuthority: "candidate" }],
|
||||
});
|
||||
|
||||
for (const name of [
|
||||
"object",
|
||||
|
||||
@@ -30,9 +30,16 @@ export const semanticOriginsProvenance = (
|
||||
): JavaScriptBindingProvenance => {
|
||||
const unique = uniqueSemanticOrigins(origins);
|
||||
const origin = unique[0];
|
||||
return unique.length === 1 && origin !== undefined
|
||||
return unique.length === 1 &&
|
||||
origin !== undefined &&
|
||||
origin.loaderAuthority !== "candidate"
|
||||
? { status: "module", origins: [origin], reason: null }
|
||||
: semanticAmbiguousProvenance(unique, "Multiple module origins.");
|
||||
: semanticAmbiguousProvenance(
|
||||
unique,
|
||||
unique.some(({ loaderAuthority }) => loaderAuthority === "candidate")
|
||||
? "CommonJS loader authority is unknown."
|
||||
: "Multiple module origins.",
|
||||
);
|
||||
};
|
||||
|
||||
/** Deduplicate and canonically order module origins. */
|
||||
@@ -52,6 +59,7 @@ const semanticOriginKey = (origin: JavaScriptModuleOrigin): string =>
|
||||
origin.specifier,
|
||||
origin.importedPath,
|
||||
origin.resolutionMode ?? "import",
|
||||
origin.loaderAuthority ?? "proven",
|
||||
]);
|
||||
|
||||
/** Canonically distinguish primitive values across type boundaries. */
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
import { analyzeParsedJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
const provenance = (source: string, name: string) => {
|
||||
const result = analyzeJavaScriptSemantics(source);
|
||||
const file = parseJavaScriptSource(source, "fixture.cjs");
|
||||
if (file === null) throw new Error("Expected valid CommonJS fixture");
|
||||
const result = analyzeParsedJavaScriptSemantics(file);
|
||||
const binding = result.bindings.find((candidate) => candidate.name === name);
|
||||
if (binding === undefined) throw new Error(`Missing binding ${name}`);
|
||||
return binding.provenance;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
semanticResolutionBlocked,
|
||||
semanticStableInitializer,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptBindingIndex,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
commonJsWrapperBindingDeclaration,
|
||||
@@ -173,7 +174,7 @@ const requireLoaderValueAt = (
|
||||
if (loader.name !== "require") return "local";
|
||||
if (binding === undefined) {
|
||||
if (
|
||||
javaScriptRuntimeContext(state.program) === "module" ||
|
||||
javaScriptRuntimeContext(state.program) !== "commonjs" ||
|
||||
semanticResolutionBlocked(state, loader, "require")
|
||||
)
|
||||
return "unknown";
|
||||
@@ -199,7 +200,10 @@ const requireLoaderValueAt = (
|
||||
t.isNewExpression(node)) &&
|
||||
isWrapperRequireInitializerCall(binding, node, state)
|
||||
)
|
||||
return commonJsLoaderAt(node, state);
|
||||
return binding.hostOrigin === "commonjs-wrapper" ||
|
||||
javaScriptRuntimeContext(state.program) === "commonjs"
|
||||
? commonJsLoaderAt(node, state)
|
||||
: "unknown";
|
||||
const wrapperBinding = commonJsWrapperBindingDeclaration(
|
||||
loader,
|
||||
"require",
|
||||
@@ -376,13 +380,14 @@ const selectedRequireSourcesAt = (
|
||||
):
|
||||
| Pick<
|
||||
JavaScriptRequireCallFact,
|
||||
"loader" | "sourceRelative" | "sourceRelativePath"
|
||||
"loader" | "sourceRelative" | "sourceRelativePath" | "loaderAuthority"
|
||||
>
|
||||
| undefined => {
|
||||
const pending: ModuleObjectSource[] = [{ node: root, path: [] }];
|
||||
const seen = new WeakMap<t.Node, Set<string>>();
|
||||
let count = 0;
|
||||
let found = false;
|
||||
let candidate = false;
|
||||
let loaderBinding = false;
|
||||
let selectedLoader: JavaScriptRequireLoaderFact | undefined;
|
||||
let unknown = false;
|
||||
@@ -432,6 +437,10 @@ const selectedRequireSourcesAt = (
|
||||
? commonJsLoaderAt(site, state)
|
||||
: captured;
|
||||
found = true;
|
||||
candidate ||=
|
||||
invoked === "unknown" &&
|
||||
t.isIdentifier(node, { name: "require" }) &&
|
||||
isConditionalRequireAt(node, node, state);
|
||||
unknown ||=
|
||||
invoked === "unknown" ||
|
||||
(selectedLoader !== undefined && selectedLoader !== invoked);
|
||||
@@ -655,6 +664,7 @@ const selectedRequireSourcesAt = (
|
||||
? (selectedLoader ?? "unknown")
|
||||
: "local",
|
||||
sourceRelative: found && sourceRelative && !unknown,
|
||||
...(candidate ? { loaderAuthority: "candidate" as const } : {}),
|
||||
...(found &&
|
||||
!unknown &&
|
||||
sourceRelative &&
|
||||
@@ -701,23 +711,41 @@ export interface JavaScriptRequireCallFact {
|
||||
| readonly t.CallExpression["arguments"][number][]
|
||||
| undefined;
|
||||
readonly sourceRelative: boolean;
|
||||
readonly loaderAuthority?: "candidate";
|
||||
readonly memberUnproven?: true;
|
||||
/** A createRequire filename relative to the current source directory, when proven. */
|
||||
readonly sourceRelativePath?: string;
|
||||
}
|
||||
|
||||
/** A child-property write does not replace the selected callable itself. */
|
||||
export const semanticSelectedMemberMayHaveChanged = (
|
||||
index: JavaScriptBindingIndex,
|
||||
site: t.Node,
|
||||
member: t.Node,
|
||||
): boolean => {
|
||||
const effects = index.effectsForReferenceAt(site, member);
|
||||
const targetDepth = semanticMemberChain(member)?.members.length;
|
||||
return (
|
||||
!effects.memberEffectsIndexed ||
|
||||
effects.effects.some(
|
||||
({ kind, timing, path }) =>
|
||||
timing !== "after" &&
|
||||
(kind === "escape" ||
|
||||
(kind === "member-write" &&
|
||||
(targetDepth === undefined ||
|
||||
path === undefined ||
|
||||
path.length <= targetDepth))),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/** Classify the selected loader and normalize invocation arguments once. */
|
||||
export const semanticRequireCallAt = (
|
||||
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
factoryBindingIds: ReadonlySet<string>,
|
||||
index?: JavaScriptBindingIndex,
|
||||
): JavaScriptRequireCallFact | undefined => {
|
||||
if (semanticModuleRequireCallAt(node, state))
|
||||
return {
|
||||
operation: "load",
|
||||
loader: "unknown",
|
||||
arguments: undefined,
|
||||
sourceRelative: false,
|
||||
};
|
||||
const callee = unwrapJavaScriptExpression(node.callee).node;
|
||||
if (
|
||||
(t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) &&
|
||||
@@ -733,6 +761,59 @@ export const semanticRequireCallAt = (
|
||||
arguments: undefined,
|
||||
sourceRelative: false,
|
||||
};
|
||||
if (index !== undefined) {
|
||||
let member = callee;
|
||||
while (
|
||||
t.isMemberExpression(member) ||
|
||||
t.isOptionalMemberExpression(member)
|
||||
) {
|
||||
const key = semanticStaticPropertyKey(member.property, member.computed);
|
||||
if (key === "call" || key === "apply" || key === "resolve") {
|
||||
const loader = requireLoaderValueAt(
|
||||
member.object,
|
||||
node,
|
||||
state,
|
||||
factoryBindingIds,
|
||||
);
|
||||
const selected =
|
||||
loader === "local"
|
||||
? selectedRequireSourcesAt(
|
||||
member.object,
|
||||
node,
|
||||
state,
|
||||
factoryBindingIds,
|
||||
)
|
||||
: undefined;
|
||||
if (
|
||||
loader === "local" &&
|
||||
(selected === undefined || selected.loader === "local")
|
||||
) {
|
||||
member = unwrapJavaScriptExpression(member.object).node;
|
||||
continue;
|
||||
}
|
||||
if (semanticSelectedMemberMayHaveChanged(index, node, member))
|
||||
return {
|
||||
operation: "load",
|
||||
loader: "unknown",
|
||||
// A changed call/apply member has no trustworthy argument mapping.
|
||||
arguments:
|
||||
member === callee && key === "resolve"
|
||||
? node.arguments
|
||||
: undefined,
|
||||
sourceRelative: false,
|
||||
memberUnproven: true,
|
||||
};
|
||||
}
|
||||
member = unwrapJavaScriptExpression(member.object).node;
|
||||
}
|
||||
}
|
||||
if (semanticModuleRequireCallAt(node, state))
|
||||
return {
|
||||
operation: "load",
|
||||
loader: "unknown",
|
||||
arguments: undefined,
|
||||
sourceRelative: false,
|
||||
};
|
||||
if (
|
||||
(t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) &&
|
||||
semanticStaticPropertyKey(callee.property, callee.computed) === "resolve"
|
||||
@@ -743,13 +824,19 @@ export const semanticRequireCallAt = (
|
||||
state,
|
||||
factoryBindingIds,
|
||||
);
|
||||
if (loader === "unknown")
|
||||
if (loader === "unknown") {
|
||||
const receiver = unwrapJavaScriptExpression(callee.object).node;
|
||||
const candidate =
|
||||
t.isIdentifier(receiver, { name: "require" }) &&
|
||||
isConditionalRequireAt(receiver, node, state);
|
||||
return {
|
||||
operation: "load",
|
||||
operation: "resolve",
|
||||
loader,
|
||||
arguments: undefined,
|
||||
arguments: candidate ? node.arguments : undefined,
|
||||
sourceRelative: false,
|
||||
...(candidate ? { loaderAuthority: "candidate" as const } : {}),
|
||||
};
|
||||
}
|
||||
// Bundler factory loaders can share the `require` shape, but their
|
||||
// `.resolve` member is not Node's module resolver.
|
||||
if (loader === "commonjs-loader" || loader === "create-require") {
|
||||
@@ -790,6 +877,9 @@ export const semanticRequireCallAt = (
|
||||
loader: selected?.loader ?? loader,
|
||||
arguments: node.arguments,
|
||||
sourceRelative: selected?.sourceRelative ?? loader !== "create-require",
|
||||
...(selected?.loaderAuthority === "candidate"
|
||||
? { loaderAuthority: "candidate" as const }
|
||||
: {}),
|
||||
...(selected?.sourceRelativePath === undefined
|
||||
? {}
|
||||
: { sourceRelativePath: selected.sourceRelativePath }),
|
||||
@@ -804,8 +894,19 @@ export const semanticRequireCallAt = (
|
||||
state,
|
||||
factoryBindingIds,
|
||||
);
|
||||
if (loader !== "local" && loader !== "create-require")
|
||||
return ["require-loader", loader, "relative"];
|
||||
if (loader !== "local" && loader !== "create-require") {
|
||||
const root = unwrapJavaScriptExpression(callee).node;
|
||||
const candidate =
|
||||
loader === "unknown" &&
|
||||
t.isIdentifier(root, { name: "require" }) &&
|
||||
isConditionalRequireAt(root, node, state);
|
||||
return [
|
||||
"require-loader",
|
||||
loader,
|
||||
"relative",
|
||||
...(candidate ? ["candidate"] : []),
|
||||
];
|
||||
}
|
||||
const selected = selectedRequireSourcesAt(
|
||||
callee,
|
||||
node,
|
||||
@@ -817,6 +918,7 @@ export const semanticRequireCallAt = (
|
||||
"require-loader",
|
||||
selected.loader,
|
||||
selected.sourceRelative ? "relative" : "unknown",
|
||||
...(selected.loaderAuthority === "candidate" ? ["candidate"] : []),
|
||||
];
|
||||
return interop.intrinsicPath(callee);
|
||||
});
|
||||
@@ -836,6 +938,9 @@ export const semanticRequireCallAt = (
|
||||
? "unknown"
|
||||
: (invocation.path[1] as JavaScriptRequireLoaderFact),
|
||||
arguments: invocation.arguments,
|
||||
...(invocation.kind === "known" && invocation.path[3] === "candidate"
|
||||
? { loaderAuthority: "candidate" as const }
|
||||
: {}),
|
||||
sourceRelative:
|
||||
invocation.kind === "known" && invocation.path[2] === "relative",
|
||||
...(selected?.sourceRelativePath === undefined
|
||||
@@ -1267,6 +1372,18 @@ const hasCommonJsModuleSource = (
|
||||
commonJsWrapperBindingDeclaration(value, "require", state) !==
|
||||
undefined &&
|
||||
!read.hasDefiniteInitializer;
|
||||
// A top-level `var require` may refer to a host wrapper on an unknown
|
||||
// runtime. Preserve a possible member-loader call without resolving it.
|
||||
if (
|
||||
includeUnknown &&
|
||||
mainModulePath &&
|
||||
javaScriptRuntimeContext(state.program) === "unknown" &&
|
||||
state.scopesById.get(binding.scopeId)?.kind === "program" &&
|
||||
binding.declarationKinds.size === 1 &&
|
||||
binding.declarationKinds.has("var") &&
|
||||
!read.hasDefiniteInitializer
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
value.name === "module" &&
|
||||
copyAdmitsModuleDelegate &&
|
||||
@@ -1549,40 +1666,89 @@ export const semanticGlobalProcessPathOrigin = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Check one unknown-host wrapper candidate at its source-ordered read. */
|
||||
const isConditionalRequireAt = (
|
||||
root: t.Identifier,
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
allowAmbientModule = false,
|
||||
): boolean => {
|
||||
const runtime = javaScriptRuntimeContext(state.program);
|
||||
if (
|
||||
(runtime !== "unknown" && !(allowAmbientModule && runtime === "module")) ||
|
||||
semanticResolutionBlocked(state, root, "require") ||
|
||||
commonJsDelegateChangedAt(node, state)
|
||||
)
|
||||
return false;
|
||||
const binding = resolveSemanticBindingState(state, root, "require");
|
||||
if (
|
||||
binding !== undefined &&
|
||||
(binding.hostOrigin === "commonjs-wrapper" ||
|
||||
state.scopesById.get(binding.scopeId)?.kind !== "program" ||
|
||||
binding.declarationKinds.size !== 1 ||
|
||||
!binding.declarationKinds.has("var") ||
|
||||
(semanticBindingHasPriorValueWrite(node, binding, state) &&
|
||||
!(
|
||||
(t.isCallExpression(node) ||
|
||||
t.isOptionalCallExpression(node) ||
|
||||
t.isNewExpression(node)) &&
|
||||
isWrapperRequireInitializerCall(binding, node, state)
|
||||
)))
|
||||
)
|
||||
return false;
|
||||
return !(state.ambientBindingWrites.get("require") ?? []).some(
|
||||
(write) =>
|
||||
!semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode),
|
||||
);
|
||||
};
|
||||
|
||||
/** Recover an unshadowed literal require origin and its exact member path. */
|
||||
export const semanticRequireOrigin = (
|
||||
export const semanticRequireOriginCandidate = (
|
||||
node: t.Node | null | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
allowAmbientModule = false,
|
||||
): JavaScriptRequireOrigin | undefined => {
|
||||
const chain = semanticMemberChain(node);
|
||||
const current = chain?.root;
|
||||
if (
|
||||
chain === undefined ||
|
||||
!t.isCallExpression(current) ||
|
||||
(!t.isCallExpression(current) && !t.isOptionalCallExpression(current)) ||
|
||||
!t.isIdentifier(current.callee, { name: "require" })
|
||||
)
|
||||
return undefined;
|
||||
if (commonJsDelegateChangedAt(current, state)) return undefined;
|
||||
const requireBinding = resolveSemanticBindingState(
|
||||
state,
|
||||
current.callee,
|
||||
"require",
|
||||
);
|
||||
const binding = resolveSemanticBindingState(state, current.callee, "require");
|
||||
const runtime = javaScriptRuntimeContext(state.program);
|
||||
const candidate =
|
||||
(runtime === "unknown" ||
|
||||
(allowAmbientModule && runtime === "module" && binding === undefined)) &&
|
||||
binding?.hostOrigin !== "commonjs-wrapper";
|
||||
if (
|
||||
(requireBinding !== undefined &&
|
||||
!isUnmodifiedCommonJsWrapperBinding(current.callee, "require", state) &&
|
||||
!isWrapperRequireInitializerCall(requireBinding, current, state)) ||
|
||||
(requireBinding === undefined &&
|
||||
(javaScriptRuntimeContext(state.program) === "module" ||
|
||||
semanticResolutionBlocked(state, current.callee, "require"))) ||
|
||||
(state.ambientBindingWrites.get("require") ?? []).some(
|
||||
(write) =>
|
||||
!semanticTopLevelEffectFollowsCapture(
|
||||
candidate
|
||||
? !isConditionalRequireAt(
|
||||
current.callee,
|
||||
current,
|
||||
write,
|
||||
state.parentsByNode,
|
||||
),
|
||||
)
|
||||
state,
|
||||
allowAmbientModule,
|
||||
)
|
||||
: commonJsDelegateChangedAt(current, state) ||
|
||||
(binding !== undefined &&
|
||||
!isUnmodifiedCommonJsWrapperBinding(
|
||||
current.callee,
|
||||
"require",
|
||||
state,
|
||||
) &&
|
||||
!isWrapperRequireInitializerCall(binding, current, state)) ||
|
||||
(binding === undefined &&
|
||||
(javaScriptRuntimeContext(state.program) === "module" ||
|
||||
semanticResolutionBlocked(state, current.callee, "require"))) ||
|
||||
(state.ambientBindingWrites.get("require") ?? []).some(
|
||||
(write) =>
|
||||
!semanticTopLevelEffectFollowsCapture(
|
||||
current,
|
||||
write,
|
||||
state.parentsByNode,
|
||||
),
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
const specifier = stringValue(current.arguments[0]);
|
||||
@@ -1593,11 +1759,21 @@ export const semanticRequireOrigin = (
|
||||
specifier,
|
||||
importedPath: chain.members,
|
||||
resolutionMode: "require",
|
||||
...(candidate ? { loaderAuthority: "candidate" as const } : {}),
|
||||
},
|
||||
call: current,
|
||||
};
|
||||
};
|
||||
|
||||
/** Only a proven host loader may establish Node builtin identity. */
|
||||
export const semanticRequireOrigin = (
|
||||
node: t.Node | null | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptRequireOrigin | undefined => {
|
||||
const result = semanticRequireOriginCandidate(node, state);
|
||||
return result?.origin.loaderAuthority === "candidate" ? undefined : result;
|
||||
};
|
||||
|
||||
/** Node's exported child objects retain their direct builtin-module identity. */
|
||||
export const semanticRequireExportsIdentity = <Key>(
|
||||
specifier: string,
|
||||
@@ -1656,4 +1832,5 @@ export const semanticMutableBuiltinOrigin = <Key = never>(
|
||||
};
|
||||
|
||||
semanticRequireOriginReaders.origin = semanticRequireOrigin;
|
||||
semanticRequireOriginReaders.candidate = semanticRequireOriginCandidate;
|
||||
semanticRequireOriginReaders.loader = semanticRequireLoaderAt;
|
||||
@@ -5,7 +5,7 @@ import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.
|
||||
/** An unshadowed literal require and the member path selected from its result. */
|
||||
export interface JavaScriptRequireOrigin {
|
||||
readonly origin: JavaScriptModuleOrigin;
|
||||
readonly call: t.CallExpression;
|
||||
readonly call: t.CallExpression | t.OptionalCallExpression;
|
||||
}
|
||||
|
||||
export type JavaScriptRequireLoaderFact =
|
||||
@@ -33,12 +33,19 @@ type RequireLoaderReader = (
|
||||
*/
|
||||
export const semanticRequireOriginReaders: {
|
||||
origin: RequireOriginReader | undefined;
|
||||
candidate: RequireOriginReader | undefined;
|
||||
loader: RequireLoaderReader | undefined;
|
||||
} = { origin: undefined, loader: undefined };
|
||||
} = { origin: undefined, candidate: undefined, loader: undefined };
|
||||
|
||||
export const readSemanticRequireOrigin: RequireOriginReader = (node, state) =>
|
||||
semanticRequireOriginReaders.origin?.(node, state);
|
||||
|
||||
/** Includes conditional requests whose unbound wrapper is not established. */
|
||||
export const readSemanticRequireOriginCandidate: RequireOriginReader = (
|
||||
node,
|
||||
state,
|
||||
) => semanticRequireOriginReaders.candidate?.(node, state);
|
||||
|
||||
export const readSemanticRequireLoader: RequireLoaderReader = (
|
||||
node,
|
||||
state,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type JavaScriptSemanticBindingState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
builtinDataEffectMethod,
|
||||
builtinDataEffectIdentity,
|
||||
dataEffectMemberCallee,
|
||||
outerDataEffectBinding,
|
||||
traverseDataEffects,
|
||||
@@ -45,6 +45,7 @@ const releaseMethodsFor = (
|
||||
};
|
||||
|
||||
interface ResourceCandidate {
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly node: t.CallExpression | t.OptionalCallExpression;
|
||||
readonly specifier: string;
|
||||
readonly method: JavaScriptSemanticResourceOperation["method"];
|
||||
@@ -339,6 +340,9 @@ const collectAcquisitions = (
|
||||
node,
|
||||
specifier: reference.specifier,
|
||||
method: reference.method,
|
||||
...(reference.builtinAuthority === undefined
|
||||
? {}
|
||||
: { builtinAuthority: reference.builtinAuthority }),
|
||||
ownerCallableId: context.callableStack.at(-1) ?? null,
|
||||
resultBindingId: outerDataEffectBinding(node, context),
|
||||
});
|
||||
@@ -391,6 +395,9 @@ const collectReleases = (
|
||||
ownerCallableId: context.callableStack.at(-1) ?? null,
|
||||
resultBindingId: binding.bindingId,
|
||||
linkedResourceIds: linked.map(resourceCandidateId).sort(),
|
||||
...(linked.some((value) => value.builtinAuthority === "candidate")
|
||||
? { builtinAuthority: "candidate" as const }
|
||||
: {}),
|
||||
resolution:
|
||||
linked.length === 1 &&
|
||||
holdsOnlyAcquisitions(binding, linked, context.state)
|
||||
@@ -440,6 +447,9 @@ const immutableAcquisition = (
|
||||
resourceId: resourceCandidateId(candidate),
|
||||
kind: "acquire",
|
||||
method: candidate.method,
|
||||
...(candidate.builtinAuthority === undefined
|
||||
? {}
|
||||
: { builtinAuthority: candidate.builtinAuthority }),
|
||||
location: range(candidate.node),
|
||||
ownerCallableId: candidate.ownerCallableId,
|
||||
resultBindingId: candidate.resultBindingId,
|
||||
@@ -448,6 +458,7 @@ const immutableAcquisition = (
|
||||
});
|
||||
|
||||
interface AcquisitionReference {
|
||||
readonly builtinAuthority?: "candidate";
|
||||
readonly specifier: string;
|
||||
readonly method: JavaScriptSemanticResourceOperation["method"];
|
||||
}
|
||||
@@ -457,7 +468,9 @@ const acquisitionMethod = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): AcquisitionReference | null => {
|
||||
for (const specifier of BUILTIN_RESOURCE_SPECIFIERS) {
|
||||
const method = builtinDataEffectMethod(callee, state, [specifier]);
|
||||
const identity = builtinDataEffectIdentity(callee, state, [specifier]);
|
||||
const method =
|
||||
identity?.path.length === 1 ? (identity.path[0] ?? null) : null;
|
||||
if (
|
||||
method !== null &&
|
||||
(
|
||||
@@ -468,6 +481,9 @@ const acquisitionMethod = (
|
||||
)
|
||||
return {
|
||||
specifier,
|
||||
...(identity?.builtinAuthority === undefined
|
||||
? {}
|
||||
: { builtinAuthority: identity.builtinAuthority }),
|
||||
method: method as JavaScriptSemanticResourceOperation["method"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface JavaScriptSemanticReferenceEffectsAtSite {
|
||||
|
||||
export interface JavaScriptSemanticRequiredModuleEffect extends JavaScriptSemanticMemberEffect {
|
||||
readonly kind: "write" | "escape";
|
||||
readonly loaderAuthority?: "candidate";
|
||||
}
|
||||
|
||||
export interface JavaScriptSemanticBindingState {
|
||||
@@ -192,7 +193,9 @@ export interface JavaScriptSemanticAnalysisState {
|
||||
readonly callableNodesById: Map<string, t.Node>;
|
||||
readonly moduleLinks: JavaScriptSemanticModuleLink[];
|
||||
/** Exact calls already represented by binding or dependency links. */
|
||||
readonly linkedRequireCalls: WeakSet<t.CallExpression>;
|
||||
readonly linkedRequireCalls: WeakSet<
|
||||
t.CallExpression | t.OptionalCallExpression
|
||||
>;
|
||||
readonly moduleLinkBindings: WeakMap<
|
||||
JavaScriptSemanticModuleLink,
|
||||
JavaScriptSemanticBindingState
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { readSemanticRequireOrigin } from "./javascriptSemanticRequireOriginSlot.js";
|
||||
import { readSemanticRequireOriginCandidate } from "./javascriptSemanticRequireOriginSlot.js";
|
||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
import {
|
||||
readExactJavaScriptLiteral,
|
||||
@@ -1230,17 +1230,18 @@ const provenanceForBindingInitializers = (
|
||||
} finally {
|
||||
context.bindings.delete(binding.bindingId);
|
||||
}
|
||||
if (resolved.status !== "module" || initializer.projection.length === 0)
|
||||
if (resolved.origins.length === 0 || initializer.projection.length === 0)
|
||||
return resolved;
|
||||
return semanticOriginsProvenance(
|
||||
resolved.origins.map((origin) => ({
|
||||
...origin,
|
||||
importedPath: [
|
||||
...origin.importedPath,
|
||||
...initializer.projection.map((segment) => String(segment)),
|
||||
],
|
||||
})),
|
||||
);
|
||||
const projected = resolved.origins.map((origin) => ({
|
||||
...origin,
|
||||
importedPath: [
|
||||
...origin.importedPath,
|
||||
...initializer.projection.map((segment) => String(segment)),
|
||||
],
|
||||
}));
|
||||
return resolved.status === "ambiguous"
|
||||
? semanticAmbiguousProvenance(projected, resolved.reason)
|
||||
: semanticOriginsProvenance(projected);
|
||||
};
|
||||
|
||||
const provenanceForExpression = (
|
||||
@@ -1273,7 +1274,7 @@ const provenanceForExpression = (
|
||||
expressionDepth: context.expressionDepth + unwrapped.depth,
|
||||
});
|
||||
}
|
||||
const required = readSemanticRequireOrigin(node, context.state);
|
||||
const required = readSemanticRequireOriginCandidate(node, context.state);
|
||||
if (required !== undefined)
|
||||
return semanticOriginsProvenance([required.origin]);
|
||||
if (t.isSequenceExpression(node)) {
|
||||
@@ -1319,14 +1320,14 @@ const provenanceForExpression = (
|
||||
}
|
||||
const base = provenanceForExpression(current, nestedContext(context));
|
||||
const path = members.reverse();
|
||||
return base.status !== "module"
|
||||
? base
|
||||
: semanticOriginsProvenance(
|
||||
base.origins.map((origin) => ({
|
||||
...origin,
|
||||
importedPath: [...origin.importedPath, ...path],
|
||||
})),
|
||||
);
|
||||
if (base.origins.length === 0) return base;
|
||||
const projected = base.origins.map((origin) => ({
|
||||
...origin,
|
||||
importedPath: [...origin.importedPath, ...path],
|
||||
}));
|
||||
return base.status === "ambiguous"
|
||||
? semanticAmbiguousProvenance(projected, base.reason)
|
||||
: semanticOriginsProvenance(projected);
|
||||
}
|
||||
if (t.isConditionalExpression(node) || t.isLogicalExpression(node)) {
|
||||
const candidates = semanticSelectedBranches(node, context).map((branch) =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, it } from "vitest";
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js";
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
|
||||
it.each([
|
||||
'var require;require("./before.cjs");',
|
||||
@@ -45,7 +46,7 @@ it.each([
|
||||
"({require}={require:()=>({BrowserWindow:class{}})});",
|
||||
"[require]=[()=>({BrowserWindow:class{}})];",
|
||||
])("preserves an Electron capture before the projected write: %s", (write) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(
|
||||
const analysis = analyzeCommonJsStaticFixture(
|
||||
`var require;const {BrowserWindow}=require("electron");${write}new BrowserWindow({webPreferences:{preload:"./before.cjs"}});`,
|
||||
);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
@@ -65,7 +66,7 @@ it.each([
|
||||
])(
|
||||
"distinguishes definite and conditional wrapper replacement: %s",
|
||||
(write, unknown) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(
|
||||
const analysis = analyzeCommonJsStaticFixture(
|
||||
`var module;${write}module.require("./unused.cjs");`,
|
||||
);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
|
||||
@@ -120,9 +120,17 @@ ipcMain.handle("layout:ready",()=>pick);`;
|
||||
for (const path of ["main.js", "main.cjs", "main.mjs", "main.jsx"]) {
|
||||
const result = analyze(source, path);
|
||||
expect(result?.errors).toBe(0);
|
||||
expect(result?.facts.electron.ipc).toContainEqual(
|
||||
expect.objectContaining({ channel: "layout:ready", operation: "handle" }),
|
||||
);
|
||||
if (path === "main.mjs") {
|
||||
expect(result?.facts.electron.ipc).toEqual([]);
|
||||
} else {
|
||||
expect(result?.facts.electron.ipc).toContainEqual(
|
||||
expect.objectContaining({
|
||||
channel: "layout:ready",
|
||||
operation: "handle",
|
||||
...(path === "main.cjs" ? {} : { loader_authority: "candidate" }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { posix } from "node:path";
|
||||
import { parse, type ParserOptions, type ParserPlugin } from "@babel/parser";
|
||||
import * as t from "@babel/types";
|
||||
import { javaScriptDefinitionExpression } from "./javascriptAstValues.js";
|
||||
import {
|
||||
explicitJavaScriptLoaderMode,
|
||||
hasCaseVariantJavaScriptLoaderSuffix,
|
||||
} from "./javascriptModuleResolutionMode.js";
|
||||
import {
|
||||
hasOnlyInlineTypeExportSpecifiers,
|
||||
hasOnlyInlineTypeImportSpecifiers,
|
||||
@@ -78,27 +83,31 @@ const parseWithRuntimeContext = (
|
||||
packageContext?: JavaScriptPackageType,
|
||||
): ParsedJavaScriptSource => {
|
||||
const file = parse(source, options);
|
||||
const filenameMode = explicitJavaScriptLoaderMode(sourcePath);
|
||||
runtimeContextByProgram.set(
|
||||
file.program,
|
||||
/\.c[jt]s$/iu.test(sourcePath ?? "")
|
||||
? /\.d\.cts$/iu.test(sourcePath ?? "")
|
||||
filenameMode === "commonjs"
|
||||
? sourcePath?.toLowerCase().endsWith(".d.cts")
|
||||
? "unknown"
|
||||
: "commonjs"
|
||||
: isExplicitModuleSourcePath(sourcePath)
|
||||
: filenameMode === "module"
|
||||
? "module"
|
||||
: packageContext === "module" || packageContext === "commonjs"
|
||||
? packageContext
|
||||
: packageContext === "unspecified" && /\.js$/iu.test(sourcePath ?? "")
|
||||
: packageContext === "unspecified" &&
|
||||
posix.extname(sourcePath ?? "") === ".js"
|
||||
? file.program.sourceType === "module"
|
||||
? "module"
|
||||
: "commonjs"
|
||||
: file.program.body.some((node) => t.isTSExportAssignment(node))
|
||||
? // CommonJS isolation does not establish strict ESM semantics.
|
||||
"commonjs"
|
||||
: file.program.sourceType === "module" &&
|
||||
hasRuntimeModuleDeclaration(file.program)
|
||||
? "module"
|
||||
: "unknown",
|
||||
: hasCaseVariantJavaScriptLoaderSuffix(sourcePath)
|
||||
? "unknown"
|
||||
: file.program.body.some((node) => t.isTSExportAssignment(node))
|
||||
? // CommonJS isolation does not establish strict ESM semantics.
|
||||
"commonjs"
|
||||
: file.program.sourceType === "module" &&
|
||||
hasRuntimeModuleDeclaration(file.program)
|
||||
? "module"
|
||||
: "unknown",
|
||||
);
|
||||
return file;
|
||||
};
|
||||
@@ -141,16 +150,12 @@ export const parserPluginsForPath = (sourcePath?: string): ParserPlugin[] => {
|
||||
return jsxCapable ? [...syntax, "jsx", typescript] : [...syntax, typescript];
|
||||
};
|
||||
|
||||
/** `.mjs` and `.mts` explicitly select module mode. */
|
||||
export const isExplicitModuleSourcePath = (path?: string): boolean =>
|
||||
/\.m[jt]s$/iu.test(path ?? "");
|
||||
|
||||
const runtimeSourceTypeForPath = (
|
||||
path?: string,
|
||||
): "commonjs" | "module" | "unambiguous" =>
|
||||
/\.cjs$/iu.test(path ?? "")
|
||||
explicitJavaScriptLoaderMode(path) === "commonjs" && path?.endsWith(".cjs")
|
||||
? "commonjs"
|
||||
: isExplicitModuleSourcePath(path)
|
||||
: explicitJavaScriptLoaderMode(path) === "module"
|
||||
? "module"
|
||||
: "unambiguous";
|
||||
|
||||
@@ -162,7 +167,7 @@ const packageContextForPath = (
|
||||
sourcePath: string | undefined,
|
||||
observedPackageType: JavaScriptPackageType | undefined,
|
||||
): JavaScriptPackageType | undefined =>
|
||||
/\.(?:js|ts)$/iu.test(sourcePath ?? "") &&
|
||||
[".js", ".ts"].includes(posix.extname(sourcePath ?? "")) &&
|
||||
!/\.d\.ts$/iu.test(sourcePath ?? "")
|
||||
? observedPackageType
|
||||
: undefined;
|
||||
@@ -193,7 +198,8 @@ export const parseRecoveredJavaScript = (
|
||||
? filenameType
|
||||
: packageContext === "module"
|
||||
? "module"
|
||||
: packageContext === "commonjs" && /\.js$/iu.test(sourcePath ?? "")
|
||||
: packageContext === "commonjs" &&
|
||||
posix.extname(sourcePath ?? "") === ".js"
|
||||
? "commonjs"
|
||||
: "unambiguous";
|
||||
if (sourceType !== "unambiguous") {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
isJsonModuleTarget,
|
||||
isNativeAddonModuleTarget,
|
||||
moduleResolutionModeForJavaScriptReference,
|
||||
} from "./javascriptModuleResolutionMode.js";
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import {
|
||||
@@ -21,18 +26,54 @@ import {
|
||||
parserPluginsForPath,
|
||||
} from "./javascriptSourceParser.js";
|
||||
|
||||
it("does not treat suffix-only dotfiles as JSON or native addon loaders", () => {
|
||||
expect(isJsonModuleTarget(".json", "json")).toBe(false);
|
||||
expect(isNativeAddonModuleTarget(".node", "native-addon")).toBe(false);
|
||||
expect(isJsonModuleTarget("value.json", "json")).toBe(true);
|
||||
expect(isNativeAddonModuleTarget("value.node", "native-addon")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["entry.cjs", "require"],
|
||||
["entry.cts", "require"],
|
||||
["entry.CJS", "import"],
|
||||
["entry.CTS", "import"],
|
||||
["entry.TS", "compiler-dependent"],
|
||||
] as const)(
|
||||
"uses exact loader evidence for static imports from %s",
|
||||
(path, expected) => {
|
||||
expect(
|
||||
moduleResolutionModeForJavaScriptReference(path, "static-import"),
|
||||
).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[".js", "module", "unknown"],
|
||||
[".ts", "commonjs", "unknown"],
|
||||
[".mjs", "commonjs", "unknown"],
|
||||
[".cjs", "module", "unknown"],
|
||||
["entry.cjs", "module", "commonjs"],
|
||||
["entry.cts", "module", "commonjs"],
|
||||
["entry.mjs", "commonjs", "module"],
|
||||
["entry.mts", "commonjs", "module"],
|
||||
["entry.MJS", "commonjs", "unknown"],
|
||||
["entry.CJS", "module", "unknown"],
|
||||
["entry.MTS", "commonjs", "unknown"],
|
||||
["entry.CTS", "module", "unknown"],
|
||||
["entry.d.cts", "module", "unknown"],
|
||||
["entry.D.cts", "module", "unknown"],
|
||||
["entry.d.ts", "commonjs", "unknown"],
|
||||
["entry.d.mts", "commonjs", "module"],
|
||||
["entry.jsx", "commonjs", "unknown"],
|
||||
["entry.tsx", "commonjs", "unknown"],
|
||||
["entry.js", "module", "module"],
|
||||
["entry.ts", "commonjs", "commonjs"],
|
||||
["entry.JS", "module", "unknown"],
|
||||
["entry.JS", "commonjs", "unknown"],
|
||||
["entry.JS", "unspecified", "unknown"],
|
||||
["entry.TS", "module", "unknown"],
|
||||
["entry.TS", "commonjs", "unknown"],
|
||||
["entry.js", "unspecified", "commonjs"],
|
||||
["entry.ts", "unspecified", "unknown"],
|
||||
["entry.js", "unknown", "unknown"],
|
||||
@@ -64,6 +105,34 @@ it("does not derive factory authority from an ambient require in a proven module
|
||||
expect([...referenceSourceRequireCalls(file, "entry.js")]).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"entry.JS",
|
||||
"entry.MJS",
|
||||
"entry.CJS",
|
||||
"entry.TS",
|
||||
"entry.MTS",
|
||||
"entry.CTS",
|
||||
".MJS",
|
||||
])("retains unknown host authority for mixed module syntax in %s", (path) => {
|
||||
const { file } = parseRecoveredJavaScript(
|
||||
'import {join} from "node:path"; require("./dep.cjs");',
|
||||
path,
|
||||
{ plugins: parserPluginsForPath(path) },
|
||||
"module",
|
||||
);
|
||||
expect(javaScriptRuntimeContext(file.program)).toBe("unknown");
|
||||
expect(analyzeParsedJavaScriptSemantics(file).moduleLinks).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "import", specifier: "node:path" }),
|
||||
expect.objectContaining({
|
||||
kind: "require",
|
||||
specifier: "./dep.cjs",
|
||||
loaderAuthority: "candidate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["without source redeclarations", "", "", true],
|
||||
["with a var redeclaration", "var require;\n", "", true],
|
||||
@@ -112,6 +181,86 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["unbound", 'require("./dep.cjs");'],
|
||||
["var redeclaration", 'var require; require("./dep.cjs");'],
|
||||
["var initializer", 'var require; require = require("./dep.cjs");'],
|
||||
] as const)(
|
||||
"does not infer a CommonJS wrapper from %s in unknown runtime context",
|
||||
(_name, source) => {
|
||||
const { file } = parseRecoveredJavaScript(
|
||||
source,
|
||||
"entry.MJS",
|
||||
{},
|
||||
"commonjs",
|
||||
);
|
||||
expect(javaScriptRuntimeContext(file.program)).toBe("unknown");
|
||||
const links = analyzeParsedJavaScriptSemantics(file).moduleLinks;
|
||||
expect(links.filter((link) => link.specifier === "./dep.cjs")).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "require",
|
||||
loaderAuthority: "candidate",
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["module.exports = 1;", "default"],
|
||||
["var module; module.exports = 1;", "default"],
|
||||
["var exports; exports.value = 1;", "value"],
|
||||
] as const)("retains unknown-host export candidates for %s", (source, name) => {
|
||||
const { file } = parseRecoveredJavaScript(
|
||||
source,
|
||||
"entry.MJS",
|
||||
{},
|
||||
"commonjs",
|
||||
);
|
||||
expect(analyzeParsedJavaScriptSemantics(file).moduleLinks).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "commonjs-export",
|
||||
exportedName: name,
|
||||
loaderAuthority: "candidate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not claim an unbound module export in a known ES module", () => {
|
||||
const { file } = parseRecoveredJavaScript(
|
||||
"module.exports = 1;",
|
||||
"entry.mjs",
|
||||
{},
|
||||
);
|
||||
expect(analyzeParsedJavaScriptSemantics(file).moduleLinks).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains conditional require provenance through aliases without claiming a host loader", () => {
|
||||
const source =
|
||||
'const { ipcMain: bus } = require("electron"); const alias = bus;';
|
||||
const { file } = parseRecoveredJavaScript(
|
||||
source,
|
||||
"entry.MJS",
|
||||
{},
|
||||
"commonjs",
|
||||
);
|
||||
const ir = analyzeParsedJavaScriptSemantics(file);
|
||||
for (const name of ["bus", "alias"]) {
|
||||
const binding = ir.bindings.find((item) => item.name === name);
|
||||
expect(binding?.provenance).toMatchObject({
|
||||
status: "ambiguous",
|
||||
origins: [
|
||||
{
|
||||
specifier: "electron",
|
||||
importedPath: ["ipcMain"],
|
||||
loaderAuthority: "candidate",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("detects JavaScript module syntax within an observed unspecified package", () => {
|
||||
const { file } = parseRecoveredJavaScript(
|
||||
"void import.meta.url;",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
it.each([
|
||||
@@ -36,7 +37,6 @@ it.each([
|
||||
'import Database from "better-sqlite3"; new Database("known.db");',
|
||||
'import Database = require("better-sqlite3"); new Database("known.db");',
|
||||
'import sqlite = require("sqlite3"); new sqlite.Database("known.db");',
|
||||
'const Database = require("better-sqlite3"); new Database("known.db");',
|
||||
'import {Database} from "sqlite3"; new Database("known.db");',
|
||||
'import * as sqlite from "sqlite3"; new sqlite.Database("known.db");',
|
||||
])("retains proven SQLite API origins: %s", (source) => {
|
||||
@@ -48,3 +48,15 @@ it.each([
|
||||
});
|
||||
expect(analysis.storage[0]).not.toHaveProperty("interpretation");
|
||||
});
|
||||
|
||||
it("distinguishes conditional and proven CommonJS SQLite origins", () => {
|
||||
const source =
|
||||
'const Database = require("better-sqlite3"); new Database("known.db");';
|
||||
expect(analyzeJavaScriptStaticSource(source).storage).toMatchObject([
|
||||
{ kind: "sqlite", name: "known.db", interpretation: "candidate" },
|
||||
]);
|
||||
const proven = analyzeCommonJsStaticFixture(source).storage;
|
||||
expect(proven).toHaveLength(1);
|
||||
expect(proven[0]).toMatchObject({ kind: "sqlite", name: "known.db" });
|
||||
expect(proven[0]).not.toHaveProperty("interpretation");
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
|
||||
import {
|
||||
isElectronRendererLoad,
|
||||
electronRendererLoadAuthority,
|
||||
resolveElectronBrowserWindowReceiver,
|
||||
} from "./electronStaticRenderer.js";
|
||||
import { finishElectronNativeBindingsSteps } from "./electronStaticAnalysisNative.js";
|
||||
@@ -121,7 +121,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
|
||||
browserWindowReceiver: (node, site, suffix) =>
|
||||
resolveElectronBrowserWindowReceiver(node, site, suffix, findings, index),
|
||||
rendererLoad: (callee, site) =>
|
||||
isElectronRendererLoad(callee, site, findings, index),
|
||||
electronRendererLoadAuthority(callee, site, findings, index),
|
||||
source,
|
||||
accumulator,
|
||||
vitePreloadDependencyTable: indexVitePreloadDependencyTable(
|
||||
@@ -130,9 +130,12 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
|
||||
),
|
||||
openReceiverFacts: receivers.openReceivers,
|
||||
electronBindings: electronIdentity.bindings,
|
||||
electronCandidateBindings: electronIdentity.candidateBindings,
|
||||
electronExportIntact: electronIdentity.intactExport,
|
||||
electronInvocationPath: electronIdentity.invocationPath,
|
||||
electronAppPathChangePossible: electronIdentity.mayChangeAppPath,
|
||||
electronCandidateAppPathChangePossible:
|
||||
electronIdentity.candidateMayChangeAppPath,
|
||||
rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index),
|
||||
expressionShapes: createSemanticExpressionShapeResolver(bindings),
|
||||
};
|
||||
@@ -211,6 +214,7 @@ const finalizeStaticAnalysis = (
|
||||
value.operation === "load" &&
|
||||
ELECTRON_REFERENCE_KINDS.has(value.kind) &&
|
||||
ELECTRON_MODULE.test(value.specifier) &&
|
||||
value.loader_authority !== "candidate" &&
|
||||
// A `factory-loader` reference names a recovered bundle module, so its
|
||||
// specifier is a module key inside the artifact rather than a package. A
|
||||
// real external load in the same file keeps its `commonjs-loader`
|
||||
@@ -218,6 +222,14 @@ const finalizeStaticAnalysis = (
|
||||
value.loader !== "factory-loader",
|
||||
);
|
||||
const resourceLimits = [...bindings.valueResourceLimits].sort();
|
||||
const candidateElectron = [
|
||||
...accumulator.browserWindows,
|
||||
...accumulator.contextBridgeApis,
|
||||
...accumulator.ipc,
|
||||
...accumulator.utilityProcesses,
|
||||
...accumulator.protocols,
|
||||
...accumulator.appPathChanges,
|
||||
].some(({ value }) => value.loader_authority === "candidate");
|
||||
const coverage: JavaScriptStaticCoverage = {
|
||||
syntax:
|
||||
parserErrors === 0
|
||||
@@ -282,6 +294,11 @@ const finalizeStaticAnalysis = (
|
||||
]
|
||||
: []),
|
||||
...(electronObserved ? [ELECTRON_IDENTITY_LIMITATION] : []),
|
||||
...(candidateElectron
|
||||
? [
|
||||
"Some Electron syntax depends on an unproved CommonJS loader; its Electron identity remains conditional.",
|
||||
]
|
||||
: []),
|
||||
"JavaScript syntax was parsed as data and was never evaluated.",
|
||||
];
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js";
|
||||
|
||||
const expressionWrappers = [
|
||||
@@ -408,7 +409,7 @@ it("retains endpoint observations for exact template and TypeScript member keys"
|
||||
|
||||
it("preserves preload and utility paths through exact computed join members", () => {
|
||||
for (const key of ['"join"', "`join`", '"join" as const']) {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const analysis = analyzeCommonJsStaticFixture(`
|
||||
const { BrowserWindow, utilityProcess } = require("electron");
|
||||
const path = require("node:path");
|
||||
new BrowserWindow({ webPreferences: {
|
||||
@@ -491,7 +492,7 @@ it("keeps dynamic, interpolated, and invalid template keys unresolved", () => {
|
||||
]) {
|
||||
const preload = `path[${key}](__dirname, "preload.js")`;
|
||||
const worker = `path[${key}](__dirname, "worker.js")`;
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const analysis = analyzeCommonJsStaticFixture(`
|
||||
const { BrowserWindow, utilityProcess } = require("electron");
|
||||
const path = require("node:path");
|
||||
client[${key}]("/api/items");
|
||||
|
||||
@@ -86,10 +86,26 @@ export const inspectCall = (
|
||||
bindings,
|
||||
accumulator.factoryRequireBindingIds,
|
||||
) !== "local";
|
||||
const unwrappedCallee = unwrapJavaScriptExpression(node.callee).node;
|
||||
const invocationMember =
|
||||
(t.isMemberExpression(unwrappedCallee) ||
|
||||
t.isOptionalMemberExpression(unwrappedCallee)) &&
|
||||
["call", "apply"].includes(
|
||||
semanticStaticPropertyKey(
|
||||
unwrappedCallee.property,
|
||||
unwrappedCallee.computed,
|
||||
) ?? "",
|
||||
);
|
||||
const deferredModuleRequire =
|
||||
semanticModuleRequireReceiver(node) !== undefined ||
|
||||
semanticModuleRequireCallAt(node, bindings, false) ||
|
||||
deferredRequireResolve;
|
||||
deferredRequireResolve ||
|
||||
(invocationMember &&
|
||||
semanticRequireCallAt(
|
||||
node,
|
||||
bindings,
|
||||
accumulator.factoryRequireBindingIds,
|
||||
) !== undefined);
|
||||
if (deferredModuleRequire) accumulator.pendingModuleRequireCalls.push(node);
|
||||
const requireCall = deferredModuleRequire
|
||||
? null
|
||||
@@ -106,6 +122,9 @@ export const inspectCall = (
|
||||
...(requireCall.loader === undefined
|
||||
? {}
|
||||
: { loader: requireCall.loader }),
|
||||
...(requireCall.loaderAuthority === undefined
|
||||
? {}
|
||||
: { loaderAuthority: requireCall.loaderAuthority }),
|
||||
});
|
||||
const chunkLoad = chunkLoadSpecifier(node, bindings, context);
|
||||
if (chunkLoad !== null)
|
||||
@@ -160,9 +179,16 @@ export const inspectCall = (
|
||||
});
|
||||
inspectEndpointCall(node, name, first, context, bindings);
|
||||
inspectStorageCall(node, name, first, context, bindings);
|
||||
const renderer =
|
||||
name === "loadFile" ||
|
||||
name.endsWith(".loadFile") ||
|
||||
name === "loadURL" ||
|
||||
name.endsWith(".loadURL")
|
||||
? context.rendererLoad?.(node.callee, node)
|
||||
: undefined;
|
||||
if (
|
||||
(name === "loadFile" || name.endsWith(".loadFile")) &&
|
||||
context.rendererLoad?.(node.callee, node) === true
|
||||
renderer !== undefined
|
||||
) {
|
||||
const argument = argumentNode(node.arguments[0]);
|
||||
const filePath =
|
||||
@@ -185,6 +211,7 @@ export const inspectCall = (
|
||||
: {}),
|
||||
resolution_context: "filesystem-expression",
|
||||
mechanism: `call:${name}`,
|
||||
...renderer,
|
||||
module_key: null,
|
||||
location: range(node),
|
||||
},
|
||||
@@ -192,7 +219,7 @@ export const inspectCall = (
|
||||
}
|
||||
if (
|
||||
(name === "loadURL" || name.endsWith(".loadURL")) &&
|
||||
context.rendererLoad?.(node.callee, node) === true
|
||||
renderer !== undefined
|
||||
) {
|
||||
if (first === undefined) accumulator.unknownFindings += 1;
|
||||
else
|
||||
@@ -205,6 +232,7 @@ export const inspectCall = (
|
||||
path: first,
|
||||
resolution_context: "module-specifier",
|
||||
mechanism: `call:${name}`,
|
||||
...renderer,
|
||||
module_key: null,
|
||||
location: range(node),
|
||||
},
|
||||
@@ -279,28 +307,33 @@ const requireCallSpecifier = (
|
||||
node: JavaScriptCallLike,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
context: FindingContext,
|
||||
resolveMemberChanged = false,
|
||||
index?: JavaScriptBindingIndex,
|
||||
): {
|
||||
readonly specifier: string | undefined;
|
||||
readonly operation: "load" | "resolve";
|
||||
readonly resolutionBasePath?: string;
|
||||
readonly loader?: "commonjs-loader" | "factory-loader" | "create-require";
|
||||
readonly loaderAuthority?: "candidate";
|
||||
} | null => {
|
||||
const call = semanticRequireCallAt(
|
||||
node,
|
||||
bindings,
|
||||
context.accumulator.factoryRequireBindingIds,
|
||||
index,
|
||||
);
|
||||
if (call === undefined || call.loader === "local") return null;
|
||||
if (
|
||||
resolveMemberChanged ||
|
||||
call.loader === "unknown" ||
|
||||
!call.sourceRelative
|
||||
) {
|
||||
if (call.loader === "unknown" || !call.sourceRelative) {
|
||||
context.accumulator.unknownFindings += 1;
|
||||
const candidate =
|
||||
!call.memberUnproven && call.loaderAuthority === "candidate"
|
||||
? (argumentValue(call.arguments?.[0]) ?? undefined)
|
||||
: undefined;
|
||||
return {
|
||||
specifier: undefined,
|
||||
operation: resolveMemberChanged ? "load" : call.operation,
|
||||
specifier: candidate,
|
||||
operation: call.operation,
|
||||
...(candidate === undefined
|
||||
? {}
|
||||
: { loaderAuthority: "candidate" as const }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -325,23 +358,7 @@ export const finishModuleRequireCalls = (
|
||||
index: JavaScriptBindingIndex,
|
||||
): void => {
|
||||
for (const node of context.accumulator.pendingModuleRequireCalls) {
|
||||
const resolveMemberChanged =
|
||||
isRequireResolveCall(node) &&
|
||||
(t.isMemberExpression(node.callee) ||
|
||||
t.isOptionalMemberExpression(node.callee)) &&
|
||||
index
|
||||
.effectsForReferenceAt(node, node.callee.object, ["resolve"])
|
||||
.effects.some(
|
||||
({ kind, timing }) =>
|
||||
timing !== "after" &&
|
||||
(kind === "member-write" || kind === "escape"),
|
||||
);
|
||||
const call = requireCallSpecifier(
|
||||
node,
|
||||
index.bindings,
|
||||
context,
|
||||
resolveMemberChanged,
|
||||
);
|
||||
const call = requireCallSpecifier(node, index.bindings, context, index);
|
||||
if (call !== null)
|
||||
addReference(context, {
|
||||
node,
|
||||
@@ -355,6 +372,9 @@ export const finishModuleRequireCalls = (
|
||||
// still names a bundle module key, not a package. Dropping the loader here
|
||||
// would read that key as an external specifier.
|
||||
...(call.loader === undefined ? {} : { loader: call.loader }),
|
||||
...(call.loaderAuthority === undefined
|
||||
? {}
|
||||
: { loaderAuthority: call.loaderAuthority }),
|
||||
});
|
||||
}
|
||||
context.accumulator.pendingModuleRequireCalls.length = 0;
|
||||
@@ -363,14 +383,14 @@ export const finishModuleRequireCalls = (
|
||||
);
|
||||
};
|
||||
|
||||
const isRequireResolveCall = (
|
||||
node: JavaScriptCallLike,
|
||||
): node is t.CallExpression | t.OptionalCallExpression =>
|
||||
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
|
||||
(t.isMemberExpression(node.callee) ||
|
||||
t.isOptionalMemberExpression(node.callee)) &&
|
||||
semanticStaticPropertyKey(node.callee.property, node.callee.computed) ===
|
||||
"resolve";
|
||||
const isRequireResolveCall = (node: JavaScriptCallLike): boolean => {
|
||||
if (t.isNewExpression(node)) return false;
|
||||
const callee = unwrapJavaScriptExpression(node.callee).node;
|
||||
return (
|
||||
(t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) &&
|
||||
semanticStaticPropertyKey(callee.property, callee.computed) === "resolve"
|
||||
);
|
||||
};
|
||||
|
||||
const chunkLoadSpecifier = (
|
||||
node: JavaScriptCallLike,
|
||||
@@ -551,7 +571,8 @@ const inspectEndpointCall = (
|
||||
context: FindingContext,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
if (isKnownNetworkRequest(node, bindings, context)) {
|
||||
const networkAuthority = networkRequestAuthority(node, bindings, context);
|
||||
if (networkAuthority !== undefined) {
|
||||
const target = argumentNode(node.arguments[0]);
|
||||
if (target === undefined) return;
|
||||
const literal =
|
||||
@@ -565,6 +586,9 @@ const inspectEndpointCall = (
|
||||
kind: "network",
|
||||
value: literal,
|
||||
mechanism: `call:${name}`,
|
||||
...(networkAuthority === "candidate"
|
||||
? { interpretation: "candidate" }
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -578,7 +602,7 @@ const inspectEndpointCall = (
|
||||
context.accumulator.pendingNetworkCalls.push({ node, name });
|
||||
return;
|
||||
}
|
||||
addPartialNetworkEndpoint(context, node, name, target);
|
||||
addPartialNetworkEndpoint(context, node, name, target, networkAuthority);
|
||||
return;
|
||||
}
|
||||
// A local or shadowed function with a familiar network name is not a known
|
||||
@@ -642,21 +666,26 @@ const isNetworkRequestSpelling = (name: string): boolean =>
|
||||
name === "navigator.sendBeacon";
|
||||
|
||||
/** Confirm that the call resolves to a built-in network API at this source site. */
|
||||
const isKnownNetworkRequest = (
|
||||
const networkRequestAuthority = (
|
||||
node: JavaScriptCallLike,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
context: FindingContext,
|
||||
): boolean => {
|
||||
if (isBrowserNetworkRequest(node, bindings)) return true;
|
||||
): "proven" | "candidate" | undefined => {
|
||||
if (isBrowserNetworkRequest(node, bindings)) return "proven";
|
||||
const callee = unwrapJavaScriptExpression(node.callee).node;
|
||||
if (
|
||||
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
|
||||
(t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) &&
|
||||
semanticStaticPropertyKey(callee.property, callee.computed) === "loadURL" &&
|
||||
context.rendererLoad?.(node.callee, node) === true
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
semanticStaticPropertyKey(callee.property, callee.computed) === "loadURL"
|
||||
) {
|
||||
const renderer = context.rendererLoad?.(node.callee, node);
|
||||
return renderer === undefined
|
||||
? undefined
|
||||
: renderer.loader_authority === "candidate"
|
||||
? "candidate"
|
||||
: "proven";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Finish queued targets after shared member-mutation facts are indexed. */
|
||||
@@ -666,7 +695,8 @@ export const finishPartialNetworkEndpoints = (
|
||||
index: JavaScriptBindingIndex,
|
||||
): void => {
|
||||
for (const { node, name } of context.accumulator.pendingNetworkCalls) {
|
||||
if (!isKnownNetworkRequest(node, bindings, context)) {
|
||||
const authority = networkRequestAuthority(node, bindings, context);
|
||||
if (authority === undefined) {
|
||||
if (!isIntrinsicMapLookup(node, bindings, index))
|
||||
inspectLiteralEndpointCall(node, name, context);
|
||||
continue;
|
||||
@@ -680,8 +710,9 @@ export const finishPartialNetworkEndpoints = (
|
||||
kind: "network",
|
||||
value: resolved,
|
||||
mechanism: `call:${name}`,
|
||||
...(authority === "candidate" ? { interpretation: "candidate" } : {}),
|
||||
});
|
||||
else addPartialNetworkEndpoint(context, node, name, target);
|
||||
else addPartialNetworkEndpoint(context, node, name, target, authority);
|
||||
}
|
||||
context.accumulator.pendingNetworkCalls.length = 0;
|
||||
context.accumulator.endpoints.sort(
|
||||
@@ -694,6 +725,7 @@ const addPartialNetworkEndpoint = (
|
||||
node: JavaScriptCallLike,
|
||||
name: string,
|
||||
target: t.Node,
|
||||
authority: "proven" | "candidate",
|
||||
): void =>
|
||||
addEndpoint(context, {
|
||||
node,
|
||||
@@ -701,6 +733,7 @@ const addPartialNetworkEndpoint = (
|
||||
// Partial values preserve the original expression text, byte for byte.
|
||||
value: sourceSlice(context.source, target),
|
||||
mechanism: `call:${name}`,
|
||||
...(authority === "candidate" ? { interpretation: "candidate" } : {}),
|
||||
resolution: "partial",
|
||||
});
|
||||
|
||||
@@ -997,6 +1030,9 @@ export const addReference = (
|
||||
? {}
|
||||
: { resolution_base_path: input.resolutionBasePath }),
|
||||
...(input.loader === undefined ? {} : { loader: input.loader }),
|
||||
...(input.loaderAuthority === undefined
|
||||
? {}
|
||||
: { loader_authority: input.loaderAuthority }),
|
||||
module_key: null,
|
||||
location: range(input.node),
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { runInNewContext } from "node:vm";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
|
||||
it("keeps repeated Node resolver calls separate from loader delegate eligibility", () => {
|
||||
const expression = "path.resolve(runtimePath,__dirname,'preload.js')";
|
||||
@@ -33,7 +34,7 @@ it("keeps repeated Node resolver calls separate from loader delegate eligibility
|
||||
"/fixture/app/preload.js",
|
||||
"/fixture/app/preload.js",
|
||||
]);
|
||||
const analysis = analyzeJavaScriptStaticSource(source);
|
||||
const analysis = analyzeCommonJsStaticFixture(source);
|
||||
expect(analysis.parse_status).toBe("complete");
|
||||
expect(analysis.references).toEqual([
|
||||
expect.objectContaining({ kind: "require", specifier: "node:path" }),
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { runInNewContext } from "node:vm";
|
||||
import * as t from "@babel/types";
|
||||
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js";
|
||||
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
|
||||
import { semanticRequireCallAt } from "./javascriptSemanticRequireOrigin.js";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import {
|
||||
analyzeJavaScriptStaticSource,
|
||||
analyzeParsedJavaScriptStaticSource,
|
||||
} from "./javascriptStaticAnalysis.js";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
|
||||
const changedIterator =
|
||||
"Array.prototype[Symbol.iterator]=function*(){yield '../shared/window.cjs'};";
|
||||
@@ -116,7 +124,103 @@ it.each(cases)(
|
||||
const analysis = analyzeJavaScriptStaticSource(source);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.references).toEqual([
|
||||
expect.objectContaining({ kind: "require", specifier }),
|
||||
expect.objectContaining({
|
||||
kind: "require",
|
||||
specifier,
|
||||
...(specifier === null ? {} : { loader_authority: "candidate" }),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["call", "apply"])(
|
||||
"keeps overwritten require.%s unresolved",
|
||||
(method) => {
|
||||
for (const path of ["fixture.MJS", "fixture.cjs"]) {
|
||||
const argument =
|
||||
method === "call" ? 'null,"./a.cjs"' : 'null,["./a.cjs"]';
|
||||
const source = `require.${method}=custom;require.${method}(${argument});`;
|
||||
const unrelated = `require.other=custom;require.${method}(${argument});`;
|
||||
const parse = (text: string) => {
|
||||
const parsed = parseJavaScriptSource(text, path);
|
||||
if (parsed === null) throw new Error("fixture did not parse");
|
||||
return analyzeParsedJavaScriptStaticSource(text, parsed).references.at(
|
||||
-1,
|
||||
);
|
||||
};
|
||||
expect(parse(source)).toMatchObject({ kind: "require", specifier: null });
|
||||
expect(parse(unrelated)).toMatchObject({
|
||||
kind: "require",
|
||||
specifier: "./a.cjs",
|
||||
...(path === "fixture.MJS"
|
||||
? { loader_authority: "candidate" }
|
||||
: { loader: "commonjs-loader" }),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not prove invocation members before the mutation index is complete", () => {
|
||||
const source = 'require.call=custom;require?.call(null,"./a.cjs");';
|
||||
const parsed = parseJavaScriptSource(source, "fixture.cjs");
|
||||
if (parsed === null) throw new Error("fixture did not parse");
|
||||
const index = completeSemanticSteps(
|
||||
indexJavaScriptBindingsWithReceiversSteps(parsed.program),
|
||||
);
|
||||
const statement = parsed.program.body[1];
|
||||
if (
|
||||
!t.isExpressionStatement(statement) ||
|
||||
!t.isOptionalCallExpression(statement.expression)
|
||||
)
|
||||
throw new Error("expected optional call");
|
||||
const fact = semanticRequireCallAt(
|
||||
statement.expression,
|
||||
index.bindings,
|
||||
new Set(),
|
||||
index,
|
||||
);
|
||||
expect(fact).toMatchObject({ loader: "unknown", memberUnproven: true });
|
||||
});
|
||||
|
||||
it("does not turn a mutated local callable into a loader candidate", () => {
|
||||
const source =
|
||||
'const f=()=>0;f.call=()=>0;const addon=f.call(null,"./x.node");';
|
||||
const parsed = parseJavaScriptSource(source, "fixture.cjs");
|
||||
if (parsed === null) throw new Error("fixture did not parse");
|
||||
const result = analyzeParsedJavaScriptStaticSource(source, parsed);
|
||||
expect(result.references).toEqual([]);
|
||||
expect(result.electron.native_addon_bindings).toEqual([]);
|
||||
expect(result.parse_status).toBe("complete");
|
||||
});
|
||||
|
||||
it.each(["fixture.cjs", "fixture.MJS"])(
|
||||
"does not invent nested loader requests after an inner invocation member is replaced: %s",
|
||||
(path) => {
|
||||
const parse = (source: string) => {
|
||||
const parsed = parseJavaScriptSource(source, path);
|
||||
if (parsed === null) throw new Error("fixture did not parse");
|
||||
return analyzeParsedJavaScriptStaticSource(source, parsed);
|
||||
};
|
||||
const changed = parse(
|
||||
'require.call=custom;const addon=require.call.call(require,"./receiver.cjs","./actual.node");',
|
||||
);
|
||||
expect(
|
||||
changed.references.every(
|
||||
(ref) =>
|
||||
ref.specifier !== "./actual.node" &&
|
||||
ref.specifier !== "./receiver.cjs",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(changed.electron.native_addon_bindings).toEqual([]);
|
||||
const intact = parse('require.call.call(require,null,"./actual.node");');
|
||||
expect(intact.references).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "require",
|
||||
specifier: "./actual.node",
|
||||
...(path.endsWith(".MJS")
|
||||
? { loader_authority: "candidate" }
|
||||
: { loader: "commonjs-loader" }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -27,7 +27,7 @@ it.each([
|
||||
expect(result.parse_status).toBe("partial");
|
||||
});
|
||||
|
||||
it("retains complete coverage for an inline Electron factory export", () => {
|
||||
it("retains conditional coverage for an inline Electron factory export", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`(() => {
|
||||
const modules={
|
||||
5(module,exports){
|
||||
@@ -46,14 +46,42 @@ it("retains complete coverage for an inline Electron factory export", () => {
|
||||
}
|
||||
load(9);
|
||||
})();`);
|
||||
expect(result.parse_status).toBe("complete");
|
||||
expect(result.parse_status).toBe("partial");
|
||||
expect(result.references).toContainEqual(
|
||||
expect.objectContaining({ kind: "require", specifier: "electron" }),
|
||||
expect.objectContaining({
|
||||
kind: "require",
|
||||
specifier: "electron",
|
||||
loader_authority: "candidate",
|
||||
}),
|
||||
);
|
||||
expect(result.references.every(({ specifier }) => specifier !== null)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.electron.ipc).toContainEqual(
|
||||
expect.objectContaining({ channel: "test:copy", side: "main" }),
|
||||
expect.objectContaining({
|
||||
channel: "test:copy",
|
||||
side: "main",
|
||||
loader_authority: "candidate",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps captured unknown-host loader authority without promoting shadowed values", () => {
|
||||
const cases = [
|
||||
['const load=require;load("./candidate.cjs");', true],
|
||||
['const load=require;require=()=>0;load("./candidate.cjs");', true],
|
||||
['const require=()=>0;const load=require;load("./candidate.cjs");', false],
|
||||
['require=()=>0;const load=require;load("./candidate.cjs");', false],
|
||||
] as const;
|
||||
for (const [source, candidate] of cases) {
|
||||
const result = analyzeJavaScriptStaticSource(source);
|
||||
expect(
|
||||
result.references.some(
|
||||
(reference) =>
|
||||
reference.specifier === "./candidate.cjs" &&
|
||||
reference.loader_authority === "candidate",
|
||||
),
|
||||
source,
|
||||
).toBe(candidate);
|
||||
}
|
||||
});
|
||||
@@ -3,11 +3,37 @@ import * as path from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import {
|
||||
analyzeJavaScriptStaticSource,
|
||||
analyzeParsedJavaScriptStaticSource,
|
||||
} from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
import { referenceSourceRequireCalls } from "../referenceSourceRequireBindings.js";
|
||||
import { parseReferenceSourceImports } from "../referenceSourceImportParsing.js";
|
||||
|
||||
it.each([
|
||||
'(require.resolve as any)("./dep.cjs");',
|
||||
'(require as any).resolve("./dep.cjs");',
|
||||
'(require as any)?.["resolve"]("./dep.cjs");',
|
||||
])(
|
||||
"retains an unknown-host resolver through TypeScript wrappers: %s",
|
||||
(source) => {
|
||||
const parsed = parseJavaScriptSource(source, "entry.TS");
|
||||
if (parsed === null) throw new Error("Expected TypeScript fixture");
|
||||
expect(
|
||||
analyzeParsedJavaScriptStaticSource(source, parsed).references,
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "require",
|
||||
specifier: "./dep.cjs",
|
||||
operation: "resolve",
|
||||
loader_authority: "candidate",
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["require", "load"],
|
||||
["require?.", "load"],
|
||||
@@ -70,9 +96,60 @@ it.each([
|
||||
);
|
||||
|
||||
const imported = "import {createRequire as make} from 'node:module';";
|
||||
it.each([
|
||||
["call", 'null,"./valid.cjs"', 'null,"./invented.cjs"'],
|
||||
["apply", 'null,["./valid.cjs"]', 'null,["./invented.cjs"]'],
|
||||
])(
|
||||
"distinguishes nested loader-%s replacement from a child property write",
|
||||
(method, valid, invented) => {
|
||||
const references = (source: string) =>
|
||||
analyzeCommonJsStaticFixture(source).references.map(
|
||||
({ operation, specifier }) => ({ operation, specifier }),
|
||||
);
|
||||
expect(
|
||||
references(
|
||||
`const box={load:require};box.load.${method}.extra=1;box.load.${method}(${valid});`,
|
||||
),
|
||||
).toContainEqual({ operation: "load", specifier: "./valid.cjs" });
|
||||
expect(
|
||||
references(
|
||||
`const box={load:require};box.load.${method}=custom;box.load.${method}(${invented});`,
|
||||
),
|
||||
).not.toContainEqual({ operation: "load", specifier: "./invented.cjs" });
|
||||
},
|
||||
);
|
||||
it("preserves a native resolver before a later loader receiver escape", () => {
|
||||
const references = (source: string) =>
|
||||
analyzeCommonJsStaticFixture(source).references.map(
|
||||
({ operation, specifier }) => ({ operation, specifier }),
|
||||
);
|
||||
expect(
|
||||
references(
|
||||
'require.resolve.paths=custom;require.resolve("./first.cjs");require.call.extra=1;require.call(null,"./second.cjs");',
|
||||
),
|
||||
).toEqual([
|
||||
{ operation: "resolve", specifier: "./first.cjs" },
|
||||
{ operation: "load", specifier: "./second.cjs" },
|
||||
]);
|
||||
expect(
|
||||
references('opaque(require);require.resolve("./unsafe.cjs");').at(-1),
|
||||
).toEqual({
|
||||
operation: "load",
|
||||
specifier: null,
|
||||
});
|
||||
for (const source of [
|
||||
'function run(){require.resolve("./unsafe.cjs");opaque(require);}run();run();',
|
||||
'function mutate(){opaque(require)}mutate();require.resolve("./unsafe.cjs");',
|
||||
]) {
|
||||
expect(references(source)).not.toContainEqual({
|
||||
operation: "resolve",
|
||||
specifier: "./unsafe.cjs",
|
||||
});
|
||||
}
|
||||
});
|
||||
it("retains consecutive native resolver calls while keeping replaced or escaped loaders uncertain", () => {
|
||||
const references = (source: string) =>
|
||||
analyzeJavaScriptStaticSource(source).references.map(
|
||||
analyzeCommonJsStaticFixture(source).references.map(
|
||||
({ operation, specifier }) => ({ operation, specifier }),
|
||||
);
|
||||
expect(
|
||||
@@ -293,7 +370,7 @@ it.each([
|
||||
require: (request: string) => module.require(request),
|
||||
});
|
||||
expect(result.join === path.join).toBe(host);
|
||||
const analysis = analyzeJavaScriptStaticSource(source);
|
||||
const analysis = analyzeCommonJsStaticFixture(source);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.references).toEqual(
|
||||
specifier === undefined
|
||||
@@ -493,3 +570,23 @@ it("retains unknown historical .js loader scope until a package manifest establi
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["main.cjs", "main.MJS"])(
|
||||
"does not use an unproven invocation receiver as a dependency: %s",
|
||||
(path) => {
|
||||
for (const source of [
|
||||
'require.call=custom;require.call("./receiver.cjs","./actual.cjs");',
|
||||
'require.call=custom;require.call.call(require,"./receiver.cjs","./actual.cjs");',
|
||||
'require.call=custom;require.call.apply(require,["./receiver.cjs","./actual.cjs"]);',
|
||||
]) {
|
||||
const result = parseReferenceSourceImports(
|
||||
path,
|
||||
new TextEncoder().encode(source),
|
||||
"JavaScript",
|
||||
);
|
||||
expect(
|
||||
result.relationships.filter(({ kind }) => kind === "requires"),
|
||||
).toEqual([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -88,3 +88,16 @@ it.each(cases)(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps unknown wrapper eligibility separate from local replacement", () => {
|
||||
const local = analyzeJavaScriptStaticSource(
|
||||
'var require; require={main:{require:()=>0}}; require.main.require("node:path");',
|
||||
);
|
||||
expect(local.references).toEqual([]);
|
||||
const conditional = analyzeJavaScriptStaticSource(
|
||||
'var require; if(flag) require={main:{require:()=>0}}; require.main.require("node:path");',
|
||||
);
|
||||
expect(conditional.references).toMatchObject([
|
||||
{ kind: "require", specifier: null },
|
||||
]);
|
||||
});
|
||||
@@ -110,10 +110,17 @@ export interface JavaScriptFindingContext {
|
||||
site: t.Node,
|
||||
suffix: readonly string[],
|
||||
) =>
|
||||
| { readonly owner: t.NewExpression; readonly path: readonly string[] }
|
||||
| {
|
||||
readonly owner: t.NewExpression;
|
||||
readonly path: readonly string[];
|
||||
readonly authority: { readonly loader_authority?: "candidate" };
|
||||
}
|
||||
| undefined;
|
||||
readonly xhrOpenRequest?: (node: t.Node) => boolean;
|
||||
readonly rendererLoad?: (callee: t.Node, site: t.Node) => boolean;
|
||||
readonly rendererLoad?: (
|
||||
callee: t.Node,
|
||||
site: t.Node,
|
||||
) => { readonly loader_authority?: "candidate" } | undefined;
|
||||
readonly rpcScan: JavaScriptRpcScan;
|
||||
/** Shared bounded argv/nullish argument shapes for optional-slot signatures. */
|
||||
readonly expressionShapes: JavaScriptExpressionShapeResolver;
|
||||
@@ -123,12 +130,15 @@ export interface JavaScriptFindingContext {
|
||||
readonly openReceiverFacts?: ReadonlyMap<number, JavaScriptOpenReceiverFact>;
|
||||
/** Proven Electron export path segments by root offset. */
|
||||
readonly electronBindings?: ReadonlyMap<number, readonly string[]>;
|
||||
/** Conditional Electron export paths, keyed by call/construction root. */
|
||||
readonly electronCandidateBindings?: ReadonlyMap<number, readonly string[]>;
|
||||
/** Normalized invocation target from the shared Electron and intrinsic owners. */
|
||||
readonly electronInvocationPath?: (
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
) => readonly string[] | undefined;
|
||||
/** A possible shared-app mutation does not establish concrete API identity. */
|
||||
readonly electronAppPathChangePossible?: (node: t.Node) => boolean;
|
||||
readonly electronCandidateAppPathChangePossible?: (node: t.Node) => boolean;
|
||||
}
|
||||
|
||||
/** Candidate import, require, worker, or service-worker reference. */
|
||||
@@ -139,6 +149,7 @@ export interface JavaScriptReferenceInput {
|
||||
readonly specifier: string | undefined;
|
||||
readonly resolutionBasePath?: string;
|
||||
readonly loader?: JavaScriptStaticReference["loader"];
|
||||
readonly loaderAuthority?: "candidate";
|
||||
}
|
||||
|
||||
/** Candidate route or network endpoint literal. */
|
||||
|
||||
@@ -50,6 +50,8 @@ interface JavaScriptStaticReferenceState {
|
||||
* when it reads like a package name.
|
||||
*/
|
||||
readonly loader?: "commonjs-loader" | "factory-loader" | "create-require";
|
||||
/** The literal is observed but the CommonJS loader is not established. */
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly location: JavaScriptSourceRange;
|
||||
}
|
||||
|
||||
@@ -128,6 +130,7 @@ export interface JavaScriptBundlerRegistration {
|
||||
/** Static Electron role paths discovered from package or JavaScript syntax. */
|
||||
export interface JavaScriptRolePath {
|
||||
readonly role: "preload" | "renderer";
|
||||
readonly loader_authority?: "candidate";
|
||||
readonly path: string;
|
||||
readonly resolution_context: JavaScriptStaticPathContext;
|
||||
/** Inferred from intact source-directory intrinsics; not a runtime/bundler guarantee. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
|
||||
import {
|
||||
javaScriptStaticParseStatus,
|
||||
unexaminedJavaScriptStaticCoverage,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js";
|
||||
|
||||
it("separates dynamic semantic uncertainty from completed syntax and examination", () => {
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
const result = analyzeCommonJsStaticFixture(
|
||||
"const {ipcMain}=require('electron'); ipcMain.on(channel, () => {});",
|
||||
);
|
||||
expect(result.parse_status).toBe("partial");
|
||||
@@ -46,7 +47,7 @@ it("keeps framework applicability unknown without positive Electron evidence", (
|
||||
const ordinary = analyzeJavaScriptStaticSource("export const value = 1;");
|
||||
expect(ordinary.parse_status).toBe("complete");
|
||||
expect(ordinary.analysis_coverage?.applicability.electron).toBe("unknown");
|
||||
const electron = analyzeJavaScriptStaticSource(
|
||||
const electron = analyzeCommonJsStaticFixture(
|
||||
"const {ipcMain}=require('electron');",
|
||||
);
|
||||
expect(electron.analysis_coverage?.applicability.electron).toBe("observed");
|
||||
|
||||
@@ -82,6 +82,7 @@ it("substitutes numeric modules, named exports and captured wrapper arguments",
|
||||
const env = resolver.bind(fn, []);
|
||||
expect(resolver.evaluate(fn.node.body.callee, env)).toEqual({
|
||||
kind: "external",
|
||||
loaderAuthority: "candidate",
|
||||
path: ["electron", "ipcRenderer", "send"],
|
||||
});
|
||||
expect(resolver.arguments(fn.node.body, env)[0]).toEqual({
|
||||
@@ -127,7 +128,11 @@ it("checks block lexical initializers against their containing function call", (
|
||||
);
|
||||
},
|
||||
});
|
||||
expect(resolved).toEqual({ kind: "external", path: ["electron"] });
|
||||
expect(resolved).toEqual({
|
||||
kind: "external",
|
||||
loaderAuthority: "candidate",
|
||||
path: ["electron"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps same-factory reads before initialization unresolved", () => {
|
||||
@@ -236,8 +241,16 @@ it("keeps function declarations hoisted and checks closures at invocation", () =
|
||||
inspect(localRead());
|
||||
`);
|
||||
expect(inspected[0]).toMatchObject({ kind: "function" });
|
||||
expect(inspected[1]).toEqual({ kind: "external", path: ["electron"] });
|
||||
expect(inspected[2]).toEqual({ kind: "external", path: ["electron"] });
|
||||
expect(inspected[1]).toEqual({
|
||||
kind: "external",
|
||||
loaderAuthority: "candidate",
|
||||
path: ["electron"],
|
||||
});
|
||||
expect(inspected[2]).toEqual({
|
||||
kind: "external",
|
||||
loaderAuthority: "candidate",
|
||||
path: ["electron"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a synchronous cross-function callback before initialization unresolved", () => {
|
||||
@@ -288,7 +301,11 @@ it("uses completed activation provenance for a returned closure", () => {
|
||||
const value = require("electron");
|
||||
return () => value;
|
||||
}`);
|
||||
expect(result).toEqual({ kind: "external", path: ["electron"] });
|
||||
expect(result).toEqual({
|
||||
kind: "external",
|
||||
loaderAuthority: "candidate",
|
||||
path: ["electron"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat statements after a returned closure as initialized", () => {
|
||||
@@ -898,7 +915,11 @@ it.each([
|
||||
})();`);
|
||||
expect(value.kind).toBe(kind);
|
||||
if (kind === "external")
|
||||
expect(value).toEqual({ kind: "external", path: ["electron"] });
|
||||
expect(value).toEqual({
|
||||
kind: "external",
|
||||
loaderAuthority: "candidate",
|
||||
path: ["electron"],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -910,7 +931,11 @@ it("reads a literal prototype only from its actual prototype setter", () => {
|
||||
inspectOne(
|
||||
'inspect(({__proto__:require("electron"),ordinary:1}).__proto__);',
|
||||
),
|
||||
).toEqual({ kind: "external", path: ["electron"] });
|
||||
).toEqual({
|
||||
kind: "external",
|
||||
loaderAuthority: "candidate",
|
||||
path: ["electron"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["local", "outer"])(
|
||||
@@ -1031,3 +1056,23 @@ it("keeps repeated cached queries within an independent depth budget", () => {
|
||||
expect(values).toHaveLength(1);
|
||||
expect(resolver.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("retains each external selection guard once across deep member reads", () => {
|
||||
const { parsed, resolver } = resolverFor("if (guard) {};");
|
||||
const condition = { node: parsed.program.body[0]!, positive: true as const };
|
||||
let value: SymbolicValue = {
|
||||
kind: "external",
|
||||
path: ["electron"],
|
||||
loaderAuthority: "candidate",
|
||||
conditions: [condition],
|
||||
};
|
||||
for (let index = 0; index < 32; index++) {
|
||||
value = resolver.property(value, `member${index}`);
|
||||
}
|
||||
expect(value).toMatchObject({
|
||||
kind: "external",
|
||||
loaderAuthority: "candidate",
|
||||
conditions: [condition],
|
||||
});
|
||||
expect(resolver.truncated).toBe(false);
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js";
|
||||
import { semanticRequireOriginCandidate } from "./javascriptSemanticRequireOrigin.js";
|
||||
import { javaScriptRuntimeContext } from "./javascriptSourceParser.js";
|
||||
import { createGlobalReferenceReader } from "./javascriptSemanticAmbientWrites.js";
|
||||
import { javascriptBootstrapAt } from "./javascriptBootstrap.js";
|
||||
import { ELECTRON_MODULE } from "./javascriptElectronMemberWrites.js";
|
||||
@@ -358,7 +360,13 @@ export class JavaScriptSymbolicBindings {
|
||||
) {
|
||||
return symbolicUnknown("overwritten Electron API member");
|
||||
}
|
||||
return { kind: "external", path };
|
||||
return {
|
||||
kind: "external",
|
||||
path,
|
||||
...(value.loaderAuthority === "candidate"
|
||||
? { loaderAuthority: "candidate" as const }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (value.kind === "record") {
|
||||
const property = value.values.get(key);
|
||||
@@ -765,13 +773,25 @@ export class JavaScriptSymbolicBindings {
|
||||
if (!binding)
|
||||
return !semanticResolutionBlocked(this.state, node, node.name) &&
|
||||
["require", "navigator", "String", "Boolean"].includes(node.name)
|
||||
? { kind: "external", path: [node.name] }
|
||||
? node.name === "require" &&
|
||||
javaScriptRuntimeContext(this.state.program) === "module"
|
||||
? symbolicUnknown("CommonJS require is unavailable in an ES module")
|
||||
: {
|
||||
kind: "external",
|
||||
path: [node.name],
|
||||
...(node.name === "require" &&
|
||||
javaScriptRuntimeContext(this.state.program) === "unknown"
|
||||
? { loaderAuthority: "candidate" as const }
|
||||
: {}),
|
||||
}
|
||||
: symbolicUnknown("unbound identifier");
|
||||
if (binding.definitions.some((d) => d.kind === "assignment"))
|
||||
return symbolicUnknown("reassigned binding");
|
||||
// Electron member identity is checked at the selected property. A write
|
||||
// to one method must not erase the module origin or unaffected siblings.
|
||||
const electronOrigin = this.#electronIdentity.exportPath(node);
|
||||
const electronOrigin =
|
||||
this.#electronIdentity.exportPath(node) ??
|
||||
this.#electronIdentity.candidateExportPath(node);
|
||||
if (electronOrigin === undefined && this.#mutated.has(binding.bindingId))
|
||||
return symbolicUnknown("dynamic member writes");
|
||||
for (let e: SymbolicEnvironment | undefined = env; e; e = e.parent) {
|
||||
@@ -1176,7 +1196,15 @@ export class JavaScriptSymbolicBindings {
|
||||
const spec = args[0];
|
||||
result =
|
||||
spec?.kind === "literal" && typeof spec.value === "string"
|
||||
? { kind: "external", path: [spec.value] }
|
||||
? {
|
||||
kind: "external",
|
||||
path: [spec.value],
|
||||
...(semanticRequireOriginCandidate(node, this.state)?.origin
|
||||
.loaderAuthority === "candidate" ||
|
||||
value.loaderAuthority === "candidate"
|
||||
? { loaderAuthority: "candidate" as const }
|
||||
: {}),
|
||||
}
|
||||
: symbolicUnknown("dynamic require");
|
||||
} else if (
|
||||
value.kind !== "function" ||
|
||||
|
||||
@@ -47,7 +47,11 @@ export type SymbolicValue = (
|
||||
readonly kind: "literal";
|
||||
readonly value: string | number | boolean | null | undefined;
|
||||
}
|
||||
| { readonly kind: "external"; readonly path: readonly string[] }
|
||||
| {
|
||||
readonly kind: "external";
|
||||
readonly path: readonly string[];
|
||||
readonly loaderAuthority?: "candidate";
|
||||
}
|
||||
| {
|
||||
readonly kind: "object";
|
||||
readonly node: t.ObjectExpression;
|
||||
|
||||
Reference in new issue
Block a user