mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user