From c9bf6a9df712f8e968ac76147c97457ee911f10a Mon Sep 17 00:00:00 2001 From: LN Bodicherla <119286838+ln-bodicherla@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:34:42 -0700 Subject: [PATCH] feat(managed)!: show readable type names in member signatures (#5309) feat(managed): show readable type names in member signatures Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- docs/managed-code-analysis.md | 14 +- src/domain/managed/managedArtifact.ts | 4 +- src/dotnet/ManagedMemberInspector.test.ts | 4 +- src/dotnet/ManagedMemberInspector.ts | 18 +- src/dotnet/ManagedMemberInspectorCore.ts | 43 ++- src/dotnet/ManagedMemberRows.ts | 20 + src/dotnet/ManagedMetadataInventoryRows.ts | 2 +- src/dotnet/ManagedSignatureResolution.test.ts | 355 +++++++++++++++++- ...t.ts => ManagedSignatureTypeNames.test.ts} | 26 +- ...igests.ts => ManagedSignatureTypeNames.ts} | 56 ++- tests/acceptance/managedCliOutput.test.ts | 44 +++ .../mcp/managedMemberSummaryMcp.test.ts | 55 +-- 12 files changed, 566 insertions(+), 75 deletions(-) rename src/dotnet/{ManagedTypeNameDigests.test.ts => ManagedSignatureTypeNames.test.ts} (81%) rename src/dotnet/{ManagedTypeNameDigests.ts => ManagedSignatureTypeNames.ts} (82%) diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 2d3df2f071..35e46dd841 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -476,8 +476,18 @@ raw digest stands in. Missing or unreadable TypeDef and TypeRef operands are reported as coverage issues. Invalid NestedClass rows retain their failure reasons and withhold TypeDef signature identities; type names and nesting are parsed once by their shared owner. Each current inspection records -`signature_resolution: "type-names-v1"`, even when none of its operands can be -resolved. Historical Evidence defaults this attestation to `null`. When either +`signature_resolution: "type-names-v2"`, even when none of its operands can be +resolved. Version 2 also appends readable names to signature operands, for +example `class:7772 (AnalysisJob)`. Coded indexes remain build-local references; +names are display text and do not change raw or resolved signature digests. +Nested names use `Outer/Inner`. Generic instantiations keep their decoded +arguments, for example ``class:9 (System.Collections.Generic.List`1)``; +custom modifiers are named too. Valid but unresolved TypeSpecs, invalid scopes, +unreadable type names, cycles and nesting beyond the cap keep their raw operand +spelling. Invalid or missing metadata coordinates retain the existing malformed +signature result and source issue; rendering does not bypass validation. +Version 1 Evidence remains readable and compares using the same digest policy. +Historical Evidence defaults this attestation to `null`. When either observation predates resolution, both sides compare raw digests, which pair only where type rows agree, and the comparison reports that limitation. The signature is exact whether or not REA decoded it, so undecoded signatures pair by that tuple too, but they diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index 18246a7863..67eb604856 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -514,11 +514,11 @@ export const managedArtifactInspectionSchema = z.object({ /** Provider-neutral, execution-free metadata/signature/IL member inspection. */ export const managedMemberInspectionSchema = z.object({ signature_resolution: z - .literal("type-names-v1") + .enum(["type-names-v1", "type-names-v2"]) .nullable() .default(null) .describe( - "Signature type-name resolution was examined, including unresolved operands; null means no attestation, including historical or capacity-limited Evidence.", + "Signature type-name resolution was examined, including unresolved operands; v2 also renders readable operand names, v1 retains raw operand text; null means no attestation, including historical or capacity-limited Evidence.", ), artifact: managedArtifactReferenceSchema, module: moduleIdentitySchema.nullable(), diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index e110048c4f..bd1045a837 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -2504,12 +2504,12 @@ it("decodes generic instantiation member signatures as complete coverage", () => expect(result.methods[0]?.signature).toMatchObject({ parse_status: "decoded", return_type: "void", - parameter_types: ["class:9"], + parameter_types: ["class:9 (System.Func`1)"], issue: null, }); expect(result.fields[0]?.signature).toMatchObject({ parse_status: "decoded", - field_type: "valuetype:13", + field_type: "valuetype:13 (System.ValueTuple`2)", }); expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); }); diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index b2d604c8b2..b1291293af 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -34,7 +34,7 @@ import { type DecodedTypeDefinition, } from "./ManagedMemberInspectorCore.js"; import { edgeTargets } from "./ManagedMethodSpecs.js"; -import { managedTypeNameDigests } from "./ManagedTypeNameDigests.js"; +import { managedSignatureTypeNames } from "./ManagedSignatureTypeNames.js"; import { typeNameLookup } from "./ManagedTypeNames.js"; import { managedTypeReferences } from "./ManagedTypeReferences.js"; import { parseUserStrings } from "./ManagedUserStrings.js"; @@ -81,7 +81,7 @@ const unavailable = ( requires_mvid: null, }, types: [], - signature_resolution: "type-names-v1", + signature_resolution: "type-names-v2", nesting_read: false, exported_types: null, user_strings: null, @@ -211,8 +211,11 @@ export const inspectManagedMembersBytes = ( typeNames === undefined ? 0 : ranges.length - parsedTypes.types.length; appendManagedParseIssues(issues, parsedTypes.issues); const typeReferences = managedTypeReferences(bytes, layout, issues); - const { operand: signatureTypeName, declaration: declarationTypeName } = - managedTypeNameDigests(layout, issues, parsedTypes, typeReferences); + const { + operand: signatureTypeName, + declaration: declarationTypeName, + displayName: displayTypeName, + } = managedSignatureTypeNames(layout, issues, parsedTypes, typeReferences); const types = parsedTypes.types .filter(({ type }) => selected === null || selected.has(type.token)) .map(({ row, type }) => { @@ -282,6 +285,7 @@ export const inspectManagedMembersBytes = ( appendManagedParseIssues(issues, exportedTypes.issues); const fields = parseFields(bytes, layout, ranges, memberLists, { signatureTypeName, + displayTypeName, declarationTypeName, selected, }); @@ -295,6 +299,7 @@ export const inspectManagedMembersBytes = ( const memberRefs = parseMemberRefs(bytes, layout, { typeName: resolveTypeName, signatureTypeName, + displayTypeName, declarationTypeName, }); appendManagedParseIssues(issues, memberRefs.issues); @@ -304,6 +309,7 @@ export const inspectManagedMembersBytes = ( pe, ranges, signatureTypeName, + displayTypeName, declarationTypeName, memberRefKinds: new Map( memberRefs.refs.flatMap((memberRef) => @@ -394,7 +400,7 @@ export const inspectManagedMembersBytes = ( }, assembly: inventory.assembly, types, - signature_resolution: "type-names-v1", + signature_resolution: "type-names-v2", nesting_read: parsedTypes.nesting.complete, exported_types: exportedTypes.exportedTypes, fields: fields.fields, @@ -418,7 +424,7 @@ export const inspectManagedMembersBytes = ( "CIL instruction anchors are decoded from file-backed method bodies only; no target assembly is loaded or executed.", "Exported types are reported from ExportedType rows as declared; forwarded types are not resolved in their implementing assemblies.", "User strings are the #US literals loaded by ldstr in the inspected method bodies, with their own coverage; instruction anchors and normalized CIL digests keep their build-local tokens.", - "Member signatures decode ECMA-335 primitive, class, valuetype, generic instantiation, generic variable, pointer, byref, single-dimension and general array, function pointer including VARARG sentinels, custom modifier, and typed reference forms; class and valuetype operands keep their encoded TypeDefOrRefOrSpec values.", + "Member signatures decode ECMA-335 primitive, class, valuetype, generic instantiation, generic variable, pointer, byref, single-dimension and general array, function pointer including VARARG sentinels, custom modifier, and typed reference forms; type operands keep their encoded TypeDefOrRefOrSpec values and append readable TypeDef/TypeRef names when resolved.", ...selectionLimitations, ], }, diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index 378fdf4318..ad5ff130d9 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -578,6 +578,7 @@ const readTypeSignature = ( layout, operands, onElement, + displayTypeName, }: { /** Validate each type operand against these tables, as specs require. */ readonly layout?: ManagedMetadataLayout; @@ -585,6 +586,7 @@ const readTypeSignature = ( readonly operands?: TypeOperand[]; /** Observe parsed element kinds without reinterpreting signature bytes. */ readonly onElement?: (kind: number) => void; + readonly displayTypeName?: SignatureDisplayTypeName | undefined; } = {}, ): { readonly value: string; @@ -592,6 +594,15 @@ const readTypeSignature = ( readonly nominalGenericType: NominalGenericType | null; } => { const stack: TypeContinuation[] = []; + const namedOperand = ( + kind: string, + operand: Parameters[0], + ): string => { + // Enclosing continuations copy names during rendering; admit first. + const name = displayTypeName?.(operand, (stack.length + 1) * 2); + const raw = `${kind}:${String(operand.value)}`; + return name === undefined || name === null ? raw : `${raw} (${name})`; + }; let cursor = offset; let nominalGenericType: NominalGenericType | null = null; let pinned = false; @@ -676,7 +687,7 @@ const readTypeSignature = ( ); const token = readTypeOperand(blob, cursor + 1, layout); operands?.push({ offset: cursor + 1, ...token }); - const modifier = `${kind === 0x1f ? "modreq" : "modopt"}:${String(token.value)}`; + const modifier = namedOperand(kind === 0x1f ? "modreq" : "modopt", token); // A modifier frame on top was pushed by the directly preceding byte; // keep an adjacent run together and in source order. const top = stack.at(-1); @@ -686,7 +697,7 @@ const readTypeSignature = ( } else if (kind === 0x11 || kind === 0x12) { const token = readTypeOperand(blob, cursor + 1, layout); operands?.push({ offset: cursor + 1, ...token }); - value = `${kind === 0x11 ? "valuetype" : "class"}:${String(token.value)}`; + value = namedOperand(kind === 0x11 ? "valuetype" : "class", token); cursor = token.next; } else if (kind === 0x1e || kind === 0x13) { const variable = readCompressed(blob, cursor + 1); @@ -731,7 +742,10 @@ const readTypeSignature = ( "generic instantiation has no type arguments", token.next, ); - const head = `${generic === 0x11 ? "valuetype" : "class"}:${String(token.value)}`; + const head = namedOperand( + generic === 0x11 ? "valuetype" : "class", + token, + ); stack.push({ kind: "list", remaining: argumentCount.value, @@ -975,6 +989,7 @@ const decodeSignature = ( operands: TypeOperand[] = [], issueSink?: (issue: ManagedParseIssue) => void, scope = "signature", + displayTypeName?: SignatureDisplayTypeName, ): DecodedSignature => { let kind: DecodedSignature["kind"] = "unknown"; try { @@ -987,6 +1002,7 @@ const decodeSignature = ( kind = "field"; const fieldType = readTypeSignature(blob, 1, "field", { operands, + displayTypeName, ...(layout === undefined ? {} : { layout }), }); return { @@ -1006,6 +1022,7 @@ const decodeSignature = ( kind = "method"; const returnType = readTypeSignature(blob, header.next, "return", { operands, + displayTypeName, ...(layout === undefined ? {} : { layout }), }); let offset = returnType.next; @@ -1025,6 +1042,7 @@ const decodeSignature = ( } const parameter = readTypeSignature(blob, offset, "parameter", { operands, + displayTypeName, ...(layout === undefined ? {} : { layout }), }); parameters.push(parameter.value); @@ -1110,6 +1128,12 @@ export type SignatureTypeName = ( export type DeclarationTypeName = (table: 1 | 2, row: number) => Buffer | null; +/** Display an already decoded operand, admitting its transient copies first. */ +export type SignatureDisplayTypeName = ( + operand: Parameters[0], + copies: number, +) => string | null; + /** * Each type operand is replaced by 0xff, which never starts a compressed * coded index, then the 32-byte name digest; a blob without type operands @@ -1165,13 +1189,21 @@ export const signature = ( blob: Buffer, rawSha256?: string, typeName: SignatureTypeName = () => null, + displayTypeName?: SignatureDisplayTypeName, ): ManagedSignature => { const operands: TypeOperand[] = []; return projectSignature( blob, rawSha256, typeName, - decodeSignature(blob, undefined, operands), + decodeSignature( + blob, + undefined, + operands, + undefined, + "signature", + displayTypeName, + ), operands, ); }; @@ -1187,6 +1219,7 @@ export const metadataSignature = ( scope, blobOffset, expectedKind, + displayTypeName, }: { readonly rawSha256: string; readonly typeName: SignatureTypeName; @@ -1196,6 +1229,7 @@ export const metadataSignature = ( /** Artifact file offset of the blob's first byte. */ readonly blobOffset: number; readonly expectedKind?: "field" | "method"; + readonly displayTypeName?: SignatureDisplayTypeName | undefined; }, ): ManagedSignature => { const operands: TypeOperand[] = []; @@ -1205,6 +1239,7 @@ export const metadataSignature = ( operands, (issue) => issues.push(artifactSignatureIssue(issue, scope, blobOffset)), scope, + displayTypeName, ); if ( expectedKind !== undefined && diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index afe272d9ce..4e4c128a92 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -27,6 +27,7 @@ import { type DecodedMethod, type MemberCore, type SignatureTypeName, + type SignatureDisplayTypeName, type DeclarationTypeName, type TypeRange, } from "./ManagedMemberInspectorCore.js"; @@ -65,10 +66,12 @@ export const parseFields = ( memberLists: TypeMemberListFacts, { signatureTypeName, + displayTypeName, declarationTypeName, selected = null, }: { readonly signatureTypeName: SignatureTypeName; + readonly displayTypeName: SignatureDisplayTypeName; readonly declarationTypeName: DeclarationTypeName; readonly selected?: SelectedTypeTokens; }, @@ -117,6 +120,7 @@ export const parseFields = ( sig, layout, signatureTypeName, + displayTypeName, issues, { scope: `Field:${token}.signature`, kind: "field", artifact: bytes }, ); @@ -219,12 +223,14 @@ export const parseMemberRefs = ( { typeName, signatureTypeName, + displayTypeName, declarationTypeName, }: { readonly typeName: ( reference: Pick | null, ) => string | null; readonly signatureTypeName: SignatureTypeName; + readonly displayTypeName: SignatureDisplayTypeName; readonly declarationTypeName: DeclarationTypeName; }, ): { @@ -268,6 +274,7 @@ export const parseMemberRefs = ( sig, layout, signatureTypeName, + displayTypeName, issues, { scope: `MemberRef:${token}.signature`, artifact: bytes }, ); @@ -303,6 +310,7 @@ interface ParseMethodsInput { readonly pe: ManagedPeLayout; readonly ranges: readonly TypeRange[]; readonly signatureTypeName: SignatureTypeName; + readonly displayTypeName: SignatureDisplayTypeName; readonly declarationTypeName: DeclarationTypeName; readonly memberRefKinds: ReadonlyMap; readonly memberLists: TypeMemberListFacts; @@ -317,6 +325,7 @@ export const parseMethods = ({ pe, ranges, signatureTypeName, + displayTypeName, declarationTypeName, memberRefKinds, memberLists, @@ -396,6 +405,7 @@ export const parseMethods = ({ sig, layout, signatureTypeName, + displayTypeName, issues, { scope: `MethodDef:${token}.signature`, @@ -568,6 +578,7 @@ const budgetedSignature = ( bytes: Buffer, layout: ManagedMetadataLayout, signatureTypeName: SignatureTypeName, + displayTypeName: SignatureDisplayTypeName, issues: ManagedParseIssue[], owner: { readonly scope: string; @@ -586,6 +597,15 @@ const budgetedSignature = ( metadataSignature(bytes, { rawSha256: budget.digest(bytes, "sha256"), typeName: signatureTypeName, + displayTypeName: (operand, copies) => { + const name = displayTypeName(operand, copies); + if (name !== null) + budget.admitTransient( + (name.length * 6 + 2) * copies, + layout.blob?.offset ?? layout.rootOffset, + ); + return name; + }, layout, issues, scope: owner.scope, diff --git a/src/dotnet/ManagedMetadataInventoryRows.ts b/src/dotnet/ManagedMetadataInventoryRows.ts index 7c0f06d05c..96b479d9f4 100644 --- a/src/dotnet/ManagedMetadataInventoryRows.ts +++ b/src/dotnet/ManagedMetadataInventoryRows.ts @@ -2,7 +2,7 @@ import { readMemberReferenceRowFacts } from "./ManagedMemberReferenceRows.js"; import { readTypeDefinitionRowFacts } from "./ManagedTypeDefinitionRows.js"; import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; import { managedTypeReferences } from "./ManagedTypeReferences.js"; -import { MAX_TYPE_NESTING_DEPTH } from "./ManagedTypeNameDigests.js"; +import { MAX_TYPE_NESTING_DEPTH } from "./ManagedSignatureTypeNames.js"; import { readManagedTypeNesting } from "./ManagedTypeNesting.js"; import { memberDeclaringTypeRowLookup } from "./ManagedMemberOwnership.js"; import type { TypeMemberListFacts } from "./ManagedTypeMemberLists.js"; diff --git a/src/dotnet/ManagedSignatureResolution.test.ts b/src/dotnet/ManagedSignatureResolution.test.ts index 1a2e605fa5..d33066ba91 100644 --- a/src/dotnet/ManagedSignatureResolution.test.ts +++ b/src/dotnet/ManagedSignatureResolution.test.ts @@ -1,3 +1,4 @@ +import { fc } from "@fast-check/vitest"; import { createHash } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; @@ -23,12 +24,14 @@ import { managedPeFixtureTarget, } from "./ManagedPe.fixture.js"; import { readManagedPeLayout } from "./ManagedPeReader.js"; -import { managedTypeNameDigests } from "./ManagedTypeNameDigests.js"; +import { compareManagedMembers } from "../domain/managed/managedMemberComparison.js"; +import { managedMemberInspectionSchema } from "../domain/managed/managedArtifact.js"; +import { managedSignatureTypeNames } from "./ManagedSignatureTypeNames.js"; import { metadataCodedIndexCoordinate } from "./ManagedMetadataHeaps.js"; import { managedTypeReferences } from "./ManagedTypeReferences.js"; import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js"; -// A top-level type's name digest, as ManagedTypeNameDigests forms it. +// A top-level type's name digest, as ManagedSignatureTypeNames forms it. const nameDigest = (name: string): Buffer => { const dot = name.lastIndexOf("."); return createHash("sha256") @@ -149,7 +152,7 @@ describe("metadata type-name digest ownership", () => { const bytes = buildManagedPeFixture(); const layout = metadataLayout(bytes); const issues: ManagedParseIssue[] = []; - const names = managedTypeNameDigests( + const names = managedSignatureTypeNames( layout, issues, { types: [], nesting: { enclosing: new Map(), complete: true } }, @@ -187,7 +190,7 @@ describe("metadata type-name digest ownership", () => { const layout = metadataLayout(bytes); const budget = new ManagedDecodeBudget(); attachManagedDecodeBudget(layout, budget); - const typeNames = managedTypeNameDigests( + const typeNames = managedSignatureTypeNames( layout, [], { @@ -206,6 +209,7 @@ describe("metadata type-name digest ownership", () => { const reserve = vi.spyOn(budget, "reserve"); expect(typeNames.operand(operand)).toEqual(nameDigest("System.Uri")); expect(typeNames.declaration(1, 2)).toEqual(nameDigest("System.Uri")); + expect(typeNames.displayName(operand)).toBe("System.Uri"); expect(reserve).not.toHaveBeenCalled(); }); @@ -246,7 +250,7 @@ describe("metadata type-name digest ownership", () => { readTypeMemberListFacts(bytes, layout), ), ); - const resolve = managedTypeNameDigests( + const resolve = managedSignatureTypeNames( layout, issues, parsedTypes, @@ -404,3 +408,344 @@ describe("type operand names", () => { }, ); }); + +const inspectDisplayFixture = ( + options: Parameters[0], +) => { + const bytes = buildManagedPeFixture({ + typeSpecSignatures: [Buffer.from([0x08])], + ...options, + }); + return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes)); +}; +const displayReferences = [ + { namespace: "N", name: "G`1" }, + { namespace: "N", name: "Value" }, +]; + +describe("readable signature operands", () => { + it("names fields, method returns and parameters, and member references", () => { + const result = inspectDisplayFixture({ + fieldSignature: Buffer.from([0x06, 0x12, 0x04]), + methodSignature: Buffer.from([0x00, 0x01, 0x12, 0x04, 0x11, 0x09]), + memberRefSignature: Buffer.from([0x00, 0x01, 0x12, 0x09, 0x12, 0x04]), + typeReferences: displayReferences, + }); + expect(result.signature_resolution).toBe("type-names-v2"); + expect(result.fields[0]?.signature.field_type).toBe( + "class:4 (Fixture.Program)", + ); + expect(result.methods[0]?.signature).toMatchObject({ + return_type: "class:4 (Fixture.Program)", + parameter_types: ["valuetype:9 (N.G`1)"], + }); + expect(result.member_refs[0]?.signature).toMatchObject({ + return_type: "class:9 (N.G`1)", + parameter_types: ["class:4 (Fixture.Program)"], + }); + expect(result.coverage).toEqual({ state: "complete", issues: [] }); + }); + + it.each([ + ["class", [0x12, 0x09], "class:9 (N.G`1)"], + ["value type", [0x11, 0x0d], "valuetype:13 (N.Value)"], + ["pointer", [0x0f, 0x12, 0x09], "class:9 (N.G`1)*"], + ["byref", [0x10, 0x12, 0x09], "class:9 (N.G`1)&"], + ["vector", [0x1d, 0x12, 0x09], "class:9 (N.G`1)[]"], + [ + "ranked array", + [0x14, 0x12, 0x09, 0x02, 0x00, 0x00], + "class:9 (N.G`1)[,]", + ], + [ + "required modifier", + [0x1f, 0x0d, 0x12, 0x09], + "class:9 (N.G`1) modreq:13 (N.Value)", + ], + [ + "optional modifier", + [0x20, 0x0d, 0x12, 0x09], + "class:9 (N.G`1) modopt:13 (N.Value)", + ], + [ + "generic argument", + [0x15, 0x12, 0x09, 0x01, 0x11, 0x0d], + "class:9 (N.G`1)", + ], + [ + "generic variable", + [0x15, 0x12, 0x09, 0x01, 0x13, 0x00], + "class:9 (N.G`1)", + ], + [ + "function pointer", + [0x1b, 0x00, 0x01, 0x12, 0x09, 0x11, 0x0d], + "fnptr[default](valuetype:13 (N.Value))->class:9 (N.G`1)", + ], + [ + "mixed resolved and unresolved", + [0x15, 0x12, 0x09, 0x01, 0x12, 0x06], + "class:9 (N.G`1)", + ], + ])("preserves %s grammar and signature hashes", (_label, type, expected) => { + const blob = Buffer.from([0x06, ...(type as number[])]); + const result = inspectDisplayFixture({ + fieldSignature: blob, + typeReferences: displayReferences, + }); + const decoded = result.fields[0]?.signature; + const names = named({ 9: "N.G`1", 13: "N.Value" }); + const original = signature(blob, undefined, names); + expect(decoded?.field_type).toBe(expected); + expect(decoded?.parse_status).toBe(original.parse_status); + expect(decoded?.raw_sha256).toBe(original.raw_sha256); + expect(decoded?.resolved_sha256).toBe(original.resolved_sha256); + }); +}); + +describe("signature display name validity", () => { + it("resolves types outside the selected type inventory", () => { + const result = inspectDisplayFixture({ + additionalEmptyTypes: 1, + fieldSignature: Buffer.from([0x06, 0x12, 0x04]), + }); + expect(result.fields[0]?.signature.field_type).toBe( + "class:4 (Fixture.Empty0)", + ); + const bytes = buildManagedPeFixture({ + additionalEmptyTypes: 1, + fieldSignature: Buffer.from([0x06, 0x12, 0x04]), + }); + const selected = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + { typeNames: ["Fixture.Program"] }, + ); + expect(selected.fields[0]?.signature).toEqual(result.fields[0]?.signature); + }); + + it("uses the enclosing path for nested TypeDefs", () => { + const result = inspectDisplayFixture({ + additionalEmptyTypes: 1, + nestedTypes: [[2, 1]], + fieldSignature: Buffer.from([0x06, 0x12, 0x08]), + }); + expect(result.fields[0]?.signature.field_type).toBe( + "class:8 (Fixture.Empty0/Fixture.Program)", + ); + }); + + it("uses the enclosing path for nested TypeRefs", () => { + const result = inspectDisplayFixture({ + typeReferences: [ + { namespace: "N", name: "Outer" }, + { namespace: "", name: "Inner", enclosingRow: 2 }, + ], + fieldSignature: Buffer.from([0x06, 0x12, 0x0d]), + }); + expect(result.fields[0]?.signature.field_type).toBe( + "class:13 (N.Outer/Inner)", + ); + }); + + it.each(["", "名", "A.B", "G`1", "A (B)/D", "\uFEFFName"])( + "preserves source name %j", + (name) => { + const result = inspectDisplayFixture({ + typeReferences: [{ namespace: "", name }], + fieldSignature: Buffer.from([0x06, 0x12, 0x09]), + }); + expect(result.fields[0]?.signature.field_type).toBe(`class:9 (${name})`); + }, + ); + + it.each([ + ["TypeSpec", 0x06, "class:6", "decoded"], + ["missing TypeRef", 0x25, null, "malformed"], + ["missing TypeDef", 0x24, null, "malformed"], + ["invalid tag", 0x07, null, "malformed"], + ["null TypeDef", 0x00, null, "malformed"], + ] as const)( + "preserves validity of unresolved %s operands", + (_label, operand, display, status) => { + const result = inspectDisplayFixture({ + fieldSignature: Buffer.from([0x06, 0x12, operand]), + }); + expect(result.fields[0]?.signature.field_type).toBe(display); + expect(result.fields[0]?.signature.parse_status).toBe(status); + expect(result.fields[0]?.signature.resolved_sha256).toBeNull(); + }, + ); + + it("keeps invalid-scope TypeRefs raw and retains their issue once", () => { + const result = inspectDisplayFixture({ + typeRefScopeRaw: (99 << 2) | 2, + fieldSignature: Buffer.from([0x06, 0x12, 0x05]), + methodSignature: Buffer.from([0x00, 0x01, 0x12, 0x05, 0x12, 0x05]), + }); + expect(result.fields[0]?.signature.field_type).toBe("class:5"); + expect(result.methods[0]?.signature.parameter_types).toEqual(["class:5"]); + expect(result.coverage.issues).toHaveLength(1); + }); + + it("keeps cyclic TypeRefs raw", () => { + const result = inspectDisplayFixture({ + typeReferences: [ + { namespace: "", name: "A", enclosingRow: 3 }, + { namespace: "", name: "B", enclosingRow: 2 }, + ], + fieldSignature: Buffer.from([0x06, 0x12, 0x09]), + }); + expect(result.fields[0]?.signature.field_type).toBe("class:9"); + expect(result.coverage.state).toBe("partial"); + }); + + it.each([ + ["truncated", [0x06, 0x12], "malformed"], + ["truncated compressed index", [0x06, 0x12, 0x80], "malformed"], + ["unknown element", [0x06, 0x50], "unsupported"], + ["trailing bytes", [0x06, 0x12, 0x04, 0x08], "partial"], + ])("retains %s parse status and raw hash", (_label, bytes, status) => { + const blob = Buffer.from(bytes as number[]); + const result = inspectDisplayFixture({ fieldSignature: blob }); + expect(result.fields[0]?.signature.parse_status).toBe(status); + expect(result.fields[0]?.signature.raw_sha256).toBe( + signature(blob).raw_sha256, + ); + }); +}); + +describe("signature display compatibility and capacity", () => { + it("reads historical v1 Evidence without relabelling its raw display", () => { + const current = inspectDisplayFixture({ + fieldSignature: Buffer.from([0x06, 0x12, 0x04]), + }); + const old = { + ...current, + signature_resolution: "type-names-v1", + fields: current.fields.map((field) => ({ + ...field, + signature: { ...field.signature, field_type: "class:4" }, + })), + }; + const parsed = managedMemberInspectionSchema.parse(old); + const comparison = compareManagedMembers( + { evidenceId: `ev_${"a".repeat(64)}`, result: parsed }, + { evidenceId: `ev_${"b".repeat(64)}`, result: current }, + ); + expect(comparison.fields[0]?.status).toBe("unchanged"); + expect(comparison.methods[0]?.status).toBe("unchanged"); + expect(parsed.signature_resolution).toBe("type-names-v1"); + expect(parsed.fields[0]?.signature.field_type).toBe("class:4"); + expect(parsed.fields[0]?.signature.resolved_sha256).toBe( + current.fields[0]?.signature.resolved_sha256, + ); + }); + + it.each([64, 65])("bounds a real %i-level TypeRef display path", (depth) => { + const typeReferences = Array.from({ length: depth }, (_, index) => ({ + namespace: "", + name: `T${index}`, + ...(index === 0 ? {} : { enclosingRow: index + 1 }), + })); + const index = ((depth + 1) << 2) | 1; + const encoded = [0x80 | (index >> 8), index & 0xff]; + const result = inspectDisplayFixture({ + typeReferences, + fieldSignature: Buffer.from([0x06, 0x12, ...encoded]), + }); + const expected = + depth === 64 + ? `class:${index} (${typeReferences.map((ref) => ref.name).join("/")})` + : `class:${index}`; + expect(result.fields[0]?.signature.field_type).toBe(expected); + }); + + it("bounds repeated display-name expansion before allocation", () => { + const bytes = buildManagedPeFixture({ + typeReferences: [{ namespace: "N", name: "X".repeat(200_000) }], + methodSignature: Buffer.from([ + 0x00, + 0x40, + 0x01, + ...Array.from({ length: 64 }, () => [0x12, 0x09]).flat(), + ]), + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.coverage.state).not.toBe("complete"); + expect(result.coverage.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "resource-limit" }), + ]), + ); + }); +}); + +describe("signature display property regressions", () => { + const assertIdentity = (blob: Buffer) => { + const digest = ({ value }: Parameters[0]) => + nameDigest(`N.Type${value}`); + const old = signature(blob, undefined, digest); + const current = signature( + blob, + undefined, + digest, + ({ value }) => `N.Type${value}`, + ); + const identity = ({ + field_type: _field, + parameter_types: _parameters, + return_type: _return, + ...rest + }: typeof old) => rest; + expect(identity(current)).toEqual(identity(old)); + }; + + it("keeps parse status, hashes and counts for 1000 arbitrary blobs", () => { + fc.assert( + fc.property(fc.uint8Array({ maxLength: 128 }), (bytes) => { + assertIdentity(Buffer.from(bytes)); + }), + { seed: 5149, numRuns: 1000 }, + ); + }); + + it("keeps identity through 1000 generated valid nested signatures", () => { + fc.assert( + fc.property( + fc.array( + fc.constantFrom( + "pointer", + "vector", + "array", + "generic", + "modifier", + "fnptr", + ), + { maxLength: 30 }, + ), + fc.constantFrom([0x12, 0x09], [0x11, 0x0d], [0x08]), + (wrappers, leaf) => { + let type: number[] = [...leaf]; + for (const wrapper of wrappers) { + if (wrapper === "pointer") type = [0x0f, ...type]; + else if (wrapper === "vector") type = [0x1d, ...type]; + else if (wrapper === "array") + type = [0x14, ...type, 0x02, 0x00, 0x00]; + else if (wrapper === "generic") + type = [0x15, 0x12, 0x09, 0x01, ...type]; + else if (wrapper === "modifier") type = [0x1f, 0x0d, ...type]; + else type = [0x1b, 0x00, 0x00, ...type]; + } + const blob = Buffer.from([0x06, ...type]); + expect(signature(blob).parse_status).toBe("decoded"); + assertIdentity(blob); + }, + ), + { seed: 5149, numRuns: 1000 }, + ); + }); +}); diff --git a/src/dotnet/ManagedTypeNameDigests.test.ts b/src/dotnet/ManagedSignatureTypeNames.test.ts similarity index 81% rename from src/dotnet/ManagedTypeNameDigests.test.ts rename to src/dotnet/ManagedSignatureTypeNames.test.ts index f309a722c3..02334fbe63 100644 --- a/src/dotnet/ManagedTypeNameDigests.test.ts +++ b/src/dotnet/ManagedSignatureTypeNames.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { MAX_TYPE_NESTING_DEPTH, - nestedTypeDigests, -} from "./ManagedTypeNameDigests.js"; + nestedTypeNames, +} from "./ManagedSignatureTypeNames.js"; /** Rows 1..length, each nested in the row before it. */ const chain = (length: number) => { @@ -18,14 +18,14 @@ const chain = (length: number) => { } : null; }; - return { calls, digest: nestedTypeDigests(link, () => undefined) }; + return { calls, digest: nestedTypeNames(link, () => undefined) }; }; describe("nested signature type digests", () => { it("reads each row of a chain within the nesting cap once", () => { const { calls, digest } = chain(MAX_TYPE_NESTING_DEPTH); for (let row = MAX_TYPE_NESTING_DEPTH; row >= 1; row -= 1) - expect(digest(row)).toBeInstanceOf(Buffer); + expect(digest(row)?.digest).toBeInstanceOf(Buffer); expect(calls).toHaveLength(MAX_TYPE_NESTING_DEPTH); }); @@ -35,7 +35,7 @@ describe("nested signature type digests", () => { expect(digest(4_000)).toBeNull(); expect(calls.length).toBeLessThanOrEqual(MAX_TYPE_NESTING_DEPTH + 1); - expect(digest(MAX_TYPE_NESTING_DEPTH)).toBeInstanceOf(Buffer); + expect(digest(MAX_TYPE_NESTING_DEPTH)?.digest).toBeInstanceOf(Buffer); expect(digest(MAX_TYPE_NESTING_DEPTH + 1)).toBeNull(); }); @@ -61,7 +61,7 @@ describe("nested signature type digests", () => { } : null; }; - const digest = nestedTypeDigests(link, () => undefined); + const digest = nestedTypeNames(link, () => undefined); for (let row = 1_001; row <= 1_000 + children; row += 1) expect(digest(row)).toBeNull(); @@ -82,7 +82,7 @@ describe("nested signature type digests", () => { }); it("does not name a type whose nesting is cyclic", () => { - const digest = nestedTypeDigests( + const digest = nestedTypeNames( (row) => ({ namespace: "", name: `T${String(row)}`, @@ -96,18 +96,24 @@ describe("nested signature type digests", () => { }); it("tells a nested type from a top-level type with its joined name", () => { - const nested = nestedTypeDigests( + const nested = nestedTypeNames( (row) => row === 1 ? { namespace: "", name: "Outer", enclosingRow: null } : { namespace: "", name: "Inner", enclosingRow: 1 }, () => undefined, ); - const flat = nestedTypeDigests( + const flat = nestedTypeNames( () => ({ namespace: "", name: "Outer/Inner", enclosingRow: null }), () => undefined, ); - expect(nested(2)).not.toEqual(flat(1)); + const nestedName = nested(2); + const flatName = flat(1); + if (nestedName === null || flatName === null) + throw new Error("Expected both type names to resolve"); + expect(nestedName.name).toBe("Outer/Inner"); + expect(flatName.name).toBe(nestedName.name); + expect(nestedName.digest.equals(flatName.digest)).toBe(false); }); }); diff --git a/src/dotnet/ManagedTypeNameDigests.ts b/src/dotnet/ManagedSignatureTypeNames.ts similarity index 82% rename from src/dotnet/ManagedTypeNameDigests.ts rename to src/dotnet/ManagedSignatureTypeNames.ts index 9c07a82eb1..e1f7aaea79 100644 --- a/src/dotnet/ManagedTypeNameDigests.ts +++ b/src/dotnet/ManagedSignatureTypeNames.ts @@ -39,6 +39,7 @@ const NAME_SEPARATOR = Buffer.from([0]); interface NamedRow { readonly digest: Buffer; + readonly name: string; readonly depth: number; } @@ -51,10 +52,10 @@ interface NamedRow { * A gap, a cycle, or nesting deeper than `MAX_TYPE_NESTING_DEPTH` has no * digest. `retain` is charged once per row read and once per remembered row. */ -export const nestedTypeDigests = ( +export const nestedTypeNames = ( link: (row: number) => TypeLink | null, - retain: () => void, -): ((row: number) => Buffer | null) => { + retain: (bytes?: number) => void, +): ((row: number) => NamedRow | null) => { const named = new Map(); const remember = (row: number, value: NamedRow | null): void => { retain(); @@ -124,10 +125,21 @@ export const nestedTypeDigests = ( .update(step.link.namespace) .update(NAME_SEPARATOR) .update(step.link.name); - inner = { digest: hash.digest(), depth }; + // Charge each retained expanded path before allocation, including parents. + const ownLength = + step.link.namespace.length + + step.link.name.length + + Number(step.link.namespace.length > 0); + retain(((inner?.name.length ?? -1) + 1 + ownLength) * 6 + 2); + const ownName = + step.link.namespace.length === 0 + ? step.link.name + : `${step.link.namespace}.${step.link.name}`; + const name = inner === null ? ownName : `${inner.name}/${ownName}`; + inner = { digest: hash.digest(), name, depth }; remember(step.row, inner); } - return exceeded ? null : (inner?.digest ?? null); + return exceeded ? null : inner; }; }; @@ -142,7 +154,7 @@ export const nestedTypeDigests = ( * retained in `issues`, once per row. Each coded index and each row is * named and digested once, so repeated operands cost a lookup. */ -export const managedTypeNameDigests = ( +export const managedSignatureTypeNames = ( layout: ManagedMetadataLayout, issues: ManagedParseIssue[], parsedTypes: Pick, "types" | "nesting">, @@ -150,12 +162,16 @@ export const managedTypeNameDigests = ( ): { readonly operand: SignatureTypeName; readonly declaration: DeclarationTypeName; + readonly displayName: ( + operand: Parameters[0], + ) => string | null; } => { - const retain = () => managedDecodeBudget(layout).reserve(128, null); + const retain = (bytes = 128) => + managedDecodeBudget(layout).reserve(bytes, null); managedDecodeBudget(layout).reserve(parsedTypes.types.length * 128, null); const types = new Map(); for (const { row, type } of parsedTypes.types) types.set(row, type); - const definitions = nestedTypeDigests((row) => { + const definitions = nestedTypeNames((row) => { if (!parsedTypes.nesting.complete) return null; const type = types.get(row); if ( @@ -195,33 +211,35 @@ export const managedTypeNameDigests = ( enclosingRow, }; }, retain); - const references = nestedTypeDigests((row) => { + const references = nestedTypeNames((row) => { const value = reference(row); return value?.scopeValid === true ? value : null; }, retain); - const digests = new Map(); - const digestFor = ( + const names = new Map(); + const nameFor = ( key: number, table: number | undefined, row: number, - ): Buffer | null => { - let digest = digests.get(key); - if (digest === undefined) { + ): NamedRow | null => { + let named = names.get(key); + if (named === undefined) { managedDecodeBudget(layout).reserve(256, null); - digest = + named = table === TYPE_DEF ? definitions(row) : table === TYPE_REF ? references(row) : null; - digests.set(key, digest); + names.set(key, named); } - return digest; + return named; }; return { operand: ({ value, coordinate }) => - digestFor(value, coordinate.table, coordinate.row), + nameFor(value, coordinate.table, coordinate.row)?.digest ?? null, declaration: (table, row) => - digestFor(row * 4 + Number(table === TYPE_REF), table, row), + nameFor(row * 4 + Number(table === TYPE_REF), table, row)?.digest ?? null, + displayName: ({ value, coordinate }) => + nameFor(value, coordinate.table, coordinate.row)?.name ?? null, }; }; diff --git a/tests/acceptance/managedCliOutput.test.ts b/tests/acceptance/managedCliOutput.test.ts index 978f141e2b..52e65a5808 100644 --- a/tests/acceptance/managedCliOutput.test.ts +++ b/tests/acceptance/managedCliOutput.test.ts @@ -646,3 +646,47 @@ cliTest( } }, ); + +cliTest( + "managed CLI renders type names and preserves unresolved operands", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-managed-signature-names-"); + const path = join(root, "names.dll"); + await writeFile( + path, + buildManagedPeFixture({ + fieldSignature: Buffer.from([0x06, 0x12, 0x04]), + typeSpecSignatures: [Buffer.from([0x08])], + methodSignature: Buffer.from([ + 0x00, 0x02, 0x12, 0x04, 0x12, 0x09, 0x12, 0x06, + ]), + typeReferences: [{ namespace: "System", name: "Uri" }], + }), + ); + const result = await cli.run({ + arguments: ["inspect-managed-members", path, "--json"], + }); + expect(result.exitCode).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + signature_resolution: "type-names-v2", + fields: [ + expect.objectContaining({ + signature: expect.objectContaining({ + field_type: "class:4 (Fixture.Program)", + }), + }), + ], + methods: [ + expect.objectContaining({ + signature: expect.objectContaining({ + return_type: "class:4 (Fixture.Program)", + parameter_types: ["class:9 (System.Uri)", "class:6"], + resolved_sha256: null, + }), + }), + ], + }, + }); + }, +); diff --git a/tests/boundary/mcp/managedMemberSummaryMcp.test.ts b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts index 2393c94052..d1887c62e6 100644 --- a/tests/boundary/mcp/managedMemberSummaryMcp.test.ts +++ b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts @@ -146,6 +146,29 @@ const verifyProjection = async ({ }); }; +// ldarg.0; ldfld field; call member ref; ldstr "ok"; pop; ret +const SUMMARY_METHOD_BODY = Buffer.from([ + (18 << 2) | 2, + 0x02, + 0x7b, + 1, + 0, + 0, + 0x04, + 0x28, + 1, + 0, + 0, + 0x0a, + 0x72, + 1, + 0, + 0, + 0x70, + 0x26, + 0x2a, +]); + it("summarizes selected members and pages every original collection after the file disappears", async () => { const directory = await createTestTempDirectory("rea-member-summary-"); const path = join(directory, "fixture.dll"); @@ -153,28 +176,9 @@ it("summarizes selected members and pages every original collection after the fi path, buildManagedPeFixture({ methodRowCount: 2, - // ldarg.0; ldfld field; call member ref; ldstr "ok"; pop; ret - ilBody: Buffer.from([ - (18 << 2) | 2, - 0x02, - 0x7b, - 1, - 0, - 0, - 0x04, - 0x28, - 1, - 0, - 0, - 0x0a, - 0x72, - 1, - 0, - 0, - 0x70, - 0x26, - 0x2a, - ]), + fieldSignature: Buffer.from([0x06, 0x12, 0x04]), + methodSignature: Buffer.from([0x00, 0x00, 0x12, 0x04]), + ilBody: SUMMARY_METHOD_BODY, userStringHeap: Buffer.from([0, 5, 0x6f, 0, 0x6b, 0, 0]), exportedTypes: [ { @@ -225,13 +229,16 @@ it("summarizes selected members and pages every original collection after the fi expect(parent.parameters).toEqual({ path, type_names }); expect(receipt.evidence_links).toEqual([parent.evidence_id]); const report = managedMemberInspectionSchema.parse(parent.normalized_result); + expect(report.fields[0]?.signature.field_type).toBe( + "class:4 (Fixture.Program)", + ); expect(view.summary.selection).toEqual({ type_names, 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.signature_resolution).toBe("type-names-v2"); expect(view.summary.identity_scope).toEqual(report.identity_scope); expect(view.summary).not.toHaveProperty("methods"); for (const detail of [undefined, "complete"]) { @@ -276,7 +283,7 @@ it("summarizes selected members and pages every original collection after the fi if (page.kind !== "managed-members") throw new Error("Unexpected page kind"); expect(page.page.collection).toBe(collection); - expect(page.signature_resolution).toBe("type-names-v1"); + expect(page.signature_resolution).toBe("type-names-v2"); expect(page.selection).toEqual(report.selection); expect(page.coverage.total).toBe(rows.length); expect(page.method_rows).toEqual(report.method_rows);