fix(javascript): distinguish resolver discovery from module loading

Preserve load and resolve operations through semantic facts, worker transfer and graph Evidence. Only loads propagate executable ownership. Project ambient member writes and escapes through the shared path- and site-aware mutation owner; retain source literals, bases and uncertainty. Cover ownership and mutation boundaries through CLI and MCP.
This commit is contained in:
morluto committed 2026-10-24 02:35:59 +08:00
1 parent 635f673a8c
commit f91b7f8009
14 files changed
+351 -17

No files matched your search

@@ -777,6 +777,16 @@ does not change the source file or call-site Evidence identity. If the filename
cannot be proven, REA keeps the require target unresolved rather than silently
resolving it from the caller's directory.
Module-reference graph edges, including native-addon `loads` edges, retain
`properties.operation`: `load` identifies
an import or loader invocation that may execute the target; `resolve` identifies
a proven `require.resolve()` lookup that discovers a target without executing it.
Both retain the exact specifier, resolution mode, selected resolution base, and
call-site Evidence. Resolve-only edges do not propagate application ownership or
create executable-import gaps when their targets are missing. A modified
`.resolve` member remains an uncertain `load`, because its replacement may execute
code. These static facts do not establish that a call runs at runtime.
ESM relative module paths, legacy package subpaths without exports, and selected
package exports targets use URL suffix handling and one percent-decoding pass.
Bare specifiers retain their complete query/fragment text for exports key matching.
@@ -205,6 +205,7 @@ const addReference = (
? null
: resolveReference(context, input.file.path, input.value, input.coverage);
if (
input.value.operation === "load" &&
(input.value.kind === "static-import" ||
input.value.kind === "dynamic-import" ||
input.value.kind === "require") &&
@@ -222,6 +223,7 @@ const addReference = (
resolved !== null &&
resolved.path !== null &&
resolved.file !== null &&
input.value.operation === "load" &&
(input.value.kind === "static-import" ||
input.value.kind === "dynamic-import" ||
input.value.kind === "require")
@@ -305,6 +307,7 @@ const addReference = (
relation: resolved?.file?.kind === "native-addon" ? "loads" : "imports",
properties: {
kind: input.value.kind,
operation: input.value.operation,
specifier: input.value.specifier,
expression: input.value.expression,
resolution_base_path: input.value.resolution_base_path ?? null,
@@ -1,7 +1,10 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import {
indexJavaScriptBindingsSteps,
indexJavaScriptBindingsWithReceiversSteps,
} from "./javascriptSemanticAnalysis.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
@@ -103,3 +106,25 @@ new Worker(new URL("./worker.js", import.meta.url));`;
);
expect(worker?.specifier).toBe(specifier);
});
it("retains getter-derived ambient escapes once per invoked read site", () => {
const source = `const box={loader:require,get trigger(){this.loader.resolve=this.loader;return 0}};
box.trigger;
box.trigger;`;
const parsed = parseJavaScriptSource(source);
if (parsed === null) throw new Error("Expected getter fixture");
const index = completeSemanticSteps(
indexJavaScriptBindingsWithReceiversSteps(parsed.program),
);
completeSemanticSteps(index.indexMemberMutationFactsSteps());
const effects = index.bindings.ambientEscapeEffects.filter(
({ path }) =>
JSON.stringify(path) === JSON.stringify(["global", "require", null]),
);
expect(
effects.map(({ node }) =>
node === undefined ? null : source.slice(node.start ?? 0, node.end ?? 0),
),
).toEqual(["box.trigger", "box.trigger"]);
expect(effects[0]?.node).not.toBe(effects[1]?.node);
});
@@ -49,6 +49,36 @@ const canonicalAmbientPath = (
type AmbientPath = readonly (string | null)[];
const retainedAmbientEffects = new WeakMap<
JavaScriptSemanticAnalysisState,
Map<t.Node | undefined, Set<string>>
>();
/** Retain canonical ambient effects once, whether discovered by syntax or receiver expansion. */
export const retainSemanticAmbientEffect = (
state: JavaScriptSemanticAnalysisState,
kind: "write" | "escape",
path: AmbientPath,
node: t.Node | undefined,
): boolean => {
const selected = kind === "escape" ? [...path, null] : path;
const byNode = retainedAmbientEffects.get(state) ?? new Map();
const keys = byNode.get(node) ?? new Set<string>();
const key = JSON.stringify([kind, selected]);
if (keys.has(key)) return false;
keys.add(key);
byNode.set(node, keys);
retainedAmbientEffects.set(state, byNode);
if (kind === "write") {
state.ambientWrites.retain(selected);
state.ambientWriteEffects.push({ path: selected, node });
} else {
state.ambientEscapes.retain(selected);
state.ambientEscapeEffects.push({ path: selected, node });
}
return true;
};
/** Undefined means a cyclic or conflicting projection can select any depth. */
type AmbientProjection = AmbientPath | undefined;
@@ -641,17 +671,14 @@ export function* indexJavaScriptAmbientWritesSteps(
...path.slice(2),
]);
for (const resolved of paths) {
state.ambientWrites.retain(resolved);
state.ambientWriteEffects.push({ path: resolved, node });
retainSemanticAmbientEffect(state, "write", resolved, node);
}
}
for (const { node, target, browserReceiver, projection } of escapes)
for (const path of ambientWrites(target, node, true, projection))
// Only proven browser APIs keep their global receiver from user code.
if (!(browserReceiver && path.length === 1 && path[0] === "global")) {
const escaped = [...path, null];
state.ambientEscapes.retain(escaped);
state.ambientEscapeEffects.push({ path: escaped, node });
retainSemanticAmbientEffect(state, "escape", path, node);
}
// A function may call a browser method replaced later in source order.
if (
@@ -68,6 +68,7 @@ import { semanticBundledRequireOrigin } from "./javascriptBundledRequireOrigin.j
import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js";
import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js";
import {
createAmbientPathResolver,
indexJavaScriptAmbientWritesSteps,
isUnmodifiedGlobalReference,
} from "./javascriptSemanticAmbientWrites.js";
@@ -279,6 +280,20 @@ const visitSemanticReferenceEffectsAtSite = (
} else if (lookup.kind === "ambient" || lookup.kind === "uncertain-ambient") {
for (const node of state.ambientBindingWrites.get(root.name) ?? [])
if (!retain("ambient-write", node)) return metadata;
const ambientRoots = createAmbientPathResolver(state, false)(root);
for (const ambientRoot of ambientRoots) {
const selectedPath = [...ambientRoot, ...path];
for (const [kind, effects] of [
["member-write", state.ambientWriteEffects],
["escape", state.ambientEscapeEffects],
] as const)
for (const effect of effects)
if (
semanticPathsOverlap(selectedPath, effect.path) &&
!retain(kind, effect.node, effect.path.slice(ambientRoot.length))
)
return metadata;
}
}
return metadata;
};
@@ -1,4 +1,8 @@
import * as t from "@babel/types";
import {
createAmbientPathResolver,
retainSemanticAmbientEffect,
} from "./javascriptSemanticAmbientWrites.js";
import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js";
import {
isIntrinsicTypedArrayConstruction,
@@ -522,6 +526,7 @@ export function* collectSemanticMemberMutationsSteps(
iterableReferenceKeys.set(reference.node, keys);
iterableReferences.push(reference);
};
const ambientPaths = createAmbientPathResolver(state, true);
const markValue = (
node: t.Node,
path: PropertyPath,
@@ -577,6 +582,18 @@ export function* collectSemanticMemberMutationsSteps(
if (t.isIdentifier(expression)) {
const binding = resolveBinding(expression, expression.name);
if (binding === undefined) {
for (const path of ambientPaths(
expression,
current.originAt,
effect === "escape",
current.path.map((key) =>
key === null || typeof key === "object" ? null : String(key),
),
))
if (retainSemanticAmbientEffect(state, effect, path, mutation)) {
recordedEffectCount += 1;
clearSemanticValueCaches(state);
}
const processOrigin = semanticGlobalProcessOrigin(expression, state);
if (processOrigin !== undefined)
recordRequiredEffect(
@@ -1478,6 +1495,7 @@ const observableMutationPaths = (
prefix: PropertyPath,
) => boolean;
} => {
const ambientPaths = createAmbientPathResolver(state, false);
const bindingNodes = new Map<string, MutationObserverNode>();
const expressions = new WeakMap<t.Node, MutationObserverNode>();
const pendingExpressions: {
@@ -1617,6 +1635,8 @@ const observableMutationPaths = (
["globalThis", "global", "window", "self"].includes(expression.name)
)
(observer.origins ??= new Set()).add("ambient");
else if (ambientPaths(expression).length > 0)
(observer.origins ??= new Set()).add("opaque");
} else if (
t.isMemberExpression(expression) ||
t.isOptionalMemberExpression(expression)
@@ -1686,11 +1706,12 @@ const observableMutationPaths = (
add("opaque");
} else if (origin === "array-prototype" || origin === "opaque")
add(origin);
else if (
origin === "ambient" &&
semanticPropertyPathKeyMatches(key, "Array")
)
add("array");
else if (origin === "ambient")
add(
semanticPropertyPathKeyMatches(key, "Array")
? "array"
: "opaque",
);
else if (
origin === "array" &&
semanticPropertyPathKeyMatches(key, "prototype")
@@ -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"
@@ -691,6 +702,13 @@ export const semanticRequireCallAt = (
state,
factoryBindingIds,
);
if (loader === "unknown")
return {
operation: "load",
loader,
arguments: undefined,
sourceRelative: false,
};
// 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") {
@@ -701,6 +719,7 @@ export const semanticRequireCallAt = (
factoryBindingIds,
);
return {
operation: "resolve",
loader: selected?.loader ?? loader,
arguments: node.arguments,
sourceRelative:
@@ -726,6 +745,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 +787,7 @@ export const semanticRequireCallAt = (
factoryBindingIds,
);
return {
operation: "load",
loader:
invocation.path[1] === "local"
? "local"
@@ -790,6 +811,7 @@ export const semanticRequireCallAt = (
return factory === undefined
? undefined
: {
operation: "load",
loader: factory,
arguments: node.arguments.slice(t.isIdentifier(node.callee) ? 0 : 1),
sourceRelative: true,
@@ -91,6 +91,7 @@ export const inspectCall = (
node,
kind: "require",
specifier: requireCall.specifier,
operation: requireCall.operation,
...(requireCall.resolutionBasePath === undefined
? {}
: { resolutionBasePath: requireCall.resolutionBasePath }),
@@ -270,6 +271,7 @@ const requireCallSpecifier = (
resolveMemberChanged = false,
): {
readonly specifier: string | undefined;
readonly operation: "load" | "resolve";
readonly resolutionBasePath?: string;
} | null => {
const call = semanticRequireCallAt(
@@ -284,9 +286,13 @@ const requireCallSpecifier = (
!call.sourceRelative
) {
context.accumulator.unknownFindings += 1;
return { specifier: undefined };
return {
specifier: undefined,
operation: resolveMemberChanged ? "load" : call.operation,
};
}
return {
operation: call.operation,
specifier: argumentValue(call.arguments?.[0]),
...(call.sourceRelativePath === undefined
? {}
@@ -322,6 +328,7 @@ export const finishModuleRequireCalls = (
node,
kind: "require",
specifier: call.specifier,
operation: call.operation,
...(call.resolutionBasePath === undefined
? {}
: { resolutionBasePath: call.resolutionBasePath }),
@@ -947,10 +954,11 @@ export const addReference = (
: { specifier, expression: null };
addLocatedFinding(context, {
collection: context.accumulator.references,
key: `reference\0${input.kind}\0${reference.specifier ?? reference.expression}\0${input.resolutionBasePath ?? ""}`,
key: `reference\0${input.kind}\0${input.operation ?? "load"}\0${reference.specifier ?? reference.expression}\0${input.resolutionBasePath ?? ""}`,
node: input.node,
value: {
kind: input.kind,
operation: input.operation ?? "load",
...reference,
...(input.resolutionBasePath === undefined
? {}
@@ -157,7 +157,13 @@ it("retains a mutated require.resolve request as an unknown fact", () => {
analyzeJavaScriptStaticSource(source).references.filter(
({ kind }) => kind === "require",
),
).toEqual([expect.objectContaining({ kind: "require", specifier: null })]);
).toEqual([
expect.objectContaining({
kind: "require",
operation: "load",
specifier: null,
}),
]);
});
it("does not treat bundler factory resolve members as Node resolvers", () => {
@@ -110,6 +110,7 @@ export interface JavaScriptFindingContext {
/** Candidate import, require, worker, or service-worker reference. */
export interface JavaScriptReferenceInput {
readonly operation?: JavaScriptStaticReference["operation"];
readonly node: t.Node;
readonly kind: JavaScriptStaticReference["kind"];
readonly specifier: string | undefined;
@@ -22,6 +22,8 @@ export interface JavaScriptSourceRange {
}
interface JavaScriptStaticReferenceState {
/** Resolution discovers a target without loading or executing it. */
readonly operation: "load" | "resolve";
readonly kind:
| "static-import"
| "dynamic-import"
@@ -296,6 +296,7 @@ const electron = z
}));
const referenceShape = {
...findingContext,
operation: z.enum(["load", "resolve"]),
resolution_base_path: z.string().exactOptional(),
kind: z.enum([
"static-import",
@@ -40,9 +40,112 @@ const cases = [
},
{
name: "require.resolve does not execute its resolved module",
resolverSpecifier: "../shared/window.cjs",
resolverTarget: "shared/window.cjs",
load: 'require.resolve("../shared/window.cjs");',
possible: false,
},
{
name: "resolution discovery does not suppress a subsequent executable load",
resolverSpecifier: "../shared/window.cjs",
resolverTarget: "shared/window.cjs",
load: 'require.resolve("../shared/window.cjs");require("../shared/window.cjs");',
possible: true,
},
{
name: "sibling wrapper property writes preserve resolver identity",
resolverSpecifier: "../shared/window.cjs",
resolverTarget: "shared/window.cjs",
load: 'require.unrelated=custom;require.resolve("../shared/window.cjs");',
possible: false,
},
{
name: "later resolver writes do not change an earlier lookup",
resolverSpecifier: "../shared/window.cjs",
resolverTarget: "shared/window.cjs",
load: 'require.resolve("../shared/window.cjs");require.resolve=custom;',
possible: false,
},
{
name: "conditional wrapper resolver writes retain possible execution",
uncertainResolver: true,
load: 'if(flag)require.resolve=custom;require.resolve("../shared/window.cjs");',
possible: true,
},
{
name: "escaped wrapper resolver retains possible execution",
uncertainResolver: true,
load: 'consume(require);require.resolve("../shared/window.cjs");',
possible: true,
},
{
name: "invoked getter retains ambient resolver mutation",
uncertainResolver: true,
load: 'const box={loader:require,get trigger(){this.loader.resolve=this.loader;return 0}};box.trigger;require.resolve("../shared/window.cjs");',
possible: true,
},
{
name: "invoked getter retains qualified ambient resolver mutation",
uncertainResolver: true,
load: 'const box={loader:globalThis.require,get trigger(){this.loader.resolve=this.loader;return 0}};box.trigger;require.resolve("../shared/window.cjs");',
possible: true,
},
{
name: "unused getter preserves ambient resolver identity",
resolverSpecifier: "../shared/window.cjs",
resolverTarget: "shared/window.cjs",
load: 'const box={loader:require,get trigger(){this.loader.resolve=this.loader;return 0}};require.resolve("../shared/window.cjs");',
possible: false,
},
{
name: "later getter read preserves earlier ambient resolution",
resolverSpecifier: "../shared/window.cjs",
resolverTarget: "shared/window.cjs",
load: 'const box={loader:require,get trigger(){this.loader.resolve=this.loader;return 0}};require.resolve("../shared/window.cjs");box.trigger;',
possible: false,
},
{
name: "invoked getter retains lexical created resolver mutation",
uncertainResolver: true,
mainFile: "main.mjs",
load: 'import {createRequire} from "node:module";const loader=createRequire(import.meta.url);const box={loader,get trigger(){this.loader.resolve=this.loader;return 0}};box.trigger;loader.resolve("../shared/window.cjs");',
possible: true,
},
{
name: "replaced resolver retains possible executable ownership",
uncertainResolver: true,
load: 'require.resolve=require;require.resolve("../shared/window.cjs");',
possible: true,
},
{
name: "created resolver retains discovery without module execution",
resolverSpecifier: "../shared/window.cjs",
resolverTarget: "shared/window.cjs",
mainFile: "main.mjs",
load: 'import {createRequire} from "node:module";const load=createRequire(import.meta.url);load.resolve("../shared/window.cjs");',
possible: false,
},
{
name: "missing resolver target does not create executable ownership gaps",
missingTargetOperation: "resolve",
resolverSpecifier: "../missing/window.cjs",
resolverTarget: null,
load: 'require.resolve("../missing/window.cjs");',
possible: false,
},
{
name: "dynamic executable target retains application ownership uncertainty",
unresolvedExecutable: true,
load: "require(runtimeTarget);",
possible: true,
},
{
name: "modified created resolver retains possible executable ownership",
uncertainResolver: true,
mainFile: "main.mjs",
load: 'import {createRequire} from "node:module";const load=createRequire(import.meta.url);load.resolve=load;load.resolve("../shared/window.cjs");',
possible: true,
},
{
name: "require.main is not an implicit module loader",
load: 'require.main("../shared/window.cjs");',
@@ -256,10 +359,87 @@ cliTest.for(
root,
);
expect(statistics.parse_failures).toBe(0);
if ("resolverSpecifier" in testCase) {
expect(graph.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({
relation: "imports",
properties: expect.objectContaining({
kind: "require",
operation: "resolve",
specifier: testCase.resolverSpecifier,
resolution_mode: "require",
resolved_path: testCase.resolverTarget,
}),
}),
]),
);
}
if ("uncertainResolver" in testCase)
expect(graph.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({
relation: "imports",
properties: expect.objectContaining({
kind: "require",
operation: "load",
specifier: null,
}),
evidence: expect.objectContaining({
coverage: expect.objectContaining({ status: "partial" }),
}),
}),
]),
);
const roles = graph.nodes.filter(
({ kind }) => kind === "electron-renderer",
);
expect(roles).toHaveLength(1);
if (
"missingTargetOperation" in testCase ||
"unresolvedExecutable" in testCase
) {
const baseLimitation =
"Inventoried package metadata does not establish one application root for this source; the loadFile base is unknown.";
const executable = "unresolvedExecutable" in testCase;
expect(graph.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({
relation: "imports",
properties: expect.objectContaining({
operation: executable ? "load" : "resolve",
specifier: executable ? null : "../missing/window.cjs",
expression: executable ? "require(runtimeTarget)" : null,
resolved_path: null,
resolution_status: executable ? null : "not-found",
}),
evidence: expect.objectContaining({
coverage: expect.objectContaining({ status: "complete" }),
limitations: expect.arrayContaining([
"Static syntax does not prove that this relationship executes at runtime.",
]),
}),
}),
]),
);
expect(roles[0]?.observations).toEqual(
expect.arrayContaining([
expect.objectContaining({
properties: expect.objectContaining({
limitations: executable ? [baseLimitation] : [],
}),
evidence: expect.objectContaining({
coverage: expect.objectContaining({ status: "complete" }),
limitations: executable
? expect.arrayContaining([baseLimitation])
: expect.not.arrayContaining([baseLimitation]),
}),
}),
]),
);
}
expect(roles[0]?.observations).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -319,6 +319,7 @@ const expectCompetingLoaderGraph = (
"local module",
"projected module",
"resolve-only",
"createRequire base",
"missing wildcard alias",
"earlier reassigned module alias",
].includes(loader);
@@ -359,9 +360,21 @@ const expectCompetingLoaderGraph = (
relation: "imports",
properties: expect.objectContaining({
kind: "require",
specifier: null,
operation: "load",
specifier: "../shared/window.cjs",
resolution_base_path: "../other/nested/main.cjs",
resolution_status: "not-found",
resolved_path: null,
}),
evidence: expect.objectContaining({
location: expect.objectContaining({
available: true,
value: expect.objectContaining({
kind: "source-range",
source: "b/main.mjs",
}),
}),
}),
}),
]),
);