mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(managed): preserve member scope and nominal edge identity
This commit is contained in:
1 parent
f5d791f2a1
commit
357e354ac7
18 files changed
+605
-134
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ const emptyManagedMembers = (
|
||||
},
|
||||
types: [],
|
||||
nesting_read: true,
|
||||
method_rows: { offset: 0, count: 0, total: 0 },
|
||||
exported_types: [],
|
||||
fields: [],
|
||||
methods: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<string> | null;
|
||||
readonly rightSelection: ReadonlySet<string> | 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<number | null, ReferenceTarget | null>();
|
||||
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
|
||||
|
||||
@@ -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<Field> => ({
|
||||
const matchMethods = (
|
||||
left: readonly Keyed<Method>[],
|
||||
right: readonly Keyed<Method>[],
|
||||
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<Field>[],
|
||||
right: readonly Keyed<Field>[],
|
||||
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),
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof prepareManagedMemberRecovery> | 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
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -18,6 +18,7 @@ type TypeInventory = Pick<
|
||||
| "types"
|
||||
| "nesting_read"
|
||||
| "selection"
|
||||
| "method_rows"
|
||||
| "limitations"
|
||||
>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, MemberCore>[],
|
||||
signatureTypeName: SignatureTypeName,
|
||||
): {
|
||||
readonly members: ReadonlyMap<string, MemberCore>;
|
||||
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<string, MemberCore>;
|
||||
readonly issues: ManagedParseIssue[];
|
||||
readonly arities: Map<string, number | null>;
|
||||
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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<typeof buildManagedPeFixture>[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",
|
||||
|
||||
Reference in new issue
Block a user