mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(managed): compare retained declaring-type selection scope
Derive aggregate selected-scope agreement from retained nominal type identities instead of requested spellings. Keep incomplete or historical selected scope unknown while preserving caller selections, whole-inventory distinction, paging and member absence behavior. Cover equivalent requests, same-label nested owners, missing and partial identity facts, and complete empty selections. Exercise CLI inspection Evidence through stdio MCP comparison after removing its selected source file.
This commit is contained in:
1 parent
60bb202ca3
commit
091d6209af
6 files changed
+266
-27
No files matched your search
@@ -254,7 +254,15 @@ 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. The
|
||||
`method_rows` interval records the examined zero-based MethodDef offset, row
|
||||
comparison's aggregate selected-scope agreement uses retained `name_sha256`
|
||||
sets, including enclosing names, rather than requested spellings or display
|
||||
labels. Equivalent requests can cover the same types, while the same bare name
|
||||
can select different nested owners. Incomplete selected inventories or missing
|
||||
nominal identities leave that agreement unknown and aggregate coverage partial;
|
||||
observed member pairs remain available. Complete empty selected inventories
|
||||
agree even if their unmatched request names differ. Whole-assembly and selected
|
||||
inventories remain distinct scopes, and each source retains its exact request.
|
||||
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
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./managedArtifact.js";
|
||||
import {
|
||||
buildComparisonCoverage,
|
||||
compareSelectedTypes,
|
||||
hasCompleteMethodRows,
|
||||
selectedDeclaringTypes,
|
||||
buildComparisonMatching,
|
||||
@@ -288,11 +289,17 @@ export const compareManagedMembers = (
|
||||
const allItems = [...methodItems, ...fieldItems];
|
||||
const summary = buildComparisonSummary(allItems);
|
||||
const matching = buildComparisonMatching(methodItems, fieldItems);
|
||||
const selectionAgreement = compareSelectedTypes(left.result, right.result);
|
||||
const coverage = buildComparisonCoverage({
|
||||
left: left.result,
|
||||
right: right.result,
|
||||
selectionAgreement,
|
||||
});
|
||||
const limitations = comparisonLimitations(left.result, right.result);
|
||||
const limitations = comparisonLimitations(
|
||||
left.result,
|
||||
right.result,
|
||||
selectionAgreement,
|
||||
);
|
||||
const result = {
|
||||
comparison_id: `mmc_${digestCanonicalValue(
|
||||
{
|
||||
|
||||
@@ -8,18 +8,22 @@ import { hasSignatureResolution } from "./managedMemberSignatureIdentity.js";
|
||||
interface ComparisonCoverageInput {
|
||||
readonly left: ManagedMemberInspection;
|
||||
readonly right: ManagedMemberInspection;
|
||||
readonly selectionAgreement: SelectionAgreement;
|
||||
}
|
||||
|
||||
type SelectionAgreement = "same" | "different" | "unknown";
|
||||
|
||||
export const buildComparisonCoverage = ({
|
||||
left,
|
||||
right,
|
||||
selectionAgreement,
|
||||
}: ComparisonCoverageInput): ManagedMemberComparisonResult["coverage"] => {
|
||||
const leftStatus = sideCoverageStatus(left);
|
||||
const rightStatus = sideCoverageStatus(right);
|
||||
const unknownInput =
|
||||
left.coverage.state !== "complete" ||
|
||||
right.coverage.state !== "complete" ||
|
||||
!sameSelection(left, right) ||
|
||||
selectionAgreement !== "same" ||
|
||||
!sameMethodRows(left, right);
|
||||
return {
|
||||
status:
|
||||
@@ -58,18 +62,28 @@ export const selectedDeclaringTypes = (
|
||||
? null
|
||||
: new Set(result.types.map(({ full_name }) => full_name));
|
||||
|
||||
/** Whether both observations inspected the same declaring types. */
|
||||
const sameSelection = (
|
||||
/** Compare the nominal types the owner retained, independently of request spelling. */
|
||||
export const compareSelectedTypes = (
|
||||
left: ManagedMemberInspection,
|
||||
right: ManagedMemberInspection,
|
||||
): boolean => {
|
||||
): SelectionAgreement => {
|
||||
if (left.selection === null || right.selection === null)
|
||||
return left.selection === right.selection;
|
||||
const rightNames = new Set(right.selection.type_names);
|
||||
return (
|
||||
rightNames.size === new Set(left.selection.type_names).size &&
|
||||
left.selection.type_names.every((name) => rightNames.has(name))
|
||||
);
|
||||
return left.selection === right.selection ? "same" : "different";
|
||||
if (
|
||||
left.coverage.state !== "complete" ||
|
||||
right.coverage.state !== "complete" ||
|
||||
left.metadata.status !== "complete" ||
|
||||
right.metadata.status !== "complete" ||
|
||||
left.types.some(({ name_sha256 }) => name_sha256 === null) ||
|
||||
right.types.some(({ name_sha256 }) => name_sha256 === null)
|
||||
)
|
||||
return "unknown";
|
||||
const leftNames = new Set(left.types.map(({ name_sha256 }) => name_sha256));
|
||||
const rightNames = new Set(right.types.map(({ name_sha256 }) => name_sha256));
|
||||
return leftNames.size === rightNames.size &&
|
||||
[...leftNames].every((name) => rightNames.has(name))
|
||||
? "same"
|
||||
: "different";
|
||||
};
|
||||
|
||||
const sideCoverageStatus = (
|
||||
@@ -133,6 +147,7 @@ export const buildComparisonMatching = (
|
||||
export const comparisonLimitations = (
|
||||
left: ManagedMemberInspection,
|
||||
right: ManagedMemberInspection,
|
||||
selectionAgreement: SelectionAgreement = compareSelectedTypes(left, right),
|
||||
): string[] => {
|
||||
const limitations: string[] = [
|
||||
"Metadata tokens are build-local coordinates; matched pairs are remaps, not persistent identities.",
|
||||
@@ -160,13 +175,21 @@ export const comparisonLimitations = (
|
||||
);
|
||||
if (left.coverage.state !== "complete" || right.coverage.state !== "complete")
|
||||
limitations.push("At least one managed member observation is partial.");
|
||||
if (!sameSelection(left, right))
|
||||
if (selectionAgreement === "different")
|
||||
limitations.push(
|
||||
"The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.",
|
||||
);
|
||||
else if (selectionAgreement === "unknown")
|
||||
limitations.push(
|
||||
"Selected declaring-type scope agreement is unknown because at least one observation is incomplete or lacks resolved nominal type identities; matching request names do not establish the same observed scope.",
|
||||
);
|
||||
else if (left.selection !== null && left.types.length === 0)
|
||||
limitations.push(
|
||||
"Both observations select no observed declaring types; requested and unmatched names remain in each source selection.",
|
||||
);
|
||||
else if (left.selection !== null)
|
||||
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.`,
|
||||
`Both observations cover only the selected declaring types (${[...new Set(left.types.map(({ full_name }) => full_name))].join(", ")}); member totals and statuses describe those types, not the whole assembly.`,
|
||||
);
|
||||
if (!hasCompleteMethodRows(left) || !hasCompleteMethodRows(right))
|
||||
limitations.push(
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { managedMemberInspectionSchema } from "../../src/domain/managed/managedArtifact.js";
|
||||
import { managedMemberComparisonResultSchema } from "../../src/domain/managed/managedMemberComparison.js";
|
||||
import { namespaceBearingNestedTypes } from "../fixtures/managedSelectedScope.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"compares actual selected type scope from public CLI Evidence through stdio MCP after source removal",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-managed-selected-scope-");
|
||||
const path = join(root, "selected.dll");
|
||||
const bytes = namespaceBearingNestedTypes();
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
await writeFile(path, bytes);
|
||||
const sources: ReturnType<typeof parseEvidence>[] = [];
|
||||
for (const selection of [
|
||||
["Inner"],
|
||||
["Inner", "Fixture.Inner"],
|
||||
["Fixture.Inner"],
|
||||
]) {
|
||||
const output = await cli.sharedCliRun!({
|
||||
arguments: [
|
||||
"inspect-managed-members",
|
||||
path,
|
||||
...selection.flatMap((name) => ["--type", name]),
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(output.exitCode, output.stderr).toBe(0);
|
||||
const evidence = parseEvidence(output.json);
|
||||
const report = managedMemberInspectionSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(report.artifact).toMatchObject({ path, sha256 });
|
||||
expect(report.selection).toEqual({
|
||||
type_names: selection,
|
||||
unmatched_type_names: [],
|
||||
});
|
||||
expect(report.coverage.state).toBe("complete");
|
||||
sources.push(evidence);
|
||||
}
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
await rm(path);
|
||||
const call = await cli.sharedMcpCall!();
|
||||
for (const [index, right] of sources.slice(1).entries()) {
|
||||
const response = await call("compare_managed_members", {
|
||||
left: sources[0]!,
|
||||
right,
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
const report = managedMemberComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(report.evidence_links).toEqual([
|
||||
sources[0]!.evidence_id,
|
||||
right.evidence_id,
|
||||
]);
|
||||
expect(report.coverage.status).toBe(
|
||||
index === 0 ? "complete-within-inputs" : "partial",
|
||||
);
|
||||
expect(report.summary).toMatchObject({ added: 0, removed: 0 });
|
||||
if (index === 0)
|
||||
expect(report.limitations).toContain(
|
||||
"Both observations cover only the selected declaring types (Fixture.Inner, Inner); member totals and statuses describe those types, not the whole assembly.",
|
||||
);
|
||||
else {
|
||||
expect(report.summary.unknown).toBeGreaterThan(0);
|
||||
expect(report.limitations).toContain(
|
||||
"The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -20,21 +20,10 @@ import {
|
||||
} from "../../../../src/dotnet/ManagedPe.fixture.js";
|
||||
|
||||
import { buildManagedPeFixture as buildMultiMethodFixture } from "../../../../scripts/fixtures/managed/pe.mjs";
|
||||
import { namespaceBearingNestedTypes } from "../../../fixtures/managedSelectedScope.js";
|
||||
|
||||
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 bytes = namespaceBearingNestedTypes();
|
||||
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([
|
||||
@@ -65,6 +54,7 @@ it("selects namespace-bearing nested declarations from parsed type facts and com
|
||||
),
|
||||
topPe.cli.metadata.size,
|
||||
);
|
||||
const column = 4 + topLayout.stringIndexSize;
|
||||
topLevel.writeUInt16LE(
|
||||
topLevel.readUInt16LE(metadataRowOffset(topLayout, 2, 2) + column),
|
||||
metadataRowOffset(topLayout, 2, 4) + column,
|
||||
@@ -74,6 +64,112 @@ it("selects namespace-bearing nested declarations from parsed type facts and com
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
describe("managed comparison selected scope agreement", () => {
|
||||
it("compares retained types rather than equivalent request spellings", () => {
|
||||
const bytes = namespaceBearingNestedTypes();
|
||||
const bare = inspect(bytes, "/fixture/bare.dll", ["Inner"]);
|
||||
const explicit = inspect(bytes, "/fixture/explicit.dll", [
|
||||
"Inner",
|
||||
"Fixture.Inner",
|
||||
]);
|
||||
expect(explicit.result.types).toEqual(bare.result.types);
|
||||
expect(explicit.result.selection).not.toEqual(bare.result.selection);
|
||||
const comparison = compareManagedMembers(bare, explicit);
|
||||
expect(comparison.coverage.status).toBe("complete-within-inputs");
|
||||
expect(comparison.limitations).toContain(
|
||||
"Both observations cover only the selected declaring types (Fixture.Inner, Inner); member totals and statuses describe those types, not the whole assembly.",
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes selected nested owners with identical request and display names", () => {
|
||||
const observe = (swapEnclosing: boolean) =>
|
||||
inspect(
|
||||
buildManagedNestedTypeFixture({
|
||||
swapEnclosing,
|
||||
literalTopLevelName: "OtherInner",
|
||||
}),
|
||||
`/fixture/owner-${String(swapEnclosing)}.dll`,
|
||||
["Inner"],
|
||||
);
|
||||
const left = observe(false);
|
||||
const right = observe(true);
|
||||
expect(left.result.selection).toEqual(right.result.selection);
|
||||
expect(left.result.types.map(({ full_name }) => full_name)).toEqual([
|
||||
"Inner",
|
||||
]);
|
||||
expect(right.result.types.map(({ full_name }) => full_name)).toEqual([
|
||||
"Inner",
|
||||
]);
|
||||
expect(left.result.types[0]?.name_sha256).not.toBe(
|
||||
right.result.types[0]?.name_sha256,
|
||||
);
|
||||
const comparison = compareManagedMembers(left, right);
|
||||
expect(comparison.coverage.status).toBe("partial");
|
||||
expect(comparison.limitations).toContain(
|
||||
"The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["historical identities", "partial prefix", "unavailable"])(
|
||||
"keeps selected scope agreement unknown for %s",
|
||||
(condition) => {
|
||||
const source = inspect(
|
||||
namespaceBearingNestedTypes(),
|
||||
"/fixture/scope.dll",
|
||||
["Inner"],
|
||||
);
|
||||
const limited = structuredClone(source.result);
|
||||
if (condition === "historical identities")
|
||||
limited.types = limited.types.map((type) => ({
|
||||
...type,
|
||||
name_sha256: null,
|
||||
}));
|
||||
else {
|
||||
limited.metadata.status =
|
||||
condition === "unavailable" ? "malformed" : "partial";
|
||||
limited.coverage.state =
|
||||
condition === "unavailable" ? "unavailable" : "partial";
|
||||
limited.types = limited.types.slice(
|
||||
0,
|
||||
condition === "unavailable" ? 0 : 1,
|
||||
);
|
||||
}
|
||||
const comparison = compareManagedMembers(source, {
|
||||
...source,
|
||||
result: limited,
|
||||
});
|
||||
expect(comparison.coverage.status).toBe("partial");
|
||||
expect(comparison.limitations).toContainEqual(
|
||||
expect.stringContaining(
|
||||
"Selected declaring-type scope agreement is unknown",
|
||||
),
|
||||
);
|
||||
expect(comparison.limitations).not.toContain(
|
||||
"The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("recognizes complete empty selected scopes despite different unmatched requests", () => {
|
||||
const bytes = buildManagedPeFixture();
|
||||
const left = inspect(bytes, "/fixture/empty-left.dll", ["Absent.Left"]);
|
||||
const right = inspect(bytes, "/fixture/empty-right.dll", ["Absent.Right"]);
|
||||
expect(left.result.types).toEqual([]);
|
||||
expect(right.result.types).toEqual([]);
|
||||
expect(left.result.selection?.unmatched_type_names).toEqual([
|
||||
"Absent.Left",
|
||||
]);
|
||||
expect(right.result.selection?.unmatched_type_names).toEqual([
|
||||
"Absent.Right",
|
||||
]);
|
||||
const comparison = compareManagedMembers(left, right);
|
||||
expect(comparison.coverage.status).toBe("complete-within-inputs");
|
||||
expect(comparison.limitations).toContain(
|
||||
"Both observations select no observed declaring types; requested and unmatched names remain in each source selection.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed comparison method row coverage", () => {
|
||||
it("keeps paged omissions unknown while preserving full and equal-page controls", () => {
|
||||
const bytes = buildMultiMethodFixture({
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { buildManagedNestedTypeFixture } from "../../src/dotnet/ManagedPe.fixture.js";
|
||||
import { readManagedPeLayout } from "../../src/dotnet/ManagedPeReader.js";
|
||||
import {
|
||||
metadataRowOffset,
|
||||
readManagedMetadataLayout,
|
||||
} from "../../src/dotnet/ManagedMetadataLayout.js";
|
||||
|
||||
/** Two nested Inner declarations, one with its own Fixture namespace. */
|
||||
export const namespaceBearingNestedTypes = () => {
|
||||
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);
|
||||
return bytes;
|
||||
};
|
||||
Reference in new issue
Block a user