Files
rea/src/domain/javascript/javascriptAstFingerprint.ts
T
morluto b58adafa4f fix(javascript): align module lookup and source evidence
Preserve CommonJS literal paths and confined ESM URL semantics, including package main fallback, directory intent, Unicode identity, and present opaque modules. Parse extraction inputs at their AST/source owner, retain skipped HTML and source-map coverage, and distinguish endpoint candidates from proven receiver facts. Version structural fingerprints with computed literal contents and propagate them through worker contracts.

Reviewed fixes include #1810, #1840, #2642, #2700, #2702, #2714, #2803, #2960, #2968, #2986, #3035, and #3036.

Validation: 158 resolver tests passed; public CLI/MCP module lookup controls, package-main/Unicode workflows, and six builtin workflows passed in the current compiled lane. Worker schema/fingerprint regressions passed. Large public pdf.js completion and real Electron analysis remain unverified.
2026-10-14 08:46:50 +08:00

233 lines
7.4 KiB
TypeScript

import { createHash } from "node:crypto";
import * as t from "@babel/types";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import {
propertyName,
runtimeFunctionParameters,
semanticStaticPropertyKey,
} from "./javascriptAstValues.js";
import { stringValue } from "./javascriptStaticAnalysisHelpers.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
/** Static CommonJS export names. */
export interface StaticExports {
readonly values: readonly string[];
}
/** Derive a rename-resistant syntax fingerprint without code execution. */
export const fingerprintJavaScriptAst = (node: t.Node): string => {
const tokens: string[] = [];
traverseJavaScriptAst(node, {
enter: (current) => {
tokens.push(...semanticTokens(current));
return undefined;
},
});
return createHash("sha256").update(JSON.stringify(tokens)).digest("hex");
};
/** Collect statically declared CommonJS/bundler export names from one factory. */
export const collectJavaScriptExports = (
node: t.Node,
bindings?: JavaScriptSemanticAnalysisState,
): StaticExports => {
const exports = new Set<string>();
const parameter = t.isFunction(node)
? runtimeFunctionParameters(node)[1]
: undefined;
const factoryExports =
bindings !== undefined && t.isFunction(node) && t.isIdentifier(parameter)
? resolveSemanticBindingState(bindings, parameter, parameter.name)
?.bindingId
: undefined;
const isExportsAlias = (reference: t.Node | null): boolean => {
if (!t.isIdentifier(reference)) return false;
if (factoryExports === undefined) return reference.name === "exports";
return (
bindings !== undefined &&
resolveSemanticBindingState(bindings, reference, reference.name)
?.bindingId === factoryExports
);
};
traverseJavaScriptAst(node, {
enter: (current) => {
if (t.isAssignmentExpression(current))
collectAssignmentExports(
current.left,
current.right,
exports,
isExportsAlias,
);
if (t.isCallExpression(current))
collectCallExports(current, exports, isExportsAlias);
return undefined;
},
});
return {
values: [...exports].sort(compareUnicodeCodePoints),
};
};
const semanticTokens = (node: t.Node): string[] => {
const tokens = [`node:${node.type}`];
if (t.isStringLiteral(node)) tokens.push(`string:${node.value}`);
else if (t.isNumericLiteral(node))
tokens.push(`number:${String(node.value)}`);
else if (t.isBooleanLiteral(node))
tokens.push(`boolean:${String(node.value)}`);
else if (t.isRegExpLiteral(node))
tokens.push(`regexp:${node.pattern}/${node.flags}`);
else if (t.isTemplateElement(node))
tokens.push(`template:${JSON.stringify(node.value)}`);
else if (t.isBinaryExpression(node) || t.isLogicalExpression(node))
tokens.push(`operator:${node.operator}`);
else if (t.isUnaryExpression(node) || t.isUpdateExpression(node))
tokens.push(`operator:${node.operator}`);
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
// A computed identifier names a local binding, not a fixed member.
tokens.push(`computed:${String(node.computed)}`);
if (!node.computed) {
const property = propertyName(node.property);
if (property !== "") tokens.push(`property:${property}`);
}
}
if ((t.isObjectProperty(node) || t.isObjectMethod(node)) && !node.computed) {
const key = propertyName(node.key);
if (key !== "") tokens.push(`key:${key}`);
}
return tokens;
};
const collectAssignmentExports = (
left: t.LVal | t.OptionalMemberExpression,
right: t.Expression,
output: Set<string>,
isExportsAlias: (root: t.Node | null) => boolean,
): void => {
const path = memberPath(left);
if (path === null) return;
const root = memberRoot(left);
const exportsAlias = isExportsAlias(root);
// Rebinding the CommonJS alias does not replace module.exports.
if (path.length === 1 && exportsAlias) return;
const exportOffset = exportsAlias
? 1
: path[0] === "module" && path[1] === "exports"
? 2
: null;
if (exportOffset === null) return;
if (path.length === exportOffset) {
if (t.isObjectExpression(right)) collectObjectKeys(right, output);
else addExport(output, "default");
return;
}
// A CommonJS export assignment exposes the first property below the
// exports object. Deeper writes mutate that exported value, rather than
// creating an export whose name is the dotted member path.
addExport(output, path[exportOffset]!);
};
const collectCallExports = (
call: t.CallExpression,
output: Set<string>,
isExportsAlias: (root: t.Node | null) => boolean,
): void => {
const callee = memberPath(call.callee);
if (callee === null) return;
const definePropertyCall =
callee.length === 2 &&
callee[0] === "Object" &&
callee[1] === "defineProperty";
const declarationCall = callee.length >= 2 && callee.at(-1) === "d";
if (!definePropertyCall && !declarationCall) return;
const targetNode = call.arguments[0];
const target = memberPath(targetNode);
const exportTarget =
isExportsAlias(targetNode ?? null) ||
(target !== null &&
target.length === 2 &&
target[0] === "module" &&
target[1] === "exports");
if (!exportTarget) return;
if (definePropertyCall) {
const name = stringValue(call.arguments[1]);
if (name !== undefined) addExport(output, name);
return;
}
const declarations = call.arguments[1];
if (t.isObjectExpression(declarations))
collectObjectKeys(declarations, output);
};
const memberRoot = (node: t.Node | null | undefined): t.Node | null => {
let current = node;
while (
current !== null &&
current !== undefined &&
(t.isMemberExpression(current) || t.isOptionalMemberExpression(current))
)
current = current.object;
return current ?? null;
};
const collectObjectKeys = (
object: t.ObjectExpression,
output: Set<string>,
): void => {
for (const property of object.properties) {
if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) continue;
const name = semanticStaticPropertyKey(property.key, property.computed);
// In an object literal, a non-computed `__proto__: value` sets the
// prototype instead of creating an own property. Shorthand, computed,
// and method forms do create own properties.
if (
name !== null &&
!(
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__"
)
)
addExport(output, name);
}
};
const addExport = (output: Set<string>, name: string): void => {
output.add(name);
};
const memberPath = (
node: t.Node | null | undefined,
): readonly string[] | null => {
const properties: string[] = [];
let current = node;
while (
current !== undefined &&
current !== null &&
(t.isMemberExpression(current) || t.isOptionalMemberExpression(current))
) {
const property = semanticStaticPropertyKey(
current.property,
current.computed,
);
if (property === null) return null;
properties.push(property);
current = current.object;
}
if (current === undefined || current === null) return null;
const root = t.isIdentifier(current)
? current.name
: t.isThisExpression(current)
? "this"
: null;
return root === null ? null : [root, ...properties.reverse()];
};