diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 9120a73d9..edcb37a20 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -8,6 +8,48 @@ import { createWebTextArtifact } from "./webContentArtifact.js"; const origin = "https://app.example.test"; +it.each([ + { + kind: "recovered", + source: "function legacy(a, a) { return a; } fetch('/partial');", + parsed: 2, + failures: 0, + reason: "recovered", + endpoints: ["/partial", "/complete"], + }, + { + kind: "failed", + source: "function broken( {", + parsed: 1, + failures: 1, + reason: "could not be parsed", + endpoints: ["/complete"], + }, +])( + "attributes $kind parse uncertainty only to its affected source", + ({ source, parsed, failures, reason, endpoints }) => { + const captured = inspection(source); + const good = inspection("fetch('/complete');").scripts.items[0]; + if (good === undefined) throw new Error("Missing valid script fixture"); + captured.scripts.items.push({ ...good, script_key: "valid-script" }); + captured.scripts.total += 1; + const result = analyzeCapturedWebBundle(captured); + expect(result.completeness).toMatchObject({ + status: "partial", + parsed_scripts: parsed, + parse_failures: failures, + }); + expect(result.unknowns).toContainEqual({ + dimension: "javascript_ast", + reason: expect.stringContaining(reason), + affected_script_keys: [captured.scripts.items[0]?.script_key], + }); + expect(result.observations.endpoints.map(({ value }) => value)).toEqual( + endpoints, + ); + }, +); + describe("web bundle analyzer", () => { it("extracts graph, route, endpoint, vendor, and WebMCP evidence", () => { const source = ` diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 522f22caa..12c35109c 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -50,7 +50,8 @@ export interface AnalysisAccumulator { readonly seen: Set; visitedNodes: number; parsedScripts: number; - parseFailures: number; + readonly failedScripts: string[]; + readonly recoveredScripts: string[]; } export const analyzeScript = ( @@ -67,10 +68,12 @@ export const analyzeScript = ( } catch (cause: unknown) { // Unparseable scripts are counted; the failure needs no extra detail. void cause; - accumulator.parseFailures += 1; + accumulator.failedScripts.push(script.script_key); return; } accumulator.parsedScripts += 1; + if ((file.errors?.length ?? 0) > 0) + accumulator.recoveredScripts.push(script.script_key); detectVendorFingerprints(script, accumulator); const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(file); const rpc = createJavaScriptRpcScan( @@ -386,7 +389,8 @@ export const emptyAccumulator = (): AnalysisAccumulator => ({ seen: new Set(), visitedNodes: 0, parsedScripts: 0, - parseFailures: 0, + failedScripts: [], + recoveredScripts: [], }); export const isIncludedScript = ( diff --git a/src/domain/webBundleAnalyzerResult.ts b/src/domain/webBundleAnalyzerResult.ts index 1e08f0f11..4d2509675 100644 --- a/src/domain/webBundleAnalyzerResult.ts +++ b/src/domain/webBundleAnalyzerResult.ts @@ -25,7 +25,8 @@ export const buildWebBundleAnalysis = ( const sourceMapIncomplete = sourceMaps.status === "partial" || sourceMaps.status === "unavailable"; const partial = - accumulator.parseFailures > 0 || + accumulator.failedScripts.length > 0 || + accumulator.recoveredScripts.length > 0 || unavailable.length > 0 || sourceMapIncomplete || omittedScripts !== undefined || @@ -44,9 +45,9 @@ export const buildWebBundleAnalysis = ( }, inferences: accumulator.inferences, unknowns: buildUnknowns({ - sourceScripts, unavailable, - parseFailures: accumulator.parseFailures, + failedScripts: accumulator.failedScripts, + recoveredScripts: accumulator.recoveredScripts, sourceMaps, omittedScripts, omittedSourceMaps, @@ -54,7 +55,7 @@ export const buildWebBundleAnalysis = ( completeness: buildCompleteness({ partial, parsedScripts: accumulator.parsedScripts, - parseFailures: accumulator.parseFailures, + parseFailures: accumulator.failedScripts.length, visitedNodes: accumulator.visitedNodes, }), limitations: bundleLimitations(), @@ -88,9 +89,9 @@ const chunkNode = (script: IncludedScript) => { }; interface UnknownsInput { - readonly sourceScripts: IncludedScript[]; readonly unavailable: string[]; - readonly parseFailures: number; + readonly failedScripts: readonly string[]; + readonly recoveredScripts: readonly string[]; readonly sourceMaps: WebBundleAnalysis["observations"]["source_maps"]; readonly omittedScripts: number | null | undefined; readonly omittedSourceMaps: number | null | undefined; @@ -115,15 +116,23 @@ const buildUnknowns = (input: UnknownsInput): WebBundleAnalysis["unknowns"] => [ affected_script_keys: input.unavailable, }, ]), - ...(input.parseFailures === 0 + ...(input.failedScripts.length === 0 ? [] : [ { dimension: "javascript_ast" as const, reason: "One or more source artifacts could not be parsed", - affected_script_keys: input.sourceScripts.map( - ({ script_key }) => script_key, - ), + affected_script_keys: [...input.failedScripts], + }, + ]), + ...(input.recoveredScripts.length === 0 + ? [] + : [ + { + dimension: "javascript_ast" as const, + reason: + "The parser recovered from errors in one or more source artifacts; findings may be incomplete", + affected_script_keys: [...input.recoveredScripts], }, ]), ...(input.omittedSourceMaps === undefined