fix(managed): preserve member scope and nominal edge identity

This commit is contained in:
morluto committed 2026-10-24 01:47:31 +08:00
1 parent f5d791f2a1
commit 357e354ac7
18 files changed
+605 -134

No files matched your search

+20 -1
View File
@@ -250,7 +250,13 @@ remain an assembly-wide inventory, so output size is not guaranteed to be small.
Coverage describes the selected members. Comparisons leave members outside the
opposite observation's selection unknown, and application graphs report their
selected scope as partial. Neither makes an absence claim about the whole
assembly. Older member Evidence without `selection` remains readable.
assembly. Older member Evidence without `selection` remains readable. The
`method_rows` interval records the examined zero-based MethodDef offset, row
count and full table total before type selection. A complete observation can
cover only that interval. Comparison keeps omissions from a method page unknown:
build-local row positions cannot prove member absence across builds. Historical
Evidence without this interval also cannot establish method absence; observed
pairs and field inventory comparisons remain available.
For MCP callers, request `detail: "summary"` and page the `types` collection
using the returned parent Evidence ID. Select one or more returned full names
@@ -319,6 +325,19 @@ native/runtime questions retain their own authority and coverage.
## Member comparison
Call edges and field accesses retain `il_offset` as their instruction site within
the caller's build-local method token and artifact identity. Their
`target_identity_sha256` combines the existing enclosing-type nominal digest,
member name and resolved decoded signature. Comparison joins edges to their
instruction sites rather than array positions and compares these identities;
display labels alone cannot establish equal targets. Same-named nested types
under different enclosing types and different overload signatures stay distinct.
This is nominal metadata identity, not assembly resolution or runtime binding.
Historical edges without a site or identity, unresolved/cyclic type scopes,
TypeSpec owners and unresolved MethodSpec instantiations retain unknown target
equivalence. Valid MethodSpecs combine the base member identity with the
resolved instantiation signature, preserving generic argument distinctions.
Names and semantic labels do not establish durable member identity. Comparison
preserves observed differences, structural inferences, and unknowns separately.
@@ -50,6 +50,7 @@ const emptyManagedMembers = (
},
types: [],
nesting_read: true,
method_rows: { offset: 0, count: 0, total: 0 },
exported_types: [],
fields: [],
methods: [],
@@ -72,6 +72,7 @@ export const managedMemberSummarySchema = z.strictObject({
metadata: member.metadata,
identity_scope: member.identity_scope,
selection: member.selection.unwrap(),
method_rows: member.method_rows,
coverage: member.coverage,
user_strings: z
.strictObject({
@@ -97,6 +98,7 @@ export const managedMembersViewContextSchema = z.strictObject({
nesting_read: member.nesting_read,
identity_scope: member.identity_scope,
selection: member.selection.unwrap(),
method_rows: member.method_rows,
source_metadata_status: member.metadata.shape.status,
source_coverage: member.coverage,
});
@@ -278,6 +280,7 @@ export const projectManagedMemberView = (
nesting_read: report.nesting_read,
identity_scope: report.identity_scope,
selection: report.selection,
method_rows: report.method_rows,
source_metadata_status: report.metadata.status,
source_coverage:
view.collection === "user_strings" && report.user_strings !== null
+25
View File
@@ -353,6 +353,13 @@ const managedNativeImplementationSchema = z.object({
const managedCallEdgeSchema = z.object({
caller_token: managedMetadataTokenSchema,
il_offset: offsetSchema.nullable().default(null),
target_identity_sha256: digestSchema
.nullable()
.default(null)
.describe(
"Nominal declaring-type/name/signature digest; null when unresolved or unrecorded. Does not establish assembly or runtime binding.",
),
caller: z.string().nullable(),
opcode: z.string(),
target_token: managedMetadataTokenSchema,
@@ -368,6 +375,13 @@ const managedCallEdgeSchema = z.object({
const managedFieldAccessSchema = z.object({
method_token: managedMetadataTokenSchema,
il_offset: offsetSchema.nullable().default(null),
target_identity_sha256: digestSchema
.nullable()
.default(null)
.describe(
"Nominal declaring-type/name/signature digest; null when unresolved or unrecorded. Does not establish assembly or runtime binding.",
),
method: z.string().nullable(),
opcode: z.string(),
field_token: managedMetadataTokenSchema,
@@ -538,6 +552,17 @@ export const managedMemberInspectionSchema = z.object({
})
.nullable()
.default(null),
method_rows: z
.object({
offset: z.int().nonnegative(),
count: z.int().nonnegative(),
total: z.int().nonnegative(),
})
.nullable()
.default(null)
.describe(
"Examined zero-based MethodDef row interval before declaring-type selection; null means the row scope was not recorded. Row coordinates are build-local and cannot prove cross-build member absence for a page.",
),
coverage: z.object({
state: z.enum(["complete", "partial", "unavailable"]),
issues: z.array(managedParseIssueSchema),
@@ -10,6 +10,7 @@ import {
} from "./managedArtifact.js";
import {
buildComparisonCoverage,
hasCompleteMethodRows,
buildComparisonMatching,
buildComparisonSummary,
comparisonLimitations,
@@ -268,6 +269,12 @@ export const compareManagedMembers = (
rightEvidenceId: right.evidenceId,
leftComplete: left.result.metadata.status === "complete",
rightComplete: right.result.metadata.status === "complete",
leftMethodsComplete:
left.result.metadata.status === "complete" &&
hasCompleteMethodRows(left.result),
rightMethodsComplete:
right.result.metadata.status === "complete" &&
hasCompleteMethodRows(right.result),
leftSelection: selectedTypes(left.result),
rightSelection: selectedTypes(right.result),
signatureResolutionExamined:
@@ -19,7 +19,8 @@ export const buildComparisonCoverage = ({
const unknownInput =
left.coverage.state !== "complete" ||
right.coverage.state !== "complete" ||
!sameSelection(left, right);
!sameSelection(left, right) ||
!sameMethodRows(left, right);
return {
status:
!unknownInput && leftStatus === "complete" && rightStatus === "complete"
@@ -30,6 +31,25 @@ export const buildComparisonCoverage = ({
};
};
/** Full MethodDef coverage is required before inferring cross-build absence. */
export const hasCompleteMethodRows = (
result: ManagedMemberInspection,
): boolean =>
result.method_rows !== null &&
result.method_rows.offset === 0 &&
result.method_rows.count === result.method_rows.total;
const sameMethodRows = (
left: ManagedMemberInspection,
right: ManagedMemberInspection,
): boolean =>
(hasCompleteMethodRows(left) && hasCompleteMethodRows(right)) ||
(left.method_rows !== null &&
right.method_rows !== null &&
left.method_rows.offset === right.method_rows.offset &&
left.method_rows.count === right.method_rows.count &&
left.method_rows.total === right.method_rows.total);
/** Whether both observations inspected the same declaring types. */
const sameSelection = (
left: ManagedMemberInspection,
@@ -140,6 +160,10 @@ export const comparisonLimitations = (
limitations.push(
`Both observations cover only the selected declaring types (${left.selection.type_names.join(", ")}); member totals and statuses describe those types, not the whole assembly.`,
);
if (!hasCompleteMethodRows(left) || !hasCompleteMethodRows(right))
limitations.push(
"At least one observation lacks a complete MethodDef row inventory; paged or unrecorded method scope cannot establish cross-build method absence. Row intervals are reported in method_rows and describe build-local selections.",
);
if (!hasSignatureResolution(left) || !hasSignatureResolution(right))
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.",
@@ -36,6 +36,8 @@ interface ComparisonItemContext {
readonly rightEvidenceId: string;
readonly leftComplete: boolean;
readonly rightComplete: boolean;
readonly leftMethodsComplete: boolean;
readonly rightMethodsComplete: boolean;
/** Declaring types each side selected; null when it inspected every type. */
readonly leftSelection: ReadonlySet<string> | null;
readonly rightSelection: ReadonlySet<string> | null;
@@ -281,99 +283,109 @@ const sameAssemblyIdentity = (
left.public_key.sha256 === right.public_key.sha256 &&
left.public_key.token === right.public_key.token;
interface ReferenceTarget {
readonly offset: number | null;
readonly opcode: string;
readonly token: string;
readonly identity: string | null;
}
/** Join each edge to its instruction site, then compare aligned instructions. */
const compareReferenceTargets = (
left: Method,
right: Method,
kind: "field" | "method",
leftTargets: readonly ReferenceTarget[],
rightTargets: readonly ReferenceTarget[],
): { readonly changed: boolean; readonly unknown: boolean } => {
const align = (method: Method, targets: readonly ReferenceTarget[]) => {
const anchors = method.body.anchors.filter(
(anchor) =>
isManagedMemberReferenceInstruction(anchor) &&
anchor.operand_kind === kind,
);
const byOffset = new Map<number | null, ReferenceTarget | null>();
for (const target of targets)
byOffset.set(target.offset, byOffset.has(target.offset) ? null : target);
let unknown = targets.length !== anchors.length;
const aligned = anchors.map((anchor) => {
const target = byOffset.get(anchor.il_offset);
if (
target == null ||
target.opcode !== anchor.opcode ||
target.token !== anchor.operand
) {
unknown = true;
return null;
}
return target;
});
return { aligned, unknown };
};
const a = align(left, leftTargets);
const b = align(right, rightTargets);
let changed = false;
let unknown = a.unknown || b.unknown || a.aligned.length !== b.aligned.length;
for (
let index = 0;
index < Math.max(a.aligned.length, b.aligned.length);
index += 1
) {
const leftIdentity = a.aligned[index]?.identity;
const rightIdentity = b.aligned[index]?.identity;
if (leftIdentity == null || rightIdentity == null) unknown = true;
else if (leftIdentity !== rightIdentity) changed = true;
}
return { changed, unknown };
};
const compareFieldTargets = (
left: Method,
right: Method,
leftAccesses: readonly ManagedMemberInspection["field_accesses"][number][],
rightAccesses: readonly ManagedMemberInspection["field_accesses"][number][],
): { readonly changed: boolean; readonly unknown: boolean } => {
const fieldAnchors = (method: Method) =>
method.body.anchors.filter(
(instruction) =>
isManagedMemberReferenceInstruction(instruction) &&
instruction.operand_kind === "field",
);
const leftAnchors = fieldAnchors(left);
const rightAnchors = fieldAnchors(right);
if (leftAnchors.length !== rightAnchors.length)
return { changed: false, unknown: true };
if (
leftAccesses.length !== leftAnchors.length ||
rightAccesses.length !== rightAnchors.length ||
leftAccesses.some(
(edge, index) => edge.opcode !== leftAnchors[index]?.opcode,
) ||
rightAccesses.some(
(edge, index) => edge.opcode !== rightAnchors[index]?.opcode,
)
)
return { changed: false, unknown: true };
let changed = false;
let unknown = false;
for (let index = 0; index < leftAccesses.length; index += 1) {
const leftAccess = leftAccesses[index];
const rightAccess = rightAccesses[index];
if (leftAccess === undefined || rightAccess === undefined) {
unknown = true;
continue;
}
for (const field of ["field_name", "field_declaring_type"] as const) {
const leftValue = leftAccess[field];
const rightValue = rightAccess[field];
if (leftValue === null || rightValue === null) unknown = true;
else if (leftValue !== rightValue) changed = true;
}
}
return { changed, unknown };
};
) =>
compareReferenceTargets(
left,
right,
"field",
leftAccesses.map((edge) => ({
offset: edge.il_offset,
opcode: edge.opcode,
token: edge.field_token,
identity: edge.target_identity_sha256,
})),
rightAccesses.map((edge) => ({
offset: edge.il_offset,
opcode: edge.opcode,
token: edge.field_token,
identity: edge.target_identity_sha256,
})),
);
const compareCallTargets = (
left: Method,
right: Method,
leftEdges: readonly ManagedMemberInspection["call_edges"][number][],
rightEdges: readonly ManagedMemberInspection["call_edges"][number][],
): { readonly changed: boolean; readonly unknown: boolean } => {
const callAnchors = (method: Method) =>
method.body.anchors.filter(
(instruction) =>
isManagedMemberReferenceInstruction(instruction) &&
instruction.operand_kind === "method",
);
const leftAnchors = callAnchors(left);
const rightAnchors = callAnchors(right);
if (leftAnchors.length !== rightAnchors.length)
return { changed: false, unknown: true };
if (
leftEdges.length !== leftAnchors.length ||
rightEdges.length !== rightAnchors.length ||
leftEdges.some(
(edge, index) => edge.opcode !== leftAnchors[index]?.opcode,
) ||
rightEdges.some(
(edge, index) => edge.opcode !== rightAnchors[index]?.opcode,
)
)
return { changed: false, unknown: true };
let changed = false;
let unknown = false;
for (let index = 0; index < leftEdges.length; index++) {
const leftEdge = leftEdges[index];
const rightEdge = rightEdges[index];
if (leftEdge === undefined || rightEdge === undefined) {
unknown = true;
continue;
}
for (const field of ["target_name", "target_declaring_type"] as const) {
const leftValue = leftEdge[field];
const rightValue = rightEdge[field];
if (leftValue === null || rightValue === null) unknown = true;
else if (leftValue !== rightValue) changed = true;
}
}
return { changed, unknown };
};
) =>
compareReferenceTargets(
left,
right,
"method",
leftEdges.map((edge) => ({
offset: edge.il_offset,
opcode: edge.opcode,
token: edge.target_token,
identity: edge.target_identity_sha256,
})),
rightEdges.map((edge) => ({
offset: edge.il_offset,
opcode: edge.opcode,
token: edge.target_token,
identity: edge.target_identity_sha256,
})),
);
const callShape = (anchors: Method["body"]["anchors"]): string =>
JSON.stringify(
@@ -430,7 +442,7 @@ const methodOnlyItem = (
if (member.left !== undefined) {
const limitations = absenceLimitations(member.left, identity, {
oppositeComplete: inventoryCovers(
context.rightComplete,
context.rightMethodsComplete,
context.rightSelection,
member.left,
),
@@ -453,7 +465,7 @@ const methodOnlyItem = (
const limitations = absenceLimitations(member.right, identity, {
oppositeComplete: inventoryCovers(
context.leftComplete,
context.leftMethodsComplete,
context.leftSelection,
member.right,
),
@@ -761,12 +773,12 @@ export const buildMethodItems = (
: []),
...(callTargetUnknown
? [
"One or more call targets are unavailable because the observed method edges do not cover aligned call instructions or lack a target name or declaring type.",
"One or more call targets are unavailable because the observed method edges do not cover aligned call instructions or lack a resolved nominal target identity or instruction site.",
]
: []),
...(fieldTargetUnknown
? [
"One or more field targets are unavailable because observed field accesses do not cover aligned field instructions or lack a target name or declaring type.",
"One or more field targets are unavailable because observed field accesses do not cover aligned field instructions or lack a resolved nominal target identity or instruction site.",
]
: []),
...(exceptionTargetUnknown
@@ -1,3 +1,4 @@
import { hasCompleteMethodRows } from "./managedMemberComparisonCoverage.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import {
managedMemberInspectionSchema,
@@ -152,6 +153,7 @@ const keyField = (item: Field, declared: DeclaredNameOf): Keyed<Field> => ({
const matchMethods = (
left: readonly Keyed<Method>[],
right: readonly Keyed<Method>[],
allowRemap: boolean,
) =>
matchByKeys({
left,
@@ -176,12 +178,12 @@ const matchMethods = (
},
{
basis: "exact-il-signature",
key: ({ exactKey }) => exactKey,
key: ({ exactKey }) => (allowRemap ? exactKey : null),
remap: true,
},
{
basis: "structural-method-shape",
key: ({ structuralKey }) => structuralKey,
key: ({ structuralKey }) => (allowRemap ? structuralKey : null),
competitionKey: ({ structuralKey }) => structuralKey,
remap: true,
},
@@ -191,6 +193,7 @@ const matchMethods = (
const matchFields = (
left: readonly Keyed<Field>[],
right: readonly Keyed<Field>[],
allowRemap: boolean,
) =>
matchByKeys({
left,
@@ -207,7 +210,7 @@ const matchFields = (
},
{
basis: "field-signature",
key: ({ exactKey }) => exactKey,
key: ({ exactKey }) => (allowRemap ? exactKey : null),
remap: true,
},
],
@@ -562,7 +565,15 @@ export const keyMembers = (
left.result.methods,
right.result.methods,
sides,
matchMethods,
(a, b) =>
matchMethods(
a,
b,
left.result.metadata.status === "complete" &&
right.result.metadata.status === "complete" &&
hasCompleteMethodRows(left.result) &&
hasCompleteMethodRows(right.result),
),
(item) => keyMethod(item, declaredLeft),
(item) => keyMethod(item, declaredRight),
),
@@ -570,7 +581,7 @@ export const keyMembers = (
left.result.fields,
right.result.fields,
sides,
matchFields,
(a, b) => matchFields(a, b, left.result.metadata.status === "complete" && right.result.metadata.status === "complete"),
(item) => keyField(item, declaredLeft),
(item) => keyField(item, declaredRight),
),
@@ -428,6 +428,8 @@ describe("managed member inspection", () => {
expect(result.call_edges).toEqual([
{
caller_token: "0x06000001",
il_offset: 6,
target_identity_sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
caller: "Fixture.Program.Main",
opcode: "call",
target_token: "0x0a000001",
@@ -440,6 +442,8 @@ describe("managed member inspection", () => {
expect(result.field_accesses).toEqual([
{
method_token: "0x06000001",
il_offset: 1,
target_identity_sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
method: "Fixture.Program.Main",
opcode: "ldfld",
field_token: "0x04000001",
+16 -5
View File
@@ -92,6 +92,7 @@ const unavailable = (
typeNames === undefined
? null
: { type_names: [...typeNames], unmatched_type_names: [] },
method_rows: null,
coverage: {
state: "unavailable",
issues: issue === null ? [] : [issue],
@@ -188,6 +189,12 @@ export const inspectManagedMembersBytes = (
let recover: ReturnType<typeof prepareManagedMemberRecovery> | undefined;
try {
const { layout, memberLists } = readMemberLayout(bytes, pe);
const methodTotal = layout.table(6)?.rowCount ?? 0;
const methodRows = {
offset: Math.min(methodOffset, methodTotal),
count: Math.min(methodLimit, Math.max(0, methodTotal - methodOffset)),
total: methodTotal,
};
const identity = readManagedModuleIdentity(bytes, layout);
appendManagedParseIssues(issues, identity.issues);
const ranges = readTypeDefinitions(bytes, layout, issues, memberLists);
@@ -252,6 +259,7 @@ export const inspectManagedMembersBytes = (
types,
nesting_read: parsedTypes.nesting.complete,
selection,
method_rows: methodRows,
limitations: [...typeIdentityLimitations, ...selectionLimitations],
},
uniqueManagedParseIssues(issues),
@@ -355,11 +363,13 @@ export const inspectManagedMembersBytes = (
unreadableSelectedTypes: unreadableNames,
})
: null;
const targets = edgeTargets(bytes, layout, methods.methods, [
fields.core,
memberRefs.core,
methods.core,
]);
const targets = edgeTargets(
bytes,
layout,
methods.methods,
[fields.core, memberRefs.core, methods.core],
signatureTypeName,
);
appendManagedParseIssues(issues, targets.issues);
const incompleteMembers =
[...fields.fields, ...memberRefs.refs, ...methods.methods].some(
@@ -396,6 +406,7 @@ export const inspectManagedMembersBytes = (
field_accesses: related.fieldAccesses,
user_strings: userStrings,
selection,
method_rows: methodRows,
coverage: {
state:
issues.length === 0 && !incompleteMembers
+41 -4
View File
@@ -83,8 +83,37 @@ export interface MemberCore {
* MethodDef outside the type selection.
*/
readonly genericArity?: number | null;
/** Nominal declaring type, name and decoded signature; never runtime binding. */
readonly identitySha256?: string | null;
}
export const memberTargetIdentity = (
name: string,
declaringToken: string | null,
signature: ManagedSignature,
typeName: SignatureTypeName,
): string | null => {
if (
declaringToken === null ||
signature.resolved_sha256 === null ||
signature.parse_status !== "decoded"
)
return null;
const table = Number.parseInt(declaringToken.slice(2, 4), 16);
if (table !== 1 && table !== 2) return null;
const owner = typeName(
Number.parseInt(declaringToken.slice(4), 16) * 4 + Number(table === 1),
);
return owner === null
? null
: createHash("sha256")
.update("managed-member-nominal-v1\0")
.update(owner)
.update(Buffer.from(signature.resolved_sha256, "hex"))
.update(name)
.digest("hex");
};
export interface ParsedInstruction {
readonly offset: number;
readonly opcode: string;
@@ -804,7 +833,7 @@ const callingConvention = (value: number): string => {
* rendered types. A malformed or unsupported blob throws invalid-blob or
* unsupported-signature under the owning row, so coverage records it.
*/
export const specSignature = (
export const specSignatureFacts = (
layout: ManagedMetadataLayout,
blob: Buffer,
owner: {
@@ -812,7 +841,11 @@ export const specSignature = (
readonly scope: string;
readonly offset: number;
},
): readonly string[] => {
typeName?: SignatureTypeName,
): {
readonly types: readonly string[];
readonly resolvedSha256: string | null;
} => {
const budget = managedDecodeBudget(layout);
// Rendering is transient, so charge it as member signatures are charged.
budget.admitTransient(blob.length * 256, owner.offset);
@@ -829,15 +862,19 @@ export const specSignature = (
cursor = header.next;
}
const types: string[] = [];
const operands: TypeOperand[] = [];
// Each Type consumes at least one byte, so a false count fails on truncation.
for (let index = 0; index < count; index += 1) {
const type = readTypeSignature(blob, cursor, "type", { layout });
const type = readTypeSignature(blob, cursor, "type", {
layout,
operands,
});
types.push(type.value);
cursor = type.next;
}
if (cursor !== blob.length)
malformedSignature("Trailing signature data", cursor);
return types;
return { types, resolvedSha256: typeName === undefined ? null : resolvedSha256(blob, operands, typeName) };
} catch (cause: unknown) {
if (
!(cause instanceof ManagedReaderFailure) ||
+1
View File
@@ -18,6 +18,7 @@ type TypeInventory = Pick<
| "types"
| "nesting_read"
| "selection"
| "method_rows"
| "limitations"
>;
+37 -7
View File
@@ -10,6 +10,7 @@ import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import {
metadataSignature,
memberTargetIdentity,
signatureGenericArity,
type ManagedCallEdge,
type ManagedExportedType,
@@ -103,6 +104,25 @@ export const parseFields = (
cursor.readIndex(layout.blobIndexSize),
layout.blob?.size ?? 0,
);
const decoded = budgetedSignature(
sig,
layout,
signatureTypeName,
issues,
`Field:${token}.signature`,
);
core.set(token, {
token,
name,
declaringType: declared?.fullName ?? null,
kind: "field",
identitySha256: memberTargetIdentity(
name,
declared?.token ?? null,
decoded,
signatureTypeName,
),
});
fields.push({
token,
row_offset: cursor.start,
@@ -110,13 +130,7 @@ export const parseFields = (
declaring_type: declared?.fullName ?? null,
name,
flags,
signature: budgetedSignature(
sig,
layout,
signatureTypeName,
issues,
`Field:${token}.signature`,
),
signature: decoded,
});
}, issues);
}
@@ -271,6 +285,12 @@ export const parseMemberRefs = (
declaringType: parentName,
kind: decoded.kind,
genericArity: signatureGenericArity(decoded),
identitySha256: memberTargetIdentity(
name,
parentToken,
decoded,
signatureTypeName,
),
});
}, issues);
}
@@ -375,6 +395,12 @@ export const parseMethods = ({
core.set(token, {
...member,
genericArity: signatureGenericArity(decoded),
identitySha256: memberTargetIdentity(
name,
declared?.token ?? null,
decoded,
signatureTypeName,
),
});
const read = methodBody(bytes, pe, rva, {
implementation: { implFlags, flags },
@@ -461,6 +487,8 @@ export const edges = (
admitManagedProjection(
{
caller_token: method.token,
il_offset: anchor.il_offset,
target_identity_sha256: target?.identitySha256 ?? null,
caller,
opcode: anchor.opcode,
target_token: operand,
@@ -477,6 +505,8 @@ export const edges = (
admitManagedProjection(
{
method_token: method.token,
il_offset: anchor.il_offset,
target_identity_sha256: target?.identitySha256 ?? null,
method: caller,
opcode: anchor.opcode,
field_token: operand,
+29 -4
View File
@@ -1,7 +1,9 @@
import { createHash } from "node:crypto";
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import {
methodGenericArity,
specSignature,
specSignatureFacts,
type SignatureTypeName,
type ManagedMethod,
type MemberCore,
} from "./ManagedMemberInspectorCore.js";
@@ -24,6 +26,7 @@ export const edgeTargets = (
layout: ManagedMetadataLayout,
methods: readonly ManagedMethod[],
cores: readonly ReadonlyMap<string, MemberCore>[],
signatureTypeName: SignatureTypeName,
): {
readonly members: ReadonlyMap<string, MemberCore>;
readonly issues: readonly ManagedParseIssue[];
@@ -39,7 +42,10 @@ export const edgeTargets = (
seen.add(operand);
readManagedValue(
() =>
readMethodSpec({ bytes, layout, members, issues, arities }, operand),
readMethodSpec(
{ bytes, layout, members, issues, arities, signatureTypeName },
operand,
),
issues,
);
}
@@ -58,12 +64,14 @@ const readMethodSpec = (
members,
issues,
arities,
signatureTypeName,
}: {
readonly bytes: Buffer;
readonly layout: ManagedMetadataLayout;
readonly members: Map<string, MemberCore>;
readonly issues: ManagedParseIssue[];
readonly arities: Map<string, number | null>;
readonly signatureTypeName: SignatureTypeName;
},
token: string,
): void => {
@@ -97,12 +105,14 @@ const readMethodSpec = (
// A MethodSpec instantiates a method, so a field target never lends it a
// name, even to a field operand.
if (target !== undefined && target.kind !== "field")
members.set(token, { ...target, token });
const types = specSignature(
members.set(token, { ...target, token, identitySha256: null });
const instantiationFacts = specSignatureFacts(
layout,
readMetadataBlob(bytes, layout, instantiation, layout.blob?.size ?? 0),
{ form: "instantiation", scope, offset: cursor.start },
signatureTypeName,
);
const { types } = instantiationFacts;
if (generic === null) return;
// Prefer the arity the target's owner decoded; read the raw signature only
// for a target nothing decoded, such as a MethodDef outside the selection.
@@ -124,6 +134,21 @@ const readMethodSpec = (
: `MethodSpec supplies ${String(types.length)} type argument(s), but its method declares ${String(arity)} generic parameter(s)`,
cursor.start,
);
if (
target !== undefined &&
target.kind === "method" &&
target.identitySha256 != null &&
instantiationFacts.resolvedSha256 !== null
)
members.set(token, {
...target,
token,
identitySha256: createHash("sha256")
.update("managed-method-instantiation-v1\0")
.update(Buffer.from(target.identitySha256, "hex"))
.update(Buffer.from(instantiationFacts.resolvedSha256, "hex"))
.digest("hex"),
});
};
/** The Signature column of a MethodDef or MemberRef row. */
+2 -2
View File
@@ -3,7 +3,7 @@ import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import {
fullName,
readCompressed,
specSignature,
specSignatureFacts,
type TypeRange,
} from "./ManagedMemberInspectorCore.js";
import {
@@ -55,7 +55,7 @@ export const typeNameLookup = (
scope: `metadata.TypeSpec:${metadataToken(27, row)}`,
offset: cursor.start,
} as const;
specSignature(layout, spec, owner);
specSignatureFacts(layout, spec, owner);
if (spec[0] !== 0x15) return null;
// GENERICINST (CLASS | VALUETYPE) TypeDefOrRefEncoded: never a TypeSpec,
// so naming never recurses.
@@ -119,6 +119,7 @@ it("summarizes selected members and pages every original collection after the fi
unmatched_type_names: ["Missing.Type"],
});
expect(view.summary.coverage).toEqual(report.coverage);
expect(view.summary.method_rows).toEqual({ offset: 0, count: 2, total: 2 });
expect(view.summary.signature_resolution).toBe("type-names-v1");
expect(view.summary.identity_scope).toEqual(report.identity_scope);
expect(view.summary).not.toHaveProperty("methods");
@@ -146,7 +147,7 @@ it("summarizes selected members and pages every original collection after the fi
expect(rows.length, collection).toBeGreaterThan(0);
expect(view.summary.reported_counts[collection]).toBe(rows.length);
const paged: unknown[] = [];
for (let offset: number | null = 0; offset !== null;) {
for (let offset: number | null = 0; offset !== null; ) {
const result = await client.callTool({
name: "inspect_analysis_view",
arguments: {
@@ -167,6 +168,7 @@ it("summarizes selected members and pages every original collection after the fi
expect(page.signature_resolution).toBe("type-names-v1");
expect(page.selection).toEqual(report.selection);
expect(page.coverage.total).toBe(rows.length);
expect(page.method_rows).toEqual(report.method_rows);
paged.push(...page.page.rows);
offset = page.coverage.next_offset;
}
@@ -333,9 +333,9 @@ describe("managed member comparison of nested types", () => {
// from TypeDef 4 on the left into TypeDef 5 on the right.
expect(pairs(compare(original, swapped))).toEqual({
methods: [
["0x06000001", "0x06000002", "unknown", "exact-il-signature", "exact"],
["0x06000002", "0x06000001", "unknown", "exact-il-signature", "exact"],
["0x06000003", "0x06000003", "unknown", "exact-il-signature", "exact"],
["0x06000001", "0x06000002", "changed", "exact-il-signature", "exact"],
["0x06000002", "0x06000001", "changed", "exact-il-signature", "exact"],
["0x06000003", "0x06000003", "changed", "exact-il-signature", "exact"],
],
fields: [
["0x04000001", "0x04000002", "unchanged", "field-signature", "exact"],
@@ -365,10 +365,10 @@ describe("managed member comparison of nested types", () => {
expect(
comparison.methods.slice(0, 2).map(({ dimensions }) => dimensions),
).toEqual([
["cil", "availability"],
["cil", "availability"],
["field-target", "cil", "availability"],
["field-target", "cil", "availability"],
]);
expect(comparison.summary).toMatchObject({ changed: 2 });
expect(comparison.summary).toMatchObject({ changed: 3 });
});
it("retains explicitly limited bare names for historical observations", () => {
@@ -580,7 +580,12 @@ describe("managed call-target comparison", () => {
(edges) =>
edges.map((edge, index) =>
index === 1
? { ...edge, target_name: null, target_declaring_type: null }
? {
...edge,
target_name: null,
target_declaring_type: null,
target_identity_sha256: null,
}
: edge,
),
);
@@ -13,6 +13,262 @@ import {
managedPeFixtureTarget,
} from "../../../../src/dotnet/ManagedPe.fixture.js";
import { buildManagedPeFixture as buildMultiMethodFixture } from "../../../../scripts/fixtures/managed/pe.mjs";
describe("managed comparison method row coverage", () => {
it("keeps paged omissions unknown while preserving full and equal-page controls", () => {
const bytes = buildMultiMethodFixture({
methods: [
{ name: "First", body: Buffer.from([0x2a]) },
{ name: "Second", body: Buffer.from([0x2a]) },
{ name: "Third", body: Buffer.from([0x2a]) },
],
});
const target = managedPeFixtureTarget(bytes);
const observe = (offset: number, limit: number) => ({
evidenceId: `ev_${target.sha256}`,
result: inspectManagedMembersBytes(bytes, target, {
methodOffset: offset,
methodLimit: limit,
}),
});
const full = observe(0, 3);
const middle = observe(1, 1);
expect(middle.result.method_rows).toEqual({
offset: 1,
count: 1,
total: 3,
});
const limited = compareManagedMembers(full, middle);
expect(limited.summary).toMatchObject({
removed: 0,
added: 0,
unknown: 2,
unchanged: 2,
});
expect(limited.coverage.status).toBe("partial");
expect(compareManagedMembers(full, full).summary).toMatchObject({
unchanged: 4,
unknown: 0,
});
expect(compareManagedMembers(full, full).coverage.status).toBe(
"complete-within-inputs",
);
expect(compareManagedMembers(middle, middle).coverage.status).toBe(
"complete-within-inputs",
);
const overlapping = compareManagedMembers(observe(0, 2), observe(1, 2));
expect(overlapping.summary).toMatchObject({
added: 0,
removed: 0,
unchanged: 2,
unknown: 2,
});
expect(compareManagedMembers(observe(0, 1), middle).summary).toMatchObject({
added: 0,
removed: 0,
unknown: 2,
});
const selected = {
...middle,
result: inspectManagedMembersBytes(bytes, target, {
typeNames: [full.result.types[0]!.full_name],
methodOffset: 1,
methodLimit: 1,
}),
};
expect(compareManagedMembers(full, selected).summary.removed).toBe(0);
const historical = {
...full,
result: managedMemberInspectionSchema.parse({
...full.result,
method_rows: undefined,
}),
};
expect(compareManagedMembers(historical, middle).summary.removed).toBe(0);
});
});
describe("managed edge nominal identity", () => {
const observe = (options: Parameters<typeof buildManagedPeFixture>[0] = {}) =>
inspect(
buildManagedPeFixture({
targetFramework: null,
memberRefName: "Ping",
memberRefSignature: Buffer.from([0, 0, 1]),
memberRefParentRaw: 25,
ilBody: Buffer.from([0x1a, 0x28, 1, 0, 0, 0x0a, 0x2a]),
typeReferences: [
{ namespace: "Fixture", name: "Outer" },
{ namespace: "", name: "Inner", enclosingRow: 2 },
],
...options,
}),
"/fixture/edges.dll",
);
it("detects an enclosing TypeRef change even when display labels and tokens are equal", () => {
const left = observe();
const right = observe({
typeReferences: [
{ namespace: "Fixture", name: "OtherOuter" },
{ namespace: "", name: "Inner", enclosingRow: 2 },
],
});
expect(left.result.call_edges[0]?.target_declaring_type).toBe(
right.result.call_edges[0]?.target_declaring_type,
);
expect(compareManagedMembers(left, right).methods[0]).toMatchObject({
status: "changed",
dimensions: ["call-target"],
});
expect(compareManagedMembers(left, left).methods[0]?.status).toBe(
"unchanged",
);
});
it("compares the decoded MemberRef signature for calls and field accesses", () => {
expect(
compareManagedMembers(
observe(),
observe({ memberRefSignature: Buffer.from([0, 1, 1, 8]) }),
).methods[0],
).toMatchObject({ status: "changed", dimensions: ["call-target"] });
const field = (type: number) =>
observe({
memberRefName: "Value",
memberRefSignature: Buffer.from([6, type]),
ilBody: Buffer.from([0x1a, 0x7e, 1, 0, 0, 0x0a, 0x2a]),
});
expect(
compareManagedMembers(field(8), field(0x0a)).methods[0],
).toMatchObject({ status: "changed", dimensions: ["field-target"] });
});
it("joins repeated calls by IL offset even when edge arrays are reordered", () => {
const source = observe({
ilBody: Buffer.from([
0x2e, 0x28, 1, 0, 0, 0x0a, 0x28, 1, 0, 0, 0x0a, 0x2a,
]),
});
expect(source.result.call_edges.map((edge) => edge.il_offset)).toEqual([
0, 5,
]);
const reordered = {
...source,
result: {
...source.result,
call_edges: [...source.result.call_edges].reverse(),
},
};
expect(compareManagedMembers(source, reordered).methods[0]?.status).toBe(
"unchanged",
);
});
it.each(["method", "field"] as const)(
"keeps a known %s target change beside an omitted edge",
(kind) => {
const opcode = kind === "method" ? 0x28 : 0x7e;
const args = {
ilBody: Buffer.from([
0x2e,
opcode,
1,
0,
0,
0x0a,
opcode,
1,
0,
0,
0x0a,
0x2a,
]),
memberRefSignature:
kind === "method" ? Buffer.from([0, 0, 1]) : Buffer.from([6, 8]),
};
const left = observe(args);
const right = observe({ ...args, memberRefName: "Changed" });
const partial = {
...right,
result: {
...right.result,
...(kind === "method"
? { call_edges: right.result.call_edges.slice(0, 1) }
: { field_accesses: right.result.field_accesses.slice(0, 1) }),
},
};
expect(compareManagedMembers(left, partial).methods[0]).toMatchObject({
status: "changed",
dimensions: expect.arrayContaining([
kind === "method" ? "call-target" : "field-target",
"availability",
]),
});
},
);
it("compares validated MethodSpec base identities and resolved generic arguments", () => {
const options = {
memberRefSignature: Buffer.from([0x10, 1, 0, 1]),
methodSpecMethods: [3],
methodSpecInstantiation: Buffer.from([0x0a, 1, 8]),
ilBody: Buffer.from([0x1a, 0x28, 1, 0, 0, 0x2b, 0x2a]),
};
const source = observe(options);
expect(compareManagedMembers(source, source).methods[0]?.status).toBe(
"unchanged",
);
const changedArgument = observe({
...options,
methodSpecInstantiation: Buffer.from([0x0a, 1, 0x0a]),
});
const changedBase = observe({ ...options, memberRefName: "OtherGeneric" });
for (const changed of [changedArgument, changedBase])
expect(compareManagedMembers(source, changed).methods[0]).toMatchObject({
status: "changed",
dimensions: ["call-target"],
});
});
it("retains unknown identity for cyclic scope, TypeSpec and historical edges", () => {
const cyclic = observe({
typeReferences: [
{ namespace: "Fixture", name: "Outer", enclosingRow: 3 },
{ namespace: "", name: "Inner", enclosingRow: 2 },
],
});
expect(compareManagedMembers(cyclic, cyclic).methods[0]?.status).toBe(
"unknown",
);
const spec = observe({
memberRefParentRaw: 12,
typeSpecSignatures: [Buffer.from([0x15, 0x12, 9, 1, 8])],
});
expect(compareManagedMembers(spec, spec).methods[0]?.status).toBe(
"unknown",
);
const source = observe();
const historical = {
...source,
result: managedMemberInspectionSchema.parse({
...source.result,
call_edges: source.result.call_edges.map(
({
il_offset: _offset,
target_identity_sha256: _identity,
...edge
}) => edge,
),
}),
};
expect(compareManagedMembers(source, historical).methods[0]?.status).toBe(
"unknown",
);
});
});
describe("managed operand-free instruction structural matching", () => {
it("preserves operand-free opcode order when remapping renamed methods", () => {
const observe = (
@@ -151,16 +407,13 @@ describe("managed field target comparison", () => {
});
const base = inspect(bytes, "/tmp/field-target-base.dll");
const counterpart = inspect(bytes, "/tmp/field-target-counterpart.dll");
const changedTarget = {
...counterpart,
result: managedMemberInspectionSchema.parse({
...counterpart.result,
field_accesses: counterpart.result.field_accesses.map((access) => ({
...access,
field_name: "differentField",
})),
const changedTarget = inspect(
buildManagedPeFixture({
fieldName: "differentField",
ilBody: Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]),
}),
};
"/tmp/field-target-changed.dll",
);
const comparison = compareManagedMembers(base, changedTarget);
expect(comparison.methods[0]).toMatchObject({
@@ -174,6 +427,7 @@ describe("managed field target comparison", () => {
...counterpart.result,
field_accesses: counterpart.result.field_accesses.map((access) => ({
...access,
target_identity_sha256: null,
field_name: null,
field_declaring_type: null,
})),
@@ -1007,10 +1261,10 @@ describe("managed member comparison of undecoded signatures", () => {
expect(left.result.fields[0]?.signature.parse_status).toBe("unsupported");
const result = compareManagedMembers(left, right);
expect(result.summary).toMatchObject({
unchanged: 2,
unchanged: 1,
added: 0,
removed: 0,
unknown: 0,
unknown: 1,
});
expect(result.methods[0]?.match).toMatchObject({
status: "matched",