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:
1 parent
794932e033
commit
0785223582
8 files changed
+361
-72
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
};
|
||||
Reference in new issue
Block a user