From 357e354ac7f5aed8e90c209dff30327d6bf212dc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:34:47 +0800 Subject: [PATCH] fix(managed): preserve member scope and nominal edge identity --- docs/managed-code-analysis.md | 21 +- .../managed/managedWorkflowExamples.ts | 1 + src/domain/analysisView/managedMemberView.ts | 3 + src/domain/managed/managedArtifact.ts | 25 ++ src/domain/managed/managedMemberComparison.ts | 7 + .../managedMemberComparisonCoverage.ts | 26 +- .../managed/managedMemberComparisonItems.ts | 184 ++++++------ .../managed/managedMemberComparisonMatch.ts | 21 +- src/dotnet/ManagedMemberInspector.test.ts | 4 + src/dotnet/ManagedMemberInspector.ts | 21 +- src/dotnet/ManagedMemberInspectorCore.ts | 45 ++- src/dotnet/ManagedMemberRecovery.ts | 1 + src/dotnet/ManagedMemberRows.ts | 44 ++- src/dotnet/ManagedMethodSpecs.ts | 33 ++- src/dotnet/ManagedTypeNames.ts | 4 +- .../mcp/managedMemberSummaryMcp.test.ts | 4 +- .../managed/managedMemberComparison.test.ts | 19 +- ...anagedMemberComparisonFromMetadata.test.ts | 276 +++++++++++++++++- 18 files changed, 605 insertions(+), 134 deletions(-) diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index c9f656d7ea..e442ba8965 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -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. diff --git a/src/contracts/managed/managedWorkflowExamples.ts b/src/contracts/managed/managedWorkflowExamples.ts index 367ee9a88f..5242fc18d0 100644 --- a/src/contracts/managed/managedWorkflowExamples.ts +++ b/src/contracts/managed/managedWorkflowExamples.ts @@ -50,6 +50,7 @@ const emptyManagedMembers = ( }, types: [], nesting_read: true, + method_rows: { offset: 0, count: 0, total: 0 }, exported_types: [], fields: [], methods: [], diff --git a/src/domain/analysisView/managedMemberView.ts b/src/domain/analysisView/managedMemberView.ts index 0c878aa231..57016e9119 100644 --- a/src/domain/analysisView/managedMemberView.ts +++ b/src/domain/analysisView/managedMemberView.ts @@ -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 diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index daea732d4e..994f21484e 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -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), diff --git a/src/domain/managed/managedMemberComparison.ts b/src/domain/managed/managedMemberComparison.ts index 82d8b6f634..aaeadef15d 100644 --- a/src/domain/managed/managedMemberComparison.ts +++ b/src/domain/managed/managedMemberComparison.ts @@ -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: diff --git a/src/domain/managed/managedMemberComparisonCoverage.ts b/src/domain/managed/managedMemberComparisonCoverage.ts index e2ca6529ff..6dd2d0903b 100644 --- a/src/domain/managed/managedMemberComparisonCoverage.ts +++ b/src/domain/managed/managedMemberComparisonCoverage.ts @@ -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.", diff --git a/src/domain/managed/managedMemberComparisonItems.ts b/src/domain/managed/managedMemberComparisonItems.ts index d960257f3e..02d3fab0d3 100644 --- a/src/domain/managed/managedMemberComparisonItems.ts +++ b/src/domain/managed/managedMemberComparisonItems.ts @@ -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 | null; readonly rightSelection: ReadonlySet | 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(); + 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 diff --git a/src/domain/managed/managedMemberComparisonMatch.ts b/src/domain/managed/managedMemberComparisonMatch.ts index 3690a3a562..ebf64e1076 100644 --- a/src/domain/managed/managedMemberComparisonMatch.ts +++ b/src/domain/managed/managedMemberComparisonMatch.ts @@ -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 => ({ const matchMethods = ( left: readonly Keyed[], right: readonly Keyed[], + 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[], right: readonly Keyed[], + 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), ), diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 5d3ce5b902..59a26fa4a2 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -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", diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 217dc2b4fe..7e6d0381a7 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -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 | 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 diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index 9d6e41b83e..b6175b8b8a 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -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) || diff --git a/src/dotnet/ManagedMemberRecovery.ts b/src/dotnet/ManagedMemberRecovery.ts index bf2e1395f8..00ad4a9a5e 100644 --- a/src/dotnet/ManagedMemberRecovery.ts +++ b/src/dotnet/ManagedMemberRecovery.ts @@ -18,6 +18,7 @@ type TypeInventory = Pick< | "types" | "nesting_read" | "selection" + | "method_rows" | "limitations" >; diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 290e31e7a5..bfe5c950fe 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -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, diff --git a/src/dotnet/ManagedMethodSpecs.ts b/src/dotnet/ManagedMethodSpecs.ts index 959e119b7f..fd97737dc3 100644 --- a/src/dotnet/ManagedMethodSpecs.ts +++ b/src/dotnet/ManagedMethodSpecs.ts @@ -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[], + signatureTypeName: SignatureTypeName, ): { readonly members: ReadonlyMap; 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; readonly issues: ManagedParseIssue[]; readonly arities: Map; + 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. */ diff --git a/src/dotnet/ManagedTypeNames.ts b/src/dotnet/ManagedTypeNames.ts index d4cae10b56..845ad145e8 100644 --- a/src/dotnet/ManagedTypeNames.ts +++ b/src/dotnet/ManagedTypeNames.ts @@ -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. diff --git a/tests/boundary/mcp/managedMemberSummaryMcp.test.ts b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts index be0d05eb45..c188c2b4d3 100644 --- a/tests/boundary/mcp/managedMemberSummaryMcp.test.ts +++ b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts @@ -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; } diff --git a/tests/boundary/providers/managed/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts index 3d48bdf2c1..36143b90bd 100644 --- a/tests/boundary/providers/managed/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -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, ), ); diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index b754b55d5d..07b02965d9 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -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[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",