* fix(javascript): resolve module-relative Worker URL references * fix(javascript): validate Worker URL paths independently of suffixes * fix(javascript): preserve directory-form Worker URL paths * fix(javascript): unwrap type assertions in Worker URL references * fix(javascript): validate Worker bindings before URL resolution * fix(javascript): leave drive-letter Worker URLs unresolved Use WHATWG file URL parsing before pathname decoding to detect drive-letter forms whose resource identity depends on the unmodeled source URL scheme. Preserve encoded and ordinary relative pipe paths, and cover false matches through the compiled CLI and resolver boundary tests. * fix(javascript): preserve Worker URL identity boundaries Recognize known Worker bindings before filtering callee spellings so ESM aliases and transparent constructor wrappers reach URL analysis. Keep root-relative module URLs unknown without a modeled runtime base root. Index direct source-local global writes with the shared mutation collector before promoting URL and browser Worker constructors. Preserve lexical shadowing and unrelated property identities, and exercise all three review regressions through the compiled CLI. * fix(javascript): invalidate mutated import.meta URL bases Track source-local import.meta writes alongside ambient global writes so Worker URL expressions keep an unknown base after a possible URL mutation. Tag the identity roots independently and cover direct mutation and unrelated property cases through the compiled CLI. * fix(javascript): close Worker URL identity and pathname gaps Resolve ambient object identities before appending written properties, and retain possible source-local aliases across rebinding. Reuse intrinsic invocation handling for aliased and bound mutators, array spreads, and transparent TypeScript wrappers while preserving Electron write semantics. Keep malformed-percent nested Worker URL pathnames unknown because the base scheme is unavailable and Node rejects these file URLs. Preserve tolerant HTML URL handling, valid escapes, and query/fragment evidence. Cover the new cases and unrelated writes through the compiled CLI with existing-file decoys. All 591 targeted tests and npm run check pass. * fix(javascript): bound alias expansion and classify Worker paths Join ambient identity projections instead of enumerating alias histories. Memoize repeated value and provenance queries while preserving capture, depth, and cycle-dependent uncertainty. Keep Node Worker string arguments unresolved without filesystem or cwd context, and admit qualified global URL constructors through identity checks. Validate three 40-level alias branch shapes through the compiled CLI, 925 source tests, 25 CLI/boundary tests, and the full npm run check gate. * fix(server): preserve partial Evidence target-content provenance Pass the exact named contract returnsTargetContent flag through the shared partial-Evidence error delivery path introduced by the shortlist integration. Exercise successful and failed recording through in-memory MCP for marked and unmarked contracts, preserving source payload and original failures. Observe both shared-handler emission and wire delivery so the SDK error-marker fallback cannot hide omissions. Suppressing the flag fails exactly the two marked cases. Validation: check:fast, check, 736 semantic tests, 29 MCP transport/error tests and six compiled static Worker CLI journeys pass. No live target/provider execution or hosted publication. * fix(javascript): structurally share semantic dependency proofs --------- Co-authored-by: baron <richstyles@gmail.com>
734 lines
24 KiB
TypeScript
734 lines
24 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,
|
|
semanticStaticPropertyKey,
|
|
semanticStaticPropertyName,
|
|
unwrapJavaScriptExpression,
|
|
} from "./javascriptAstValues.js";
|
|
import {
|
|
compareUnicodeCodePoints,
|
|
compositeKey,
|
|
} from "../unicodeCodePointOrder.js";
|
|
|
|
/** Explicit result for source text that Babel cannot parse. */
|
|
export const failedJavaScriptStaticAnalysis = (): JavaScriptStaticAnalysis => ({
|
|
parse_status: "failed",
|
|
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 = factory.params[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 const staticPathResolutionContext = (
|
|
node: t.Node,
|
|
): JavaScriptStaticPathContext =>
|
|
isFilesystemPathExpression(node)
|
|
? "filesystem-expression"
|
|
: "module-specifier";
|
|
|
|
/** Resolve a path composed only from inert literal syntax. */
|
|
export const staticPath = (node: t.Node): string | undefined => {
|
|
if (t.isStringLiteral(node)) return node.value;
|
|
if (t.isTemplateLiteral(node) && node.expressions.length === 0)
|
|
return stringValue(node);
|
|
if (t.isBinaryExpression(node, { operator: "+" })) {
|
|
const left = staticPath(node.left);
|
|
const right = staticPath(node.right);
|
|
return left === undefined || right === undefined
|
|
? undefined
|
|
: `${left}${right}`;
|
|
}
|
|
if (t.isCallExpression(node) || t.isNewExpression(node))
|
|
return staticCallPath(node);
|
|
return undefined;
|
|
};
|
|
|
|
const staticCallPath = (
|
|
node: t.CallExpression | t.NewExpression,
|
|
): string | undefined => {
|
|
const name = calleeName(node.callee);
|
|
if (name === "URL" || name.endsWith(".URL"))
|
|
return isFileIdentity(argumentNode(node.arguments[1]))
|
|
? stringValue(node.arguments[0])
|
|
: undefined;
|
|
if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) {
|
|
return staticFileUrlPath(argumentNode(node.arguments[0]));
|
|
}
|
|
if (
|
|
(name === "dirname" || name.endsWith(".dirname")) &&
|
|
isFileIdentity(argumentNode(node.arguments[0]))
|
|
)
|
|
return "";
|
|
if (name.endsWith(".resolve"))
|
|
return name === "win32.resolve" || name.endsWith(".win32.resolve")
|
|
? undefined
|
|
: staticResolvedPath(node);
|
|
if (!name.endsWith(".join") && name !== "join") return undefined;
|
|
const parts: string[] = [];
|
|
for (const argument of node.arguments) {
|
|
if (isDirectoryIdentity(argumentNode(argument))) continue;
|
|
const value = t.isNode(argument) ? staticPath(argument) : undefined;
|
|
if (value === undefined) return undefined;
|
|
parts.push(value);
|
|
}
|
|
return parts.length === 0 ? undefined : posix.join(...parts);
|
|
};
|
|
|
|
const staticFileUrlPath = (
|
|
argument: t.Node | undefined,
|
|
): string | undefined => {
|
|
if (argument === undefined) return undefined;
|
|
return isSourceFileUrl(argument)
|
|
? sourceRelativeFileUrlPath(argument)
|
|
: staticPath(argument);
|
|
};
|
|
|
|
/** Project a known source-relative URL through the actual file URL decoder. */
|
|
const sourceRelativeFileUrlPath = (node: t.Node): string | undefined => {
|
|
if (!isSourceFileUrl(node)) return 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;
|
|
const relative = posix.relative(directory, path) || ".";
|
|
// Keep the established explicit source-directory observation spelling.
|
|
return value.startsWith("./") &&
|
|
relative !== "." &&
|
|
!relative.startsWith("../")
|
|
? `./${relative}`
|
|
: relative;
|
|
} catch (cause: unknown) {
|
|
// Malformed escapes and non-file URL forms remain unknown.
|
|
void cause;
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const isSourceFileUrl = (
|
|
node: t.Node,
|
|
): node is t.CallExpression | t.NewExpression =>
|
|
(t.isCallExpression(node) || t.isNewExpression(node)) &&
|
|
(calleeName(node.callee) === "URL" ||
|
|
calleeName(node.callee).endsWith(".URL")) &&
|
|
isFileIdentity(argumentNode(node.arguments[1]));
|
|
|
|
const staticResolvedPath = (
|
|
node: t.CallExpression | t.NewExpression,
|
|
): string | undefined => {
|
|
const parts: (string | null)[] = [];
|
|
for (const argument of node.arguments) {
|
|
if (isDirectoryIdentity(argumentNode(argument))) {
|
|
parts.push(null);
|
|
continue;
|
|
}
|
|
const value = t.isNode(argument) ? staticPath(argument) : undefined;
|
|
// Validate every argument, including those before the last anchor.
|
|
if (value === undefined) return undefined;
|
|
parts.push(value);
|
|
}
|
|
const anchor = parts.findLastIndex(
|
|
(part) => part === null || posix.isAbsolute(part),
|
|
);
|
|
// Relative-only calls depend on the target process cwd, which is unknown.
|
|
if (anchor === -1) return undefined;
|
|
// A null anchor is the source directory; preserve its relative projection.
|
|
const path = posix.join(
|
|
...parts.slice(anchor).filter((part) => part !== null),
|
|
);
|
|
return path === "/" ? path : path.replace(/\/$/u, "");
|
|
};
|
|
|
|
const isFilesystemPathExpression = (node: t.Node): boolean => {
|
|
if (t.isStringLiteral(node)) return node.value.startsWith("/");
|
|
if (isFileIdentity(node) || isDirectoryIdentity(node)) return true;
|
|
if (t.isBinaryExpression(node, { operator: "+" }))
|
|
return (
|
|
isFilesystemPathExpression(node.left) ||
|
|
isFilesystemPathExpression(node.right)
|
|
);
|
|
if (t.isCallExpression(node) || t.isNewExpression(node)) {
|
|
const name = calleeName(node.callee);
|
|
if (name.endsWith(".resolve") && staticCallPath(node) !== undefined)
|
|
return true;
|
|
if (name === "URL" || name.endsWith(".URL"))
|
|
return isFileIdentity(argumentNode(node.arguments[1]));
|
|
if (name === "fileURLToPath" || name.endsWith(".fileURLToPath"))
|
|
return node.arguments.some((argument) => {
|
|
const value = argumentNode(argument);
|
|
return value !== undefined && isFilesystemPathExpression(value);
|
|
});
|
|
return node.arguments.some((argument) => {
|
|
const value = argumentNode(argument);
|
|
return value !== undefined && isFilesystemPathExpression(value);
|
|
});
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const isDirectoryIdentity = (node: t.Node | undefined): boolean => {
|
|
if (t.isIdentifier(node, { name: "__dirname" })) return true;
|
|
if (!t.isCallExpression(node) && !t.isNewExpression(node)) return false;
|
|
const name = calleeName(node.callee);
|
|
if (name === "URL" || name.endsWith(".URL"))
|
|
return isFileIdentity(argumentNode(node.arguments[1]));
|
|
return (
|
|
(name === "dirname" || name.endsWith(".dirname")) &&
|
|
isFileIdentity(argumentNode(node.arguments[0]))
|
|
);
|
|
};
|
|
|
|
/** 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 (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",
|
|
]);
|
|
|
|
/** An absolute HTTP(S)/WebSocket URL, or an absolute or relative path. */
|
|
const isRequestUrlLiteral = (value: string): boolean =>
|
|
/^(?:https?|wss?):\/\//iu.test(value) ||
|
|
value.startsWith("/") ||
|
|
value.startsWith("./") ||
|
|
value.startsWith("../");
|
|
|
|
/**
|
|
* 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?:
|
|
| "window"
|
|
| "indexed-db"
|
|
| "cache-storage"
|
|
| "local"
|
|
| "local-indexed-db"
|
|
| "local-cache-storage",
|
|
): 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 => {
|
|
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
|
|
return false;
|
|
const receiver = callee.object;
|
|
if (t.isNewExpression(receiver))
|
|
return (
|
|
t.isIdentifier(receiver.callee) &&
|
|
KEYED_COLLECTION_CONSTRUCTORS.has(receiver.callee.name)
|
|
);
|
|
if (
|
|
!t.isMemberExpression(receiver) &&
|
|
!t.isOptionalMemberExpression(receiver)
|
|
)
|
|
return false;
|
|
const property = semanticStaticPropertyName(
|
|
receiver.property,
|
|
receiver.computed,
|
|
);
|
|
return property === "headers" || property === "searchParams";
|
|
};
|
|
|
|
/** Select the literal URL argument for recognized network callees. */
|
|
export const endpointArgument = (
|
|
name: string,
|
|
args: readonly (
|
|
| t.Expression
|
|
| t.SpreadElement
|
|
| t.JSXNamespacedName
|
|
| t.ArgumentPlaceholder
|
|
)[],
|
|
callee: t.Node,
|
|
receiverFact?:
|
|
| "window"
|
|
| "indexed-db"
|
|
| "cache-storage"
|
|
| "local"
|
|
| "local-indexed-db"
|
|
| "local-cache-storage",
|
|
): string | undefined => {
|
|
if (receiverFact === "window") return undefined;
|
|
if (name === "fetch" || name.endsWith(".fetch") || name === "WebSocket")
|
|
return stringValue(args[0]);
|
|
if (name.endsWith(".open"))
|
|
return xhrOpenUrl(name, args[0], args[1], receiverFact);
|
|
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.
|
|
return value !== undefined &&
|
|
(method === "get" || method === "delete") &&
|
|
!isRequestUrlLiteral(value) &&
|
|
isKeyedCollectionReceiver(callee)
|
|
? undefined
|
|
: value;
|
|
}
|
|
if (name.endsWith("loadURL")) return stringValue(args[0]);
|
|
return undefined;
|
|
};
|
|
|
|
/** Classify a recognized storage API call name. */
|
|
export 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";
|
|
if (name === "Database" || name.endsWith(".Database")) return "sqlite";
|
|
return undefined;
|
|
};
|
|
|
|
/** Produce a dotted callee name from member syntax. */
|
|
export const calleeName = (node: t.Node): string => {
|
|
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 = node.object;
|
|
}
|
|
const base = t.isIdentifier(node)
|
|
? node.name
|
|
: t.isImport(node)
|
|
? "import"
|
|
: "";
|
|
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 =>
|
|
node.computed &&
|
|
!t.isStringLiteral(node.property) &&
|
|
!t.isNumericLiteral(node.property)
|
|
? `[computed@${String(node.property.start ?? -1)}]`
|
|
: semanticStaticPropertyName(node.property, node.computed);
|
|
|
|
/**
|
|
* 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),
|
|
registration.modules.map(({ module_key: key, source_sha256: digest }) => [
|
|
key,
|
|
digest,
|
|
]),
|
|
]);
|
|
|
|
/** Sort values by a deterministic unique semantic key. */
|
|
export const sortedUnique = <Value>(
|
|
values: readonly Value[],
|
|
key: (value: Value) => string,
|
|
): Value[] =>
|
|
[...new Map(values.map((value) => [key(value), value])).values()].sort(
|
|
(left, right) => compareUnicodeCodePoints(key(left), key(right)),
|
|
);
|
|
|
|
/** 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;
|