fix(browser): derive source-map imports from syntax nodes (#588)

* fix(browser): derive source-map imports from syntax nodes

Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>

* fix(browser): recover source-map dependencies from decorated and aliased sources

Discarding a source's edges whenever it had parse errors cost more than it
protected. parseJavaScriptSource only enabled jsx and typescript, so a
decorated TypeScript source did not parse at all and contributed no edges at
all, even for the imports Babel could otherwise have read. That is the common
shape for the bundles this boundary inspects. "decorators-legacy" is enabled
instead of "decorators" because the standard form still rejects parameter
decorators such as constructor(@Inject() private s: S), which is the Angular
and Nest dependency-injection shape.

import x = require("m") parses cleanly but is a TSImportEqualsDeclaration
rather than a call expression, so it contributed no edge. It is now read as a
require dependency. It is only legal at module top level and always refers to
the host loader, so it needs no unbound-require check; the local-alias form
imports an entity name and still declares nothing.

A source whose syntax could not be recovered now yields status "partial" with
a limitation naming the affected sources, instead of an included item whose
empty edge list was indistinguishable from a source that genuinely imports
nothing. Edges that did parse are retained and the caller is told the list is
a subset, which matches how the static and semantic analysers already report
recovered syntax.

The source-map item status union gains "partial" and the coverage rollup now
counts it as retained, so a collection of only-partial items reports partial
rather than unavailable.

* Merge main into fix(browser): derive source-map imports from syntax nodes

Resolve the generated-file conflicts by taking main's generated output and
regenerating from the merged source contracts, rather than merging generated
text by hand.

---------

Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Rudy Mizrahi Celekli 2026-10-05 13:03:41 -04:00 committed by GitHub
parent 82895959d1
commit bae87b4ded
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 281 additions and 50 deletions

View File

@ -545,9 +545,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "0238e00bcc841db0a7922f68b0f3acb5920e2bdff754a58a962f5dba811516dc",
"tools_sha256": "6974b96ecd0544334571658ce91156eecbac544fd7b7fb7ff92a1d7c1e60e2c9",
"prompts_sha256": "4762db348821fec787a5b8c6423f2c48a71f6f0ec2afee97b16ad69030ec504b",
"combined_sha256": "08ce24418a9d9490c642cf864f395d2f846b9b47bdc48c7674f0f826224fcc40",
"combined_sha256": "14d38f650b2d33a0e243f9ee5ecb02b3f2d9e2ba6125e9c3409dd08768e0db1f",
"providers_sha256": "2ae8b85b20be338739e500549d275e78e875aaa10235d2ca283f0ade7787da90"
}
}

View File

@ -1,5 +1,5 @@
{
"manifest_id": "ecm_87a5bbeaf15d8c334615ea74f94bb073c603c98d2261bc3355d31d21a8955ed7",
"manifest_id": "ecm_e1e42b22674dbfc40005184c3b934a99bf7ab085fe5f1e0a3f7f091760f918dc",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "22e5301421d35d11c6dd101e43e743b86956801ad676bc8b168226874efa8bea"
"sha256": "451efcd0ea11a017ca5417ccf57cc2f3fc5dcc07b71d0e94ba97f2d1757b7de9"
}
],
"claims": [

View File

@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
metadata:
version: "23"
tool_count: 116
catalog_digest: "08ce24418a9d9490c642cf864f395d2f846b9b47bdc48c7674f0f826224fcc40"
catalog_digest: "14d38f650b2d33a0e243f9ee5ecb02b3f2d9e2ba6125e9c3409dd08768e0db1f"
---
# REA

View File

@ -1,6 +1,9 @@
import * as t from "@babel/types";
import { AnyMap, eachMapping } from "@jridgewell/trace-mapping";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { analyzeParsedJavaScriptSemantics } from "../domain/javascriptSemanticAnalysis.js";
import { parseJavaScriptSource } from "../domain/javascriptSourceParser.js";
import { hasValidSourceMapContents } from "../domain/sourceMapContents.js";
import type {
AnalyzeWebBundleInput,
@ -22,7 +25,10 @@ interface SourceMapFetchHost {
type SourceMaps = WebSourceMaps;
type SourceMapItem = WebSourceMapItem;
type ParsedSourceMapItem = Extract<SourceMapItem, { status: "included" }>;
type ParsedSourceMapItem = Extract<
SourceMapItem,
{ status: "included" | "partial" }
>;
/** Fetch and validate approved source maps without browser credentials. */
export const fetchWebSourceMaps = async (
@ -36,14 +42,17 @@ export const fetchWebSourceMaps = async (
if (signal?.aborted === true) throw signal.reason;
items.push(await fetchOne(request, input, signal, host));
}
const included = items.filter(({ status }) => status === "included").length;
const retained = items.filter(
({ status }) => status === "included" || status === "partial",
).length;
return webSourceMapsSchema.parse({
status:
items.length === 0
? "unavailable"
: included === items.length
: retained === items.length &&
!items.some(({ status }) => status === "partial")
? "included"
: included > 0
: retained > 0
? "partial"
: "unavailable",
requested: requests.length,
@ -176,10 +185,16 @@ const normalizeSourceMap = (
...sourceMapContext(request),
artifact: createWebTextArtifact(text, "application/source-map+json"),
original_sources: originalSources,
original_module_edges: modules,
original_module_edges: modules.edges,
mappings,
};
return { ...parsed, status: "included", limitation: null };
return modules.incomplete.length === 0
? { ...parsed, status: "included", limitation: null }
: {
...parsed,
status: "partial",
limitation: `Module edges are incomplete: ${modules.incomplete.length} of ${originalSources.filter(({ artifact }) => artifact !== null).length} original sources could not be parsed in full (${modules.incomplete.join(", ")}).`,
};
} catch {
return emptySourceMapItem(
request,
@ -189,30 +204,68 @@ const normalizeSourceMap = (
}
};
interface OriginalModuleEdges {
readonly edges: ParsedSourceMapItem["original_module_edges"];
/** Original sources whose dependency edges may be incomplete. */
readonly incomplete: readonly string[];
}
const originalModuleEdges = (
sources: ParsedSourceMapItem["original_sources"],
): ParsedSourceMapItem["original_module_edges"] => {
): OriginalModuleEdges => {
const edges: ParsedSourceMapItem["original_module_edges"] = [];
const seen = new Set<string>();
const incomplete: string[] = [];
for (const source of sources) {
if (source.artifact === null) continue;
for (const detector of originalImportDetectors) {
for (const match of source.artifact.text.matchAll(detector.pattern)) {
const specifier = match[1];
if (specifier === undefined) continue;
const key = `${source.source}\0${detector.kind}\0${specifier}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
from_source: source.source,
kind: detector.kind,
specifier,
resolved_source: resolveOriginalSource(specifier, source.source),
});
}
const parsed = parseJavaScriptSource(source.artifact.text);
if (parsed === null) {
incomplete.push(source.source);
continue;
}
// A recovered program still yields the imports it did parse, but nodes
// after an unrecoverable point are missing, so the edges are a subset.
if (parsed.errors.length > 0) incomplete.push(source.source);
let unboundRequires: ReadonlySet<string> | undefined;
t.traverseFast(parsed, (node) => {
const dependency = originalDependency(node);
if (dependency === null) return;
const { kind, specifier } = dependency;
if (kind === "require" && t.isCallExpression(node)) {
unboundRequires ??= new Set(
analyzeParsedJavaScriptSemantics(parsed)
.references.filter(
({ name, role, resolution }) =>
name === "require" &&
role === "read" &&
resolution === "unbound",
)
.map(
({ location }) =>
`${String(location.start.line)}:${String(location.start.column)}`,
),
);
const location = node.callee.loc?.start;
if (
location === undefined ||
!unboundRequires.has(
`${String(location.line)}:${String(location.column)}`,
)
)
return;
}
const key = `${source.source}\0${kind}\0${specifier}`;
if (seen.has(key)) return;
seen.add(key);
edges.push({
from_source: source.source,
kind,
specifier,
resolved_source: resolveOriginalSource(specifier, source.source),
});
});
}
return edges;
return { edges, incomplete };
};
const validSourceMapEnvelope = (text: string): boolean => {
@ -308,18 +361,39 @@ const sourceMediaType = (source: string | null): string =>
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const originalImportDetectors = [
{
kind: "static_import" as const,
pattern:
/\b(?:import|export)\s+(?:[^'"\n]*?\s+from\s+)?["']([^"'\n]+)["']/gu,
},
{
kind: "dynamic_import" as const,
pattern: /\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/gu,
},
{
kind: "require" as const,
pattern: /\brequire\s*\(\s*["']([^"'\n]+)["']\s*\)/gu,
},
] as const;
const originalDependency = (
node: t.Node,
): {
readonly kind: ParsedSourceMapItem["original_module_edges"][number]["kind"];
readonly specifier: string;
} | null => {
if (
t.isImportDeclaration(node) ||
t.isExportAllDeclaration(node) ||
t.isExportNamedDeclaration(node)
)
return node.source === null || node.source === undefined
? null
: { kind: "static_import", specifier: node.source.value };
if (t.isImportExpression(node) && t.isStringLiteral(node.source))
return { kind: "dynamic_import", specifier: node.source.value };
if (
t.isCallExpression(node) &&
t.isIdentifier(node.callee, { name: "require" }) &&
t.isStringLiteral(node.arguments[0])
)
return { kind: "require", specifier: node.arguments[0].value };
// `import x = require("m")` is only legal at module top level and always
// refers to the host loader, so it needs no unbound-`require` check. The
// `moduleReference` is an entity name for a local alias instead, which
// declares no dependency.
if (
t.isTSImportEqualsDeclaration(node) &&
t.isTSExternalModuleReference(node.moduleReference)
)
return {
kind: "require",
specifier: node.moduleReference.expression.value,
};
return null;
};

View File

@ -12,7 +12,11 @@ export const parseJavaScriptSource = (
sourceType: "unambiguous",
errorRecovery: true,
attachComment: false,
plugins: ["jsx", "typescript"],
// "decorators-legacy" admits both the standard and the legacy decorator
// forms, including parameter decorators. Without it a decorated
// TypeScript source fails to parse at all rather than reporting
// recovered syntax, which loses every fact derived from that source.
plugins: ["decorators-legacy", "jsx", "typescript"],
});
} catch {
return null;

View File

@ -67,6 +67,12 @@ const sourceMapSchema = z.union([
status: z.literal("included"),
limitation: z.null(),
}),
z.object({
...sourceMapContextShape,
...parsedSourceMapShape,
status: z.literal("partial"),
limitation: z.string(),
}),
z.object({
...sourceMapContextShape,
...parsedSourceMapShape,
@ -104,15 +110,18 @@ export const webSourceMapsSchema = z
message: "Source-map coverage counts are inconsistent",
});
const statuses = sourceMaps.items.map(({ status }) => status);
const included = statuses.filter((status) => status === "included").length;
const retained = statuses.filter(
(status) => status === "included" || status === "partial",
).length;
const allowedStatuses =
sourceMaps.items.length === 0
? ["not_requested", "unavailable"]
: included === sourceMaps.items.length
? ["included"]
: included > 0
? ["partial"]
: ["unavailable"];
: retained === 0
? ["unavailable"]
: retained === sourceMaps.items.length &&
!statuses.includes("partial")
? ["included"]
: ["partial"];
if (!allowedStatuses.includes(sourceMaps.status))
context.addIssue({
code: "custom",

File diff suppressed because one or more lines are too long

View File

@ -286,6 +286,150 @@ describe("web source-map sourcesContent validation", () => {
});
});
describe("source-map original dependency syntax", () => {
it("does not invent edges from comments or string contents", async () => {
const text = [
'// import "./comment.js";',
String.raw`const quoted = "require(\"./string.js\")";`,
'export { value } from "./real.js";',
'import("./dynamic.js");',
'require("./common.cjs");',
].join("\n");
const result = await fetchWebSourceMaps([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
JSON.stringify({
version: 3,
names: [],
sources: ["main.ts"],
sourcesContent: [text],
mappings: "AAAA",
}),
),
),
});
expect(
result.items[0]?.original_module_edges.map(({ specifier }) => specifier),
).toEqual(["./real.js", "./dynamic.js", "./common.cjs"]);
});
});
describe("source-map original syntax boundaries", () => {
it.each([
[
`import "./it's-real.js"; const quoted = \`import './template-comment.js'\`;`,
"included",
["./it's-real.js"],
],
[
'function fake(require) { require("./not-a-module.js"); } require("./real.cjs");',
"included",
["./real.cjs"],
],
// An unrecoverable source cannot invent edges, and the caller is told its
// edge list is incomplete rather than being told it has no imports.
['import "./recovered.js"; const = ;', "partial", []],
] as const)(
"retains literal syntax without inventing module calls",
async (text, status, expected) => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
JSON.stringify({
version: 3,
names: [],
sources: ["main.ts"],
sourcesContent: [text],
mappings: "AAAA",
}),
),
),
});
const item = result.items[0];
expect(item?.status).toBe(status);
if (status === "partial")
expect(item?.limitation).toContain("could not be parsed in full");
else expect(item?.limitation).toBeNull();
expect(
item?.original_module_edges.map(({ specifier }) => specifier),
).toEqual(expected);
},
);
});
describe("source-map dependency coverage of hard-to-parse sources", () => {
const fetchWithSources = (sources: readonly string[]) =>
fetchWebSourceMaps([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
JSON.stringify({
version: 3,
names: [],
sources: sources.map((_, index) => `source-${String(index)}.ts`),
sourcesContent: [...sources],
mappings: "AAAA",
}),
),
),
});
it.each([
[
"decorated classes and parameters",
'@Component({ selector: "app" })\nexport class A { @Inject() b: C; constructor(@Optional() private s: S) {} }\nimport "./a.js";',
["./a.js"],
],
[
"legacy class decorators",
'class A { @dec method() {} }\nimport "./a.js";',
["./a.js"],
],
[
"TypeScript import equals",
'import lib = require("./lib.js");',
["./lib.js"],
],
[
"a local import alias",
'import lib = localAlias;\nimport "./a.js";',
["./a.js"],
],
])("recovers dependencies from %s", async (_label, text, expected) => {
const result = await fetchWithSources([text]);
expect(result.items[0]?.status).toBe("included");
expect(
result.items[0]?.original_module_edges.map(({ specifier }) => specifier),
).toEqual(expected);
});
it("keeps recovered edges from a partly recovered source and reports them as partial", async () => {
const result = await fetchWithSources([
'with (scope) { require("./c.js"); }\nimport "./a.js";',
]);
expect(result.status).toBe("partial");
expect(result.items[0]?.status).toBe("partial");
expect(result.items[0]?.limitation).toContain(
"could not be parsed in full",
);
expect(
result.items[0]?.original_module_edges.map(({ specifier }) => specifier),
).toEqual(["./c.js", "./a.js"]);
});
it("distinguishes an unparsable source from a source with no imports", async () => {
const broken = await fetchWithSources(['import "./a.js"; const s = "oops']);
expect(broken.items[0]?.status).toBe("partial");
expect(broken.items[0]?.original_module_edges).toEqual([]);
const clean = await fetchWithSources(["export const value = 1;"]);
expect(clean.status).toBe("included");
expect(clean.items[0]?.status).toBe("included");
expect(clean.items[0]?.original_module_edges).toEqual([]);
});
});
describe("web source-map collection", () => {
it("retains every mapping from a sectioned source map", async () => {
const segmentCount = 10_001;