fix(managed): share nominal scope across member comparisons

Resolve declaring TypeDef tokens within their own source and reuse the retained nominal identities for pairing, absence, and aggregate coverage. Remove display-name membership interpretation while preserving partial known pairs and whole-inventory matching.

A generated managed fixture reproduced false additions and removals for distinct nested owners sharing a display name before this change. All 305 focused producer and public CLI-to-stdio-MCP checks passed, including comparisons after source removal, missing identities, and genuine whole-inventory changes. Clarify selected-scope semantics in the docs; no live managed runtime was used.
This commit is contained in:
morluto committed 2026-10-24 07:07:06 +08:00
1 parent 794932e033
commit 0785223582
8 files changed
+361 -72

No files matched your search

+14 -7
View File
@@ -254,13 +254,20 @@ Coverage describes the selected members. Comparisons leave members outside the
opposite observation's selection unknown, and application graphs report their
selected scope as partial. Neither makes an absence claim about the whole
assembly. Older member Evidence without `selection` remains readable. The
comparison's aggregate selected-scope agreement uses retained `name_sha256`
sets, including enclosing names, rather than requested spellings or display
labels. Equivalent requests can cover the same types, while the same bare name
can select different nested owners. Incomplete selected inventories or missing
nominal identities leave that agreement unknown and aggregate coverage partial;
observed member pairs remain available. Complete empty selected inventories
agree even if their unmatched request names differ. Whole-assembly and selected
comparison resolves a member's declaring TypeDef token in its own source, then
checks that nominal `name_sha256` against the opposite selected type set.
Pairing, absence and aggregate selected-scope agreement share these retained
facts, including enclosing names, rather than requested spellings or display
labels. Equivalent requests can cover the same types, while the same display
name can select different nested owners. Such disjoint selected owners cannot
establish a member pair or added/removed status even when both inventories are
complete. Missing nominal identities cannot establish selected membership,
including historical selected Evidence that retains only display names;
the explicitly limited historical whole-inventory comparison remains available.
Incomplete selected inventories or missing identities leave aggregate agreement
unknown and coverage partial; known observed member pairs remain available.
Complete empty selected inventories agree even if their unmatched request names
differ. Whole-assembly and selected
inventories remain distinct scopes, and each source retains its exact request.
The `method_rows` interval records the examined zero-based MethodDef offset, row
count and full table total before type selection. A complete observation can
@@ -10,9 +10,8 @@ import {
} from "./managedArtifact.js";
import {
buildComparisonCoverage,
compareSelectedTypes,
hasCompleteMethodRows,
selectedDeclaringTypes,
managedComparisonScope,
buildComparisonMatching,
buildComparisonSummary,
comparisonLimitations,
@@ -260,7 +259,8 @@ export const compareManagedMembers = (
left: ManagedMemberComparisonSide,
right: ManagedMemberComparisonSide,
): ManagedMemberComparisonResult => {
const { methodMatches, fieldMatches } = keyMembers(left, right);
const scope = managedComparisonScope(left.result, right.result);
const { methodMatches, fieldMatches } = keyMembers(left, right, scope);
const itemContext = {
leftEvidenceId: left.evidenceId,
rightEvidenceId: right.evidenceId,
@@ -272,8 +272,7 @@ export const compareManagedMembers = (
rightMethodsComplete:
right.result.metadata.status === "complete" &&
hasCompleteMethodRows(right.result),
leftSelection: selectedDeclaringTypes(left.result),
rightSelection: selectedDeclaringTypes(right.result),
scope,
signatureResolutionExamined:
hasSignatureResolution(left.result) &&
hasSignatureResolution(right.result),
@@ -289,7 +288,7 @@ export const compareManagedMembers = (
const allItems = [...methodItems, ...fieldItems];
const summary = buildComparisonSummary(allItems);
const matching = buildComparisonMatching(methodItems, fieldItems);
const selectionAgreement = compareSelectedTypes(left.result, right.result);
const selectionAgreement = scope.agreement;
const coverage = buildComparisonCoverage({
left: left.result,
right: right.result,
@@ -54,38 +54,74 @@ const sameMethodRows = (
left.method_rows.count === right.method_rows.count &&
left.method_rows.total === right.method_rows.total);
/** Returned type facts, rather than request spelling, own the observed scope. */
export const selectedDeclaringTypes = (
result: ManagedMemberInspection,
): ReadonlySet<string> | null =>
result.selection === null
? null
: new Set(result.types.map(({ full_name }) => full_name));
/** One side's build-local TypeDef lookup and observed nominal selection. */
const declaringTypeScope = (result: ManagedMemberInspection) => {
const names = new Map(
result.types.map((type) => [type.token, type.name_sha256]),
);
return {
names,
selected:
result.selection === null
? null
: new Set([...names.values()].filter((name) => name !== null)),
complete:
result.coverage.state === "complete" &&
result.metadata.status === "complete" &&
result.types.every(({ name_sha256 }) => name_sha256 !== null),
};
};
type DeclaringTypeScope = ReturnType<typeof declaringTypeScope>;
type DeclaredMember =
| ManagedMemberInspection["methods"][number]
| ManagedMemberInspection["fields"][number];
/** Resolve tokens only in their own source, then test opposite nominal scope. */
const observedBy = (
own: DeclaringTypeScope,
opposite: DeclaringTypeScope,
member: DeclaredMember,
): boolean => {
if (opposite.selected === null) return true;
const name = own.names.get(member.declaring_type_token ?? "");
return name != null && opposite.selected.has(name);
};
/** Compare the nominal types the owner retained, independently of request spelling. */
export const compareSelectedTypes = (
left: ManagedMemberInspection,
right: ManagedMemberInspection,
const compareSelectedTypes = (
left: DeclaringTypeScope,
right: DeclaringTypeScope,
): SelectionAgreement => {
if (left.selection === null || right.selection === null)
return left.selection === right.selection ? "same" : "different";
if (
left.coverage.state !== "complete" ||
right.coverage.state !== "complete" ||
left.metadata.status !== "complete" ||
right.metadata.status !== "complete" ||
left.types.some(({ name_sha256 }) => name_sha256 === null) ||
right.types.some(({ name_sha256 }) => name_sha256 === null)
)
return "unknown";
const leftNames = new Set(left.types.map(({ name_sha256 }) => name_sha256));
const rightNames = new Set(right.types.map(({ name_sha256 }) => name_sha256));
const leftNames = left.selected;
const rightNames = right.selected;
if (leftNames === null || rightNames === null)
return leftNames === rightNames ? "same" : "different";
if (!left.complete || !right.complete) return "unknown";
return leftNames.size === rightNames.size &&
[...leftNames].every((name) => rightNames.has(name))
? "same"
: "different";
};
/** Share the same nominal facts across pairing, absence and scope agreement. */
export const managedComparisonScope = (
left: ManagedMemberInspection,
right: ManagedMemberInspection,
) => {
const a = declaringTypeScope(left);
const b = declaringTypeScope(right);
return {
left: a,
right: b,
inLeft: (member: DeclaredMember) => observedBy(b, a, member),
inRight: (member: DeclaredMember) => observedBy(a, b, member),
agreement: compareSelectedTypes(a, b),
};
};
export type ManagedComparisonScope = ReturnType<typeof managedComparisonScope>;
const sideCoverageStatus = (
result: ManagedMemberInspection,
): ManagedMemberComparisonResult["coverage"]["left_status"] =>
@@ -147,7 +183,8 @@ export const buildComparisonMatching = (
export const comparisonLimitations = (
left: ManagedMemberInspection,
right: ManagedMemberInspection,
selectionAgreement: SelectionAgreement = compareSelectedTypes(left, right),
selectionAgreement: SelectionAgreement = managedComparisonScope(left, right)
.agreement,
): string[] => {
const limitations: string[] = [
"Metadata tokens are build-local coordinates; matched pairs are remaps, not persistent identities.",
@@ -9,6 +9,7 @@ import type {
} from "./managedMemberComparisonMatch.js";
import { signatureIdentity } from "./managedMemberSignatureIdentity.js";
import { isManagedMemberReferenceInstruction } from "./managedMemberInstructionFacts.js";
import type { ManagedComparisonScope } from "./managedMemberComparisonCoverage.js";
type Method = ManagedMemberInspection["methods"][number];
type Field = ManagedMemberInspection["fields"][number];
@@ -38,9 +39,7 @@ interface ComparisonItemContext {
readonly rightComplete: boolean;
readonly leftMethodsComplete: boolean;
readonly rightMethodsComplete: boolean;
/** Declaring types each side selected; null when it inspected every type. */
readonly leftSelection: ReadonlySet<string> | null;
readonly rightSelection: ReadonlySet<string> | null;
readonly scope: ManagedComparisonScope;
/** Whether matching keyed signatures by resolved digests, not raw bytes. */
readonly signatureResolutionExamined: boolean;
readonly leftAssembly: ManagedMemberInspection["assembly"];
@@ -54,12 +53,9 @@ interface ComparisonItemContext {
/** Whether an inventory observed every member of this member's declaring type. */
const inventoryCovers = (
complete: boolean,
selection: ReadonlySet<string> | null,
observes: (member: Method | Field) => boolean,
member: Method | Field,
): boolean =>
complete &&
(selection === null ||
(member.declaring_type !== null && selection.has(member.declaring_type)));
): boolean => complete && observes(member);
/**
* Declared type and name of each unpaired member, per side, whose signature
@@ -443,7 +439,7 @@ const methodOnlyItem = (
const limitations = absenceLimitations(member.left, identity, {
oppositeComplete: inventoryCovers(
context.rightMethodsComplete,
context.rightSelection,
context.scope.inRight,
member.left,
),
unpaired: undecoded,
@@ -466,7 +462,7 @@ const methodOnlyItem = (
const limitations = absenceLimitations(member.right, identity, {
oppositeComplete: inventoryCovers(
context.leftMethodsComplete,
context.leftSelection,
context.scope.inLeft,
member.right,
),
unpaired: undecoded,
@@ -640,7 +636,7 @@ const fieldOnlyItem = (
const limitations = absenceLimitations(member.left, identity, {
oppositeComplete: inventoryCovers(
context.rightComplete,
context.rightSelection,
context.scope.inRight,
member.left,
),
unpaired: undecoded,
@@ -662,7 +658,7 @@ const fieldOnlyItem = (
const limitations = absenceLimitations(member.right, identity, {
oppositeComplete: inventoryCovers(
context.leftComplete,
context.leftSelection,
context.scope.inLeft,
member.right,
),
unpaired: undecoded,
@@ -1,6 +1,6 @@
import {
hasCompleteMethodRows,
selectedDeclaringTypes,
type ManagedComparisonScope,
} from "./managedMemberComparisonCoverage.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import {
@@ -492,17 +492,6 @@ const groupBy = <Item>(
return grouped;
};
/** Whether a side's inventory observed members of this declaring type. */
const observedBy = (side: ManagedMemberComparisonSide) => {
const types = selectedDeclaringTypes(side.result);
return ({
declaring_type,
}: {
readonly declaring_type: string | null;
}): boolean =>
types === null || (declaring_type !== null && types.has(declaring_type));
};
/**
* Pair only members whose declaring type both sides observed. A member outside
* the other side's selection stays one-sided, so a type selection never yields
@@ -542,18 +531,18 @@ const matchObserved = <Item extends Declared>(
export const keyMembers = (
observedLeft: ManagedMemberComparisonSide,
observedRight: ManagedMemberComparisonSide,
scope: ManagedComparisonScope,
): {
readonly methodMatches: ReturnType<typeof matchMethods>;
readonly fieldMatches: ReturnType<typeof matchFields>;
} => {
const [left, right] = comparableSides(observedLeft, observedRight);
const sides = { inLeft: observedBy(left), inRight: observedBy(right) };
const modern =
left.result.nesting_read !== null && right.result.nesting_read !== null;
const declared = (inspection: ManagedMemberInspection): DeclaredNameOf => {
const names = new Map(
inspection.types.map((type) => [type.token, type.name_sha256]),
);
const declared = (
inspection: ManagedMemberInspection,
names: ReadonlyMap<string, string | null>,
): DeclaredNameOf => {
return (item) => {
if (inspection.nesting_read === false) return null;
const digest = names.get(item.declaring_type_token ?? "");
@@ -564,13 +553,13 @@ export const keyMembers = (
return digest == null ? null : ["nominal-name-sha256", digest];
};
};
const declaredLeft = declared(left.result);
const declaredRight = declared(right.result);
const declaredLeft = declared(left.result, scope.left.names);
const declaredRight = declared(right.result, scope.right.names);
return {
methodMatches: matchObserved(
left.result.methods,
right.result.methods,
sides,
scope,
(a, b) =>
matchMethods(
a,
@@ -586,7 +575,7 @@ export const keyMembers = (
fieldMatches: matchObserved(
left.result.fields,
right.result.fields,
sides,
scope,
(a, b) =>
matchFields(
a,
+104 -1
View File
@@ -6,7 +6,10 @@ import { expect } from "vitest";
import { parseEvidence } from "../../src/domain/evidence.js";
import { managedMemberInspectionSchema } from "../../src/domain/managed/managedArtifact.js";
import { managedMemberComparisonResultSchema } from "../../src/domain/managed/managedMemberComparison.js";
import { namespaceBearingNestedTypes } from "../fixtures/managedSelectedScope.js";
import {
distinctSelectedNestedOwners,
namespaceBearingNestedTypes,
} from "../fixtures/managedSelectedScope.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
@@ -79,3 +82,103 @@ cliTest(
}
},
);
cliTest(
"preserves nominal selected membership and whole-inventory absence through CLI Evidence and stdio MCP after source removal",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-managed-nominal-membership-",
);
const bytes = distinctSelectedNestedOwners();
const selected: ReturnType<typeof parseEvidence>[] = [];
const whole: ReturnType<typeof parseEvidence>[] = [];
for (const [name, content] of Object.entries(bytes)) {
const path = join(root, `${name}.dll`);
const sha256 = createHash("sha256").update(content).digest("hex");
await writeFile(path, content);
for (const isSelected of [true, false]) {
const output = await cli.sharedCliRun!({
arguments: [
"inspect-managed-members",
path,
...(isSelected ? ["--type", "Fixture.Inner"] : []),
"--json",
],
});
expect(output.exitCode, output.stderr).toBe(0);
const evidence = parseEvidence(output.json);
const report = managedMemberInspectionSchema.parse(
evidence.normalized_result,
);
expect(report.artifact).toMatchObject({ path, sha256 });
expect(report.coverage.state).toBe("complete");
if (isSelected) {
expect(report.selection).toEqual({
type_names: ["Fixture.Inner"],
unmatched_type_names: [],
});
expect(report.types).toHaveLength(1);
expect(report.types[0]).toMatchObject({
full_name: "Fixture.Inner",
token: "0x02000004",
});
expect(report.types[0]?.name_sha256).toMatch(/^[a-f0-9]{64}$/u);
selected.push(evidence);
} else {
expect(report.selection).toBeNull();
whole.push(evidence);
}
}
expect(await readFile(path)).toEqual(content);
await rm(path);
}
const left = managedMemberInspectionSchema.parse(
selected[0]!.normalized_result,
);
const right = managedMemberInspectionSchema.parse(
selected[1]!.normalized_result,
);
expect(left.types[0]?.name_sha256).not.toBe(right.types[0]?.name_sha256);
const call = await cli.sharedMcpCall!();
for (const [sources, isSelected] of [
[selected, true],
[whole, false],
] as const) {
const response = await call("compare_managed_members", {
left: sources[0]!,
right: sources[1]!,
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const evidence = parseEvidence(response.structuredContent);
const report = managedMemberComparisonResultSchema.parse(
evidence.normalized_result,
);
expect(report.evidence_links).toEqual(
sources.map(({ evidence_id }) => evidence_id),
);
expect(report.left.artifact_sha256).toBe(left.artifact.sha256);
expect(report.right.artifact_sha256).toBe(right.artifact.sha256);
expect(report.coverage.status).toBe(
isSelected ? "partial" : "complete-within-inputs",
);
expect(report.summary).toEqual({
added: isSelected ? 0 : 5,
removed: isSelected ? 0 : 5,
unknown: isSelected ? 4 : 0,
changed: 0,
unchanged: 0,
});
if (isSelected) {
expect(report.methods).toHaveLength(2);
expect(report.fields).toHaveLength(2);
for (const item of [...report.methods, ...report.fields]) {
expect(item.match.status).toBe("unmatched");
expect(item.left?.declaring_type ?? item.right?.declaring_type).toBe(
"Fixture.Inner",
);
expect(item.evidence_links).toEqual(report.evidence_links);
}
}
}
},
);
@@ -20,7 +20,10 @@ import {
} from "../../../../src/dotnet/ManagedPe.fixture.js";
import { buildManagedPeFixture as buildMultiMethodFixture } from "../../../../scripts/fixtures/managed/pe.mjs";
import { namespaceBearingNestedTypes } from "../../../fixtures/managedSelectedScope.js";
import {
distinctSelectedNestedOwners,
namespaceBearingNestedTypes,
} from "../../../fixtures/managedSelectedScope.js";
it("selects namespace-bearing nested declarations from parsed type facts and compares the observed scope", () => {
const bytes = namespaceBearingNestedTypes();
@@ -64,6 +67,106 @@ it("selects namespace-bearing nested declarations from parsed type facts and com
).toEqual([]);
});
describe("managed comparison selected nominal membership", () => {
it("retains known selected pairs in a partial inventory without needing display labels", () => {
const source = inspect(
distinctSelectedNestedOwners().left,
"/fixture/known.dll",
["Fixture.Inner"],
);
const partial = structuredClone(source.result);
partial.metadata.status = "partial";
partial.coverage.state = "partial";
partial.methods = partial.methods.map((method) => ({
...method,
declaring_type: null,
}));
partial.fields = partial.fields.map((field) => ({
...field,
declaring_type: null,
}));
const comparison = compareManagedMembers(source, {
...source,
result: partial,
});
expect(comparison.coverage.status).toBe("partial");
expect(comparison.summary).toEqual({
added: 0,
removed: 0,
unknown: 0,
changed: 0,
unchanged: 2,
});
expect(
[...comparison.methods, ...comparison.fields].every(
({ match, right }) =>
match.status === "matched" && right?.declaring_type === null,
),
).toBe(true);
});
it.each(["current", "historical"])(
"does not recover missing %s selected identity from display names",
(kind) => {
const source = inspect(
distinctSelectedNestedOwners().left,
"/fixture/selected.dll",
["Fixture.Inner"],
);
const unknown = structuredClone(source.result);
unknown.types = unknown.types.map((type) => ({
...type,
name_sha256: null,
}));
if (kind === "historical") unknown.nesting_read = null;
const comparison = compareManagedMembers(source, {
...source,
result: unknown,
});
expect(comparison.summary).toEqual({
added: 0,
removed: 0,
unknown: 4,
changed: 0,
unchanged: 0,
});
expect(comparison.matching.unmatched).toBe(4);
expect(comparison.coverage.status).toBe("partial");
},
);
it("keeps one-sided members unknown when distinct selected owners share a display name", () => {
const bytes = distinctSelectedNestedOwners();
const left = inspect(bytes.left, "/fixture/left.dll", ["Fixture.Inner"]);
const right = inspect(bytes.right, "/fixture/right.dll", ["Fixture.Inner"]);
expect(left.result.coverage.state).toBe("complete");
expect(right.result.coverage.state).toBe("complete");
expect(left.result.types[0]?.full_name).toBe("Fixture.Inner");
expect(right.result.types[0]?.full_name).toBe("Fixture.Inner");
expect(left.result.types[0]?.name_sha256).not.toBe(
right.result.types[0]?.name_sha256,
);
const selected = compareManagedMembers(left, right);
expect(selected.coverage.status).toBe("partial");
expect(selected.summary).toEqual({
added: 0,
removed: 0,
unknown: 4,
changed: 0,
unchanged: 0,
});
expect(selected.matching.unmatched).toBe(4);
expect(selected.methods).toHaveLength(2);
expect(selected.fields).toHaveLength(2);
const whole = compareManagedMembers(
inspect(bytes.left, "/fixture/left.dll"),
inspect(bytes.right, "/fixture/right.dll"),
);
expect(whole.coverage.status).toBe("complete-within-inputs");
expect(whole.summary).toMatchObject({ added: 5, removed: 5, unknown: 0 });
});
});
describe("managed comparison selected scope agreement", () => {
it("compares retained types rather than equivalent request spellings", () => {
const bytes = namespaceBearingNestedTypes();
@@ -105,6 +208,14 @@ describe("managed comparison selected scope agreement", () => {
);
const comparison = compareManagedMembers(left, right);
expect(comparison.coverage.status).toBe("partial");
expect(comparison.summary).toEqual({
added: 0,
removed: 0,
unknown: 4,
changed: 0,
unchanged: 0,
});
expect(comparison.matching.unmatched).toBe(4);
expect(comparison.limitations).toContain(
"The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.",
);
+47
View File
@@ -4,6 +4,11 @@ import {
metadataRowOffset,
readManagedMetadataLayout,
} from "../../src/dotnet/ManagedMetadataLayout.js";
import {
metadataRowCursor,
readMetadataBlob,
} from "../../src/dotnet/ManagedMetadataHeaps.js";
import { readMethodDefinitionRow } from "../../src/dotnet/ManagedMethodDefinitionRows.js";
/** Two nested Inner declarations, one with its own Fixture namespace. */
export const namespaceBearingNestedTypes = () => {
@@ -22,3 +27,45 @@ export const namespaceBearingNestedTypes = () => {
bytes.writeUInt16LE(namespace, metadataRowOffset(layout, 2, 4) + column);
return bytes;
};
/** Same selected display, different enclosing owner, primitive signatures and CIL. */
export const distinctSelectedNestedOwners = () => {
const left = namespaceBearingNestedTypes();
const right = Buffer.from(left);
for (const [index, bytes] of [left, right].entries()) {
const pe = readManagedPeLayout(bytes);
if (pe.cli === null) throw new Error("Missing fixture CLI");
const layout = readManagedMetadataLayout(
bytes,
pe.rvaToOffset(pe.cli.metadata.rva, pe.cli.metadata.size, "fixture"),
pe.cli.metadata.size,
);
const method = readMethodDefinitionRow(bytes, layout, 1);
if (index === 1) {
bytes.writeUInt16LE(3, metadataRowOffset(layout, 41, 1) + 2);
readMetadataBlob(
bytes,
layout,
method.signatureIndex,
layout.blob!.size,
)[2] = 0x0a;
const field = metadataRowCursor(bytes, layout, 4, 1);
field.readUInt16();
field.readIndex(layout.stringIndexSize);
readMetadataBlob(
bytes,
layout,
field.readIndex(layout.blobIndexSize),
layout.blob!.size,
)[1] = 0x0a;
}
// All three MethodDefs share this fixture body and signature. Tiny bodies
// avoid unresolved MemberRefs and keep the two signatures and shapes distinct.
const body =
index === 0
? Buffer.from([0x0a, 0x17, 0x2a])
: Buffer.from([0x2a, 0x21, 2, 0, 0, 0, 0, 0, 0, 0, 0x2a]);
bytes.set(body, pe.rvaToOffset(method.rva, body.length, "fixture CIL"));
}
return { left, right };
};