mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(javascript): follow bundler namespace interop for Electron APIs (#1827)
* fix(javascript): follow bundler namespace interop for Electron APIs
Rolldown/tsdown emit `let electron = require("electron"); electron =
__toESM(electron);` and esbuild/tsc emit `__toESM(require("electron"))`
or `__importStar(require("electron"))` for namespace imports. The
reassignment or the helper call hid the Electron origin, so bundled
main processes reported zero BrowserWindows and preloads.
Locally declared, never reassigned interop helpers recognized by name
now pass the module through, and a binding's rewrap of its own value is
not treated as a reassignment. Other writes stay unresolved.
Fixes #1802
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(javascript): require emitted interop helper definitions
Only trust an interop helper name when it has a single program-scope
function definition, and state that helper bodies are not evaluated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
91f843bf38
commit
6ac5c36503
5 files changed
+187
-14
No files matched your search
@@ -323,8 +323,15 @@ substitute version-dependent Electron defaults for omitted `webPreferences`.
|
||||
Dynamic option objects, bridge keys, API objects, and IPC channel expressions
|
||||
remain unknown and make coverage partial.
|
||||
|
||||
Electron API identity follows proven lexical module aliases. Reassigned bindings,
|
||||
dynamic selections, and visibly overwritten namespace members are unresolved.
|
||||
Electron API identity follows proven lexical module aliases. Bundled CommonJS
|
||||
namespace imports are also followed through the interop helpers that bundlers
|
||||
and compilers emit (`__toESM`, `__importStar`, `_interopRequireWildcard`),
|
||||
including the `electron = __toESM(electron)` rewrap that Rolldown emits. This
|
||||
step is inferred: a helper is recognized by its emitted name and a single
|
||||
program-scope function definition, and its body is not evaluated, so a local
|
||||
function that reuses one of these names is trusted to pass the module through.
|
||||
Other reassigned bindings, dynamic selections, and visibly overwritten namespace
|
||||
members are unresolved.
|
||||
Direct and destructuring assignment, loop, update, and delete targets are checked
|
||||
through namespace aliases; unrelated member writes do not discard observed API
|
||||
identity. This static check does
|
||||
|
||||
@@ -73,4 +73,51 @@ describe("Electron API identity", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("follows bundler namespace interop helpers without accepting other writes", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
var __toESM = (mod) => mod;
|
||||
var __importStar = (this && this.__importStar) || ((mod) => mod);
|
||||
let electron = require("electron");
|
||||
electron = __toESM(electron);
|
||||
new electron.BrowserWindow({ webPreferences: { preload: "./rolldown.js" } });
|
||||
var import_electron = __toESM(require("electron"));
|
||||
new import_electron.BrowserWindow({ webPreferences: { preload: "./esbuild.js" } });
|
||||
function _interopRequireWildcard(mod) { return mod; }
|
||||
const Electron = __importStar(require("electron"));
|
||||
new Electron.BrowserWindow({ webPreferences: { preload: "./tsc.js" } });
|
||||
|
||||
let replaced = require("electron");
|
||||
replaced = __toESM(require("./fake"));
|
||||
new replaced.BrowserWindow({ webPreferences: { preload: "./replaced.js" } });
|
||||
let rewritten = require("electron");
|
||||
rewritten = __toESM(rewritten);
|
||||
rewritten = fake;
|
||||
new rewritten.BrowserWindow({ webPreferences: { preload: "./rewritten.js" } });
|
||||
const babel = _interopRequireWildcard(require("electron"));
|
||||
new babel.BrowserWindow({ webPreferences: { preload: "./babel.js" } });
|
||||
function nested() {
|
||||
const __toESM = (mod) => mod;
|
||||
const scoped = __toESM(require("electron"));
|
||||
new scoped.BrowserWindow({ webPreferences: { preload: "./nested.js" } });
|
||||
}
|
||||
`);
|
||||
|
||||
expect(
|
||||
analysis.electron.browser_windows.map(({ preload_path }) => preload_path),
|
||||
).toEqual(["./rolldown.js", "./esbuild.js", "./tsc.js", "./babel.js"]);
|
||||
});
|
||||
|
||||
it("does not trust unbound helper names or non-function helper values", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const __toESM = makeLookalike();
|
||||
const electron = __toESM(require("electron"));
|
||||
new electron.BrowserWindow({});
|
||||
let unbound = require("electron");
|
||||
unbound = __importStar(unbound);
|
||||
new unbound.BrowserWindow({});
|
||||
`);
|
||||
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export const electronExportPath = (
|
||||
|
||||
/** Static identity recovery cannot establish the absence of Electron usage. */
|
||||
export const ELECTRON_IDENTITY_LIMITATION =
|
||||
"Electron API identity follows proven lexical module aliases. Reassigned bindings, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated.";
|
||||
"Electron API identity follows proven lexical module aliases, including results of namespace interop helpers (__toESM, __importStar, _interopRequireWildcard) recognized by name and a single program-scope function definition; helper bodies are not evaluated. Other reassigned bindings, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated.";
|
||||
|
||||
/** Preserve exact property segments instead of flattening literal dotted keys. */
|
||||
export const electronMemberPath = (
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
completeSemanticSteps,
|
||||
traverseJavaScriptAstSteps,
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
|
||||
import {
|
||||
semanticNamespaceInteropOperand,
|
||||
semanticRequireOrigin,
|
||||
} from "./javascriptSemanticRequireOrigin.js";
|
||||
import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js";
|
||||
import {
|
||||
collectSemanticReturns,
|
||||
@@ -384,7 +387,10 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
|
||||
}
|
||||
return exported ?? undefined;
|
||||
}
|
||||
const origin = semanticRequireOrigin(root, state)?.origin;
|
||||
const origin = semanticRequireOrigin(
|
||||
semanticNamespaceInteropOperand(root, state) ?? root,
|
||||
state,
|
||||
)?.origin;
|
||||
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
|
||||
? electronExportPath(origin.importedPath)
|
||||
: undefined;
|
||||
@@ -416,14 +422,15 @@ const electronExport = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): readonly string[] | null => {
|
||||
if (binding.definitions.some(({ kind }) => kind === "assignment"))
|
||||
return null;
|
||||
const directOrigin = bindingOrigin(binding, state);
|
||||
const [initializer] = binding.initializers;
|
||||
const initializers = namespacePreservingInitializers(binding, state);
|
||||
if (initializers === undefined) return null;
|
||||
const directOrigin = bindingOrigin(binding, initializers, state);
|
||||
const [initializer] = initializers;
|
||||
if (
|
||||
directOrigin === undefined &&
|
||||
(initializer === undefined ||
|
||||
binding.initializers.length !== 1 ||
|
||||
initializers.length !== 1 ||
|
||||
initializers.length !== binding.initializers.length ||
|
||||
state.conditionalInitializers.has(initializer.node))
|
||||
)
|
||||
return null;
|
||||
@@ -441,25 +448,66 @@ const electronExport = (
|
||||
: null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the initializers that can change which module a binding names.
|
||||
* Bundlers emit `name = __toESM(name)` after a CommonJS require; rewrapping
|
||||
* the binding's own value keeps every member, so it is not a reassignment.
|
||||
*/
|
||||
const namespacePreservingInitializers = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticBindingState["initializers"] | undefined => {
|
||||
const initializers = binding.initializers.filter(
|
||||
({ node }) => !rewrapsOwnNamespace(binding, node, state),
|
||||
);
|
||||
const rewraps = binding.initializers.length - initializers.length;
|
||||
const assignments = binding.definitions.filter(
|
||||
({ kind }) => kind === "assignment",
|
||||
).length;
|
||||
return assignments === rewraps ? initializers : undefined;
|
||||
};
|
||||
|
||||
const rewrapsOwnNamespace = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
const assignment = state.parentsByNode.get(node);
|
||||
const operand = semanticNamespaceInteropOperand(node, state);
|
||||
return (
|
||||
t.isAssignmentExpression(assignment) &&
|
||||
assignment.operator === "=" &&
|
||||
assignment.right === node &&
|
||||
t.isIdentifier(assignment.left, { name: binding.name }) &&
|
||||
t.isIdentifier(operand, { name: binding.name }) &&
|
||||
!semanticResolutionBlocked(state, operand, operand.name) &&
|
||||
resolveSemanticBindingState(state, operand, operand.name) === binding
|
||||
);
|
||||
};
|
||||
|
||||
const bindingOrigin = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
initializers: JavaScriptSemanticBindingState["initializers"],
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptModuleOrigin | undefined => {
|
||||
if (binding.directOrigins.length > 0)
|
||||
return binding.directOrigins.length === 1
|
||||
? binding.directOrigins[0]
|
||||
: undefined;
|
||||
const [initializer] = binding.initializers;
|
||||
const [initializer] = 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") ||
|
||||
initializers.length !== 1 ||
|
||||
state.conditionalInitializers.has(initializer.node)
|
||||
)
|
||||
return undefined;
|
||||
const origin = semanticRequireOrigin(initializer.node, state)?.origin;
|
||||
const origin = semanticRequireOrigin(
|
||||
semanticNamespaceInteropOperand(initializer.node, state) ??
|
||||
initializer.node,
|
||||
state,
|
||||
)?.origin;
|
||||
const projection = initializer.projection;
|
||||
return origin === undefined ||
|
||||
!projection.every((key): key is string => typeof key === "string")
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
resolveSemanticBindingState,
|
||||
semanticResolutionBlocked,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
@@ -58,3 +59,73 @@ export const semanticRequireOrigin = (
|
||||
call: current,
|
||||
};
|
||||
};
|
||||
|
||||
// esbuild/Rolldown, TypeScript, and Babel emit these helpers for namespace
|
||||
// imports of CommonJS modules; each returns an object exposing every own
|
||||
// member of its operand, so member paths through the result are unchanged.
|
||||
// Helper bodies are not evaluated: recognition trusts a single program-scope
|
||||
// function definition under one of these emitted names.
|
||||
const NAMESPACE_INTEROP_HELPERS = new Set([
|
||||
"__importStar",
|
||||
"__toESM",
|
||||
"_interopRequireWildcard",
|
||||
]);
|
||||
|
||||
/** Return the module operand of a locally declared namespace interop helper. */
|
||||
export const semanticNamespaceInteropOperand = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): t.Expression | undefined => {
|
||||
const call = unwrapJavaScriptExpression(node).node;
|
||||
if (
|
||||
!t.isCallExpression(call) ||
|
||||
!t.isIdentifier(call.callee) ||
|
||||
!NAMESPACE_INTEROP_HELPERS.has(call.callee.name) ||
|
||||
semanticResolutionBlocked(state, call.callee, call.callee.name)
|
||||
)
|
||||
return undefined;
|
||||
const helper = resolveSemanticBindingState(
|
||||
state,
|
||||
call.callee,
|
||||
call.callee.name,
|
||||
);
|
||||
const [operand] = call.arguments;
|
||||
return helper !== undefined &&
|
||||
emittedInteropHelper(helper, state) &&
|
||||
t.isExpression(operand)
|
||||
? operand
|
||||
: undefined;
|
||||
};
|
||||
|
||||
// Emitters declare each helper once at program scope as a function, an
|
||||
// arrow/function expression, or `(this && this.name) || <function>`.
|
||||
const emittedInteropHelper = (
|
||||
helper: JavaScriptSemanticBindingState,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
const [initializer] = helper.initializers;
|
||||
if (
|
||||
initializer === undefined ||
|
||||
helper.initializers.length !== 1 ||
|
||||
helper.definitions.length !== 1 ||
|
||||
(helper.kind !== "function" && helper.kind !== "variable") ||
|
||||
state.scopesById.get(helper.scopeId)?.kind !== "program"
|
||||
)
|
||||
return false;
|
||||
const value = unwrapJavaScriptExpression(initializer.node).node;
|
||||
const emitted =
|
||||
t.isLogicalExpression(value, { operator: "||" }) &&
|
||||
t.isLogicalExpression(unwrapJavaScriptExpression(value.left).node, {
|
||||
operator: "&&",
|
||||
})
|
||||
? unwrapJavaScriptExpression(value.right).node
|
||||
: value;
|
||||
return (
|
||||
t.isFunctionDeclaration(emitted) ||
|
||||
t.isFunctionExpression(emitted) ||
|
||||
t.isArrowFunctionExpression(emitted) ||
|
||||
(t.isCallExpression(emitted) &&
|
||||
(t.isFunctionExpression(emitted.callee) ||
|
||||
t.isArrowFunctionExpression(emitted.callee)))
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user