From 1f0eb228e50908ee2a2aaa72d329178ae1a2cdbd Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:37:15 +0800 Subject: [PATCH] fix(managed): derive selected scope from parsed declarations --- docs/managed-code-analysis.md | 9 ++- src/domain/managed/managedMemberComparison.ts | 10 +--- .../managedMemberComparisonCoverage.ts | 8 +++ .../managed/managedMemberComparisonMatch.ts | 20 ++++--- src/dotnet/ManagedMemberInspector.ts | 31 +++++----- ...anagedMemberComparisonFromMetadata.test.ts | 59 +++++++++++++++++++ 6 files changed, 105 insertions(+), 32 deletions(-) diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 6bc303b004..6ceb183471 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -229,9 +229,12 @@ rea inspect-managed-members /absolute/path/Assembly.dll --type Example.Parser MCP uses `inspect_managed_members` with `{"path":"/absolute/path/Assembly.dll","type_names":["Example.Parser"]}`. Repeat `--type` or supply multiple unique `type_names` for several types. -Names match the reported `full_name` exactly. Top-level names are namespace -qualified; nested types currently report bare names, which can select several -types with that name. Each type's `enclosing_type_token` records its enclosing +Names match the reported `full_name` exactly. Top-level names require that +namespace-qualified spelling. Nested declarations also accept their bare +`name`, including when their own namespace is nonempty; this can select several +types with that name. Selection uses the parsed TypeDef facts and comparison +uses the returned declaring types, rather than reconstructing scope from the +caller's spelling. Each type's `enclosing_type_token` records its enclosing TypeDef. `nesting_read` distinguishes complete source rows (true), incomplete current rows (false), and historical Evidence (null). A null enclosing token alone does not establish that a type is top-level. diff --git a/src/domain/managed/managedMemberComparison.ts b/src/domain/managed/managedMemberComparison.ts index aaeadef15d..105314236e 100644 --- a/src/domain/managed/managedMemberComparison.ts +++ b/src/domain/managed/managedMemberComparison.ts @@ -11,6 +11,7 @@ import { import { buildComparisonCoverage, hasCompleteMethodRows, + selectedDeclaringTypes, buildComparisonMatching, buildComparisonSummary, comparisonLimitations, @@ -216,11 +217,6 @@ export interface ManagedMemberComparisonSide { readonly result: ManagedMemberInspection; } -const selectedTypes = ( - result: ManagedMemberInspection, -): ReadonlySet | null => - result.selection === null ? null : new Set(result.selection.type_names); - const callEdgesByCaller = ( result: ManagedMemberInspection, ): ReadonlyMap< @@ -275,8 +271,8 @@ export const compareManagedMembers = ( rightMethodsComplete: right.result.metadata.status === "complete" && hasCompleteMethodRows(right.result), - leftSelection: selectedTypes(left.result), - rightSelection: selectedTypes(right.result), + leftSelection: selectedDeclaringTypes(left.result), + rightSelection: selectedDeclaringTypes(right.result), signatureResolutionExamined: hasSignatureResolution(left.result) && hasSignatureResolution(right.result), diff --git a/src/domain/managed/managedMemberComparisonCoverage.ts b/src/domain/managed/managedMemberComparisonCoverage.ts index 6dd2d0903b..09426627cb 100644 --- a/src/domain/managed/managedMemberComparisonCoverage.ts +++ b/src/domain/managed/managedMemberComparisonCoverage.ts @@ -50,6 +50,14 @@ const sameMethodRows = ( left.method_rows.count === right.method_rows.count && left.method_rows.total === right.method_rows.total); +/** Returned type facts, rather than request spelling, own the observed scope. */ +export const selectedDeclaringTypes = ( + result: ManagedMemberInspection, +): ReadonlySet | null => + result.selection === null + ? null + : new Set(result.types.map(({ full_name }) => full_name)); + /** Whether both observations inspected the same declaring types. */ const sameSelection = ( left: ManagedMemberInspection, diff --git a/src/domain/managed/managedMemberComparisonMatch.ts b/src/domain/managed/managedMemberComparisonMatch.ts index c62c1877a3..3cee7a0c85 100644 --- a/src/domain/managed/managedMemberComparisonMatch.ts +++ b/src/domain/managed/managedMemberComparisonMatch.ts @@ -1,4 +1,7 @@ -import { hasCompleteMethodRows } from "./managedMemberComparisonCoverage.js"; +import { + hasCompleteMethodRows, + selectedDeclaringTypes, +} from "./managedMemberComparisonCoverage.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { managedMemberInspectionSchema, @@ -490,12 +493,15 @@ const groupBy = ( }; /** Whether a side's inventory observed members of this declaring type. */ -const observedBy = - (side: ManagedMemberComparisonSide) => - ({ declaring_type }: { readonly declaring_type: string | null }): boolean => - side.result.selection === null || - (declaring_type !== null && - side.result.selection.type_names.includes(declaring_type)); +const observedBy = (side: ManagedMemberComparisonSide) => { + const types = selectedDeclaringTypes(side.result); + return ({ + declaring_type, + }: { + readonly declaring_type: string | null; + }): boolean => + types === null || (declaring_type !== null && types.has(declaring_type)); +}; /** * Pair only members whose declaring type both sides observed. A member outside diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 7e6d0381a7..971b334f69 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -29,7 +29,6 @@ import { import { parseTypes, readTypeDefinitions, - type TypeRange, } from "./ManagedMemberInspectorCore.js"; import { edgeTargets } from "./ManagedMethodSpecs.js"; import { managedTypeNameDigests } from "./ManagedTypeNameDigests.js"; @@ -124,26 +123,27 @@ const readMemberLayout = (bytes: Buffer, pe: ManagedPeLayout) => { }; const selectTypes = ( - ranges: readonly TypeRange[], + types: ManagedMemberInspection["types"], typeNames: readonly string[] | undefined, ): { readonly tokens: SelectedTypeTokens; readonly selection: ManagedMemberInspection["selection"]; - readonly unreadableNames: number; } => { - if (typeNames === undefined) - return { tokens: null, selection: null, unreadableNames: 0 }; + if (typeNames === undefined) return { tokens: null, selection: null }; const requested = new Set(typeNames); const tokens = new Set(); const matched = new Set(); - for (const range of ranges) { - if (range.fullName === null || !requested.has(range.fullName)) continue; - tokens.add(range.token); - matched.add(range.fullName); + for (const type of types) { + const nested = (type.flags & 0x7) >= 2; + const spellings = nested ? [type.full_name, type.name] : [type.full_name]; + for (const name of spellings) { + if (!requested.has(name)) continue; + tokens.add(type.token); + matched.add(name); + } } return { tokens, - unreadableNames: ranges.filter(({ fullName }) => fullName === null).length, selection: { type_names: [...typeNames], unmatched_type_names: typeNames.filter((name) => !matched.has(name)), @@ -198,12 +198,13 @@ export const inspectManagedMembersBytes = ( const identity = readManagedModuleIdentity(bytes, layout); appendManagedParseIssues(issues, identity.issues); const ranges = readTypeDefinitions(bytes, layout, issues, memberLists); - const { - tokens: selected, - selection, - unreadableNames, - } = selectTypes(ranges, typeNames); const parsedTypes = parseTypes(bytes, layout, ranges); + const { tokens: selected, selection } = selectTypes( + parsedTypes.types, + typeNames, + ); + const unreadableNames = + typeNames === undefined ? 0 : ranges.length - parsedTypes.types.length; appendManagedParseIssues(issues, parsedTypes.issues); const typeReferences = managedTypeReferences(bytes, layout, issues); const signatureTypeName = managedTypeNameDigests( diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index 4f9946b624..c85468741e 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -1,3 +1,9 @@ +import { readManagedPeLayout } from "../../../../src/dotnet/ManagedPeReader.js"; +import { + metadataRowOffset, + readManagedMetadataLayout, +} from "../../../../src/dotnet/ManagedMetadataLayout.js"; +import { buildManagedNestedTypeFixture } from "../../../../src/dotnet/ManagedPe.fixture.js"; import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; @@ -15,6 +21,59 @@ import { import { buildManagedPeFixture as buildMultiMethodFixture } from "../../../../scripts/fixtures/managed/pe.mjs"; +it("selects namespace-bearing nested declarations from parsed type facts and compares the observed scope", () => { + const bytes = buildManagedNestedTypeFixture(); + const pe = readManagedPeLayout(bytes); + if (pe.cli === null) throw new Error("Missing fixture CLI"); + const layout = readManagedMetadataLayout( + bytes, + pe.rvaToOffset(pe.cli.metadata.rva, pe.cli.metadata.size, "fixture"), + pe.cli.metadata.size, + ); + const column = 4 + layout.stringIndexSize; + const namespace = bytes.readUInt16LE( + metadataRowOffset(layout, 2, 2) + column, + ); + bytes.writeUInt16LE(namespace, metadataRowOffset(layout, 2, 4) + column); + const selected = inspect(bytes, "/fixture/nested-selection.dll", ["Inner"]); + expect(selected.result.selection?.unmatched_type_names).toEqual([]); + expect(selected.result.types.map(({ token }) => token)).toEqual([ + "0x02000004", + "0x02000005", + ]); + expect(selected.result.methods).toHaveLength(2); + expect(selected.result.fields).toHaveLength(2); + const comparison = compareManagedMembers(selected, selected); + expect( + comparison.methods.every(({ match }) => match.status === "matched"), + ).toBe(true); + expect(comparison.summary).toMatchObject({ added: 0, removed: 0 }); + expect( + inspect(bytes, "/fixture/nested-qualified.dll", [ + "Fixture.Inner", + ]).result.types.map(({ token }) => token), + ).toEqual(["0x02000004"]); + const topLevel = buildManagedNestedTypeFixture({ topLevelTypes: true }); + const topPe = readManagedPeLayout(topLevel); + if (topPe.cli === null) throw new Error("Missing top-level fixture CLI"); + const topLayout = readManagedMetadataLayout( + topLevel, + topPe.rvaToOffset( + topPe.cli.metadata.rva, + topPe.cli.metadata.size, + "fixture", + ), + topPe.cli.metadata.size, + ); + topLevel.writeUInt16LE( + topLevel.readUInt16LE(metadataRowOffset(topLayout, 2, 2) + column), + metadataRowOffset(topLayout, 2, 4) + column, + ); + expect( + inspect(topLevel, "/fixture/top-level.dll", ["Inner"]).result.types, + ).toEqual([]); +}); + describe("managed comparison method row coverage", () => { it("keeps paged omissions unknown while preserving full and equal-page controls", () => { const bytes = buildMultiMethodFixture({