perf(managed): add retained member summary and pages
inspect_managed_members always returned the assembly-wide member_refs, so one selected or unmatched type produced megabytes, and an unselected ordinary assembly exceeded the MCP response budget. Add opt-in detail: "summary" that retains the complete Evidence and returns identity, selection with unmatched names, coverage, user-string coverage, limitations and per-collection counts. Page original rows through inspect_analysis_view with kind managed-members; each page names its collection so no row schema is chosen by union order. Complete delivery stays the default. Refs #3010 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
30128a03cf
commit
54e89bd5c8
|
|
@ -169,6 +169,14 @@ repeat parsing. Returned-row exhaustion
|
|||
does not prove complete metadata or absence under partial/unavailable coverage;
|
||||
preserve build-local tokens and their artifact/MVID scope.
|
||||
|
||||
`inspect_managed_members` always returns the assembly-wide `member_refs`, even
|
||||
for one selected type. For questions about a selected type, select
|
||||
`detail: "summary"` with `type_names`; it returns the selection (with unmatched
|
||||
names), coverage and per-collection counts. Page rows with
|
||||
`view: {kind: "managed-members", collection: "call_edges", offset: 0, limit: 64}`
|
||||
over `types`, `exported_types`, `fields`, `methods`, `member_refs`, `call_edges`,
|
||||
`field_accesses` or `user_strings`.
|
||||
|
||||
### .NET NativeAOT
|
||||
|
||||
NativeAOT binaries contain native machine code, so ordinary CIL decompilers
|
||||
|
|
|
|||
|
|
@ -81,6 +81,40 @@ including the existing `inspect-analysis-view` CLI. Session record lifetime and
|
|||
export/import semantics remain unchanged. A server without Evidence retention
|
||||
rejects selected summary before analysis; complete delivery remains available.
|
||||
|
||||
### Member summary and retained member rows
|
||||
|
||||
Complete `inspect_managed_members` delivery always includes the assembly-wide
|
||||
`member_refs` and `exported_types`, which `type_names` does not narrow, so even
|
||||
one selected or unmatched type can return megabytes. Select
|
||||
`{"path":"/absolute/path/Assembly.dll","type_names":["Namespace.Type"],"detail":"summary"}`
|
||||
to keep the complete source in the session and receive identity, metadata
|
||||
status, the selection with its unmatched names, coverage/issues, the separate
|
||||
user-string coverage, limitations and per-collection counts. Then page original
|
||||
rows with view kind `managed-members`:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"kind": "retained-evidence",
|
||||
"evidence_id": "<parent_evidence_id>"
|
||||
},
|
||||
"view": {
|
||||
"kind": "managed-members",
|
||||
"collection": "call_edges",
|
||||
"offset": 0,
|
||||
"limit": 64
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Collections are `types`, `exported_types`, `fields`, `methods`, `member_refs`,
|
||||
`call_edges`, `field_accesses` and `user_strings` (the literals). Each page names
|
||||
its collection, retains original rows, order, tokens, signatures and body facts,
|
||||
and carries the source selection and coverage; `user_strings` pages carry the
|
||||
literal pool's own coverage. Older Evidence that never examined exported types
|
||||
or user strings reports a null count and refuses those pages rather than
|
||||
returning an empty one.
|
||||
|
||||
## Shipped scope
|
||||
|
||||
The canonical parser admits PE/CLI bytes and their metadata/CIL without loading
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -21,13 +21,13 @@ export const managedTargetInputSchema = z.strictObject({
|
|||
.optional(),
|
||||
});
|
||||
|
||||
/** Delivery selection belongs only to artifact inspection, not member/native queries. */
|
||||
/** Delivery selection belongs to artifact and member inspection, not native queries. */
|
||||
export const managedArtifactInputSchema = managedTargetInputSchema.extend({
|
||||
detail: analysisDetailSchema.unwrap().optional(),
|
||||
});
|
||||
|
||||
/** Member inspection may select declaring types before members are decoded. */
|
||||
export const managedMembersInputSchema = managedTargetInputSchema.extend({
|
||||
export const managedMembersInputSchema = managedArtifactInputSchema.extend({
|
||||
type_names: z
|
||||
.array(
|
||||
z
|
||||
|
|
@ -87,7 +87,7 @@ export const MANAGED_TOOL_CONTRACTS = [
|
|||
name: "inspect_managed_members",
|
||||
...toolContractMetadata("inspect_managed_members"),
|
||||
description:
|
||||
"Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, field-access anchors, and the #US string literals that ldstr loads, without loading or executing target code. Select type_names to decode only the members of those exact declaring types and the literals their bodies load, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow.",
|
||||
'Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, field-access anchors, and the #US string literals that ldstr loads, without loading or executing target code. Select type_names to decode only the members of those exact declaring types and the literals their bodies load, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow. Omitted or complete detail returns every collection, including the assembly-wide member_refs and exported_types, which type_names does not narrow. Select detail: "summary" for identity, metadata status, selection with unmatched names, coverage/issues, limitations and per-collection counts; it retains complete Evidence and returns a linked inspect_analysis_view summary. Page original rows with view kind managed-members, normalized_result.parent_evidence_id and collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings.',
|
||||
kind: "managed-provider",
|
||||
inputSchema: managedMembersInputSchema,
|
||||
outputSchema: memberOutputSchema,
|
||||
|
|
@ -103,6 +103,14 @@ export const MANAGED_TOOL_CONTRACTS = [
|
|||
type_names: ["System.Threading.Monitor"],
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Summarize selected managed types and retain their members",
|
||||
input: {
|
||||
path: "/tmp/mscorlib.dll",
|
||||
type_names: ["System.Threading.Monitor"],
|
||||
detail: "summary",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
artifactInventorySummaryViewResultSchema,
|
||||
machoSummaryViewResultSchema,
|
||||
managedArtifactSummaryViewResultSchema,
|
||||
managedMemberSummaryViewResultSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js";
|
||||
import { BINARY_ARCHITECTURES } from "../domain/binaryTargetTypes.js";
|
||||
|
|
@ -418,7 +419,12 @@ export const managedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
|||
managedArtifactSummaryViewResultSchema,
|
||||
]),
|
||||
),
|
||||
inspect_managed_members: evidenceResultOf(managedMemberInspectionSchema),
|
||||
inspect_managed_members: evidenceResultOf(
|
||||
z.union([
|
||||
managedMemberInspectionSchema,
|
||||
managedMemberSummaryViewResultSchema,
|
||||
]),
|
||||
),
|
||||
inspect_managed_native_boundaries: evidenceResultOf(
|
||||
managedNativeBoundaryInspectionSchema,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ import {
|
|||
managedSummarySchema,
|
||||
managedViewContextSchema,
|
||||
} from "./managedArtifactView.js";
|
||||
import {
|
||||
projectManagedMemberView,
|
||||
managedMembersViewRequestSchema,
|
||||
managedMembersViewPageSchema,
|
||||
managedMemberSummarySchema,
|
||||
managedMembersViewContextSchema,
|
||||
} from "./managedMemberView.js";
|
||||
import {
|
||||
projectWasmArtifactView,
|
||||
wasmViewRequestSchema,
|
||||
|
|
@ -208,6 +215,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
|||
machoViewRequestSchema,
|
||||
evmViewRequestSchema,
|
||||
managedViewRequestSchema,
|
||||
managedMembersViewRequestSchema,
|
||||
androidViewRequestSchema,
|
||||
apiSurfaceViewSchema,
|
||||
captureViewRequestSchema,
|
||||
|
|
@ -328,6 +336,7 @@ const viewResultBase = {
|
|||
"inspect_macho",
|
||||
"inspect_evm_interface",
|
||||
"inspect_managed_artifact",
|
||||
"inspect_managed_members",
|
||||
"inspect_web_network_capture",
|
||||
"inspect_android_package",
|
||||
"inspect_pcap_capture",
|
||||
|
|
@ -396,6 +405,13 @@ export const managedArtifactSummaryViewResultSchema = z.strictObject({
|
|||
summary: managedSummarySchema,
|
||||
});
|
||||
|
||||
/** Exact member selection, coverage and counts for the selected delivery. */
|
||||
export const managedMemberSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
parent_operation: z.literal("inspect_managed_members"),
|
||||
summary: managedMemberSummarySchema,
|
||||
});
|
||||
|
||||
/** Exact historical capture summary, with no body or member-inventory payload. */
|
||||
export const webNetworkCaptureSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
|
|
@ -446,6 +462,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
|||
machoSummarySchema,
|
||||
evmSummarySchema,
|
||||
managedSummarySchema,
|
||||
managedMemberSummarySchema,
|
||||
captureSummarySchema,
|
||||
androidPackageSummarySchema,
|
||||
pcapSummarySchema,
|
||||
|
|
@ -529,6 +546,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
|||
...managedViewContextSchema.shape,
|
||||
parent_operation: z.literal("inspect_managed_artifact"),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("managed-members"),
|
||||
view: managedMembersViewRequestSchema,
|
||||
page: managedMembersViewPageSchema,
|
||||
...viewResultBase,
|
||||
...managedMembersViewContextSchema.shape,
|
||||
parent_operation: z.literal("inspect_managed_members"),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("android"),
|
||||
view: androidViewRequestSchema,
|
||||
|
|
@ -622,6 +647,10 @@ export type UnsignedAnalysisView =
|
|||
Extract<AnalysisViewResult, { readonly kind: "managed" }>,
|
||||
"view_digest"
|
||||
>
|
||||
| Omit<
|
||||
Extract<AnalysisViewResult, { readonly kind: "managed-members" }>,
|
||||
"view_digest"
|
||||
>
|
||||
| Omit<
|
||||
Extract<AnalysisViewResult, { readonly kind: "android" }>,
|
||||
"view_digest"
|
||||
|
|
@ -674,7 +703,7 @@ const unsupportedParent = (
|
|||
new AnalysisUnsupportedTargetError(
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
parentArtifactPath(parent),
|
||||
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
|
||||
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, EVM, managed artifact or member, Android package, Dart AOT, or web network capture Evidence.`,
|
||||
);
|
||||
|
||||
const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||
|
|
@ -704,6 +733,7 @@ const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
|
|||
["inspect_macho", projectMachoView],
|
||||
["inspect_evm_interface", projectEvmInterfaceView],
|
||||
["inspect_managed_artifact", projectManagedArtifactView],
|
||||
["inspect_managed_members", projectManagedMemberView],
|
||||
["inspect_android_package", projectAndroidPackageView],
|
||||
["inspect_dart_aot", projectDartAotView],
|
||||
["inspect_artifact", projectArtifactInventoryView],
|
||||
|
|
|
|||
|
|
@ -51,6 +51,24 @@ export const managedViewContextSchema = z.strictObject({
|
|||
source_coverage: managedArtifactInspectionSchema.shape.coverage,
|
||||
});
|
||||
|
||||
/** Token scope and identity limits shared by every managed metadata view. */
|
||||
export const managedViewIdentityUnknowns = (
|
||||
parent: AnalysisViewParent,
|
||||
mvid: string | null,
|
||||
): string[] => [
|
||||
"Metadata tokens are build-local coordinates bound to the recorded artifact SHA-256 and module MVID; this view does not load or execute the assembly.",
|
||||
...(parent.subject == null
|
||||
? [
|
||||
"The parent has no recorded subject; its normalized artifact identity cannot be cross-checked against a subject digest.",
|
||||
]
|
||||
: []),
|
||||
...(mvid === null
|
||||
? [
|
||||
"The module MVID is unknown; tokens cannot establish a cross-observation module match.",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
/** Project authenticated metadata without reading a path or invoking a provider. */
|
||||
export const projectManagedArtifactView = (
|
||||
parent: AnalysisViewParent,
|
||||
|
|
@ -96,17 +114,7 @@ export const projectManagedArtifactView = (
|
|||
limitations: uniqueSorted([...parent.limitations, ...report.limitations]),
|
||||
unknowns: [
|
||||
"Collection counts and pages cover producer-returned rows; metadata completeness remains the original metadata status and coverage, not an absence claim about unreturned rows.",
|
||||
"Metadata tokens are build-local coordinates bound to the recorded artifact SHA-256 and module MVID; this view does not load or execute the assembly.",
|
||||
...(parent.subject == null
|
||||
? [
|
||||
"The parent has no recorded subject; its normalized artifact identity cannot be cross-checked against a subject digest.",
|
||||
]
|
||||
: []),
|
||||
...(identity_scope.requires_mvid === null
|
||||
? [
|
||||
"The module MVID is unknown; tokens cannot establish a cross-observation module match.",
|
||||
]
|
||||
: []),
|
||||
...managedViewIdentityUnknowns(parent, identity_scope.requires_mvid),
|
||||
],
|
||||
};
|
||||
if (view.kind === "summary") {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import {
|
||||
managedMemberInspectionSchema,
|
||||
type ManagedMemberInspection,
|
||||
} from "../managed/managedArtifact.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
type AnalysisViewRequest,
|
||||
type UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import {
|
||||
completeWithinViewCoverage,
|
||||
pageViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
import { managedViewIdentityUnknowns } from "./managedArtifactView.js";
|
||||
|
||||
const member = managedMemberInspectionSchema.shape;
|
||||
|
||||
/** Original member rows; offsets count entries in the returned parent collection. */
|
||||
export const managedMembersViewRequestSchema = z.strictObject({
|
||||
kind: z.literal("managed-members"),
|
||||
collection: z.enum([
|
||||
"types",
|
||||
"exported_types",
|
||||
"fields",
|
||||
"methods",
|
||||
"member_refs",
|
||||
"call_edges",
|
||||
"field_accesses",
|
||||
"user_strings",
|
||||
]),
|
||||
offset: z.number().int().nonnegative().default(0),
|
||||
limit: z.number().int().positive().default(64),
|
||||
});
|
||||
|
||||
const rowsOf = <C extends string, S extends z.ZodArray>(
|
||||
collection: C,
|
||||
rows: S,
|
||||
) => z.strictObject({ collection: z.literal(collection), rows });
|
||||
|
||||
/**
|
||||
* Rows of one collection, keyed by that collection: several row schemas accept
|
||||
* each other's rows after stripping keys, so no union may pick one by order.
|
||||
*/
|
||||
export const managedMembersViewPageSchema = z.discriminatedUnion("collection", [
|
||||
rowsOf("types", member.types),
|
||||
rowsOf("exported_types", member.exported_types.unwrap().unwrap()),
|
||||
rowsOf("fields", member.fields),
|
||||
rowsOf("methods", member.methods),
|
||||
rowsOf("member_refs", member.member_refs),
|
||||
rowsOf("call_edges", member.call_edges),
|
||||
rowsOf("field_accesses", member.field_accesses),
|
||||
rowsOf("user_strings", member.user_strings.unwrap().unwrap().shape.literals),
|
||||
]);
|
||||
|
||||
type ManagedMembersViewPage = z.infer<typeof managedMembersViewPageSchema>;
|
||||
|
||||
const nullableCount = z.number().int().nonnegative().nullable();
|
||||
|
||||
/** Keep identity, selection and coverage; member collections remain retained. */
|
||||
export const managedMemberSummarySchema = z.strictObject({
|
||||
artifact: member.artifact,
|
||||
module: member.module,
|
||||
metadata: member.metadata,
|
||||
identity_scope: member.identity_scope,
|
||||
selection: member.selection.unwrap(),
|
||||
coverage: member.coverage,
|
||||
user_strings: z
|
||||
.strictObject({
|
||||
state: member.user_strings.unwrap().unwrap().shape.state,
|
||||
issues: member.user_strings.unwrap().unwrap().shape.issues,
|
||||
})
|
||||
.nullable(),
|
||||
limitations: member.limitations,
|
||||
reported_counts: z.strictObject({
|
||||
types: z.number().int().nonnegative(),
|
||||
exported_types: nullableCount,
|
||||
fields: z.number().int().nonnegative(),
|
||||
methods: z.number().int().nonnegative(),
|
||||
member_refs: z.number().int().nonnegative(),
|
||||
call_edges: z.number().int().nonnegative(),
|
||||
field_accesses: z.number().int().nonnegative(),
|
||||
user_strings: nullableCount,
|
||||
}),
|
||||
});
|
||||
|
||||
export const managedMembersViewContextSchema = z.strictObject({
|
||||
identity_scope: member.identity_scope,
|
||||
selection: member.selection.unwrap(),
|
||||
source_metadata_status: member.metadata.shape.status,
|
||||
source_coverage: member.coverage,
|
||||
});
|
||||
|
||||
type ManagedMembersViewRequest = z.infer<
|
||||
typeof managedMembersViewRequestSchema
|
||||
>;
|
||||
type MemberCollection = ManagedMembersViewRequest["collection"];
|
||||
|
||||
const slice = <C extends MemberCollection, T>(
|
||||
collection: C,
|
||||
rows: readonly T[] | null | undefined,
|
||||
{ offset, limit }: { readonly offset: number; readonly limit: number },
|
||||
) =>
|
||||
rows == null
|
||||
? null
|
||||
: {
|
||||
page: { collection, rows: rows.slice(offset, offset + limit) },
|
||||
total: rows.length,
|
||||
};
|
||||
|
||||
/** One page of a collection, or null when the parent never examined it. */
|
||||
const collectionPage = (
|
||||
report: ManagedMemberInspection,
|
||||
view: ManagedMembersViewRequest,
|
||||
): { readonly page: ManagedMembersViewPage; readonly total: number } | null => {
|
||||
const { collection } = view;
|
||||
switch (collection) {
|
||||
case "types":
|
||||
return slice(collection, report.types, view);
|
||||
case "exported_types":
|
||||
return slice(collection, report.exported_types, view);
|
||||
case "fields":
|
||||
return slice(collection, report.fields, view);
|
||||
case "methods":
|
||||
return slice(collection, report.methods, view);
|
||||
case "member_refs":
|
||||
return slice(collection, report.member_refs, view);
|
||||
case "call_edges":
|
||||
return slice(collection, report.call_edges, view);
|
||||
case "field_accesses":
|
||||
return slice(collection, report.field_accesses, view);
|
||||
case "user_strings":
|
||||
return slice(collection, report.user_strings?.literals, view);
|
||||
}
|
||||
};
|
||||
|
||||
/** Everything but the member collections, which are replaced by their counts. */
|
||||
const summarizeMembers = (
|
||||
report: ManagedMemberInspection,
|
||||
): z.infer<typeof managedMemberSummarySchema> => {
|
||||
const {
|
||||
types,
|
||||
exported_types,
|
||||
fields,
|
||||
methods,
|
||||
member_refs,
|
||||
call_edges,
|
||||
field_accesses,
|
||||
user_strings,
|
||||
...identity
|
||||
} = report;
|
||||
return {
|
||||
...identity,
|
||||
user_strings:
|
||||
user_strings === null
|
||||
? null
|
||||
: { state: user_strings.state, issues: user_strings.issues },
|
||||
reported_counts: {
|
||||
types: types.length,
|
||||
exported_types: exported_types?.length ?? null,
|
||||
fields: fields.length,
|
||||
methods: methods.length,
|
||||
member_refs: member_refs.length,
|
||||
call_edges: call_edges.length,
|
||||
field_accesses: field_accesses.length,
|
||||
user_strings: user_strings?.literals.length ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Project authenticated member Evidence without reading a path or invoking a provider. */
|
||||
export const projectManagedMemberView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
if (view.kind !== "summary" && view.kind !== "managed-members")
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Managed member inspection supports summary and managed-members views.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
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 shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "inspect_managed_members" as const,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: { path: report.artifact.path, sha256: report.artifact.sha256 },
|
||||
limitations: uniqueSorted([...parent.limitations, ...report.limitations]),
|
||||
unknowns: [
|
||||
"Collection counts and pages cover producer-returned rows; member completeness remains the original coverage, not an absence claim about unreturned rows.",
|
||||
...(report.selection === null
|
||||
? []
|
||||
: [
|
||||
"Exported types and member references cover the whole assembly; the other collections cover only the selected declaring types.",
|
||||
]),
|
||||
...managedViewIdentityUnknowns(
|
||||
parent,
|
||||
report.identity_scope.requires_mvid,
|
||||
),
|
||||
],
|
||||
};
|
||||
if (view.kind === "summary") {
|
||||
const summary = summarizeMembers(report);
|
||||
const total = Object.values(summary.reported_counts).reduce<number>(
|
||||
(sum, count) => sum + (count ?? 0),
|
||||
0,
|
||||
);
|
||||
return ok({
|
||||
kind: "summary",
|
||||
view,
|
||||
...shared,
|
||||
summary,
|
||||
coverage: completeWithinViewCoverage(total, total),
|
||||
});
|
||||
}
|
||||
const selected = collectionPage(report, view);
|
||||
if (selected === null)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view", "collection"],
|
||||
reason: "invalid_value",
|
||||
message: `The parent Evidence did not examine ${view.collection}; repeat inspect_managed_members with the current runtime to record it.`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const { page, total } = selected;
|
||||
return ok({
|
||||
kind: "managed-members",
|
||||
view,
|
||||
page,
|
||||
...shared,
|
||||
identity_scope: report.identity_scope,
|
||||
selection: report.selection,
|
||||
source_metadata_status: report.metadata.status,
|
||||
source_coverage:
|
||||
view.collection === "user_strings" && report.user_strings !== null
|
||||
? {
|
||||
state: report.user_strings.state,
|
||||
issues: report.user_strings.issues,
|
||||
}
|
||||
: report.coverage,
|
||||
coverage: pageViewCoverage(view.offset, page.rows.length, total),
|
||||
});
|
||||
};
|
||||
|
|
@ -16,6 +16,15 @@ import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
|||
import { runManagedProviderExecution } from "../composition/directAnalysis.js";
|
||||
import { isManagedToolName } from "../contracts/managed/managedToolContracts.js";
|
||||
|
||||
/** Operations whose caller may select a retained summary instead of complete delivery. */
|
||||
const summarizesRetainedEvidence = (
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, unknown>>,
|
||||
): boolean =>
|
||||
(operation === "inspect_managed_artifact" ||
|
||||
operation === "inspect_managed_members") &&
|
||||
parameters.detail === "summary";
|
||||
|
||||
/** Register execution-free managed PE/CLI inspection. */
|
||||
export const registerManagedTools = (
|
||||
server: EvidenceMcpServer,
|
||||
|
|
@ -34,11 +43,8 @@ export const registerManagedTools = (
|
|||
execute: async (operation, parameters, executionOptions) => {
|
||||
if (!isManagedToolName(operation))
|
||||
return analysis.execute(operation, parameters, executionOptions);
|
||||
const { detail: _detail, ...artifactParameters } = parameters;
|
||||
if (
|
||||
operation === "inspect_managed_artifact" &&
|
||||
parameters.detail === "summary"
|
||||
) {
|
||||
const { detail: _detail, ...providerParameters } = parameters;
|
||||
if (summarizesRetainedEvidence(operation, parameters)) {
|
||||
const ready = requireAnalysisViewRetention(operation, {
|
||||
recordEvidence: options.recordEvidence,
|
||||
evidenceById: options.evidenceById,
|
||||
|
|
@ -69,9 +75,7 @@ export const registerManagedTools = (
|
|||
path,
|
||||
operation,
|
||||
executionOptions?.signal,
|
||||
operation === "inspect_managed_artifact"
|
||||
? artifactParameters
|
||||
: parameters,
|
||||
providerParameters,
|
||||
);
|
||||
if (execution.ok && typeof requestedPath === "string")
|
||||
selectedManagedPath = requestedPath;
|
||||
|
|
@ -82,8 +86,7 @@ export const registerManagedTools = (
|
|||
...options,
|
||||
analysisForAdmission,
|
||||
projectEvidence: (operation, parameters, evidence) =>
|
||||
operation === "inspect_managed_artifact" &&
|
||||
parameters.detail === "summary"
|
||||
summarizesRetainedEvidence(operation, parameters)
|
||||
? summarizeRetainedAnalysis(evidence, {
|
||||
recordEvidence: options.recordEvidence,
|
||||
evidenceById: options.evidenceById,
|
||||
|
|
|
|||
|
|
@ -49,16 +49,13 @@ it("returns a first-call overview and pages the unchanged source after its file
|
|||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
expect(ajv.validateSchema(definition.inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
for (const name of [
|
||||
"inspect_managed_members",
|
||||
"inspect_managed_native_boundaries",
|
||||
])
|
||||
expect(
|
||||
ajv.validate(catalog.find((x) => x.name === name)!.inputSchema, {
|
||||
path,
|
||||
detail: "summary",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
ajv.validate(
|
||||
catalog.find((x) => x.name === "inspect_managed_native_boundaries")!
|
||||
.inputSchema,
|
||||
{ path, detail: "summary" },
|
||||
),
|
||||
).toBe(false);
|
||||
const reply = await client.callTool({
|
||||
name: "inspect_managed_artifact",
|
||||
arguments: { path, detail: "summary" },
|
||||
|
|
@ -239,11 +236,15 @@ it("refuses selected delivery before file access when the server cannot retain E
|
|||
const [a, b] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(b);
|
||||
await client.connect(a);
|
||||
const rejected = await client.callTool({
|
||||
name: "inspect_managed_artifact",
|
||||
arguments: { path: join(directory, "absent.dll"), detail: "summary" },
|
||||
});
|
||||
expect(parseMcpToolError(rejected).error.code).toBe("capability_unavailable");
|
||||
for (const name of ["inspect_managed_artifact", "inspect_managed_members"]) {
|
||||
const rejected = await client.callTool({
|
||||
name,
|
||||
arguments: { path: join(directory, "absent.dll"), detail: "summary" },
|
||||
});
|
||||
expect(parseMcpToolError(rejected).error.code).toBe(
|
||||
"capability_unavailable",
|
||||
);
|
||||
}
|
||||
const complete = await client.callTool({
|
||||
name: "inspect_managed_artifact",
|
||||
arguments: { path },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
import { writeFile, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
|
||||
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
|
||||
import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js";
|
||||
import { ManagedStaticProvider } from "../../../src/dotnet/ManagedStaticProvider.js";
|
||||
import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
analysisViewResultSchema,
|
||||
managedMemberSummaryViewResultSchema,
|
||||
} from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { managedMemberInspectionSchema } from "../../../src/domain/managed/managedArtifact.js";
|
||||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const COLLECTIONS = [
|
||||
"types",
|
||||
"exported_types",
|
||||
"fields",
|
||||
"methods",
|
||||
"member_refs",
|
||||
"call_edges",
|
||||
"field_accesses",
|
||||
"user_strings",
|
||||
] as const;
|
||||
|
||||
const createHarness = async () => {
|
||||
const session = composeBinarySession(new AnalysisProviderRegistry([]), [
|
||||
new ManagedStaticProvider(),
|
||||
]);
|
||||
const harness = await createApplicationMcpHarness({ session });
|
||||
onTestFinished(harness.close);
|
||||
return harness;
|
||||
};
|
||||
|
||||
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 }));
|
||||
const { client, session } = await createHarness();
|
||||
const definition = (await client.listTools()).tools.find(
|
||||
(x) => x.name === "inspect_managed_members",
|
||||
);
|
||||
if (definition?.outputSchema === undefined)
|
||||
throw new Error("Managed member schemas are absent");
|
||||
const outputSchema: Record<string, unknown> = definition.outputSchema;
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
expect(ajv.validateSchema(definition.inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
const type_names = ["Fixture.Program", "Missing.Type"];
|
||||
expect(
|
||||
ajv.validate(definition.inputSchema, {
|
||||
path,
|
||||
type_names,
|
||||
detail: "summary",
|
||||
}),
|
||||
).toBe(true);
|
||||
const reply = await client.callTool({
|
||||
name: "inspect_managed_members",
|
||||
arguments: { path, type_names, detail: "summary" },
|
||||
});
|
||||
expect(reply.isError).not.toBe(true);
|
||||
expect(
|
||||
ajv.validate(outputSchema, reply.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
).toBe(true);
|
||||
const receipt = parseEvidence(reply.structuredContent);
|
||||
const view = managedMemberSummaryViewResultSchema.parse(
|
||||
receipt.normalized_result,
|
||||
);
|
||||
const parent = session.evidenceById(view.parent_evidence_id);
|
||||
if (parent === undefined)
|
||||
throw new Error("Complete member source was not retained");
|
||||
expect(parent.operation).toBe("inspect_managed_members");
|
||||
expect(receipt.evidence_links).toEqual([parent.evidence_id]);
|
||||
const report = managedMemberInspectionSchema.parse(parent.normalized_result);
|
||||
expect(view.summary.selection).toEqual({
|
||||
type_names,
|
||||
unmatched_type_names: ["Missing.Type"],
|
||||
});
|
||||
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(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "inspect_managed_members",
|
||||
arguments: { path, type_names, ...(detail ? { detail } : {}) },
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
expect(complete.operation).toBe("inspect_managed_members");
|
||||
expect(complete.normalized_result).toEqual(parent.normalized_result);
|
||||
}
|
||||
const original = JSON.stringify(parent);
|
||||
await unlink(path);
|
||||
for (const collection of COLLECTIONS) {
|
||||
const rows =
|
||||
collection === "user_strings"
|
||||
? report.user_strings?.literals
|
||||
: report[collection];
|
||||
if (rows == null) throw new Error(`${collection} was not examined`);
|
||||
expect(view.summary.reported_counts[collection]).toBe(rows.length);
|
||||
const paged: unknown[] = [];
|
||||
for (let offset: number | null = 0; offset !== null;) {
|
||||
const result = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: parent.evidence_id,
|
||||
},
|
||||
view: { kind: "managed-members", collection, offset, limit: 1 },
|
||||
},
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
const page = analysisViewResultSchema.parse(
|
||||
parseEvidence(result.structuredContent).normalized_result,
|
||||
);
|
||||
if (page.kind !== "managed-members")
|
||||
throw new Error("Unexpected page kind");
|
||||
expect(page.page.collection).toBe(collection);
|
||||
expect(page.selection).toEqual(report.selection);
|
||||
expect(page.coverage.total).toBe(rows.length);
|
||||
paged.push(...page.page.rows);
|
||||
offset = page.coverage.next_offset;
|
||||
}
|
||||
// Method rows carry body facts that a looser row schema would strip.
|
||||
expect(paged).toEqual(rows);
|
||||
}
|
||||
expect(JSON.stringify(session.evidenceById(parent.evidence_id))).toBe(
|
||||
original,
|
||||
);
|
||||
const portable = inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: parent },
|
||||
view: { kind: "summary" },
|
||||
});
|
||||
if (!portable.ok) throw portable.error;
|
||||
expect(portable.value.normalized_result).toEqual(view);
|
||||
}, 30000);
|
||||
|
||||
it("keeps collections that historical member Evidence never examined unknown", async () => {
|
||||
const directory = await createTestTempDirectory("rea-member-historical-");
|
||||
const path = join(directory, "fixture.dll");
|
||||
await writeFile(path, buildManagedPeFixture());
|
||||
const { client } = await createHarness();
|
||||
const parent = parseEvidence(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "inspect_managed_members",
|
||||
arguments: { path },
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
const {
|
||||
exported_types: _exported,
|
||||
user_strings: _strings,
|
||||
selection: _selection,
|
||||
...recorded
|
||||
} = managedMemberInspectionSchema.parse(parent.normalized_result);
|
||||
const historical = createEvidence(undefined, parent.provider, {
|
||||
operation: parent.operation,
|
||||
parameters: parent.parameters,
|
||||
result: recorded,
|
||||
limitations: parent.limitations,
|
||||
authority: parent.authority,
|
||||
});
|
||||
const summary = inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: historical },
|
||||
view: { kind: "summary" },
|
||||
});
|
||||
if (!summary.ok) throw summary.error;
|
||||
const view = managedMemberSummaryViewResultSchema.parse(
|
||||
summary.value.normalized_result,
|
||||
);
|
||||
expect(view.summary.reported_counts.exported_types).toBeNull();
|
||||
expect(view.summary.reported_counts.user_strings).toBeNull();
|
||||
expect(view.summary.user_strings).toBeNull();
|
||||
expect(view.summary.selection).toBeNull();
|
||||
expect(view.unknowns.join(" ")).toContain("no recorded subject");
|
||||
for (const collection of ["exported_types", "user_strings"] as const)
|
||||
expect(
|
||||
inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: historical },
|
||||
view: { kind: "managed-members", collection, offset: 0, limit: 1 },
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
expect(
|
||||
inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: historical },
|
||||
view: { kind: "managed", collection: "attributes", offset: 0, limit: 1 },
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
}, 30000);
|
||||
Loading…
Reference in New Issue