diff --git a/src/application/desktop/AgentEnvNoteRecords.test.ts b/src/application/desktop/AgentEnvNoteRecords.test.ts new file mode 100644 index 0000000000..eaba042c9f --- /dev/null +++ b/src/application/desktop/AgentEnvNoteRecords.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from "vitest"; + +import { parseAgentEnvNotes } from "../../../tests/fixtures/fakeAgentEnvComputer.js"; + +const record = { id: 1, kind: "call" }; + +it("keeps complete note records and an unfinished trailing fragment", () => { + expect(parseAgentEnvNotes(`${JSON.stringify(record)}\n{"id":`)).toEqual([ + record, + ]); + expect(parseAgentEnvNotes(`${JSON.stringify(record)}\n`)).toEqual([record]); + expect(parseAgentEnvNotes("")).toEqual([]); +}); + +it("rejects a newline-terminated note that is not a record", () => { + expect(() => + parseAgentEnvNotes(`${JSON.stringify(record)}\n{bad}\n`), + ).toThrow(SyntaxError); + expect(() => parseAgentEnvNotes("{bad}\n")).toThrow(SyntaxError); +}); diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index f44331c5ba..32188128cd 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -559,6 +559,7 @@ export const erlangBeamSummaryViewResultSchema = z.strictObject({ }); export const erlangBeamTableViewResultSchema = z.strictObject({ ...viewResultBase, + coverage: nullableCollectionViewCoverageSchema, kind: z.literal("beam"), parent_operation: z.literal("inspect_erlang_beam"), view: erlangBeamViewRequestSchema, @@ -624,6 +625,7 @@ export const rustBinarySummaryViewResultSchema = z.strictObject({ export const rustBinaryPageViewResultSchema = z.strictObject({ ...viewResultBase, + coverage: nullableCollectionViewCoverageSchema, ...rustBinaryViewContextSchema.shape, kind: z.literal("rust"), parent_operation: z.literal("inspect_rust_binary"), @@ -1506,6 +1508,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ z.strictObject({ ...viewResultBase, ...goBinaryViewContextSchema.shape, + coverage: nullableCollectionViewCoverageSchema, kind: z.literal("go"), parent_operation: z.literal("inspect_go_binary"), view: goBinaryViewRequestSchema, diff --git a/src/domain/analysisView/artifactAvailabilityView.test.ts b/src/domain/analysisView/artifactAvailabilityView.test.ts new file mode 100644 index 0000000000..485a9f5e09 --- /dev/null +++ b/src/domain/analysisView/artifactAvailabilityView.test.ts @@ -0,0 +1,125 @@ +import { expect, it } from "vitest"; +import { createEvidence } from "../evidence.js"; +import type { ErlangBeam } from "../erlang/beam.js"; +import type { ModelArtifact } from "../model/modelArtifact.js"; +import { + projectAnalysisView, + type AnalysisViewParent, +} from "./analysisView.js"; + +const parent = ( + operation: string, + result: ErlangBeam | ModelArtifact, +): AnalysisViewParent => { + const evidence = createEvidence( + { path: result.artifact.path, sha256: "a".repeat(64), format: "file" }, + { id: "fixture", name: "Fixture reader", version: "1" }, + { operation, parameters: {}, result }, + ); + return { + predicateType: evidence.predicate_type, + evidenceId: evidence.evidence_id, + operation, + subject: evidence.subject, + normalizedResult: result, + limitations: [], + }; +}; + +const beam: ErlangBeam = { + artifact: { + path: "/artifacts/module.beam", + sha256: "a".repeat(64), + bytes: 12, + }, + form: { location: { offset: 0, bytes: 12 }, size: 4 }, + chunks: [], + atoms: null, + atom_encoding: null, + exports: null, + imports: null, + lambdas: null, + code: null, + module: null, + coverage: { status: "complete", examined_chunks: 0, atoms: 0 }, + limitations: [], +}; + +it.each(["atoms", "exports", "imports", "lambdas"] as const)( + "distinguishes unavailable BEAM %s from a decoded empty table", + (collection) => { + for (const available of [false, true]) { + const result = projectAnalysisView( + parent("inspect_erlang_beam", { + ...beam, + [collection]: available ? [] : null, + }), + { kind: "beam", collection, offset: 7, limit: 1 }, + ); + if (!result.ok) throw result.error; + expect(result.value.coverage).toEqual({ + status: available ? "empty" : "unavailable", + examined: 0, + total: available ? 0 : null, + next_offset: null, + exhausted: available, + }); + expect(result.value).toMatchObject({ page: { collection, items: [] } }); + if (!available) + expect(result.value.unknowns.join(" ")).toContain( + `No ${collection} table was decoded`, + ); + } + }, +); + +const model: ModelArtifact = { + artifact: { + path: "/artifacts/model.gguf", + file_size: 32, + header_sha256: "a".repeat(64), + header_length: 32, + sha256: "a".repeat(64), + bytes_read: 32, + read_scope: "full-file", + }, + format: "gguf", + version: 3, + byte_order: "little", + alignment: 32, + tensor_data_offset: 32, + metadata: [], + tensors: [], + totals: { + confidence: "derived", + parameter_count: "0", + tensor_bytes: 0, + by_type: [], + }, + coverage: { + header: "complete", + tensor_data: "not-interpreted", + array_values: "preview", + tensor_ranges: "partial", + storage_sizes: "partial", + }, + limitations: [ + "Array metadata retains only the requested preview; storage sizes remain unknown.", + ], +}; + +it.each(["metadata", "tensors", "types"] as const)( + "retains producer limitations on model %s pages with no envelope warnings", + (collection) => { + const input = parent("inspect_model_artifact", model); + const result = projectAnalysisView(input, { + kind: "model-artifact", + collection, + offset: 0, + limit: 1, + }); + if (!result.ok) throw result.error; + expect(result.value.limitations).toEqual(model.limitations); + expect(input.limitations).toEqual([]); + }, +); diff --git a/src/domain/analysisView/erlangBeamView.ts b/src/domain/analysisView/erlangBeamView.ts index 7f14f88ee2..d52f608e53 100644 --- a/src/domain/analysisView/erlangBeamView.ts +++ b/src/domain/analysisView/erlangBeamView.ts @@ -13,6 +13,7 @@ import { boundedViewMessages, completeWithinViewCoverage, pageViewCoverage, + unavailableViewCoverage, } from "./analysisViewCoverage.js"; import { selectViewRows } from "./selectViewRows.js"; @@ -120,7 +121,7 @@ type Row = NonNullable[number]; type BeamView = z.infer; /** Select already parsed rows; original atom/lambda indexes are never repurposed. */ -const selectRows = (rows: readonly Row[] | null, view: BeamView) => { +const selectRows = (source: readonly Row[], view: BeamView) => { const names = view.names === undefined ? undefined : new Set(view.names); const indices = view.indices === undefined ? undefined : new Set(view.indices); @@ -138,7 +139,6 @@ const selectRows = (rows: readonly Row[] | null, view: BeamView) => { ), source_index, }); - const source = rows ?? []; if (names === undefined && indices === undefined && modules === undefined) return { rows: source @@ -223,7 +223,17 @@ export const projectErlangBeamView = ( summary, coverage: completeWithinViewCoverage(chunks.length, chunks.length), }); - const selected = selectRows(parsed.value[view.collection], view); + const rows = parsed.value[view.collection]; + if (rows === null) + return ok({ + ...shared, + kind: "beam" as const, + view, + beam: summary, + page: { collection: view.collection, items: [] }, + coverage: unavailableViewCoverage(), + }); + const selected = selectRows(rows, view); return ok({ ...shared, kind: "beam" as const, diff --git a/src/domain/analysisView/goBinaryView.test.ts b/src/domain/analysisView/goBinaryView.test.ts index 223046addb..239a33b324 100644 --- a/src/domain/analysisView/goBinaryView.test.ts +++ b/src/domain/analysisView/goBinaryView.test.ts @@ -238,3 +238,53 @@ it("refuses string_literals view when unrequested and projects filtered/truncate ], }); }); + +it.each(["dependencies", "settings"] as const)( + "distinguishes unavailable Go %s from decoded empty records", + (collection) => { + for (const available of [false, true]) { + const input = parent(); + const result = projectAnalysisView( + { + ...input, + normalizedResult: { + ...report, + build_info: available + ? { + header_offset: 0, + encoding: "inline", + go_version: null, + go_version_bytes_base64: "", + module_text: null, + module_bytes_base64: "", + version_location: { offset: 32, bytes: 0 }, + module_location: { offset: 32, bytes: 0 }, + module: { + path: null, + main: null, + dependencies: [], + settings: [], + unparsed_lines: [], + unparsed_line_bytes_base64: [], + complete: true, + }, + } + : null, + }, + }, + { kind: "go", collection, offset: 3, limit: 1 }, + ); + if (!result.ok) throw result.error; + expect(result.value.coverage).toEqual({ + status: available ? "empty" : "unavailable", + examined: 0, + total: available ? 0 : null, + next_offset: null, + exhausted: available, + }); + expect(result.value).toMatchObject({ + source_coverage: { build_info_available: available }, + }); + } + }, +); diff --git a/src/domain/analysisView/goBinaryView.ts b/src/domain/analysisView/goBinaryView.ts index d04545b894..079ea1d91c 100644 --- a/src/domain/analysisView/goBinaryView.ts +++ b/src/domain/analysisView/goBinaryView.ts @@ -20,6 +20,7 @@ import { boundedViewMessages, completeWithinViewCoverage, pageViewCoverage, + unavailableViewCoverage, } from "./analysisViewCoverage.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; import { createAnalysisViewResultReader } from "./analysisViewParent.js"; @@ -322,10 +323,18 @@ export const projectGoBinaryView = ( ), }); } + if (info === null) + return ok({ + kind: "go" as const, + view, + ...shared, + page: { collection: view.collection, rows: [] }, + coverage: unavailableViewCoverage(), + }); if (view.collection === "dependencies") { const paths = view.paths === undefined ? undefined : new Set(view.paths); const selected = selectViewRows( - info?.module.dependencies ?? [], + info.module.dependencies, view, (row, index) => (indices?.has(index) ?? true) && (paths?.has(row.path) ?? true), @@ -353,7 +362,7 @@ export const projectGoBinaryView = ( } const keys = view.keys === undefined ? undefined : new Set(view.keys); const selected = selectViewRows( - info?.module.settings ?? [], + info.module.settings, view, (row, index) => (indices?.has(index) ?? true) && (keys?.has(row.key) ?? true), diff --git a/src/domain/analysisView/modelArtifactView.ts b/src/domain/analysisView/modelArtifactView.ts index 9ddb513059..86ffa7b21a 100644 --- a/src/domain/analysisView/modelArtifactView.ts +++ b/src/domain/analysisView/modelArtifactView.ts @@ -217,7 +217,7 @@ export const projectModelArtifactView = ( parent_digest: parent.evidenceId.slice(3), artifact: { path: report.artifact.path, sha256: report.artifact.sha256 }, limitations: boundedViewMessages( - parent.limitations, + [...parent.limitations, ...report.limitations], parent.evidenceId, "limitations", ), diff --git a/src/domain/analysisView/rustBinaryView.test.ts b/src/domain/analysisView/rustBinaryView.test.ts index 43d7906e70..9cb8d8d898 100644 --- a/src/domain/analysisView/rustBinaryView.test.ts +++ b/src/domain/analysisView/rustBinaryView.test.ts @@ -206,3 +206,40 @@ it("pages matching original indices and omits dependency vectors unless selected next_offset: null, }); }); + +it.each(["packages", "edges"] as const)( + "distinguishes unavailable Rust %s from decoded empty records", + (collection) => { + for (const available of [false, true]) { + const metadata = report.auditable_metadata!; + const result = projectAnalysisView( + parent({ + ...report, + auditable_metadata: available + ? { + ...metadata, + packages: [], + root_package_index: null, + dependency_edges: [], + cyclic_dependency_indices: [], + } + : null, + }), + { kind: "rust", collection, offset: 3, limit: 1 }, + ); + if (!result.ok) throw result.error; + expect(result.value.coverage).toEqual({ + status: available ? "empty" : "unavailable", + examined: 0, + total: available ? 0 : null, + next_offset: null, + exhausted: available, + }); + expect(result.value).toMatchObject({ + source_coverage: { + embedded_records: available ? "complete" : "unknown", + }, + }); + } + }, +); diff --git a/src/domain/analysisView/rustBinaryView.ts b/src/domain/analysisView/rustBinaryView.ts index ed63ecf06b..2781196b0e 100644 --- a/src/domain/analysisView/rustBinaryView.ts +++ b/src/domain/analysisView/rustBinaryView.ts @@ -22,6 +22,7 @@ import { boundedViewMessages, completeWithinViewCoverage, pageViewCoverage, + unavailableViewCoverage, } from "./analysisViewCoverage.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; import { selectViewRows } from "./selectViewRows.js"; @@ -190,6 +191,15 @@ export const projectRustBinaryView = ( summary, coverage: completeWithinViewCoverage(1, 1), }); + if (metadata === null) + return ok({ + kind: "rust" as const, + view, + ...shared, + rust: summary, + page: { collection: view.collection, rows: [] }, + coverage: unavailableViewCoverage(), + }); const indices = view.indices === undefined ? undefined : new Set(view.indices); const fields = @@ -199,7 +209,7 @@ export const projectRustBinaryView = ( const versions = view.versions === undefined ? undefined : new Set(view.versions); const selected = selectViewRows( - metadata?.packages ?? [], + metadata.packages, view, (row, index) => (indices?.has(index) ?? true) && @@ -238,7 +248,7 @@ export const projectRustBinaryView = ( const to = view.to_indices === undefined ? undefined : new Set(view.to_indices); const selected = selectViewRows( - metadata?.dependency_edges ?? [], + metadata.dependency_edges, view, (row, index) => (indices?.has(index) ?? true) && diff --git a/tests/boundary/mcp/goBinaryViews.test.ts b/tests/boundary/mcp/goBinaryViews.test.ts index e0d43a0d08..78cbd5d7a7 100644 --- a/tests/boundary/mcp/goBinaryViews.test.ts +++ b/tests/boundary/mcp/goBinaryViews.test.ts @@ -368,7 +368,7 @@ mcpTest( const page = goPage(parseEvidence(empty.structuredContent)); expect(page).toMatchObject({ page: { rows: [] }, - coverage: { total: 0 }, + coverage: { status: "unavailable", total: null, exhausted: false }, source_coverage: { build_info_available: false, module_parse_complete: null, diff --git a/tests/fixtures/fakeAgentEnvComputer.ts b/tests/fixtures/fakeAgentEnvComputer.ts index 1a3e07a511..b5f56db9c2 100644 --- a/tests/fixtures/fakeAgentEnvComputer.ts +++ b/tests/fixtures/fakeAgentEnvComputer.ts @@ -732,6 +732,28 @@ export interface FakeAgentEnvRunner { notes(name: string): Promise; } +/** + * JSONL notes from the fake build computer. A trailing fragment without a + * newline is still being appended. Every newline-terminated line is a record + * and must parse. + */ +export const parseAgentEnvNotes = (text: string): unknown[] => { + if (text.length === 0) return []; + const terminated = text.endsWith("\n"); + const lines = text.split("\n"); + if (terminated) lines.pop(); + const records: unknown[] = []; + for (const [index, line] of lines.entries()) { + try { + records.push(JSON.parse(line) as unknown); + } catch (error) { + if (!terminated && index === lines.length - 1) break; + throw error; + } + } + return records; +}; + export const fakeAgentEnvRunner = async ( computer: Pick, ): Promise => { @@ -769,10 +791,7 @@ export const fakeAgentEnvRunner = async ( } catch { return []; } - return (await readFile(path, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as unknown); + return parseAgentEnvNotes(await readFile(path, "utf8")); }, }; };