fix(electron): consume shared native loader and export facts

This commit is contained in:
morluto committed 2026-10-24 02:35:51 +08:00
1 parent 75daf0c7df
commit 5a7bddb3ae
7 files changed
+254 -52

No files matched your search

@@ -514,6 +514,14 @@ candidate. Dynamic channels are never paired, and multiple compatible handlers
are reported as ambiguous without adding a caller-to-handler edge. A matching
channel still does not prove registration order, reachability, or runtime use.
Native-addon call bindings consume the same loader identity and normalized
arguments as dependency analysis, including optional calls. Local methods or
parameters named `require` establish no native binding; `require.resolve` records
lookup rather than a native namespace. CommonJS re-export bindings require a
selected export value and an applicable shared export receiver. Arithmetic and
logical compound stores keep their dependency observations without asserting that
the addon is the selected exported value.
For `.node` bindings, member names mean “requested by JavaScript syntax.” A
resolved add-on path does not convert them into verified native exports. Native
symbol verification remains a separate deep-analysis claim.
@@ -15,14 +15,23 @@ import {
import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js";
import {
argumentNode,
calleeName,
range,
stringValue,
} from "./javascriptStaticAnalysisHelpers.js";
import {
propertyName,
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticRequireCallAt } from "./javascriptSemanticRequireOrigin.js";
import {
commonJsRuntimeExportValues,
indexCommonJsExportReceiversSteps,
type CommonJsExportReceiverEffect,
type CommonJsExportSite,
} from "./javascriptCommonJsExportReceivers.js";
import type { JavaScriptReceiverIndex } from "./javascriptSemanticAnalysis.js";
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
interface NativeBindingInput {
@@ -44,10 +53,44 @@ export const inspectElectronNativeNode = (
if (t.isImportDeclaration(node)) inspectImport(node, context);
else if (t.isExportNamedDeclaration(node)) inspectNamedExport(node, context);
else if (t.isExportAllDeclaration(node)) inspectExportAll(node, context);
else if (t.isVariableDeclarator(node)) inspectRequireBinding(node, context);
else if (t.isAssignmentExpression(node)) inspectReExport(node, context);
else if (t.isVariableDeclarator(node) || t.isAssignmentExpression(node)) {
const value = nativeCallReceiver(
t.isVariableDeclarator(node) ? node.init : node.right,
);
// Admission is syntax only; the shared call owner proves loader and arguments later.
if (
value !== undefined &&
value.arguments.some((argument) =>
t.isArrayExpression(argument)
? argument.elements.some((element) =>
isNativeSpecifier(stringValue(element) ?? "", "require"),
)
: isNativeSpecifier(stringValue(argument) ?? "", "require"),
)
)
context.accumulator.pendingNativeAddonBindings.push(node);
}
};
/** Shared loader, argument and receiver facts settle before native projection. */
export function* finishElectronNativeBindingsSteps(
context: JavaScriptFindingContext,
index: JavaScriptReceiverIndex,
): Generator<void, void> {
const pending = context.accumulator.pendingNativeAddonBindings;
if (pending.length === 0) return;
yield* index.indexMemberMutationFactsSteps();
const receivers = pending.some((node) => t.isAssignmentExpression(node))
? yield* indexCommonJsExportReceiversSteps(index.bindings)
: 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));
}
pending.length = 0;
}
const inspectImport = (
node: t.ImportDeclaration,
context: JavaScriptFindingContext,
@@ -140,8 +183,9 @@ const inspectExportAll = (
const inspectRequireBinding = (
node: t.VariableDeclarator,
context: JavaScriptFindingContext,
bindings: JavaScriptSemanticAnalysisState,
): void => {
const required = nativeRequire(node.init);
const required = nativeRequire(node.init, context, bindings);
if (required === undefined) return;
addBinding({
context,
@@ -156,9 +200,20 @@ const inspectRequireBinding = (
const inspectReExport = (
node: t.AssignmentExpression,
context: JavaScriptFindingContext,
bindings: JavaScriptSemanticAnalysisState,
receiver: CommonJsExportReceiverEffect | undefined,
): void => {
const required = nativeRequire(node.right);
if (required === undefined || !isModuleExport(node.left)) return;
const exported = commonJsRuntimeExportValues(node, bindings);
if (exported.length === 0 || receiver === "detached") return;
if (receiver === "uncertain") {
context.accumulator.unknownFindings += 1;
return;
}
// The export owner leaves arithmetic/logical stores without a selected RHS.
const value = exported[0]?.value;
if (value == null) return;
const required = nativeRequire(value, context, bindings);
if (required === undefined) return;
addBinding({
context,
node,
@@ -198,8 +253,22 @@ const addBinding = (input: NativeBindingInput): void => {
});
};
const nativeCallReceiver = (
node: t.Node | null | undefined,
): t.CallExpression | t.OptionalCallExpression | undefined => {
if (node == null) return undefined;
node = unwrapJavaScriptExpression(node).node;
while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node))
node = unwrapJavaScriptExpression(node.object).node;
return t.isCallExpression(node) || t.isOptionalCallExpression(node)
? node
: undefined;
};
const nativeRequire = (
node: t.Node | null | undefined,
context: JavaScriptFindingContext,
bindings: JavaScriptSemanticAnalysisState,
):
| {
readonly specifier: string;
@@ -207,6 +276,8 @@ const nativeRequire = (
readonly access: NativeMemberAccess;
}
| undefined => {
if (node == null) return undefined;
node = unwrapJavaScriptExpression(node).node;
let member: string | null = null;
let hasMemberAccess = false;
while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
@@ -215,28 +286,41 @@ const nativeRequire = (
// Walking inward leaves the first member consumed from the addon itself.
member = property;
hasMemberAccess = true;
node = node.object;
node = unwrapJavaScriptExpression(node.object).node;
}
if (!t.isCallExpression(node)) return undefined;
const name = calleeName(node.callee);
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return undefined;
const call = semanticRequireCallAt(
node,
bindings,
context.accumulator.factoryRequireBindingIds,
);
if (
name !== "require" &&
!name.endsWith(".require") &&
!name.includes("__webpack_require__")
call === undefined ||
call.loader === "local" ||
call.operation !== "load"
)
return undefined;
const specifier = stringValue(argumentNode(node.arguments[0]));
return specifier !== undefined && isNativeSpecifier(specifier, "require")
? {
specifier,
hasMemberAccess,
access: {
members: member === null ? [] : [member],
namespaceAccess: !hasMemberAccess,
dynamicMemberAccess: hasMemberAccess && member === null,
},
}
: undefined;
const specifier = stringValue(argumentNode(call.arguments?.[0]));
if (specifier === undefined || !isNativeSpecifier(specifier, "require"))
return undefined;
if (
call.loader === "unknown" ||
!call.sourceRelative ||
call.sourceRelativePath !== undefined
) {
context.accumulator.unknownFindings += 1;
return undefined;
}
return {
specifier,
hasMemberAccess,
access: {
members: member === null ? [] : [member],
namespaceAccess: !hasMemberAccess,
dynamicMemberAccess: hasMemberAccess && member === null,
},
};
};
interface NativeMemberAccess {
@@ -263,18 +347,6 @@ const bindingMembers = (pattern: t.Node): NativeMemberAccess => {
return { members, namespaceAccess, dynamicMemberAccess };
};
const isModuleExport = (node: t.Node): boolean => {
const keys: (string | null)[] = [];
while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
keys.unshift(semanticStaticPropertyKey(node.property, node.computed));
node = node.object;
}
if (!t.isIdentifier(node)) return false;
return node.name === "exports"
? keys.length > 0
: node.name === "module" && keys[0] === "exports";
};
const isNativeSpecifier = (
specifier: string,
moduleKind: ElectronNativeAddonBindingFinding["module_kind"],
@@ -654,6 +654,7 @@ const loaderInvocationInterop = (state: JavaScriptSemanticAnalysisState) => {
};
export interface JavaScriptRequireCallFact {
readonly operation: "load" | "resolve";
readonly loader: JavaScriptRequireLoaderFact;
readonly arguments:
| readonly t.CallExpression["arguments"][number][]
@@ -670,7 +671,12 @@ export const semanticRequireCallAt = (
factoryBindingIds: ReadonlySet<string>,
): JavaScriptRequireCallFact | undefined => {
if (semanticModuleRequireCallAt(node, state))
return { loader: "unknown", arguments: undefined, sourceRelative: false };
return {
operation: "load",
loader: "unknown",
arguments: undefined,
sourceRelative: false,
};
const callee = unwrapJavaScriptExpression(node.callee).node;
if (
(t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) &&
@@ -680,7 +686,12 @@ export const semanticRequireCallAt = (
selectedRequireSourcesAt(callee, node, state, factoryBindingIds)?.loader ===
"local"
)
return { loader: "local", arguments: undefined, sourceRelative: false };
return {
operation: "load",
loader: "local",
arguments: undefined,
sourceRelative: false,
};
if (
(t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) &&
semanticStaticPropertyKey(callee.property, callee.computed) === "resolve"
@@ -701,6 +712,7 @@ export const semanticRequireCallAt = (
factoryBindingIds,
);
return {
operation: "resolve",
loader: selected?.loader ?? loader,
arguments: node.arguments,
sourceRelative:
@@ -726,6 +738,7 @@ export const semanticRequireCallAt = (
);
if (selected !== undefined || loader !== "local")
return {
operation: "load",
loader: selected?.loader ?? loader,
arguments: node.arguments,
sourceRelative: selected?.sourceRelative ?? loader !== "create-require",
@@ -767,6 +780,7 @@ export const semanticRequireCallAt = (
factoryBindingIds,
);
return {
operation: "load",
loader:
invocation.path[1] === "local"
? "local"
@@ -790,6 +804,7 @@ export const semanticRequireCallAt = (
return factory === undefined
? undefined
: {
operation: "load",
loader: factory,
arguments: node.arguments.slice(t.isIdentifier(node.callee) ? 0 : 1),
sourceRelative: true,
@@ -15,6 +15,7 @@ import {
} from "./javascriptSemanticTraversal.js";
import { isElectronRendererLoad } from "./electronStaticRenderer.js";
import { finishElectronNativeBindingsSteps } from "./electronStaticAnalysisNative.js";
import { inspectElectronStaticNode } from "./electronStaticAnalysis.js";
import { hasPossibleCommonJsDelegateWrite } from "./javascriptSemanticRequireOrigin.js";
import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js";
@@ -124,6 +125,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
);
yield* traverseStaticSourceSteps(file, findings, bindings, routeScan);
const routes = yield* routeScan.finishSteps();
yield* finishElectronNativeBindingsSteps(findings, index);
yield* finishElectronBrowserWindowsSteps(findings, index);
if (accumulator.pendingModuleRequireCalls.length > 0) {
yield* index.indexMemberMutationFactsSteps();
@@ -77,6 +77,10 @@ export interface JavaScriptAnalysisAccumulator {
readonly senderValidations: LocatedJavaScriptFinding<ElectronSenderValidationFinding>[];
readonly utilityProcesses: LocatedJavaScriptFinding<ElectronUtilityProcessFinding>[];
readonly nativeAddonBindings: LocatedJavaScriptFinding<ElectronNativeAddonBindingFinding>[];
readonly pendingNativeAddonBindings: (
| t.VariableDeclarator
| t.AssignmentExpression
)[];
readonly moduleRangeIndex: JavaScriptModuleRangeIndex;
/** Binding identities proven as third-parameter loaders of recovered factories. */
readonly factoryRequireBindingIds: Set<string>;
@@ -157,6 +161,7 @@ export const createJavaScriptAnalysisAccumulator = (
senderValidations: [],
utilityProcesses: [],
nativeAddonBindings: [],
pendingNativeAddonBindings: [],
moduleRangeIndex: new JavaScriptModuleRangeIndex(sourceLength),
factoryRequireBindingIds: new Set(),
seen: new Set(),
+3 -14
View File
@@ -108,7 +108,7 @@ export const referenceSourceRequireCalls = (
: "source-file"
: "unknown";
const searchBase = requireResolveSearchBase(
node,
callFact.operation,
callFact.arguments,
index.bindings,
base,
@@ -144,14 +144,14 @@ export const referenceSourceRequireCalls = (
/** `require.resolve` options replace the default search paths unless exact current-directory paths are supplied. */
const requireResolveSearchBase = (
call: t.CallExpression | t.OptionalCallExpression,
operation: JavaScriptRequireCallFact["operation"],
callArguments: JavaScriptRequireCallFact["arguments"],
bindings: JavaScriptSemanticAnalysisState,
loaderBase: ReferenceSourceRequireResolutionBase,
hasCommonJsWrapper: boolean,
): ReferenceSourceRequireResolutionBase => {
const arguments_ = callArguments ?? [];
if (!isRequireResolveCall(call) || arguments_.length < 2) return loaderBase;
if (operation !== "resolve" || arguments_.length < 2) return loaderBase;
if (arguments_.length !== 2) return "unknown";
const options = t.isExpression(arguments_[1]) ? arguments_[1] : undefined;
if (options === undefined || !t.isObjectExpression(options)) return "unknown";
@@ -172,17 +172,6 @@ const requireResolveSearchBase = (
: "unknown";
};
const isRequireResolveCall = (
call: t.CallExpression | t.OptionalCallExpression,
): boolean => {
const callee = call.callee;
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
return false;
return (
semanticStaticPropertyKey(callee.property, callee.computed) === "resolve"
);
};
type CommonJsWrapperMode = "present" | "absent" | "unknown";
const commonJsWrapperMode = (
@@ -0,0 +1,111 @@
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
cliTest.each(["CLI", "stdio MCP"] as const)(
"uses shared loader and export ownership for native bindings through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-native-binding-ownership-");
await writeFixtureFiles(root, {
"ordinary.cjs": 'const addon = require("./addon.node");',
"optional.cjs": 'const addon = require?.("./addon.node");',
"plain.cjs": 'module.exports = require("./addon.node");',
"members.cjs":
'const {run} = require?.("./addon.node"); module.exports = require?.("./addon.node").open;',
"local.cjs":
'const helper = { require: name => ({name}) }; const local = helper.require("./addon.node"); function shadow(require) { const local = require("./addon.node"); }',
"shadow-export.cjs":
'function local(module, exports) { module.exports = require("./addon.node"); exports.addon = require("./addon.node"); }',
"detached.cjs":
'module.exports = {}; exports.addon = require("./addon.node");',
"arithmetic.cjs": 'exports.sum += require("./addon.node");',
"logical.cjs": 'module.exports ||= require("./addon.node");',
"resolver.cjs": 'const filename = require.resolve?.("./addon.node");',
"addon.node": "inert native addon placeholder; never executed",
});
const result = await analyzeJavaScriptDirectory(surface, cli, root);
expect(result.statistics.parse_failures).toBe(0);
const bindings = result.graph.nodes.flatMap((node) =>
node.kind === "native-export"
? node.observations.map((observation) => ({
source:
observation.evidence.location.available &&
observation.evidence.location.value.kind === "source-range"
? observation.evidence.location.value.source
: null,
kind: observation.properties.binding_kind,
members: observation.properties.requested_members,
target: observation.properties.resolved_path,
}))
: [],
);
expect(
bindings.sort(
(left, right) =>
String(left.source).localeCompare(String(right.source)) ||
String(left.kind).localeCompare(String(right.kind)),
),
).toEqual([
{
source: "members.cjs",
kind: "re-export",
members: ["open"],
target: "addon.node",
},
{
source: "members.cjs",
kind: "require",
members: ["run"],
target: "addon.node",
},
{
source: "optional.cjs",
kind: "require",
members: [],
target: "addon.node",
},
{
source: "ordinary.cjs",
kind: "require",
members: [],
target: "addon.node",
},
{
source: "plain.cjs",
kind: "re-export",
members: [],
target: "addon.node",
},
]);
expect(result.summary).toMatchObject({
native_addon_bindings: 5,
resolved_native_addon_bindings: 5,
});
// Loading and assigning an addon are separate: compound stores keep their load edge.
const dependencies = result.graph.edges.filter(
(edge) =>
(edge.relation === "imports" || edge.relation === "loads") &&
edge.properties.specifier === "./addon.node" &&
edge.evidence.extractor.operation === "map-static-relationship",
);
const dependencySources = dependencies.flatMap((edge) =>
edge.evidence.location.available &&
edge.evidence.location.value.kind === "source-range"
? [edge.evidence.location.value.source]
: [],
);
expect(dependencySources).toEqual(
expect.arrayContaining([
"ordinary.cjs",
"optional.cjs",
"arithmetic.cjs",
"logical.cjs",
"resolver.cjs",
]),
);
expect(dependencySources).not.toContain("local.cjs");
},
);