[GH-ISSUE #6845] [Bug] Managed comparisons miss nested call-target owner changes #4184

Closed
opened 2026-10-31 00:47:31 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 23, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6845

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

Changing a MemberRef call from Fixture.OuterA+Inner.Ping() to Fixture.OuterB+Inner.Ping() leaves the caller unchanged, with no changed dimensions or method limitations and complete-within-inputs comparison coverage. Both source inspections have complete coverage and no issues.

The parsed member_refs[0].parent_name and call_edges[0].target_declaring_type are Inner for both artifacts. A top-level control that changes Fixture.LeafA.Ping() to Fixture.LeafB.Ping() reports changed with call-target.

Observed through the production byte-reader and comparison APIs on benign reduced fixtures. This is a comparison false negative after the display-name projection loses nested ownership; it is not a claim about runtime dispatch.

Reproduction

From the source checkout with locked dependencies installed, save this as /tmp/rea-example.mts, then run:

REA_ROOT="$PWD" node_modules/.bin/tsx /tmp/rea-example.mts /tmp/rea-example.json
import { createHash } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';

const root = process.env.REA_ROOT;
if (!root) throw new Error('Set REA_ROOT to the checkout');
const load = (name: string) => import(pathToFileURL(`${root}/src/${name}.ts`).href);
const { buildManagedPeFixture, managedPeFixtureTarget } = await load('dotnet/ManagedPe.fixture');
const { inspectManagedMembersBytes } = await load('dotnet/ManagedMemberInspector');
const { readManagedPeLayout } = await load('dotnet/ManagedPeReader');
const { readManagedMetadataLayout, metadataRowOffset } = await load('dotnet/ManagedMetadataLayout');
const { compareManagedMembers } = await load('domain/managed/managedMemberComparison');

const tiny = (il: number[]) => Buffer.from([(il.length << 2) | 2, ...il]);
function observe(options: object, label: string) {
  const typeReferences = [...((options as any).typeReferences ?? []), { namespace: 'System', name: 'Object' }];
  const bytes = buildManagedPeFixture({
    targetFramework: null,
    memberRefName: 'Ping',
    memberRefSignature: Buffer.from([0, 0, 1]),
    memberRefParentRaw: 25,
    ilBody: tiny([0x28, 1, 0, 0, 0x0a, 0x2a]),
    ...options,
    typeReferences,
    extendsRaw: ((typeReferences.length + 1) << 2) | 1,
  });
  const pe = readManagedPeLayout(bytes);
  const cli = pe.cli;
  if (!cli) throw new Error('Missing fixture CLI');
  const md = readManagedMetadataLayout(bytes,
    pe.rvaToOffset(cli.metadata.rva, cli.metadata.size, 'probe'), cli.metadata.size);
  // A real external top-level reference binds to AssemblyRef 1, not null scope.
  for (let index = 0; index < typeReferences.length; index++)
    if (typeReferences[index].enclosingRow === undefined)
      bytes.writeUInt16LE(6, metadataRowOffset(md, 1, index + 2));
  const coff = bytes.readUInt32LE(0x3c) + 4;
  bytes.writeUInt16LE(bytes.readUInt16LE(coff + 18) | 0x2000, coff + 18);
  bytes.writeUInt32LE(0, cli.headerOffset + 20);
  const target = managedPeFixtureTarget(bytes, `/tmp/rea-example/${label}.dll`);
  const result = inspectManagedMembersBytes(bytes, target);
  return { evidenceId: `ev_${target.sha256}`, result };
}
function report(label: string, left: any, right: any) {
  const comparison = compareManagedMembers(left, right);
  return { label, runtime: { node: process.version, platform: process.platform, arch: process.arch },
    left: { artifact: left.result.artifact, memberRefs: left.result.member_refs,
      callEdges: left.result.call_edges, coverage: left.result.coverage },
    right: { artifact: right.result.artifact, memberRefs: right.result.member_refs,
      callEdges: right.result.call_edges, coverage: right.result.coverage },
    comparison: { methods: comparison.methods, coverage: comparison.coverage, limitations: comparison.limitations } };
}
const records = [];
for (const [lane, leftArg, rightArg] of [
  ['nested', 'OuterA', 'OuterB'],
  ['control-top-level', 'LeafA', 'LeafB'],
] as const) {
  const opts = (name: string) => lane === 'nested'
    ? { typeReferences: [{ namespace: 'Fixture', name }, { namespace: '', name: 'Inner', enclosingRow: 2 }] }
    : { typeReferences: [{ namespace: 'Fixture', name: 'Unused' }, { namespace: 'Fixture', name }] };
  records.push(report(lane, observe(opts(leftArg), `${lane}-left`), observe(opts(rightArg), `${lane}-right`)));
}
const output = JSON.stringify(records, null, 2) + '\n';
writeFileSync(process.argv[2], output);
for (const r of records) console.log(JSON.stringify({ lane: r.label,
  leftParent: r.left.memberRefs[0]?.parent_name, rightParent: r.right.memberRefs[0]?.parent_name,
  leftCoverage: r.left.coverage, rightCoverage: r.right.coverage,
  comparison: r.comparison.methods.map((m: any) => ({ status: m.status, dimensions: m.dimensions, limitations: m.limitations })),
  coverage: r.comparison.coverage }));

The final captured run inspected one nested pair and one top-level control pair. The nested fixtures have identical caller signatures and CIL, and their TypeRef enclosing names differ. No target code ran.

Expected behavior

Preserve the enclosing TypeRef chain when establishing call-target identity. If the deliberately shortened display name cannot establish that identity, leave the comparison facet unknown with an inline limitation instead of reporting an unqualified unchanged method.

ECMA-335 II.7.3 and II.22.38 make the enclosing TypeRef part of nested type resolution. REA already retains that relationship in its TypeRef owner. The comparison guide says target declaring-type changes are compared and missing target identity remains unknown. The edge-name documentation explicitly describes bare nested display names; the defect is consuming those lossy names as sufficient comparison identity with no inline caveat.

Impact and scope

A changed static call target is hidden when nested types share a leaf name and only the enclosing type changes. Observed for the two reduced external nested-TypeRef fixtures; the top-level control detects its change correctly. Other nesting shapes, field accesses, compiler-generated binaries, public CLI/MCP delivery, and runtime binding were not tested.

REA version

6.7.0; source revision 169512a35bb015ff209168fb8bc1c9b31a2c678b.

Runtime and platform

Node.js v26.12.1; Linux 7.2.8-9-omarchy x86_64; source imports through the locked tsx dependency. No CLR or external analysis engine was invoked.

Target and artifact context

Reduced synthetic 4 KiB managed PE libraries. The probes read metadata and CIL without loading or executing target code. Top-level TypeRefs bind to AssemblyRef 1; nested TypeRefs use an enclosing TypeRef ResolutionScope. External assembly resolution and CLR admission were not tested.

Interface

Other

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Selected output from the final captured run:

{"lane":"nested","leftParent":"Inner","rightParent":"Inner","leftCoverage":{"state":"complete","issues":[]},"rightCoverage":{"state":"complete","issues":[]},"comparison":[{"status":"unchanged","dimensions":[],"limitations":[]}],"coverage":{"status":"complete-within-inputs","left_status":"complete","right_status":"complete"}}
{"lane":"control-top-level","leftParent":"Fixture.LeafA","rightParent":"Fixture.LeafB","comparison":[{"status":"changed","dimensions":["call-target"],"limitations":[]}]}

Evidence provenance: observed production API execution plus source-derived explanation. Local verification: reproduced. Reproduction completeness: complete for the reduced parser/comparison boundary; NOT_ENOUGH_INFO for compiler-produced assemblies or CLR/runtime behavior.

Typed outcome or recovery expected

A changed call-target dimension, or an explicit unknown target-identity facet, with enough provenance to preserve the enclosing owner.

Analysis or suspected cause

Source-derived: typeNameLookup joins only a TypeRef's own namespace and name even though managedTypeReferences retains enclosingRow. parseMemberRefs stores the shortened name in the core target. compareCallTargets checks only that target name and declaring-type string, so it sees no difference.

Additional context

The supplied open-issue inventory was searched in both titles and bodies for nested/enclosing call targets, MemberRef, and parent_name. Related #6815 reports nested custom-attribute type names in a different reader; it does not report call-edge identity or comparison false negatives. Closed #6630 covered changed call-target names; its own-name control is detected here, while the enclosing-only change still disappears. Closed #6760 covered nested TypeDef catch targets, rather than the external TypeRef call targets demonstrated here.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The observed complete comparison hides a changed nested call owner and returns an unchanged method without an inline limitation. The reach demonstrated here is bounded to reduced metadata fixtures.
  • Workaround status: unverified
  • Workaround basis: Enclosing TypeRef facts remain in the input bytes, but an independent manual reconstruction workflow was not exercised.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6845 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior Changing a MemberRef call from `Fixture.OuterA+Inner.Ping()` to `Fixture.OuterB+Inner.Ping()` leaves the caller `unchanged`, with no changed dimensions or method limitations and `complete-within-inputs` comparison coverage. Both source inspections have complete coverage and no issues. The parsed `member_refs[0].parent_name` and `call_edges[0].target_declaring_type` are `Inner` for both artifacts. A top-level control that changes `Fixture.LeafA.Ping()` to `Fixture.LeafB.Ping()` reports `changed` with `call-target`. Observed through the production byte-reader and comparison APIs on benign reduced fixtures. This is a comparison false negative after the display-name projection loses nested ownership; it is not a claim about runtime dispatch. ### Reproduction From the source checkout with locked dependencies installed, save this as `/tmp/rea-example.mts`, then run: ```sh REA_ROOT="$PWD" node_modules/.bin/tsx /tmp/rea-example.mts /tmp/rea-example.json ``` ```ts import { createHash } from 'node:crypto'; import { writeFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; const root = process.env.REA_ROOT; if (!root) throw new Error('Set REA_ROOT to the checkout'); const load = (name: string) => import(pathToFileURL(`${root}/src/${name}.ts`).href); const { buildManagedPeFixture, managedPeFixtureTarget } = await load('dotnet/ManagedPe.fixture'); const { inspectManagedMembersBytes } = await load('dotnet/ManagedMemberInspector'); const { readManagedPeLayout } = await load('dotnet/ManagedPeReader'); const { readManagedMetadataLayout, metadataRowOffset } = await load('dotnet/ManagedMetadataLayout'); const { compareManagedMembers } = await load('domain/managed/managedMemberComparison'); const tiny = (il: number[]) => Buffer.from([(il.length << 2) | 2, ...il]); function observe(options: object, label: string) { const typeReferences = [...((options as any).typeReferences ?? []), { namespace: 'System', name: 'Object' }]; const bytes = buildManagedPeFixture({ targetFramework: null, memberRefName: 'Ping', memberRefSignature: Buffer.from([0, 0, 1]), memberRefParentRaw: 25, ilBody: tiny([0x28, 1, 0, 0, 0x0a, 0x2a]), ...options, typeReferences, extendsRaw: ((typeReferences.length + 1) << 2) | 1, }); const pe = readManagedPeLayout(bytes); const cli = pe.cli; if (!cli) throw new Error('Missing fixture CLI'); const md = readManagedMetadataLayout(bytes, pe.rvaToOffset(cli.metadata.rva, cli.metadata.size, 'probe'), cli.metadata.size); // A real external top-level reference binds to AssemblyRef 1, not null scope. for (let index = 0; index < typeReferences.length; index++) if (typeReferences[index].enclosingRow === undefined) bytes.writeUInt16LE(6, metadataRowOffset(md, 1, index + 2)); const coff = bytes.readUInt32LE(0x3c) + 4; bytes.writeUInt16LE(bytes.readUInt16LE(coff + 18) | 0x2000, coff + 18); bytes.writeUInt32LE(0, cli.headerOffset + 20); const target = managedPeFixtureTarget(bytes, `/tmp/rea-example/${label}.dll`); const result = inspectManagedMembersBytes(bytes, target); return { evidenceId: `ev_${target.sha256}`, result }; } function report(label: string, left: any, right: any) { const comparison = compareManagedMembers(left, right); return { label, runtime: { node: process.version, platform: process.platform, arch: process.arch }, left: { artifact: left.result.artifact, memberRefs: left.result.member_refs, callEdges: left.result.call_edges, coverage: left.result.coverage }, right: { artifact: right.result.artifact, memberRefs: right.result.member_refs, callEdges: right.result.call_edges, coverage: right.result.coverage }, comparison: { methods: comparison.methods, coverage: comparison.coverage, limitations: comparison.limitations } }; } const records = []; for (const [lane, leftArg, rightArg] of [ ['nested', 'OuterA', 'OuterB'], ['control-top-level', 'LeafA', 'LeafB'], ] as const) { const opts = (name: string) => lane === 'nested' ? { typeReferences: [{ namespace: 'Fixture', name }, { namespace: '', name: 'Inner', enclosingRow: 2 }] } : { typeReferences: [{ namespace: 'Fixture', name: 'Unused' }, { namespace: 'Fixture', name }] }; records.push(report(lane, observe(opts(leftArg), `${lane}-left`), observe(opts(rightArg), `${lane}-right`))); } const output = JSON.stringify(records, null, 2) + '\n'; writeFileSync(process.argv[2], output); for (const r of records) console.log(JSON.stringify({ lane: r.label, leftParent: r.left.memberRefs[0]?.parent_name, rightParent: r.right.memberRefs[0]?.parent_name, leftCoverage: r.left.coverage, rightCoverage: r.right.coverage, comparison: r.comparison.methods.map((m: any) => ({ status: m.status, dimensions: m.dimensions, limitations: m.limitations })), coverage: r.comparison.coverage })); ``` The final captured run inspected one nested pair and one top-level control pair. The nested fixtures have identical caller signatures and CIL, and their TypeRef enclosing names differ. No target code ran. ### Expected behavior Preserve the enclosing TypeRef chain when establishing call-target identity. If the deliberately shortened display name cannot establish that identity, leave the comparison facet unknown with an inline limitation instead of reporting an unqualified unchanged method. [ECMA-335 II.7.3 and II.22.38](https://ecma-international.org/wp-content/uploads/ECMA-335_6th_edition_june_2012.pdf) make the enclosing TypeRef part of nested type resolution. REA already retains that relationship in its TypeRef owner. The [comparison guide](https://github.com/morluto/rea/blob/169512a35bb015ff209168fb8bc1c9b31a2c678b/docs/managed-code-analysis.md#L395) says target declaring-type changes are compared and missing target identity remains unknown. The [edge-name documentation](https://github.com/morluto/rea/blob/169512a35bb015ff209168fb8bc1c9b31a2c678b/docs/managed-code-analysis.md#L288) explicitly describes bare nested display names; the defect is consuming those lossy names as sufficient comparison identity with no inline caveat. ### Impact and scope A changed static call target is hidden when nested types share a leaf name and only the enclosing type changes. Observed for the two reduced external nested-TypeRef fixtures; the top-level control detects its change correctly. Other nesting shapes, field accesses, compiler-generated binaries, public CLI/MCP delivery, and runtime binding were not tested. ### REA version 6.7.0; source revision `169512a35bb015ff209168fb8bc1c9b31a2c678b`. ### Runtime and platform Node.js v26.12.1; Linux 7.2.8-9-omarchy x86_64; source imports through the locked tsx dependency. No CLR or external analysis engine was invoked. ### Target and artifact context Reduced synthetic 4 KiB managed PE libraries. The probes read metadata and CIL without loading or executing target code. Top-level TypeRefs bind to AssemblyRef 1; nested TypeRefs use an enclosing TypeRef ResolutionScope. External assembly resolution and CLR admission were not tested. ### Interface Other ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Selected output from the final captured run: ```jsonl {"lane":"nested","leftParent":"Inner","rightParent":"Inner","leftCoverage":{"state":"complete","issues":[]},"rightCoverage":{"state":"complete","issues":[]},"comparison":[{"status":"unchanged","dimensions":[],"limitations":[]}],"coverage":{"status":"complete-within-inputs","left_status":"complete","right_status":"complete"}} {"lane":"control-top-level","leftParent":"Fixture.LeafA","rightParent":"Fixture.LeafB","comparison":[{"status":"changed","dimensions":["call-target"],"limitations":[]}]} ``` Evidence provenance: observed production API execution plus source-derived explanation. Local verification: reproduced. Reproduction completeness: complete for the reduced parser/comparison boundary; NOT_ENOUGH_INFO for compiler-produced assemblies or CLR/runtime behavior. ### Typed outcome or recovery expected A changed call-target dimension, or an explicit unknown target-identity facet, with enough provenance to preserve the enclosing owner. ### Analysis or suspected cause Source-derived: [typeNameLookup](https://github.com/morluto/rea/blob/169512a35bb015ff209168fb8bc1c9b31a2c678b/src/dotnet/ManagedTypeNames.ts#L35) joins only a TypeRef's own namespace and name even though [managedTypeReferences](https://github.com/morluto/rea/blob/169512a35bb015ff209168fb8bc1c9b31a2c678b/src/dotnet/ManagedTypeReferences.ts#L62) retains `enclosingRow`. [parseMemberRefs](https://github.com/morluto/rea/blob/169512a35bb015ff209168fb8bc1c9b31a2c678b/src/dotnet/ManagedMemberRows.ts#L252) stores the shortened name in the core target. [compareCallTargets](https://github.com/morluto/rea/blob/169512a35bb015ff209168fb8bc1c9b31a2c678b/src/domain/managed/managedMemberComparisonItems.ts#L368) checks only that target name and declaring-type string, so it sees no difference. ### Additional context The supplied open-issue inventory was searched in both titles and bodies for nested/enclosing call targets, MemberRef, and parent_name. Related #6815 reports nested **custom-attribute** type names in a different reader; it does not report call-edge identity or comparison false negatives. Closed [#6630](https://github.com/morluto/rea/issues/6630) covered changed call-target names; its own-name control is detected here, while the enclosing-only change still disappears. Closed [#6760](https://github.com/morluto/rea/issues/6760) covered nested TypeDef catch targets, rather than the external TypeRef call targets demonstrated here. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: The observed complete comparison hides a changed nested call owner and returns an unchanged method without an inline limitation. The reach demonstrated here is bounded to reduced metadata fixtures. - Workaround status: unverified - Workaround basis: Enclosing TypeRef facts remain in the input bytes, but an independent manual reconstruction workflow was not exercised.
Author
Owner

@morluto commented on GitHub (Oct 23, 2026):

Nested TypeRef owner changes now alter retained target identity even when display names/tokens stay equal. The exact producer/comparison positive and same-input control passed. Implemented in github.com/morluto/rea@357e354ac7. Validated on 488c21ab2 using the named production-parser/comparison regression (detects an enclosing TypeRef change even when display labels and tokens are equal; five selected tests passed). Fixtures are synthetic metadata; no CLR execution or real-provider claim.

<!-- gh-comment-id:6376089578 --> @morluto commented on GitHub (Oct 23, 2026): Nested TypeRef owner changes now alter retained target identity even when display names/tokens stay equal. The exact producer/comparison positive and same-input control passed. Implemented in https://github.com/morluto/rea/commit/357e354ac7f5aed8e90c209dff30327d6bf212dc. Validated on 488c21ab2 using the named production-parser/comparison regression (`detects an enclosing TypeRef change even when display labels and tokens are equal`; five selected tests passed). Fixtures are synthetic metadata; no CLR execution or real-provider claim.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#4184
No description provided.