fix(managed): surface capacity limits in member comparison

Preserve per-side capacity diagnostics in shared managed member comparisons and apply repeatable CLI type selection to both source inspections. Keep partial and historical unknown semantics intact, with one typed diagnostic projection and side schema.

Validated with 159 focused tests, final-main CLI/MCP boundary checks, schema validation and official public WPF 8.0.32/8.0.33 assemblies. Full source Evidence remains available through the retained-record MCP workflow. No target execution or budget increase.

Fixes #9021

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
This commit is contained in:
Beqa authored and GitHub committed 2026-11-06 13:23:13 +00:00
1 parent 1480a7c72e
commit fbe7532fab
10 files changed
+296 -38

No files matched your search

+2 -1
View File
@@ -796,7 +796,8 @@ from the bundle for every supported view kind. Bundles exported from MCP work
for these commands. Its inline source form cannot also select a bundle.
A missing ID is refused with `details.reason: "missing"`;
supplying a bundle does not make incompatible Evidence valid for a workflow.
`compare-managed-members` keeps its two assembly paths, and
`compare-managed-members` keeps its two assembly paths and an optional
repeatable `--type` selection applied to both sides, and
`compare-process-captures` keeps its two capture Evidence JSON paths. The bundle
option above selects an input; producer options that write a new bundle, such
as `inspect-managed-members --evidence-bundle`, retain their existing meaning.
+15
View File
@@ -280,6 +280,21 @@ inspects its two selected paths sequentially, retaining each inspection rather
than both full byte snapshots. The limit therefore remains a per-file resource
admission rule, not an aggregate comparison limit. Comparison Evidence keeps
the caller-selected paths as its parameters and artifact locations.
When either side's member inspection exceeds its representation budget, the
comparison limitations name that side and its `resource-limit` diagnostic; its
empty member collections do not establish added or removed members. Each side's
`resource_limits` preserves the reported diagnostic's code, scope, detail and
nullable offset. An empty array means no resource limit was reported; historical
comparisons omit this field, leaving those diagnostics unrecorded. Always read
the comparison coverage alongside member counts. To compare
a smaller selection, pass the same repeatable `--type` full names that
`inspect-managed-members --type` accepts to `compare-managed-members`, which
applies them to both sides and records them as `type_names`; over MCP, compare
two `inspect_managed_members` Evidence records selected with the same
`type_names`. A single selected type can still exceed the budget.
The two-record MCP workflow keeps full source Evidence available for
`inspect_analysis_view` field selection and paging; path-based CLI comparison
records the selected paths, type names and per-side diagnostics.
Input overflow returns a resource constraint. Decode or projection overflow
reports `resource-limit` with unavailable or partial coverage; omitted facts do
@@ -102,6 +102,8 @@ export const compareManagedMemberPaths = async (
input: {
readonly leftPath: string;
readonly rightPath: string;
/** Applied to both sides with inspect_managed_members selection semantics. */
readonly typeNames?: readonly string[];
},
dependencies: Partial<ManagedMemberComparisonPathDependencies> = {},
resources?: ArtifactResourceScope,
@@ -159,6 +161,9 @@ export const compareManagedMemberPaths = async (
try {
inspection = inspectManagedMembersBytes(snapshot.bytes, target, {
userStrings: false,
...(input.typeNames === undefined
? {}
: { typeNames: input.typeNames }),
});
} catch (cause: unknown) {
throw (
@@ -172,7 +177,10 @@ export const compareManagedMemberPaths = async (
}
const evidence = createEvidence(target, MANAGED_STATIC_PROVIDER, {
operation: "inspect_managed_members",
parameters: {},
parameters:
input.typeNames === undefined
? {}
: { type_names: [...input.typeNames] },
result: inspection,
rawResult: null,
limitations: inspection.limitations,
@@ -198,7 +206,7 @@ export const compareManagedMemberPaths = async (
left.evidence,
right.evidence,
result,
{ leftPath: input.leftPath, rightPath: input.rightPath },
input,
),
);
} catch (cause: unknown) {
@@ -215,7 +223,11 @@ const createManagedMemberComparisonEvidence = (
left: Evidence,
right: Evidence,
result: ManagedMemberComparisonResult,
sourcePaths?: { readonly leftPath: string; readonly rightPath: string },
sourcePaths?: {
readonly leftPath: string;
readonly rightPath: string;
readonly typeNames?: readonly string[];
},
): Evidence =>
createEvidence(undefined, MANAGED_WORKFLOW_PROVIDER, {
predicateType: "rea.managed-member-comparison",
@@ -229,6 +241,9 @@ const createManagedMemberComparisonEvidence = (
left_path: sourcePaths.leftPath,
right_path: sourcePaths.rightPath,
}),
...(sourcePaths?.typeNames === undefined
? {}
: { type_names: [...sourcePaths.typeNames] }),
},
result,
rawResult: null,
+9 -1
View File
@@ -152,7 +152,12 @@ const registerCompareManagedMembers = (
description:
"Compare managed PE/CLI member inventories by exact declared type, name, and signature; names alone never form a match",
args: compareManagedMemberArgs,
run: ({ args }) =>
options: z.object({
type: managedMembersInputSchema.shape.type_names.describe(
"Compare only members declared by this exact type full name on both sides, as selected by inspect-managed-members --type; use it when a whole-assembly comparison exceeds capacity (repeatable)",
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, "compare-managed-members", async () => {
const resources = new ArtifactResourceScope();
@@ -162,6 +167,9 @@ const registerCompareManagedMembers = (
{
leftPath: args.leftPath,
rightPath: args.rightPath,
...(options.type === undefined
? {}
: { typeNames: options.type }),
},
{},
resources,
+23 -18
View File
@@ -7,6 +7,7 @@ import { evidenceInputSchema } from "../evidenceReference.js";
import {
cliMetadataGuidSchema,
managedMetadataTokenSchema,
managedMemberInspectionSchema,
type ManagedMemberInspection,
} from "./managedArtifact.js";
import {
@@ -173,6 +174,26 @@ const fieldComparisonItemSchema = comparisonItemSchema(
memberIdentitySchema.omit({ normalized_il_sha256: true }),
);
const comparisonSideSchema = z.strictObject({
evidence_id: evidenceIdSchema,
artifact_sha256: digestSchema,
mvid: cliMetadataGuidSchema.nullable(),
module_name: z.string().nullable(),
metadata_status: z.enum(["absent", "complete", "partial", "malformed"]),
methods_total: z.int().min(0),
fields_total: z.int().min(0),
resource_limits: z
.array(
managedMemberInspectionSchema.shape.coverage.shape.issues.element.extend({
code: z.literal("resource-limit"),
}),
)
.optional()
.describe(
"Resource-limit diagnostics reported by this source; an empty array means none were reported. Historical comparisons omit this field; omission does not establish that no resource limit occurred.",
),
});
/** Obfuscation-resistant, execution-free managed member comparison. */
export const managedMemberComparisonResultSchema = z.strictObject({
comparison_id: prefixedDigestSchema("mmc"),
@@ -181,24 +202,8 @@ export const managedMemberComparisonResultSchema = z.strictObject({
token_identity: z.literal("build-local"),
name_matching: z.literal("exact-signature-fallback"),
}),
left: z.strictObject({
evidence_id: evidenceIdSchema,
artifact_sha256: digestSchema,
mvid: cliMetadataGuidSchema.nullable(),
module_name: z.string().nullable(),
metadata_status: z.enum(["absent", "complete", "partial", "malformed"]),
methods_total: z.int().min(0),
fields_total: z.int().min(0),
}),
right: z.strictObject({
evidence_id: evidenceIdSchema,
artifact_sha256: digestSchema,
mvid: cliMetadataGuidSchema.nullable(),
module_name: z.string().nullable(),
metadata_status: z.enum(["absent", "complete", "partial", "malformed"]),
methods_total: z.int().min(0),
fields_total: z.int().min(0),
}),
left: comparisonSideSchema,
right: comparisonSideSchema,
summary: z.strictObject({
unchanged: z.int().min(0),
changed: z.int().min(0),
@@ -131,6 +131,14 @@ const sideCoverageStatus = (
? "complete"
: "partial";
/** Copy the source's typed capacity facts without retaining unrelated issue inventories. */
const resourceLimits = (
result: ManagedMemberInspection,
): NonNullable<ManagedMemberComparisonResult["left"]["resource_limits"]> =>
result.coverage.issues.flatMap(({ code, scope, detail, offset }) =>
code === "resource-limit" ? [{ code, scope, detail, offset }] : [],
);
export const sideManifest = (
side: ManagedMemberComparisonSide,
): ManagedMemberComparisonResult["left"] => ({
@@ -141,6 +149,7 @@ export const sideManifest = (
metadata_status: side.result.metadata.status,
methods_total: side.result.methods.length,
fields_total: side.result.fields.length,
resource_limits: resourceLimits(side.result),
});
export const buildComparisonSummary = (
@@ -180,6 +189,27 @@ export const buildComparisonMatching = (
).length,
});
/** Carry each side's representation-budget diagnostic and its recovery. */
const capacityLimitations = (
left: ManagedMemberInspection,
right: ManagedMemberInspection,
): string[] => {
const exceeded = new Set(
(["left", "right"] as const).flatMap((side) =>
resourceLimits(side === "left" ? left : right).map(
({ scope, detail }) =>
`The ${side} managed member observation exceeded capacity at ${scope}: ${detail}. Its member collections are not examined to completion, so empty or missing members do not establish absence, addition or removal.`,
),
),
);
return exceeded.size === 0
? []
: [
...exceeded,
"Compare a smaller selection by inspecting both sides with the same type_names, or by passing --type to CLI compare-managed-members; a single selected type can still exceed capacity.",
];
};
export const comparisonLimitations = (
left: ManagedMemberInspection,
right: ManagedMemberInspection,
@@ -220,6 +250,7 @@ export const comparisonLimitations = (
);
if (left.coverage.state !== "complete" || right.coverage.state !== "complete")
limitations.push("At least one managed member observation is partial.");
limitations.push(...capacityLimitations(left, right));
if (selectionAgreement === "different")
limitations.push(
"The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.",
+5 -11
View File
@@ -8,7 +8,10 @@ import {
buildManagedNestedTypeFixture,
managedPeFixtureTarget,
} from "./ManagedPe.fixture.js";
import { buildManagedMemberCapacityFixture } from "../../tests/fixtures/managedMemberCapacity.js";
import {
buildManagedMemberCapacityFixture,
buildManagedMemberSelectionCapacityFixture,
} from "../../tests/fixtures/managedMemberCapacity.js";
import { readManagedPeLayout } from "./ManagedPeReader.js";
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import {
@@ -89,16 +92,7 @@ it("decodes a single selected type to completion on a game-scale assembly", () =
// one field and one method while the rest of the member inventory stays
// on the last type, so this also proves the selected members decode
// instead of merely proving that skipping unselected rows saves budget.
const bytes = buildManagedPeFixture({
additionalEmptyTypes: 9400,
fieldRowCount: 40037,
methodRowCount: 49738,
memberRefRowCount: 11442,
customAttributeRowCount: 58698,
distinctMemberNames: true,
firstTypeFieldRowCount: 1,
firstTypeMethodRowCount: 1,
});
const bytes = buildManagedMemberSelectionCapacityFixture();
const target = managedPeFixtureTarget(bytes);
const inventory = inspectManagedMembersBytes(bytes, target);
const selected = inventory.types.find(
@@ -16,6 +16,8 @@ import { parseEvidence } from "../../../src/domain/evidence.js";
import { managedMemberInspectionSchema } from "../../../src/domain/managed/managedArtifact.js";
import { managedMemberComparisonResultSchema } from "../../../src/domain/managed/managedMemberComparison.js";
import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js";
import { comparisonLimitations } from "../../../src/domain/managed/managedMemberComparisonCoverage.js";
import { buildManagedMemberSelectionCapacityFixture } from "../../fixtures/managedMemberCapacity.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -193,6 +195,134 @@ cliTest(
},
);
cliTest(
"surfaces one side's member capacity limit in the comparison and recovers with a shared --type selection",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-managed-capacity-");
const left = join(directory, "left.dll");
const right = join(directory, "right.dll");
await writeFile(
left,
buildManagedPeFixture({
additionalEmptyTypes: 1,
fieldRowCount: 2,
methodRowCount: 2,
firstTypeFieldRowCount: 1,
firstTypeMethodRowCount: 1,
distinctMemberNames: true,
}),
);
await writeFile(right, buildManagedMemberSelectionCapacityFixture(1));
const call = await cli.sharedMcpCall!();
const sources = [];
for (const path of [left, right]) {
const reply = await call("inspect_managed_members", { path });
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
const evidence = parseEvidence(reply.structuredContent);
sources.push({
evidence,
report: managedMemberInspectionSchema.parse(evidence.normalized_result),
});
}
const [leftSource, rightSource] = sources;
if (leftSource === undefined || rightSource === undefined)
throw new Error("Missing source inspection");
const limits = rightSource.report.coverage.issues.filter(
({ code }) => code === "resource-limit",
);
expect(limits).toEqual([
expect.objectContaining({
code: "resource-limit",
scope: "metadata.representation",
offset: null,
}),
]);
const whole = await cli.run({
arguments: ["compare-managed-members", left, right, "--json"],
});
expect(whole.exitCode, whole.stderr).toBe(0);
const wholeEvidence = parseEvidence(whole.json);
const wholeReport = managedMemberComparisonResultSchema.parse(
wholeEvidence.normalized_result,
);
expect(wholeReport.coverage).toEqual({
status: "partial",
left_status: "complete",
right_status: "partial",
});
expect(wholeReport.right.methods_total).toBe(0);
expect(wholeReport.left.resource_limits).toEqual([]);
expect(wholeReport.right.resource_limits).toEqual(limits);
expect(wholeReport.limitations).toEqual(
comparisonLimitations(leftSource.report, rightSource.report),
);
const mcpCompared = await call("compare_managed_members", {
left: leftSource.evidence,
right: rightSource.evidence,
});
expect(mcpCompared.isError, JSON.stringify(mcpCompared)).not.toBe(true);
expect(
parseEvidence(mcpCompared.structuredContent).normalized_result,
).toMatchObject({
left: { resource_limits: [] },
right: { resource_limits: limits },
coverage: wholeReport.coverage,
});
expect(wholeEvidence.limitations).toEqual(
expect.arrayContaining(wholeReport.limitations),
);
const selected = await cli.run({
arguments: [
"compare-managed-members",
left,
right,
"--type",
"Fixture.Empty0",
"--json",
],
});
expect(selected.exitCode, selected.stderr).toBe(0);
const selectedEvidence = parseEvidence(selected.json);
expect(selectedEvidence.parameters).toMatchObject({
left_path: left,
right_path: right,
type_names: ["Fixture.Empty0"],
});
const selectedReport = managedMemberComparisonResultSchema.parse(
selectedEvidence.normalized_result,
);
expect(selectedReport.coverage).toMatchObject({
left_status: "complete",
right_status: "complete",
});
expect(selectedReport.left.resource_limits).toEqual([]);
expect(selectedReport.right.resource_limits).toEqual([]);
expect(selectedReport.summary).toEqual({
unchanged: 2,
changed: 0,
added: 0,
removed: 0,
unknown: 0,
});
expect(selectedReport.fields).toMatchObject([
{
status: "unchanged",
left: { name: "Field0" },
right: { name: "Field0" },
},
]);
expect(selectedReport.methods).toMatchObject([
{
status: "unchanged",
left: { name: "Method0" },
right: { name: "Method0" },
},
]);
},
);
const managedComparisonEvidence = async () => {
const directory = await createTestTempDirectory("rea-managed-finalization-");
const path = join(directory, "comparison.dll");
@@ -248,6 +378,7 @@ it.each([true, false])(
try {
const result = await registeredComparisonCommand().run({
args: { leftPath: "left.dll", rightPath: "right.dll" },
options: {},
});
expect(result).toMatchObject({
error: "Managed member comparison failed",
@@ -296,6 +427,7 @@ it("retains completed comparison Evidence when interrupted during final cleanup"
try {
const result = await registeredComparisonCommand().run({
args: { leftPath: "left.dll", rightPath: "right.dll" },
options: {},
});
expect(result).toMatchObject({
error: "Managed member comparison failed",
@@ -17,6 +17,7 @@ import {
compareManagedMembersInputSchema,
} from "../../../../src/domain/managed/managedMemberComparison.js";
import { readManagedMemberEvidence } from "../../../../src/domain/managed/managedMemberComparisonMatch.js";
import { comparisonLimitations } from "../../../../src/domain/managed/managedMemberComparisonCoverage.js";
import { createEvidence } from "../../../../src/domain/evidence.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
@@ -94,11 +95,27 @@ it("keeps missing members unknown when the opposite inspection retains only type
);
const completePath = join(directory, "complete.dll");
const limitedPath = join(directory, "limited.dll");
await writeFile(
completePath,
buildManagedPeFixture({ additionalEmptyTypes: 2 }),
const completeBytes = buildManagedPeFixture({ additionalEmptyTypes: 2 });
const limitedBytes = buildManagedMemberCapacityFixture();
await writeFile(completePath, completeBytes);
await writeFile(limitedPath, limitedBytes);
const complete = inspectManagedMembersBytes(
completeBytes,
managedPeFixtureTarget(completeBytes, completePath),
{ userStrings: false },
);
const limited = inspectManagedMembersBytes(
limitedBytes,
managedPeFixtureTarget(limitedBytes, limitedPath),
{ userStrings: false },
);
expect(limited.coverage.issues).toContainEqual(
expect.objectContaining({
code: "resource-limit",
scope: "metadata.representation",
offset: expect.any(Number),
}),
);
await writeFile(limitedPath, buildManagedMemberCapacityFixture());
for (const [leftPath, rightPath] of [
[completePath, limitedPath],
[limitedPath, completePath],
@@ -116,6 +133,18 @@ it("keeps missing members unknown when the opposite inspection retains only type
expect(report.fields).toHaveLength(1);
expect(report.methods[0]?.status).toBe("unknown");
expect(report.fields[0]?.status).toBe("unknown");
const sources =
leftPath === completePath
? ([complete, limited] as const)
: ([limited, complete] as const);
for (const [side, source] of [
[report.left, sources[0]],
[report.right, sources[1]],
] as const)
expect(side.resource_limits).toEqual(
source.coverage.issues.filter(({ code }) => code === "resource-limit"),
);
expect(report.limitations).toEqual(comparisonLimitations(...sources));
}
});
@@ -278,6 +307,19 @@ const pairs = (comparison: ReturnType<typeof compare>) => {
const original = nestedInspection();
const swapped = nestedInspection({ swapEnclosing: true });
it("preserves unrecorded resource-limit facts in historical comparison manifests", () => {
const current = compare(original, swapped);
expect(current.left.resource_limits).toEqual([]);
expect(current.right.resource_limits).toEqual([]);
const historical = structuredClone(current);
delete historical.left.resource_limits;
delete historical.right.resource_limits;
const parsed = managedMemberComparisonResultSchema.parse(historical);
expect(parsed.left).not.toHaveProperty("resource_limits");
expect(parsed.right).not.toHaveProperty("resource_limits");
expect(parsed.summary).toEqual(current.summary);
});
it("does not equate missing nested parents with genuinely top-level types", () => {
const missing = nestedInspection({ nestedClassRows: [] });
const topLevel = nestedInspection({ topLevelTypes: true });
+15
View File
@@ -1,5 +1,20 @@
import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js";
/** Large member inventories with one small, nonempty type available for selection. */
export const buildManagedMemberSelectionCapacityFixture = (
additionalEmptyTypes = 9400,
): Buffer =>
buildManagedPeFixture({
additionalEmptyTypes,
fieldRowCount: 40037,
methodRowCount: 49738,
memberRefRowCount: 11442,
customAttributeRowCount: 58698,
distinctMemberNames: true,
firstTypeFieldRowCount: 1,
firstTypeMethodRowCount: 1,
});
/** Valid dense CIL exceeds the unchanged decoding bound, after TypeDef admission. */
export const buildManagedMemberCapacityFixture = (
options: Parameters<typeof buildManagedPeFixture>[0] = {},