diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index f12033ee0..c4cc79f81 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -21,6 +21,7 @@ import { type InspectAnalysisViewInput, } from "../../domain/analysisView/analysisView.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; +import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js"; import { err, ok, type Result } from "../../domain/result.js"; import { WebNetworkApiSurfaceAccumulator } from "../../domain/analysisView/WebNetworkApiSurfaceAccumulator.js"; @@ -66,6 +67,8 @@ export const inspectAnalysisViewValidated = ( evidenceId: parent.evidence_id, operation: parent.operation, normalizedResult: parent.normalized_result, + // Sealing an Evidence root deep-freezes it; only the root is registered. + sealedResult: isImmutableJsonSnapshot(parent), subject: parent.subject, limitations: parent.limitations, parameters: parent.parameters, diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index d676d491c..2c124bd14 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -599,6 +599,8 @@ export interface AnalysisViewParent { readonly evidenceId: string; readonly operation: string; readonly normalizedResult: unknown; + /** The owner proved normalizedResult belongs to a sealed, deep-frozen Evidence snapshot. */ + readonly sealedResult?: boolean; /** Preserve the authenticated source identity, including explicit absence. */ readonly subject?: Evidence["subject"]; readonly limitations: readonly string[]; diff --git a/src/domain/analysisView/managedMemberView.ts b/src/domain/analysisView/managedMemberView.ts index 399dd59a1..c2c54ccdf 100644 --- a/src/domain/analysisView/managedMemberView.ts +++ b/src/domain/analysisView/managedMemberView.ts @@ -139,6 +139,44 @@ const collectionPage = ( } }; +// A sealed parent result cannot change, so its validated report stays exact for +// every later page. The weak reference lets memory pressure reclaim the copy; +// unsealed input is validated on each call. +const validatedReports = new WeakMap< + object, + WeakRef +>(); + +/** Validate the parent once per sealed result instead of once per page. */ +const parseMemberReport = ({ + normalizedResult, + sealedResult, +}: AnalysisViewParent): Result => { + const sealed = + sealedResult === true && + typeof normalizedResult === "object" && + normalizedResult !== null; + const cached = sealed + ? validatedReports.get(normalizedResult)?.deref() + : undefined; + if (cached !== undefined) return ok(cached); + const parsed = managedMemberInspectionSchema.safeParse(normalizedResult); + if (!parsed.success) + return err( + analysisInputErrorFromIssues( + "inspect_analysis_view", + parsed.error.issues.map((issue) => ({ + ...issue, + path: ["source", "normalized_result", ...issue.path], + })), + { source: { normalized_result: normalizedResult } }, + { cause: parsed.error }, + ), + ); + if (sealed) validatedReports.set(normalizedResult, new WeakRef(parsed.data)); + return ok(parsed.data); +}; + /** Everything but the member collections, which are replaced by their counts. */ const summarizeMembers = ( report: ManagedMemberInspection, @@ -189,22 +227,9 @@ export const projectManagedMemberView = ( }, ]), ); - const parsed = managedMemberInspectionSchema.safeParse( - parent.normalizedResult, - ); - if (!parsed.success) - return err( - analysisInputErrorFromIssues( - "inspect_analysis_view", - parsed.error.issues.map((issue) => ({ - ...issue, - path: ["source", "normalized_result", ...issue.path], - })), - { source: { normalized_result: parent.normalizedResult } }, - { cause: parsed.error }, - ), - ); - const report = parsed.data; + const parsed = parseMemberReport(parent); + if (!parsed.ok) return parsed; + const report = parsed.value; const shared = { parent_evidence_id: parent.evidenceId, parent_operation: "inspect_managed_members" as const, diff --git a/tests/boundary/mcp/managedMemberSummaryMcp.test.ts b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts index 83afc5d0c..d3ddf0d69 100644 --- a/tests/boundary/mcp/managedMemberSummaryMcp.test.ts +++ b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts @@ -39,7 +39,43 @@ const createHarness = async () => { it("summarizes selected members and pages every original collection after the file disappears", async () => { const directory = await createTestTempDirectory("rea-member-summary-"); const path = join(directory, "fixture.dll"); - await writeFile(path, buildManagedPeFixture({ methodRowCount: 2 })); + await writeFile( + path, + buildManagedPeFixture({ + methodRowCount: 2, + // ldarg.0; ldfld field; call member ref; ldstr "ok"; pop; ret + ilBody: Buffer.from([ + (18 << 2) | 2, + 0x02, + 0x7b, + 1, + 0, + 0, + 0x04, + 0x28, + 1, + 0, + 0, + 0x0a, + 0x72, + 1, + 0, + 0, + 0x70, + 0x26, + 0x2a, + ]), + userStringHeap: Buffer.from([0, 5, 0x6f, 0, 0x6b, 0, 0]), + exportedTypes: [ + { + namespace: "System", + name: "Object", + flags: 0x0020_0001, + implementationRaw: (1 << 2) | 1, + }, + ], + }), + ); const { client, session } = await createHarness(); const definition = (await client.listTools()).tools.find( (x) => x.name === "inspect_managed_members", @@ -84,8 +120,6 @@ it("summarizes selected members and pages every original collection after the fi expect(view.summary.coverage).toEqual(report.coverage); expect(view.summary.identity_scope).toEqual(report.identity_scope); expect(view.summary).not.toHaveProperty("methods"); - expect(report.methods.length).toBeGreaterThan(0); - expect(report.member_refs.length).toBeGreaterThan(0); for (const detail of [undefined, "complete"]) { const complete = parseEvidence( ( @@ -106,6 +140,8 @@ it("summarizes selected members and pages every original collection after the fi ? report.user_strings?.literals : report[collection]; if (rows == null) throw new Error(`${collection} was not examined`); + // Empty collections would compare equal even if a row schema dropped facts. + expect(rows.length, collection).toBeGreaterThan(0); expect(view.summary.reported_counts[collection]).toBe(rows.length); const paged: unknown[] = []; for (let offset: number | null = 0; offset !== null;) {