fix(javascript): preserve intrinsic alias capture timing
Validate each alias read after initialization and retain the original function capture across copies. Share declaration initializer facts with RPC and Electron analysis, including class declarations used for fresh-target identity. Accept single-statement control blocks while keeping lexical roles and added operations checked. Cover the capture regressions through inert source and public CLI/MCP workflows.
This commit is contained in:
1 parent
b03df22ce4
commit
548a4334c4
7 files changed
+221
-68
No files matched your search
@@ -326,15 +326,18 @@ remain unknown and make coverage partial.
|
||||
Electron API identity follows proven lexical module aliases. Namespace interop
|
||||
also preserves identity when the complete Rolldown helper operations, lexical
|
||||
roles, live getters, and captured intrinsic origins match, or when a stable
|
||||
helper simply returns its module parameter. Recognition uses structure rather
|
||||
than emitted names; other helper bodies and ambiguous runtime fallbacks remain
|
||||
unresolved. Captured function identities survive later global-property
|
||||
replacement. Visible intrinsic writes before capture or conversion are checked
|
||||
in their owning callable scope, including identifier leaves in nested/default/rest
|
||||
helper simply returns its module parameter. Harmless single-statement control
|
||||
blocks preserve recognition; added operations still require proof. Recognition
|
||||
uses structure rather than emitted names; other helper bodies and ambiguous
|
||||
runtime fallbacks remain unresolved. Captured function identities survive later global-property
|
||||
replacement, including copies through initialized aliases. Reads before an
|
||||
alias initializer remain unresolved. Visible intrinsic writes before capture or
|
||||
conversion are checked in their owning callable scope, including identifier leaves in nested/default/rest
|
||||
destructuring and loop targets. Property keys and shadowed local bindings do not
|
||||
overwrite the global intrinsic identity. Arbitrary cross-call side effects are not
|
||||
evaluated. Other reassignments, dynamic selections, and visibly overwritten
|
||||
namespace members are unresolved.
|
||||
overwrite the global intrinsic identity. Cross-call side effects, including
|
||||
writes inside directly invoked local functions, are not evaluated. Other
|
||||
reassignments, dynamic selections, and visibly overwritten namespace members
|
||||
are unresolved.
|
||||
Known Object/Reflect mutators remain recognizable through literal destructuring
|
||||
of intrinsic objects. Stable identifier chains to fresh object, array, function,
|
||||
undecorated class or RegExp syntax keep those targets separate from intrinsic
|
||||
|
||||
@@ -7,6 +7,18 @@ const WINDOW =
|
||||
'new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}});';
|
||||
const IMPORT = 'let electron = require("electron");';
|
||||
const WRAP = "electron = __toESM(electron, 1);";
|
||||
// The upstream runtime uses control-body braces that its bundled printer omits.
|
||||
// https://github.com/rolldown/rolldown/blob/22a0ab13732d81f2559ad2b7064b688699324889/crates/rolldown/src/runtime/runtime-base.js
|
||||
const BLOCK_MODULE_INTEROP = ROLLDOWN_MODULE_INTEROP.replace(
|
||||
'if (from && typeof from === "object" || typeof from === "function") for',
|
||||
'if (from && typeof from === "object" || typeof from === "function") { for',
|
||||
)
|
||||
.replace(
|
||||
"if (!__hasOwnProp.call(to, key) && key !== except) __defProp",
|
||||
"if (!__hasOwnProp.call(to, key) && key !== except) { __defProp",
|
||||
)
|
||||
.replace(" });", " });\n }")
|
||||
.replace(" return to;", " }\n return to;");
|
||||
|
||||
describe("Electron module interop identity", () => {
|
||||
it("preserves named APIs through the released Rolldown namespace conversion", () => {
|
||||
@@ -40,6 +52,25 @@ describe("Electron module interop identity", () => {
|
||||
analyzeJavaScriptStaticSource(source).electron.browser_windows,
|
||||
).toMatchObject([{ preload_path: "./preload.js" }]);
|
||||
});
|
||||
|
||||
it("preserves the same operations through control-body braces", () => {
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(
|
||||
`${BLOCK_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`,
|
||||
).electron.browser_windows,
|
||||
).toMatchObject([{ preload_path: "./preload.js" }]);
|
||||
});
|
||||
|
||||
it("keeps additional operations inside a control block unresolved", () => {
|
||||
const altered = BLOCK_MODULE_INTEROP.replace(
|
||||
"{ __defProp",
|
||||
"{ from = unrelated; __defProp",
|
||||
);
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(`${altered}\n${IMPORT} ${WRAP} ${WINDOW}`)
|
||||
.electron.browser_windows,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Electron module interop uncertainty", () => {
|
||||
@@ -233,6 +264,50 @@ describe("Electron module interop pattern writes", () => {
|
||||
});
|
||||
|
||||
describe("Electron module interop capture and scope", () => {
|
||||
it.each([
|
||||
"var captured = Object.defineProperty;",
|
||||
"const {defineProperty:captured} = Object;",
|
||||
"var first = Object.defineProperty; const captured = first;",
|
||||
])("retains the original function capture through %s", (capture) => {
|
||||
const runtime = ROLLDOWN_MODULE_INTEROP.replace(
|
||||
"__defProp = Object.defineProperty",
|
||||
"__defProp = captured",
|
||||
);
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
${capture} Object.defineProperty = custom;
|
||||
${runtime} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["var", "const"])(
|
||||
"keeps an intrinsic alias read before its %s initializer unresolved",
|
||||
(kind) => {
|
||||
const runtime = ROLLDOWN_MODULE_INTEROP.replace(
|
||||
"__defProp = Object.defineProperty",
|
||||
"__defProp = later",
|
||||
);
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
${runtime} ${kind} later = Object.defineProperty;
|
||||
${IMPORT} ${WRAP} ${WINDOW}
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("requires the helper's intrinsic binding to initialize before conversion", () => {
|
||||
const runtime = ROLLDOWN_MODULE_INTEROP.replace(
|
||||
"var __defProp = Object.defineProperty;",
|
||||
"",
|
||||
);
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
var captured = Object.defineProperty;
|
||||
${runtime} ${IMPORT} ${WRAP} ${WINDOW}
|
||||
var __defProp = captured;
|
||||
`);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'const {defineProperty:change} = Object; change(Object,"create",{value:customCreate});',
|
||||
"const objectAlias = Object; const {assign:change} = objectAlias; change(Object,{create:customCreate});",
|
||||
|
||||
@@ -120,4 +120,27 @@ describe("Electron API identity", () => {
|
||||
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("follows stable declaration aliases but not an assigned default parameter", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const direct = require("electron");
|
||||
const constAlias = direct;
|
||||
new constAlias.BrowserWindow({webPreferences:{preload:"./const.js"}});
|
||||
|
||||
let letAlias = require("electron");
|
||||
new letAlias.BrowserWindow({webPreferences:{preload:"./let.js"}});
|
||||
|
||||
const { BrowserWindow } = require("electron");
|
||||
new BrowserWindow({webPreferences:{preload:"./destructured.js"}});
|
||||
|
||||
function withDefault(electron = require("./not-electron")) {
|
||||
if (flag) electron = require("electron");
|
||||
new electron.BrowserWindow({webPreferences:{preload:"./default.js"}});
|
||||
}
|
||||
`);
|
||||
|
||||
expect(
|
||||
analysis.electron.browser_windows.map(({ preload_path }) => preload_path),
|
||||
).toEqual(["./const.js", "./let.js", "./destructured.js"]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isUnshadowedGlobal,
|
||||
resolveSemanticBindingState,
|
||||
semanticScopeId,
|
||||
semanticStableInitializer,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
@@ -122,7 +123,7 @@ export const createSemanticModuleInterop = (
|
||||
: intrinsicBindings.get(binding.bindingId);
|
||||
if (cached !== undefined)
|
||||
return finish(cached === null ? undefined : [...cached, ...suffix]);
|
||||
const initializer = stableBindingInitializer(binding, state);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (
|
||||
binding === undefined ||
|
||||
initializer === undefined ||
|
||||
@@ -137,6 +138,36 @@ export const createSemanticModuleInterop = (
|
||||
}
|
||||
};
|
||||
|
||||
// A nominal alias path is sufficient for conservative mutation lookup, but
|
||||
// helper proof also needs each read to follow initialization. An alias copy
|
||||
// retains the earlier function capture; a member read captures it anew.
|
||||
const capturedIntrinsic = (node: t.Node): IntrinsicDependency | undefined => {
|
||||
const path = intrinsicPath(node);
|
||||
if (path === undefined) return undefined;
|
||||
const visited = new Set<string>();
|
||||
let current = node;
|
||||
let capturedAt = node;
|
||||
let suffixLength = 0;
|
||||
for (;;) {
|
||||
const { root, members } = electronMemberPath(current);
|
||||
suffixLength += members.length;
|
||||
if (!t.isIdentifier(root)) return undefined;
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
if (binding === undefined) return { path, capturedAt };
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (
|
||||
initializer === undefined ||
|
||||
(initializer.node.end ?? Infinity) >= (root.start ?? -1) ||
|
||||
visited.has(binding.bindingId)
|
||||
)
|
||||
return undefined;
|
||||
visited.add(binding.bindingId);
|
||||
if (suffixLength === 0) capturedAt = initializer.node;
|
||||
suffixLength += initializer.projection.length;
|
||||
current = initializer.node;
|
||||
}
|
||||
};
|
||||
|
||||
const isFreshObject = (node: t.Node): boolean => {
|
||||
const visited = new Set<string>();
|
||||
let current = node;
|
||||
@@ -152,7 +183,7 @@ export const createSemanticModuleInterop = (
|
||||
return true;
|
||||
if (!t.isIdentifier(current)) return false;
|
||||
const binding = resolveSemanticBindingState(state, current, current.name);
|
||||
const initializer = stableInitializer(binding, state);
|
||||
const initializer = unprojectedInitializer(binding, state);
|
||||
if (
|
||||
binding === undefined ||
|
||||
initializer === undefined ||
|
||||
@@ -173,7 +204,7 @@ export const createSemanticModuleInterop = (
|
||||
helpers.set(binding.bindingId, null);
|
||||
if (state.scopesById.get(binding.scopeId)?.kind !== "program")
|
||||
return undefined;
|
||||
const wrapper = stableInitializer(binding, state);
|
||||
const wrapper = unprojectedInitializer(binding, state);
|
||||
if (wrapper === undefined) return undefined;
|
||||
if (identityFunction(wrapper, state)) {
|
||||
const proof = {
|
||||
@@ -188,7 +219,7 @@ export const createSemanticModuleInterop = (
|
||||
}
|
||||
const wrapperRoles = canonicalRoles(wrapper, TO_ESM, state);
|
||||
const copyBinding = wrapperRoles?.get("__copyProps");
|
||||
const copy = stableInitializer(copyBinding, state);
|
||||
const copy = unprojectedInitializer(copyBinding, state);
|
||||
const copyRoles =
|
||||
copy === undefined ? undefined : canonicalRoles(copy, COPY_PROPS, state);
|
||||
if (
|
||||
@@ -198,22 +229,27 @@ export const createSemanticModuleInterop = (
|
||||
)
|
||||
return undefined;
|
||||
const dependencies: IntrinsicDependency[] = [];
|
||||
let readyAfter = Math.max(wrapper.end ?? Infinity, copy.end ?? Infinity);
|
||||
for (const roles of [wrapperRoles, copyRoles])
|
||||
for (const [role, dependency] of roles) {
|
||||
const expected = INTRINSICS[role];
|
||||
if (expected === undefined) continue;
|
||||
const initializer = stableInitializer(dependency, state);
|
||||
const path =
|
||||
initializer === undefined ? undefined : intrinsicPath(initializer);
|
||||
const initializer = unprojectedInitializer(dependency, state);
|
||||
const captured =
|
||||
initializer === undefined
|
||||
? undefined
|
||||
: capturedIntrinsic(initializer);
|
||||
if (
|
||||
initializer === undefined ||
|
||||
path === undefined ||
|
||||
JSON.stringify(path) !== JSON.stringify(["intrinsic", ...expected])
|
||||
captured === undefined ||
|
||||
JSON.stringify(captured.path) !==
|
||||
JSON.stringify(["intrinsic", ...expected])
|
||||
)
|
||||
return undefined;
|
||||
dependencies.push({ path, capturedAt: initializer });
|
||||
readyAfter = Math.max(readyAfter, initializer.end ?? Infinity);
|
||||
dependencies.push(captured);
|
||||
if (role === "__hasOwnProp")
|
||||
dependencies.push({ path: [...path, "call"] });
|
||||
dependencies.push({ path: [...captured.path, "call"] });
|
||||
}
|
||||
dependencies.push(
|
||||
{ path: ["intrinsic", "Function", "prototype", "bind"] },
|
||||
@@ -222,11 +258,7 @@ export const createSemanticModuleInterop = (
|
||||
const proof = {
|
||||
dependencies,
|
||||
identity: false,
|
||||
readyAfter: Math.max(
|
||||
wrapper.end ?? Infinity,
|
||||
copy.end ?? Infinity,
|
||||
...dependencies.map(({ capturedAt }) => capturedAt?.end ?? -1),
|
||||
),
|
||||
readyAfter,
|
||||
};
|
||||
helpers.set(binding.bindingId, proof);
|
||||
return proof;
|
||||
@@ -282,7 +314,7 @@ export const createSemanticModuleInterop = (
|
||||
const origin = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
): InteropOrigin | undefined => {
|
||||
const single = stableInitializer(binding, state);
|
||||
const single = unprojectedInitializer(binding, state);
|
||||
if (single !== undefined) {
|
||||
const result = callOrigin(single);
|
||||
if (result !== undefined) origins.set(binding.bindingId, result);
|
||||
@@ -416,29 +448,15 @@ const identityFunction = (
|
||||
);
|
||||
};
|
||||
|
||||
const stableInitializer = (
|
||||
const unprojectedInitializer = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): t.Node | undefined => {
|
||||
const initializer = stableBindingInitializer(binding, state);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
return initializer?.projection.length === 0 ? initializer.node : undefined;
|
||||
};
|
||||
|
||||
const stableBindingInitializer = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticBindingState["initializers"][number] | undefined => {
|
||||
const [initializer] = binding?.initializers ?? [];
|
||||
return binding !== undefined &&
|
||||
binding.definitions.length === 1 &&
|
||||
binding.initializers.length === 1 &&
|
||||
initializer !== undefined &&
|
||||
!state.conditionalInitializers.has(initializer.node)
|
||||
? initializer
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Exact AST shape with a bijection of lexical roles, not identifier spelling. */
|
||||
/** Match operations and lexical roles, allowing single-statement control blocks. */
|
||||
const canonicalRoles = (
|
||||
actual: t.Node,
|
||||
pattern: t.Node,
|
||||
@@ -450,9 +468,18 @@ const canonicalRoles = (
|
||||
[{ actual, pattern, parent: null }];
|
||||
while (pending.length !== 0) {
|
||||
const pair = pending.pop();
|
||||
if (pair === undefined || pair.actual.type !== pair.pattern.type)
|
||||
return undefined;
|
||||
const { actual: node, pattern: expected, parent } = pair;
|
||||
if (pair === undefined) return undefined;
|
||||
const { parent } = pair;
|
||||
const controlBody =
|
||||
(t.isIfStatement(parent) &&
|
||||
(parent.consequent === pair.pattern ||
|
||||
parent.alternate === pair.pattern)) ||
|
||||
(t.isForStatement(parent) && parent.body === pair.pattern);
|
||||
const node = controlBody ? controlBodyOperation(pair.actual) : pair.actual;
|
||||
const expected = controlBody
|
||||
? controlBodyOperation(pair.pattern)
|
||||
: pair.pattern;
|
||||
if (node.type !== expected.type) return undefined;
|
||||
const literalKey =
|
||||
parent !== null &&
|
||||
((t.isMemberExpression(parent) &&
|
||||
@@ -504,3 +531,19 @@ const canonicalRoles = (
|
||||
}
|
||||
return roles;
|
||||
};
|
||||
|
||||
// Braces around one control-body statement do not add operations. Keep other
|
||||
// blocks, directives and extra statements intact; their semantics need proof.
|
||||
// Lexical declarations/references still pass through canonicalRoles' bijection.
|
||||
const controlBodyOperation = (node: t.Node): t.Node => {
|
||||
while (
|
||||
t.isBlockStatement(node) &&
|
||||
node.directives.length === 0 &&
|
||||
node.body.length === 1
|
||||
) {
|
||||
const statement = node.body[0];
|
||||
if (statement === undefined) break;
|
||||
node = statement;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
resolveSemanticBindingFromScope,
|
||||
semanticBindingId,
|
||||
semanticScopeId,
|
||||
semanticStableInitializer,
|
||||
semanticVariableScope,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
@@ -205,13 +206,10 @@ export function* classifyParsedJavaScriptRpcClientRootsSteps(
|
||||
if (binding === undefined) return;
|
||||
let client = clients.get(binding.bindingId);
|
||||
if (client === undefined) {
|
||||
const [initializer] = binding.initializers;
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
client =
|
||||
initializer !== undefined &&
|
||||
initializer.projection.length === 0 &&
|
||||
binding.initializers.length === 1 &&
|
||||
!binding.definitions.some(({ kind }) => kind === "assignment") &&
|
||||
!state.conditionalInitializers.has(initializer.node) &&
|
||||
isJavaScriptRpcClientFactory(initializer.node);
|
||||
clients.set(binding.bindingId, client);
|
||||
}
|
||||
@@ -453,14 +451,8 @@ const electronExport = (
|
||||
const converted = interop.origin(binding);
|
||||
if (reassigned && converted === undefined) return null;
|
||||
const directOrigin = converted?.origin ?? bindingOrigin(binding, state);
|
||||
const [initializer] = binding.initializers;
|
||||
if (
|
||||
directOrigin === undefined &&
|
||||
(initializer === undefined ||
|
||||
binding.initializers.length !== 1 ||
|
||||
state.conditionalInitializers.has(initializer.node))
|
||||
)
|
||||
return null;
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (directOrigin === undefined && initializer === undefined) return null;
|
||||
const provenance =
|
||||
directOrigin === undefined
|
||||
? evaluateSemanticProvenance(binding, state)
|
||||
@@ -483,16 +475,8 @@ const bindingOrigin = (
|
||||
return binding.directOrigins.length === 1
|
||||
? binding.directOrigins[0]
|
||||
: undefined;
|
||||
const [initializer] = binding.initializers;
|
||||
// A var/let declaration permits writes without proving one occurred.
|
||||
// Require one unconditional initializer and no actual binding writes.
|
||||
if (
|
||||
initializer === undefined ||
|
||||
binding.initializers.length !== 1 ||
|
||||
binding.definitions.some(({ kind }) => kind === "assignment") ||
|
||||
state.conditionalInitializers.has(initializer.node)
|
||||
)
|
||||
return undefined;
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (initializer === undefined) return undefined;
|
||||
const origin = semanticRequireOrigin(initializer.node, state)?.origin;
|
||||
const projection = initializer.projection;
|
||||
return origin === undefined ||
|
||||
|
||||
@@ -75,6 +75,29 @@ export interface JavaScriptSemanticAnalysisState {
|
||||
readonly conditionalInitializers: WeakSet<t.Node>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one unconditional source initializer for a declared lexical binding.
|
||||
* This establishes a stable source fact only; it does not prove immutability or
|
||||
* that the declaration has executed at a particular program point.
|
||||
*/
|
||||
export const semanticStableInitializer = (
|
||||
binding: JavaScriptSemanticBindingState | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticBindingState["initializers"][number] | undefined => {
|
||||
const [initializer] = binding?.initializers ?? [];
|
||||
const [definition] = binding?.definitions ?? [];
|
||||
return binding !== undefined &&
|
||||
binding.definitions.length === 1 &&
|
||||
(definition?.kind === "variable" ||
|
||||
definition?.kind === "function" ||
|
||||
definition?.kind === "class") &&
|
||||
binding.initializers.length === 1 &&
|
||||
initializer !== undefined &&
|
||||
!state.conditionalInitializers.has(initializer.node)
|
||||
? initializer
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Return the active scope from a non-empty construction stack. */
|
||||
export const currentSemanticScope = (
|
||||
stack: readonly JavaScriptSemanticScopeState[],
|
||||
|
||||
@@ -50,7 +50,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
Object.defineProperty(localClass,"x",{value:1});
|
||||
const localRegExp = /local/;
|
||||
Object.defineProperty(localRegExp,"x",{value:1});
|
||||
${ROLLDOWN_MODULE_INTEROP}
|
||||
const captured = Object.defineProperty;
|
||||
Object.defineProperty = custom;
|
||||
${ROLLDOWN_MODULE_INTEROP.replace("__defProp = Object.defineProperty", "__defProp = captured")}
|
||||
let electron = require("electron");
|
||||
electron = __toESM(electron, 1);
|
||||
new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}});
|
||||
|
||||
Reference in new issue
Block a user