fix(managed): preserve enclosing identity in member comparisons (#3079)

Keep same-named nested members paired by their source-owned enclosing identity, including selected inventories that omit the parent TypeDefs. Share the cached, bounded nominal type-name digest owner with signature resolution; preserve current unknown identity through matching, remapping and absence instead of repairing it with display names or historical compatibility.

Verification: 457 related tests passed after preserving TypeDef visibility/parent applicability and coherent fixtures; the complete static/architecture gate and real pinned-SDK MCP nested/official .NET DLL workflows passed after a sigrok-experiment-only main refresh. Documentation checks passed after the compatibility fix. Full CoreLib inspection retains the pre-existing explicit representation limit. No target assembly execution; local verification used two CPUs and an 8 GiB RSS guard.

Co-authored-by: 8rulerstar <190670431+8rulerstar@users.noreply.github.com>
Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
JANG BYUNGKUN
2026-10-15 00:20:07 +09:00
committed by GitHub
parent eb44921c4c
commit 1438422dfe
16 changed files with 977 additions and 125 deletions
+21 -6
View File
@@ -167,7 +167,14 @@ MCP uses `inspect_managed_members` with
Repeat `--type` or supply multiple unique `type_names` for several types.
Names match the reported `full_name` exactly. Top-level names are namespace
qualified; nested types currently report bare names, which can select several
types with that name.
types with that name. Each type's `enclosing_type_token` records its enclosing
TypeDef. `nesting_read` distinguishes complete source rows (true), incomplete
current rows (false), and historical Evidence (null). A null enclosing token
alone does not establish that a type is top-level.
Each selected type also retains `name_sha256`, a framed digest of its namespace,
name and enclosing names. It uses the same bounded, cached owner as signature
type operands and remains available when selection omits the enclosing types.
It adds one fixed-size digest per returned type rather than repeated name paths.
The result preserves the requested names in `selection.type_names` and reports
unmatched names separately. If metadata names are unreadable, limitations retain
@@ -243,7 +250,14 @@ native/runtime questions retain their own authority and coverage.
Names and semantic labels do not establish durable member identity. Comparison
preserves observed differences, structural inferences, and unknowns separately.
Cross-version matching first pairs members of the same declared type and name:
Cross-version matching first pairs members of the same declared type and name.
A current declared type uses its observed `name_sha256`, so same-named nested
types in different enclosing types stay distinct even in selected inventories.
Malformed, missing, contradictory, cyclic or over-deep enclosing identity remains unknown;
the comparison preserves display names without using them to repair that gap.
Historical observations retain an explicitly limited bare-name mode when either
side predates the identity facts. A known current identity gap still remains
unknown beside a historical observation. Matching then pairs
methods by exact CIL/signature identity, then by signature, then by
structural body shape; fields by signature. Exact CIL/signature identity
requires equal raw CIL digests and decoded fingerprints. Equal decoded
@@ -253,10 +267,11 @@ renamed or moved member across declared types or names: a method by exact
CIL/signature identity or structural body shape, a field by exact decoded
signature. A remap never takes a member whose own declared type and name remain
unpaired on the other side, so a member is never taken from its own changed
counterpart. A member whose declaring type is unresolved could belong to any
type, so it pairs by name only when no other member shares its exact
CIL/signature key, never by structural shape alone, and any remap group it
joins stays ambiguous. The exact-signature key uses names only as part of that
counterpart. A member whose declared identity is unresolved could belong to any type. It
never makes a declared-identity pair, and any remap group it joins stays
ambiguous. Unresolved same-named counterparts prevent definitive added/removed
claims, while observed names and tokens remain available in the result.
The exact-signature key uses names only as part of that
full tuple; names alone never select a pair. Duplicate tuples remain ambiguous
rather than being paired by token order. Every round compares signatures by
`resolved_sha256`: the raw signature with each TypeDef or TypeRef operand
@@ -48,6 +48,7 @@ const emptyManagedMembers = (
requires_mvid: mvid,
},
types: [],
nesting_read: true,
exported_types: [],
fields: [],
methods: [],
@@ -65,6 +65,7 @@ const nullableCount = z.number().int().nonnegative().nullable();
/** Keep identity, selection and coverage; member collections remain retained. */
export const managedMemberSummarySchema = z.strictObject({
signature_resolution: member.signature_resolution,
nesting_read: member.nesting_read,
artifact: member.artifact,
module: member.module,
metadata: member.metadata,
@@ -92,6 +93,7 @@ export const managedMemberSummarySchema = z.strictObject({
export const managedMembersViewContextSchema = z.strictObject({
signature_resolution: member.signature_resolution,
nesting_read: member.nesting_read,
identity_scope: member.identity_scope,
selection: member.selection.unwrap(),
source_metadata_status: member.metadata.shape.status,
@@ -272,6 +274,7 @@ export const projectManagedMemberView = (
page,
...shared,
signature_resolution: report.signature_resolution,
nesting_read: report.nesting_read,
identity_scope: report.identity_scope,
selection: report.selection,
source_metadata_status: report.metadata.status,
+22
View File
@@ -103,8 +103,20 @@ const managedTypeSchema = z.object({
namespace: z.string(),
name: z.string(),
full_name: z.string(),
name_sha256: digestSchema
.nullable()
.default(null)
.describe(
"Framed nominal namespace/name/enclosing-name digest, independent of build-local rows. Null for historical Evidence or unresolved nesting; defining assemblies and CLR runtime identity are not established.",
),
flags: z.number().int().min(0).max(0xffff_ffff),
extends_token: tokenSchema.nullable(),
enclosing_type_token: tokenSchema
.nullable()
.default(null)
.describe(
"Observed enclosing TypeDef from NestedClass; null when no enclosing entry was available, which alone does not establish top-level identity.",
),
field_list: z.object({
first_row: z.number().int().min(1).nullable(),
last_row: z.number().int().min(0).nullable(),
@@ -473,6 +485,16 @@ export const managedMemberInspectionSchema = z.object({
requires_mvid: cliMetadataGuidSchema.nullable(),
}),
types: z.array(managedTypeSchema),
// Member Evidence recorded before NestedClass rows were read omits this
// field; null is historical uncertainty. False means a current NestedClass
// row was unreadable or invalid, not that no nested types exist.
nesting_read: z
.boolean()
.nullable()
.default(null)
.describe(
"Whether all NestedClass rows were read and valid. Null for historical Evidence; false means current nesting is incomplete. Individual name digests can remain unresolved for missing, cyclic or over-deep enclosing chains.",
),
// Member Evidence recorded before ExportedType rows were read omits this
// field; null means the table was not examined, not that it was empty.
exported_types: z.array(managedExportedTypeSchema).nullable().default(null),
@@ -144,6 +144,10 @@ export const comparisonLimitations = (
limitations.push(
"At least one managed member observation does not attest signature type-name resolution, so signatures compare by raw bytes, which name types by build-local metadata row.",
);
if (left.nesting_read === null || right.nesting_read === null)
limitations.push(
"At least one managed member observation predates enclosing-name identity. The legacy bare-name comparison does not establish the enclosing owner of a nested type.",
);
if (left.exported_types === null || right.exported_types === null)
limitations.push(
"At least one managed member observation predates ExportedType inspection; its exported and forwarded types are unknown.",
@@ -5,7 +5,6 @@ import type { ManagedMemberComparisonResult } from "./managedMemberComparison.js
import {
type ManagedFieldMatches,
type ManagedMethodMatches,
identityOf,
sha256,
} from "./managedMemberComparisonMatch.js";
import { signatureIdentity } from "./managedMemberSignatureIdentity.js";
@@ -54,6 +53,9 @@ interface UndecodedOneSidedNames {
readonly resolved: boolean;
readonly undecoded: Readonly<Record<"left" | "right", ReadonlySet<string>>>;
readonly unresolved: Readonly<Record<"left" | "right", ReadonlySet<string>>>;
readonly unknownOwners: Readonly<
Record<"left" | "right", ReadonlySet<string>>
>;
}
const methodDimensions = (
@@ -144,17 +146,20 @@ const unmatchedComparison = (): UnmatchedComparison => ({
const methodOnlyItem = (
member: OneSided<Method>,
identity: string,
identityUnavailable: boolean,
context: ComparisonItemContext,
undecoded: UndecodedOneSidedNames,
): MethodItem => {
if (member.left !== undefined) {
const limitations = absenceLimitations(member.left, {
const limitations = absenceLimitations(member.left, identity, {
oppositeComplete: inventoryCovers(
context.rightComplete,
context.rightSelection,
member.left,
),
unpaired: undecoded,
ownIdentityUnavailable: identityUnavailable,
opposite: "right",
inventory: "method",
});
@@ -170,13 +175,14 @@ const methodOnlyItem = (
};
}
const limitations = absenceLimitations(member.right, {
const limitations = absenceLimitations(member.right, identity, {
oppositeComplete: inventoryCovers(
context.leftComplete,
context.leftSelection,
member.right,
),
unpaired: undecoded,
ownIdentityUnavailable: identityUnavailable,
opposite: "left",
inventory: "method",
});
@@ -204,13 +210,25 @@ const methodOnlyItem = (
*/
const absenceLimitations = (
member: Method | Field,
identity: string,
opposite: {
readonly oppositeComplete: boolean;
readonly ownIdentityUnavailable: boolean;
readonly unpaired: UndecodedOneSidedNames;
readonly opposite: "left" | "right";
readonly inventory: "method" | "field";
},
): string[] => [
...(opposite.ownIdentityUnavailable
? [
"declaring-type-name-not-resolved: Declared type identity could not be resolved; a bare display name cannot establish a counterpart or absence.",
]
: []),
...(opposite.unpaired.unknownOwners[opposite.opposite].has(member.name)
? [
`counterpart-declaring-type-name-not-resolved: The ${opposite.opposite} ${opposite.inventory} inventory has an unpaired same-named member with unresolved enclosing-name identity, so absence was not established.`,
]
: []),
...(opposite.oppositeComplete
? []
: [
@@ -222,7 +240,7 @@ const absenceLimitations = (
`signature-not-decoded: The member's ${member.signature.parse_status} signature was compared only by exact declared type, name, and signature bytes, so a changed or renamed counterpart could not be sought.`,
]),
...(member.signature.parse_status === "decoded" &&
opposite.unpaired.undecoded[opposite.opposite].has(identityOf(member))
opposite.unpaired.undecoded[opposite.opposite].has(identity)
? [
`counterpart-signature-not-decoded: The ${opposite.opposite} ${opposite.inventory} inventory has an unpaired member with the same declared type and name whose signature was not decoded, so it may be this member with a changed signature.`,
]
@@ -234,7 +252,7 @@ const absenceLimitations = (
: []),
...(member.signature.parse_status === "decoded" &&
!unresolved(member, opposite.unpaired.resolved) &&
opposite.unpaired.unresolved[opposite.opposite].has(identityOf(member))
opposite.unpaired.unresolved[opposite.opposite].has(identity)
? [
`counterpart-signature-not-resolved: The ${opposite.opposite} ${opposite.inventory} inventory has an unpaired member with the same declared type and name whose signature names a type REA could not name, so it may be this member.`,
]
@@ -266,6 +284,7 @@ const pairedSignatureLimitations = (
interface Unpaired {
readonly item: Method | Field;
readonly identity: string;
readonly identityUnavailable: boolean;
}
const undecodedOneSidedNames = (
@@ -301,6 +320,16 @@ const undecodedOneSidedNames = (
const notResolved = (item: Method | Field) => unresolved(item, resolved);
return {
resolved,
unknownOwners: {
left: new Set(
sides.left.filter((x) => x.identityUnavailable).map((x) => x.item.name),
),
right: new Set(
sides.right
.filter((x) => x.identityUnavailable)
.map((x) => x.item.name),
),
},
undecoded: {
left: names(sides.left, undecoded),
right: names(sides.right, undecoded),
@@ -314,17 +343,20 @@ const undecodedOneSidedNames = (
const fieldOnlyItem = (
member: OneSided<Field>,
identity: string,
identityUnavailable: boolean,
context: ComparisonItemContext,
undecoded: UndecodedOneSidedNames,
): FieldItem => {
if (member.left !== undefined) {
const limitations = absenceLimitations(member.left, {
const limitations = absenceLimitations(member.left, identity, {
oppositeComplete: inventoryCovers(
context.rightComplete,
context.rightSelection,
member.left,
),
unpaired: undecoded,
ownIdentityUnavailable: identityUnavailable,
opposite: "right",
inventory: "field",
});
@@ -339,13 +371,14 @@ const fieldOnlyItem = (
};
}
const limitations = absenceLimitations(member.right, {
const limitations = absenceLimitations(member.right, identity, {
oppositeComplete: inventoryCovers(
context.leftComplete,
context.leftSelection,
member.right,
),
unpaired: undecoded,
ownIdentityUnavailable: identityUnavailable,
opposite: "left",
inventory: "field",
});
@@ -367,14 +400,17 @@ const fieldOnlyItem = (
const ambiguityLimitation = (
members: "methods" | "fields",
basis: MethodItem["match"]["basis"],
identityUnavailable: boolean,
): string =>
basis === "exact-signature"
? `Multiple managed ${members} share the same signature; REA did not guess declared ownership or a token remap.`
: basis === "exact-il-signature"
? "Multiple managed methods share the same CIL and signature; REA did not guess a token remap."
: members === "methods"
? "Multiple managed methods share the same structural shape; REA did not guess a token remap."
: "Multiple managed fields share the same signature; REA did not guess a token remap.";
identityUnavailable
? "declaring-type-name-not-resolved: Declared type identity remains unresolved; REA did not guess a counterpart from display names, signatures or CIL."
: basis === "exact-signature"
? `Multiple managed ${members} share the same signature; REA did not guess declared ownership or a token remap.`
: basis === "exact-il-signature"
? "Multiple managed methods share the same CIL and signature; REA did not guess a token remap."
: members === "methods"
? "Multiple managed methods share the same structural shape; REA did not guess a token remap."
: "Multiple managed fields share the same signature; REA did not guess a token remap.";
export const buildMethodItems = (
matches: ManagedMethodMatches,
@@ -446,7 +482,14 @@ export const buildMethodItems = (
},
dimensions: ["availability"],
evidence_links: [context.leftEvidenceId, context.rightEvidenceId],
limitations: [ambiguityLimitation("methods", ambiguous.basis)],
limitations: [
ambiguityLimitation(
"methods",
ambiguous.basis,
ambiguous.left.some((item) => item.identityUnavailable) ||
ambiguous.right.some((item) => item.identityUnavailable),
),
],
});
}
const undecoded = undecodedOneSidedNames(
@@ -454,9 +497,25 @@ export const buildMethodItems = (
context.signatureResolutionExamined,
);
for (const item of matches.leftOnly)
items.push(methodOnlyItem({ left: item.item }, context, undecoded));
items.push(
methodOnlyItem(
{ left: item.item },
item.identity,
item.identityUnavailable,
context,
undecoded,
),
);
for (const item of matches.rightOnly)
items.push(methodOnlyItem({ right: item.item }, context, undecoded));
items.push(
methodOnlyItem(
{ right: item.item },
item.identity,
item.identityUnavailable,
context,
undecoded,
),
);
return items;
};
@@ -515,15 +574,38 @@ export const buildFieldItems = (
candidate_right_tokens: ambiguous.right.map(({ item }) => item.token),
},
evidence_links: [context.leftEvidenceId, context.rightEvidenceId],
limitations: [ambiguityLimitation("fields", ambiguous.basis)],
limitations: [
ambiguityLimitation(
"fields",
ambiguous.basis,
ambiguous.left.some((item) => item.identityUnavailable) ||
ambiguous.right.some((item) => item.identityUnavailable),
),
],
});
const undecoded = undecodedOneSidedNames(
matches,
context.signatureResolutionExamined,
);
for (const item of matches.leftOnly)
items.push(fieldOnlyItem({ left: item.item }, context, undecoded));
items.push(
fieldOnlyItem(
{ left: item.item },
item.identity,
item.identityUnavailable,
context,
undecoded,
),
);
for (const item of matches.rightOnly)
items.push(fieldOnlyItem({ right: item.item }, context, undecoded));
items.push(
fieldOnlyItem(
{ right: item.item },
item.identity,
item.identityUnavailable,
context,
undecoded,
),
);
return items;
};
@@ -35,6 +35,8 @@ interface Keyed<Item> {
readonly item: Item;
/** Declared type and name; an unresolved type is null, the name remains. */
readonly identity: string;
/** Declared identity could not be resolved; display facts remain. */
readonly identityUnavailable: boolean;
readonly exactKey: string | null;
readonly signatureKey: string | null;
/** The name-free exact signature, resolved where both sides resolve it. */
@@ -55,9 +57,13 @@ interface Ambiguous<Item> {
readonly basis: ConcreteMatchBasis;
}
const keyMethod = (item: Method): Keyed<Method> => ({
/** A declared type: a historical bare name or a tagged observed nominal digest. */
type DeclaredName = string | string[] | null;
type DeclaredNameOf = (item: Method | Field) => DeclaredName;
const keyMethod = (item: Method, declared: DeclaredNameOf): Keyed<Method> => ({
item,
identity: identityOf(item),
identity: identityOf({ declaring_type: declared(item), name: item.name }),
identityUnavailable: declared(item) === null,
signatureId: signatureIdentity(item.signature),
exactKey:
item.body.status === "present" &&
@@ -72,12 +78,15 @@ const keyMethod = (item: Method): Keyed<Method> => ({
: null,
// The blob, with type operands named, is the exact signature; decoding
// only gates the shape keys.
signatureKey: sha256([
"method-signature",
item.declaring_type,
item.name,
signatureIdentity(item.signature),
]),
signatureKey:
declared(item) === null
? null
: sha256([
"method-signature",
declared(item),
item.name,
signatureIdentity(item.signature),
]),
structuralKey:
item.signature.parse_status === "decoded" && item.body.status === "present"
? sha256([
@@ -106,9 +115,10 @@ const keyMethod = (item: Method): Keyed<Method> => ({
: null,
});
const keyField = (item: Field): Keyed<Field> => ({
const keyField = (item: Field, declared: DeclaredNameOf): Keyed<Field> => ({
item,
identity: identityOf(item),
identity: identityOf({ declaring_type: declared(item), name: item.name }),
identityUnavailable: declared(item) === null,
signatureId: signatureIdentity(item.signature),
exactKey:
item.signature.parse_status === "decoded"
@@ -116,12 +126,15 @@ const keyField = (item: Field): Keyed<Field> => ({
: null,
// Only an undecoded field reaches this round: a decoded field with the
// same signature already met its counterpart in the exact round.
signatureKey: sha256([
"field-signature",
item.declaring_type,
item.name,
signatureIdentity(item.signature),
]),
signatureKey:
declared(item) === null
? null
: sha256([
"field-signature",
declared(item),
item.name,
signatureIdentity(item.signature),
]),
structuralKey: sha256([
"field-structural",
item.signature.kind,
@@ -151,7 +164,7 @@ const matchMethods = (
basis: "structural-method-shape",
// An unresolved owner has no declared shape match to prove.
key: declaredAs((keyed) =>
keyed.item.declaring_type === null ? null : keyed.structuralKey,
keyed.identityUnavailable ? null : keyed.structuralKey,
),
competitionKey: ({ structuralKey }) => structuralKey,
},
@@ -222,7 +235,7 @@ export const identityOf = ({
declaring_type,
name,
}: {
readonly declaring_type: string | null;
readonly declaring_type: DeclaredName;
readonly name: string;
}): string => sha256([declaring_type, name]);
@@ -231,7 +244,9 @@ const declaredAs =
<Item>(key: (item: Keyed<Item>) => string | null) =>
(item: Keyed<Item>): string | null => {
const value = key(item);
return value === null ? null : sha256([item.identity, value]);
return value === null || item.identityUnavailable
? null
: sha256([item.identity, value]);
};
const matchByKeys = <Item extends Declared>({
@@ -254,7 +269,7 @@ const matchByKeys = <Item extends Declared>({
const competitionKey = round.competitionKey ?? ((item) => item.exactKey);
const unpairedLeft = unpairedOf(left, pairedLeft);
const unpairedRight = unpairedOf(right, pairedRight);
const declarable = ownerlessDeclarable(
const declarable = declaredCandidates(
unpairedLeft,
unpairedRight,
competitionKey,
@@ -288,7 +303,7 @@ const matchByKeys = <Item extends Declared>({
const refused =
round.remap === true &&
[...leftItems, ...rightItems].some(
({ item }) => item.declaring_type === null,
({ identityUnavailable }) => identityUnavailable,
);
if (!refused && leftItems.length === 1 && rightItems.length === 1) {
const [leftItem] = leftItems;
@@ -401,7 +416,7 @@ const ownerlessContested = <Item extends Declared>(
const key = competitionIdentityKey(member, keyOf(member));
if (key === null) continue;
seen.set(key, (seen.get(key) ?? 0) + 1);
if (member.item.declaring_type === null) ownerless.add(key);
if (member.identityUnavailable) ownerless.add(key);
}
for (const key of ownerless)
if ((seen.get(key) ?? 0) > 1) contested.add(key);
@@ -429,44 +444,23 @@ const remappable = <Item extends Declared>(
return (
(key !== null &&
contested.has(competitionIdentityKey(member, key) ?? "")) ||
member.item.declaring_type === null ||
member.identityUnavailable ||
!kept.has(member.identity)
);
});
};
/**
* Whether a member may pair in a declared-identity round. A member with an
* unresolved owner may, only when no other member on either side shares its
* selected evidence key: its owner might be any of them, so a shared key
* leaves it to the remap rounds, which keep such a group ambiguous. The
* resolved members that share such a key stay out too, so they cannot pair and
* consume the candidate the ownerless member might own.
*/
const ownerlessDeclarable = <Item extends Declared>(
/** A declared round needs a resolved owner and no unresolved contender. */
const declaredCandidates = <Item extends Declared>(
left: readonly Keyed<Item>[],
right: readonly Keyed<Item>[],
keyOf: (item: Keyed<Item>) => string | null,
): ((member: Keyed<Item>) => boolean) => {
const count = (side: readonly Keyed<Item>[]) => {
const seen = new Map<string, number>();
for (const member of side) {
const key = keyOf(member);
if (key !== null) seen.set(key, (seen.get(key) ?? 0) + 1);
}
return seen;
};
const leftSeen = count(left);
const rightSeen = count(right);
const contested = ownerlessContested([left, right], keyOf);
return (member) => {
const key = keyOf(member);
return key !== null &&
contested.has(competitionIdentityKey(member, key) ?? "")
? false
: member.item.declaring_type !== null ||
key === null ||
((leftSeen.get(key) ?? 0) <= 1 && (rightSeen.get(key) ?? 0) <= 1);
if (member.identityUnavailable) return false;
const key = competitionIdentityKey(member, keyOf(member));
return key === null || !contested.has(key);
};
};
@@ -509,21 +503,22 @@ const matchObserved = <Item extends Declared>(
left: readonly Keyed<Item>[],
right: readonly Keyed<Item>[],
) => ReturnType<typeof matchByKeys<Item>>,
key: (item: Item) => Keyed<Item>,
keyLeft: (item: Item) => Keyed<Item>,
keyRight: (item: Item) => Keyed<Item>,
): ReturnType<typeof matchByKeys<Item>> => {
const matched = match(
left.filter(sides.inRight).map(key),
right.filter(sides.inLeft).map(key),
left.filter(sides.inRight).map(keyLeft),
right.filter(sides.inLeft).map(keyRight),
);
return {
...matched,
leftOnly: [
...matched.leftOnly,
...left.filter((item) => !sides.inRight(item)).map(key),
...left.filter((item) => !sides.inRight(item)).map(keyLeft),
],
rightOnly: [
...matched.rightOnly,
...right.filter((item) => !sides.inLeft(item)).map(key),
...right.filter((item) => !sides.inLeft(item)).map(keyRight),
],
};
};
@@ -537,20 +532,40 @@ export const keyMembers = (
} => {
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]),
);
return (item) => {
if (inspection.nesting_read === false) return null;
const digest = names.get(item.declaring_type_token ?? "");
// A historical counterpart cannot repair an observed current name gap.
if (inspection.nesting_read !== null && digest == null) return null;
// Historical observations retain their explicitly limited bare-name mode.
if (!modern) return item.declaring_type;
return digest == null ? null : ["nominal-name-sha256", digest];
};
};
const declaredLeft = declared(left.result);
const declaredRight = declared(right.result);
return {
methodMatches: matchObserved(
left.result.methods,
right.result.methods,
sides,
matchMethods,
keyMethod,
(item) => keyMethod(item, declaredLeft),
(item) => keyMethod(item, declaredRight),
),
fieldMatches: matchObserved(
left.result.fields,
right.result.fields,
sides,
matchFields,
keyField,
(item) => keyField(item, declaredLeft),
(item) => keyField(item, declaredRight),
),
};
};
+106
View File
@@ -21,6 +21,7 @@ import { comparisonLimitations } from "../domain/managed/managedMemberComparison
import { buildManagedPeFixture as buildMultiMethodManagedPeFixture } from "../../scripts/fixtures/managed/pe.mjs";
import {
buildManagedNestedTypeFixture,
buildManagedPeFixture,
buildNativePeFixture,
fixtureUserStringHeap,
@@ -525,6 +526,111 @@ describe("managed MethodSpec call targets", () => {
});
});
describe("managed nested types", () => {
const inspectNested = (
options: Parameters<typeof buildManagedNestedTypeFixture>[0] = {},
) => {
const bytes = buildManagedNestedTypeFixture(options);
return inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes, "/fixture/nested.dll"),
);
};
it("reports the enclosing type of each nested type", () => {
const result = inspectNested();
expect(
result.types.map(({ full_name, enclosing_type_token }) => [
full_name,
enclosing_type_token,
]),
).toEqual([
["<Module>", null],
["Fixture.A", null],
["Fixture.B", null],
["Inner", "0x02000002"],
["Inner", "0x02000003"],
["Leaf", "0x02000004"],
]);
expect(result.nesting_read).toBe(true);
expect(result.coverage.issues).toEqual([]);
});
it.each([2, 3, 4, 5, 6, 7])(
"keeps nested visibility %i unresolved when its parent entry is missing",
(visibility) => {
const bytes = buildManagedNestedTypeFixture({ nestedClassRows: [] });
const target = () => managedPeFixtureTarget(bytes, "/fixture/nested.dll");
const row = inspectManagedMembersBytes(bytes, target()).types.find(
(type) => type.token === "0x02000004",
)!.row_offset;
bytes.writeUInt32LE((bytes.readUInt32LE(row) & ~7) | visibility, row);
const result = inspectManagedMembersBytes(bytes, target());
expect(result.nesting_read).toBe(true);
expect(
result.types.find((type) => type.token === "0x02000004"),
).toMatchObject({ enclosing_type_token: null, name_sha256: null });
expect(result.coverage.issues).toContainEqual(
expect.objectContaining({
code: "invalid-row",
scope: "metadata.TypeDef:0x02000004",
offset: row,
}),
);
},
);
it("retains a selected child's contradictory parent issue exactly once", () => {
const bytes = buildManagedNestedTypeFixture();
const target = () => managedPeFixtureTarget(bytes, "/fixture/nested.dll");
const row = inspectManagedMembersBytes(bytes, target()).types.find(
(type) => type.token === "0x02000004",
)!.row_offset;
bytes.writeUInt32LE((bytes.readUInt32LE(row) & ~7) | 1, row);
const result = inspectManagedMembersBytes(bytes, target(), {
typeNames: ["Leaf"],
});
expect(result.types).toHaveLength(1);
expect(result.types[0]?.name_sha256).toBeNull();
expect(result.coverage.state).toBe("partial");
expect(
result.coverage.issues.filter(
(issue) => issue.scope === "metadata.TypeDef:0x02000004",
),
).toEqual([expect.objectContaining({ code: "invalid-row", offset: row })]);
});
it("reports NestedClass rows that do not pair distinct existing types once", () => {
const result = inspectNested({
nestedClassRows: [
[4, 4],
[5, 3],
[5, 2],
[6, 9],
[0, 2],
[4, 0],
[9, 2],
],
});
expect(
result.types.map(({ enclosing_type_token }) => enclosing_type_token),
).toEqual([null, null, null, null, "0x02000003", null]);
expect(result.nesting_read).toBe(false);
expect(
result.coverage.issues.map(({ code, scope }) => [code, scope]),
).toEqual([
["invalid-row", "metadata.NestedClass:0x29000001"],
["invalid-row", "metadata.NestedClass:0x29000003"],
["invalid-row", "metadata.NestedClass:0x29000004"],
["invalid-row", "metadata.NestedClass:0x29000005"],
["invalid-row", "metadata.NestedClass:0x29000006"],
["invalid-row", "metadata.NestedClass:0x29000007"],
]);
});
});
describe("managed exported types", () => {
it("reports type forwarders and nested exported types with their implementation scope", () => {
const bytes = buildManagedPeFixture({
+27 -7
View File
@@ -29,7 +29,7 @@ import {
type TypeRange,
} from "./ManagedMemberInspectorCore.js";
import { edgeTargets } from "./ManagedMethodSpecs.js";
import { signatureTypeNames } from "./ManagedSignatureTypeNames.js";
import { managedTypeNameDigests } from "./ManagedTypeNameDigests.js";
import { typeNameLookup } from "./ManagedTypeNames.js";
import { managedTypeReferences } from "./ManagedTypeReferences.js";
import { parseUserStrings } from "./ManagedUserStrings.js";
@@ -75,6 +75,7 @@ const unavailable = (
},
types: [],
signature_resolution: "type-names-v1",
nesting_read: false,
exported_types: null,
user_strings: null,
fields: [],
@@ -208,7 +209,7 @@ export const inspectManagedMembersBytes = (
const exportedTypes = parseExportedTypes(bytes, layout);
appendManagedParseIssues(issues, exportedTypes.issues);
const typeReferences = managedTypeReferences(bytes, layout, issues);
const signatureTypeName = signatureTypeNames(
const signatureTypeName = managedTypeNameDigests(
layout,
issues,
parsedTypes,
@@ -251,7 +252,6 @@ export const inspectManagedMembersBytes = (
methods.core,
]);
appendManagedParseIssues(issues, targets.issues);
const coverageIssues = uniqueManagedParseIssues(issues);
const incompleteMembers =
[...fields.fields, ...memberRefs.refs, ...methods.methods].some(
(member) => member.signature.parse_status !== "decoded",
@@ -266,6 +266,23 @@ export const inspectManagedMembersBytes = (
targets.members,
managedDecodeBudget(layout),
);
const types = parsedTypes.types
.filter((type) => selected === null || selected.has(type.token))
.map((type) => {
managedDecodeBudget(layout).reserve(512, type.row_offset);
return {
...type,
name_sha256:
signatureTypeName(
Number.parseInt(type.token.slice(4), 16) * 4,
)?.toString("hex") ?? null,
};
});
const unnamedTypes = types.reduce(
(count, type) => count + Number(type.name_sha256 === null),
0,
);
const coverageIssues = uniqueManagedParseIssues(issues);
return managedMemberInspectionSchema.parse(
admitManagedProjection(
{
@@ -286,11 +303,9 @@ export const inspectManagedMembersBytes = (
requires_artifact_sha256: target.sha256,
requires_mvid: inventory.module?.mvid ?? null,
},
types:
selected === null
? parsedTypes.types
: parsedTypes.types.filter((type) => selected.has(type.token)),
types,
signature_resolution: "type-names-v1",
nesting_read: parsedTypes.nesting.complete,
exported_types: exportedTypes.exportedTypes,
fields: fields.fields,
methods: methods.methods,
@@ -307,6 +322,11 @@ export const inspectManagedMembersBytes = (
issues: coverageIssues,
},
limitations: [
...(unnamedTypes === 0
? []
: [
`${String(unnamedTypes)} returned TypeDef name identity(s) remain unresolved; incomplete, missing, contradictory, cyclic or over-deep enclosing facts cannot be repaired by bare display names.`,
]),
"Metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.",
"CIL instruction anchors are decoded from file-backed method bodies only; no target assembly is loaded or executed.",
"Exported types are reported from ExportedType rows as declared; forwarded types are not resolved in their implementing assemblies.",
+8
View File
@@ -157,6 +157,10 @@ export const typeRanges = (
return ranges;
};
/**
* Enclosing TypeDef row of each nested TypeDef row, from the NestedClass
* table. Nesting is complete only when every row was read and valid.
*/
export const parseTypes = (
bytes: Buffer,
layout: ManagedMetadataLayout,
@@ -216,6 +220,7 @@ export const parseTypes = (
offset: extendsOffset,
detail: `TypeDef Extends coded index 0x${extendsRaw.toString(16)} is invalid: ${extendsReason}`,
});
const enclosingRow = nesting.enclosing.get(row);
items.push({
token: metadataToken(2, row),
row_offset: cursor.start,
@@ -229,6 +234,9 @@ export const parseTypes = (
[2, 1, 27],
layout.rowCounts,
),
enclosing_type_token:
enclosingRow === undefined ? null : metadataToken(2, enclosingRow),
name_sha256: null,
field_list: {
first_row: fieldRange.first,
last_row: fieldRange.last,
+132 -1
View File
@@ -826,6 +826,9 @@ const fixtureMetadataRows = ({
indexes,
heapSizes,
});
const nestedRows = new Set(
(options.nestedTypes ?? []).map(([nested]) => nested),
);
const rows = new Map<number, readonly Buffer[]>([
[
0,
@@ -836,9 +839,10 @@ const fixtureMetadataRows = ({
typeRefTable(options, indexes, heapSizes, widths),
[
2,
[...indexes.additionalTypeNames, indexes.typeName].map((name) =>
[...indexes.additionalTypeNames, indexes.typeName].map((name, index) =>
typeDefRow({
name,
flags: nestedRows.has(index + 1) ? 0x0010_0002 : 0x0010_0001,
namespace: indexes.typeNamespace,
fieldList: 1,
methodList: 1,
@@ -1326,6 +1330,133 @@ export const buildManagedMemberPointerFixture = (
return image;
};
/**
* Two top-level types A and B, each enclosing a nested type named Inner with
* one method M and one field F; the first Inner also encloses Leaf with a
* method M. Every method shares the fixture body. Swapping the enclosing
* types keeps every row but moves each Inner, with its Leaf, under the other
* top-level type. NestedClass rows pair a nested TypeDef row with its
* enclosing row and may be replaced to exercise malformed metadata.
*/
export const buildManagedNestedTypeFixture = ({
swapEnclosing = false,
nestedClassRows,
literalTopLevelName,
topLevelTypes = false,
}: {
readonly swapEnclosing?: boolean;
readonly nestedClassRows?: readonly (readonly [number, number])[];
readonly literalTopLevelName?: string;
readonly topLevelTypes?: boolean;
} = {}): Buffer => {
const image = buildManagedFixtureImage({}, (methodRva) => {
const strings = new StringHeap();
const blobs = new BlobHeap();
const moduleName = strings.add("Nested.dll");
const assemblyName = strings.add("Fixture.Nested");
const namespace = strings.add("Fixture");
const typeNames = [
"<Module>",
"A",
"B",
"Inner",
literalTopLevelName ?? (topLevelTypes ? "OtherInner" : "Inner"),
"Leaf",
].map((name) => strings.add(name));
const methodName = strings.add("M");
const fieldName = strings.add("F");
const objectName = strings.add("Object");
const systemNamespace = strings.add("System");
const runtimeName = strings.add("System.Runtime");
const methodSignature = blobs.add(Buffer.from([0x20, 0, 0x08]));
const fieldSignature = blobs.add(Buffer.from([0x06, 0x08]));
// TypeDef rows 4 and 5 own one method and one field each; row 6 owns a method.
const memberList = [1, 1, 1, 1, 2, 3];
const rows = new Map<number, readonly Buffer[]>([
[0, [moduleRow(moduleName, 2, 2)]],
[
1,
[
Buffer.concat([
u16((1 << 2) | 2),
u16(objectName),
u16(systemNamespace),
]),
],
],
[
2,
typeNames.map((name, index) =>
typeDefRow({
name,
namespace: index === 1 || index === 2 ? namespace : 0,
fieldList: Math.min(memberList[index] ?? 1, 3),
methodList: memberList[index] ?? 1,
stringSize: 2,
typeDefOrRefSize: 2,
fieldListSize: 2,
methodListSize: 2,
extendsRaw: index === 0 ? 0 : (1 << 2) | 1,
flags:
index <= 2 ||
topLevelTypes ||
(index === 4 && literalTopLevelName !== undefined)
? 0x0010_0001
: 0x0010_0002,
}),
),
],
[4, [1, 2].map(() => fieldRow(fieldName, fieldSignature, 2, 2))],
[
6,
[1, 2, 3].map(() =>
methodDefRow({
name: methodName,
signature: methodSignature,
stringSize: 2,
blobSize: 2,
parameterSize: 2,
parameterList: 1,
rva: methodRva,
flags: 0x0086,
}),
),
],
[
41,
(
nestedClassRows ??
(topLevelTypes
? []
: [
[4, swapEnclosing ? 3 : 2],
[5, swapEnclosing ? 2 : 3],
[6, 4],
])
)
.filter(
([nested]) => literalTopLevelName === undefined || nested !== 5,
)
.map(([nested, enclosing]) =>
Buffer.concat([u16(nested), u16(enclosing)]),
),
],
[32, [assemblyRow(assemblyName, 2, 2)]],
[35, [assemblyRefRow(runtimeName, 0, false, 2, 2)]],
]);
return metadataRoot({
tables: tablesStream(rows, 0n, 0),
strings: strings.toBuffer(),
guid: guidHeap(),
blob: blobs.toBuffer(),
});
});
// The shared PE shell defaults to an executable; this fixture is a DLL.
image.writeUInt16LE(image.readUInt16LE(0x84 + 18) | 0x2000, 0x84 + 18);
image.writeUInt32LE(0, 0x0200 + 20);
return image;
};
/** Build a syntactically valid PE32 fixture with no CLI directory. */
export const buildNativePeFixture = (): Buffer => {
const image = buildManagedPeFixture();
@@ -21,11 +21,11 @@ import {
managedPeFixtureTarget,
} from "./ManagedPe.fixture.js";
import { readManagedPeLayout } from "./ManagedPeReader.js";
import { signatureTypeNames } from "./ManagedSignatureTypeNames.js";
import { managedTypeNameDigests } from "./ManagedTypeNameDigests.js";
import { managedTypeReferences } from "./ManagedTypeReferences.js";
import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js";
// A top-level type's name digest, as ManagedSignatureTypeNames forms it.
// A top-level type's name digest, as ManagedTypeNameDigests forms it.
const nameDigest = (name: string): Buffer => {
const dot = name.lastIndexOf(".");
return createHash("sha256")
@@ -127,7 +127,7 @@ describe("resolved member signature digests", () => {
const layout = metadataLayout(bytes);
const budget = new ManagedDecodeBudget();
attachManagedDecodeBudget(layout, budget);
const typeName = signatureTypeNames(
const typeName = managedTypeNameDigests(
layout,
[],
{
@@ -176,7 +176,7 @@ describe("resolved member signature digests", () => {
layout,
typeRanges(bytes, layout, issues, readTypeMemberListFacts(bytes, layout)),
);
const resolve = signatureTypeNames(
const resolve = managedTypeNameDigests(
layout,
issues,
parsedTypes,
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import {
MAX_TYPE_NESTING_DEPTH,
nestedTypeDigests,
} from "./ManagedSignatureTypeNames.js";
} from "./ManagedTypeNameDigests.js";
/** Rows 1..length, each nested in the row before it. */
const chain = (length: number) => {
@@ -137,11 +137,11 @@ export const nestedTypeDigests = (
* the same nominal name, and a TypeRef's resolution scope assembly is not part of its
* name, so a type forwarded between assemblies keeps its name. A TypeSpec, an
* unreadable row, cyclic nesting, or nesting deeper than the cap has no name,
* so its signature has no resolved digest; an unreadable row's failure is
* so its declaration and signature operands have no resolved digest; an unreadable row's failure is
* retained in `issues`, once per row. Each coded index and each row is
* named and digested once, so repeated operands cost a lookup.
*/
export const signatureTypeNames = (
export const managedTypeNameDigests = (
layout: ManagedMetadataLayout,
issues: ManagedParseIssue[],
parsedTypes: Pick<ReturnType<typeof parseTypes>, "types" | "nesting">,
@@ -168,10 +168,27 @@ export const signatureTypeNames = (
return null;
}
if (type === undefined) return null;
const enclosingRow = parsedTypes.nesting.enclosing.get(row) ?? null;
// ECMA-335 TypeAttributes.VisibilityMask: 0/1 are top-level, 2..7 nested.
const nested = (type.flags & 0x7) >= 2;
if (nested !== (enclosingRow !== null)) {
managedDecodeBudget(layout).reserve(512, type.row_offset);
readManagedValue(() => {
throw managedFailure(
"invalid-row",
`metadata.TypeDef:${type.token}`,
nested
? `Nested TypeDef ${type.token} has no NestedClass enclosing entry`
: `Top-level TypeDef ${type.token} has a NestedClass enclosing entry`,
type.row_offset,
);
}, issues);
return null;
}
return {
namespace: type.namespace,
name: type.name,
enclosingRow: parsedTypes.nesting.enclosing.get(row) ?? null,
enclosingRow,
};
}, retain);
const references = nestedTypeDigests((row) => {
@@ -20,7 +20,10 @@ import { jsonValueSchema } from "../../../../src/domain/jsonValue.js";
import { compareManagedMembersInputSchema } from "../../../../src/domain/managed/managedMemberComparison.js";
import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js";
import { managedPeFixtureTarget } from "../../../../src/dotnet/ManagedPe.fixture.js";
import { buildManagedPeFixture } from "../../../../src/dotnet/ManagedPe.fixture.js";
import {
buildManagedNestedTypeFixture,
buildManagedPeFixture,
} from "../../../../src/dotnet/ManagedPe.fixture.js";
import {
MAX_MANAGED_SNAPSHOT_BYTES,
readManagedSnapshot,
@@ -62,6 +65,356 @@ it.each([
},
);
type Inspection = ReturnType<typeof inspectManagedMembersBytes>;
const nestedInspection = (
options: Parameters<typeof buildManagedNestedTypeFixture>[0] = {},
typeNames?: readonly string[],
) => {
const bytes = buildManagedNestedTypeFixture(options);
const target = managedPeFixtureTarget(
bytes,
`/fixture/nested-${JSON.stringify(options)}.dll`,
);
return {
target,
inspection: inspectManagedMembersBytes(
bytes,
target,
typeNames === undefined ? {} : { typeNames },
),
};
};
// Historical Evidence has neither nesting facts nor declared-name digests.
const recordedBefore = ({ nesting_read, types, ...rest }: Inspection) => ({
...rest,
types: types.map(({ enclosing_type_token, name_sha256, ...type }) => type),
});
const compare = (
left: ReturnType<typeof nestedInspection>,
right: ReturnType<typeof nestedInspection>,
edit: {
readonly left?: (inspection: Inspection) => unknown;
readonly right?: (inspection: Inspection) => unknown;
} = {},
) => {
const observe = (
{ target, inspection }: ReturnType<typeof nestedInspection>,
change: ((inspection: Inspection) => unknown) | undefined,
) =>
createEvidence(
target,
{ id: "nested-fixture", name: "Nested fixture", version: "1" },
{
operation: "inspect_managed_members",
parameters: {},
result: jsonValueSchema.parse(change?.(inspection) ?? inspection),
confidence: "observed",
authority: "shipped-artifact",
},
);
const result = compareManagedMembersEvidenceValidated(
compareManagedMembersInputSchema.parse({
left: observe(left, edit.left),
right: observe(right, edit.right),
}),
);
if (!result.ok) throw result.error;
return managedMemberComparisonResultSchema.parse(
result.value.normalized_result,
);
};
const withCilDigests =
(byToken: Readonly<Record<string, string>>) => (inspection: Inspection) => ({
...inspection,
methods: inspection.methods.map((method) => {
const digest = byToken[method.token];
return digest === undefined || method.body.status !== "present"
? method
: {
...method,
body: {
...method.body,
il_sha256: digest,
normalized_il_sha256: digest,
},
};
}),
});
const pairs = (comparison: ReturnType<typeof compare>) => {
const of = (
items: readonly {
readonly left: { readonly token: string } | null;
readonly right: { readonly token: string } | null;
readonly status: string;
readonly match: { readonly basis: string; readonly confidence: string };
}[],
) =>
items.map(({ left, right, status, match }) => [
left?.token ?? null,
right?.token ?? null,
status,
match.basis,
match.confidence,
]);
return { methods: of(comparison.methods), fields: of(comparison.fields) };
};
const original = nestedInspection();
const swapped = nestedInspection({ swapEnclosing: true });
it("does not equate missing nested parents with genuinely top-level types", () => {
const missing = nestedInspection({ nestedClassRows: [] });
const topLevel = nestedInspection({ topLevelTypes: true });
const result = compare(missing, topLevel);
expect(result.summary.unchanged).toBe(0);
expect(
[...result.methods, ...result.fields].every(
({ status }) => status === "unknown",
),
).toBe(true);
});
it.each(["left", "right"] as const)(
"preserves a current enclosing-cycle gap beside %s historical Evidence",
(historicalSide) => {
const cyclic = nestedInspection({
nestedClassRows: [
[4, 5],
[5, 4],
[6, 4],
],
});
expect(cyclic.inspection.nesting_read).toBe(true);
const result =
historicalSide === "left"
? compare(original, cyclic, { left: recordedBefore })
: compare(cyclic, original, { right: recordedBefore });
expect(result.summary.unchanged).toBe(0);
expect(
[...result.methods, ...result.fields].every(
({ status }) => status === "unknown",
),
).toBe(true);
},
);
describe("managed member comparison of nested types", () => {
it("uses owned type identity while preserving omitted display labels", () => {
const result = compare(original, swapped, {
left: (inspection) => ({
...inspection,
methods: inspection.methods.map((method) => ({
...method,
declaring_type: null,
})),
fields: inspection.fields.map((field) => ({
...field,
declaring_type: null,
})),
}),
});
expect(pairs(result)).toEqual(pairs(compare(original, swapped)));
expect(
[...result.methods, ...result.fields].every(
(item) => item.left?.declaring_type === null,
),
).toBe(true);
});
it("retains nested identity when selected inventories omit enclosing types", () => {
const left = nestedInspection({}, ["Inner"]);
const right = nestedInspection({ swapEnclosing: true }, ["Inner"]);
expect(left.inspection.types.map(({ full_name }) => full_name)).toEqual([
"Inner",
"Inner",
]);
expect(pairs(compare(left, right))).toEqual({
methods: pairs(compare(original, swapped)).methods.slice(0, 2),
fields: pairs(compare(original, swapped)).fields,
});
});
it("withholds unchanged claims when current nesting rows are malformed", () => {
const malformed = nestedInspection({
swapEnclosing: true,
nestedClassRows: [
[4, 3],
[5, 2],
[6, 4],
[6, 5],
],
});
const comparison = compare(original, malformed);
expect(comparison.methods.every(({ status }) => status === "unknown")).toBe(
true,
);
expect(comparison.fields.every(({ status }) => status === "unknown")).toBe(
true,
);
});
it("withholds unchanged claims for a current enclosing cycle", () => {
const cyclic = nestedInspection({
nestedClassRows: [
[4, 5],
[5, 4],
[6, 4],
],
});
const comparison = compare(original, cyclic);
expect(comparison.methods.every(({ status }) => status === "unknown")).toBe(
true,
);
expect(comparison.fields.every(({ status }) => status === "unknown")).toBe(
true,
);
});
it("pairs members of same-named nested types by their enclosing types", () => {
// Moving each Inner under the other top-level type turns Fixture.A+Inner
// from TypeDef 4 on the left into TypeDef 5 on the right.
expect(pairs(compare(original, swapped))).toEqual({
methods: [
[
"0x06000001",
"0x06000002",
"unchanged",
"exact-il-signature",
"exact",
],
[
"0x06000002",
"0x06000001",
"unchanged",
"exact-il-signature",
"exact",
],
[
"0x06000003",
"0x06000003",
"unchanged",
"exact-il-signature",
"exact",
],
],
fields: [
["0x04000001", "0x04000002", "unchanged", "field-signature", "exact"],
["0x04000002", "0x04000001", "unchanged", "field-signature", "exact"],
],
});
});
it("reports bodies swapped between same-named nested types as changed", () => {
// Give each Inner.M its own CIL, then swap the bodies between A and B.
// Fixture.A+Inner.M is MethodDef 1 on the left and MethodDef 2 on the right.
const comparison = compare(original, swapped, {
left: withCilDigests({
"0x06000001": "a".repeat(64),
"0x06000002": "b".repeat(64),
}),
right: withCilDigests({
"0x06000002": "b".repeat(64),
"0x06000001": "a".repeat(64),
}),
});
expect(pairs(comparison).methods.slice(0, 2)).toEqual([
["0x06000001", "0x06000002", "changed", "exact-signature", "exact"],
["0x06000002", "0x06000001", "changed", "exact-signature", "exact"],
]);
expect(
comparison.methods.slice(0, 2).map(({ dimensions }) => dimensions),
).toEqual([["cil"], ["cil"]]);
expect(comparison.summary).toMatchObject({ changed: 2 });
});
it("retains explicitly limited bare names for historical observations", () => {
const bare = pairs(
compare(original, swapped, {
left: recordedBefore,
right: recordedBefore,
}),
);
expect(bare).not.toEqual(pairs(compare(original, swapped)));
expect(pairs(compare(original, swapped, { left: recordedBefore }))).toEqual(
bare,
);
expect(
pairs(compare(original, swapped, { right: recordedBefore })),
).toEqual(bare);
expect(
compare(original, swapped, { left: recordedBefore }).limitations,
).toContainEqual(expect.stringContaining("legacy bare-name comparison"));
});
it("does not pair a nested type with a top-level type of the same name", () => {
const topLevel = nestedInspection({ topLevelTypes: true });
expect(
topLevel.inspection.types.find((type) => type.token === "0x02000004")
?.name_sha256,
).toMatch(/^[a-f0-9]{64}$/u);
expect(
pairs(compare(original, topLevel)).methods.filter(
([left, right]) => left !== null && right !== null,
),
).toEqual([]);
});
});
describe("managed nested type names", () => {
it("keeps a top-level name containing the separator apart from nesting", () => {
// Produce the literal plus sign in PE metadata, which C# identifiers disallow.
const literal = nestedInspection({
literalTopLevelName: "Fixture.A+Inner",
});
const spelled = compare(original, literal);
expect(
literal.inspection.types.find((type) => type.token === "0x02000005"),
).toMatchObject({
full_name: "Fixture.A+Inner",
enclosing_type_token: null,
});
// Joined names would make both sides' Fixture.A+Inner one ambiguous group.
expect(pairs(spelled).methods).toEqual([
["0x06000001", "0x06000001", "unchanged", "exact-il-signature", "exact"],
["0x06000003", "0x06000003", "unchanged", "exact-il-signature", "exact"],
["0x06000002", "0x06000002", "unchanged", "exact-il-signature", "exact"],
]);
});
it("warns of an undecoded counterpart only for the same enclosing type", () => {
// Keep only Fixture.B+Inner.M on the right, with an undecoded signature.
const comparison = compare(original, original, {
right: (inspection) => ({
...inspection,
methods: inspection.methods
.filter(({ token }) => token === "0x06000002")
.map((method) => ({
...method,
signature: {
...method.signature,
raw_sha256: "c".repeat(64),
resolved_sha256: null,
parse_status: "malformed",
},
})),
}),
});
const warned = comparison.methods.map(({ left, right, limitations }) => [
left?.token ?? null,
right?.token ?? null,
limitations.some((limitation) =>
limitation.startsWith("counterpart-signature-not-decoded"),
),
]);
expect(warned).toContainEqual(["0x06000001", null, false]);
expect(warned).toContainEqual(["0x06000002", null, true]);
});
});
describe("managed member comparison subject identity", () => {
it("rejects authenticated member Evidence whose subject digest differs from its artifact", () => {
const bytes = buildManagedPeFixture();
@@ -986,6 +986,10 @@ describe("managed member comparison of unresolved and held identities", () => {
...base,
result: {
...base.result,
types: [
...base.result.types,
namedTypeFact("Twin", "Other", "0x02000003"),
],
methods: [
{ ...method, declaring_type: null, declaring_type_token: null },
{
@@ -1120,6 +1124,12 @@ describe("managed member comparison of consumed ownerless ambiguity", () => {
);
const [method] = base.result.methods;
if (method === undefined) throw new Error("Expected a fixture method");
const ownerTypes = new Map(
["T", "D", "U"].map((name, index) => [
name,
namedTypeFact(name, "", `0x0200000${String(index + 3)}`),
]),
);
const candidate = (
raw: string,
token: string,
@@ -1130,7 +1140,8 @@ describe("managed member comparison of consumed ownerless ambiguity", () => {
...method,
token,
declaring_type: owner,
declaring_type_token: owner === null ? null : method.declaring_type_token,
declaring_type_token:
owner === null ? null : ownerTypes.get(owner)!.token,
name,
// An operand-free signature: its resolved digest is the raw one.
signature: {
@@ -1148,6 +1159,7 @@ describe("managed member comparison of consumed ownerless ambiguity", () => {
...base,
result: {
...base.result,
types: [...base.result.types, ...ownerTypes.values()],
fields: [],
methods: [
candidate("a", "0x06000001", "T", "M", 1),
@@ -1160,6 +1172,7 @@ describe("managed member comparison of consumed ownerless ambiguity", () => {
...base,
result: {
...base.result,
types: [...base.result.types, ...ownerTypes.values()],
fields: [],
methods: [
candidate("c", "0x06000001", "U", "N", 1),
@@ -1210,6 +1223,10 @@ describe("managed member comparison of retained ownership contention", () => {
...base,
result: {
...base.result,
types: [
...base.result.types,
namedTypeFact("Twin", "Other", "0x02000003"),
],
fields: [],
methods: [
{ ...method, body: body("c", 3) },
@@ -1217,6 +1234,7 @@ describe("managed member comparison of retained ownership contention", () => {
...method,
token: "0x06000002",
declaring_type: "Other.Twin",
declaring_type_token: "0x02000003",
name: "Copy",
body: ownerless.body,
},
@@ -1309,6 +1327,45 @@ describe("managed member comparison of ownerless structural competitors", () =>
});
});
it("keeps a lone ownerless member unknown despite an identical payload", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-lone.dll");
const [method] = base.result.methods;
if (method === undefined) throw new Error("Expected a fixture method");
const ownerless = {
...method,
declaring_type: null,
declaring_type_token: null,
};
const side = {
...base,
result: { ...base.result, methods: [ownerless], fields: [] },
};
const result = compareManagedMembers(side, side);
expect(result.matching.ambiguous).toBe(1);
expect(
result.methods.filter(({ match }) => match.status === "matched"),
).toHaveLength(0);
expect(result.methods[0]).toMatchObject({
status: "unknown",
match: {
status: "ambiguous",
candidate_left_tokens: [ownerless.token],
candidate_right_tokens: [ownerless.token],
},
limitations: [
expect.stringContaining("Declared type identity remains unresolved"),
],
});
expect(result.summary).toMatchObject({
unchanged: 0,
added: 0,
removed: 0,
unknown: 1,
});
});
describe("managed member comparison of ownerless members", () => {
it("keeps an ownerless member ambiguous beside a resolved copy, as main does", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-copy.dll");
@@ -1346,28 +1403,6 @@ describe("managed member comparison of ownerless members", () => {
}
});
it("still pairs a lone ownerless member with its lone counterpart", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-lone.dll");
const [method] = base.result.methods;
if (method === undefined) throw new Error("Expected a fixture method");
const ownerless = {
...method,
declaring_type: null,
declaring_type_token: null,
};
const side = {
...base,
result: { ...base.result, methods: [ownerless], fields: [] },
};
const result = compareManagedMembers(side, side);
expect(result.matching.ambiguous).toBe(0);
expect(
result.methods.filter(({ match }) => match.status === "matched"),
).toHaveLength(1);
});
it("keeps a resolved copy out of the declared round when an ownerless competitor is on the left, as main does", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-left.dll");
const [method] = base.result.methods;
@@ -1536,6 +1571,10 @@ describe("managed member comparison of unresolved owners", () => {
...held,
result: {
...held.result,
types: [
...held.result.types,
namedTypeFact("Twin", "Other", "0x02000003"),
],
methods: [
undecodedMethod,
{ ...undecodedMethod, token: "0x06000002" },
@@ -1576,8 +1615,17 @@ describe("managed member comparison of unresolved owners", () => {
...base,
result: {
...base.result,
types: [
...base.result.types,
namedTypeFact("Twin", "Other", "0x02000003"),
],
methods: [
{ ...method, declaring_type: "Other.Twin", token: "0x06000002" },
{
...method,
declaring_type: "Other.Twin",
declaring_type_token: "0x02000003",
token: "0x06000002",
},
],
fields: [],
},
@@ -2152,6 +2200,22 @@ describe("managed member comparison of resolved-signature contention", () => {
* type, to an inspection. The copies come from `source`, so they can carry
* the bytes of a different build than the inspection they join.
*/
const namedTypeFact = (
typeName: string,
typeNamespace: string,
token: string,
) => {
const type = inspect(
buildManagedPeFixture({ typeName, typeNamespace }),
`/fixture/${typeNamespace}/${typeName}.dll`,
).result.types.find(
(type) => type.name === typeName && type.namespace === typeNamespace,
);
if (type === undefined || type.name_sha256 === null)
throw new Error("Expected a named fixture TypeDef");
return { ...type, token };
};
const withTwin = (
inspected: ReturnType<typeof inspect>,
source: ReturnType<typeof inspect> = inspected,
@@ -2162,14 +2226,25 @@ const withTwin = (
if (method === undefined || field === undefined)
throw new Error("Expected a fixture method and field");
const rows = Array.from({ length: count }, (_, index) => index + 2);
// This consumer fixture needs matching TypeDef facts as well as member
// facts. Obtain nominal digests from the production metadata owner; copying
// only display names leaves declaring_type_token pointing at a missing row.
const types = rows.map((row) =>
namedTypeFact(
row === 2 ? "Twin" : `Twin${String(row)}`,
"Other",
`0x0200000${String(row + 1)}`,
),
);
const twin = (row: number) => ({
declaring_type: row === 2 ? "Other.Twin" : `Other.Twin${String(row)}`,
declaring_type_token: `0x0200000${String(row + 1)}`,
declaring_type: types[row - 2]!.full_name,
declaring_type_token: types[row - 2]!.token,
});
return {
...inspected,
result: {
...inspected.result,
types: [...inspected.result.types, ...types],
methods: [
...inspected.result.methods,
...rows.map((row) => ({