From fe52ea12c3828f6763f5da3ed168dec0dc5fdfb1 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:55:53 +0800 Subject: [PATCH] fix(evidence): explain fixed analysis view page recovery (#7860) * fix(evidence): explain fixed analysis view page recovery * fix(evidence): explain unpublished view recovery --- src/domain/analysisView/analysisViewErrors.ts | 7 ++ src/domain/analysisView/androidJniView.ts | 11 +- .../analysisView/androidPackageView.test.ts | 16 ++- src/domain/analysisView/androidPackageView.ts | 6 +- src/domain/analysisView/gpuCodeObjectsView.ts | 7 +- .../analysisView/javascriptRoutesView.test.ts | 42 +++++++ .../analysisView/javascriptRoutesView.ts | 6 +- src/domain/analysisView/webBundleView.test.ts | 4 + src/domain/analysisView/webBundleView.ts | 6 +- .../javascriptRouteBudgetRecovery.test.ts | 103 ++++++++++++++++++ 10 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 tests/acceptance/javascriptRouteBudgetRecovery.test.ts diff --git a/src/domain/analysisView/analysisViewErrors.ts b/src/domain/analysisView/analysisViewErrors.ts index e1aaaeab5c..fcbe84aac3 100644 --- a/src/domain/analysisView/analysisViewErrors.ts +++ b/src/domain/analysisView/analysisViewErrors.ts @@ -5,6 +5,13 @@ import { export const INSPECT_ANALYSIS_VIEW_OPERATION = "inspect_analysis_view" as const; +/** Recovery belongs to the view projection, not to the MCP transport. */ +export const ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY = { + retryable: false, + remediationAction: + "The 1,048,576-byte analysis-view page allowance is fixed and has no runtime override. For a pageable collection, select fewer fields or a smaller selection; one oversized field may not fit even in a one-row page. If the error includes details.partial_observation, use that complete parent Evidence. Otherwise, read the parent directly from the caller-supplied bundle, or retrieve/export the retained parent in the same MCP session. Reading the parent does not increase the page allowance.", +} as const; + /** Construct a typed input failure for one selected-view constraint. */ export const analysisViewInputError = ( issues: readonly AnalysisInputIssue[], diff --git a/src/domain/analysisView/androidJniView.ts b/src/domain/analysisView/androidJniView.ts index 4718d689cf..c91407956b 100644 --- a/src/domain/analysisView/androidJniView.ts +++ b/src/domain/analysisView/androidJniView.ts @@ -17,7 +17,10 @@ import type { AnalysisViewRequest, UnsignedAnalysisView, } from "./analysisView.js"; -import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY, + analysisViewInputError, +} from "./analysisViewErrors.js"; import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import { boundedViewMessages, @@ -260,11 +263,7 @@ export const projectAndroidJniView = ( "transport", "JNI view exceeds its 1 MiB serialized allowance.", { parent_evidence_id: parent.evidenceId, limit_bytes: 1024 * 1024 }, - { - retryable: false, - remediationAction: - "Select a smaller collection page and requested fields, or read the complete retained Evidence.", - }, + ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY, ), ); return ok(result); diff --git a/src/domain/analysisView/androidPackageView.test.ts b/src/domain/analysisView/androidPackageView.test.ts index 8ce3c4523f..c97b866966 100644 --- a/src/domain/analysisView/androidPackageView.test.ts +++ b/src/domain/analysisView/androidPackageView.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "vitest"; import { createEvidence } from "../evidence.js"; +import { projectAnalysisError } from "../analysisErrorProjection.js"; import type { JsonValue } from "../jsonValue.js"; import { analysisViewRequestSchema, @@ -219,15 +220,18 @@ it("bounds selected row serialization and lets field selection recover without c component("large.Class", { process: "x".repeat(2 * 1024 * 1024) }), ]), ); - expect( - projectAnalysisView( - source, - analysisViewRequestSchema.parse({ kind: "android", facet: "components" }), - ), - ).toMatchObject({ + const wide = projectAnalysisView( + source, + analysisViewRequestSchema.parse({ kind: "android", facet: "components" }), + ); + expect(wide).toMatchObject({ ok: false, error: { _tag: "AnalysisResourceConstraintError" }, }); + if (wide.ok) throw new Error("Expected an oversized-row refusal"); + expect(projectAnalysisError(wide.error).remediation.action).toContain( + "select fewer fields", + ); const small = androidComponentsViewResultSchema.parse( project(source, { kind: "android", facet: "components", fields: ["name"] }), ); diff --git a/src/domain/analysisView/androidPackageView.ts b/src/domain/analysisView/androidPackageView.ts index 564a883c76..e4a029ed4a 100644 --- a/src/domain/analysisView/androidPackageView.ts +++ b/src/domain/analysisView/androidPackageView.ts @@ -21,7 +21,10 @@ import { completeWithinViewCoverage, pageViewCoverage, } from "./analysisViewCoverage.js"; -import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY, + analysisViewInputError, +} from "./analysisViewErrors.js"; import { analysisTextPage, analysisTextPageItemSchema, @@ -233,6 +236,7 @@ export const projectAndroidPackageView = ( limit_bytes: PAGE_BYTES, facet: view.facet, }, + ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY, ), ); // Admit caller selections and provenance before building filter indexes or output rows. diff --git a/src/domain/analysisView/gpuCodeObjectsView.ts b/src/domain/analysisView/gpuCodeObjectsView.ts index d6f360888f..671489002b 100644 --- a/src/domain/analysisView/gpuCodeObjectsView.ts +++ b/src/domain/analysisView/gpuCodeObjectsView.ts @@ -17,7 +17,10 @@ import type { AnalysisViewRequest, UnsignedAnalysisView, } from "./analysisView.js"; -import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY, + analysisViewInputError, +} from "./analysisViewErrors.js"; import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import { boundedViewMessages, @@ -305,6 +308,7 @@ export const projectGpuCodeObjectsView = ( "memory", "PTX page exceeds the 1 MiB response budget; lower limit", { maximum_page_bytes: 1024 * 1024 }, + ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY, ), ); return ok({ @@ -411,6 +415,7 @@ export const projectGpuCodeObjectsView = ( "memory", "GPU page exceeds the 1 MiB response budget; select fewer fields or rows", { maximum_page_bytes: 1024 * 1024 }, + ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY, ), ); return ok({ diff --git a/src/domain/analysisView/javascriptRoutesView.test.ts b/src/domain/analysisView/javascriptRoutesView.test.ts index c58af1afa7..68e542a7a0 100644 --- a/src/domain/analysisView/javascriptRoutesView.test.ts +++ b/src/domain/analysisView/javascriptRoutesView.test.ts @@ -3,6 +3,7 @@ import { analyzeJavaScriptStaticSource } from "../javascript/javascriptStaticAna import { completeSemanticSteps } from "../javascript/javascriptSemanticTraversal.js"; import { buildJavaScriptRouteInventorySteps } from "../javascript/javascriptRouteInventory.js"; import { freezeJsonSnapshot } from "../immutableJson.js"; +import { projectAnalysisError } from "../analysisErrorProjection.js"; import { projectAnalysisView, analysisViewRequestSchema, @@ -269,6 +270,47 @@ it("returns a typed retained-parent resource error when even a summary identity expect(result.error.message).toContain("cannot fit"); }); +it("explains how to recover an oversized route row without changing the parent", () => { + const source = { + ...parent(), + normalizedResult: { + input_path: "/gone/app", + root_artifact_sha256: "c".repeat(64), + route_inventory: inventory(`router.push('/${"a".repeat(1_100_000)}');`), + }, + }; + const selection = { collection: "routes", limit: 1 }; + const result = projectAnalysisView( + source, + analysisViewRequestSchema.parse({ + kind: "javascript-routes", + ...selection, + }), + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected an oversized-row refusal"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: false, + details: { + reported_limits: { + parent_evidence_id: source.evidenceId, + limit_bytes: 1024 * 1024, + }, + }, + }); + expect(projected.remediation.action).toContain("select fewer fields"); + expect(projected.remediation.action).toContain("no runtime override"); + expect(projected.remediation.action).toContain("caller-supplied bundle"); + const narrow = query(source, { + ...selection, + fields: ["classification", "confidence"], + }); + expect(narrow.items).toHaveLength(1); + expect(narrow.items[0]).not.toHaveProperty("path"); +}); + it.each(["summary", "routes"] as const)( "includes the sealed digest in the %s byte ceiling", (collection) => { diff --git a/src/domain/analysisView/javascriptRoutesView.ts b/src/domain/analysisView/javascriptRoutesView.ts index 1b2974ff5c..cabb87dd7c 100644 --- a/src/domain/analysisView/javascriptRoutesView.ts +++ b/src/domain/analysisView/javascriptRoutesView.ts @@ -26,7 +26,10 @@ import type { AnalysisViewRequest, UnsignedAnalysisView, } from "./analysisView.js"; -import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY, + analysisViewInputError, +} from "./analysisViewErrors.js"; import { boundedViewMessages, completeWithinViewCoverage, @@ -404,6 +407,7 @@ export const projectJavaScriptRoutesView = ( limit_bytes: PAGE_BYTES, collection: view.collection, }, + ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY, ), ); if (view.collection === "summary") { diff --git a/src/domain/analysisView/webBundleView.test.ts b/src/domain/analysisView/webBundleView.test.ts index 2adb08d8d2..d146529887 100644 --- a/src/domain/analysisView/webBundleView.test.ts +++ b/src/domain/analysisView/webBundleView.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "vitest"; import type { WebBundleAnalysis } from "../webBundleAnalysis.js"; import { AnalysisInputError } from "../analysisErrorCore.js"; +import { projectAnalysisError } from "../analysisErrorProjection.js"; import { analysisViewRequestSchema, projectAnalysisView, @@ -414,6 +415,9 @@ it("returns typed AnalysisResourceConstraintError when a single record exceeds 1 if (result.ok) throw new Error("Expected resource error"); expect(result.error._tag).toBe("AnalysisResourceConstraintError"); expect(result.error.message).toContain("byte budget"); + expect(projectAnalysisError(result.error).remediation.action).toContain( + "select fewer fields", + ); }); it.each([undefined, ["call:fetch"]])( diff --git a/src/domain/analysisView/webBundleView.ts b/src/domain/analysisView/webBundleView.ts index 6006fa7386..75b55fe6f2 100644 --- a/src/domain/analysisView/webBundleView.ts +++ b/src/domain/analysisView/webBundleView.ts @@ -19,7 +19,10 @@ import { completeWithinViewCoverage, pageViewCoverage, } from "./analysisViewCoverage.js"; -import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY, + analysisViewInputError, +} from "./analysisViewErrors.js"; import { createAnalysisViewResultReader } from "./analysisViewParent.js"; const PAGE_BYTES = 1024 * 1024; @@ -187,6 +190,7 @@ export const projectWebBundleView = ( limit_bytes: PAGE_BYTES, collection, }, + ANALYSIS_VIEW_PAGE_BUDGET_RECOVERY, ), ); diff --git a/tests/acceptance/javascriptRouteBudgetRecovery.test.ts b/tests/acceptance/javascriptRouteBudgetRecovery.test.ts new file mode 100644 index 0000000000..5ac467c568 --- /dev/null +++ b/tests/acceptance/javascriptRouteBudgetRecovery.test.ts @@ -0,0 +1,103 @@ +import { 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"; + +cliTest( + "recovers an oversized retained route page by selecting fields", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-route-budget-recovery-"); + const source = join(root, "app.js"); + const bundle = join(root, "parent.json"); + const route = `/api/${"a".repeat(1_100_000)}`; + await writeFile(source, `router.push(${JSON.stringify(route)});\n`); + const unpublished = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--detail", + "summary", + "--evidence_output", + bundle, + "--view", + JSON.stringify({ + kind: "javascript-routes", + collection: "routes", + limit: 1, + }), + "--json", + ], + timeoutMs: 60_000, + }); + expect(unpublished.exitCode, unpublished.stderr).toBe(1); + expect(unpublished.json).toMatchObject({ + code: "resource_constraint", + details: { + partial_observation: { + evidence_id: expect.stringMatching(/^ev_/), + normalized_result: expect.any(Object), + }, + }, + }); + const unpublishedError = unpublished.json as { + remediation: { action: string }; + details: { partial_observation: unknown }; + }; + expect(unpublishedError.remediation.action).toContain( + "details.partial_observation", + ); + expect( + JSON.stringify(unpublishedError.details.partial_observation), + ).toContain(route); + await expect(readFile(bundle)).rejects.toMatchObject({ code: "ENOENT" }); + const analyzed = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--evidence_output", + bundle, + "--json", + ], + timeoutMs: 60_000, + }); + expect(analyzed.exitCode, analyzed.stderr).toBe(0); + const request = (fields?: readonly string[]) => + JSON.stringify({ + source: { kind: "evidence-file", path: bundle }, + view: { + kind: "javascript-routes", + collection: "routes", + limit: 1, + ...(fields === undefined ? {} : { fields }), + }, + }); + const wide = await cli.run({ + arguments: ["inspect-analysis-view", request(), "--json"], + }); + expect(wide.exitCode).toBe(1); + expect(wide.json).toMatchObject({ + code: "resource_constraint", + retryable: false, + details: { reported_limits: { limit_bytes: 1024 * 1024 } }, + }); + const wideError = wide.json as { remediation: { action: string } }; + expect(wideError.remediation.action).toContain("select fewer fields"); + expect(wideError.remediation.action).toContain("no runtime override"); + const narrow = await cli.run({ + arguments: [ + "inspect-analysis-view", + request(["classification", "confidence"]), + "--json", + ], + }); + expect(narrow.exitCode, narrow.stderr).toBe(0); + expect(narrow.json).toMatchObject({ + normalized_result: { + items: [expect.not.objectContaining({ path: expect.anything() })], + }, + }); + expect(await readFile(bundle, "utf8")).toContain(route); + }, +);