fix(managed): pair members by declared identity before name-free remaps (#2116)

* fix(managed): pair members by declared identity before name-free remaps

The first matching round keyed methods on signature and normalized CIL,
and fields on signature, without the declared type or name. Identical
members of different types fell into one ambiguous group, so a file
compared with itself reported unknowns, and a lone match across types
was reported as an unchanged exact pair while the member's own
counterpart went unpaired.

Pair by declared type, name, and exact CIL/signature first, then by
declared type, name, and raw signature. The name-free CIL/signature,
field-signature, and structural rounds now run last, as remaps, and
skip a pair across declared identities while either member's own type
and name remain unpaired on the other side.

Nested types that share a bare name still share one declared identity,
so their identical members stay ambiguous in a self-comparison.

* fix(managed): keep members with kept identities out of remap rounds

The cross-identity check applied only to one-to-one groups. A member
whose own declared type and name were still unpaired on the other side
could fall into a larger remap group and be reported ambiguous, and a
blocked candidate could make an otherwise unique remap ambiguous.

A remap round now drops such members before grouping, along with
members whose declaring type is unresolved. Same-identity structural
pairs get their own round before the remaps. Each member's declared
identity is computed once.

Ambiguity limitations now name the evidence a group shares, which
stays true whether the round keyed on names or not.

* fix(managed): keep names in unresolved identities and hold ambiguous ones

A member of an unresolved declaring type had no declared identity, so
the declared-identity rounds keyed it by CIL or signature alone. Two
unresolved members with different names paired as an exact match, and
a file compared with itself reported unresolved members ambiguous.

The declared identity now always includes the name, with a null type
for an unresolved one. Remap rounds still leave such members out.

A remap round now also leaves out a member whose own identity sits in
an ambiguous group on the other side, not only one left one-sided.

The undecoded-counterpart check uses the same declared identity.

* fix(managed): keep remap groups with unresolved owners ambiguous

Remap rounds dropped members of an unresolved declaring type before
grouping. Such a member can be another member's own counterpart, so a
resolved member could remap to a copy in another type while its
unresolved counterpart was reported added.

Unresolved members now join remap grouping, and a group that contains
one stays ambiguous instead of pairing.

The undecoded-counterpart set reuses each member's computed identity.

* fix(managed): keep ownerless members out of declared rounds when shared

A member whose declaring type did not resolve has a declared identity
of a null type and its name. The declared rounds paired two such
same-named members even when the other side also held a resolved
member with the same CIL and signature, which could be the owner. The
old name-free first round reported that group as ambiguous.

An ownerless member now enters the declared rounds only when no other
member on either side shares its exact CIL/signature key, and never
the declared structural round. Otherwise it reaches the remap rounds,
which keep any group with an unresolved member ambiguous.

* fix(managed): keep competitors of an ownerless member out of the declared round

When an ownerless member shares its exact CIL/signature key with another
member on the same side, its owner may be that member. The resolved
members holding the key now stay out of the declared round too, so they
cannot pair and consume the candidate the ownerless member may own, and
the remap round keeps the whole group ambiguous as main does. A lone
ownerless member still pairs with its lone counterpart.

* test(managed): split the ownerless comparison tests under the function length limit

The ownerless describe block reached 181 lines, past oxlint's
max-lines-per-function limit of 150. Move the structural-competitor
case into its own block.

* fix(managed): contest signatures and remap only open candidates

* fix(managed): retain blocked raw-signature choices as unknown

* fix(managed): keep member names in signature contention

* fix(managed): scope residual signature contention by name once

ownerlessContested applies competitionIdentityKey itself, but
unresolvedSignatureAmbiguities passed it keys that were already
name-scoped, so the contested set never matched the groups and the
same-signature, different-body case reported additions and removals
instead of one unknown group. Pass the raw signature key.

* fix(managed): constrain matchObserved to declared members

matchObserved passes its items to matchByKeys, which needs Declared, so
its looser { declaring_type } bound failed typecheck (TS2344, TS2322).

* fix(managed): retain held ownership contention for residual matches

* fix(test): bind workspace fixture in Hopper launch cases

* fix(ci): prepare worker tests and preserve Hopper cleanup ordering

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Jeremy Levartovsky 2026-10-14 09:36:43 +11:00 committed by GitHub
parent c86b09a109
commit 1ebbc3b371
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1192 additions and 58 deletions

View File

@ -209,19 +209,27 @@ 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 prefers exact raw CIL/signature identity, with
equal decoded fingerprints. Equal decoded fingerprints alone cannot establish
an exact match: they omit switch targets and collapse distinct NaN encodings.
When that does not match, it pairs an exact declared type, method name, and raw
signature before trying structural body shape. 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. Fields follow the
same exact tiers. The raw signature is exact whether or not REA decoded it, so
undecoded signatures pair by that tuple too, but they never enter structural
rounds. An unmatched member is therefore reported as `unknown`, not added or
removed, when its own signature was not decoded or when the other side has an
unpaired member, one-sided or ambiguous, with the same declared type and name
whose signature was not decoded.
Cross-version matching first pairs members of the same declared type and name:
methods by exact CIL/signature identity, then by raw signature, then by
structural body shape; fields by raw signature. Exact CIL/signature identity
requires equal raw CIL digests and decoded fingerprints. Equal decoded
fingerprints alone cannot establish an exact match: they omit switch targets
and collapse distinct NaN encodings. Only then may a remap pair a
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
full tuple; names alone never select a pair. Duplicate tuples remain ambiguous
rather than being paired by token order. The raw signature is exact whether or
not REA decoded it, so undecoded signatures pair by that tuple too, but they
never enter structural rounds. An unmatched member is therefore reported as
`unknown`, not added or removed, when its own signature was not decoded or when
the other side has an unpaired member, one-sided or ambiguous, with the same
declared type and name whose signature was not decoded.
For a matched method, an unavailable or partial body makes body-shape facets
unknown while preserving observed signature differences. For complete bodies,

View File

@ -98,7 +98,7 @@ export const MANAGED_WORKFLOW_TOOL_CONTRACTS = [
name: "compare_managed_members",
...toolContractMetadata("compare_managed_members"),
description:
"Compare two authenticated inspect_managed_members Evidence records using unique-only tiers: exact CIL/signature, exact declared type, name, and raw signature (decoded or not), and decoded structural method shape. Names alone are never a matching basis; a member whose signature was not decoded, or whose same-named counterpart's was not, stays unknown rather than added or removed. Metadata tokens remain build-local coordinates bound to each artifact SHA-256 and MVID, and the tuple digest does not itself remap them.",
"Compare two authenticated inspect_managed_members Evidence records using unique-only tiers: exact declared type, name, and CIL/signature; exact declared type, name, and raw signature (decoded or not); exact declared type, name, and decoded structural method shape; then exact CIL/signature or decoded structural method shape alone, which never takes a member whose own declared type and name remain unpaired on the other side or whose declaring type is unresolved. Names alone are never a matching basis; a member whose signature was not decoded, or whose same-named counterpart's was not, stays unknown rather than added or removed. Metadata tokens remain build-local coordinates bound to each artifact SHA-256 and MVID, and the tuple digest does not itself remap them.",
kind: "application",
inputSchema: compareManagedMembersReferenceInputSchema,
outputSchema: comparisonOutputSchema,

View File

@ -107,8 +107,8 @@ export const comparisonLimitations = (
): string[] => {
const limitations: string[] = [
"Metadata tokens are build-local coordinates; matched pairs are remaps, not persistent identities.",
"Methods pair by exact CIL/signature, then declared type, name, and signature, then structural shape; names alone are never a matching basis.",
"Fields pair by exact signature, then declared type, name, and signature; an undecoded signature is compared only by its raw bytes, and an unmatched member with one stays unknown.",
"Methods pair within one declared type and name by exact CIL/signature, then signature, then structural shape; only then may exact CIL/signature or structural shape alone remap across declared types or names, never taking a member whose own declared type and name remain unpaired on the other side or whose declaring type is unresolved. Names alone are never a matching basis.",
"Fields pair by declared type, name, and signature, then remap by exact decoded signature alone under the same rule; an undecoded signature is compared only by its raw bytes, and an unmatched member with one stays unknown.",
];
if (left.coverage.state !== "complete" || right.coverage.state !== "complete")
limitations.push("At least one managed member observation is partial.");

View File

@ -5,6 +5,7 @@ import type { ManagedMemberComparisonResult } from "./managedMemberComparison.js
import {
type ManagedFieldMatches,
type ManagedMethodMatches,
identityOf,
sha256,
} from "./managedMemberComparisonMatch.js";
@ -211,33 +212,36 @@ const absenceLimitations = (
`signature-not-decoded: The member's ${member.signature.parse_status} signature was compared only by exact declared type, name, and raw bytes, so a changed or renamed counterpart could not be sought.`,
]),
...(member.signature.parse_status === "decoded" &&
opposite.oppositeUndecodedNames.has(declaredName(member))
opposite.oppositeUndecodedNames.size > 0 &&
opposite.oppositeUndecodedNames.has(identityOf(member))
? [
`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.`,
]
: []),
];
const declaredName = (member: Method | Field): string =>
JSON.stringify([member.declaring_type, member.name]);
/**
* Declared names of unpaired members whose signature was not decoded: one-sided
* members and candidates of ambiguous groups, which also remain unpaired.
*/
interface Unpaired {
readonly item: Method | Field;
readonly identity: string;
}
const undecodedOneSidedNames = (members: {
readonly leftOnly: readonly { readonly item: Method | Field }[];
readonly rightOnly: readonly { readonly item: Method | Field }[];
readonly leftOnly: readonly Unpaired[];
readonly rightOnly: readonly Unpaired[];
readonly ambiguous: readonly {
readonly left: readonly { readonly item: Method | Field }[];
readonly right: readonly { readonly item: Method | Field }[];
readonly left: readonly Unpaired[];
readonly right: readonly Unpaired[];
}[];
}): UndecodedOneSidedNames => {
const names = (side: readonly { readonly item: Method | Field }[]) =>
const names = (side: readonly Unpaired[]) =>
new Set(
side
.filter(({ item }) => item.signature.parse_status !== "decoded")
.map(({ item }) => declaredName(item)),
.map(({ identity }) => identity),
);
return {
left: names([
@ -300,18 +304,20 @@ const fieldOnlyItem = (
};
/**
* Name the identity key an ambiguous group shares. The exact-signature key
* already includes the declared type and name, so names cannot separate it.
* Name only evidence shared by the whole group. Exact-signature ambiguities
* include both declared-identity collisions and unresolved raw-signature choices.
*/
const ambiguityLimitation = (
members: "methods" | "fields",
basis: MethodItem["match"]["basis"],
): string =>
basis === "exact-signature"
? `Multiple managed ${members} share the same declared type, name, and raw signature; REA did not guess a token remap.`
: members === "methods"
? "Multiple managed methods share the same non-name identity key; REA did not guess a token remap."
: "Multiple managed fields share the same signature; REA did not guess a token remap from names.";
? `Multiple managed ${members} share the same raw 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,

View File

@ -28,8 +28,11 @@ type ConcreteMatchBasis = Exclude<MatchBasis, "none">;
interface Keyed<Item> {
readonly item: Item;
/** Declared type and name; an unresolved type is null, the name remains. */
readonly identity: string;
readonly exactKey: string | null;
readonly signatureKey: string | null;
readonly rawSignatureKey: string;
readonly structuralKey: string | null;
}
@ -48,6 +51,8 @@ interface Ambiguous<Item> {
const keyMethod = (item: Method): Keyed<Method> => ({
item,
identity: identityOf(item),
rawSignatureKey: item.signature.raw_sha256,
exactKey:
item.body.status === "present" &&
item.body.il_sha256 !== null &&
@ -99,6 +104,8 @@ const keyMethod = (item: Method): Keyed<Method> => ({
const keyField = (item: Field): Keyed<Field> => ({
item,
identity: identityOf(item),
rawSignatureKey: item.signature.raw_sha256,
exactKey:
item.signature.parse_status === "decoded"
? sha256(["field-exact", item.signature.raw_sha256])
@ -126,15 +133,34 @@ const matchMethods = (
matchByKeys({
left,
right,
exactBasis: "exact-il-signature",
fallbackBases: [
rounds: [
{
basis: "exact-il-signature",
key: declaredAs(({ exactKey }) => exactKey),
},
{
basis: "exact-signature",
key: ({ signatureKey }) => signatureKey,
competitionKey: ({ rawSignatureKey }) => rawSignatureKey,
},
{
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,
),
competitionKey: ({ structuralKey }) => structuralKey,
},
{
basis: "exact-il-signature",
key: ({ exactKey }) => exactKey,
remap: true,
},
{
basis: "structural-method-shape",
key: ({ structuralKey }) => structuralKey,
competitionKey: ({ structuralKey }) => structuralKey,
remap: true,
},
],
});
@ -146,11 +172,20 @@ const matchFields = (
matchByKeys({
left,
right,
exactBasis: "field-signature",
fallbackBases: [
rounds: [
{
basis: "field-signature",
key: declaredAs(({ exactKey }) => exactKey),
},
{
basis: "exact-signature",
key: ({ signatureKey }) => signatureKey,
competitionKey: ({ rawSignatureKey }) => rawSignatureKey,
},
{
basis: "field-signature",
key: ({ exactKey }) => exactKey,
remap: true,
},
],
});
@ -158,21 +193,47 @@ const matchFields = (
export type ManagedMethodMatches = ReturnType<typeof matchMethods>;
export type ManagedFieldMatches = ReturnType<typeof matchFields>;
interface MatchRound<Item> {
readonly basis: ConcreteMatchBasis;
readonly key: (item: Keyed<Item>) => string | null;
/** Name-free evidence paired with the retained member name for contesting. */
readonly competitionKey?: (item: Keyed<Item>) => string | null;
/**
* The key omits the declared type, so a pair may join different declared
* identities; same-identity pairs were taken by earlier rounds. Contention
* still retains the member name, which unresolved owners keep as evidence.
* A member whose own identity is still unpaired on the other side does not
* take part, and a group with a member of an unresolved type stays ambiguous.
*/
readonly remap?: true;
}
interface MatchByKeysInput<Item> {
readonly left: readonly Keyed<Item>[];
readonly right: readonly Keyed<Item>[];
readonly exactBasis: ConcreteMatchBasis;
readonly fallbackBases: readonly {
readonly basis: ConcreteMatchBasis;
readonly key: (item: Keyed<Item>) => string | null;
}[];
readonly rounds: readonly MatchRound<Item>[];
}
const matchByKeys = <Item>({
export const identityOf = ({
declaring_type,
name,
}: {
readonly declaring_type: string | null;
readonly name: string;
}): string => sha256([declaring_type, name]);
/** Narrow a key to members of one declared type and name. */
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]);
};
const matchByKeys = <Item extends Declared>({
left,
right,
exactBasis,
fallbackBases,
rounds,
}: MatchByKeysInput<Item>): {
readonly pairs: readonly MatchedPair<Item>[];
readonly ambiguous: readonly Ambiguous<Item>[];
@ -181,33 +242,58 @@ const matchByKeys = <Item>({
} => {
const usedLeft = new Set<Keyed<Item>>();
const usedRight = new Set<Keyed<Item>>();
const pairedLeft = new Set<Keyed<Item>>();
const pairedRight = new Set<Keyed<Item>>();
const pairs: MatchedPair<Item>[] = [];
const ambiguous: Ambiguous<Item>[] = [];
const rounds: readonly {
readonly basis: ConcreteMatchBasis;
readonly key: (item: Keyed<Item>) => string | null;
}[] = [
{ basis: exactBasis, key: ({ exactKey }) => exactKey },
...fallbackBases,
];
for (const round of rounds) {
const competitionKey = round.competitionKey ?? ((item) => item.exactKey);
const unpairedLeft = unpairedOf(left, pairedLeft);
const unpairedRight = unpairedOf(right, pairedRight);
const declarable = ownerlessDeclarable(
unpairedLeft,
unpairedRight,
competitionKey,
);
const openLeft = left.filter((item) => !usedLeft.has(item));
const openRight = right.filter((item) => !usedRight.has(item));
// Only open candidates can justify overriding a held declared identity:
// consumed ambiguous rows remain identity barriers, but cannot join a
// later remap group to establish its ambiguity.
const remapContested =
round.remap === true
? ownerlessContested([openLeft, openRight], competitionKey)
: new Set<string>();
const leftGroups = groupBy(
left.filter((item) => !usedLeft.has(item)),
round.remap === true
? remappable(openLeft, unpairedRight, competitionKey, remapContested)
: openLeft.filter(declarable),
round.key,
);
const rightGroups = groupBy(
right.filter((item) => !usedRight.has(item)),
round.remap === true
? remappable(openRight, unpairedLeft, competitionKey, remapContested)
: openRight.filter(declarable),
round.key,
);
for (const [key, leftItems] of leftGroups) {
const rightItems = rightGroups.get(key);
if (rightItems === undefined) continue;
if (leftItems.length === 1 && rightItems.length === 1) {
// An unresolved owner may be either member's own counterpart, so a
// remap group it joins stays ambiguous.
const refused =
round.remap === true &&
[...leftItems, ...rightItems].some(
({ item }) => item.declaring_type === null,
);
if (!refused && leftItems.length === 1 && rightItems.length === 1) {
const [leftItem] = leftItems;
const [rightItem] = rightItems;
if (leftItem === undefined || rightItem === undefined) continue;
usedLeft.add(leftItem);
usedRight.add(rightItem);
pairedLeft.add(leftItem);
pairedRight.add(rightItem);
pairs.push({
left: leftItem,
right: rightItem,
@ -230,6 +316,22 @@ const matchByKeys = <Item>({
}
}
}
// Contention can prevent a declared signature pair even when CIL/shape
// evidence cannot form a later remap. Held ambiguities still contest
// ownership; only proven pairs leave that evidence pool. Group only open
// candidates so held members retain their original ambiguity and provenance.
for (const group of unresolvedSignatureAmbiguities(
left.filter((item) => !usedLeft.has(item)),
right.filter((item) => !usedRight.has(item)),
ownerlessContested(
[unpairedOf(left, pairedLeft), unpairedOf(right, pairedRight)],
(member) => member.rawSignatureKey,
),
)) {
for (const item of group.left) usedLeft.add(item);
for (const item of group.right) usedRight.add(item);
ambiguous.push(group);
}
return {
pairs,
ambiguous,
@ -238,6 +340,132 @@ const matchByKeys = <Item>({
};
};
/** Open signature choices contested by unresolved owners, including held rows. */
const unresolvedSignatureAmbiguities = <Item extends Declared>(
left: readonly Keyed<Item>[],
right: readonly Keyed<Item>[],
contested: ReadonlySet<string>,
): Ambiguous<Item>[] => {
const key = (member: Keyed<Item>): string =>
competitionIdentityKey(member, member.rawSignatureKey) ?? "";
const leftGroups = groupBy(left, key);
const rightGroups = groupBy(right, key);
const groups: Ambiguous<Item>[] = [];
for (const [signature, leftItems] of leftGroups) {
const rightItems = rightGroups.get(signature);
if (!contested.has(signature) || rightItems === undefined) continue;
groups.push({
left: leftItems,
right: rightItems,
basis: "exact-signature",
});
}
return groups;
};
interface Declared {
readonly declaring_type: string | null;
readonly name: string;
}
const unpairedOf = <Item>(
items: readonly Keyed<Item>[],
paired: ReadonlySet<Keyed<Item>>,
): Keyed<Item>[] => items.filter((item) => !paired.has(item));
/**
* Round-specific evidence keys an unresolved owner competes for, paired with
* its retained member name. Another same-name member on the same side can be
* the unresolved row's owner; a different name cannot. Contested candidates
* stay in remap rounds rather than pairing by declared identity.
*/
const competitionIdentityKey = <Item extends Declared>(
member: Keyed<Item>,
evidenceKey: string | null,
): string | null =>
evidenceKey === null ? null : JSON.stringify([member.item.name, evidenceKey]);
const ownerlessContested = <Item extends Declared>(
sides: readonly (readonly Keyed<Item>[])[],
keyOf: (item: Keyed<Item>) => string | null,
): ReadonlySet<string> => {
const contested = new Set<string>();
for (const side of sides) {
const seen = new Map<string, number>();
const ownerless = new Set<string>();
for (const member of side) {
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);
}
for (const key of ownerless)
if ((seen.get(key) ?? 0) > 1) contested.add(key);
}
return contested;
};
/**
* Members a remap round may group: an unresolved owner, which keeps its group
* ambiguous; a member whose selected key an unresolved owner competes for, so the
* group that preserved the base matcher's ambiguity can form; or no member of
* the same declared identity left unpaired on the other side, whether
* one-sided or in an ambiguous group. The sets are taken before grouping, so
* no group order decides a remap.
*/
const remappable = <Item extends Declared>(
items: readonly Keyed<Item>[],
others: readonly Keyed<Item>[],
keyOf: (item: Keyed<Item>) => string | null,
contested: ReadonlySet<string>,
): Keyed<Item>[] => {
const kept = new Set(others.map(({ identity }) => identity));
return items.filter((member) => {
const key = keyOf(member);
return (
(key !== null &&
contested.has(competitionIdentityKey(member, key) ?? "")) ||
member.item.declaring_type === null ||
!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>(
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);
};
};
const groupBy = <Item>(
items: readonly Item[],
keyOf: (item: Item) => string | null,
@ -266,7 +494,7 @@ const observedBy =
* the other side's selection stays one-sided, so a type selection never yields
* a remap to a member of a type the other side did not inspect.
*/
const matchObserved = <Item extends { readonly declaring_type: string | null }>(
const matchObserved = <Item extends Declared>(
left: readonly Item[],
right: readonly Item[],
sides: {

View File

@ -282,6 +282,8 @@ test.for(["handoff", "not-found"] as const)(
const status = z.object({
result: z.object({
provider_operation_health: z.object({
// A timeout observes incomplete startup; a failed launcher without
// confirmed ownership leaves the provider's state unknown.
state: z.literal(
launchMode === "handoff" ? "not_started" : "unknown",
),

View File

@ -47,7 +47,7 @@ describe("managed member comparison", () => {
}),
]);
expect(result.limitations).toContain(
"Methods pair by exact CIL/signature, then declared type, name, and signature, then structural shape; names alone are never a matching basis.",
"Methods pair within one declared type and name by exact CIL/signature, then signature, then structural shape; only then may exact CIL/signature or structural shape alone remap across declared types or names, never taking a member whose own declared type and name remain unpaired on the other side or whose declaring type is unresolved. Names alone are never a matching basis.",
);
expect(
managedMemberComparisonResultSchema.safeParse({
@ -339,10 +339,12 @@ describe("managed member comparison uncertainty", () => {
const rightField = right.result.fields[0];
expect(rightField).toBeDefined();
if (rightField === undefined) return;
// Both right fields are renamed, so neither left field keeps its declared
// identity and only the shared signature links them.
const duplicatedRight = {
...right.result,
fields: [
rightField,
{ ...rightField, name: "moved" },
{
...rightField,
token: "0x04000002",
@ -365,6 +367,9 @@ describe("managed member comparison uncertainty", () => {
candidate_left_tokens: ["0x04000001", "0x04000002"],
candidate_right_tokens: ["0x04000001", "0x04000002"],
}),
limitations: [
"Multiple managed fields share the same signature; REA did not guess a token remap.",
],
}),
]);
});
@ -582,7 +587,7 @@ describe("managed member comparison of ambiguous undecoded signatures", () => {
basis: "exact-il-signature",
}),
limitations: [
"Multiple managed methods share the same non-name identity key; REA did not guess a token remap.",
"Multiple managed methods share the same CIL and signature; REA did not guess a token remap.",
],
}),
expect.objectContaining({
@ -670,6 +675,891 @@ describe("managed member comparison of selected types", () => {
});
});
describe("managed member comparison of one signature in several types", () => {
it("pairs a file with itself when two types hold identical members", () => {
const inspected = withTwin(
inspect(buildManagedPeFixture(), "/tmp/twin.dll"),
);
const result = compareManagedMembers(inspected, inspected);
expect(result.matching.ambiguous).toBe(0);
expect(result.summary).toMatchObject({
unchanged: 4,
changed: 0,
added: 0,
removed: 0,
unknown: 0,
});
for (const item of [...result.methods, ...result.fields])
expect(item.right?.token).toBe(item.left?.token);
});
it.each([1, 2])(
"keeps a member with its own changed counterpart beside %i identical members of other types",
(twins) => {
const left = inspect(buildManagedPeFixture(), "/tmp/own-before.dll");
const changed = withTwin(
inspect(
buildManagedPeFixture({
ilBody: Buffer.from([
0x32, 0x02, 0x7b, 0x02, 0x00, 0x00, 0x04, 0x28, 0x02, 0x00, 0x00,
0x0a, 0x2a,
]),
}),
"/tmp/own-after.dll",
),
left,
twins,
);
const result = compareManagedMembers(left, changed);
expect(result.methods).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: "changed",
left: expect.objectContaining({
declaring_type: "Fixture.Program",
}),
right: expect.objectContaining({
declaring_type: "Fixture.Program",
}),
match: expect.objectContaining({ basis: "exact-signature" }),
dimensions: expect.arrayContaining(["cil"]),
}),
expect.objectContaining({
status: "added",
right: expect.objectContaining({ declaring_type: "Other.Twin" }),
}),
]),
);
expect(result.fields).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: "unchanged",
left: expect.objectContaining({
declaring_type: "Fixture.Program",
}),
right: expect.objectContaining({
declaring_type: "Fixture.Program",
}),
}),
expect.objectContaining({
status: "added",
right: expect.objectContaining({ declaring_type: "Other.Twin" }),
}),
]),
);
expect(result.summary).toMatchObject({ unknown: 0, removed: 0 });
},
);
it("does not remap a member whose own identity remains on the other side", () => {
const left = inspect(buildManagedPeFixture(), "/tmp/sig-before.dll");
const changed = withTwin(
inspect(
buildManagedPeFixture({
methodSignature: Buffer.from([0x00, 0x00, 0x08]),
fieldSignature: Buffer.from([0x06, 0x0a]),
}),
"/tmp/sig-after.dll",
),
left,
);
// Either side may hold the member whose own identity is left behind.
const forward = compareManagedMembers(left, changed);
const backward = compareManagedMembers(changed, left);
for (const item of [
...forward.methods,
...forward.fields,
...backward.methods,
...backward.fields,
])
expect(item.match.status).not.toBe("matched");
expect(forward.summary).toMatchObject({ removed: 2, added: 4 });
expect(backward.summary).toMatchObject({ removed: 4, added: 2 });
});
it("does not remap by structural shape a method whose own identity remains on the other side", () => {
const left = inspect(buildManagedPeFixture(), "/tmp/shape-before.dll");
const changed = withTwin(
inspect(
buildManagedPeFixture({
methodSignature: Buffer.from([0x00, 0x00, 0x08]),
}),
"/tmp/shape-after.dll",
),
left,
);
const [own, twin] = changed.result.methods;
if (own === undefined || twin === undefined)
throw new Error("Expected two methods");
// Same body shape, different CIL digest: only the structural round links
// the left method to the twin.
const result = compareManagedMembers(left, {
...changed,
result: {
...changed.result,
methods: [
own,
{
...twin,
body: { ...twin.body, normalized_il_sha256: "0".repeat(64) },
},
],
},
});
for (const item of result.methods)
expect(item.match.status).not.toBe("matched");
});
});
describe("managed member comparison remaps around kept identities", () => {
it("does not hide a member whose own identity remains in an ambiguous remap group", () => {
const left = inspect(buildManagedPeFixture(), "/tmp/group-before.dll");
const changed = withTwin(
inspect(
buildManagedPeFixture({
methodSignature: Buffer.from([0x00, 0x00, 0x08]),
fieldSignature: Buffer.from([0x06, 0x0a]),
}),
"/tmp/group-after.dll",
),
left,
2,
);
const result = compareManagedMembers(left, changed);
expect(result.matching.ambiguous).toBe(0);
expect(result.summary).toMatchObject({ removed: 2, added: 6 });
});
it("remaps to the one candidate whose own identity is not kept elsewhere", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/kept-base.dll");
const changedSignature = inspect(
buildManagedPeFixture({
methodSignature: Buffer.from([0x00, 0x00, 0x08]),
}),
"/tmp/kept-signature.dll",
);
// Left: Fixture.Program.Main, plus Other.Twin.Main with another signature.
const left = withTwin(base, changedSignature);
// Right: Other.Twin.Main and Other.Twin3.Main, both identical to the
// left Fixture.Program.Main, which the right no longer has.
const twins = withTwin(base, base, 2);
const right = {
...twins,
result: { ...twins.result, methods: twins.result.methods.slice(1) },
};
const result = compareManagedMembers(left, right);
expect(result.methods).toEqual(
expect.arrayContaining([
expect.objectContaining({
left: expect.objectContaining({ declaring_type: "Fixture.Program" }),
right: expect.objectContaining({ declaring_type: "Other.Twin3" }),
match: expect.objectContaining({
status: "matched",
basis: "exact-il-signature",
}),
}),
]),
);
expect(
result.methods.filter(({ match }) => match.status === "ambiguous"),
).toEqual([]);
});
it("names the shared structural shape of an ambiguous remap group", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/shape-group.dll");
const twins = withTwin(base, base, 2);
// Two right methods share the left method's shape but not its CIL digest,
// and the right no longer declares the left method's identity.
const right = {
...twins,
result: {
...twins.result,
methods: twins.result.methods.slice(1).map((method) => ({
...method,
body: { ...method.body, normalized_il_sha256: "0".repeat(64) },
})),
fields: [],
},
};
const left = { ...base, result: { ...base.result, fields: [] } };
const result = compareManagedMembers(left, right);
expect(result.methods).toEqual([
expect.objectContaining({
match: expect.objectContaining({
status: "ambiguous",
basis: "structural-method-shape",
}),
limitations: [
"Multiple managed methods share the same structural shape; REA did not guess a token remap.",
],
}),
]);
});
});
describe("managed member comparison of unresolved and held identities", () => {
it("does not remap past an unresolved member that may be the counterpart", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/orphan-copy.dll");
const [method] = base.result.methods;
if (method === undefined) throw new Error("Expected a fixture method");
const left = { ...base, result: { ...base.result, fields: [] } };
// The right keeps Main with an unresolved owner, and an identical copy
// in another type; either could be the left Main.
const right = {
...base,
result: {
...base.result,
methods: [
{ ...method, declaring_type: null, declaring_type_token: null },
{
...method,
declaring_type: "Other.Twin",
declaring_type_token: "0x02000003",
name: "Copy",
token: "0x06000002",
},
],
fields: [],
},
};
for (const result of [
compareManagedMembers(left, right),
compareManagedMembers(right, left),
]) {
expect(
result.methods.filter(({ match }) => match.status === "matched"),
).toEqual([]);
expect(result.summary).toMatchObject({ added: 0, removed: 0 });
}
});
});
describe("managed member comparison of raw-signature contention", () => {
it("contests same-signature methods even when their bodies differ", () => {
const base = inspect(
buildManagedPeFixture(),
"/tmp/ownerless-signatures.dll",
);
const [method] = base.result.methods;
if (method === undefined) throw new Error("Expected a fixture method");
const changedBody = (hash: string, stack: number) => ({
...method.body,
normalized_il_sha256: hash.repeat(64),
max_stack: stack,
});
const withOwnerless = {
...base,
result: {
...base.result,
fields: [],
methods: [
{ ...method, body: changedBody("a", 1) },
{
...method,
token: "0x06000002",
declaring_type: null,
declaring_type_token: null,
body: changedBody("b", 2),
},
],
},
};
const resolvedOnly = {
...base,
result: {
...base.result,
fields: [],
methods: [{ ...method, body: changedBody("c", 3) }],
},
};
for (const result of [
compareManagedMembers(withOwnerless, resolvedOnly),
compareManagedMembers(resolvedOnly, withOwnerless),
]) {
expect(result.methods).toHaveLength(1);
const [item] = result.methods;
if (item === undefined)
throw new Error("Expected uncertain signature group");
expect(item).toMatchObject({
status: "unknown",
match: { status: "ambiguous", basis: "exact-signature" },
});
expect(result.summary).toMatchObject({
added: 0,
removed: 0,
unknown: 1,
});
expect(
item.match.candidate_left_tokens.length +
item.match.candidate_right_tokens.length,
).toBe(3);
}
});
it("contests ownerless undecoded fields by raw signature", () => {
const base = inspect(
buildManagedPeFixture({ fieldSignature: Buffer.from([0x06, 0x7f]) }),
"/tmp/ownerless-field-signatures.dll",
);
const [field] = base.result.fields;
if (field === undefined) throw new Error("Expected a fixture field");
expect(field.signature.parse_status).toBe("unsupported");
const withOwnerless = {
...base,
result: {
...base.result,
methods: [],
fields: [
field,
{
...field,
token: "0x04000002",
declaring_type: null,
declaring_type_token: null,
},
],
},
};
const resolvedOnly = {
...base,
result: { ...base.result, methods: [], fields: [field] },
};
for (const result of [
compareManagedMembers(withOwnerless, resolvedOnly),
compareManagedMembers(resolvedOnly, withOwnerless),
])
expect(
result.fields.filter(({ match }) => match.status === "matched"),
).toEqual([]);
});
});
describe("managed member comparison of consumed ownerless ambiguity", () => {
it("keeps a consumed ownerless ambiguity from authorizing a later remap", () => {
const base = inspect(
buildManagedPeFixture(),
"/tmp/consumed-ownerless.dll",
);
const [method] = base.result.methods;
if (method === undefined) throw new Error("Expected a fixture method");
const candidate = (
raw: string,
token: string,
owner: string | null,
name: string,
stack: number,
) => ({
...method,
token,
declaring_type: owner,
declaring_type_token: owner === null ? null : method.declaring_type_token,
name,
signature: { ...method.signature, raw_sha256: raw.repeat(64) },
body: {
...method.body,
normalized_il_sha256: raw.repeat(64),
max_stack: stack,
},
});
const left = {
...base,
result: {
...base.result,
fields: [],
methods: [
candidate("a", "0x06000001", "T", "M", 1),
candidate("b", "0x06000002", null, "M", 1),
candidate("b", "0x06000003", "D", "D", 2),
],
},
};
const right = {
...base,
result: {
...base.result,
fields: [],
methods: [
candidate("c", "0x06000001", "U", "N", 1),
candidate("b", "0x06000002", null, "M", 1),
candidate("d", "0x06000003", "T", "M", 3),
],
},
};
for (const result of [
compareManagedMembers(left, right),
compareManagedMembers(right, left),
]) {
expect(result.matching.ambiguous).toBeGreaterThan(0);
expect(
result.methods.filter(({ match }) => match.status === "matched"),
).toEqual([]);
}
});
});
describe("managed member comparison of retained ownership contention", () => {
it("keeps changed counterparts unknown after an ownerless candidate is held", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/held-ownerless.dll");
const [method] = base.result.methods;
if (method === undefined) throw new Error("Expected a fixture method");
const body = (hash: string, stack: number) => ({
...method.body,
il_sha256: hash.repeat(64),
normalized_il_sha256: hash.repeat(64),
max_stack: stack,
});
const ownerless = {
...method,
token: "0x06000002",
declaring_type: null,
declaring_type_token: null,
body: body("b", 2),
};
const left = {
...base,
result: {
...base.result,
fields: [],
methods: [{ ...method, body: body("a", 1) }, ownerless],
},
};
const right = {
...base,
result: {
...base.result,
fields: [],
methods: [
{ ...method, body: body("c", 3) },
{
...method,
token: "0x06000002",
declaring_type: "Other.Twin",
name: "Copy",
body: ownerless.body,
},
],
},
};
for (const result of [
compareManagedMembers(left, right),
compareManagedMembers(right, left),
]) {
expect(result.summary).toEqual({
unchanged: 0,
changed: 0,
added: 0,
removed: 0,
unknown: 2,
});
expect(result.methods).toEqual([
expect.objectContaining({
status: "unknown",
match: {
status: "ambiguous",
basis: "exact-il-signature",
confidence: "unknown",
candidate_left_tokens: ["0x06000002"],
candidate_right_tokens: ["0x06000002"],
},
}),
expect.objectContaining({
status: "unknown",
match: {
status: "ambiguous",
basis: "exact-signature",
confidence: "unknown",
candidate_left_tokens: ["0x06000001"],
candidate_right_tokens: ["0x06000001"],
},
}),
]);
}
});
});
describe("managed member comparison of ownerless structural competitors", () => {
it("keeps structural competitors of an ownerless method ambiguous in both directions", () => {
const base = inspect(
buildManagedPeFixture(),
"/tmp/ownerless-shape-competitors.dll",
);
const [method] = base.result.methods;
if (method === undefined) throw new Error("Expected a fixture method");
const withSignature = (raw: string, token: string) => ({
...method,
token,
signature: { ...method.signature, raw_sha256: raw.repeat(64) },
});
const withOwnerless = {
...base,
result: {
...base.result,
methods: [
withSignature("a", "0x06000001"),
{
...withSignature("b", "0x06000002"),
declaring_type: null,
declaring_type_token: null,
},
],
fields: [],
},
};
const resolvedOnly = {
...base,
result: {
...base.result,
methods: [withSignature("c", "0x06000001")],
fields: [],
},
};
for (const result of [
compareManagedMembers(withOwnerless, resolvedOnly),
compareManagedMembers(resolvedOnly, withOwnerless),
]) {
expect(result.matching.ambiguous).toBe(1);
expect(
result.methods.filter(({ match }) => match.status === "matched"),
).toEqual([]);
expect(result.summary).toMatchObject({ added: 0, removed: 0 });
}
});
});
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");
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 left = {
...base,
result: { ...base.result, methods: [ownerless], fields: [] },
};
// The right keeps the ownerless Foo and a resolved copy with the same
// CIL and signature; the left Foo's owner may be either one.
const right = {
...base,
result: {
...base.result,
methods: [ownerless, { ...method, token: "0x06000002" }],
fields: [],
},
};
for (const result of [
compareManagedMembers(left, right),
compareManagedMembers(right, left),
]) {
expect(result.matching.ambiguous).toBe(1);
expect(
result.methods.filter(({ match }) => match.status === "matched"),
).toEqual([]);
expect(result.summary).toMatchObject({ added: 0, removed: 0 });
}
});
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;
if (method === undefined) throw new Error("Expected a fixture method");
const ownerless = {
...method,
declaring_type: null,
declaring_type_token: null,
};
// The left holds the resolved Foo and an ownerless Foo with the same CIL
// and signature; the right holds only the resolved Foo. The ownerless
// Foo's owner may be the resolved one, so the three stay ambiguous.
const withOwnerless = {
...base,
result: { ...base.result, methods: [method, ownerless], fields: [] },
};
const resolvedOnly = {
...base,
result: { ...base.result, methods: [method], fields: [] },
};
const result = compareManagedMembers(withOwnerless, resolvedOnly);
expect(result.matching.ambiguous).toBe(1);
expect(
result.methods.filter(({ match }) => match.status === "matched"),
).toEqual([]);
expect(result.summary).toMatchObject({ added: 0, removed: 0 });
});
it("keeps a resolved copy out of the declared round when an ownerless competitor is on the right, as main does", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-right.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 withOwnerless = {
...base,
result: { ...base.result, methods: [method, ownerless], fields: [] },
};
const resolvedOnly = {
...base,
result: { ...base.result, methods: [method], fields: [] },
};
const result = compareManagedMembers(resolvedOnly, withOwnerless);
expect(result.matching.ambiguous).toBe(1);
expect(
result.methods.filter(({ match }) => match.status === "matched"),
).toEqual([]);
expect(result.summary).toMatchObject({ added: 0, removed: 0 });
});
it("keeps an ownerless method ambiguous beside a resolved method of the same shape", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-shape.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,
};
// Raw signature digests differ, so only the decoded shape links them.
const reshaped = (raw: string, token: string) => ({
signature: { ...method.signature, raw_sha256: raw.repeat(64) },
token,
});
const left = {
...base,
result: { ...base.result, methods: [ownerless], fields: [] },
};
const right = {
...base,
result: {
...base.result,
methods: [
{ ...ownerless, ...reshaped("e", "0x06000001") },
{ ...method, name: "Bar", ...reshaped("d", "0x06000002") },
],
fields: [],
},
};
const result = compareManagedMembers(left, right);
expect(result.matching.ambiguous).toBe(1);
expect(
result.methods.filter(({ match }) => match.status === "matched"),
).toEqual([]);
});
});
describe("managed member comparison of unresolved owners", () => {
it("does not pair members of unresolved types under different names", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/orphan-names.dll");
const [method] = base.result.methods;
if (method === undefined) throw new Error("Expected a fixture method");
const orphan = { declaring_type: null, declaring_type_token: null };
const side = (name: string) => ({
...base,
result: {
...base.result,
methods: [{ ...method, ...orphan, name }],
fields: [],
},
});
const result = compareManagedMembers(side("Main"), side("Renamed"));
for (const item of result.methods)
expect(item.match.status).not.toBe("matched");
});
it("keeps ownerless members that share a body ambiguous, as main does", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/orphan-self.dll");
const [method] = base.result.methods;
const [field] = base.result.fields;
if (method === undefined || field === undefined)
throw new Error("Expected a fixture method and field");
const orphan = { declaring_type: null, declaring_type_token: null };
const inspected = {
...base,
result: {
...base.result,
methods: [
{ ...method, ...orphan, name: "p" },
{ ...method, ...orphan, name: "q", token: "0x06000002" },
],
fields: [
{ ...field, ...orphan, name: "a" },
{ ...field, ...orphan, name: "b", token: "0x04000002" },
],
},
};
const result = compareManagedMembers(inspected, inspected);
// Their owners are unknown, so `p` may be `q`; names alone cannot tell.
expect(result.matching.ambiguous).toBe(2);
expect(
[...result.methods, ...result.fields].filter(
({ match }) => match.status === "matched",
),
).toEqual([]);
});
it("does not remap a member whose own identity is in an ambiguous group", () => {
const undecoded = { methodSignature: Buffer.from([0x00, 0x00, 0x7f]) };
const decoded = inspect(buildManagedPeFixture(), "/tmp/held-decoded.dll");
const held = inspect(buildManagedPeFixture(undecoded), "/tmp/held.dll");
const [undecodedMethod] = held.result.methods;
const [decodedMethod] = decoded.result.methods;
if (undecodedMethod === undefined || decodedMethod === undefined)
throw new Error("Expected fixture methods");
// The right keeps two identical undecoded Fixture.Program.Main, which
// stay ambiguous, and a copy of the decoded Main in another type.
const left = {
...held,
result: {
...held.result,
methods: [{ ...undecodedMethod, token: "0x06000002" }, decodedMethod],
},
};
const right = {
...held,
result: {
...held.result,
methods: [
undecodedMethod,
{ ...undecodedMethod, token: "0x06000002" },
{
...decodedMethod,
declaring_type: "Other.Twin",
declaring_type_token: "0x02000003",
token: "0x06000003",
},
],
},
};
for (const result of [
compareManagedMembers(left, right),
compareManagedMembers(right, left),
])
expect(
result.methods.filter(({ match }) => match.status === "matched"),
).toEqual([]);
});
it("does not remap a member whose declaring type is unresolved", () => {
const base = inspect(buildManagedPeFixture(), "/tmp/orphan.dll");
const [method] = base.result.methods;
if (method === undefined) throw new Error("Expected a fixture method");
const left = {
...base,
result: {
...base.result,
methods: [
{ ...method, declaring_type: null, declaring_type_token: null },
],
fields: [],
},
};
const right = {
...base,
result: {
...base.result,
methods: [
{ ...method, declaring_type: "Other.Twin", token: "0x06000002" },
],
fields: [],
},
};
for (const result of [
compareManagedMembers(left, right),
compareManagedMembers(right, left),
])
for (const item of result.methods)
expect(item.match.status).not.toBe("matched");
});
});
/**
* Add copies of the fixture's method and field, each pair declared by another
* 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 withTwin = (
inspected: ReturnType<typeof inspect>,
source: ReturnType<typeof inspect> = inspected,
count = 1,
): ReturnType<typeof inspect> => {
const [method] = source.result.methods;
const [field] = source.result.fields;
if (method === undefined || field === undefined)
throw new Error("Expected a fixture method and field");
const rows = Array.from({ length: count }, (_, index) => index + 2);
const twin = (row: number) => ({
declaring_type: row === 2 ? "Other.Twin" : `Other.Twin${String(row)}`,
declaring_type_token: `0x0200000${String(row + 1)}`,
});
return {
...inspected,
result: {
...inspected.result,
methods: [
...inspected.result.methods,
...rows.map((row) => ({
...method,
...twin(row),
token: `0x0600000${String(row)}`,
})),
],
fields: [
...inspected.result.fields,
...rows.map((row) => ({
...field,
...twin(row),
token: `0x0400000${String(row)}`,
})),
],
},
};
};
const inspect = (bytes: Buffer, path: string, typeNames?: string[]) => {
const target = managedPeFixtureTarget(bytes, path);
const result = inspectManagedMembersBytes(