mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
committed by
GitHub
parent
82895959d1
commit
bae87b4ded
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user