fix(electron): consume shared native loader and export facts
This commit is contained in:
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(),
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user