fix(evidence): explain fixed analysis view page recovery (#7860)

* fix(evidence): explain fixed analysis view page recovery

* fix(evidence): explain unpublished view recovery
This commit is contained in:
morluto authored and GitHub committed 2026-10-29 04:55:53 +08:00
1 parent 00bd92c56e
commit fe52ea12c3
10 files changed
+192 -16

No files matched your search

@@ -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[],
+5 -6
View File
@@ -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);
@@ -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"] }),
);
@@ -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.
@@ -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({
@@ -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) => {
@@ -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") {
@@ -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"]])(
+5 -1
View File
@@ -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,
),
);
@@ -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);
},
);