From b58adafa4fa16d578132810f677eabc6d9ffdc09 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Wed, 14 Oct 2026 08:46:50 +0800
Subject: [PATCH] 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.
---
docs/javascript-artifact-reconstruction.md | 17 +-
.../javascript/ElectronBoundaryAnalysis.ts | 7 +
.../javascript/ElectronBoundaryGraphNative.ts | 1 +
.../ElectronBoundaryGraphWindows.ts | 57 ++-
.../javascript/JavaScriptArtifactAnalysis.ts | 95 ++--
.../JavaScriptArtifactAnalysisTypes.ts | 11 +
.../JavaScriptArtifactGraphBuilder.ts | 46 +-
.../JavaScriptArtifactGraphContext.ts | 46 +-
.../JavaScriptArtifactGraphDocuments.ts | 2 +
.../JavaScriptArtifactGraphFindings.ts | 63 ++-
.../JavaScriptArtifactGraphStructure.ts | 39 +-
.../JavaScriptArtifactPathResolution.test.ts | 225 +++++++-
.../JavaScriptArtifactPathResolution.ts | 343 ++++++++++---
.../JavaScriptArtifactReconstruction.ts | 9 +-
.../JavaScriptModuleRelationships.ts | 2 +
...avaScriptRuntimeObservationService.test.ts | 46 +-
.../JavaScriptRuntimeObservationService.ts | 2 +
.../javascript/htmlArtifactReferences.test.ts | 83 ++-
.../javascript/htmlArtifactReferences.ts | 126 ++++-
.../javascriptApplicationEvidenceSchemas.ts | 2 +-
.../javascript/javascriptArtifactFiles.ts | 26 +
.../javascriptAstFingerprint.test.ts | 60 +++
.../javascript/javascriptAstFingerprint.ts | 10 +-
...scriptExportShapeComparisonSchemas.test.ts | 21 +
.../javascriptExportShapeComparisonSchemas.ts | 11 +-
.../javascriptExportShapeVariants.ts | 2 +-
.../javascript/javascriptRpcEndpoints.test.ts | 19 +
.../javascript/javascriptRpcEndpoints.ts | 33 +-
...javascriptRuntimeReconciliationMatching.ts | 50 +-
.../javascriptRuntimeReconciliationSchemas.ts | 4 +-
.../javascriptRuntimeStaticCandidates.ts | 164 +++++-
.../javascript/javascriptSourceMapPaths.ts | 21 +-
.../javascript/javascriptStaticAnalysis.ts | 2 +
.../javascriptStaticAnalysisBundler.ts | 3 +-
...scriptStaticAnalysisCalleeBoundary.test.ts | 41 ++
.../javascriptStaticAnalysisCalls.ts | 189 ++++++-
.../javascriptStaticAnalysisHelpers.ts | 77 ++-
.../javascriptStaticAnalysisState.ts | 2 +
.../javascriptStaticAnalysisTypes.ts | 7 +-
.../javascriptSymbolicBindings.test.ts | 80 +++
.../javascript/javascriptSymbolicBindings.ts | 64 ++-
.../javascriptVitePreloadDependencies.test.ts | 86 ++++
.../sourceToBundleComparison.test.ts | 12 +-
.../javascript/sourceToBundleSignals.ts | 55 +-
src/domain/webBundleAnalysis.ts | 7 +-
src/domain/webBundleAnalyzer.test.ts | 344 ++++++++++++-
src/domain/webBundleAnalyzerInspection.ts | 40 +-
src/domain/webBundleAnalyzerResult.ts | 2 +
.../JavaScriptAnalysisWorkerSchemas.test.ts | 33 ++
.../JavaScriptAnalysisWorkerSchemas.ts | 5 +-
.../JavaScriptModuleLookupCli.test.ts | 27 +-
.../applications/packageMainLookupCli.test.ts | 284 +++++++++++
.../packageSpecifierSuffixCli.test.ts | 106 ++++
.../applications/packageUrlUnicodeCli.test.ts | 268 ++++++++++
.../electronApplicationAnalysis.test.ts | 28 +-
.../javascriptArtifactReconstruction.test.ts | 2 +-
.../javascriptRuntimeReconciliation.test.ts | 482 ++++++++++++++++++
.../javascriptExportShapeComparison.test.ts | 80 +++
.../javascriptHtmlEntrypoints.test.ts | 182 +++++++
.../javascriptRequireLiteralPaths.test.ts | 36 ++
.../filesystem/sourceToBundlePaths.test.ts | 108 +++-
.../filesystem/webScriptManifestPaths.test.ts | 81 +++
tests/support/cli/javascriptApplication.ts | 16 +
63 files changed, 4066 insertions(+), 326 deletions(-)
create mode 100644 src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts
create mode 100644 src/domain/javascript/javascriptVitePreloadDependencies.test.ts
create mode 100644 src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts
create mode 100644 tests/acceptance/applications/packageMainLookupCli.test.ts
create mode 100644 tests/acceptance/applications/packageUrlUnicodeCli.test.ts
diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md
index 090902ff8..b166bf65e 100644
--- a/docs/javascript-artifact-reconstruction.md
+++ b/docs/javascript-artifact-reconstruction.md
@@ -282,9 +282,14 @@ suffix. Utility calls such as `useUtils().invoice.list.fetch()`, `useQueries`,
and links whose callee was renamed by a minifier are not recognized.
Each recovered bundle module retains the exact factory-source digest. A complete
-bounded AST also receives a `babel-ast-v1` structural fingerprint that ignores
+bounded AST also receives a `babel-ast-v2` structural fingerprint that ignores
ordinary identifier names while retaining syntax, literals, operators, object
-keys, and member properties. If fingerprint construction reaches its structural
+keys, and member properties. Computed member identifiers retain their access
+syntax without treating a local variable's spelling as a fixed property; template
+literal contents remain part of the fingerprint. Version comparison keeps the
+algorithm in its matching key, so retained `babel-ast-v1` fingerprints cannot match
+these new fingerprints. Re-analyze both snapshots to compare their new structure.
+If fingerprint construction reaches its structural
bound, the fingerprint is unavailable and its status is `truncated`; a bounded
prefix is never presented as a complete stable fingerprint.
@@ -619,3 +624,11 @@ Unresolved HTML references retain their declaration, source range, and resolutio
reason in the renderer observations.
HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR
line endings, preserving UTF-16 columns.
+Inline JavaScript in local HTML, meaning classic or module `', []],
@@ -35,17 +38,21 @@ it.each([
).toEqual(expected);
});
-it("retains exact opening-tag offsets rather than the script body", () => {
+it("retains exact opening-tag coordinates rather than the script body", () => {
const opening = '`;
expect(htmlArtifactReferences(text)).toEqual({
baseHref: null,
+ inlineScripts: [],
+ inlineScriptCount: 0,
scripts: [
{
scriptPath: "actual.js",
- startOffset: prefix.length,
- endOffset: prefix.length + opening.length,
+ location: {
+ start: { line: 3, column: 0 },
+ end: { line: 4, column: ' title=">" src=actual.js>'.length },
+ },
},
],
});
@@ -68,3 +75,71 @@ it("traverses deep valid document content without recursive stack growth", () =>
htmlArtifactReferences(text).scripts.map(({ scriptPath }) => scriptPath),
).toEqual(["actual.js"]);
});
+
+it.each([
+ ["", ["classic"]],
+ ['', ["classic"]],
+ ['', ["classic"]],
+ ['', ["classic"]],
+ ['', ["classic"]],
+ ['', ["classic"]],
+ ['', ["module"]],
+ // Only the JavaScript MIME check strips whitespace; `module` must match.
+ ['', []],
+ ['', []],
+ ["", ["classic"]],
+ // A src attribute, even an empty one, means the browser ignores the body.
+ ['', []],
+ ['', []],
+ ["", []],
+ ['', []],
+ ['', []],
+ ['', []],
+ ['', []],
+ ['', []],
+ ['', []],
+ ["", []],
+ ["", []],
+ ["", []],
+])("classifies inline JavaScript in %s", (text, expected) => {
+ expect(
+ htmlArtifactReferences(text).inlineScripts.map(
+ ({ scriptType }) => scriptType,
+ ),
+ ).toEqual(expected);
+});
+
+it("retains exact inline script body coordinates, including unclosed scripts", () => {
+ const prefix = "\n
after
`;
+ expect(htmlArtifactReferences(closed).inlineScripts).toEqual([
+ {
+ scriptType: "classic",
+ location: { start: { line: 2, column: 8 }, end: { line: 4, column: 0 } },
+ },
+ ]);
+ const unclosed = `${prefix}${body}`;
+ expect(htmlArtifactReferences(unclosed).inlineScripts).toEqual([
+ {
+ scriptType: "classic",
+ location: { start: { line: 2, column: 8 }, end: { line: 4, column: 0 } },
+ },
+ ]);
+});
+
+it("counts every inline body but retains bounded locations", () => {
+ const count = 150_000;
+ const text = "".repeat(count);
+ const { inlineScripts, inlineScriptCount } = htmlArtifactReferences(text);
+ expect(inlineScriptCount).toBe(count);
+ expect(inlineScripts).toHaveLength(HTML_INLINE_SCRIPT_LOCATION_LIMIT);
+ const column = 18 * (HTML_INLINE_SCRIPT_LOCATION_LIMIT - 1) + 8;
+ expect(inlineScripts.at(-1)).toEqual({
+ scriptType: "classic",
+ location: {
+ start: { line: 1, column },
+ end: { line: 1, column: column + 1 },
+ },
+ });
+});
diff --git a/src/domain/javascript/htmlArtifactReferences.ts b/src/domain/javascript/htmlArtifactReferences.ts
index fa9945e36..10a52f21a 100644
--- a/src/domain/javascript/htmlArtifactReferences.ts
+++ b/src/domain/javascript/htmlArtifactReferences.ts
@@ -4,23 +4,99 @@ import {
type DefaultTreeAdapterTypes,
} from "parse5";
+import type {
+ JavaScriptSourcePoint,
+ JavaScriptSourceRange,
+} from "./javascriptStaticAnalysisTypes.js";
+
interface HtmlScriptReference {
readonly scriptPath: string;
- readonly startOffset: number;
- readonly endOffset: number;
+ /** The opening tag's range in parse5 coordinates. */
+ readonly location: JavaScriptSourceRange;
}
-/** Extract inert HTML script references and the first document base href. */
+/** JavaScript written directly in a script element instead of loaded by URL. */
+export interface HtmlInlineScript {
+ readonly scriptType: "classic" | "module";
+ /** The body's range, between the opening and closing tags. */
+ readonly location: JavaScriptSourceRange;
+}
+
+/**
+ * Inline bodies whose exact locations are retained per HTML file. Every body
+ * is still counted; locations beyond this bound are reported as omitted.
+ */
+export const HTML_INLINE_SCRIPT_LOCATION_LIMIT = 100;
+
+// The JavaScript MIME type essences from the WHATWG MIME Sniffing standard.
+const JAVASCRIPT_MIME_TYPES = new Set([
+ "application/ecmascript",
+ "application/javascript",
+ "application/x-ecmascript",
+ "application/x-javascript",
+ "text/ecmascript",
+ "text/javascript",
+ "text/javascript1.0",
+ "text/javascript1.1",
+ "text/javascript1.2",
+ "text/javascript1.3",
+ "text/javascript1.4",
+ "text/javascript1.5",
+ "text/jscript",
+ "text/livescript",
+ "text/x-ecmascript",
+ "text/x-javascript",
+]);
+
+/**
+ * Classify a script element's type as HTML "prepare the script element" does.
+ * Only a type attribute is whitespace-stripped, and only for the JavaScript
+ * MIME check; `module` must match the type string exactly, ignoring ASCII
+ * case. Import maps, speculation rules and data blocks are not JavaScript.
+ */
+const inlineScriptType = (
+ attrs: readonly { readonly name: string; readonly value: string }[],
+): HtmlInlineScript["scriptType"] | null => {
+ const type = attrs.find(({ name }) => name === "type");
+ const language = attrs.find(({ name }) => name === "language");
+ const typeString =
+ type !== undefined
+ ? type.value
+ : language !== undefined && language.value !== ""
+ ? `text/${language.value}`
+ : "";
+ if (typeString === "") return "classic";
+ const mimeCandidate =
+ type === undefined
+ ? typeString
+ : typeString.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/gu, "");
+ if (JAVASCRIPT_MIME_TYPES.has(asciiLowercase(mimeCandidate)))
+ return "classic";
+ return asciiLowercase(typeString) === "module" ? "module" : null;
+};
+
+const asciiLowercase = (value: string): string =>
+ value.replace(/[A-Z]/gu, (letter) => letter.toLowerCase());
+
+/**
+ * Extract inert HTML script references, inline JavaScript bodies, and the
+ * first document base href.
+ */
export const htmlArtifactReferences = (
text: string,
): {
readonly baseHref: string | null;
readonly scripts: readonly HtmlScriptReference[];
+ /** The first inline bodies in document order, up to the location limit. */
+ readonly inlineScripts: readonly HtmlInlineScript[];
+ readonly inlineScriptCount: number;
} => {
const pending: DefaultTreeAdapterTypes.Node[] = [
parse(text, { sourceCodeLocationInfo: true }),
];
const scripts: HtmlScriptReference[] = [];
+ const inlineScripts: HtmlInlineScript[] = [];
+ let inlineScriptCount = 0;
let baseHref: string | null = null;
while (pending.length > 0) {
const node = pending.pop();
@@ -39,10 +115,20 @@ export const htmlArtifactReferences = (
if (src !== undefined && src.value !== "" && location !== undefined) {
scripts.push({
scriptPath: src.value,
- startOffset: location.startOffset,
- endOffset: location.endOffset,
+ location: {
+ start: sourcePoint(location.startLine, location.startCol),
+ end: sourcePoint(location.endLine, location.endCol),
+ },
});
}
+ const inline = src === undefined ? inlineScriptBody(node) : undefined;
+ const scriptType =
+ inline === undefined ? null : inlineScriptType(node.attrs);
+ if (inline !== undefined && scriptType !== null) {
+ inlineScriptCount += 1;
+ if (inlineScripts.length < HTML_INLINE_SCRIPT_LOCATION_LIMIT)
+ inlineScripts.push({ scriptType, location: inline });
+ }
}
}
// Template content belongs to a separate inert document fragment. Only
@@ -51,5 +137,33 @@ export const htmlArtifactReferences = (
for (const child of node.childNodes.toReversed()) pending.push(child);
}
}
- return { baseHref, scripts };
+ return { baseHref, scripts, inlineScripts, inlineScriptCount };
};
+
+/** Locate a script element's non-blank body; browsers ignore it when src is set. */
+const inlineScriptBody = (
+ node: DefaultTreeAdapterTypes.Element,
+): JavaScriptSourceRange | undefined => {
+ const startTag = node.sourceCodeLocation?.startTag;
+ if (startTag === undefined) return undefined;
+ let blank = true;
+ let end: DefaultTreeAdapterTypes.TextNode["sourceCodeLocation"];
+ for (const child of node.childNodes) {
+ if (!defaultTreeAdapter.isTextNode(child)) continue;
+ if (blank && /\S/u.test(child.value)) blank = false;
+ end = child.sourceCodeLocation ?? end;
+ }
+ // An unclosed script's element range ends at its opening tag, so the body's
+ // own text location bounds it.
+ if (blank || end == null) return undefined;
+ return {
+ start: sourcePoint(startTag.endLine, startTag.endCol),
+ end: sourcePoint(end.endLine, end.endCol),
+ };
+};
+
+/** parse5 lines and UTF-16 columns are one-based; ranges use zero-based columns. */
+const sourcePoint = (line: number, column: number): JavaScriptSourcePoint => ({
+ line,
+ column: column - 1,
+});
diff --git a/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts b/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts
index 65bce595b..73f379895 100644
--- a/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts
+++ b/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts
@@ -6,7 +6,7 @@ const boundedTextSchema = z.string().min(1);
const boundedKeySchema = z.string().min(1);
const unsafePathSegments = new Set(["", ".", ".."]);
const isSafeRelativePath = (path: string): boolean => {
- if (path.startsWith("/") || path.includes("\\")) return false;
+ if (path.startsWith("/")) return false;
return path.split("/").every((part) => !unsafePathSegments.has(part));
};
const relativePathSchema = z
diff --git a/src/domain/javascript/javascriptArtifactFiles.ts b/src/domain/javascript/javascriptArtifactFiles.ts
index 819c6ceae..e5c7e05fa 100644
--- a/src/domain/javascript/javascriptArtifactFiles.ts
+++ b/src/domain/javascript/javascriptArtifactFiles.ts
@@ -1,5 +1,9 @@
import type { JavaScriptSourceSelection } from "./javascriptSourceSelection.js";
+/** Whether an inventoried path has a source extension read by the analyzer. */
+export const isJavaScriptSourcePath = (path: string): boolean =>
+ /\.(?:cjs|cts|mjs|mts|js|jsx|ts|tsx)$/iu.test(path);
+
/** Relevant file categories projected from the complete artifact inventory. */
export type JavaScriptArtifactFileKind =
| "package-json"
@@ -29,6 +33,26 @@ export interface JavaScriptArtifactFile {
};
}
+/**
+ * One inventoried regular file outside the JavaScript-relevant kinds. Its presence
+ * was inventoried, including unavailable or unhashed members. The source projection
+ * does not read its bytes; references remain present-but-unanalyzed rather than absent.
+ */
+export interface JavaScriptUnanalyzedArtifactFile {
+ readonly path: string;
+ readonly container_sha256: string;
+ readonly sha256: string | null;
+ readonly bytes: number | null;
+ readonly inventory_artifact_id: string | null;
+ readonly limitations?: readonly string[];
+}
+
+/** Borrowed inventory lookup; metadata is materialized only for requested paths. */
+export interface JavaScriptUnanalyzedArtifactLookup {
+ get(path: string): JavaScriptUnanalyzedArtifactFile | undefined;
+ hasDirectory(path: string, containerSha256: string | undefined): boolean;
+}
+
/** One filesystem-backed ASAR nested beneath a directory input. */
export interface JavaScriptArtifactContainer {
readonly path: string;
@@ -40,6 +64,8 @@ export interface JavaScriptArtifactContainer {
/** Deterministic relevant-file projection plus explicit omissions. */
export interface JavaScriptArtifactFileSet {
readonly files: readonly JavaScriptArtifactFile[];
+ /** Borrowed inventory lookup for regular files the source projection does not read. */
+ readonly unanalyzed_files?: JavaScriptUnanalyzedArtifactLookup;
readonly containers: readonly JavaScriptArtifactContainer[];
readonly text_bytes_read: number;
readonly invalid_utf8_files: number;
diff --git a/src/domain/javascript/javascriptAstFingerprint.test.ts b/src/domain/javascript/javascriptAstFingerprint.test.ts
index ddbb85ade..4a12ac8e3 100644
--- a/src/domain/javascript/javascriptAstFingerprint.test.ts
+++ b/src/domain/javascript/javascriptAstFingerprint.test.ts
@@ -2,6 +2,7 @@ import { parse } from "@babel/parser";
import { describe, expect, it } from "vitest";
import { fingerprintJavaScriptAst } from "./javascriptAstFingerprint.js";
+import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
describe("JavaScript AST fingerprints", () => {
it("includes complete literal values in the fingerprint", () => {
@@ -14,3 +15,62 @@ describe("JavaScript AST fingerprints", () => {
);
});
});
+
+it.each(["obj[key]", "obj?.[key]"])(
+ "ignores a local computed key binding in %s",
+ (expression) => {
+ const left = parse(`function read(obj, key) { return ${expression}; }`);
+ const renamed = expression
+ .replace("obj", "value")
+ .replace("key", "selector");
+ const right = parse(
+ `function read(value, selector) { return ${renamed}; }`,
+ );
+ expect(fingerprintJavaScriptAst(left)).toBe(
+ fingerprintJavaScriptAst(right),
+ );
+ },
+);
+
+it("distinguishes computed reads from fixed named members", () => {
+ expect(fingerprintJavaScriptAst(parse("obj[key]"))).not.toBe(
+ fingerprintJavaScriptAst(parse("obj.key")),
+ );
+ expect(fingerprintJavaScriptAst(parse("obj.key"))).not.toBe(
+ fingerprintJavaScriptAst(parse("obj.other")),
+ );
+ expect(fingerprintJavaScriptAst(parse('obj["key"]'))).not.toBe(
+ fingerprintJavaScriptAst(parse('obj["other"]')),
+ );
+});
+
+it.each([
+ ["obj[`left`]", "obj[`right`]"],
+ ["obj[``]", "obj[`right`]"],
+ ["obj[true]", "obj[false]"],
+ ["obj[null]", "obj[false]"],
+ ['obj["" ]', 'obj["right"]'],
+ ["obj[`prefix-${key}`]", "obj[`changed-${key}`]"],
+])("retains computed literal contents in %s", (left, right) => {
+ expect(fingerprintJavaScriptAst(parse(left))).not.toBe(
+ fingerprintJavaScriptAst(parse(right)),
+ );
+});
+
+it("keeps renamed computed-key bundle modules structurally matchable", () => {
+ const source = (name: string) => `globalThis.webpackChunkApp.push([[1], {
+ 17(module, exports) { const ${name} = selectKey(); exports.read = value[${name}]; }
+ }]);`;
+ const before = analyzeJavaScriptStaticSource(source("key"))
+ .bundler_registrations[0]?.modules[0];
+ const after = analyzeJavaScriptStaticSource(source("selector"))
+ .bundler_registrations[0]?.modules[0];
+ expect(before).toBeDefined();
+ expect(after).toBeDefined();
+ expect(before?.structural_fingerprint_algorithm).toBe("babel-ast-v2");
+ expect(before?.source_sha256).not.toBe(after?.source_sha256);
+ expect(before?.structural_fingerprint_sha256).toMatch(/^[a-f0-9]{64}$/u);
+ expect(before?.structural_fingerprint_sha256).toBe(
+ after?.structural_fingerprint_sha256,
+ );
+});
diff --git a/src/domain/javascript/javascriptAstFingerprint.ts b/src/domain/javascript/javascriptAstFingerprint.ts
index b9e53f975..1dee83fa0 100644
--- a/src/domain/javascript/javascriptAstFingerprint.ts
+++ b/src/domain/javascript/javascriptAstFingerprint.ts
@@ -84,13 +84,19 @@ const semanticTokens = (node: t.Node): string[] => {
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)) {
- const property = propertyName(node.property);
- if (property !== "") tokens.push(`property:${property}`);
+ // 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);
diff --git a/src/domain/javascript/javascriptExportShapeComparisonSchemas.test.ts b/src/domain/javascript/javascriptExportShapeComparisonSchemas.test.ts
index 067452b7e..82e826d3b 100644
--- a/src/domain/javascript/javascriptExportShapeComparisonSchemas.test.ts
+++ b/src/domain/javascript/javascriptExportShapeComparisonSchemas.test.ts
@@ -1,10 +1,31 @@
import { expect, it } from "vitest";
import {
+ compareJavaScriptExportShapesInputSchema,
javaScriptExportShapeComparisonChangeSchema,
projectedExportReturnShapesSchema,
} from "./javascriptExportShapeComparisonSchemas.js";
+it("accepts exact empty export names in selectors and projections", () => {
+ expect(
+ compareJavaScriptExportShapesInputSchema.shape.left_export_name.safeParse(
+ "",
+ ).success,
+ ).toBe(true);
+ expect(
+ projectedExportReturnShapesSchema.safeParse({
+ ...projectionWithField({
+ path: "/kind",
+ presence: "present",
+ state: "literal",
+ value: "ok",
+ reason: null,
+ }),
+ exported_name: "",
+ }).success,
+ ).toBe(true);
+});
+
const projectionWithField = (field: unknown) => ({
semantic_role: "export-return-shapes",
module_path: "index.js",
diff --git a/src/domain/javascript/javascriptExportShapeComparisonSchemas.ts b/src/domain/javascript/javascriptExportShapeComparisonSchemas.ts
index 5f9425015..b7ac7ee4f 100644
--- a/src/domain/javascript/javascriptExportShapeComparisonSchemas.ts
+++ b/src/domain/javascript/javascriptExportShapeComparisonSchemas.ts
@@ -7,6 +7,7 @@ const evidenceIdSchema = prefixedDigestSchema("ev");
const graphIdSchema = prefixedDigestSchema("jag");
const nodeIdSchema = prefixedDigestSchema("jag_node");
const textSchema = z.string().min(1);
+const exportNameSchema = z.string();
const semanticPrimitiveSchema = z.union([
z.string(),
z.number().finite(),
@@ -85,9 +86,9 @@ export const compareJavaScriptExportShapesInputSchema = z
left: evidenceSchema,
right: evidenceSchema,
left_module_path: textSchema,
- left_export_name: textSchema,
+ left_export_name: exportNameSchema,
right_module_path: textSchema,
- right_export_name: textSchema,
+ right_export_name: exportNameSchema,
})
.superRefine(validateJavaScriptExportShapeIdentities);
@@ -170,7 +171,7 @@ export const projectedExportReturnShapesSchema = z
.strictObject({
semantic_role: z.literal("export-return-shapes"),
module_path: textSchema,
- exported_name: textSchema,
+ exported_name: exportNameSchema,
callable_id: textSchema,
callable_kind: z.enum(["function", "class", "method"]),
static_return_shapes: z.array(projectedReturnShapeSchema),
@@ -198,7 +199,7 @@ export const projectedExportReturnShapesSchema = z
const exportCandidateSchema = z.strictObject({
node_id: nodeIdSchema,
module_path: textSchema,
- export_name: textSchema,
+ export_name: exportNameSchema,
matches_requested_module: z.boolean(),
matches_requested_export: z.boolean(),
});
@@ -207,7 +208,7 @@ const selectorResultShape = {
evidence_id: evidenceIdSchema,
graph_id: graphIdSchema,
requested_module_path: textSchema,
- requested_export_name: textSchema,
+ requested_export_name: exportNameSchema,
candidates: z.array(exportCandidateSchema),
omitted_candidates: z.number().int().min(0),
};
diff --git a/src/domain/javascript/javascriptExportShapeVariants.ts b/src/domain/javascript/javascriptExportShapeVariants.ts
index bedf57cbe..c4f0839ac 100644
--- a/src/domain/javascript/javascriptExportShapeVariants.ts
+++ b/src/domain/javascript/javascriptExportShapeVariants.ts
@@ -98,7 +98,7 @@ export const buildJavaScriptExportShapeChanges = (
input.rightSelection.selection.status !== "selected"
)
return [selectionUnknownChange(input)];
- if (input.leftShapes.length === 0 || input.rightShapes.length === 0)
+ if (input.leftShapes.length === 0 && input.rightShapes.length === 0)
return [emptyShapeUnknownChange(input)];
const paired = input.pairing.pairs.flatMap((pair) => {
const left = input.leftShapes[pair.leftIndex];
diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts
index b086470f3..c416c8461 100644
--- a/src/domain/javascript/javascriptRpcEndpoints.test.ts
+++ b/src/domain/javascript/javascriptRpcEndpoints.test.ts
@@ -278,6 +278,25 @@ describe("GraphQL operation recognition", () => {
]);
});
+ it("recognizes operations after leading fragment interpolations only when prior quasis are ignored", () => {
+ expect(
+ rpcEndpoints(`
+ const one = gql\` \${CORE_FIELDS}
+ query CommentsForPost($postId: ID!) { comments { id } }\`;
+ const two = gql\`\${CORE_FIELDS}
+ # another fragment
+ ,\${MORE_FIELDS}
+ mutation UpdatePost($id: ID!) { updatePost(id: $id) { id } }\`;
+ const prose = gql\`fragment prose before interpolation
+ \${CORE_FIELDS}
+ query NotAnOperation { viewer { id } }\`;
+ `),
+ ).toEqual([
+ { kind: "network", value: "CommentsForPost", mechanism: "graphql:query" },
+ { kind: "network", value: "UpdatePost", mechanism: "graphql:mutation" },
+ ]);
+ });
+
it("retains operations after long ignored prefixes and long headers", () => {
const name = "Operation" + "x".repeat(512);
const text =
diff --git a/src/domain/javascript/javascriptRpcEndpoints.ts b/src/domain/javascript/javascriptRpcEndpoints.ts
index 1ccf6d3e5..d706c9aed 100644
--- a/src/domain/javascript/javascriptRpcEndpoints.ts
+++ b/src/domain/javascript/javascriptRpcEndpoints.ts
@@ -163,11 +163,8 @@ const GRAPHQL_OPERATION_HEADER =
const isGraphqlIgnoredCharacter = (character: string): boolean =>
character === "," || character === "" || /\s/u.test(character);
-/** Recover a named operation from GraphQL document text. */
-export const graphqlOperationFromText = (
- text: string,
-): JavaScriptRpcEndpoint | undefined => {
- // Skip GraphQL ignored tokens: whitespace, commas, BOM and # comments.
+/** Return the first offset after GraphQL ignored tokens. */
+const skipGraphqlIgnored = (text: string): number => {
const limit = text.length;
let index = 0;
while (index < limit) {
@@ -177,7 +174,15 @@ export const graphqlOperationFromText = (
while (index < limit && !/[\n\r]/u.test(text.charAt(index))) index += 1;
} else break;
}
- if (index >= limit) return undefined;
+ return index;
+};
+
+/** Recover a named operation from GraphQL document text. */
+export const graphqlOperationFromText = (
+ text: string,
+): JavaScriptRpcEndpoint | undefined => {
+ const index = skipGraphqlIgnored(text);
+ if (index >= text.length) return undefined;
const header = GRAPHQL_OPERATION_HEADER.exec(text.slice(index));
const type = header?.[1];
const name = header?.[2];
@@ -310,11 +315,17 @@ export const createJavaScriptRpcScan = (
? undefined
: graphqlOperationFromText(node.value);
if (t.isTemplateLiteral(node)) {
- // A template recovered without a cooked value stays dynamic.
- const cooked = node.quasis[0]?.value.cooked;
- return cooked === undefined || cooked === null
- ? undefined
- : graphqlOperationFromText(cooked);
+ // Interpolations may contain fragment definitions. Continue only
+ // across quasis that contain ignored tokens; prose or dynamic text
+ // before an operation prevents claiming a later operation belongs to
+ // this document prefix.
+ for (const quasi of node.quasis) {
+ const cooked = quasi.value.cooked;
+ if (cooked === undefined || cooked === null) return undefined;
+ if (skipGraphqlIgnored(cooked) === cooked.length) continue;
+ return graphqlOperationFromText(cooked);
+ }
+ return undefined;
}
return undefined;
},
diff --git a/src/domain/javascript/javascriptRuntimeReconciliationMatching.ts b/src/domain/javascript/javascriptRuntimeReconciliationMatching.ts
index 96bbe2e59..0c3d6e80b 100644
--- a/src/domain/javascript/javascriptRuntimeReconciliationMatching.ts
+++ b/src/domain/javascript/javascriptRuntimeReconciliationMatching.ts
@@ -51,6 +51,13 @@ interface CandidateIndex {
string,
readonly StaticRuntimeCandidate[]
>;
+ readonly byNativeDeclarationPath: ReadonlyMap<
+ string,
+ readonly {
+ readonly candidate: StaticRuntimeCandidate;
+ readonly sourcePath: string | null;
+ }[]
+ >;
readonly byLayerCategoryPath: ReadonlyMap<
string,
readonly StaticRuntimeCandidate[]
@@ -170,9 +177,25 @@ const candidatesWithLocation = (
const located = new Map();
for (const layer of layers)
for (const mapping of mappedRuntimePaths(layer, entity))
- for (const candidate of index.byLayerCategoryPath.get(
- layerCategoryPathKey(layer.layerId, entity.staticKind, mapping.path),
- ) ?? []) {
+ for (const candidate of [
+ ...(index.byLayerCategoryPath.get(
+ layerCategoryPathKey(layer.layerId, entity.staticKind, mapping.path),
+ ) ?? []),
+ ...(mapping.fileStyle === null || mapping.nativeRelativePath === null
+ ? []
+ : (
+ index.byNativeDeclarationPath.get(
+ `${mapping.fileStyle}\0${layerCategoryPathKey(layer.layerId, entity.staticKind, mapping.nativeRelativePath)}`,
+ ) ?? []
+ )
+ .filter(
+ ({ sourcePath }) =>
+ mapping.artifactPrefix === "" ||
+ (sourcePath !== null &&
+ sourcePath.startsWith(`${mapping.artifactPrefix}/`)),
+ )
+ .map(({ candidate }) => candidate)),
+ ]) {
const key = candidateKey(candidate);
if (!located.has(key)) located.set(key, { candidate, mapping });
}
@@ -189,6 +212,10 @@ const indexCandidates = (
): CandidateIndex => {
const byCategoryDigest = new Map();
const byLayerCategoryPath = new Map();
+ const byNativeDeclarationPath = new Map<
+ string,
+ { candidate: StaticRuntimeCandidate; sourcePath: string | null }[]
+ >();
for (const candidate of candidates) {
for (const { sha256 } of candidate.digests)
appendIndex(
@@ -202,14 +229,23 @@ const indexCandidates = (
layerCategoryPathKey(candidate.layer.layerId, candidate.category, path),
candidate,
);
+ for (const style of ["posix", "windows"] as const)
+ for (const { path, sourcePath } of candidate.nativeDeclarationPaths[
+ style
+ ])
+ appendIndex(
+ byNativeDeclarationPath,
+ `${style}\0${layerCategoryPathKey(candidate.layer.layerId, candidate.category, path)}`,
+ { candidate, sourcePath },
+ );
}
- return { byCategoryDigest, byLayerCategoryPath };
+ return { byCategoryDigest, byLayerCategoryPath, byNativeDeclarationPath };
};
-const appendIndex = (
- index: Map,
+const appendIndex = (
+ index: Map,
key: string,
- candidate: StaticRuntimeCandidate,
+ candidate: Value,
): void => {
const values = index.get(key);
if (values === undefined) index.set(key, [candidate]);
diff --git a/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts b/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts
index ada1c8048..684a4a4d2 100644
--- a/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts
+++ b/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
+import { logicalPathEscapesRoot } from "../artifactIdentity.js";
import { evidenceSchema } from "../evidence.js";
import { javascriptRuntimeUnresolvedLocationSchema } from "./javascriptRuntimeObservation.js";
import { javascriptApplicationGraphSchema } from "./javascriptApplicationGraph.js";
@@ -16,8 +17,7 @@ const safePrefixSchema = z
.refine(
(value) =>
value === "" ||
- (!value.startsWith("/") &&
- !value.includes("\\") &&
+ (!logicalPathEscapesRoot(value) &&
value.split("/").every((part) => !["", ".", ".."].includes(part))),
"Artifact prefix must be empty or a safe relative POSIX path",
);
diff --git a/src/domain/javascript/javascriptRuntimeStaticCandidates.ts b/src/domain/javascript/javascriptRuntimeStaticCandidates.ts
index 810ab7f5e..a7e1afa73 100644
--- a/src/domain/javascript/javascriptRuntimeStaticCandidates.ts
+++ b/src/domain/javascript/javascriptRuntimeStaticCandidates.ts
@@ -16,6 +16,12 @@ export interface StaticRuntimeCandidate {
readonly node: ApplicationNode;
readonly category: "renderer" | "javascript" | "worker" | "service-worker";
readonly paths: readonly string[];
+ readonly nativeDeclarationPaths: Readonly<
+ Record<
+ "posix" | "windows",
+ readonly { readonly path: string; readonly sourcePath: string | null }[]
+ >
+ >;
readonly digests: readonly {
readonly sha256: string;
readonly basis: "content-sha256" | "module-source-sha256";
@@ -24,6 +30,9 @@ export interface StaticRuntimeCandidate {
export interface MappedRuntimePath {
readonly path: string;
+ readonly fileStyle: "posix" | "windows" | null;
+ readonly nativeRelativePath: string | null;
+ readonly artifactPrefix: string;
readonly basis:
| "artifact-path"
| "operator-file-mapping"
@@ -54,9 +63,20 @@ export const mappedRuntimePaths = (
entity: RuntimeReconciliationEntity,
): MappedRuntimePath[] => {
const paths: MappedRuntimePath[] = [];
+ const fileStyle =
+ entity.location.kind === "file"
+ ? absoluteFilePathStyle(entity.location.value)
+ : null;
if (entity.location.kind === "file" && layer.result.format === "directory") {
const path = pathBelowRoot(layer.result.input_path, entity.location.value);
- if (path !== null) paths.push({ path, basis: "artifact-path" });
+ if (path !== null)
+ paths.push({
+ path,
+ fileStyle,
+ nativeRelativePath: path,
+ artifactPrefix: "",
+ basis: "artifact-path",
+ });
}
for (const mapping of layer.runtimeMappings) {
const suffix =
@@ -70,6 +90,9 @@ export const mappedRuntimePaths = (
if (path === null) continue;
paths.push({
path,
+ fileStyle,
+ nativeRelativePath: mapping.kind === "file-root" ? suffix : null,
+ artifactPrefix: normalizeArtifactPrefix(mapping.artifact_prefix),
basis:
mapping.kind === "file-root"
? "operator-file-mapping"
@@ -111,6 +134,7 @@ const candidatesForLayer = (
node,
category,
paths: pathsByNodeId.get(node.node_id) ?? [],
+ nativeDeclarationPaths: nativeDeclarationPaths(node),
digests: candidateDigests(node),
},
];
@@ -157,7 +181,9 @@ const indexStaticPaths = (
node.node_id,
uniqueSorted([
...nodePaths(node),
- ...(relatedBySource.get(node.node_id) ?? []).flatMap(nodePaths),
+ ...(usesApplicationFileDeclaration(node)
+ ? []
+ : (relatedBySource.get(node.node_id) ?? []).flatMap(nodePaths)),
]),
]),
);
@@ -168,9 +194,21 @@ const nodePaths = (node: ApplicationNode): string[] => {
if (node.identity.strategy === "canonical-path")
paths.push(node.identity.path);
for (const observation of node.observations) {
- for (const property of ["path", "resolved_path", "declared_path"])
- if (typeof observation.properties[property] === "string")
- paths.push(observation.properties[property]);
+ const rawApplicationFile =
+ node.kind === "electron-renderer" &&
+ isRawApplicationFileObservation(observation);
+ if (typeof observation.properties.path === "string")
+ paths.push(observation.properties.path);
+ if (rawApplicationFile) continue;
+ if (typeof observation.properties.resolved_path === "string")
+ paths.push(observation.properties.resolved_path);
+ const declaration = observation.properties.declared_path;
+ if (
+ !hasOwnedArtifactPath(node) &&
+ typeof declaration === "string" &&
+ !declaration.includes("\\")
+ )
+ paths.push(declaration);
if (
observation.evidence.location.available &&
observation.evidence.location.value.kind === "artifact-path"
@@ -184,6 +222,95 @@ const nodePaths = (node: ApplicationNode): string[] => {
);
};
+// Raw filesystem expressions have host syntax only when a captured file location
+// establishes that format. Genuinely owned artifact facts take precedence.
+const hasOwnedArtifactPath = (node: ApplicationNode): boolean =>
+ node.identity.strategy === "canonical-path" ||
+ node.observations.some(
+ ({ properties }) => typeof properties.path === "string",
+ ) ||
+ node.observations.some(
+ (observation) =>
+ !isRawApplicationFileObservation(observation) &&
+ typeof observation.properties.resolved_path === "string",
+ );
+
+// loadFile receives a raw literal here: the syntax producer does not retain
+// computed __dirname expressions. Electron resolves this relative to the app
+// root, so its source-relative inference is not an owned runtime target.
+const isRawApplicationFileObservation = (
+ observation: ApplicationNode["observations"][number],
+): boolean =>
+ observation.properties.resolution_context === "filesystem-expression" &&
+ typeof observation.properties.mechanism === "string" &&
+ observation.properties.mechanism.startsWith("call:") &&
+ observation.properties.mechanism.endsWith("loadFile");
+
+const usesApplicationFileDeclaration = (node: ApplicationNode): boolean =>
+ node.kind === "electron-renderer" &&
+ !hasOwnedArtifactPath(node) &&
+ node.observations.some(isRawApplicationFileObservation);
+
+const nativeDeclarationPaths = (
+ node: ApplicationNode,
+): StaticRuntimeCandidate["nativeDeclarationPaths"] => {
+ if (hasOwnedArtifactPath(node)) return { posix: [], windows: [] };
+ const declarations = node.observations.flatMap((observation) => {
+ const { properties } = observation;
+ return properties.resolution_context === "filesystem-expression" &&
+ typeof properties.declared_path === "string" &&
+ (isRawApplicationFileObservation(observation) ||
+ properties.declared_path.includes("\\"))
+ ? [
+ {
+ path: properties.declared_path,
+ sourcePath: nativeDeclarationSource(observation),
+ },
+ ]
+ : [];
+ });
+ const normalized = (windows: boolean) => {
+ const values = declarations.flatMap(({ path, sourcePath }) => {
+ if (windows && (win32.isAbsolute(path) || /^[a-z]:/iu.test(path)))
+ return [];
+ const normalizedPath = relativeLogicalPath(
+ windows ? path.replaceAll("\\", "/") : path,
+ );
+ return normalizedPath === null
+ ? []
+ : [{ path: normalizedPath, sourcePath }];
+ });
+ return [
+ ...new Map(
+ values.map((value) => [JSON.stringify(value), value]),
+ ).values(),
+ ].sort(
+ (left, right) =>
+ compareUnicodeCodePoints(left.path, right.path) ||
+ compareUnicodeCodePoints(left.sourcePath ?? "", right.sourcePath ?? ""),
+ );
+ };
+ return { posix: normalized(false), windows: normalized(true) };
+};
+
+// A source anchor constrains the selected application namespace; it does not
+// supply a module-relative base for the raw filesystem declaration.
+const nativeDeclarationSource = (
+ observation: ApplicationNode["observations"][number],
+): string | null => {
+ const location = observation.evidence.location;
+ if (!location.available) return null;
+ const source =
+ location.value.kind === "source-range"
+ ? location.value.source
+ : location.value.kind === "artifact-path"
+ ? location.value.path
+ : null;
+ return source === null || source === "artifact-root"
+ ? null
+ : relativeLogicalPath(source);
+};
+
const candidateDigests = (
node: ApplicationNode,
): StaticRuntimeCandidate["digests"] => {
@@ -217,7 +344,8 @@ const pathBelowRoot = (root: string, value: string): string | null => {
rootStyle === "windows"
? win32.relative(root, value)
: posix.relative(root, value);
- const portableRemainder = remainder.replaceAll("\\", "/");
+ const portableRemainder =
+ rootStyle === "windows" ? remainder.replaceAll("\\", "/") : remainder;
if (
remainder === "" ||
(rootStyle === "windows" && win32.isAbsolute(remainder)) ||
@@ -288,10 +416,13 @@ const activeArtifactPrefixes = (
: false,
)
)
- prefixes.add(mapping.artifact_prefix);
+ prefixes.add(normalizeArtifactPrefix(mapping.artifact_prefix));
return [...prefixes].sort(compareUnicodeCodePoints);
};
+const normalizeArtifactPrefix = (prefix: string): string =>
+ prefix === "" ? "" : normalizeJoinedLogicalPath(prefix);
+
const pathWithinPrefix = (path: string, prefix: string): boolean =>
prefix === "" || path === prefix || path.startsWith(`${prefix}/`);
@@ -299,6 +430,7 @@ const safeArtifactPath = (prefix: string, suffix: string): string | null =>
relativeLogicalPath(prefix === "" ? suffix : posix.join(prefix, suffix));
const relativeLogicalPath = (value: string): string | null => {
+ // These are artifact names or already parsed runtime paths, not declarations.
const normalized = normalizeJoinedLogicalPath(value);
return normalized !== "" &&
normalized !== "." &&
@@ -312,10 +444,24 @@ const uniqueMappedPaths = (
): MappedRuntimePath[] =>
[
...new Map(
- values.map((value) => [`${value.basis}:${value.path}`, value]),
+ values.map((value) => [
+ JSON.stringify([
+ value.basis,
+ value.path,
+ value.fileStyle,
+ value.nativeRelativePath,
+ value.artifactPrefix,
+ ]),
+ value,
+ ]),
).values(),
].sort(
(left, right) =>
compareUnicodeCodePoints(left.path, right.path) ||
- compareUnicodeCodePoints(left.basis, right.basis),
+ compareUnicodeCodePoints(left.basis, right.basis) ||
+ compareUnicodeCodePoints(
+ left.nativeRelativePath ?? "",
+ right.nativeRelativePath ?? "",
+ ) ||
+ compareUnicodeCodePoints(left.artifactPrefix, right.artifactPrefix),
);
diff --git a/src/domain/javascript/javascriptSourceMapPaths.ts b/src/domain/javascript/javascriptSourceMapPaths.ts
index 06b5f8265..5966f89e6 100644
--- a/src/domain/javascript/javascriptSourceMapPaths.ts
+++ b/src/domain/javascript/javascriptSourceMapPaths.ts
@@ -93,10 +93,7 @@ const resolveJavaScriptSourceMapPath = (
const windowsPath = /^[a-z]:\//iu.test(portable) || source.startsWith("\\\\");
// Preserve parent traversal rather than letting URL resolution clamp at '/'.
const anchor = `/${"source/".repeat(portable.split("/").length + 1)}`;
- const directory = posix.join(
- anchor,
- posix.dirname(mapPath.replaceAll("\\", "/")),
- );
+ const directory = posix.join(anchor, posix.dirname(mapPath));
const base = pathToFileURL(`${directory}/`, { windows: false });
const absolute = windowsPath ? windowsFileUrl(source) : URL.parse(source);
const resolved = windowsPath ? absolute : URL.parse(source, base);
@@ -116,8 +113,11 @@ const resolveJavaScriptSourceMapPath = (
kind: "unresolved",
reason: "Source reference names a directory rather than a file.",
};
- // Encoded separators must not manufacture a different filesystem hierarchy.
- if (/%(?:2f|5c)/iu.test(resolved.pathname))
+ // Reject separators in declarations, not escaped literal names in the owned base.
+ const declaredPath = windowsPath
+ ? (absolute?.pathname ?? "")
+ : (URL.parse(source, "file:///")?.pathname ?? "");
+ if (/%(?:2f|5c)/iu.test(declaredPath))
return {
kind: "unresolved",
reason: "Source reference contains an encoded path separator.",
@@ -170,10 +170,11 @@ const windowsFileUrl = (path: string): URL | null => {
const decodedUrlPath = (url: URL): string | null => {
try {
- if (url.protocol === "file:")
- return fileURLToPath(url, {
- windows: url.hostname !== "" && url.hostname !== "localhost",
- }).replaceAll("\\", "/");
+ if (url.protocol === "file:") {
+ const windows = url.hostname !== "" && url.hostname !== "localhost";
+ const path = fileURLToPath(url, { windows });
+ return windows ? path.replaceAll("\\", "/") : path;
+ }
return `${url.host}${decodeURIComponent(url.pathname)}`;
} catch (cause: unknown) {
if (cause instanceof URIError || cause instanceof TypeError) return null;
diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts
index c34462498..165ca7fd3 100644
--- a/src/domain/javascript/javascriptStaticAnalysis.ts
+++ b/src/domain/javascript/javascriptStaticAnalysis.ts
@@ -25,6 +25,7 @@ import {
inspectRouteProperty,
inspectRoleProperty,
inspectRpcNode,
+ indexVitePreloadDependencyTable,
} from "./javascriptStaticAnalysisCalls.js";
import {
inspectBundlerRegistration,
@@ -101,6 +102,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
{
source,
accumulator,
+ vitePreloadDependencyTable: indexVitePreloadDependencyTable(file.program),
...(openReceiverFacts === undefined ? {} : { openReceiverFacts }),
electronBindings: electronIdentity.bindings,
rpcScan: createJavaScriptRpcScan(source, clientRoots),
diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts
index b49f4c2ae..0d51cacd0 100644
--- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts
+++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts
@@ -32,6 +32,7 @@ import type {
JavaScriptBundlerModule,
JavaScriptBundlerRegistration,
} from "./javascriptStaticAnalysisTypes.js";
+import { JAVASCRIPT_AST_FINGERPRINT_ALGORITHM } from "./javascriptStaticAnalysisTypes.js";
/** Inspect a bundler registration call and recover its module table. */
export const inspectBundlerRegistration = (
@@ -269,7 +270,7 @@ const recoverBundlerModule = (
factory_require_name: requireName,
source_sha256: sha256Text(sourceSlice(source, factory)),
structural_fingerprint_sha256: fingerprint,
- structural_fingerprint_algorithm: "babel-ast-v1",
+ structural_fingerprint_algorithm: JAVASCRIPT_AST_FINGERPRINT_ALGORITHM,
exports: exportsValue.values,
location: range(factory),
},
diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts
new file mode 100644
index 000000000..477bef1ac
--- /dev/null
+++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+
+import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
+
+describe("static callee boundary matching", () => {
+ it("matches complete callee members without suffix collisions", () => {
+ const analysis = analyzeJavaScriptStaticSource(`
+ win.loadFile("./window.html");
+ BrowserWindow.loadURL("https://app.example.test");
+ navigator.serviceWorker.register("./worker.js");
+ loadFile("./bare.html");
+ win?.loadURL("https://optional.example.test");
+
+ uploadFile("./upload.html");
+ preloadFile("./preload.html");
+ downloadURL("https://download.example.test");
+ reloadURL("https://reload.example.test");
+ notloadURL("https://other.example.test");
+ myserviceWorker.register("./not-worker.js");
+ `);
+
+ expect(
+ analysis.role_paths.map(({ path, mechanism }) => [path, mechanism]),
+ ).toEqual([
+ ["./window.html", "call:win.loadFile"],
+ ["https://app.example.test", "call:BrowserWindow.loadURL"],
+ ["./bare.html", "call:loadFile"],
+ ["https://optional.example.test", "call:win.loadURL"],
+ ]);
+ expect(
+ analysis.references
+ .filter(({ kind }) => kind === "service-worker")
+ .map(({ specifier }) => specifier),
+ ).toEqual(["./worker.js"]);
+ expect(
+ analysis.endpoints
+ .filter(({ mechanism }) => mechanism.endsWith("loadURL"))
+ .map(({ value }) => value),
+ ).toEqual(["https://app.example.test", "https://optional.example.test"]);
+ });
+});
diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts
index b098b3c26..b031aea9e 100644
--- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts
+++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts
@@ -71,7 +71,12 @@ export const inspectCall = (
kind: "dynamic-import",
specifier: chunkLoad.specifier,
});
- for (const specifier of vitePreloadDependencySpecifiers(source, node, name))
+ for (const specifier of vitePreloadDependencySpecifiers(
+ node,
+ name,
+ context.vitePreloadDependencyTable,
+ bindings,
+ ))
addReference(context, { node, kind: "dynamic-import", specifier });
const worker = t.isNewExpression(node) ? node : undefined;
const workerConstructor =
@@ -94,13 +99,19 @@ export const inspectCall = (
? undefined
: workerUrlSpecifier(worker, bindings)),
});
- if (name.endsWith("serviceWorker.register"))
+ if (
+ name === "serviceWorker.register" ||
+ name.endsWith(".serviceWorker.register")
+ )
addReference(context, { node, kind: "service-worker", specifier: first });
inspectEndpointCall(node, name, first, context);
inspectStorageCall(node, name, first, context);
if (first === "audio" || first === "video")
noteMediaElementPreloads(node, accumulator);
- if (name.endsWith("loadFile") && first !== undefined)
+ if (
+ (name === "loadFile" || name.endsWith(".loadFile")) &&
+ first !== undefined
+ )
addLocatedFinding(context, {
collection: accumulator.roles,
key: `role\0renderer\0${first}`,
@@ -114,7 +125,7 @@ export const inspectCall = (
location: range(node),
},
});
- if (name.endsWith("loadURL") && first !== undefined)
+ if ((name === "loadURL" || name.endsWith(".loadURL")) && first !== undefined)
addLocatedFinding(context, {
collection: accumulator.roles,
key: `role\0renderer\0${first}`,
@@ -241,28 +252,156 @@ const chunkLoadSpecifier = (
};
const vitePreloadDependencySpecifiers = (
- source: string,
node: JavaScriptCallLike,
name: string,
-): readonly string[] => {
- if (name !== "__vite__mapDeps" || !t.isArrayExpression(node.arguments[0]))
+ table: readonly (string | undefined)[] | null,
+ bindings: JavaScriptSemanticAnalysisState,
+): readonly (string | undefined)[] => {
+ if (
+ name !== "__vite__mapDeps" ||
+ table === null ||
+ !t.isArrayExpression(node.arguments[0])
+ )
return [];
- const indexes = node.arguments[0].elements.flatMap((element) =>
- t.isNumericLiteral(element) ? [element.value] : [],
- );
- if (indexes.length !== node.arguments[0].elements.length) return [];
- const table = vitePreloadDependencyTable(source);
- return indexes.flatMap((index) => table[index] ?? []);
+ const indexes = node.arguments[0].elements;
+ // Explicit cache overrides change which table the helper reads. Retain the
+ // call slots as unknown unless every override is statically undefined.
+ if (
+ !node.arguments
+ .slice(1)
+ .every((argument) => isExplicitDefaultArgument(argument, bindings))
+ )
+ return indexes.map(() => undefined);
+ return indexes.map((element) => {
+ if (element === null || !t.isNumericLiteral(element)) return undefined;
+ const index = element.value;
+ return Number.isSafeInteger(index) && index >= 0 ? table[index] : undefined;
+ });
};
-const vitePreloadDependencyTable = (source: string): readonly string[] => {
- const match =
- /\.\s*f\s*\|\|\s*\(\s*[^)]*?\.f\s*=\s*\[([\s\S]*?)\]\s*\)/u.exec(source);
- const body = match?.[1];
- if (body === undefined) return [];
- return [
- ...body.matchAll(/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/gu),
- ].map((literal) => literal[1] ?? literal[2] ?? "");
+const isExplicitDefaultArgument = (
+ argument: t.CallExpression["arguments"][number],
+ bindings: JavaScriptSemanticAnalysisState,
+): boolean => {
+ if (!t.isNode(argument)) return false;
+ const value = unwrapJavaScriptExpression(argument).node;
+ return (
+ (t.isUnaryExpression(value) && value.operator === "void") ||
+ (t.isIdentifier(value, { name: "undefined" }) &&
+ isUnmodifiedGlobalReference(value, bindings, "undefined"))
+ );
+};
+
+type ViteMapDepsFunction =
+ | t.FunctionDeclaration
+ | t.FunctionExpression
+ | t.ArrowFunctionExpression;
+
+/** Index the canonical Vite helper's cache initializer from parsed syntax. */
+export const indexVitePreloadDependencyTable = (
+ program: t.Program,
+): readonly (string | undefined)[] | null => {
+ const helpers: ViteMapDepsFunction[] = [];
+ visitJavaScriptNodes(program, (node) => {
+ if (t.isFunctionDeclaration(node) && node.id?.name === "__vite__mapDeps")
+ helpers.push(node);
+ else if (
+ t.isVariableDeclarator(node) &&
+ t.isIdentifier(node.id, { name: "__vite__mapDeps" })
+ ) {
+ const init = node.init;
+ if (t.isFunctionExpression(init) || t.isArrowFunctionExpression(init))
+ helpers.push(init);
+ }
+ });
+ if (helpers.length !== 1) return null;
+ const helper = helpers[0];
+ if (helper === undefined) return null;
+ const mapParameter = helper.params[1];
+ const mapIdentifier = t.isAssignmentPattern(mapParameter)
+ ? mapParameter.left
+ : mapParameter;
+ if (!t.isIdentifier(mapIdentifier)) return [];
+ const tables: t.ArrayExpression[] = [];
+ visitJavaScriptNodes(
+ helper,
+ (node) => {
+ if (
+ t.isAssignmentExpression(node) &&
+ node.operator === "=" &&
+ (t.isMemberExpression(node.left) ||
+ t.isOptionalMemberExpression(node.left)) &&
+ t.isIdentifier(node.left.object, { name: mapIdentifier.name }) &&
+ semanticStaticPropertyName(node.left.property, node.left.computed) ===
+ "f" &&
+ t.isArrayExpression(node.right)
+ )
+ tables.push(node.right);
+ },
+ undefined,
+ true,
+ );
+ if (tables.length !== 1) return [];
+ const table = tables[0];
+ if (table === undefined) return [];
+ const dependencies = table.elements.map((element) =>
+ element === null || t.isSpreadElement(element)
+ ? undefined
+ : stringValue(element),
+ );
+ let escapedOrMutated = false;
+ visitJavaScriptNodes(
+ program,
+ (node, parent) => {
+ if (!t.isIdentifier(node, { name: "__vite__mapDeps" })) return;
+ if (
+ (parent === helper &&
+ t.isFunctionDeclaration(parent) &&
+ parent.id === node) ||
+ (t.isVariableDeclarator(parent) &&
+ parent.init === helper &&
+ parent.id === node)
+ )
+ return;
+ if (
+ (t.isCallExpression(parent) || t.isOptionalCallExpression(parent)) &&
+ parent.callee === node
+ )
+ return;
+ escapedOrMutated = true;
+ },
+ helper,
+ );
+ return escapedOrMutated ? dependencies.map(() => undefined) : dependencies;
+};
+
+const visitJavaScriptNodes = (
+ root: t.Node,
+ visit: (node: t.Node, parent: t.Node | undefined) => void,
+ skip?: t.Node,
+ skipNestedFunctions = false,
+): void => {
+ const pending: { readonly node: t.Node; readonly parent?: t.Node }[] = [
+ { node: root },
+ ];
+ while (pending.length > 0) {
+ const current = pending.pop();
+ if (current === undefined) continue;
+ const { node, parent } = current;
+ if (node === skip) continue;
+ visit(node, parent);
+ if (skipNestedFunctions && node !== root && t.isFunction(node)) continue;
+ const keys = t.VISITOR_KEYS[node.type] ?? [];
+ for (const key of keys) {
+ const child = (node as unknown as Record)[key];
+ if (Array.isArray(child)) {
+ for (let index = child.length - 1; index >= 0; index -= 1) {
+ const item = child[index];
+ if (t.isNode(item)) pending.push({ node: item, parent: node });
+ }
+ } else if (t.isNode(child)) pending.push({ node: child, parent: node });
+ }
+ }
};
const inspectEndpointCall = (
@@ -292,8 +431,9 @@ const inspectEndpointCall = (
addEndpoint(context, {
node,
kind: "network",
- value: endpoint,
+ value: endpoint.value,
mechanism: `call:${name}`,
+ ...(endpoint.kind === "candidate" ? { interpretation: "candidate" } : {}),
});
};
@@ -401,12 +541,15 @@ export const addReference = (
const addEndpoint = (context: FindingContext, input: EndpointInput): void =>
addLocatedFinding(context, {
collection: context.accumulator.endpoints,
- key: `endpoint\0${input.kind}\0${input.value}`,
+ key: `endpoint\0${input.kind}\0${input.interpretation ?? "literal"}\0${input.value}`,
node: input.node,
value: {
kind: input.kind,
value: sanitizeEndpointCandidate(input.value),
mechanism: input.mechanism,
+ ...(input.interpretation === undefined
+ ? {}
+ : { interpretation: input.interpretation }),
module_key: null,
location: range(input.node),
},
diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts
index 14827e1c2..952abdd0e 100644
--- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts
+++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts
@@ -410,13 +410,27 @@ const XHR_METHODS = new Set([
"TRACE",
]);
-/** An absolute HTTP(S)/WebSocket URL, or an absolute or relative path. */
-const isRequestUrlLiteral = (value: string): boolean =>
+/** 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
@@ -538,10 +552,20 @@ const isKeyedCollectionReceiver = (callee: t.Node): boolean => {
receiver.property,
receiver.computed,
);
- return property === "headers" || property === "searchParams";
+ return (
+ property === "headers" ||
+ property === "headersList" ||
+ property === "searchParams"
+ );
};
-/** Select the literal URL argument for recognized network callees. */
+/** 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 (
@@ -552,7 +576,11 @@ export const endpointArgument = (
)[],
callee: t.Node,
receiverFact?: JavaScriptOpenReceiverFact,
-): string | undefined => {
+): JavaScriptEndpointArgument | undefined => {
+ const literal = (
+ value: string | undefined,
+ ): JavaScriptEndpointArgument | undefined =>
+ value === undefined ? undefined : { kind: "endpoint", value };
if (receiverFact === "window") return undefined;
if (
name === "fetch" ||
@@ -561,9 +589,18 @@ export const endpointArgument = (
name === "EventSource" ||
name.endsWith(".EventSource")
)
- return stringValue(args[0]);
- if (name.endsWith(".open"))
- return xhrOpenUrl(name, args[0], args[1], receiverFact);
+ 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}`),
);
@@ -572,18 +609,26 @@ export const endpointArgument = (
// 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.
- return value !== undefined &&
+ if (value === undefined) return undefined;
+ if (
(method === "get" || method === "delete") &&
- !isRequestUrlLiteral(value) &&
+ !isRequestUrlOrPathLiteral(value) &&
(isKeyedCollectionReceiver(callee) ||
- (method === "get" && receiverFact === "configuration"))
- ? undefined
- : value;
+ (method === "get" &&
+ (receiverFact === "configuration" ||
+ receiverFact === "extension-state")))
+ )
+ return undefined;
+ return {
+ kind: isRequestUrlReference(value) ? "endpoint" : "candidate",
+ value,
+ };
}
- // navigator.sendBeacon(url, data) queues a POST to its first argument.
+ // Beacon URLs are endpoint observations, retaining the upstream API support.
if (name === "sendBeacon" || name.endsWith(".sendBeacon"))
- return stringValue(args[0]);
- if (name.endsWith("loadURL")) return stringValue(args[0]);
+ return literal(stringValue(args[0]));
+ if (name === "loadURL" || name.endsWith(".loadURL"))
+ return literal(stringValue(args[0]));
return undefined;
};
diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts
index 5173362fa..c60b76763 100644
--- a/src/domain/javascript/javascriptStaticAnalysisState.ts
+++ b/src/domain/javascript/javascriptStaticAnalysisState.ts
@@ -65,6 +65,7 @@ export interface JavaScriptFindingContext {
readonly rpcScan: JavaScriptRpcScan;
readonly source: string;
readonly accumulator: JavaScriptAnalysisAccumulator;
+ readonly vitePreloadDependencyTable: readonly (string | undefined)[] | null;
readonly openReceiverFacts?: ReadonlyMap;
/** Proven Electron export path segments by root offset. */
readonly electronBindings?: ReadonlyMap;
@@ -83,6 +84,7 @@ export interface JavaScriptEndpointInput {
readonly kind: JavaScriptStaticEndpoint["kind"];
readonly value: string;
readonly mechanism: string;
+ readonly interpretation?: "candidate";
}
/** Values required to append one module-attributable static finding. */
diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts
index 1e72deb1d..887277950 100644
--- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts
+++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts
@@ -1,5 +1,8 @@
import type { ElectronStaticFindings } from "./electronStaticAnalysisTypes.js";
+/** Token semantics shared by bundle producers and worker admission. */
+export const JAVASCRIPT_AST_FINGERPRINT_ALGORITHM = "babel-ast-v2";
+
/** Static syntax context retained for later artifact path resolution. */
export type JavaScriptStaticPathContext =
| "module-specifier"
@@ -42,6 +45,8 @@ export interface JavaScriptStaticEndpoint {
readonly kind: "route" | "network";
readonly value: string;
readonly mechanism: string;
+ /** An overloaded callee name suggests this value; its network receiver is unproven. */
+ readonly interpretation?: "candidate";
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
@@ -75,7 +80,7 @@ interface JavaScriptBundlerModuleState {
/** One exact module factory recovered from a Webpack/Rspack registration. */
export interface JavaScriptBundlerModule extends JavaScriptBundlerModuleState {
readonly structural_fingerprint_sha256: string;
- readonly structural_fingerprint_algorithm: "babel-ast-v1";
+ readonly structural_fingerprint_algorithm: typeof JAVASCRIPT_AST_FINGERPRINT_ALGORITHM;
}
/** One statically recognized bundler chunk or module registration. */
diff --git a/src/domain/javascript/javascriptSymbolicBindings.test.ts b/src/domain/javascript/javascriptSymbolicBindings.test.ts
index a4248e30b..4e0a2acac 100644
--- a/src/domain/javascript/javascriptSymbolicBindings.test.ts
+++ b/src/domain/javascript/javascriptSymbolicBindings.test.ts
@@ -170,6 +170,34 @@ it("invalidates a bootstrap module slot mutated through an alias", () => {
]);
});
+it.each([
+ [
+ "destructuring assignment",
+ `[table[1]] = [function(module) { module.exports = require("other"); }];`,
+ ],
+ ["for-of head", `for (table[1] of replacements) {}`],
+ [
+ "wrapped assignment",
+ `(table as never)[1] = function(module) { module.exports = require("other"); };`,
+ ],
+])(
+ "invalidates a bootstrap module slot overwritten through %s",
+ (_name, write) => {
+ const values = inspectedValues(`(() => {
+ const modules = {
+ 1(module) { module.exports = require("electron"); },
+ 2(module, exports, load) { inspect(load(1)); }
+ };
+ const table = modules;
+ function load(id) { const m = {exports:{}}; modules[id](m,m.exports,load); return m.exports; }
+ ${write}
+ load(2);
+ })();`);
+ expect(values).toHaveLength(1);
+ expect(values[0]).toEqual(expect.objectContaining({ kind: "unknown" }));
+ },
+);
+
it.each([
`const electron=require("electron"); const ipc=electron.ipcMain; ipc.handle=()=>null; inspect(electron.ipcMain.handle);`,
`require("electron").ipcMain.handle=()=>null; inspect(require("electron").ipcMain.handle);`,
@@ -438,3 +466,55 @@ it("propagates branch guards through object properties and function calls", () =
]);
}
});
+
+it("completes a loaded module past a very large array without stack overflow", () => {
+ const parsed = parseJavaScriptSource(`(() => {
+ const modules = {
+ 4(module) {
+ module.exports = { table: [0] };
+ if (module) { return; }
+ return;
+ },
+ 8(module, exports, load) { const dep = load(4); inspect(dep); }
+ };
+ function load(id) { const m = {exports:{}}; modules[id](m,m.exports,load); return m.exports; }
+ load(8);
+ })();`);
+ if (parsed === null) throw new Error("symbolic source fixture did not parse");
+ const arrays: t.ArrayExpression[] = [];
+ const bareReturns: t.ReturnStatement[] = [];
+ let inspectCall: t.CallExpression | undefined;
+ traverseJavaScriptAst(parsed.program, {
+ enter: (node) => {
+ if (t.isArrayExpression(node)) arrays.push(node);
+ if (t.isReturnStatement(node) && node.argument == null)
+ bareReturns.push(node);
+ if (
+ t.isCallExpression(node) &&
+ t.isIdentifier(node.callee, { name: "inspect" })
+ )
+ inspectCall = node;
+ },
+ });
+ for (const array of arrays)
+ array.elements = Array.from({ length: 1_000_000 }, () =>
+ t.numericLiteral(0),
+ );
+ const earliestReturn = bareReturns.toSorted(
+ (left, right) => (left.start ?? 0) - (right.start ?? 0),
+ )[0];
+ const inspected = inspectCall?.arguments[0];
+ if (earliestReturn === undefined || inspected === undefined)
+ throw new Error("symbolic source fixture is missing its probes");
+
+ const state = completeSemanticSteps(
+ indexJavaScriptBindingsSteps(parsed.program),
+ );
+ const resolver = new JavaScriptSymbolicBindings(state, parsed.program);
+ const namespace = symbolicVariants(resolver.evaluate(inspected)).find(
+ (variant) => variant.kind === "namespace",
+ );
+ if (namespace?.kind !== "namespace")
+ throw new Error("loaded module namespace missing");
+ expect(namespace.environment.completedAt).toBe(earliestReturn);
+});
diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts
index faddf545f..ec1585a1b 100644
--- a/src/domain/javascript/javascriptSymbolicBindings.ts
+++ b/src/domain/javascript/javascriptSymbolicBindings.ts
@@ -1,6 +1,8 @@
import * as t from "@babel/types";
import {
semanticStaticPropertyKey,
+ semanticWriteSiteTarget,
+ semanticWriteTargets,
runtimeFunctionParameters,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
@@ -16,7 +18,6 @@ import {
modulePropertyName,
} from "./javascriptStaticAnalysisHelpers.js";
import {
- childNodes,
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
@@ -105,12 +106,8 @@ export class JavaScriptSymbolicBindings {
});
traverseJavaScriptAst(program, {
enter: (node) => {
- if (t.isAssignmentExpression(node))
- this.#indexWrite(node.left, node.right, node);
- if (t.isUpdateExpression(node))
- this.#indexWrite(node.argument, node, node);
- if (t.isUnaryExpression(node, { operator: "delete" }))
- this.#indexWrite(node.argument, node, node);
+ const target = semanticWriteSiteTarget(node);
+ if (target !== null) this.#indexWriteSite(target, node);
if (
t.isCallExpression(node) &&
t.isMemberExpression(node.callee) &&
@@ -636,20 +633,19 @@ export class JavaScriptSymbolicBindings {
#moduleCompletionPoint(factory: t.Function): t.Node {
let earliestReturn: t.ReturnStatement | undefined;
- const pending: t.Node[] = [factory.body];
- while (pending.length > 0) {
- const node = pending.pop();
- if (!node) continue;
- if (node !== factory.body && (t.isFunction(node) || t.isClass(node)))
- continue;
- if (
- t.isReturnStatement(node) &&
- (earliestReturn === undefined ||
- (node.start ?? Infinity) < (earliestReturn.start ?? Infinity))
- )
- earliestReturn = node;
- pending.push(...childNodes(node));
- }
+ // Use the shared traversal so source-sized child lists are pushed one at
+ // a time. Spreading array elements or call arguments overflows the stack.
+ traverseJavaScriptAst(factory.body, {
+ skip: (node) => t.isFunction(node) || t.isClass(node),
+ enter: (node) => {
+ if (
+ t.isReturnStatement(node) &&
+ (earliestReturn === undefined ||
+ (node.start ?? Infinity) < (earliestReturn.start ?? Infinity))
+ )
+ earliestReturn = node;
+ },
+ });
return earliestReturn ?? factory.body;
}
@@ -943,15 +939,33 @@ export class JavaScriptSymbolicBindings {
else for (const key of invalidSlots) modules.delete(key);
}
- #indexWrite(target: t.Node, value: t.Node, site: t.Node): void {
+ #indexWriteSite(target: t.Node, site: t.Node): void {
+ const direct = unwrapJavaScriptExpression(target).node;
+ if (
+ (t.isIdentifier(direct) || t.isMemberExpression(direct)) &&
+ !t.isForOfStatement(site) &&
+ !t.isForInStatement(site)
+ ) {
+ this.#indexWrite(
+ direct,
+ t.isAssignmentExpression(site) ? site.right : site,
+ site,
+ );
+ return;
+ }
+ for (const leaf of semanticWriteTargets(target))
+ this.#indexWrite(leaf, site, site, false);
+ }
+
+ #indexWrite(target: t.Node, value: t.Node, site: t.Node, exact = true): void {
const path: string[] = [];
- let root = target;
- let dynamic = false;
+ let root = unwrapJavaScriptExpression(target).node;
+ let dynamic = !exact;
while (t.isMemberExpression(root)) {
const key = semanticStaticPropertyKey(root.property, root.computed);
if (key === null) dynamic = true;
else path.unshift(key);
- root = root.object;
+ root = unwrapJavaScriptExpression(root.object).node;
}
if (dynamic && t.isIdentifier(root)) {
const binding = resolveSemanticBindingState(this.state, root, root.name);
diff --git a/src/domain/javascript/javascriptVitePreloadDependencies.test.ts b/src/domain/javascript/javascriptVitePreloadDependencies.test.ts
new file mode 100644
index 000000000..4995dd06c
--- /dev/null
+++ b/src/domain/javascript/javascriptVitePreloadDependencies.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest";
+
+import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
+
+describe("Vite preload dependency recovery", () => {
+ it("decodes helper table values and preserves sparse and dynamic slots", () => {
+ const source = String.raw`
+ const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies =
+ (map.f || (map.f = ["./caf\u00e9.js", , getDependency(), "./last.js"]))) =>
+ indexes.map((index) => dependencies[index]);
+ const dynamicIndex = chooseIndex();
+ __vite__mapDeps([0, , 3, dynamicIndex]);
+ `;
+ const analysis = analyzeJavaScriptStaticSource(source);
+ const references = analysis.references.filter(
+ ({ kind }) => kind === "dynamic-import",
+ );
+
+ expect(references.map(({ specifier }) => specifier).sort()).toEqual([
+ "./café.js",
+ "./last.js",
+ null,
+ ]);
+ expect(references.every(({ location }) => location.start.line === 6)).toBe(
+ true,
+ );
+ expect(references.filter(({ specifier }) => specifier === null)).toEqual([
+ expect.objectContaining({
+ expression: expect.stringContaining(
+ "__vite__mapDeps([0, , 3, dynamicIndex])",
+ ),
+ }),
+ ]);
+ });
+
+ it("does not take dependency strings from comments or unrelated helpers", () => {
+ const analysis = analyzeJavaScriptStaticSource(`
+ // cache.f = ["./comment.js"]
+ const unrelated = (cache) => cache.f || (cache.f = ["./unrelated.js"]);
+ __vite__mapDeps([0]);
+ `);
+
+ expect(
+ analysis.references.filter(({ kind }) => kind === "dynamic-import"),
+ ).toEqual([]);
+ });
+
+ it("keeps cache escapes and explicit cache overrides unknown", () => {
+ const helper = `
+ const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies =
+ (map.f || (map.f = ["./original.js"]))) =>
+ indexes.map((index) => dependencies[index]);
+ `;
+ const escaped = analyzeJavaScriptStaticSource(`
+ ${helper}
+ const dependencies = __vite__mapDeps.f;
+ dependencies[0] = "./changed.js";
+ __vite__mapDeps([0]);
+ `);
+ expect(
+ escaped.references
+ .filter(({ kind }) => kind === "dynamic-import")
+ .map(({ specifier }) => specifier),
+ ).toEqual([null]);
+
+ const overridden = analyzeJavaScriptStaticSource(`
+ ${helper}
+ __vite__mapDeps([0], customMap);
+ `);
+ expect(
+ overridden.references
+ .filter(({ kind }) => kind === "dynamic-import")
+ .map(({ specifier }) => specifier),
+ ).toEqual([null]);
+
+ const defaulted = analyzeJavaScriptStaticSource(`
+ ${helper}
+ __vite__mapDeps([0], undefined, void 0);
+ `);
+ expect(
+ defaulted.references
+ .filter(({ kind }) => kind === "dynamic-import")
+ .map(({ specifier }) => specifier),
+ ).toEqual(["./original.js"]);
+ });
+});
diff --git a/src/domain/javascript/sourceToBundleComparison.test.ts b/src/domain/javascript/sourceToBundleComparison.test.ts
index d231cb5ec..0853ce378 100644
--- a/src/domain/javascript/sourceToBundleComparison.test.ts
+++ b/src/domain/javascript/sourceToBundleComparison.test.ts
@@ -330,11 +330,13 @@ describe("source to bundle path syntax", () => {
});
});
- it.each([
- "https://example.test/src/modified.ts?revision=2#section",
- "webpack://fixture/./src/modified.ts?revision=2#section",
- ])("still strips URL query and fragment from %s", (path) => {
- const node = moduleNode("url", { path });
+ it.each(
+ [
+ "https://example.test/src/modified.ts?revision=2#section",
+ "webpack://fixture/./src/modified.ts?revision=2#section",
+ ].flatMap((path) => ["path", "module_path"].map((key) => ({ key, path }))),
+ )("still strips URL query and fragment from $key $path", ({ key, path }) => {
+ const node = moduleNode("url", { [key]: path });
const graph = createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
root_node_ids: [node.node_id],
diff --git a/src/domain/javascript/sourceToBundleSignals.ts b/src/domain/javascript/sourceToBundleSignals.ts
index b3546aecc..b9706f714 100644
--- a/src/domain/javascript/sourceToBundleSignals.ts
+++ b/src/domain/javascript/sourceToBundleSignals.ts
@@ -188,7 +188,7 @@ const projectCurrentNode = (node: ApplicationNode): CurrentProjection => {
digests.add(node.identity.source_sha256);
}
if (node.identity.strategy === "canonical-path")
- addPath(paths, "canonical-path", node.identity.path);
+ addPath(paths, "canonical-path", node.identity.path, "artifact");
for (const observation of node.observations) {
if (node.identity.strategy === "source-map-original") {
const resolution = observation.source_map_reference?.resolution;
@@ -202,8 +202,26 @@ const projectCurrentNode = (node: ApplicationNode): CurrentProjection => {
const sourceDigest = observation.properties.source_sha256;
if (typeof sourceDigest === "string" && isDigest(sourceDigest))
digests.add(sourceDigest);
- for (const key of PATH_PROPERTIES)
- addJsonPath(paths, observation.properties[key]);
+ for (const key of PATH_PROPERTIES) {
+ // A resolved declaration already has an owning artifact-path fact.
+ if (
+ key === "declared_path" &&
+ typeof observation.properties.resolved_path === "string"
+ )
+ continue;
+ addJsonPath(
+ paths,
+ observation.properties[key],
+ key === "resolved_path" ||
+ ((key === "path" || key === "module_path") &&
+ !(
+ typeof observation.properties[key] === "string" &&
+ SCHEME_URL.test(observation.properties[key])
+ ))
+ ? "artifact"
+ : "reference",
+ );
+ }
}
}
return {
@@ -323,29 +341,40 @@ const matchingUnicodePaths = (
.map(({ value }) => value);
};
-const addJsonPath = (paths: CurrentPath[], value: unknown): void => {
- if (typeof value === "string") addPath(paths, "observation-path", value);
+const addJsonPath = (
+ paths: CurrentPath[],
+ value: unknown,
+ format: "artifact" | "reference",
+): void => {
+ if (typeof value === "string")
+ addPath(paths, "observation-path", value, format);
};
const addPath = (
paths: CurrentPath[],
kind: CurrentPathKind,
raw: string,
+ format: "artifact" | "reference",
): void => {
- const value = normalizeCurrentPath(raw);
+ const value = normalizeCurrentPath(raw, format);
if (value !== null) paths.push({ kind, value, allowSuffix: true });
};
const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\//iu;
const SCHEME_URL_PREFIX = /^[a-z][a-z0-9+.-]*:\/\/+/iu;
-const normalizeCurrentPath = (raw: string): string | null => {
- const withoutQuery = SCHEME_URL.test(raw)
- ? (raw.split(/[?#]/u, 1)[0] ?? "")
- : raw;
- const portable = withoutQuery
- .replace(SCHEME_URL_PREFIX, "")
- .replaceAll("\\", "/");
+const normalizeCurrentPath = (
+ raw: string,
+ format: "artifact" | "reference",
+): string | null => {
+ const withoutQuery =
+ format === "reference" && SCHEME_URL.test(raw)
+ ? (raw.split(/[?#]/u, 1)[0] ?? "")
+ : raw;
+ const portable =
+ format === "artifact"
+ ? withoutQuery
+ : withoutQuery.replace(SCHEME_URL_PREFIX, "").replaceAll("\\", "/");
const parts: string[] = [];
for (const part of portable.split("/")) {
if (part === "" || part === ".") continue;
diff --git a/src/domain/webBundleAnalysis.ts b/src/domain/webBundleAnalysis.ts
index 27c79d126..b4e4c11f8 100644
--- a/src/domain/webBundleAnalysis.ts
+++ b/src/domain/webBundleAnalysis.ts
@@ -202,7 +202,12 @@ export const webBundleAnalysisSchema = z.object({
}),
inferences: z.array(
z.object({
- kind: z.enum(["vendor_fingerprint", "route_framework", "bundle_runtime"]),
+ kind: z.enum([
+ "vendor_fingerprint",
+ "route_framework",
+ "bundle_runtime",
+ "endpoint_candidate",
+ ]),
value: z.string(),
confidence: z.enum(["low", "medium", "high"]),
basis: z.array(basisSchema).min(1),
diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts
index 93bb24fdc..0450bc774 100644
--- a/src/domain/webBundleAnalyzer.test.ts
+++ b/src/domain/webBundleAnalyzer.test.ts
@@ -257,9 +257,15 @@ describe("web bundle static-analysis parity", () => {
"/dynamic-method",
"/shadowed-parent-xhr",
"/xhr-get",
- "api/relative",
"https://xhr.example.test/submit",
]);
+ expect(result.inferences).toContainEqual(
+ expect.objectContaining({
+ kind: "endpoint_candidate",
+ value: "api/relative",
+ confidence: "low",
+ }),
+ );
});
});
@@ -356,11 +362,252 @@ describe("web bundle API classification parity", () => {
"/search",
"/users",
"/v1/headers",
- "cache-key",
"https://api.example.test/headers",
"https://api.example.test/users",
+ ]);
+ expect(
+ result.inferences
+ .filter(({ kind }) => kind === "endpoint_candidate")
+ .map(({ value }) => value)
+ .sort(),
+ ).toEqual(["cache-key", "users"]);
+ });
+
+ it("keeps undici header-store reads out of endpoint observations", () => {
+ const result = analyzeCapturedWebBundle(
+ inspection(`
+ response.headersList.get("location", true);
+ request.headersList.delete("authorization");
+ request.headersList.get("/header-list");
+ api.get("/api/users");
+ `),
+ );
+ expect(result.observations.endpoints.map(({ value }) => value)).toEqual([
+ "/header-list",
+ "/api/users",
+ ]);
+ expect(
+ result.inferences
+ .filter(({ kind }) => kind === "endpoint_candidate")
+ .map(({ value }) => value),
+ ).toEqual([]);
+ });
+});
+
+describe("web bundle explicit request reference forms", () => {
+ it("recognizes URL, path, query and fragment forms after excluding lookup receivers", () => {
+ const references = [
+ "http://api.example.test/users",
+ "https://api.example.test/users?q=1#part",
+ "ws://api.example.test/events",
+ "wss://api.example.test/events",
+ "http:api.example.test/users",
+ "https:api.example.test/users?q=1&tag=a&tag=b#part",
+ "https:api.example.test:443/a/../b?x=a+b&x=two#part",
+ "ws:api.example.test/events",
+ "wss:api.example.test/events",
+ "HtTpS:/api.example.test/users",
+ String.raw`https:\api.example.test\users`,
+ "https://[invalid",
+ "//api.example.test/users",
+ "/api/users",
+ "./users",
+ "../users",
+ "?",
+ "?page=2&tag=a&tag=b#section",
+ "#",
+ "#section",
+ "#frag?tag=a&tag=b",
+ ];
+ const ambiguous = [
+ "",
+ ".",
+ "..",
+ "users",
+ "%23section",
+ "%3Fpage=2",
+ " #section",
+ " https:api.example.test/users",
+ "https:",
+ "http:?page=2",
+ "ws:#section",
+ "https:[invalid",
+ "https:api.example.test:99999",
+ "ftp:api.example.test/users",
+ "file:/users",
+ "data:text/plain,users",
+ ];
+ const source = `
+ ${references.map((value) => `api.get(${JSON.stringify(value)});`).join("\n")}
+ ${ambiguous.map((value) => `api.get(${JSON.stringify(value)});`).join("\n")}
+ xhr.open(method, "?view=1");
+ xhr.open(method, "#view=1");
+ xhr.open("GET", "#literal=1");
+ xhr.open(method, "https:api.example.test/xhr");
+ request("POST", target);
+ snackBar.open(message, "X");
+ new Map().get("?map-key");
+ new Map().get("#map-key");
+ new Map().get("https:api.example.test/map-key");
+ new Headers().delete("?header-key");
+ new Headers().delete("#header-key");
+ new URLSearchParams().get("?parameter-key");
+ new URLSearchParams().get("#parameter-key");
+ response.headers.get("?response-key");
+ response.headers.get("#response-key");
+ const config = vscode.workspace.getConfiguration("git");
+ config.get("?setting");
+ config.get("#setting");
+ config.get("https:api.example.test/setting");
+ window.open(url, "?window-target");
+ window.open(url, "#window-target");
+ indexedDB.open(name, "?database-version");
+ indexedDB.open(name, "#database-version");
+ caches.open(name, "#cache-name");
+ fs.open(path, "r");
+ `;
+ const expected = [
+ ...references,
+ "?view=1",
+ "#view=1",
+ "#literal=1",
+ "https:api.example.test/xhr",
+ ].sort();
+ const expectedCandidates = [...ambiguous, "POST", "X"].sort();
+ const web = analyzeCapturedWebBundle(inspection(source));
+ expect(web.observations.endpoints.map(({ value }) => value).sort()).toEqual(
+ expected,
+ );
+ expect(
+ web.inferences
+ .filter(({ kind }) => kind === "endpoint_candidate")
+ .map(({ value }) => value)
+ .sort(),
+ ).toEqual(expectedCandidates);
+ const analyzed = analyzeJavaScriptStaticSource(source);
+ expect(
+ analyzed.endpoints
+ .filter(({ interpretation }) => interpretation === undefined)
+ .map(({ value }) => value)
+ .sort(),
+ ).toEqual(expected);
+ expect(
+ analyzed.endpoints
+ .filter(({ interpretation }) => interpretation === "candidate")
+ .map(({ value }) => value)
+ .sort(),
+ ).toEqual(expectedCandidates);
+ });
+});
+
+describe("web bundle endpoint candidate classification", () => {
+ it("keeps ambiguous HTTP-method arguments as candidates instead of endpoint observations", () => {
+ const source = `
+ fetch("login");
+ api.get("/api/users");
+ api.get("users");
+ form.get("passwordControl");
+ translate.get("ADDRESS_ADDED");
+ cookieService.put("token", value);
+ http.request("POST", target);
+ snackBar.open(message, "X");
+ xhr.open(method, "login");
+ xhr.open("GET", "ping");
+ `;
+ const result = analyzeCapturedWebBundle(inspection(source));
+ expect(
+ result.observations.endpoints.map(({ value }) => value).sort(),
+ ).toEqual(["/api/users", "login", "ping"]);
+ const candidates = result.inferences.filter(
+ ({ kind }) => kind === "endpoint_candidate",
+ );
+ expect(candidates.map(({ value }) => value).sort()).toEqual([
+ "ADDRESS_ADDED",
+ "POST",
+ "X",
+ "login",
+ "passwordControl",
+ "token",
"users",
]);
+ expect(
+ candidates.every(
+ ({ confidence, basis }) =>
+ confidence === "low" &&
+ basis.length === 1 &&
+ basis[0]?.artifact_sha256 ===
+ createWebTextArtifact(source, "text/javascript").sha256 &&
+ basis[0]?.line !== null &&
+ basis[0]?.detector.startsWith("call:"),
+ ),
+ ).toBe(true);
+ const staticEndpoints = analyzeJavaScriptStaticSource(source).endpoints;
+ expect(
+ staticEndpoints
+ .filter(({ interpretation }) => interpretation === "candidate")
+ .map(({ value }) => value)
+ .sort(),
+ ).toEqual(candidates.map(({ value }) => value).sort());
+ expect(
+ staticEndpoints
+ .filter(({ interpretation }) => interpretation === undefined)
+ .map(({ value }) => value)
+ .sort(),
+ ).toEqual(result.observations.endpoints.map(({ value }) => value).sort());
+ expect(webBundleAnalysisSchema.parse(result)).toEqual(result);
+ });
+
+ it("deduplicates repeated candidate sites while keeping script, value and detector identity", () => {
+ const source = `${'form.get("name");\n'.repeat(5_000)}translate.get("name");\nform.get("other");`;
+ const captured = inspection(source);
+ const first = captured.scripts.items[0];
+ if (first === undefined) throw new Error("Missing captured script fixture");
+ const secondKey = `scr_${"2".repeat(64)}`;
+ const result = analyzeCapturedWebBundle({
+ ...captured,
+ scripts: {
+ total: 2,
+ items: [
+ first,
+ {
+ ...first,
+ script_key: secondKey,
+ url: `${origin}/assets/second.js`,
+ },
+ ],
+ },
+ });
+ const candidates = result.inferences.filter(
+ ({ kind }) => kind === "endpoint_candidate",
+ );
+ expect(candidates).toHaveLength(6);
+ for (const scriptKey of [first.script_key, secondKey]) {
+ const local = candidates.filter(
+ ({ basis }) => basis[0]?.script_key === scriptKey,
+ );
+ expect(
+ local.map(({ value, basis }) => [value, basis[0]?.detector]),
+ ).toEqual([
+ ["name", "call:form.get:unverified-network-receiver"],
+ ["name", "call:translate.get:unverified-network-receiver"],
+ ["other", "call:form.get:unverified-network-receiver"],
+ ]);
+ expect(local[0]?.basis[0]).toMatchObject({
+ line: 1,
+ column: 0,
+ artifact_sha256: createWebTextArtifact(source, "text/javascript")
+ .sha256,
+ });
+ }
+ expect(result.observations.endpoints).toEqual([]);
+ expect(result.capture.source_artifacts).toHaveLength(2);
+ expect(result.completeness).toMatchObject({
+ status: "complete",
+ parsed_scripts: 2,
+ });
+ expect(result.limitations).toContain(
+ 'Endpoint candidates are deduplicated by script, value and detector; basis retains only the first matching source coordinate. They are not a complete call-site inventory or a runtime occurrence count. completeness.status="complete" describes examination of captured source, not a call-site census.',
+ );
});
});
@@ -408,11 +655,16 @@ describe("web bundle configuration and navigation parity", () => {
"shadowed",
"users",
];
+ const web = analyzeCapturedWebBundle(inspection(source));
+ expect(web.observations.endpoints.map(({ value }) => value)).toEqual([
+ "/v1/settings",
+ ]);
expect(
- analyzeCapturedWebBundle(inspection(source))
- .observations.endpoints.map(({ value }) => value)
+ web.inferences
+ .filter(({ kind }) => kind === "endpoint_candidate")
+ .map(({ value }) => value)
.sort(),
- ).toEqual(expected);
+ ).toEqual(expected.slice(1));
expect(
analyzeJavaScriptStaticSource(source)
.endpoints.map(({ value }) => value)
@@ -420,6 +672,68 @@ describe("web bundle configuration and navigation parity", () => {
).toEqual(expected);
});
+ it("classifies proven VS Code extension state reads as non-endpoints", () => {
+ const reads = `
+ context.globalState.get("lastVersion");
+ this.workspaceState.get("closedRepositories", []);
+ context?.globalState.get("onboarded");
+ context.globalState.get("/v1/state");
+ context.secrets.get("token");
+ api.get("users");
+ `;
+ const analyze = (source: string) => {
+ const web = analyzeCapturedWebBundle(inspection(source));
+ const staticAnalysis = analyzeJavaScriptStaticSource(source);
+ return {
+ web: {
+ endpoints: web.observations.endpoints
+ .map(({ value }) => value)
+ .sort(),
+ candidates: web.inferences
+ .filter(({ kind }) => kind === "endpoint_candidate")
+ .map(({ value }) => value)
+ .sort(),
+ },
+ application: {
+ endpoints: staticAnalysis.endpoints
+ .filter(({ interpretation }) => interpretation === undefined)
+ .map(({ value }) => value)
+ .sort(),
+ candidates: staticAnalysis.endpoints
+ .filter(({ interpretation }) => interpretation === "candidate")
+ .map(({ value }) => value)
+ .sort(),
+ },
+ };
+ };
+ const proven = {
+ endpoints: ["/v1/state"],
+ candidates: ["token", "users"],
+ };
+ expect(analyze(`const vscode = require("vscode");${reads}`)).toEqual({
+ web: proven,
+ application: proven,
+ });
+ expect(analyze(`import * as vscode from "vscode";${reads}`)).toEqual({
+ web: proven,
+ application: proven,
+ });
+ const unproven = {
+ endpoints: ["/v1/state"],
+ candidates: [
+ "closedRepositories",
+ "lastVersion",
+ "onboarded",
+ "token",
+ "users",
+ ],
+ };
+ expect(analyze(`const vscode = require("./vscode");${reads}`)).toEqual({
+ web: unproven,
+ application: unproven,
+ });
+ });
+
it("recognizes loadURL endpoints like static analysis", () => {
const result = analyzeCapturedWebBundle(
inspection('window.win.loadURL("https://embedded.test/app");'),
@@ -662,6 +976,26 @@ describe("web metadata key semantics", () => {
});
});
+it("retains WebMCP declarations through optional member and call chaining", () => {
+ const result = analyzeCapturedWebBundle(
+ inspection(`
+ document.modelContext?.registerTool({ name: "optional member" });
+ document.modelContext.registerTool?.({ name: "optional call" });
+ `),
+ );
+
+ expect(result.observations.webmcp_declarations).toEqual([
+ expect.objectContaining({
+ name: "optional member",
+ trust: "page-declared-untrusted",
+ }),
+ expect.objectContaining({
+ name: "optional call",
+ trust: "page-declared-untrusted",
+ }),
+ ]);
+});
+
it("reads expression-free template dynamic imports as chunk edges", () => {
const result = analyzeCapturedWebBundle(
inspection(`
diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts
index f48faf552..2c6b8c7ed 100644
--- a/src/domain/webBundleAnalyzerInspection.ts
+++ b/src/domain/webBundleAnalyzerInspection.ts
@@ -192,19 +192,20 @@ const inspectCall = (
node.callee,
openReceiverFacts.get(node.start ?? -1),
);
- if (endpoint !== undefined)
+ if (endpoint?.kind === "candidate")
+ addEndpointCandidate(script, node, name, endpoint.value, accumulator);
+ else if (endpoint !== undefined)
addFinding({
collection: accumulator.endpoints,
script,
- rawValue: endpoint,
+ rawValue: endpoint.value,
mechanism: `call:${name}`,
node,
accumulator,
});
if (
- (t.isCallExpression(node) || t.isNewExpression(node)) &&
- (name.endsWith("modelContext.registerTool") ||
- name === "modelContext.registerTool")
+ name.endsWith("modelContext.registerTool") ||
+ name === "modelContext.registerTool"
)
addWebMcpDeclaration(script, node, accumulator);
};
@@ -260,6 +261,33 @@ interface AddFindingContext {
readonly accumulator: AnalysisAccumulator;
}
+const addEndpointCandidate = (
+ script: IncludedScript,
+ node: t.Node,
+ name: string,
+ rawValue: string,
+ accumulator: AnalysisAccumulator,
+): void => {
+ const value = sanitizeEndpointCandidate(rawValue);
+ const detector = `call:${name}:unverified-network-receiver`;
+ const key = compositeKey([
+ "endpoint-candidate",
+ script.script_key,
+ value,
+ detector,
+ ]);
+ // This is a per-script candidate inventory, not a callsite list. Keep the
+ // first representative source position without multiplying inline records.
+ addUnique(accumulator, key, () =>
+ accumulator.inferences.push({
+ kind: "endpoint_candidate",
+ value,
+ confidence: "low",
+ basis: [{ ...basis(script, detector), ...locationFields(node) }],
+ }),
+ );
+};
+
const addFinding = (context: AddFindingContext): void => {
const value = sanitizeEndpointCandidate(context.rawValue);
const key = `finding\0${context.mechanism}\0${context.script.script_key}\0${value}`;
@@ -274,7 +302,7 @@ const addFinding = (context: AddFindingContext): void => {
const addWebMcpDeclaration = (
script: IncludedScript,
- node: t.CallExpression | t.NewExpression,
+ node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
accumulator: AnalysisAccumulator,
): void => {
const declaration = node.arguments[0];
diff --git a/src/domain/webBundleAnalyzerResult.ts b/src/domain/webBundleAnalyzerResult.ts
index 4d2509675..b34867f85 100644
--- a/src/domain/webBundleAnalyzerResult.ts
+++ b/src/domain/webBundleAnalyzerResult.ts
@@ -201,6 +201,8 @@ const buildCompleteness = (
const bundleLimitations = (): WebBundleAnalysis["limitations"] => [
"Static bundle findings are observations or bounded inferences; REA does not execute captured JavaScript.",
+ "Bare string arguments of unbound HTTP-method-named calls are low-confidence endpoint candidates; form, translation, storage and other APIs can use the same syntax. URL-shaped findings still do not prove network execution.",
+ 'Endpoint candidates are deduplicated by script, value and detector; basis retains only the first matching source coordinate. They are not a complete call-site inventory or a runtime occurrence count. completeness.status="complete" describes examination of captured source, not a call-site census.',
"String-built routes and endpoints, encrypted configuration, and server-side behavior may remain unknown.",
"Page-declared WebMCP metadata is untrusted and is never registered or invoked as an REA tool.",
];
diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts
new file mode 100644
index 000000000..bb6f8b585
--- /dev/null
+++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts
@@ -0,0 +1,33 @@
+import { expect, it } from "vitest";
+
+import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
+import { javaScriptWorkerStaticAnalysisSchema } from "./JavaScriptAnalysisWorkerSchemas.js";
+
+it("versions fingerprints through the worker schema and rejects v1", () => {
+ const facts =
+ analyzeJavaScriptStaticSource(`globalThis.webpackChunkApp.push([[1], {
+ 17(module, exports) { const key = selectKey(); exports.read = value[key]; }
+ }]);`);
+ expect(facts.bundler_registrations[0]?.modules[0]).toMatchObject({
+ structural_fingerprint_algorithm: "babel-ast-v2",
+ structural_fingerprint_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
+ });
+ expect(
+ javaScriptWorkerStaticAnalysisSchema.safeParse(
+ JSON.parse(JSON.stringify(facts)) as unknown,
+ ).success,
+ ).toBe(true);
+ const old = {
+ ...facts,
+ bundler_registrations: facts.bundler_registrations.map((registration) => ({
+ ...registration,
+ modules: registration.modules.map((module) => ({
+ ...module,
+ structural_fingerprint_algorithm: "babel-ast-v1",
+ })),
+ })),
+ };
+ expect(javaScriptWorkerStaticAnalysisSchema.safeParse(old).success).toBe(
+ false,
+ );
+});
diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts
index 40073f1ae..b8358ec47 100644
--- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts
+++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { digestSchema } from "../../domain/digests.js";
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
+import { JAVASCRIPT_AST_FINGERPRINT_ALGORITHM } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type {
JavaScriptSemanticProperty,
JavaScriptSemanticValue,
@@ -363,7 +364,9 @@ export const javaScriptWorkerStaticAnalysisSchema = z
exports: strings,
location: range,
structural_fingerprint_sha256: digestSchema,
- structural_fingerprint_algorithm: z.literal("babel-ast-v1"),
+ structural_fingerprint_algorithm: z.literal(
+ JAVASCRIPT_AST_FINGERPRINT_ALGORITHM,
+ ),
}),
),
location: range,
diff --git a/tests/acceptance/applications/JavaScriptModuleLookupCli.test.ts b/tests/acceptance/applications/JavaScriptModuleLookupCli.test.ts
index 3a7fad35f..24dc748b7 100644
--- a/tests/acceptance/applications/JavaScriptModuleLookupCli.test.ts
+++ b/tests/acceptance/applications/JavaScriptModuleLookupCli.test.ts
@@ -18,6 +18,9 @@ const createModuleLookupFixture = async () => {
type: "commonjs",
}),
"esm/entry.mjs": [
+ 'import "./a%2eb.mjs";',
+ 'import "./sub/%2e%2e/x.mjs";',
+ 'import "./sub%2fx.mjs";',
'import "./extensionless";',
'import "./directory";',
'import "./explicit.js";',
@@ -43,6 +46,8 @@ const createModuleLookupFixture = async () => {
"esm/directory/index.js": "module.exports = 2;",
"esm/explicit.js.js": "module.exports = 3;",
"esm/exact.js": "module.exports = 4;",
+ "esm/a.b.mjs": "export default 'encoded dot';",
+ "esm/x.mjs": "export default 'confined parent';",
"esm/actual.cjs": "module.exports = 5;",
"esm/.hidden.js": "module.exports = 'local hidden';",
"esm/..pkg.js": "module.exports = 'local dot package';",
@@ -51,6 +56,8 @@ const createModuleLookupFixture = async () => {
"esm/typed.mts": 'import value from "./typed-dep.js"; void value;',
"esm/typed-dep.ts": "export default 42;",
"cjs/entry.cjs": [
+ 'require("./lit%2eb");',
+ 'require("./x%2Fy");',
'require("./extensionless");',
'require("./directory");',
'require("./feature");',
@@ -76,6 +83,8 @@ const createModuleLookupFixture = async () => {
"index.js": "module.exports = 'parent directory';",
"cjs/.hidden.js": "module.exports = 'local hidden';",
"cjs/extensionless.cjs": "module.exports = 1;",
+ "cjs/lit%2eb.js": "module.exports = 'literal dot bytes';",
+ "cjs/x%2Fy.js": "module.exports = 'literal slash bytes';",
"cjs/feature.cjs": "module.exports = 2;",
"cjs/module-only.mjs": "export default 42;",
"cjs/valid-directory/index.js": "module.exports = 42;",
@@ -98,7 +107,7 @@ const createModuleLookupFixture = async () => {
"node_modules/ancestor-blocked/index.js": "module.exports = 9;",
"node_modules/ancestor-sub/parser.js": "module.exports = 10;",
"esm/runtime-control.mjs": [
- 'const specifiers = ["./extensionless", "./directory", "./explicit.js", "./exact.js", "./actual.cjs?cache=1", "empty-main", "nested-main", "file-only", "ancestor-empty", "ancestor-blocked", "ancestor-sub/parser", "./.hidden.js", "./..pkg.js", "./exact.js/.", "./exact.js/child/..", ".hidden.js", "..pkg.js", ".", ".."];',
+ 'const specifiers = ["./a%2eb.mjs", "./sub/%2e%2e/x.mjs", "./sub%2fx.mjs", "./extensionless", "./directory", "./explicit.js", "./exact.js", "./actual.cjs?cache=1", "empty-main", "nested-main", "file-only", "ancestor-empty", "ancestor-blocked", "ancestor-sub/parser", "./.hidden.js", "./..pkg.js", "./exact.js/.", "./exact.js/child/..", ".hidden.js", "..pkg.js", ".", ".."];',
"const results = [];",
"for (const specifier of specifiers) {",
" try { const value = (await import(specifier)).default; results.push({ specifier, ok: true, value }); }",
@@ -107,7 +116,7 @@ const createModuleLookupFixture = async () => {
"console.log(JSON.stringify(results));",
].join("\n"),
"cjs/runtime-control.cjs": [
- 'const specifiers = ["./extensionless", "./directory", "./feature", "./feature.cjs", "./module-only", "./valid-directory", "./default-js", "./default-json", "empty-main", "nested-main", "file-only", "ancestor-empty", "ancestor-blocked", "ancestor-sub/parser", ".hidden.js", "./.hidden.js", ".", ".."];',
+ 'const specifiers = ["./lit%2eb", "./x%2Fy", "./extensionless", "./directory", "./feature", "./feature.cjs", "./module-only", "./valid-directory", "./default-js", "./default-json", "empty-main", "nested-main", "file-only", "ancestor-empty", "ancestor-blocked", "ancestor-sub/parser", ".hidden.js", "./.hidden.js", ".", ".."];',
"const results = [];",
"for (const specifier of specifiers) {",
" try { const value = require(specifier); results.push({ specifier, ok: true, value }); }",
@@ -142,6 +151,13 @@ const expectNodeRuntimeControls = (
const esmResults = JSON.parse(esmOutput) as RuntimeControlResult[];
const cjsResults = JSON.parse(cjsOutput) as RuntimeControlResult[];
expect(esmResults).toEqual([
+ { specifier: "./a%2eb.mjs", ok: true, value: "encoded dot" },
+ { specifier: "./sub/%2e%2e/x.mjs", ok: true, value: "confined parent" },
+ {
+ specifier: "./sub%2fx.mjs",
+ ok: false,
+ code: "ERR_INVALID_MODULE_SPECIFIER",
+ },
{
specifier: "./extensionless",
ok: false,
@@ -199,6 +215,8 @@ const expectNodeRuntimeControls = (
{ specifier: "..", ok: false, code: "ERR_UNSUPPORTED_DIR_IMPORT" },
]);
expect(cjsResults).toEqual([
+ { specifier: "./lit%2eb", ok: true, value: "literal dot bytes" },
+ { specifier: "./x%2Fy", ok: true, value: "literal slash bytes" },
{
specifier: "./extensionless",
ok: false,
@@ -250,6 +268,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
const expectations = [
+ ["import", "./a%2eb.mjs", "esm/a.b.mjs", "resolved"],
+ ["import", "./sub/%2e%2e/x.mjs", "esm/x.mjs", "resolved"],
+ ["import", "./sub%2fx.mjs", null, "rejected"],
["import", "./extensionless", null, "not-found"],
["import", "./directory", null, "not-found"],
["import", "./explicit.js", null, "not-found"],
@@ -271,6 +292,8 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
["import", "./exact.js/.", null, "not-found"],
["import", "./exact.js/child/..", null, "not-found"],
["require", "./extensionless", null, "not-found"],
+ ["require", "./lit%2eb", "cjs/lit%2eb.js", "resolved"],
+ ["require", "./x%2Fy", "cjs/x%2Fy.js", "resolved"],
["require", "./directory", null, "not-found"],
["require", "./feature", null, "not-found"],
["require", "./feature.cjs", "cjs/feature.cjs", "resolved"],
diff --git a/tests/acceptance/applications/packageMainLookupCli.test.ts b/tests/acceptance/applications/packageMainLookupCli.test.ts
new file mode 100644
index 000000000..efdef8a55
--- /dev/null
+++ b/tests/acceptance/applications/packageMainLookupCli.test.ts
@@ -0,0 +1,284 @@
+import { expect } from "vitest";
+
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+import { cliTest } from "../../support/cli/cliFixture.js";
+import {
+ analyzeJavaScriptDirectory,
+ runNodeJsonControl,
+} from "../../support/cli/javascriptApplication.js";
+import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
+
+const cases = [
+ { name: "null-main", metadata: { main: null } },
+ { name: "false-main", metadata: { main: false } },
+ { name: "zero-main", metadata: { main: 0 } },
+ { name: "true-main", metadata: { main: true } },
+ { name: "object-main", metadata: { main: {} } },
+ { name: "empty-main", metadata: { main: "" } },
+ { name: "missing-main", metadata: {} },
+ { name: "string-main", metadata: { main: "./index.js" } },
+] as const;
+
+cliTest.for(["CLI", "stdio MCP"] as const)(
+ "matches Node package main interpretation through %s",
+ async (surface, { cli }) => {
+ const root = await createTestTempDirectory("rea-package-main-");
+ const files: Record = {
+ "package.json": JSON.stringify({ type: "commonjs" }),
+ "entry.cjs": cases
+ .map(({ name }) => `require(${JSON.stringify(name)});`)
+ .join("\n"),
+ "entry.mjs": cases
+ .map(({ name }) => `import ${JSON.stringify(name)};`)
+ .join("\n"),
+ "control.cjs": [
+ `const specifiers = ${JSON.stringify(cases.map(({ name }) => name))};`,
+ "console.log(JSON.stringify(specifiers.map((specifier) => require(specifier))));",
+ ].join("\n"),
+ "control.mjs": [
+ `const specifiers = ${JSON.stringify(cases.map(({ name }) => name))};`,
+ "console.log(JSON.stringify(await Promise.all(specifiers.map(async (specifier) => (await import(specifier)).default))));",
+ ].join("\n"),
+ };
+ for (const { name, metadata } of cases) {
+ files[`node_modules/${name}/package.json`] = JSON.stringify(metadata);
+ files[`node_modules/${name}/index.js`] =
+ `module.exports = ${JSON.stringify(`node_modules/${name}/index.js`)};`;
+ }
+ await writeFixtureFiles(root, files);
+
+ for (const filename of ["control.cjs", "control.mjs"]) {
+ expect(await runNodeJsonControl(root, filename)).toEqual(
+ cases.map(({ name }) => `node_modules/${name}/index.js`),
+ );
+ }
+
+ const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
+ for (const kind of ["require", "import"])
+ for (const { name } of cases)
+ expect(
+ graph.edges
+ .filter(({ relation }) => relation === "imports")
+ .map(({ properties }) => properties),
+ ).toContainEqual(
+ expect.objectContaining({
+ module_link_kind: kind,
+ specifier: name,
+ resolved_path: `node_modules/${name}/index.js`,
+ resolution_status: "resolved",
+ }),
+ );
+ },
+);
+
+cliTest.for(["CLI", "stdio MCP"] as const)(
+ "preserves loader-specific package main probes and finalization through %s",
+ async (surface, { cli }) => {
+ const root = await createTestTempDirectory("rea-package-main-path-");
+ const packages = [
+ {
+ name: "dot-slash-main",
+ main: "./lib/",
+ files: ["lib.js", "lib/index.js", "index.js"],
+ require: "lib.js",
+ import: null,
+ },
+ {
+ name: "slash-main",
+ main: "lib/",
+ files: ["lib.js", "lib/index.js", "index.js"],
+ require: "lib.js",
+ import: null,
+ },
+ {
+ name: "plain-main",
+ main: "./lib",
+ files: ["lib.js", "lib/index.js"],
+ require: "lib.js",
+ import: "lib.js",
+ },
+ {
+ name: "directory-main",
+ main: "./lib/",
+ files: ["lib/index.js", "index.js"],
+ require: "lib/index.js",
+ import: "lib/index.js",
+ },
+ {
+ name: "fallback-main",
+ main: "./missing/",
+ files: ["index.js"],
+ require: "index.js",
+ import: "index.js",
+ },
+ {
+ name: "exact-main",
+ main: "./lib.js/",
+ files: ["lib.js", "index.js"],
+ require: "lib.js",
+ import: "lib.js",
+ },
+ {
+ name: "finalized-directory-main",
+ main: "./lib/",
+ files: ["lib.js", "lib/.js/child.js"],
+ require: "lib.js",
+ import: null,
+ importError: "ERR_UNSUPPORTED_DIR_IMPORT",
+ },
+ {
+ name: "finalized-main",
+ main: "./lib/",
+ files: ["lib.js", "lib/.js", "lib/index.js"],
+ require: "lib.js",
+ import: "lib/.js",
+ },
+ ] as const;
+ const files: Record = {
+ "package.json": JSON.stringify({ type: "commonjs" }),
+ "entry.cjs": [
+ ...packages.map(({ name }) => `require(${JSON.stringify(name)});`),
+ 'require("./local/");',
+ ].join("\n"),
+ "entry.mjs": packages
+ .map(({ name }) => `import ${JSON.stringify(name)};`)
+ .join("\n"),
+ "local.js": 'module.exports = "local.js";',
+ "local/index.js": 'module.exports = "local/index.js";',
+ };
+ for (const item of packages) {
+ files[`node_modules/${item.name}/package.json`] = JSON.stringify({
+ main: item.main,
+ });
+ for (const path of item.files) {
+ const target = `node_modules/${item.name}/${path}`;
+ files[target] = `module.exports = ${JSON.stringify(target)};`;
+ }
+ }
+ for (const kind of ["require", "import"] as const) {
+ const filename = `control.${kind === "require" ? "cjs" : "mjs"}`;
+ files[filename] = [
+ `const specifiers = ${JSON.stringify(packages.map(({ name }) => name))};`,
+ "const results = [];",
+ "for (const specifier of specifiers) {",
+ ` try { results.push({ path: ${kind === "require" ? "require(specifier)" : "(await import(specifier)).default"} }); }`,
+ " catch (error) { results.push({ code: error.code }); }",
+ "}",
+ "console.log(JSON.stringify(results));",
+ ].join("\n");
+ }
+ files["local-control.cjs"] =
+ 'console.log(JSON.stringify(require("./local/")));';
+ await writeFixtureFiles(root, files);
+ for (const kind of ["require", "import"] as const) {
+ const control = await runNodeJsonControl(
+ root,
+ `control.${kind === "require" ? "cjs" : "mjs"}`,
+ );
+ expect(control).toEqual(
+ packages.map((item) =>
+ item[kind] === null
+ ? {
+ code:
+ "importError" in item
+ ? item.importError
+ : "ERR_MODULE_NOT_FOUND",
+ }
+ : { path: `node_modules/${item.name}/${item[kind]}` },
+ ),
+ );
+ }
+ expect(await runNodeJsonControl(root, "local-control.cjs")).toBe(
+ "local/index.js",
+ );
+ const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
+ const relationships = graph.edges
+ .filter(({ relation }) => relation === "imports")
+ .map(({ properties }) => properties);
+ for (const kind of ["require", "import"] as const)
+ for (const item of packages)
+ expect(relationships).toContainEqual(
+ expect.objectContaining({
+ module_link_kind: kind,
+ specifier: item.name,
+ resolved_path:
+ item[kind] === null
+ ? null
+ : `node_modules/${item.name}/${item[kind]}`,
+ resolution_status:
+ item[kind] === null
+ ? "importError" in item
+ ? "rejected"
+ : "not-found"
+ : "resolved",
+ }),
+ );
+ expect(relationships).toContainEqual(
+ expect.objectContaining({
+ module_link_kind: "require",
+ specifier: "./local/",
+ resolved_path: "local/index.js",
+ resolution_status: "resolved",
+ }),
+ );
+ },
+);
+
+cliTest.for(["CLI", "stdio MCP"] as const)(
+ "resolves package mains that name the artifact root through %s",
+ async (surface, { cli }) => {
+ const mains = ["../..", "../../", "../../."];
+ const names = mains.map((_, index) => `root-main-${index}`);
+ const requires = names.map((name) => `require("${name}")`);
+ for (const rootIndex of ["index.js", "index.json", null]) {
+ const root = await createTestTempDirectory("rea-package-main-root-");
+ const importOptions =
+ rootIndex === "index.json" ? ', { with: { type: "json" } }' : "";
+ const imports = names.map(
+ (name) =>
+ `import("${name}"${importOptions}).then(module => module.default)`,
+ );
+ const files: Record = {
+ "package.json": '{"type":"commonjs"}',
+ "entry.cjs": requires.join(";\n"),
+ "entry.mjs": names.map((name) => `import "${name}";`).join("\n"),
+ ".js": 'module.exports = ".js decoy";',
+ ".json": '".json decoy"',
+ "control.cjs": `console.log(JSON.stringify([${requires.join(",")}]));`,
+ "control.mjs": `console.log(JSON.stringify(await Promise.all([${imports.join(",")}])));`,
+ };
+ if (rootIndex !== null)
+ files[rootIndex] =
+ rootIndex === "index.js"
+ ? 'module.exports = "index.js";'
+ : '"index.json"';
+ for (const [index, main] of mains.entries()) {
+ const directory = `node_modules/${names[index]}`;
+ files[`${directory}/package.json`] = JSON.stringify({ main });
+ files[`${directory}/index.js`] =
+ `module.exports = "${directory}/index.js";`;
+ }
+ await writeFixtureFiles(root, files);
+ const expected = names.map(
+ (name) => rootIndex ?? `node_modules/${name}/index.js`,
+ );
+ for (const filename of ["control.cjs", "control.mjs"])
+ expect(await runNodeJsonControl(root, filename)).toEqual(expected);
+
+ const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
+ const relationships = graph.edges
+ .filter(({ relation }) => relation === "imports")
+ .map(({ properties }) => properties);
+ for (const kind of ["require", "import"])
+ for (const [index, name] of names.entries())
+ expect(relationships).toContainEqual(
+ expect.objectContaining({
+ module_link_kind: kind,
+ specifier: name,
+ resolved_path: expected[index],
+ resolution_status: "resolved",
+ }),
+ );
+ }
+ },
+);
diff --git a/tests/acceptance/applications/packageSpecifierSuffixCli.test.ts b/tests/acceptance/applications/packageSpecifierSuffixCli.test.ts
index ad175006e..ee319ff7e 100644
--- a/tests/acceptance/applications/packageSpecifierSuffixCli.test.ts
+++ b/tests/acceptance/applications/packageSpecifierSuffixCli.test.ts
@@ -18,6 +18,23 @@ type PackageSpecifierCase = {
readonly error: string | null;
};
+const directoryFormSpecifiers = [
+ "legacy/feature.js/.",
+ "legacy/feature.js/child/..",
+ "legacy/feature.js/",
+ "legacy/feature.js/.?cache=1#entry",
+ "legacy/with%20space.js/.",
+ "legacy/missing/.",
+ "legacy/feature.js/.\t",
+ "legacy/feature.js/.\n?cache=1#entry",
+ "legacy/feature.js/ch\til\rd/..",
+ "legacy/feature.js/. ",
+];
+const encodedDotSpecifiers = ["\t", "\n", "\r"].flatMap((control) => [
+ `legacy/child/%2${control}e%2${control}e/feature.js`,
+ `legacy/child/.%2${control}E/feature.js`,
+]);
+
const requests = [
{
specifier: "rea-self-fixture",
@@ -131,6 +148,74 @@ const requests = [
value: "legacy spaced feature",
error: null,
},
+ ...directoryFormSpecifiers.map((specifier) => ({
+ specifier,
+ target: null,
+ status: "rejected" as const,
+ value: null,
+ error: "ERR_UNSUPPORTED_DIR_IMPORT",
+ })),
+ ...encodedDotSpecifiers.map((specifier) => ({
+ specifier,
+ target: null,
+ status: "rejected" as const,
+ value: "legacy feature",
+ error: null,
+ })),
+ {
+ specifier: "legacy/feature.js/%2e",
+ target: null,
+ status: "rejected",
+ value: null,
+ error: "ERR_UNSUPPORTED_DIR_IMPORT",
+ },
+ {
+ specifier: "legacy/feature.js/.%09",
+ target: null,
+ status: "not-found",
+ value: null,
+ error: "ERR_MODULE_NOT_FOUND",
+ },
+ {
+ specifier: "legacy/feature.js/%2f",
+ target: null,
+ status: "rejected",
+ value: null,
+ error: "ERR_INVALID_MODULE_SPECIFIER",
+ },
+ ...[
+ "legacy/feature.js?cache=/..",
+ "legacy/feature.js#entry/.",
+ "legacy/fea\tture.js",
+ "legacy/feature.js?cache=%2\te",
+ "legacy/feature.js#entry=%2\nf",
+ "legacy/child/../feature.js",
+ ].map((specifier) => ({
+ specifier,
+ target: "node_modules/legacy/feature.js",
+ status: "resolved" as const,
+ value: "legacy feature",
+ error: null,
+ })),
+ ...[
+ "fixture/feature.js/.",
+ "fixture/feature.js/.\t",
+ "fixture/child/%2\te%2\te/feature",
+ "legacy/../fixture/literal.cjs",
+ ].map((specifier) => ({
+ specifier,
+ target: "node_modules/fixture/literal.cjs",
+ status: "resolved" as const,
+ value: "literal",
+ error: null,
+ })),
+ {
+ specifier: "legacy/literal%252e.js",
+ target: "node_modules/legacy/literal%2e.js",
+ status: "resolved",
+ value: "legacy literal percent",
+ error: null,
+ },
{
specifier: "legacy/feature",
target: null,
@@ -184,6 +269,9 @@ const createPackageSpecifierFixture = async (): Promise<{
exports: {
"./feature": "./feature.cjs",
"./literal?cache=1": "./literal.cjs",
+ "./feature.js/.": "./literal.cjs",
+ "./feature.js/.\t": "./literal.cjs",
+ "./child/%2\te%2\te/feature": "./literal.cjs",
},
}),
);
@@ -206,6 +294,10 @@ const createPackageSpecifierFixture = async (): Promise<{
join(legacyPackagePath, "with space.js"),
'module.exports = "legacy spaced feature";',
),
+ writeFile(
+ join(legacyPackagePath, "literal%2e.js"),
+ 'module.exports = "legacy literal percent";',
+ ),
writeFile(
join(legacyPackagePath, "nested", "package.json"),
JSON.stringify({ main: "./main.cjs" }),
@@ -258,6 +350,20 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
resolution_status: status,
}),
evidence: expect.objectContaining({
+ ...(encodedDotSpecifiers.includes(specifier)
+ ? {
+ limitations: expect.arrayContaining([
+ "Encoded dot or separator bytes are rejected before artifact path resolution.",
+ ]),
+ }
+ : {}),
+ ...(directoryFormSpecifiers.includes(specifier)
+ ? {
+ limitations: expect.arrayContaining([
+ `ESM package subpath ${JSON.stringify(specifier)} has URL directory form; directory imports are unsupported and no exact-file, package main or index lookup was attempted.`,
+ ]),
+ }
+ : {}),
...(specifier === "legacy/feature"
? {
limitations: expect.arrayContaining([
diff --git a/tests/acceptance/applications/packageUrlUnicodeCli.test.ts b/tests/acceptance/applications/packageUrlUnicodeCli.test.ts
new file mode 100644
index 000000000..a50b4c837
--- /dev/null
+++ b/tests/acceptance/applications/packageUrlUnicodeCli.test.ts
@@ -0,0 +1,268 @@
+import { createPackageWithOptions } from "@electron/asar";
+import { createHash } from "node:crypto";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+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";
+
+const escapingTarget = "./%2\te%2\te/outside.js";
+const targets = {
+ feature: { value: "./feature.js", imports: "relative", exports: "relative" },
+ encodedSeparator: {
+ value: "./feature%2\tf.js",
+ imports: "rejected",
+ exports: "rejected",
+ },
+ malformedEncoding: {
+ value: "./feature%ZZ.js",
+ imports: "rejected",
+ exports: "rejected",
+ },
+ blocked: { value: null, imports: "external", exports: "external" },
+ empty: { value: [], imports: "external", exports: "external" },
+ inactive: {
+ value: { browser: "./feature.js" },
+ imports: "external",
+ exports: "external",
+ },
+ external: {
+ value: "another-package",
+ imports: "external",
+ exports: "rejected",
+ },
+ recursive: { value: "#another", imports: "external", exports: "rejected" },
+ invalid: { value: "../feature.js", imports: "rejected", exports: "rejected" },
+ scalar: { value: false, imports: "rejected", exports: "rejected" },
+ numeric: { value: { 0: null }, imports: "rejected", exports: "rejected" },
+ arrayNull: { value: [false, null], imports: "external", exports: "external" },
+ arrayInactive: {
+ value: [{ browser: "./feature.js" }],
+ imports: "external",
+ exports: "external",
+ },
+ arrayExternal: {
+ value: [false, "another-package"],
+ imports: "external",
+ exports: "rejected",
+ },
+ arrayRelative: {
+ value: [false, "./feature.js"],
+ imports: "relative",
+ exports: "relative",
+ },
+ relativeFirst: {
+ value: ["./feature.js", null],
+ imports: "relative",
+ exports: "relative",
+ },
+ escapeExternal: {
+ value: [escapingTarget, "another-package"],
+ imports: "external",
+ exports: "rejected",
+ },
+ escapeNull: {
+ value: [escapingTarget, null],
+ imports: "external",
+ exports: "external",
+ },
+ escapeRelative: {
+ value: [escapingTarget, "./feature.js"],
+ imports: "relative",
+ exports: "relative",
+ },
+ escapeCondition: {
+ value: { node: escapingTarget, default: "./feature.js" },
+ imports: "rejected",
+ exports: "rejected",
+ },
+ escapeNested: {
+ value: [{ node: escapingTarget }, "another-package"],
+ imports: "external",
+ exports: "rejected",
+ },
+ escapeConfig: {
+ value: [{ 0: escapingTarget }, "another-package"],
+ imports: "rejected",
+ exports: "rejected",
+ },
+ wtf8Reentry: {
+ value: ["./%2\te%2\te/%ED%A0%80/feature.js", "another-package"],
+ imports: "external",
+ exports: "rejected",
+ },
+ opaqueReentry: {
+ value: ["./%2\te%2\te/rea-op\taque-0/feature.js", "another-package"],
+ imports: "external",
+ exports: "rejected",
+ },
+ structuralReentry: {
+ value: "./%2\te%2\te/mapped/feature.js",
+ imports: "rejected",
+ exports: "relative",
+ },
+} as const;
+const requests = [
+ {
+ specifier: "legacy/feature.js",
+ path: "node_modules/legacy/feature.js",
+ status: "legacy" as const,
+ },
+ {
+ specifier: "legacy/feature.js/.",
+ path: "",
+ status: "rejected" as const,
+ },
+ {
+ specifier: "legacy/../mapped/feature.js",
+ path: "node_modules/mapped/feature.js",
+ status: "legacy" as const,
+ },
+ {
+ specifier: "legacy/../mapped/feature.js/.",
+ path: "",
+ status: "rejected" as const,
+ },
+ {
+ specifier: "legacy/../../../outside.js",
+ path: "outside.js",
+ status: "root-legacy" as const,
+ },
+ {
+ specifier: "legacy/../../../absent.js",
+ path: "",
+ status: "not-found" as const,
+ },
+ ...Object.entries(targets).flatMap(([name, target]) => [
+ { specifier: `#${name}`, path: "feature.js", status: target.imports },
+ {
+ specifier: `mapped/${name}`,
+ path: "node_modules/mapped/feature.js",
+ status: target.exports,
+ },
+ ]),
+ { specifier: "#missing", path: "", status: "external" as const },
+ { specifier: "mapped/missing", path: "", status: "external" as const },
+];
+const sources = ["import", "require"].map((mode) => ({
+ mode,
+ path: mode === "import" ? "main.mjs" : "main.cjs",
+ source: requests
+ .filter(({ specifier }) => mode === "import" || !specifier.endsWith("/."))
+ .map(({ specifier }) =>
+ mode === "import"
+ ? `import ${JSON.stringify(specifier)};`
+ : `require(${JSON.stringify(specifier)});`,
+ )
+ .join("\n"),
+}));
+
+cliTest.for(["CLI", "stdio MCP"] as const)(
+ "preserves package target facts under unrepresentable URL paths through %s",
+ async (surface, { cli }) => {
+ const root = await createTestTempDirectory("rea-package-url-unicode-");
+ const application = join(root, "application");
+ await mkdir(application);
+ const fixture = join(root, "source");
+ await writeFixtureFiles(fixture, { "outside.js": "export default 4;" });
+ const prefixes = ["highxx", "lowxxx", "valid\ud83d\ude00"];
+ for (const prefix of prefixes)
+ await writeFixtureFiles(join(fixture, prefix), {
+ ...Object.fromEntries(
+ sources.map(({ path, source }) => [path, source]),
+ ),
+ "package.json": JSON.stringify({
+ imports: Object.fromEntries(
+ Object.entries(targets).map(([name, { value }]) => [
+ `#${name}`,
+ value,
+ ]),
+ ),
+ }),
+ "feature.js": "export default 1;",
+ "node_modules/legacy/package.json": "{}",
+ "node_modules/legacy/feature.js": "export default 2;",
+ "node_modules/mapped/package.json": JSON.stringify({
+ exports: Object.fromEntries(
+ Object.entries(targets).map(([name, { value }]) => [
+ `./${name}`,
+ value,
+ ]),
+ ),
+ }),
+ "node_modules/mapped/feature.js": "export default 3;",
+ });
+ const archive = join(application, "app.asar");
+ await createPackageWithOptions(fixture, archive, {});
+ const bytes = await readFile(archive);
+ const header = bytes.subarray(16, 16 + bytes.readUInt32LE(12));
+ const escaped = Buffer.from(
+ header
+ .toString("utf8")
+ .replace('"highxx":', '"\\ud800":')
+ .replace('"lowxxx":', '"\\udc00":'),
+ );
+ expect(escaped.length).toBe(header.length);
+ expect(escaped.equals(header)).toBe(false);
+ escaped.copy(bytes, 16);
+ await writeFile(archive, bytes);
+
+ const { graph } = await analyzeJavaScriptDirectory(
+ surface,
+ cli,
+ application,
+ );
+ for (const prefix of ["\ud800", "\udc00", "valid\ud83d\ude00"])
+ for (const { mode, path, source } of sources)
+ for (const request of requests) {
+ if (mode === "require" && request.specifier.endsWith("/.")) continue;
+ const base = `app.asar/${prefix}`;
+ const available =
+ prefix.startsWith("valid") ||
+ request.status === "root-legacy" ||
+ (mode === "require" && request.status === "legacy");
+ const fileStatus = available ? "resolved" : "unavailable";
+ const status =
+ request.status === "relative" ||
+ request.status === "legacy" ||
+ request.status === "root-legacy"
+ ? fileStatus
+ : request.status;
+ expect(graph.edges).toContainEqual(
+ expect.objectContaining({
+ relation: "imports",
+ properties: expect.objectContaining({
+ module_link_kind: mode,
+ specifier: request.specifier,
+ resolution_status: status,
+ resolved_path:
+ status === "resolved"
+ ? `${request.status === "root-legacy" ? "app.asar" : base}/${request.path}`
+ : null,
+ }),
+ evidence: expect.objectContaining({
+ limitations:
+ status === "unavailable"
+ ? expect.arrayContaining([
+ expect.stringContaining("unpaired UTF-16 surrogate"),
+ ])
+ : expect.not.arrayContaining([
+ expect.stringContaining("unpaired UTF-16 surrogate"),
+ ]),
+ artifact: expect.objectContaining({
+ sha256: createHash("sha256").update(source).digest("hex"),
+ }),
+ location: expect.objectContaining({
+ value: expect.objectContaining({ source: `${base}/${path}` }),
+ }),
+ }),
+ }),
+ );
+ }
+ expect(await readFile(archive)).toEqual(bytes);
+ },
+);
diff --git a/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts b/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts
index c2eb1070e..d04be62ee 100644
--- a/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts
+++ b/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts
@@ -77,17 +77,8 @@ describe("static Electron application analysis", () => {
const result = await reconstructJavaScriptArtifact({ input_path: root });
const graph = parseJavaScriptApplicationGraph(result.graph);
- // Computed access keeps no known members; the unknown access is an
- // explicit dynamic flag rather than a "*" sentinel inside members.
- const accesses = graph.nodes.flatMap((node) =>
- node.kind === "native-export"
- ? node.observations.map(({ properties }) => ({
- members: Array.isArray(properties.requested_members)
- ? properties.requested_members
- : [],
- dynamicMemberAccess: properties.dynamic_member_access ?? false,
- }))
- : [],
+ const nativeBindings = graph.nodes.filter(
+ (node) => node.kind === "native-export",
);
expect(result.electron_summary).toMatchObject({
@@ -95,7 +86,20 @@ describe("static Electron application analysis", () => {
native_addon_bindings: 1,
resolved_native_addon_bindings: 1,
});
- expect(accesses).toEqual([{ members: [], dynamicMemberAccess: true }]);
+ expect(nativeBindings).toMatchObject([
+ {
+ observations: [
+ {
+ properties: {
+ requested_members: [],
+ namespace_access: false,
+ dynamic_member_access: true,
+ native_export_verification: "not-performed",
+ },
+ },
+ ],
+ },
+ ]);
});
it("maps Electron boundaries through bundler-renamed bindings", async () => {
diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts
index 416337097..c35b8aef1 100644
--- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts
+++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts
@@ -361,7 +361,7 @@ it("reconstructs package, Electron roles, Webpack/Rspack modules, and cross-laye
expect.objectContaining({
properties: expect.objectContaining({
module_key: "1",
- structural_fingerprint_algorithm: "babel-ast-v1",
+ structural_fingerprint_algorithm: "babel-ast-v2",
}),
}),
]),
diff --git a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts
index a3b2231a2..ca747c90c 100644
--- a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts
+++ b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts
@@ -8,6 +8,9 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js
import { createElectronActiveEvidence } from "../../../../src/application/javascript/ElectronActiveEvidence.js";
import { reconcileJavaScriptRuntimeEvidence } from "../../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js";
import { createEvidence } from "../../../../src/domain/evidence.js";
+import { mappedRuntimePaths } from "../../../../src/domain/javascript/javascriptRuntimeStaticCandidates.js";
+import { projectRuntimeCaptures } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationRuntime.js";
+import { electronPageInspectionSchema } from "../../../../src/domain/javascript/electronObservation.js";
import {
parseRuntimeReconciliationInput,
reconcileJavaScriptRuntime,
@@ -419,3 +422,482 @@ it("imports an operator-provided cache layer through an explicit file mapping",
sharedFile?.observations.map(({ properties }) => properties.path),
).toEqual(expect.arrayContaining(["app.js", "mapped/chunk.js"]));
});
+
+const sourceFreeRuntime = (runtimeRoot: string, scriptFile: string) => {
+ const runtime = electronRuntimeEvidence(runtimeRoot, "", {
+ scriptFile,
+ sourceIncluded: false,
+ includeWorker: false,
+ });
+ const observation = electronPageInspectionSchema.parse(
+ runtime.normalized_result,
+ );
+ for (const script of observation.scripts.items)
+ script.source = { included: false, reason: "not-requested" };
+ return createEvidence(undefined, runtime.provider, {
+ operation: runtime.operation,
+ predicateType: runtime.predicate_type,
+ parameters: runtime.parameters,
+ result: observation,
+ confidence: runtime.confidence,
+ authority: runtime.authority,
+ });
+};
+
+const reconcileScriptLocation = (
+ application: Awaited>,
+ root: string,
+ scriptFile: string,
+ runtimeRoot = root,
+) => {
+ return reconcileInput({
+ static_layers: [
+ {
+ role: "application",
+ analysis: application,
+ runtime_mappings:
+ runtimeRoot === root
+ ? []
+ : [{ kind: "file-root", root: runtimeRoot, artifact_prefix: "" }],
+ },
+ ],
+ runtime_observations: [sourceFreeRuntime(runtimeRoot, scriptFile)],
+ }).reconciliations.find(({ entity_kind }) => entity_kind === "script");
+};
+
+it.skipIf(process.platform === "win32")(
+ "keeps distinct POSIX backslash and slash script locations separate",
+ async () => {
+ const root = await createTestTempDirectory("rea-backslash-runtime-");
+ const literal = "literal\\name.js";
+ await writeFile(join(root, literal), "export const marker = 'literal';\n");
+ const application = await analyzeFixture(root);
+ expect(
+ reconcileScriptLocation(application, root, "literal/name.js"),
+ ).toMatchObject({ status: "unmatched" });
+ expect(reconcileScriptLocation(application, root, literal)).toMatchObject({
+ status: "matched",
+ basis: "artifact-path",
+ });
+ },
+);
+
+it.skipIf(process.platform === "win32")(
+ "does not make POSIX literal and slash paths ambiguous, including Windows runtime mappings",
+ async () => {
+ const root = await createTestTempDirectory("rea-backslash-runtime-pair-");
+ const literal = "literal\\name.js";
+ await mkdir(join(root, "literal"));
+ await writeFile(join(root, literal), "export const marker = 'literal';\n");
+ await writeFile(
+ join(root, "literal", "name.js"),
+ "export const marker = 'slash';\n",
+ );
+ const application = await analyzeFixture(root);
+ const slash = reconcileScriptLocation(application, root, "literal/name.js");
+ const backslash = reconcileScriptLocation(application, root, literal);
+ expect(slash).toMatchObject({ status: "matched", basis: "artifact-path" });
+ expect(backslash).toMatchObject({
+ status: "matched",
+ basis: "artifact-path",
+ });
+ expect(slash?.static_node_id).not.toBe(backslash?.static_node_id);
+ // These recorded Windows paths exercise matching only, not native Windows IO.
+ expect(
+ reconcileScriptLocation(
+ application,
+ root,
+ "literal\\name.js",
+ "C:\\runtime",
+ ),
+ ).toMatchObject({
+ status: "matched",
+ static_node_id: slash?.static_node_id,
+ });
+ },
+);
+
+it.skipIf(process.platform === "win32")(
+ "preserves URL-prefix path semantics separately from logical artifact names",
+ async () => {
+ const root = await createTestTempDirectory("rea-backslash-runtime-url-");
+ await writeFile(
+ join(root, "literal\\name.js"),
+ "export const marker = 42;\n",
+ );
+ const analysis = await analyzeFixture(root);
+ const parsed = parseRuntimeReconciliationInput(
+ reconcileJavaScriptRuntimeInputSchema.parse({
+ static_layers: [
+ {
+ analysis,
+ role: "application",
+ runtime_mappings: [
+ {
+ kind: "url-prefix",
+ prefix: "https://example.test/assets/",
+ artifact_prefix: "",
+ },
+ ],
+ },
+ ],
+ runtime_observations: [sourceFreeRuntime(root, "literal\\name.js")],
+ }),
+ );
+ const layer = parsed.layers[0];
+ const entity = projectRuntimeCaptures(parsed.captures).entities.find(
+ ({ kind }) => kind === "script",
+ );
+ if (layer === undefined || entity === undefined)
+ throw new Error("Expected parsed static layer and runtime script");
+ const mapped = (value: string) =>
+ mappedRuntimePaths(layer, {
+ ...entity,
+ location: { kind: "url", value },
+ }).map(({ path }) => path);
+ expect(mapped("https://example.test/assets/literal%5Cname.js")).toEqual([
+ "literal\\name.js",
+ ]);
+ expect(mapped("https://example.test/assets/literal/name.js")).toEqual([
+ "literal/name.js",
+ ]);
+ expect(mapped("https://example.test/assets/literal\\name.js")).toEqual([
+ "literal/name.js",
+ ]);
+ expect(mapped("https://other.test/assets/literal/name.js")).toEqual([]);
+ expect(mapped("https://example.test/assets/%2e%2e/outside.js")).toEqual([]);
+ },
+);
+
+const rendererCapture = (runtimeRoot: string, filePath: string) => {
+ const runtime = sourceFreeRuntime(runtimeRoot, "main.cjs");
+ const observation = electronPageInspectionSchema.parse(
+ runtime.normalized_result,
+ );
+ observation.target.file_path = filePath;
+ for (const frame of observation.frames) frame.file_path = filePath;
+ return createEvidence(undefined, runtime.provider, {
+ operation: runtime.operation,
+ predicateType: runtime.predicate_type,
+ parameters: runtime.parameters,
+ result: observation,
+ confidence: runtime.confidence,
+ authority: runtime.authority,
+ });
+};
+
+it.skipIf(process.platform === "win32")(
+ "interprets raw loadFile declarations using the captured native path format",
+ async () => {
+ const root = await createTestTempDirectory(
+ "rea-native-loadfile-declaration-",
+ );
+ await mkdir(join(root, "dist"));
+ await writeFile(join(root, "dist", "index.html"), "slash file");
+ await writeFile(join(root, "dist\\index.html"), "literal file");
+ await writeFile(
+ join(root, "main.cjs"),
+ `const { BrowserWindow } = require('electron'); const win = new BrowserWindow(); win.loadFile(${JSON.stringify("dist\\index.html")});`,
+ );
+ const analysis = await analyzeFixture(root);
+ const reconcileTarget = (runtimeRoot: string, path: string) =>
+ reconcileInput({
+ static_layers: [
+ {
+ analysis,
+ role: "application",
+ runtime_mappings: [
+ { kind: "file-root", root: runtimeRoot, artifact_prefix: "" },
+ ],
+ },
+ ],
+ runtime_observations: [rendererCapture(runtimeRoot, path)],
+ }).reconciliations.find(({ entity_kind }) => entity_kind === "target");
+ // These Windows paths are recorded data, not native Windows execution.
+ expect(
+ reconcileTarget("C:\\runtime", "C:\\runtime\\dist\\index.html"),
+ ).toMatchObject({ status: "matched" });
+ expect(reconcileTarget(root, join(root, "dist\\index.html"))).toMatchObject(
+ { status: "matched" },
+ );
+ expect(
+ reconcileTarget(root, join(root, "dist", "index.html")),
+ ).toMatchObject({ status: "unmatched" });
+ },
+);
+
+it.skipIf(process.platform === "win32").each(["literal\\dir", "literal%5Cdir"])(
+ "maps a runtime root to the exact logical artifact prefix %j",
+ async (prefix) => {
+ const root = await createTestTempDirectory("rea-literal-artifact-prefix-");
+ await mkdir(join(root, prefix));
+ await writeFile(
+ join(root, prefix, "app.js"),
+ "export const marker = 42;\n",
+ );
+ const analysis = await analyzeFixture(root);
+ const result = reconcileInput({
+ static_layers: [
+ {
+ analysis,
+ role: "application",
+ runtime_mappings: [
+ { kind: "file-root", root: "/deployed", artifact_prefix: prefix },
+ ],
+ },
+ ],
+ runtime_observations: [sourceFreeRuntime("/deployed", "app.js")],
+ });
+ expect(
+ result.reconciliations.find(
+ ({ entity_kind }) => entity_kind === "script",
+ ),
+ ).toMatchObject({ status: "matched", basis: "operator-file-mapping" });
+ },
+);
+
+it.skipIf(process.platform === "win32")(
+ "uses resolved artifact paths instead of a raw declaration alias",
+ async () => {
+ const root = await createTestTempDirectory("rea-resolved-loadfile-alias-");
+ const directory = "literal\\dir";
+ await mkdir(join(root, directory));
+ await writeFile(join(root, directory, "index.html"), "owned target");
+ await writeFile(join(root, "index.html"), "different target");
+ await writeFile(
+ join(root, directory, "package.json"),
+ JSON.stringify({
+ name: "literal-renderer",
+ version: "1.0.0",
+ renderer: "./index.html",
+ }),
+ );
+ const analysis = await analyzeFixture(root);
+ const reconcileTarget = (path: string) =>
+ reconcileInput({
+ static_layers: [{ analysis, role: "application" }],
+ runtime_observations: [rendererCapture(root, path)],
+ }).reconciliations.find(({ entity_kind }) => entity_kind === "target");
+ expect(reconcileTarget(join(root, directory, "index.html"))).toMatchObject({
+ status: "matched",
+ });
+ expect(reconcileTarget(join(root, "index.html"))).toMatchObject({
+ status: "unmatched",
+ });
+ },
+);
+
+it("rejects unsafe logical artifact prefixes without decoding literal names", async () => {
+ const root = await createTestTempDirectory("rea-prefix-admission-");
+ await writeFile(join(root, "app.js"), "export const marker = 42;\n");
+ const analysis = await analyzeFixture(root);
+ const accepts = (artifact_prefix: string) =>
+ reconcileJavaScriptRuntimeInputSchema.safeParse({
+ static_layers: [
+ {
+ analysis,
+ role: "application",
+ runtime_mappings: [
+ { kind: "file-root", root: "/deployed", artifact_prefix },
+ ],
+ },
+ ],
+ runtime_observations: [sourceFreeRuntime("/deployed", "app.js")],
+ }).success;
+ for (const prefix of [
+ "\0",
+ "/absolute",
+ ".",
+ "..",
+ "a/../b",
+ "a//b",
+ "a/",
+ "./a",
+ ])
+ expect(accepts(prefix), prefix).toBe(false);
+ for (const prefix of ["", "literal\\dir", "literal%5Cdir", "literal%2Fdir"])
+ expect(accepts(prefix), prefix).toBe(true);
+});
+
+it.skipIf(process.platform === "win32")(
+ "keeps a leading backslash declaration literal only for POSIX captures",
+ async () => {
+ const root = await createTestTempDirectory("rea-leading-loadfile-");
+ await writeFile(join(root, "\\index.html"), "literal target");
+ await writeFile(
+ join(root, "main.cjs"),
+ `const { BrowserWindow } = require('electron'); const win = new BrowserWindow(); win.loadFile(${JSON.stringify("\\index.html")});`,
+ );
+ const analysis = await analyzeFixture(root);
+ const reconcileTarget = (runtimeRoot: string, path: string) =>
+ reconcileInput({
+ static_layers: [
+ {
+ analysis,
+ role: "application",
+ runtime_mappings: [
+ { kind: "file-root", root: runtimeRoot, artifact_prefix: "" },
+ ],
+ },
+ ],
+ runtime_observations: [rendererCapture(runtimeRoot, path)],
+ }).reconciliations.find(({ entity_kind }) => entity_kind === "target");
+ expect(reconcileTarget(root, join(root, "\\index.html"))).toMatchObject({
+ status: "matched",
+ });
+ expect(
+ reconcileTarget("C:\\runtime", "C:\\runtime\\index.html"),
+ ).toMatchObject({ status: "unmatched" });
+ },
+);
+
+it("keeps a raw nested loadFile target application-relative when the producer also resolves a nested decoy", async () => {
+ const root = await createTestTempDirectory("rea-raw-loadfile-ownership-");
+ await mkdir(join(root, "dist"));
+ await mkdir(join(root, "sub", "dist"), { recursive: true });
+ await writeFile(join(root, "dist", "index.html"), "application target");
+ await writeFile(join(root, "sub", "dist", "index.html"), "nested decoy");
+ await writeFile(
+ join(root, "sub", "main.cjs"),
+ "const { BrowserWindow } = require('electron'); const win = new BrowserWindow(); win.loadFile('dist/index.html');",
+ );
+ const analysis = await analyzeFixture(root);
+ const reconcileTarget = (path: string) =>
+ reconcileInput({
+ static_layers: [{ analysis, role: "application" }],
+ runtime_observations: [rendererCapture(root, path)],
+ }).reconciliations.find(({ entity_kind }) => entity_kind === "target");
+ expect(reconcileTarget(join(root, "dist", "index.html"))).toMatchObject({
+ status: "matched",
+ });
+ expect(
+ reconcileTarget(join(root, "sub", "dist", "index.html")),
+ ).toMatchObject({ status: "unmatched" });
+});
+
+it
+ .skipIf(process.platform === "win32")
+ .each(["app", "literal\\app", "café", "cafe\u0301"])(
+ "maps raw renderer declarations through the logical application prefix %j",
+ async (prefix) => {
+ const root = await createTestTempDirectory("rea-prefixed-native-renderer-");
+ await mkdir(join(root, prefix, "dist"), { recursive: true });
+ await writeFile(
+ join(root, prefix, "dist", "index.html"),
+ "application target",
+ );
+ await writeFile(
+ join(root, prefix, "main.cjs"),
+ "const { BrowserWindow } = require('electron'); const win = new BrowserWindow(); win.loadFile('dist/index.html');",
+ );
+ const analysis = await analyzeFixture(root);
+ const reconcileTarget = (runtimeRoot: string, path: string) =>
+ reconcileInput({
+ static_layers: [
+ {
+ analysis,
+ role: "application",
+ runtime_mappings: [
+ { kind: "file-root", root: runtimeRoot, artifact_prefix: prefix },
+ ],
+ },
+ ],
+ runtime_observations: [rendererCapture(runtimeRoot, path)],
+ }).reconciliations.find(({ entity_kind }) => entity_kind === "target");
+ expect(
+ reconcileTarget("/deployed", "/deployed/dist/index.html"),
+ ).toMatchObject({ status: "matched", basis: "operator-file-mapping" });
+ expect(
+ reconcileTarget("C:\\deployed", "C:\\deployed\\dist\\index.html"),
+ ).toMatchObject({ status: "matched", basis: "operator-file-mapping" });
+ expect(reconcileTarget("/deployed", "/deployed/index.html")).toMatchObject({
+ status: "unmatched",
+ });
+ },
+);
+
+it("confines raw renderer declarations to the operator-selected artifact prefix", async () => {
+ const root = await createTestTempDirectory("rea-native-prefix-isolation-");
+ for (const app of ["appA", "appB"]) {
+ await mkdir(join(root, app, "dist"), { recursive: true });
+ await writeFile(join(root, app, "dist", "index.html"), app);
+ await writeFile(
+ join(root, app, "main.cjs"),
+ "const { BrowserWindow } = require('electron'); const win = new BrowserWindow(); win.loadFile('dist/index.html');",
+ );
+ }
+ const analysis = await analyzeFixture(root);
+ const result = reconcileInput({
+ static_layers: [
+ {
+ analysis,
+ role: "application",
+ runtime_mappings: [
+ { kind: "file-root", root: "/deployed", artifact_prefix: "appA" },
+ ],
+ },
+ ],
+ runtime_observations: [
+ rendererCapture("/deployed", "/deployed/dist/index.html"),
+ ],
+ });
+ const target = result.reconciliations.find(
+ ({ entity_kind }) => entity_kind === "target",
+ );
+ expect(target).toMatchObject({
+ status: "matched",
+ candidate_static_count: 1,
+ });
+ const node = result.graph.nodes.find(
+ ({ node_id }) => node_id === target?.static_node_id,
+ );
+ expect(
+ node?.observations.flatMap(({ evidence }) =>
+ evidence.location.available &&
+ evidence.location.value.kind === "source-range"
+ ? [evidence.location.value.source]
+ : [],
+ ),
+ ).toEqual(["appA/main.cjs"]);
+});
+
+it("retains uncertainty for overlapping file mappings with distinct application interpretations", async () => {
+ const root = await createTestTempDirectory("rea-overlapping-native-prefix-");
+ await mkdir(join(root, "app", "dist"), { recursive: true });
+ await writeFile(join(root, "app", "dist", "index.html"), "shared target");
+ for (const [path, declaration] of [
+ ["app/main.cjs", "dist/index.html"],
+ ["app/dist/main.cjs", "index.html"],
+ ])
+ await writeFile(
+ join(root, path!),
+ `const { BrowserWindow } = require('electron'); const win = new BrowserWindow(); win.loadFile(${JSON.stringify(declaration)});`,
+ );
+ const analysis = await analyzeFixture(root);
+ const result = reconcileInput({
+ static_layers: [
+ {
+ analysis,
+ role: "application",
+ runtime_mappings: [
+ { kind: "file-root", root: "/deployed", artifact_prefix: "app" },
+ {
+ kind: "file-root",
+ root: "/deployed/dist",
+ artifact_prefix: "app/dist",
+ },
+ ],
+ },
+ ],
+ runtime_observations: [
+ rendererCapture("/deployed", "/deployed/dist/index.html"),
+ ],
+ });
+ expect(
+ result.reconciliations.find(({ entity_kind }) => entity_kind === "target"),
+ ).toMatchObject({
+ status: "ambiguous",
+ confidence: "unknown",
+ candidate_static_count: 2,
+ });
+});
diff --git a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts
index 97090c029..705fb7b18 100644
--- a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts
+++ b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts
@@ -306,7 +306,9 @@ describe("JavaScript export return-shape uncertain presence", () => {
).toBe("partial");
}
});
+});
+describe("JavaScript export return-shape array presence", () => {
it("excludes array holes while preserving observed unknown elements", async () => {
const holes = 'export default () => ({ kind: "result", items: [,] });';
const empty = await analyzeSources({
@@ -331,6 +333,84 @@ describe("JavaScript export return-shape uncertain presence", () => {
}),
);
});
+});
+
+describe("JavaScript export unpaired return locations", () => {
+ it.each([
+ {
+ description: "two right return variants",
+ left: "export default function f() {}",
+ right: `export default function f(flag) {
+ if (flag) return { count: call() };
+ return { total: call() };
+ }`,
+ side: "right" as const,
+ lines: [2, 3],
+ },
+ {
+ description: "one right return variant",
+ left: "export default function f() {}",
+ right: `export default function f() {
+ return { count: call() };
+ }`,
+ side: "right" as const,
+ lines: [2],
+ },
+ {
+ description: "two left return variants",
+ left: `export default function f(flag) {
+ if (flag) return { count: call() };
+ return { total: call() };
+ }`,
+ right: "export default function f() {}",
+ side: "left" as const,
+ lines: [2, 3],
+ },
+ {
+ description: "one left return variant",
+ left: `export default function f() {
+ return { count: call() };
+ }`,
+ right: "export default function f() {}",
+ side: "left" as const,
+ lines: [2],
+ },
+ ])(
+ "retains each location for $description when the opposite export has no return",
+ async ({ left, right, side, lines }) => {
+ const graphs = await analyzeSources({ left, right });
+ const result = compare(...graphs);
+ expect(result.coverage).toMatchObject({
+ status: "partial",
+ unpaired_left_variants: side === "left" ? lines.length : 0,
+ unpaired_right_variants: side === "right" ? lines.length : 0,
+ });
+ expect(result.changes).toHaveLength(lines.length);
+ expect(
+ result.changes.every(
+ ({ status, left_source_range, right_source_range }) =>
+ status === "unknown" &&
+ (side === "left"
+ ? left_source_range !== null && right_source_range === null
+ : left_source_range === null && right_source_range !== null),
+ ),
+ ).toBe(true);
+ const locations = result.changes
+ .map((change) =>
+ side === "left"
+ ? change.left_source_range?.start.line
+ : change.right_source_range?.start.line,
+ )
+ .sort((first, second) => (first ?? 0) - (second ?? 0));
+ expect(locations).toEqual(lines);
+ expect(result.summary).toMatchObject({
+ added: 0,
+ removed: 0,
+ changed: 0,
+ unknown: lines.length,
+ });
+ },
+ );
it("retains unpaired inventory locations when the opposite export has no return", async () => {
const graphs = await analyzeSources({
diff --git a/tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts b/tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts
index 29a9847df..ee3f9ced0 100644
--- a/tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts
+++ b/tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts
@@ -191,3 +191,185 @@ const htmlScriptRanges = (html: string) => {
}
return ranges;
};
+
+it("reports unanalyzed inline HTML scripts instead of claiming complete coverage", async () => {
+ const root = await createTestTempDirectory("rea-html-inline-scripts-");
+ const html = [
+ "",
+ '',
+ '',
+ "",
+ "",
+ '',
+ "",
+ ].join("\n");
+ await mkdir(join(root, "www"));
+ await Promise.all([
+ writeFile(
+ join(root, "package.json"),
+ JSON.stringify({ name: "game", version: "1.0.0" }),
+ ),
+ writeFile(join(root, "www", "index.html"), html),
+ writeFile(join(root, "www", "cordova.js"), "globalThis.cordova = {};"),
+ ]);
+
+ const result = await reconstructJavaScriptArtifact({ input_path: root });
+ const gaps = result.graph.nodes.filter(
+ ({ kind, observations }) =>
+ kind === "unknown" &&
+ observations.some(
+ ({ properties }) =>
+ properties.operation === "parse-html-inline-scripts",
+ ),
+ );
+
+ expect(result.graph.coverage.status).toBe("partial");
+ expect(gaps).toHaveLength(1);
+ expect(gaps[0]?.observations[0]).toMatchObject({
+ properties: {
+ path: "www/index.html",
+ inline_script_count: 2,
+ inline_scripts: [
+ {
+ script_type: "classic",
+ location: {
+ start: { line: 5, column: 8 },
+ end: { line: 7, column: 0 },
+ },
+ },
+ {
+ script_type: "module",
+ location: {
+ start: { line: 8, column: 22 },
+ end: { line: 8, column: 67 },
+ },
+ },
+ ],
+ },
+ evidence: {
+ state: "unavailable",
+ artifact: {
+ sha256: createHash("sha256").update(html).digest("hex"),
+ },
+ coverage: { status: "partial" },
+ },
+ });
+ expect(result.graph.limitations).toContain(
+ "www/index.html has 2 inline JavaScript bodies starting at lines 5, 8 that were not analyzed; endpoints, storage, imports and other facts in them are absent from the evidence.",
+ );
+ // The external script is still projected as before.
+ expect(
+ result.graph.edges.some(
+ ({ relation, properties }) =>
+ relation === "loads" && properties.resolved_path === "www/cordova.js",
+ ),
+ ).toBe(true);
+});
+
+it("keeps complete coverage when HTML has only external scripts and data blocks", async () => {
+ const root = await createTestTempDirectory("rea-html-no-inline-scripts-");
+ await Promise.all([
+ writeFile(
+ join(root, "index.html"),
+ '',
+ ),
+ writeFile(join(root, "actual.js"), "globalThis.ownedActual = true;"),
+ ]);
+
+ const result = await reconstructJavaScriptArtifact({ input_path: root });
+
+ expect(result.graph.coverage.status).toBe("complete");
+ expect(
+ result.graph.limitations.some((limitation) =>
+ limitation.includes("inline JavaScript"),
+ ),
+ ).toBe(false);
+});
+
+it.each(coordinateCases)(
+ "retains parse5 source coordinates for inline script bodies with $name",
+ async ({ prefix }) => {
+ const root = await createTestTempDirectory("rea-html-inline-coordinates-");
+ const html = `${prefix}`;
+ await writeFile(join(root, "index.html"), html);
+
+ const result = await reconstructJavaScriptArtifact({ input_path: root });
+ const gap = result.graph.nodes
+ .flatMap(({ observations }) => observations)
+ .find(
+ ({ properties }) =>
+ properties.operation === "parse-html-inline-scripts",
+ );
+
+ expect(gap?.properties.inline_scripts).toEqual([
+ { script_type: "classic", location: inlineBodyRange(html) },
+ ]);
+ },
+);
+
+const inlineBodyRange = (html: string) => {
+ const pending: DefaultTreeAdapterTypes.Node[] = [
+ parse(html, { sourceCodeLocationInfo: true }),
+ ];
+ while (pending.length > 0) {
+ const node = pending.pop();
+ if (node === undefined) continue;
+ if (defaultTreeAdapter.isElementNode(node) && node.tagName === "script") {
+ const opening = node.sourceCodeLocation?.startTag;
+ const body = node.childNodes[0]?.sourceCodeLocation;
+ if (opening === undefined || body === undefined || body === null)
+ throw new Error("Missing parse5 inline script range");
+ return {
+ start: { line: opening.endLine, column: opening.endCol - 1 },
+ end: { line: body.endLine, column: body.endCol - 1 },
+ };
+ }
+ if ("childNodes" in node) {
+ for (const child of node.childNodes.toReversed()) pending.push(child);
+ }
+ }
+ throw new Error("Missing parse5 inline script");
+};
+
+it("bounds retained inline script locations and names the omitted remainder", async () => {
+ const root = await createTestTempDirectory("rea-html-many-inline-scripts-");
+ const count = 5_000;
+ await writeFile(
+ join(root, "index.html"),
+ "\n".repeat(count),
+ );
+
+ const result = await reconstructJavaScriptArtifact({ input_path: root });
+ const gap = result.graph.nodes
+ .flatMap(({ observations }) => observations)
+ .find(
+ ({ properties }) => properties.operation === "parse-html-inline-scripts",
+ );
+ const limit = {
+ name: "html_inline_script_locations_per_file",
+ value: 100,
+ unit: "items",
+ };
+
+ expect(gap?.properties.inline_script_count).toBe(count);
+ expect(gap?.properties.inline_scripts).toHaveLength(100);
+ // The gap record knows exactly which locations the bound dropped.
+ expect(gap?.evidence.coverage).toEqual({
+ status: "partial",
+ truncated: true,
+ omitted_count: count - 100,
+ limits: [limit],
+ });
+ // The graph is truncated too, but unanalyzed bodies hide an unknown total.
+ expect(result.graph.coverage).toMatchObject({
+ status: "partial",
+ truncated: true,
+ omitted_count: null,
+ limits: expect.arrayContaining([limit]),
+ });
+ expect(result.graph.limitations).toContain(
+ `index.html has ${String(count)} inline JavaScript bodies starting at lines 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 and ${String(count - 10)} more that were not analyzed; endpoints, storage, imports and other facts in them are absent from the evidence.`,
+ );
+});
diff --git a/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts b/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts
index d5495a7f2..ab47a4181 100644
--- a/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts
+++ b/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts
@@ -1,3 +1,4 @@
+import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join, relative, sep } from "node:path";
@@ -196,3 +197,38 @@ describe("literal CommonJS percent bytes in application graphs", () => {
expect(edge.properties.resolved_path).toBeNull();
});
});
+
+it("reports a present non-JavaScript require target as unavailable with inventory identity", async () => {
+ const present = await createTestTempDirectory("rea-require-opaque-present-");
+ const absent = await createTestTempDirectory("rea-require-opaque-absent-");
+ const bytes = Buffer.from([0xde, 0xc0, 0x17, 0x0b, 0x00, 0x01, 0x02]);
+ const main = 'require("./core.jsc");';
+ await Promise.all([
+ writeFile(join(present, "main.cjs"), main),
+ writeFile(join(present, "core.jsc"), bytes),
+ writeFile(join(absent, "main.cjs"), main),
+ ]);
+ const edge = async (root: string) => {
+ const { graph } = await analyze(root);
+ return graph.edges.find(
+ ({ properties }) =>
+ properties.specifier === "./core.jsc" &&
+ properties.module_link_kind === "require",
+ );
+ };
+ const presentEdge = await edge(present);
+ expect(presentEdge?.properties).toMatchObject({
+ resolution_status: "unavailable",
+ candidate_path: "core.jsc",
+ resolved_path: null,
+ });
+ expect(presentEdge?.evidence.limitations).toContainEqual(
+ expect.stringContaining(
+ `core.jsc exists in the inventoried artifact container (sha256 ${createHash("sha256").update(bytes).digest("hex")}, ${String(bytes.length)} bytes)`,
+ ),
+ );
+ expect((await edge(absent))?.properties).toMatchObject({
+ resolution_status: "not-found",
+ resolved_path: null,
+ });
+});
diff --git a/tests/boundary/filesystem/sourceToBundlePaths.test.ts b/tests/boundary/filesystem/sourceToBundlePaths.test.ts
index 8bc9caa27..d546605de 100644
--- a/tests/boundary/filesystem/sourceToBundlePaths.test.ts
+++ b/tests/boundary/filesystem/sourceToBundlePaths.test.ts
@@ -6,7 +6,10 @@ import { analyzeJavaScriptApplication } from "../../support/javascriptApplicatio
import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js";
import { findApplicationFeatureSeeds } from "../../../src/domain/javascript/javascriptFeatureSeed.js";
import { compareSourceToBundle } from "../../../src/domain/javascript/sourceToBundleComparison.js";
-import { resolveJavaScriptSourceMapUrl } from "../../../src/domain/javascript/javascriptSourceMapPaths.js";
+import {
+ resolveJavaScriptSourceMapReferences,
+ resolveJavaScriptSourceMapUrl,
+} from "../../../src/domain/javascript/javascriptSourceMapPaths.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { importReferenceSource } from "../../support/referenceSourceResourceScope.js";
@@ -704,3 +707,106 @@ const compareTrees = async (previous: string, current: string) => {
});
return { reference: reference.value, comparison, application };
};
+
+it.skipIf(process.platform === "win32")(
+ "does not promote a POSIX literal backslash name to a different historical slash path",
+ async () => {
+ const root = await createTestTempDirectory("rea-backslash-source-match-");
+ const previous = join(root, "previous");
+ const current = join(root, "current");
+ await mkdir(join(previous, "literal"), { recursive: true });
+ await mkdir(current);
+ await writeFile(
+ join(previous, "literal", "name.js"),
+ "export const value = 1;\n",
+ );
+ await writeFile(
+ join(current, "literal\\name.js"),
+ "export const value = 2;\n",
+ );
+ const { comparison } = await compareTrees(previous, current);
+ const item = comparison.items.find(
+ ({ source_path }) => source_path === "literal/name.js",
+ );
+ expect(item?.current_node_ids).toEqual([]);
+ expect(
+ item?.candidates
+ .flatMap(({ signals }) => signals)
+ .some(({ kind }) => kind === "current-path-exact"),
+ ).toBe(false);
+ },
+);
+
+it.skipIf(process.platform === "win32").each([
+ { mapPath: "literal\\bundle.js.map", expected: "a.js" },
+ { mapPath: "literal\\dir/bundle.js.map", expected: "literal\\dir/a.js" },
+])(
+ "resolves source-map declarations from literal artifact base $mapPath",
+ async ({ mapPath, expected }) => {
+ const root = await createTestTempDirectory("rea-backslash-source-map-");
+ const previous = join(root, "previous");
+ const current = join(root, "current");
+ await mkdir(previous);
+ await mkdir(current);
+ await writeFile(join(previous, "a.js"), "export const value = 1;\n");
+ await mkdir(join(current, "literal\\dir"), { recursive: true });
+ await writeFile(join(current, mapPath.slice(0, -4)), "var value = 2;\n");
+ await writeFile(
+ join(current, mapPath),
+ JSON.stringify({
+ version: 3,
+ file: mapPath.slice(0, -4),
+ sources: ["a.js"],
+ sourcesContent: ["export const value = 2;\n"],
+ names: [],
+ mappings: "AAAA",
+ }),
+ );
+ const { application, comparison } = await compareTrees(previous, current);
+ const references = application.graph.nodes
+ .flatMap(({ observations }) =>
+ observations.map(({ source_map_reference }) => source_map_reference),
+ )
+ .filter((reference) => reference?.map_path === mapPath);
+ expect(references).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ resolution: { kind: "artifact-relative", path: expected },
+ }),
+ ]),
+ );
+ const item = comparison.items.find(
+ ({ source_path }) => source_path === "a.js",
+ );
+ const signals = item?.candidates.flatMap(({ signals }) => signals) ?? [];
+ if (expected === "a.js")
+ expect(signals).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ kind: "source-map-original-path",
+ current_values: ["a.js"],
+ }),
+ ]),
+ );
+ else
+ expect(
+ signals.some(({ kind }) => kind === "source-map-original-path"),
+ ).toBe(false);
+ },
+);
+
+it.each([
+ { source: "https://user%2Fname@host.test/a.js", expected: "host.test/a.js" },
+ {
+ source: "C:\\code\\literal%5Cname.js",
+ expected: "C:/code/literal%5Cname.js",
+ },
+])(
+ "checks encoded source-map separators in the parsed pathname of $source",
+ ({ source, expected }) => {
+ expect(
+ resolveJavaScriptSourceMapReferences([source], null, "ordinary.js.map")[0]
+ ?.resolution,
+ ).toEqual({ kind: "suffix", path: expected });
+ },
+);
diff --git a/tests/boundary/filesystem/webScriptManifestPaths.test.ts b/tests/boundary/filesystem/webScriptManifestPaths.test.ts
index 9996fa8a9..762ecd397 100644
--- a/tests/boundary/filesystem/webScriptManifestPaths.test.ts
+++ b/tests/boundary/filesystem/webScriptManifestPaths.test.ts
@@ -75,3 +75,84 @@ it("still rejects changed source bytes after normalizing the selected manifest p
"artifact_integrity_mismatch",
);
});
+
+const literalManifest = async () => {
+ const fixture = await materialize();
+ const selected = fixture.artifact.manifest.scripts[0];
+ if (selected?.content.state !== "exported")
+ throw new Error("Expected exported source");
+ selected.content.relative_path = "..\\sibling.js";
+ await writeFile(join(fixture.root, "sibling.js"), fixture.artifact.source);
+ if (process.platform !== "win32")
+ await writeFile(
+ join(fixture.root, "files", selected.content.relative_path),
+ fixture.artifact.source,
+ );
+ await writeFile(
+ fixture.manifestPath,
+ JSON.stringify(fixture.artifact.manifest),
+ );
+ return fixture;
+};
+
+it.each(["trace_web_module_imports", "trace_web_source_location"] as const)(
+ "refuses Windows backslash source interpretation for %s",
+ async (operation) => {
+ const fixture = await literalManifest();
+ const originalPlatform = Object.getOwnPropertyDescriptor(
+ process,
+ "platform",
+ );
+ const artifacts = new LocalWebScriptArtifacts(operation);
+ let response;
+ try {
+ // Exercise the Windows admission branch; this is not native Windows IO.
+ Object.defineProperty(process, "platform", { value: "win32" });
+ response = await artifacts.load({
+ manifest_path: fixture.manifestPath,
+ script_index: 0,
+ });
+ } finally {
+ if (originalPlatform !== undefined)
+ Object.defineProperty(process, "platform", originalPlatform);
+ await artifacts.close();
+ }
+ if (response.ok) throw new Error("Expected Windows source path refusal");
+ expect(projectAnalysisError(response.error)).toMatchObject({
+ code: "invalid_request",
+ details: {
+ issues: [
+ {
+ path: ["script_index"],
+ reason: "invalid_value",
+ message: expect.stringContaining(
+ "Windows source path cannot contain backslashes",
+ ),
+ },
+ ],
+ },
+ });
+ },
+);
+
+it.skipIf(process.platform === "win32")(
+ "reads the same saved POSIX literal name without selecting its sibling",
+ async () => {
+ const fixture = await literalManifest();
+ await writeFile(join(fixture.root, "sibling.js"), "wrong sibling source");
+ const artifacts = new LocalWebScriptArtifacts("trace_web_module_imports");
+ try {
+ const result = await artifacts.load({
+ manifest_path: fixture.manifestPath,
+ script_index: 0,
+ });
+ if (!result.ok) throw result.error;
+ expect(result.value.source).toBe(fixture.artifact.source);
+ expect(result.value.sourceFile.path).toBe(
+ join(fixture.root, "files", "..\\sibling.js"),
+ );
+ } finally {
+ await artifacts.close();
+ }
+ },
+);
diff --git a/tests/support/cli/javascriptApplication.ts b/tests/support/cli/javascriptApplication.ts
index 085792100..fe799e349 100644
--- a/tests/support/cli/javascriptApplication.ts
+++ b/tests/support/cli/javascriptApplication.ts
@@ -1,3 +1,6 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
import { expect } from "vitest";
import { z } from "zod";
@@ -5,6 +8,19 @@ import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/j
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import type { TestCli } from "./cliFixture.js";
+const execute = promisify(execFile);
+
+export const runNodeJsonControl = async (
+ root: string,
+ filename: string,
+): Promise => {
+ const { stdout } = await execute(process.execPath, [filename], {
+ cwd: root,
+ timeout: 5_000,
+ });
+ return JSON.parse(stdout) as unknown;
+};
+
/** Analyze a directory through a public adapter and validate its result contract. */
export const analyzeJavaScriptDirectory = async (
surface: "CLI" | "stdio MCP",