From b1b6cb64c2c9b0765e0ff6d6f48740140767d5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E9=97=8C?= <130824939+Xinglan233@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:53:45 +0800 Subject: [PATCH] fix(analysis-view)!: distinguish unavailable native collections (#8291) --- docs/mcp-contracts.md | 11 ++ .../analysisView/NativeLoadImageViews.test.ts | 7 + .../NativeObservationViews.test.ts | 7 + .../analysisView/analysisViewCoverage.ts | 25 +++ src/domain/analysisView/analysisViewResult.ts | 10 +- .../analysisView/nativeLoadImageView.ts | 12 +- .../analysisView/nativeObservationView.ts | 48 ++++-- .../unavailableNativeCollectionViews.test.ts | 154 ++++++++++++++++++ 8 files changed, 254 insertions(+), 20 deletions(-) create mode 100644 tests/acceptance/tools/unavailableNativeCollectionViews.test.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 8943ec4b2f..ac005e2c8d 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -278,6 +278,17 @@ to `/absolute/view-request.json`, then run `rea inspect-analysis-view /absolute/view-request.json --json`. This also reads pre-summary source records without changing their identity; no live provider is started. +Unavailable or inapplicable native collections keep `items.rows: null` and +report `coverage: {"status":"unavailable","examined":0,"total":null,"next_offset":null,"exhausted":false}`. +This applies to unrecorded `native-load-image` checks/sections and inapplicable +`native-context` result limitations. Their total is unknown; no continuation is +offered. Stop paging that collection and inspect the returned unknowns rather +than treating it as an observed empty set or retrying a null offset. Recorded +empty collections, empty selections and offsets past the end retain numeric +totals and exhausted page coverage. Clients upgrading from the earlier +zero-total representation must handle the new `unavailable` status and nullable +total; historical saved views remain valid. + `procedure_address`, `procedure_callees`, `procedure_callers`, `resolve_containing_procedure` and `get_call_graph` also accept `detail: "summary"` (complete remains the default). The summary includes the first 64 recorded diff --git a/src/application/analysisView/NativeLoadImageViews.test.ts b/src/application/analysisView/NativeLoadImageViews.test.ts index c1616d57c2..2c391694ea 100644 --- a/src/application/analysisView/NativeLoadImageViews.test.ts +++ b/src/application/analysisView/NativeLoadImageViews.test.ts @@ -208,6 +208,13 @@ it.each(["checks", "sections"])( await view(source(), { kind: "native-load-image", collection }), ).toMatchObject({ items: { collection, rows: null }, + coverage: { + status: "unavailable", + examined: 0, + total: null, + next_offset: null, + exhausted: false, + }, unknowns: expect.arrayContaining([ "Null collection counts or rows mean not recorded or not applicable, not observed absence.", ]), diff --git a/src/application/analysisView/NativeObservationViews.test.ts b/src/application/analysisView/NativeObservationViews.test.ts index be9092a4e0..c9c16e99ef 100644 --- a/src/application/analysisView/NativeObservationViews.test.ts +++ b/src/application/analysisView/NativeObservationViews.test.ts @@ -252,6 +252,13 @@ it("keeps absent profile and inapplicable result caveats distinct from observed ).toMatchObject({ source_context: { result_limitation_count: null }, items: { rows: null }, + coverage: { + status: "unavailable", + examined: 0, + total: null, + next_offset: null, + exhausted: false, + }, unknowns: expect.arrayContaining([ expect.stringContaining("not applicable"), ]), diff --git a/src/domain/analysisView/analysisViewCoverage.ts b/src/domain/analysisView/analysisViewCoverage.ts index 392c711095..d5f694c0fd 100644 --- a/src/domain/analysisView/analysisViewCoverage.ts +++ b/src/domain/analysisView/analysisViewCoverage.ts @@ -9,8 +9,33 @@ export const analysisViewCoverageSchema = z.strictObject({ exhausted: z.boolean(), }); +const unavailableViewCoverageSchema = z.strictObject({ + status: z.literal("unavailable"), + examined: z.literal(0), + total: z.null(), + next_offset: z.null(), + exhausted: z.literal(false), +}); + +/** Only views with nullable collections can report unavailable page coverage. */ +export const nullableCollectionViewCoverageSchema = z.union([ + analysisViewCoverageSchema, + unavailableViewCoverageSchema, +]); + export type AnalysisViewCoverage = z.output; +/** Unrecorded or inapplicable collections have no known total or continuation. */ +export const unavailableViewCoverage = (): z.output< + typeof unavailableViewCoverageSchema +> => ({ + status: "unavailable", + examined: 0, + total: null, + next_offset: null, + exhausted: false, +}); + /** Coverage for a summary, facet, or single selected object. */ export const completeWithinViewCoverage = ( examined: number, diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index c7240d0ce8..d5ea266d4e 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -1,4 +1,7 @@ -import { analysisViewCoverageSchema } from "./analysisViewCoverage.js"; +import { + analysisViewCoverageSchema, + nullableCollectionViewCoverageSchema, +} from "./analysisViewCoverage.js"; import { javaScriptRouteInventorySourceSchema, javaScriptRouteInventorySchema, @@ -447,7 +450,9 @@ const nativeContextViewResultSchema = z.discriminatedUnion("facet", [ nativeContextFor("overview"), nativeContextFor("analysis-profile"), nativeContextFor("limitations"), - nativeContextFor("result-limitations"), + nativeContextFor("result-limitations").extend({ + coverage: nullableCollectionViewCoverageSchema, + }), ]); export const byteEntropySummaryViewResultSchema = z.strictObject({ @@ -1202,6 +1207,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ z.strictObject({ ...viewResultBase, kind: z.literal("native-load-image"), + coverage: nullableCollectionViewCoverageSchema, ...nativeViewSourceContext, verification_status: z.enum(["unsupported", "verified", "mismatch"]), parent_operation: z.literal("inspect_native_load_image"), diff --git a/src/domain/analysisView/nativeLoadImageView.ts b/src/domain/analysisView/nativeLoadImageView.ts index eb5b56af88..594d9d688e 100644 --- a/src/domain/analysisView/nativeLoadImageView.ts +++ b/src/domain/analysisView/nativeLoadImageView.ts @@ -23,6 +23,7 @@ import { boundedViewMessages, completeWithinViewCoverage, pageViewCoverage, + unavailableViewCoverage, } from "./analysisViewCoverage.js"; const observation = nativeLoadImageObservationSchema.shape; @@ -290,12 +291,12 @@ export const projectNativeLoadImageView = ( ? report.sections : undefined : observed[view.collection]; - const page = selectRows(selected ?? [], view); + const page = selected === undefined ? undefined : selectRows(selected, view); const symbolUnrecorded = view.collection === "relocations" && (view.fields === undefined || view.fields.some((field: string) => field === "symbol")) && - page.rows.some(({ record }) => !("symbol" in record)); + (page?.rows.some(({ record }) => !("symbol" in record)) ?? false); return ok({ ...shared, unknowns: symbolUnrecorded @@ -309,8 +310,11 @@ export const projectNativeLoadImageView = ( view, items: nativeLoadImageViewItemsSchema.parse({ collection: view.collection, - rows: selected === undefined ? null : page.rows, + rows: page?.rows ?? null, }), - coverage: pageViewCoverage(view.offset, page.rows.length, page.total), + coverage: + page === undefined + ? unavailableViewCoverage() + : pageViewCoverage(view.offset, page.rows.length, page.total), }); }; diff --git a/src/domain/analysisView/nativeObservationView.ts b/src/domain/analysisView/nativeObservationView.ts index 48ef919da2..ee4530adc3 100644 --- a/src/domain/analysisView/nativeObservationView.ts +++ b/src/domain/analysisView/nativeObservationView.ts @@ -20,7 +20,7 @@ import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; import { pageViewCoverage, - completeWithinViewCoverage, + unavailableViewCoverage, } from "./analysisViewCoverage.js"; import { nativeContextViewRequestSchema, @@ -304,22 +304,42 @@ export const projectNativeObservationView = ( ], }; if (view.kind === "native-context") { - const { total, ...page } = contextRows(context.value, view); - return ok({ + const selected = contextRows(context.value, view); + const shared = { ...common, kind: "native-context" as const, - ...page, parent_operation: source.value.operation, - coverage: - total === null - ? completeWithinViewCoverage(0, 0) - : pageViewCoverage(view.offset, page.items.rows?.length ?? 0, total), - unknowns: - total === null - ? [ - "Result limitations are not applicable to this primitive observation; null does not report an examined empty list.", - ] - : common.unknowns, + }; + if (selected.facet === "result-limitations") { + const { total, ...page } = selected; + return ok({ + ...shared, + ...page, + coverage: + total === null + ? unavailableViewCoverage() + : pageViewCoverage( + view.offset, + page.items.rows?.length ?? 0, + total, + ), + unknowns: + total === null + ? [ + "Result limitations are not applicable to this primitive observation; null does not report an examined empty list.", + ] + : common.unknowns, + }); + } + const { total, ...page } = selected; + return ok({ + ...shared, + ...page, + coverage: pageViewCoverage( + view.offset, + page.items.rows?.length ?? 0, + total, + ), }); } const selection = view.kind === "summary" ? { offset: 0, limit: 64 } : view; diff --git a/tests/acceptance/tools/unavailableNativeCollectionViews.test.ts b/tests/acceptance/tools/unavailableNativeCollectionViews.test.ts new file mode 100644 index 0000000000..fc1a27e2b5 --- /dev/null +++ b/tests/acceptance/tools/unavailableNativeCollectionViews.test.ts @@ -0,0 +1,154 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { + nativeLoadImageObservationFixture, + nativeRawLoadImageFixture, +} from "../../fixtures/nativeLoadImage.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "distinguishes unavailable native collections from empty selections through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-unavailable-native-view-"); + const provider = { + id: "fixture", + name: "Retained native fixture", + version: "1", + }; + const load = (result: unknown) => + createEvidence(undefined, provider, { + operation: "inspect_native_load_image", + parameters: {}, + result, + limitations: [ + "Synthetic retained Evidence; no native provider was run.", + ], + }); + const unsupported = load({ + status: "unsupported", + reason: "Independent ELF load verification was not recorded.", + observations: { + ...nativeLoadImageObservationFixture(), + executable_format: "Executable and Linking Format (ELF)", + }, + limitations: [], + }); + const raw = load(nativeRawLoadImageFixture()); + const primitive = createEvidence(undefined, provider, { + operation: "procedure_callees", + parameters: {}, + result: [], + }); + const graph = createEvidence(undefined, provider, { + operation: "get_call_graph", + parameters: {}, + result: { graph: {}, limitations: [] }, + }); + const unavailable = { + status: "unavailable", + examined: 0, + total: null, + next_offset: null, + exhausted: false, + }; + const empty = (total: number) => ({ + status: "empty", + examined: 0, + total, + next_offset: null, + exhausted: true, + }); + const cases = [ + { + label: "checks-unrecorded", + evidence: unsupported, + view: { kind: "native-load-image", collection: "checks" }, + rows: null, + coverage: unavailable, + }, + { + label: "sections-unrecorded-despite-empty-selection", + evidence: unsupported, + view: { + kind: "native-load-image", + collection: "sections", + offset: 5, + indices: [], + }, + rows: null, + coverage: unavailable, + }, + { + label: "checks-recorded-empty", + evidence: raw, + view: { kind: "native-load-image", collection: "checks" }, + rows: [], + coverage: empty(0), + }, + { + label: "mappings-empty-selection", + evidence: unsupported, + view: { + kind: "native-load-image", + collection: "mappings", + indices: [], + }, + rows: [], + coverage: empty(0), + }, + { + label: "mappings-past-end", + evidence: unsupported, + view: { kind: "native-load-image", collection: "mappings", offset: 5 }, + rows: [], + coverage: empty(1), + }, + { + label: "primitive-result-limitations-inapplicable", + evidence: primitive, + view: { kind: "native-context", facet: "result-limitations" }, + rows: null, + coverage: unavailable, + }, + { + label: "graph-result-limitations-recorded-empty", + evidence: graph, + view: { kind: "native-context", facet: "result-limitations" }, + rows: [], + coverage: empty(0), + }, + ]; + const call = await cli.sharedMcpCall!(); + const run = cli.sharedCliRun!.bind(cli); + for (const test of cases) { + const request = { + source: { kind: "inline", evidence: test.evidence }, + view: test.view, + }; + const path = join(root, `${test.label}.json`); + await writeFile(path, JSON.stringify(request)); + const output = await run({ + arguments: ["inspect-analysis-view", path, "--json"], + }); + expect(output.exitCode, output.stderr || output.stdout).toBe(0); + const page = analysisViewResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + const response = await call("inspect_analysis_view", request); + expect(response.isError).not.toBe(true); + expect( + parseEvidence(response.structuredContent).normalized_result, + ).toEqual(page); + expect(page, test.label).toMatchObject({ + parent_evidence_id: test.evidence.evidence_id, + items: { rows: test.rows }, + coverage: test.coverage, + }); + } + }, +);