fix(managed): derive selected scope from parsed declarations

This commit is contained in:
morluto
2026-10-24 02:37:15 +08:00
parent ba781330d6
commit 1f0eb228e5
6 changed files with 105 additions and 32 deletions
+6 -3
View File
@@ -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.
@@ -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<string> | 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),
@@ -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<string> | 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,
@@ -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 = <Item>(
};
/** 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
+16 -15
View File
@@ -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<string>();
const matched = new Set<string>();
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(
@@ -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({