diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 8e0ff32bca..961ce5b6f2 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -254,13 +254,20 @@ 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 -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 +comparison resolves a member's declaring TypeDef token in its own source, then +checks that nominal `name_sha256` against the opposite selected type set. +Pairing, absence and aggregate selected-scope agreement share these retained +facts, including enclosing names, rather than requested spellings or display +labels. Equivalent requests can cover the same types, while the same display +name can select different nested owners. Such disjoint selected owners cannot +establish a member pair or added/removed status even when both inventories are +complete. Missing nominal identities cannot establish selected membership, +including historical selected Evidence that retains only display names; +the explicitly limited historical whole-inventory comparison remains available. +Incomplete selected inventories or missing identities leave aggregate agreement +unknown and coverage partial; known 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 diff --git a/src/domain/managed/managedMemberComparison.ts b/src/domain/managed/managedMemberComparison.ts index 9132795c4e..20b40753bd 100644 --- a/src/domain/managed/managedMemberComparison.ts +++ b/src/domain/managed/managedMemberComparison.ts @@ -10,9 +10,8 @@ import { } from "./managedArtifact.js"; import { buildComparisonCoverage, - compareSelectedTypes, hasCompleteMethodRows, - selectedDeclaringTypes, + managedComparisonScope, buildComparisonMatching, buildComparisonSummary, comparisonLimitations, @@ -260,7 +259,8 @@ export const compareManagedMembers = ( left: ManagedMemberComparisonSide, right: ManagedMemberComparisonSide, ): ManagedMemberComparisonResult => { - const { methodMatches, fieldMatches } = keyMembers(left, right); + const scope = managedComparisonScope(left.result, right.result); + const { methodMatches, fieldMatches } = keyMembers(left, right, scope); const itemContext = { leftEvidenceId: left.evidenceId, rightEvidenceId: right.evidenceId, @@ -272,8 +272,7 @@ export const compareManagedMembers = ( rightMethodsComplete: right.result.metadata.status === "complete" && hasCompleteMethodRows(right.result), - leftSelection: selectedDeclaringTypes(left.result), - rightSelection: selectedDeclaringTypes(right.result), + scope, signatureResolutionExamined: hasSignatureResolution(left.result) && hasSignatureResolution(right.result), @@ -289,7 +288,7 @@ export const compareManagedMembers = ( const allItems = [...methodItems, ...fieldItems]; const summary = buildComparisonSummary(allItems); const matching = buildComparisonMatching(methodItems, fieldItems); - const selectionAgreement = compareSelectedTypes(left.result, right.result); + const selectionAgreement = scope.agreement; const coverage = buildComparisonCoverage({ left: left.result, right: right.result, diff --git a/src/domain/managed/managedMemberComparisonCoverage.ts b/src/domain/managed/managedMemberComparisonCoverage.ts index 969e468cd3..5d19f0b4e3 100644 --- a/src/domain/managed/managedMemberComparisonCoverage.ts +++ b/src/domain/managed/managedMemberComparisonCoverage.ts @@ -54,38 +54,74 @@ 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)); +/** One side's build-local TypeDef lookup and observed nominal selection. */ +const declaringTypeScope = (result: ManagedMemberInspection) => { + const names = new Map( + result.types.map((type) => [type.token, type.name_sha256]), + ); + return { + names, + selected: + result.selection === null + ? null + : new Set([...names.values()].filter((name) => name !== null)), + complete: + result.coverage.state === "complete" && + result.metadata.status === "complete" && + result.types.every(({ name_sha256 }) => name_sha256 !== null), + }; +}; + +type DeclaringTypeScope = ReturnType; +type DeclaredMember = + | ManagedMemberInspection["methods"][number] + | ManagedMemberInspection["fields"][number]; + +/** Resolve tokens only in their own source, then test opposite nominal scope. */ +const observedBy = ( + own: DeclaringTypeScope, + opposite: DeclaringTypeScope, + member: DeclaredMember, +): boolean => { + if (opposite.selected === null) return true; + const name = own.names.get(member.declaring_type_token ?? ""); + return name != null && opposite.selected.has(name); +}; /** Compare the nominal types the owner retained, independently of request spelling. */ -export const compareSelectedTypes = ( - left: ManagedMemberInspection, - right: ManagedMemberInspection, +const compareSelectedTypes = ( + left: DeclaringTypeScope, + right: DeclaringTypeScope, ): SelectionAgreement => { - if (left.selection === null || right.selection === null) - 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)); + const leftNames = left.selected; + const rightNames = right.selected; + if (leftNames === null || rightNames === null) + return leftNames === rightNames ? "same" : "different"; + if (!left.complete || !right.complete) return "unknown"; return leftNames.size === rightNames.size && [...leftNames].every((name) => rightNames.has(name)) ? "same" : "different"; }; +/** Share the same nominal facts across pairing, absence and scope agreement. */ +export const managedComparisonScope = ( + left: ManagedMemberInspection, + right: ManagedMemberInspection, +) => { + const a = declaringTypeScope(left); + const b = declaringTypeScope(right); + return { + left: a, + right: b, + inLeft: (member: DeclaredMember) => observedBy(b, a, member), + inRight: (member: DeclaredMember) => observedBy(a, b, member), + agreement: compareSelectedTypes(a, b), + }; +}; + +export type ManagedComparisonScope = ReturnType; + const sideCoverageStatus = ( result: ManagedMemberInspection, ): ManagedMemberComparisonResult["coverage"]["left_status"] => @@ -147,7 +183,8 @@ export const buildComparisonMatching = ( export const comparisonLimitations = ( left: ManagedMemberInspection, right: ManagedMemberInspection, - selectionAgreement: SelectionAgreement = compareSelectedTypes(left, right), + selectionAgreement: SelectionAgreement = managedComparisonScope(left, right) + .agreement, ): string[] => { const limitations: string[] = [ "Metadata tokens are build-local coordinates; matched pairs are remaps, not persistent identities.", diff --git a/src/domain/managed/managedMemberComparisonItems.ts b/src/domain/managed/managedMemberComparisonItems.ts index 02d3fab0d3..e280b41b94 100644 --- a/src/domain/managed/managedMemberComparisonItems.ts +++ b/src/domain/managed/managedMemberComparisonItems.ts @@ -9,6 +9,7 @@ import type { } from "./managedMemberComparisonMatch.js"; import { signatureIdentity } from "./managedMemberSignatureIdentity.js"; import { isManagedMemberReferenceInstruction } from "./managedMemberInstructionFacts.js"; +import type { ManagedComparisonScope } from "./managedMemberComparisonCoverage.js"; type Method = ManagedMemberInspection["methods"][number]; type Field = ManagedMemberInspection["fields"][number]; @@ -38,9 +39,7 @@ interface ComparisonItemContext { readonly rightComplete: boolean; readonly leftMethodsComplete: boolean; readonly rightMethodsComplete: boolean; - /** Declaring types each side selected; null when it inspected every type. */ - readonly leftSelection: ReadonlySet | null; - readonly rightSelection: ReadonlySet | null; + readonly scope: ManagedComparisonScope; /** Whether matching keyed signatures by resolved digests, not raw bytes. */ readonly signatureResolutionExamined: boolean; readonly leftAssembly: ManagedMemberInspection["assembly"]; @@ -54,12 +53,9 @@ interface ComparisonItemContext { /** Whether an inventory observed every member of this member's declaring type. */ const inventoryCovers = ( complete: boolean, - selection: ReadonlySet | null, + observes: (member: Method | Field) => boolean, member: Method | Field, -): boolean => - complete && - (selection === null || - (member.declaring_type !== null && selection.has(member.declaring_type))); +): boolean => complete && observes(member); /** * Declared type and name of each unpaired member, per side, whose signature @@ -443,7 +439,7 @@ const methodOnlyItem = ( const limitations = absenceLimitations(member.left, identity, { oppositeComplete: inventoryCovers( context.rightMethodsComplete, - context.rightSelection, + context.scope.inRight, member.left, ), unpaired: undecoded, @@ -466,7 +462,7 @@ const methodOnlyItem = ( const limitations = absenceLimitations(member.right, identity, { oppositeComplete: inventoryCovers( context.leftMethodsComplete, - context.leftSelection, + context.scope.inLeft, member.right, ), unpaired: undecoded, @@ -640,7 +636,7 @@ const fieldOnlyItem = ( const limitations = absenceLimitations(member.left, identity, { oppositeComplete: inventoryCovers( context.rightComplete, - context.rightSelection, + context.scope.inRight, member.left, ), unpaired: undecoded, @@ -662,7 +658,7 @@ const fieldOnlyItem = ( const limitations = absenceLimitations(member.right, identity, { oppositeComplete: inventoryCovers( context.leftComplete, - context.leftSelection, + context.scope.inLeft, member.right, ), unpaired: undecoded, diff --git a/src/domain/managed/managedMemberComparisonMatch.ts b/src/domain/managed/managedMemberComparisonMatch.ts index 3cee7a0c85..beade8cefc 100644 --- a/src/domain/managed/managedMemberComparisonMatch.ts +++ b/src/domain/managed/managedMemberComparisonMatch.ts @@ -1,6 +1,6 @@ import { hasCompleteMethodRows, - selectedDeclaringTypes, + type ManagedComparisonScope, } from "./managedMemberComparisonCoverage.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { @@ -492,17 +492,6 @@ const groupBy = ( return grouped; }; -/** Whether a side's inventory observed members of this 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 * the other side's selection stays one-sided, so a type selection never yields @@ -542,18 +531,18 @@ const matchObserved = ( export const keyMembers = ( observedLeft: ManagedMemberComparisonSide, observedRight: ManagedMemberComparisonSide, + scope: ManagedComparisonScope, ): { readonly methodMatches: ReturnType; readonly fieldMatches: ReturnType; } => { const [left, right] = comparableSides(observedLeft, observedRight); - const sides = { inLeft: observedBy(left), inRight: observedBy(right) }; const modern = left.result.nesting_read !== null && right.result.nesting_read !== null; - const declared = (inspection: ManagedMemberInspection): DeclaredNameOf => { - const names = new Map( - inspection.types.map((type) => [type.token, type.name_sha256]), - ); + const declared = ( + inspection: ManagedMemberInspection, + names: ReadonlyMap, + ): DeclaredNameOf => { return (item) => { if (inspection.nesting_read === false) return null; const digest = names.get(item.declaring_type_token ?? ""); @@ -564,13 +553,13 @@ export const keyMembers = ( return digest == null ? null : ["nominal-name-sha256", digest]; }; }; - const declaredLeft = declared(left.result); - const declaredRight = declared(right.result); + const declaredLeft = declared(left.result, scope.left.names); + const declaredRight = declared(right.result, scope.right.names); return { methodMatches: matchObserved( left.result.methods, right.result.methods, - sides, + scope, (a, b) => matchMethods( a, @@ -586,7 +575,7 @@ export const keyMembers = ( fieldMatches: matchObserved( left.result.fields, right.result.fields, - sides, + scope, (a, b) => matchFields( a, diff --git a/tests/acceptance/managedSelectedScope.test.ts b/tests/acceptance/managedSelectedScope.test.ts index 45d6153ebb..9bc1172f86 100644 --- a/tests/acceptance/managedSelectedScope.test.ts +++ b/tests/acceptance/managedSelectedScope.test.ts @@ -6,7 +6,10 @@ 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 { + distinctSelectedNestedOwners, + namespaceBearingNestedTypes, +} from "../fixtures/managedSelectedScope.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; @@ -79,3 +82,103 @@ cliTest( } }, ); + +cliTest( + "preserves nominal selected membership and whole-inventory absence through CLI Evidence and stdio MCP after source removal", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-managed-nominal-membership-", + ); + const bytes = distinctSelectedNestedOwners(); + const selected: ReturnType[] = []; + const whole: ReturnType[] = []; + for (const [name, content] of Object.entries(bytes)) { + const path = join(root, `${name}.dll`); + const sha256 = createHash("sha256").update(content).digest("hex"); + await writeFile(path, content); + for (const isSelected of [true, false]) { + const output = await cli.sharedCliRun!({ + arguments: [ + "inspect-managed-members", + path, + ...(isSelected ? ["--type", "Fixture.Inner"] : []), + "--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.coverage.state).toBe("complete"); + if (isSelected) { + expect(report.selection).toEqual({ + type_names: ["Fixture.Inner"], + unmatched_type_names: [], + }); + expect(report.types).toHaveLength(1); + expect(report.types[0]).toMatchObject({ + full_name: "Fixture.Inner", + token: "0x02000004", + }); + expect(report.types[0]?.name_sha256).toMatch(/^[a-f0-9]{64}$/u); + selected.push(evidence); + } else { + expect(report.selection).toBeNull(); + whole.push(evidence); + } + } + expect(await readFile(path)).toEqual(content); + await rm(path); + } + const left = managedMemberInspectionSchema.parse( + selected[0]!.normalized_result, + ); + const right = managedMemberInspectionSchema.parse( + selected[1]!.normalized_result, + ); + expect(left.types[0]?.name_sha256).not.toBe(right.types[0]?.name_sha256); + const call = await cli.sharedMcpCall!(); + for (const [sources, isSelected] of [ + [selected, true], + [whole, false], + ] as const) { + const response = await call("compare_managed_members", { + left: sources[0]!, + right: sources[1]!, + }); + 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.map(({ evidence_id }) => evidence_id), + ); + expect(report.left.artifact_sha256).toBe(left.artifact.sha256); + expect(report.right.artifact_sha256).toBe(right.artifact.sha256); + expect(report.coverage.status).toBe( + isSelected ? "partial" : "complete-within-inputs", + ); + expect(report.summary).toEqual({ + added: isSelected ? 0 : 5, + removed: isSelected ? 0 : 5, + unknown: isSelected ? 4 : 0, + changed: 0, + unchanged: 0, + }); + if (isSelected) { + expect(report.methods).toHaveLength(2); + expect(report.fields).toHaveLength(2); + for (const item of [...report.methods, ...report.fields]) { + expect(item.match.status).toBe("unmatched"); + expect(item.left?.declaring_type ?? item.right?.declaring_type).toBe( + "Fixture.Inner", + ); + expect(item.evidence_links).toEqual(report.evidence_links); + } + } + } + }, +); diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index 493b315a8c..04fa5ec038 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -20,7 +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"; +import { + distinctSelectedNestedOwners, + namespaceBearingNestedTypes, +} from "../../../fixtures/managedSelectedScope.js"; it("selects namespace-bearing nested declarations from parsed type facts and compares the observed scope", () => { const bytes = namespaceBearingNestedTypes(); @@ -64,6 +67,106 @@ it("selects namespace-bearing nested declarations from parsed type facts and com ).toEqual([]); }); +describe("managed comparison selected nominal membership", () => { + it("retains known selected pairs in a partial inventory without needing display labels", () => { + const source = inspect( + distinctSelectedNestedOwners().left, + "/fixture/known.dll", + ["Fixture.Inner"], + ); + const partial = structuredClone(source.result); + partial.metadata.status = "partial"; + partial.coverage.state = "partial"; + partial.methods = partial.methods.map((method) => ({ + ...method, + declaring_type: null, + })); + partial.fields = partial.fields.map((field) => ({ + ...field, + declaring_type: null, + })); + const comparison = compareManagedMembers(source, { + ...source, + result: partial, + }); + expect(comparison.coverage.status).toBe("partial"); + expect(comparison.summary).toEqual({ + added: 0, + removed: 0, + unknown: 0, + changed: 0, + unchanged: 2, + }); + expect( + [...comparison.methods, ...comparison.fields].every( + ({ match, right }) => + match.status === "matched" && right?.declaring_type === null, + ), + ).toBe(true); + }); + + it.each(["current", "historical"])( + "does not recover missing %s selected identity from display names", + (kind) => { + const source = inspect( + distinctSelectedNestedOwners().left, + "/fixture/selected.dll", + ["Fixture.Inner"], + ); + const unknown = structuredClone(source.result); + unknown.types = unknown.types.map((type) => ({ + ...type, + name_sha256: null, + })); + if (kind === "historical") unknown.nesting_read = null; + const comparison = compareManagedMembers(source, { + ...source, + result: unknown, + }); + expect(comparison.summary).toEqual({ + added: 0, + removed: 0, + unknown: 4, + changed: 0, + unchanged: 0, + }); + expect(comparison.matching.unmatched).toBe(4); + expect(comparison.coverage.status).toBe("partial"); + }, + ); + + it("keeps one-sided members unknown when distinct selected owners share a display name", () => { + const bytes = distinctSelectedNestedOwners(); + const left = inspect(bytes.left, "/fixture/left.dll", ["Fixture.Inner"]); + const right = inspect(bytes.right, "/fixture/right.dll", ["Fixture.Inner"]); + expect(left.result.coverage.state).toBe("complete"); + expect(right.result.coverage.state).toBe("complete"); + expect(left.result.types[0]?.full_name).toBe("Fixture.Inner"); + expect(right.result.types[0]?.full_name).toBe("Fixture.Inner"); + expect(left.result.types[0]?.name_sha256).not.toBe( + right.result.types[0]?.name_sha256, + ); + const selected = compareManagedMembers(left, right); + expect(selected.coverage.status).toBe("partial"); + expect(selected.summary).toEqual({ + added: 0, + removed: 0, + unknown: 4, + changed: 0, + unchanged: 0, + }); + expect(selected.matching.unmatched).toBe(4); + expect(selected.methods).toHaveLength(2); + expect(selected.fields).toHaveLength(2); + const whole = compareManagedMembers( + inspect(bytes.left, "/fixture/left.dll"), + inspect(bytes.right, "/fixture/right.dll"), + ); + expect(whole.coverage.status).toBe("complete-within-inputs"); + expect(whole.summary).toMatchObject({ added: 5, removed: 5, unknown: 0 }); + }); +}); + describe("managed comparison selected scope agreement", () => { it("compares retained types rather than equivalent request spellings", () => { const bytes = namespaceBearingNestedTypes(); @@ -105,6 +208,14 @@ describe("managed comparison selected scope agreement", () => { ); const comparison = compareManagedMembers(left, right); expect(comparison.coverage.status).toBe("partial"); + expect(comparison.summary).toEqual({ + added: 0, + removed: 0, + unknown: 4, + changed: 0, + unchanged: 0, + }); + expect(comparison.matching.unmatched).toBe(4); expect(comparison.limitations).toContain( "The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.", ); diff --git a/tests/fixtures/managedSelectedScope.ts b/tests/fixtures/managedSelectedScope.ts index 793022200e..cd8670d3e7 100644 --- a/tests/fixtures/managedSelectedScope.ts +++ b/tests/fixtures/managedSelectedScope.ts @@ -4,6 +4,11 @@ import { metadataRowOffset, readManagedMetadataLayout, } from "../../src/dotnet/ManagedMetadataLayout.js"; +import { + metadataRowCursor, + readMetadataBlob, +} from "../../src/dotnet/ManagedMetadataHeaps.js"; +import { readMethodDefinitionRow } from "../../src/dotnet/ManagedMethodDefinitionRows.js"; /** Two nested Inner declarations, one with its own Fixture namespace. */ export const namespaceBearingNestedTypes = () => { @@ -22,3 +27,45 @@ export const namespaceBearingNestedTypes = () => { bytes.writeUInt16LE(namespace, metadataRowOffset(layout, 2, 4) + column); return bytes; }; + +/** Same selected display, different enclosing owner, primitive signatures and CIL. */ +export const distinctSelectedNestedOwners = () => { + const left = namespaceBearingNestedTypes(); + const right = Buffer.from(left); + for (const [index, bytes] of [left, right].entries()) { + 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 method = readMethodDefinitionRow(bytes, layout, 1); + if (index === 1) { + bytes.writeUInt16LE(3, metadataRowOffset(layout, 41, 1) + 2); + readMetadataBlob( + bytes, + layout, + method.signatureIndex, + layout.blob!.size, + )[2] = 0x0a; + const field = metadataRowCursor(bytes, layout, 4, 1); + field.readUInt16(); + field.readIndex(layout.stringIndexSize); + readMetadataBlob( + bytes, + layout, + field.readIndex(layout.blobIndexSize), + layout.blob!.size, + )[1] = 0x0a; + } + // All three MethodDefs share this fixture body and signature. Tiny bodies + // avoid unresolved MemberRefs and keep the two signatures and shapes distinct. + const body = + index === 0 + ? Buffer.from([0x0a, 0x17, 0x2a]) + : Buffer.from([0x2a, 0x21, 2, 0, 0, 0, 0, 0, 0, 0, 0x2a]); + bytes.set(body, pe.rvaToOffset(method.rva, body.length, "fixture CIL")); + } + return { left, right }; +};