mirror of
https://github.com/morluto/rea
synced 2026-10-25 14:01:13 -04:00
f62a2baad8
Separate syntax, semantic uncertainty, examination limits and observed Electron applicability at the static producer, then preserve them through worker, graph and retained/inline summary views. Derive the established parse_status from the same facts and leave historical measurements unknown. Give semantic resource-limit classifications one schema owner. Replace repeated block-prefix searches and copies with collector-scoped syntax indexes. Keep invocation environments separate, charge indexing/replay to the existing work budget and yield during prefix work. Remove the superseded synchronous collector entrypoint. Validated producer, worker, CLI/MCP, historical and partial-coverage regressions, plus actual cancellation during indexing. Bounded authored 128/256-call controls preserved all prior facts while replacing repeated search/copy work with one index pass; this is not a throughput or agent-token benchmark.
853 lines
28 KiB
TypeScript
853 lines
28 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { posix } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import * as t from "@babel/types";
|
|
|
|
import type {
|
|
JavaScriptBundlerRegistration,
|
|
JavaScriptSourceRange,
|
|
JavaScriptStaticAnalysis,
|
|
JavaScriptStaticPathContext,
|
|
JavaScriptStaticStorage,
|
|
} from "./javascriptStaticAnalysisTypes.js";
|
|
import {
|
|
readExactJavaScriptLiteral,
|
|
runtimeFunctionParameters,
|
|
semanticStaticPropertyKey,
|
|
semanticStaticPropertyName,
|
|
unwrapJavaScriptExpression,
|
|
} from "./javascriptAstValues.js";
|
|
import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js";
|
|
import {
|
|
compareUnicodeCodePoints,
|
|
compositeKey,
|
|
} from "../unicodeCodePointOrder.js";
|
|
import { unexaminedJavaScriptStaticCoverage } from "./javascriptStaticCoverage.js";
|
|
|
|
/** Explicit result for source text that Babel cannot parse. */
|
|
export const failedJavaScriptStaticAnalysis = (): JavaScriptStaticAnalysis => ({
|
|
parse_status: "failed",
|
|
analysis_coverage: {
|
|
...unexaminedJavaScriptStaticCoverage(
|
|
"JavaScript source could not be parsed.",
|
|
),
|
|
syntax: { status: "failed", error_count: null },
|
|
},
|
|
parse_error_count: 1,
|
|
visited_ast_nodes: 0,
|
|
references: [],
|
|
endpoints: [],
|
|
storage: [],
|
|
bundler_registrations: [],
|
|
role_paths: [],
|
|
source_map_urls: [],
|
|
vendors: [],
|
|
electron: {
|
|
browser_windows: [],
|
|
context_bridge_apis: [],
|
|
ipc: [],
|
|
sender_validations: [],
|
|
utility_processes: [],
|
|
native_addon_bindings: [],
|
|
},
|
|
limitations: [
|
|
"JavaScript source could not be parsed; absence of findings is not evidence of absence.",
|
|
"JavaScript syntax was parsed as data and was never evaluated.",
|
|
],
|
|
});
|
|
|
|
/** Normalize one Babel call/new argument into the inert node shared by inspectors. */
|
|
export const argumentNode = (
|
|
argument:
|
|
| t.Expression
|
|
| t.SpreadElement
|
|
| t.JSXNamespacedName
|
|
| t.ArgumentPlaceholder
|
|
| null
|
|
| undefined,
|
|
): t.Node | undefined =>
|
|
argument !== null && t.isNode(argument) ? argument : undefined;
|
|
|
|
/** Recognize the runtime name for a Webpack/Rspack push call. */
|
|
export const chunkRuntime = (call: t.CallExpression): string | undefined => {
|
|
if (
|
|
!t.isMemberExpression(call.callee) &&
|
|
!t.isOptionalMemberExpression(call.callee)
|
|
)
|
|
return undefined;
|
|
if (
|
|
semanticStaticPropertyName(call.callee.property, call.callee.computed) !==
|
|
"push"
|
|
)
|
|
return undefined;
|
|
return findChunkRuntime(call.callee.object);
|
|
};
|
|
|
|
const findChunkRuntime = (node: t.Node): string | undefined => {
|
|
const pending: t.Node[] = [node];
|
|
while (pending.length > 0) {
|
|
const current = pending.pop();
|
|
if (current === undefined) continue;
|
|
if (
|
|
t.isIdentifier(current) &&
|
|
/(?:webpack|rspack)Chunk/iu.test(current.name)
|
|
)
|
|
return current.name;
|
|
if (
|
|
t.isMemberExpression(current) ||
|
|
t.isOptionalMemberExpression(current)
|
|
) {
|
|
const property = semanticStaticPropertyName(
|
|
current.property,
|
|
current.computed,
|
|
);
|
|
if (/(?:webpack|rspack)Chunk/iu.test(property)) return property;
|
|
if (t.isNode(current.object)) pending.push(current.object);
|
|
continue;
|
|
}
|
|
if (t.isAssignmentExpression(current)) {
|
|
pending.push(current.right, current.left);
|
|
continue;
|
|
}
|
|
if (t.isLogicalExpression(current) || t.isBinaryExpression(current)) {
|
|
pending.push(current.right, current.left);
|
|
continue;
|
|
}
|
|
const unwrapped = unwrapJavaScriptExpression(current).node;
|
|
if (unwrapped !== current) pending.push(unwrapped);
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
/** Return a literal function factory from one module-table property. */
|
|
export const moduleFactory = (
|
|
property: t.ObjectMethod | t.ObjectProperty | t.SpreadElement,
|
|
):
|
|
| t.FunctionExpression
|
|
| t.ArrowFunctionExpression
|
|
| t.ObjectMethod
|
|
| undefined => {
|
|
if (t.isObjectMethod(property)) return property;
|
|
if (
|
|
t.isObjectProperty(property) &&
|
|
(t.isFunctionExpression(property.value) ||
|
|
t.isArrowFunctionExpression(property.value))
|
|
)
|
|
return property.value;
|
|
return undefined;
|
|
};
|
|
|
|
/** Derive a literal or explicitly computed module key. */
|
|
export const modulePropertyName = (
|
|
property: t.ObjectMethod | t.ObjectProperty | t.SpreadElement,
|
|
): string => {
|
|
if (!t.isObjectMethod(property) && !t.isObjectProperty(property))
|
|
return `[unknown@${String(property.start ?? -1)}]`;
|
|
const key = semanticStaticPropertyKey(property.key, property.computed);
|
|
if (key !== null) return key;
|
|
return property.computed
|
|
? `[computed@${String(property.start ?? -1)}]`
|
|
: `[unknown@${String(property.start ?? -1)}]`;
|
|
};
|
|
|
|
/** Read the factory-local bundler require parameter when declared. */
|
|
export const factoryRequireName = (
|
|
factory: t.FunctionExpression | t.ArrowFunctionExpression | t.ObjectMethod,
|
|
): string | null => {
|
|
const parameter = runtimeFunctionParameters(factory)[2];
|
|
return t.isIdentifier(parameter) ? parameter.name : null;
|
|
};
|
|
|
|
/** Collect all unique static literal values from an array. */
|
|
export const staticArrayValues = (
|
|
array: t.ArrayExpression,
|
|
): {
|
|
readonly values: readonly string[];
|
|
readonly unknown: number;
|
|
} => {
|
|
const staticValues = array.elements.flatMap((element) => {
|
|
const value = argumentValue(element);
|
|
return value === undefined ? [] : [value];
|
|
});
|
|
const values = [...new Set(staticValues)].sort(compareUnicodeCodePoints);
|
|
return {
|
|
values,
|
|
unknown: array.elements.length - staticValues.length,
|
|
};
|
|
};
|
|
|
|
/** Classify whether inert path syntax is a module specifier or file expression. */
|
|
export type JavaScriptStaticPathIntrinsicQuery = (
|
|
node: t.Node,
|
|
fact?:
|
|
| "resolve"
|
|
| "join"
|
|
| "file-url-to-path"
|
|
| "source-url"
|
|
| "source-directory",
|
|
) => boolean;
|
|
|
|
export const staticPathResolutionContext = (
|
|
node: t.Node,
|
|
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
|
|
): JavaScriptStaticPathContext =>
|
|
isFilesystemPathExpression(node, pathIntrinsics)
|
|
? "filesystem-expression"
|
|
: "module-specifier";
|
|
|
|
/** Resolve a path string composed only from inert literal syntax. */
|
|
export const staticPath = (
|
|
node: t.Node,
|
|
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
|
|
): string | undefined => {
|
|
node = unwrapJavaScriptExpression(node).node;
|
|
const value = stringValue(node);
|
|
if (value !== undefined) return value;
|
|
if (t.isBinaryExpression(node, { operator: "+" }))
|
|
return staticConcatenation(node, pathIntrinsics);
|
|
if (t.isCallExpression(node) || t.isNewExpression(node))
|
|
return staticCallPath(node, pathIntrinsics);
|
|
return undefined;
|
|
};
|
|
|
|
/**
|
|
* Concatenate inert text. A leading source directory contributes no text:
|
|
* `__dirname + "/preload.js"` names a file inside it, while text without a
|
|
* leading separator extends the directory's own name and stays unknown.
|
|
*/
|
|
const staticConcatenation = (
|
|
node: t.BinaryExpression,
|
|
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
|
|
): string | undefined => {
|
|
const operands: t.Node[] = [];
|
|
let current: t.Node = node;
|
|
while (t.isBinaryExpression(current, { operator: "+" })) {
|
|
operands.push(current.right);
|
|
current = unwrapJavaScriptExpression(current.left).node;
|
|
}
|
|
const anchored = isDirectoryPath(current, pathIntrinsics);
|
|
if (!anchored) operands.push(current);
|
|
let text = "";
|
|
for (const operand of operands.reverse()) {
|
|
const value = staticPath(operand, pathIntrinsics);
|
|
if (value === undefined) return undefined;
|
|
text += value;
|
|
}
|
|
if (!anchored) return text;
|
|
return text.startsWith("/") ? posix.join(".", text) : undefined;
|
|
};
|
|
|
|
const staticCallPath = (
|
|
node: t.CallExpression | t.NewExpression,
|
|
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
|
|
): string | undefined => {
|
|
if (t.isCallExpression(node) && pathIntrinsics?.(node.callee))
|
|
return staticResolvedPath(node, pathIntrinsics);
|
|
if (
|
|
t.isCallExpression(node) &&
|
|
pathIntrinsics?.(node.callee, "file-url-to-path")
|
|
)
|
|
return staticFileUrlPath(node, pathIntrinsics);
|
|
if (!t.isCallExpression(node) || !pathIntrinsics?.(node.callee, "join"))
|
|
return undefined;
|
|
const parts: string[] = [];
|
|
for (const argument of node.arguments) {
|
|
if (isDirectoryPath(argumentNode(argument), pathIntrinsics)) {
|
|
// join never resets at an absolute segment, so `/preload.js` after the
|
|
// source directory stays inside it.
|
|
parts.push(".");
|
|
continue;
|
|
}
|
|
const value = t.isNode(argument)
|
|
? staticPath(argument, pathIntrinsics)
|
|
: undefined;
|
|
if (value === undefined) return undefined;
|
|
parts.push(value);
|
|
}
|
|
return parts.length === 0 ? undefined : posix.join(...parts);
|
|
};
|
|
|
|
/** Normalized path facts from a source-relative file URL expression. */
|
|
export interface JavaScriptStaticSourceUrlPath {
|
|
readonly path: string;
|
|
readonly directory: boolean;
|
|
}
|
|
|
|
/** Convert an exact static file URL expression to a POSIX path. */
|
|
export const staticFileUrlPath = (
|
|
node: t.CallExpression | t.NewExpression,
|
|
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
|
|
): string | undefined => {
|
|
if (node.arguments.length !== 1) return undefined;
|
|
const rawArgument = argumentNode(node.arguments[0]);
|
|
if (rawArgument === undefined) return undefined;
|
|
const argument = unwrapJavaScriptExpression(rawArgument).node;
|
|
if (t.isNewExpression(argument) && pathIntrinsics?.(argument, "source-url"))
|
|
return staticSourceRelativeUrlPath(argument)?.path;
|
|
const value = staticPath(argument, pathIntrinsics);
|
|
const rawPath = value?.split(/[?#]/u, 1)[0] ?? "";
|
|
if (
|
|
value === undefined ||
|
|
!/^file:/iu.test(value) ||
|
|
value !== value.trim() ||
|
|
value.includes("\\") ||
|
|
[...value].some((character) => character.charCodeAt(0) < 32) ||
|
|
/%(?:2f|5c)/iu.test(rawPath)
|
|
)
|
|
return undefined;
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol !== "file:") return undefined;
|
|
// Canonical analysis paths use POSIX spelling so results do not depend on
|
|
// the analyzer host. Drive and UNC paths need an explicit target platform.
|
|
const path = fileURLToPath(url, { windows: false });
|
|
return /^\/[a-z]:/iu.test(path) ? undefined : path;
|
|
} catch (cause: unknown) {
|
|
void cause;
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
/** Decode a source-relative URL after the caller proves constructor and base identity. */
|
|
export const staticSourceRelativeUrlPath = (
|
|
node: t.NewExpression,
|
|
): JavaScriptStaticSourceUrlPath | undefined => {
|
|
const value = stringValue(node.arguments[0]);
|
|
if (
|
|
value === undefined ||
|
|
(value.split(/[?#]/u, 1)[0] ?? "") === "" ||
|
|
value !== value.trim() ||
|
|
value.includes("\\") ||
|
|
[...value].some((character) => character.charCodeAt(0) < 32) ||
|
|
/^[a-z][a-z0-9+.-]*:/iu.test(value) ||
|
|
value.startsWith("//") ||
|
|
/%(?:2e|2f|5c)/iu.test(value.split(/[?#]/u, 1)[0] ?? "")
|
|
)
|
|
return undefined;
|
|
// A sufficiently deep inert anchor preserves any leading parent segments
|
|
// without using this host's cwd or inventing the application's directory.
|
|
const directory = `/${"source/".repeat(value.split("/").length + 1)}`;
|
|
try {
|
|
const resolved = new URL(value, `file://${directory}entry.js`);
|
|
const path = fileURLToPath(resolved, { windows: false });
|
|
if (value.startsWith("/"))
|
|
return { path, directory: resolved.pathname.endsWith("/") };
|
|
const relative = posix.relative(directory, path) || ".";
|
|
// Keep the established explicit source-directory observation spelling.
|
|
return {
|
|
path:
|
|
value.startsWith("./") &&
|
|
relative !== "." &&
|
|
!relative.startsWith("../")
|
|
? `./${relative}`
|
|
: relative,
|
|
directory: resolved.pathname.endsWith("/"),
|
|
};
|
|
} catch (cause: unknown) {
|
|
// Malformed escapes and non-file URL forms remain unknown.
|
|
void cause;
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const staticResolvedPath = (
|
|
node: t.CallExpression | t.NewExpression,
|
|
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
|
|
): string | undefined => {
|
|
const parts: string[] = [];
|
|
let anchored = false;
|
|
// Node walks right-to-left and never inspects arguments before its anchor.
|
|
for (let index = node.arguments.length - 1; index >= 0; index -= 1) {
|
|
const argument = node.arguments[index];
|
|
if (isDirectoryPath(argumentNode(argument), pathIntrinsics)) {
|
|
anchored = true;
|
|
break;
|
|
}
|
|
const value = t.isNode(argument)
|
|
? staticPath(argument, pathIntrinsics)
|
|
: undefined;
|
|
if (value === undefined) return undefined;
|
|
parts.push(value);
|
|
if (posix.isAbsolute(value)) {
|
|
anchored = true;
|
|
break;
|
|
}
|
|
}
|
|
// Relative-only calls depend on the target process cwd, which is unknown.
|
|
if (!anchored) return undefined;
|
|
const path = posix.join(...parts.reverse());
|
|
return path === "/" ? path : path.replace(/\/$/u, "");
|
|
};
|
|
|
|
const isFilesystemPathExpression = (
|
|
node: t.Node,
|
|
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
|
|
): boolean => {
|
|
node = unwrapJavaScriptExpression(node).node;
|
|
const value = stringValue(node);
|
|
if (value !== undefined) return value.startsWith("/");
|
|
if (isFileIdentity(node) || isDirectoryPath(node, pathIntrinsics))
|
|
return true;
|
|
if (t.isBinaryExpression(node, { operator: "+" }))
|
|
return (
|
|
isFilesystemPathExpression(node.left, pathIntrinsics) ||
|
|
isFilesystemPathExpression(node.right, pathIntrinsics)
|
|
);
|
|
if (t.isCallExpression(node) || t.isNewExpression(node)) {
|
|
if (!t.isCallExpression(node)) return false;
|
|
const native =
|
|
pathIntrinsics?.(node.callee) ||
|
|
pathIntrinsics?.(node.callee, "join") ||
|
|
pathIntrinsics?.(node.callee, "file-url-to-path");
|
|
return (
|
|
native === true && staticCallPath(node, pathIntrinsics) !== undefined
|
|
);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/** `__dirname` or the dirname of the source file, as a string. */
|
|
const isDirectoryPath = (
|
|
node: t.Node | undefined,
|
|
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
|
|
): boolean => {
|
|
if (node !== undefined) node = unwrapJavaScriptExpression(node).node;
|
|
return (
|
|
node !== undefined && pathIntrinsics?.(node, "source-directory") === true
|
|
);
|
|
};
|
|
|
|
/** Whether the expression denotes the current module's URL. */
|
|
export const isImportMetaUrl = (node: t.Node | undefined): boolean => {
|
|
if (node === undefined) return false;
|
|
const expression = unwrapJavaScriptExpression(node).node;
|
|
if (!t.isMemberExpression(expression)) return false;
|
|
const object = unwrapJavaScriptExpression(expression.object).node;
|
|
return (
|
|
t.isMetaProperty(object) &&
|
|
object.meta.name === "import" &&
|
|
object.property.name === "meta" &&
|
|
semanticStaticPropertyName(expression.property, expression.computed) ===
|
|
"url"
|
|
);
|
|
};
|
|
|
|
const isFileIdentity = (node: t.Node | undefined): boolean => {
|
|
if (node !== undefined) node = unwrapJavaScriptExpression(node).node;
|
|
if (t.isIdentifier(node, { name: "__filename" }) || isImportMetaUrl(node))
|
|
return true;
|
|
if (!t.isCallExpression(node)) return false;
|
|
const name = calleeName(node.callee);
|
|
return (
|
|
(name === "fileURLToPath" || name.endsWith(".fileURLToPath")) &&
|
|
isFileIdentity(argumentNode(node.arguments[0]))
|
|
);
|
|
};
|
|
|
|
const XHR_METHODS = new Set([
|
|
"CONNECT",
|
|
"DELETE",
|
|
"GET",
|
|
"HEAD",
|
|
"OPTIONS",
|
|
"PATCH",
|
|
"POST",
|
|
"PUT",
|
|
"TRACE",
|
|
]);
|
|
|
|
/** Legacy URL/path shapes that bypass the overloaded lookup exclusions. */
|
|
const isRequestUrlOrPathLiteral = (value: string): boolean =>
|
|
/^(?:https?|wss?):\/\//iu.test(value) ||
|
|
value.startsWith("/") ||
|
|
value.startsWith("./") ||
|
|
value.startsWith("../");
|
|
|
|
/** Explicit request references, classified after receiver-specific exclusions. */
|
|
const isRequestUrlReference = (value: string): boolean => {
|
|
if (
|
|
isRequestUrlOrPathLiteral(value) ||
|
|
value.startsWith("?") ||
|
|
value.startsWith("#")
|
|
)
|
|
return true;
|
|
// WHATWG accepts HTTP/WS scheme forms without authority slashes. Parse
|
|
// without a base so bare keys stay ambiguous, and keep the source spelling.
|
|
if (!/^(?:https?|wss?):/iu.test(value)) return false;
|
|
return URL.parse(value) !== null;
|
|
};
|
|
|
|
/**
|
|
* Browsing-context and document `open` take a URL or type first and a target
|
|
* name second. The name is only a spelling, since `parent` or `self` may be a
|
|
* local XHR binding, so it never overrides a literal HTTP method.
|
|
*/
|
|
const WINDOW_OPEN_CALL =
|
|
/(?:^|\.)(?:window|self|globalThis|top|parent|opener|frames|document)\.open$/u;
|
|
|
|
/** Node `fs.open` flag strings, which are never request URLs. */
|
|
const FS_OPEN_FLAGS = new Set([
|
|
"a",
|
|
"a+",
|
|
"as",
|
|
"as+",
|
|
"ax",
|
|
"ax+",
|
|
"r",
|
|
"r+",
|
|
"rs",
|
|
"rs+",
|
|
"sa",
|
|
"sa+",
|
|
"sr",
|
|
"sr+",
|
|
"w",
|
|
"w+",
|
|
"wx",
|
|
"wx+",
|
|
"xa",
|
|
"xa+",
|
|
"xw",
|
|
"xw+",
|
|
]);
|
|
|
|
/** Reserved browsing-context names (ASCII case-insensitive). */
|
|
const BROWSING_CONTEXT_KEYWORDS = new Set([
|
|
"_blank",
|
|
"_parent",
|
|
"_self",
|
|
"_top",
|
|
]);
|
|
|
|
/**
|
|
* Read `XMLHttpRequest.open(method, url)`. `fs.open(path, "r")` and
|
|
* `window.open(url, "_blank")` share the callee name. A literal method must
|
|
* be an HTTP method; with a computed method, the second literal is a URL
|
|
* unless it is provably an fs flag or a browsing-context keyword, or the
|
|
* receiver is spelled as a browsing context or storage. XHR normalizes the standard
|
|
* methods' case; extension methods such as WebDAV `PROPFIND` are
|
|
* conventionally uppercase tokens.
|
|
*/
|
|
const xhrOpenUrl = (
|
|
name: string,
|
|
methodNode: t.Node | null | undefined,
|
|
urlNode: t.Node | null | undefined,
|
|
receiverFact?: JavaScriptOpenReceiverFact,
|
|
): string | undefined => {
|
|
const url = stringValue(urlNode);
|
|
if (url === undefined) return undefined;
|
|
// A lexical fact is stronger than the receiver's spelling. In particular,
|
|
// browser Window and Document globals overload `open` with URL/content APIs.
|
|
if (receiverFact === "window") return undefined;
|
|
const method = stringValue(methodNode);
|
|
// IndexedDB and Cache Storage `open` take a name and a version, so a
|
|
// receiver spelled as storage needs a standard HTTP method literal.
|
|
const storage =
|
|
receiverFact === "local-indexed-db" ||
|
|
receiverFact === "local-cache-storage" ||
|
|
receiverFact === "indexed-db" ||
|
|
receiverFact === "cache-storage" ||
|
|
storageKind(name) !== undefined;
|
|
if (method === undefined)
|
|
return storage ||
|
|
(receiverFact === undefined && WINDOW_OPEN_CALL.test(name)) ||
|
|
FS_OPEN_FLAGS.has(url) ||
|
|
BROWSING_CONTEXT_KEYWORDS.has(url.toLowerCase())
|
|
? undefined
|
|
: url;
|
|
return XHR_METHODS.has(method.toUpperCase()) ||
|
|
((!storage ||
|
|
receiverFact === "local-indexed-db" ||
|
|
receiverFact === "local-cache-storage") &&
|
|
/^[A-Z][A-Z-]*$/u.test(method))
|
|
? url
|
|
: undefined;
|
|
};
|
|
|
|
const KEYED_COLLECTION_CONSTRUCTORS = new Set([
|
|
"FormData",
|
|
"Headers",
|
|
"Map",
|
|
"Set",
|
|
"URLSearchParams",
|
|
"WeakMap",
|
|
"WeakSet",
|
|
]);
|
|
|
|
/**
|
|
* Whether a get/delete receiver is spelled as a keyed collection: a
|
|
* constructed Map, Set, Headers, FormData, or URLSearchParams, or a
|
|
* `headers`/`searchParams` property. Spelling alone does not prove the
|
|
* binding, so callers keep URL and path literals as requests.
|
|
*/
|
|
const isKeyedCollectionReceiver = (callee: t.Node): boolean => {
|
|
callee = unwrapJavaScriptExpression(callee).node;
|
|
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
|
|
return false;
|
|
const receiver = unwrapJavaScriptExpression(callee.object).node;
|
|
if (t.isNewExpression(receiver)) {
|
|
const constructor = unwrapJavaScriptExpression(receiver.callee).node;
|
|
return (
|
|
t.isIdentifier(constructor) &&
|
|
KEYED_COLLECTION_CONSTRUCTORS.has(constructor.name)
|
|
);
|
|
}
|
|
if (
|
|
!t.isMemberExpression(receiver) &&
|
|
!t.isOptionalMemberExpression(receiver)
|
|
)
|
|
return false;
|
|
const property = semanticStaticPropertyName(
|
|
receiver.property,
|
|
receiver.computed,
|
|
);
|
|
return (
|
|
property === "headers" ||
|
|
property === "headersList" ||
|
|
property === "searchParams"
|
|
);
|
|
};
|
|
|
|
/** A literal endpoint shape or an ambiguous argument of an HTTP-method-named call. */
|
|
export interface JavaScriptEndpointArgument {
|
|
readonly kind: "endpoint" | "candidate";
|
|
readonly value: string;
|
|
}
|
|
|
|
/** Classify one literal without treating a generic method name as network identity. */
|
|
export const endpointArgument = (
|
|
name: string,
|
|
args: readonly (
|
|
| t.Expression
|
|
| t.SpreadElement
|
|
| t.JSXNamespacedName
|
|
| t.ArgumentPlaceholder
|
|
)[],
|
|
callee: t.Node,
|
|
receiverFact?: JavaScriptOpenReceiverFact,
|
|
): JavaScriptEndpointArgument | undefined => {
|
|
const literal = (
|
|
value: string | undefined,
|
|
): JavaScriptEndpointArgument | undefined =>
|
|
value === undefined ? undefined : { kind: "endpoint", value };
|
|
if (receiverFact === "window") return undefined;
|
|
if (
|
|
name === "fetch" ||
|
|
name.endsWith(".fetch") ||
|
|
name === "WebSocket" ||
|
|
name === "EventSource" ||
|
|
name.endsWith(".EventSource")
|
|
)
|
|
return literal(stringValue(args[0]));
|
|
if (name.endsWith(".open")) {
|
|
const value = xhrOpenUrl(name, args[0], args[1], receiverFact);
|
|
if (value === undefined) return undefined;
|
|
return {
|
|
kind:
|
|
stringValue(args[0]) !== undefined || isRequestUrlReference(value)
|
|
? "endpoint"
|
|
: "candidate",
|
|
value,
|
|
};
|
|
}
|
|
const method = ["get", "post", "put", "patch", "delete", "request"].find(
|
|
(candidate) => name === candidate || name.endsWith(`.${candidate}`),
|
|
);
|
|
if (method !== undefined) {
|
|
const value = stringValue(args[0]);
|
|
// Only get and delete also belong to the keyed-collection APIs, whose
|
|
// keys are header, field, and parameter names rather than URLs or paths.
|
|
// Configuration `get` reads setting keys.
|
|
if (value === undefined) return undefined;
|
|
if (
|
|
(method === "get" || method === "delete") &&
|
|
!isRequestUrlOrPathLiteral(value) &&
|
|
(isKeyedCollectionReceiver(callee) ||
|
|
(method === "get" &&
|
|
(receiverFact === "configuration" ||
|
|
receiverFact === "extension-state")))
|
|
)
|
|
return undefined;
|
|
return {
|
|
kind:
|
|
method !== "get" && method !== "delete" && isRequestUrlReference(value)
|
|
? "endpoint"
|
|
: "candidate",
|
|
value,
|
|
};
|
|
}
|
|
// Beacon URLs are endpoint observations, retaining the upstream API support.
|
|
if (name === "sendBeacon" || name.endsWith(".sendBeacon"))
|
|
return literal(stringValue(args[0]));
|
|
if (name === "loadURL" || name.endsWith(".loadURL"))
|
|
return literal(stringValue(args[0]));
|
|
return undefined;
|
|
};
|
|
|
|
/** Spelling-only candidate exclusion for overloaded open calls; not API identity. */
|
|
const storageKind = (
|
|
name: string,
|
|
): JavaScriptStaticStorage["kind"] | undefined => {
|
|
if (name.includes("localStorage.")) return "local-storage";
|
|
if (name.includes("sessionStorage.")) return "session-storage";
|
|
if (name.endsWith("indexedDB.open")) return "indexed-db";
|
|
if (name.endsWith("caches.open")) return "cache-storage";
|
|
return undefined;
|
|
};
|
|
|
|
/** Produce a dotted callee name through type-erased expression wrappers. */
|
|
export const calleeName = (node: t.Node): string => {
|
|
node = unwrapJavaScriptExpression(node).node;
|
|
const segments: string[] = [];
|
|
while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
|
// Dynamic keys retain their syntax identity instead of inventing a name.
|
|
segments.push(memberPropertyName(node));
|
|
if (!t.isNode(node.object)) break;
|
|
node = unwrapJavaScriptExpression(node.object).node;
|
|
}
|
|
const base = t.isIdentifier(node) ? node.name : "";
|
|
segments.push(base);
|
|
segments.reverse();
|
|
const first = segments.findIndex((segment) => segment !== "");
|
|
return first === -1 ? "" : segments.slice(first).join(".");
|
|
};
|
|
|
|
/** Read a member property only when its syntax commits an exact name. */
|
|
const memberPropertyName = (
|
|
node: t.MemberExpression | t.OptionalMemberExpression,
|
|
): string =>
|
|
semanticStaticPropertyKey(node.property, node.computed) ??
|
|
(node.computed ? `[computed@${String(node.property.start ?? -1)}]` : "");
|
|
|
|
/**
|
|
* Return a string literal value without evaluating an expression. A template
|
|
* literal without substitutions denotes the same exact string.
|
|
*/
|
|
export const stringValue = (
|
|
node: t.Node | null | undefined,
|
|
): string | undefined => {
|
|
if (node === null || node === undefined) return undefined;
|
|
const literal = readExactJavaScriptLiteral(node);
|
|
return literal.found && typeof literal.value === "string"
|
|
? literal.value
|
|
: undefined;
|
|
};
|
|
|
|
/** Return a string or numeric argument literal. */
|
|
export const argumentValue = (
|
|
node: t.Node | null | undefined,
|
|
): string | undefined =>
|
|
t.isNumericLiteral(node) ? String(node.value) : stringValue(node);
|
|
|
|
/** Prefix a static argument value when one is available. */
|
|
export const prefixedArgument = (
|
|
prefix: string,
|
|
node: t.Node | null | undefined,
|
|
): string | undefined => {
|
|
const value = argumentValue(node);
|
|
return value === undefined ? undefined : `${prefix}${value}`;
|
|
};
|
|
|
|
/** Retain the exact source span represented by an AST node. */
|
|
export const sourceSlice = (source: string, node: t.Node): string =>
|
|
typeof node.start !== "number" || typeof node.end !== "number"
|
|
? ""
|
|
: source.slice(node.start, node.end);
|
|
|
|
/** Convert Babel locations into the stable graph source-range shape. */
|
|
export const range = (node: t.Node): JavaScriptSourceRange => ({
|
|
start: {
|
|
line: node.loc?.start.line ?? 1,
|
|
column: node.loc?.start.column ?? 0,
|
|
},
|
|
end: {
|
|
line: node.loc?.end.line ?? node.loc?.start.line ?? 1,
|
|
column: node.loc?.end.column ?? node.loc?.start.column ?? 0,
|
|
},
|
|
});
|
|
|
|
/** Convert exact text offsets into a source range. */
|
|
export const rangeForOffsets = (
|
|
source: string,
|
|
start: number,
|
|
end: number,
|
|
): JavaScriptSourceRange => ({
|
|
start: pointForOffset(source, start),
|
|
end: pointForOffset(source, end),
|
|
});
|
|
|
|
/** Compare two exact source ranges. */
|
|
export const sourceRangesEqual = (
|
|
left: JavaScriptSourceRange | null,
|
|
right: JavaScriptSourceRange,
|
|
): boolean =>
|
|
left !== null &&
|
|
left.start.line === right.start.line &&
|
|
left.start.column === right.start.column &&
|
|
left.end.line === right.end.line &&
|
|
left.end.column === right.end.column;
|
|
|
|
const pointForOffset = (source: string, offset: number) => {
|
|
const before = source.slice(0, offset);
|
|
// Match JavaScript line terminators, treating CRLF as a single newline.
|
|
const lines = before.split(/\r\n|[\n\r\u2028\u2029]/u);
|
|
return { line: lines.length, column: lines.at(-1)?.length ?? 0 };
|
|
};
|
|
|
|
/** Observe known bundler/framework marker strings without claiming dependency use. */
|
|
export const detectVendors = (source: string): string[] =>
|
|
vendorPatterns.flatMap(({ name, patterns }) =>
|
|
patterns.some((pattern) => source.includes(pattern)) ? [name] : [],
|
|
);
|
|
|
|
/** Commit the static semantic content used to deduplicate registrations. */
|
|
export const registrationKey = (
|
|
registration: JavaScriptBundlerRegistration,
|
|
): string =>
|
|
// Collision-free tuple: chunk/module keys are variable-length source-derived
|
|
// lists, so NUL-joins would collapse ["a\0b"] with ["a", "b"].
|
|
compositeKey([
|
|
registration.runtime,
|
|
registration.chunk_keys,
|
|
String(registration.unknown_chunk_keys),
|
|
// Equal factory tables can be registered with different runtime callbacks.
|
|
// Both early collection and finalization must retain those recovered facts.
|
|
registration.runtime_require_name,
|
|
registration.runtime_module_cache_status,
|
|
registration.entry_module_keys,
|
|
String(registration.unknown_entry_module_keys),
|
|
registration.async_chunk_keys,
|
|
String(registration.unknown_async_chunk_keys),
|
|
registration.modules.map(({ module_key: key, source_sha256: digest }) => [
|
|
key,
|
|
digest,
|
|
]),
|
|
]);
|
|
|
|
/** Hash exact UTF-8 source text. */
|
|
export const sha256Text = (value: string): string =>
|
|
createHash("sha256").update(value).digest("hex");
|
|
|
|
/** Recognized route-construction call suffixes. */
|
|
export const STATIC_ROUTE_CALL_NAMES = [
|
|
"route",
|
|
"addRoute",
|
|
"useRoutes",
|
|
"createBrowserRouter",
|
|
"createHashRouter",
|
|
] as const;
|
|
|
|
const vendorPatterns = [
|
|
{ name: "webpack", patterns: ["__webpack_require__", "webpackChunk"] },
|
|
{ name: "Rspack", patterns: ["__webpack_require__.f", "rspackChunk"] },
|
|
{ name: "Vite", patterns: ["__vite__", "import.meta.hot"] },
|
|
{ name: "Rollup", patterns: ["ROLLUP_FILE_URL", "import.meta.ROLLUP"] },
|
|
{ name: "esbuild", patterns: ["__commonJS", "__esm"] },
|
|
{ name: "React", patterns: ["React.createElement", "react/jsx-runtime"] },
|
|
{ name: "Vue", patterns: ["__VUE__", "createApp("] },
|
|
{ name: "Next.js", patterns: ["__NEXT_DATA__", "/_next/"] },
|
|
{ name: "Angular", patterns: ["ɵɵdefineComponent"] },
|
|
{ name: "Svelte", patterns: ["svelte/internal"] },
|
|
] as const;
|