[GH-ISSUE #6760] [Bug] Managed comparison misses an enclosing-type change in a nested catch target #4118

Closed
opened 2026-10-31 00:46:57 +00:00 by bsakaguchi · 0 comments
Owner

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

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

The shared compareManagedMemberPaths application service reports Fixture.Program.Main as unchanged, with exact-il-signature matching, confidence: "exact", no changed dimensions, and complete input coverage when a nested catch TypeDef moves to a different enclosing type. Both member inspections retain different name_sha256 values for the caught type, but each exception record retains only class_type: "Fixture.Empty1" and class_type_identity_resolved: true, losing the known distinction.

This was reproduced by executing REA's comparison service on generated local PE fixtures. The fixtures were admitted with complete parser coverage; CLR validity, loading, and execution behavior were not verified. This is an observed static-comparison failure, not a reproduced change in runtime exception handling.

Reproduction

From a locked-dependency source checkout, save the following as /tmp/nested-catch.mts, create a disposable scratch directory, and run node --import tsx /tmp/nested-catch.mts /tmp/nested-catch-fixtures from the checkout root. These are the fixture and production-path calls actually executed. The three cases isolate the enclosing-only change, an unchanged control, and a control changing the catch type's own name. The patch to the TypeRef ResolutionScope binds System.Exception to the generated System.Runtime AssemblyRef.

import { writeFile, mkdir } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
const repo=process.cwd();
const load=(path:string)=>import(pathToFileURL(`${repo}/${path}`).href);
const { buildManagedPeFixture, managedPeFixtureTarget } = await load('src/dotnet/ManagedPe.fixture.ts');
const { inspectManagedMembersBytes } = await load('src/dotnet/ManagedMemberInspector.ts');
const { readManagedPeLayout } = await load('src/dotnet/ManagedPeReader.ts');
const { readManagedMetadataLayout, metadataRowOffset } = await load('src/dotnet/ManagedMetadataLayout.ts');
const { compareManagedMemberPaths } = await load('src/application/managed/ManagedMemberComparisonService.ts');
const root=process.argv[2];
if(!root)throw new Error('Pass an existing scratch directory');
await mkdir(root,{recursive:true});
function assembly(mode:'same'|'changed-enclosing-type'|'changed-catch-name',mvid:number) {
 const il=Buffer.from([0x00,0xde,0x03,0x26,0xde,0x00,0x2a]);
 const header=Buffer.alloc(12); header.writeUInt16LE(0x300b); header.writeUInt16LE(8,2); header.writeUInt32LE(il.length,4);
 const eh=Buffer.alloc(16); eh[0]=1; eh[1]=16; eh[8]=3; eh.writeUInt16LE(3,9); eh[11]=3; eh.writeUInt32LE(0x02000002,12);
 const bytes=buildManagedPeFixture({additionalEmptyTypes:2,nestedTypes:[[2,mode==='changed-enclosing-type'?3:1]],targetFramework:null,references:['System.Runtime'],typeReferences:[{namespace:'System',name:'Exception'}],extendsRaw:9,mvid:Buffer.alloc(16,mvid),ilBody:Buffer.concat([header,il,Buffer.alloc(1),eh])});
 const pe=readManagedPeLayout(bytes); const cli=pe.cli!;const md=readManagedMetadataLayout(bytes,pe.rvaToOffset(cli.metadata.rva,cli.metadata.size,'probe'),cli.metadata.size);
 bytes.writeUInt16LE(6,metadataRowOffset(md,1,2));
 if(mode==='changed-catch-name'){const off=bytes.indexOf(Buffer.from('Empty1\0'));if(off<0)throw new Error('missing fixture catch');bytes[off+5]='9'.charCodeAt(0);}
 return bytes;
}
for (const label of ['same','changed-enclosing-type','changed-catch-name'] as const){
 const leftBytes=assembly('same',1), rightBytes=assembly(label,2);
 const leftPath=root+'/nested-catch-left.dll',rightPath=root+'/nested-catch-'+label+'.dll';
 await writeFile(leftPath,leftBytes);await writeFile(rightPath,rightBytes);
 const left=inspectManagedMembersBytes(leftBytes,managedPeFixtureTarget(leftBytes,leftPath));const right=inspectManagedMembersBytes(rightBytes,managedPeFixtureTarget(rightBytes,rightPath));
 const result=await compareManagedMemberPaths({leftPath,rightPath});if(!result.ok)throw result.error;
 const comparison:any=result.value.normalized_result;
 const caught=(value:any)=>value.types.find((t:any)=>t.token==='0x02000002');
 console.log(JSON.stringify({label,leftType:caught(left),rightType:caught(right),leftCatch:left.methods[0]?.body.exception_regions,rightCatch:right.methods[0]?.body.exception_regions,leftCoverage:left.coverage,rightCoverage:right.coverage,method:comparison.methods[0],coverage:comparison.coverage}));
}

Expected behavior

A known difference in the catch type's nominal identity must be retained, including its enclosing-type chain. The enclosing-only case should report an exception-target difference, or explicitly leave that facet unknown if the retained representation cannot establish correspondence. It must not claim unqualified equality using a flattened display name.

docs/managed-code-analysis.md:318–328 distinguishes observed differences and unknowns and states that same-named nested types in different enclosing types remain distinct. The exception comparison in src/domain/managed/managedMemberComparisonItems.ts:215–238 already treats a changed catch type name as exception-target; the executed name-change control confirms this boundary. src/dotnet/ManagedTypeNameDigests.ts:48–145 preserves the enclosing chain in type-name digests.

Impact and scope

One enclosing-only change was missed in this fixture run. The unchanged control remained unchanged, while changing the catch type's own name produced changed with dimensions: ["exception-target"]. The method's signature, CIL, token, and EH ranges are held equal, so those facts cannot reveal the target identity change. This yields a false-negative static method comparison for the demonstrated nested TypeDef case. The retained type inventory exposes enclosing tokens and name digests, enabling manual checking. Other nested target forms and real compiled assemblies were not tested.

REA version

6.7.0, source commit 2bcd7e6d13f0a8118d6619718254255e84f75d86; the latest npm release was confirmed before this investigation.

Runtime and platform

Node.js v26.12.1; Linux x86_64; locked dependencies installed with npm ci --ignore-scripts; source loaded with tsx. No CLR, Hopper, or Ghidra process ran.

Target and artifact context

Benign generated PE/CLI parser fixtures containing three TypeDefs, one nested caught type, a fat method body, and a small catch EH clause. The two compared artifacts use different MVIDs. No private binaries or live configuration were used.

Interface

Other

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Selected fields from the actual JSON output, exit status 0:

{"label":"same","left_enclosing":"0x02000001","right_enclosing":"0x02000001","left_name_sha256":"f9d89a816674031ca4197eae020b85d138bf06e6bd2599c3b6f5e4cae561fb4e","right_name_sha256":"f9d89a816674031ca4197eae020b85d138bf06e6bd2599c3b6f5e4cae561fb4e","status":"unchanged","dimensions":[],"method_limitations":[],"coverage":{"status":"complete-within-inputs","left_status":"complete","right_status":"complete"}}
{"label":"changed-enclosing-type","left_enclosing":"0x02000001","right_enclosing":"0x02000003","left_name_sha256":"f9d89a816674031ca4197eae020b85d138bf06e6bd2599c3b6f5e4cae561fb4e","right_name_sha256":"970da5539ea063770c88a1beccdf2c1a2e1a5cf90eb8840c3d78be250239ced8","status":"unchanged","dimensions":[],"method_limitations":[],"coverage":{"status":"complete-within-inputs","left_status":"complete","right_status":"complete"},"left_catch":[{"flags":0,"try_offset":0,"try_length":3,"handler_offset":3,"handler_length":3,"class_token":"0x02000002","class_type":"Fixture.Empty1","class_type_identity_resolved":true,"filter_offset":null}],"right_catch":[{"flags":0,"try_offset":0,"try_length":3,"handler_offset":3,"handler_length":3,"class_token":"0x02000002","class_type":"Fixture.Empty1","class_type_identity_resolved":true,"filter_offset":null}],"left_coverage":{"state":"complete","issues":[]},"right_coverage":{"state":"complete","issues":[]}}
{"label":"changed-catch-name","left_enclosing":"0x02000001","right_enclosing":"0x02000001","left_name_sha256":"f9d89a816674031ca4197eae020b85d138bf06e6bd2599c3b6f5e4cae561fb4e","right_name_sha256":"560b19aa95af8c2d71a6131061b1f305f70ec6cd1a691587c994e07d3ad57ea1","status":"changed","dimensions":["exception-target"],"method_limitations":[],"coverage":{"status":"complete-within-inputs","left_status":"complete","right_status":"complete"}}

The catch token is unchanged at 0x02000002. Its enclosing TypeDef changes from 0x02000001 to 0x02000003, and the inspection's retained digest changes. src/dotnet/ManagedMemberInspector.ts:311–330 computes whether the nominal digest exists but projects only that boolean beside the flattened catch name. src/domain/managed/managedMemberComparisonItems.ts:215–238 compares those names and booleans. The executed path service is src/application/managed/ManagedMemberComparisonService.ts:104–160, also reached by the CLI command at src/cli/managedCommands.ts:133–147. CLI/MCP transports and CLR loading were not executed.

Typed outcome or recovery expected

Retain the observed nominal catch-target difference as exception-target, or qualify the exception facet as unknown when its identity cannot be compared. Keep the identical-enclosing control unchanged and the own-name-change control changed.

Analysis or suspected cause

Source-derived cause: the producer already has a nesting-aware digest but reduces it to class_type_identity_resolved. Two different known nominal identities therefore become the same display string plus true. The comparison treats these reduced facts as equality. This is consistent with the observed changed digest and unchanged result; it does not depend on assigning persistent identity to the raw catch token.

Additional context

Related closed report #6712 concerned catch-token equality/relocation. This is a residual follow-up using a nested TypeDef: its enclosing identity changes while the replacement comparison's display name remains equal. The current baseline includes the name-based exception comparison. Targeted open and closed searches included repo:morluto/rea is:issue in:title "catch" and repo:morluto/rea is:issue in:title "managed" "nested"; #5412 instead concerns bare-name selection.

Three possible comparison bases were considered: raw catch tokens, flattened names plus an availability flag, and retained nominal identity with unknowns when absent. Nominal identity is strongest because tokens are build-local and the flattened names demonstrably collide here. Independent critical review inspected the source, saved fixture, and controls before drafting. No implementation change was made.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The executed production comparison path misses a known enclosing-only catch-type identity difference while reporting exact unchanged matching and complete input coverage; two controls distinguish the failure.
  • Workaround status: available
  • Workaround basis: The member inspection retains each caught TypeDef's enclosing token and different name_sha256, so a caller can manually compare the nominal target facts outside the reduced exception record.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6760 ## 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 The shared `compareManagedMemberPaths` application service reports `Fixture.Program.Main` as `unchanged`, with `exact-il-signature` matching, `confidence: "exact"`, no changed dimensions, and complete input coverage when a nested catch TypeDef moves to a different enclosing type. Both member inspections retain different `name_sha256` values for the caught type, but each exception record retains only `class_type: "Fixture.Empty1"` and `class_type_identity_resolved: true`, losing the known distinction. This was reproduced by executing REA's comparison service on generated local PE fixtures. The fixtures were admitted with complete parser coverage; CLR validity, loading, and execution behavior were not verified. This is an observed static-comparison failure, not a reproduced change in runtime exception handling. ## Reproduction From a locked-dependency source checkout, save the following as `/tmp/nested-catch.mts`, create a disposable scratch directory, and run `node --import tsx /tmp/nested-catch.mts /tmp/nested-catch-fixtures` from the checkout root. These are the fixture and production-path calls actually executed. The three cases isolate the enclosing-only change, an unchanged control, and a control changing the catch type's own name. The patch to the TypeRef ResolutionScope binds `System.Exception` to the generated `System.Runtime` AssemblyRef. ```typescript import { writeFile, mkdir } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; const repo=process.cwd(); const load=(path:string)=>import(pathToFileURL(`${repo}/${path}`).href); const { buildManagedPeFixture, managedPeFixtureTarget } = await load('src/dotnet/ManagedPe.fixture.ts'); const { inspectManagedMembersBytes } = await load('src/dotnet/ManagedMemberInspector.ts'); const { readManagedPeLayout } = await load('src/dotnet/ManagedPeReader.ts'); const { readManagedMetadataLayout, metadataRowOffset } = await load('src/dotnet/ManagedMetadataLayout.ts'); const { compareManagedMemberPaths } = await load('src/application/managed/ManagedMemberComparisonService.ts'); const root=process.argv[2]; if(!root)throw new Error('Pass an existing scratch directory'); await mkdir(root,{recursive:true}); function assembly(mode:'same'|'changed-enclosing-type'|'changed-catch-name',mvid:number) { const il=Buffer.from([0x00,0xde,0x03,0x26,0xde,0x00,0x2a]); const header=Buffer.alloc(12); header.writeUInt16LE(0x300b); header.writeUInt16LE(8,2); header.writeUInt32LE(il.length,4); const eh=Buffer.alloc(16); eh[0]=1; eh[1]=16; eh[8]=3; eh.writeUInt16LE(3,9); eh[11]=3; eh.writeUInt32LE(0x02000002,12); const bytes=buildManagedPeFixture({additionalEmptyTypes:2,nestedTypes:[[2,mode==='changed-enclosing-type'?3:1]],targetFramework:null,references:['System.Runtime'],typeReferences:[{namespace:'System',name:'Exception'}],extendsRaw:9,mvid:Buffer.alloc(16,mvid),ilBody:Buffer.concat([header,il,Buffer.alloc(1),eh])}); const pe=readManagedPeLayout(bytes); const cli=pe.cli!;const md=readManagedMetadataLayout(bytes,pe.rvaToOffset(cli.metadata.rva,cli.metadata.size,'probe'),cli.metadata.size); bytes.writeUInt16LE(6,metadataRowOffset(md,1,2)); if(mode==='changed-catch-name'){const off=bytes.indexOf(Buffer.from('Empty1\0'));if(off<0)throw new Error('missing fixture catch');bytes[off+5]='9'.charCodeAt(0);} return bytes; } for (const label of ['same','changed-enclosing-type','changed-catch-name'] as const){ const leftBytes=assembly('same',1), rightBytes=assembly(label,2); const leftPath=root+'/nested-catch-left.dll',rightPath=root+'/nested-catch-'+label+'.dll'; await writeFile(leftPath,leftBytes);await writeFile(rightPath,rightBytes); const left=inspectManagedMembersBytes(leftBytes,managedPeFixtureTarget(leftBytes,leftPath));const right=inspectManagedMembersBytes(rightBytes,managedPeFixtureTarget(rightBytes,rightPath)); const result=await compareManagedMemberPaths({leftPath,rightPath});if(!result.ok)throw result.error; const comparison:any=result.value.normalized_result; const caught=(value:any)=>value.types.find((t:any)=>t.token==='0x02000002'); console.log(JSON.stringify({label,leftType:caught(left),rightType:caught(right),leftCatch:left.methods[0]?.body.exception_regions,rightCatch:right.methods[0]?.body.exception_regions,leftCoverage:left.coverage,rightCoverage:right.coverage,method:comparison.methods[0],coverage:comparison.coverage})); } ``` ## Expected behavior A known difference in the catch type's nominal identity must be retained, including its enclosing-type chain. The enclosing-only case should report an exception-target difference, or explicitly leave that facet unknown if the retained representation cannot establish correspondence. It must not claim unqualified equality using a flattened display name. `docs/managed-code-analysis.md:318–328` distinguishes observed differences and unknowns and states that same-named nested types in different enclosing types remain distinct. The exception comparison in `src/domain/managed/managedMemberComparisonItems.ts:215–238` already treats a changed catch type name as `exception-target`; the executed name-change control confirms this boundary. `src/dotnet/ManagedTypeNameDigests.ts:48–145` preserves the enclosing chain in type-name digests. ## Impact and scope One enclosing-only change was missed in this fixture run. The unchanged control remained unchanged, while changing the catch type's own name produced `changed` with `dimensions: ["exception-target"]`. The method's signature, CIL, token, and EH ranges are held equal, so those facts cannot reveal the target identity change. This yields a false-negative static method comparison for the demonstrated nested TypeDef case. The retained type inventory exposes enclosing tokens and name digests, enabling manual checking. Other nested target forms and real compiled assemblies were not tested. ## REA version 6.7.0, source commit `2bcd7e6d13f0a8118d6619718254255e84f75d86`; the latest npm release was confirmed before this investigation. ## Runtime and platform Node.js v26.12.1; Linux x86_64; locked dependencies installed with `npm ci --ignore-scripts`; source loaded with tsx. No CLR, Hopper, or Ghidra process ran. ## Target and artifact context Benign generated PE/CLI parser fixtures containing three TypeDefs, one nested caught type, a fat method body, and a small catch EH clause. The two compared artifacts use different MVIDs. No private binaries or live configuration were used. ## Interface Other ## Earliest failure stage Native analysis or reconstruction ## Relevant output or evidence Selected fields from the actual JSON output, exit status 0: ```jsonl {"label":"same","left_enclosing":"0x02000001","right_enclosing":"0x02000001","left_name_sha256":"f9d89a816674031ca4197eae020b85d138bf06e6bd2599c3b6f5e4cae561fb4e","right_name_sha256":"f9d89a816674031ca4197eae020b85d138bf06e6bd2599c3b6f5e4cae561fb4e","status":"unchanged","dimensions":[],"method_limitations":[],"coverage":{"status":"complete-within-inputs","left_status":"complete","right_status":"complete"}} {"label":"changed-enclosing-type","left_enclosing":"0x02000001","right_enclosing":"0x02000003","left_name_sha256":"f9d89a816674031ca4197eae020b85d138bf06e6bd2599c3b6f5e4cae561fb4e","right_name_sha256":"970da5539ea063770c88a1beccdf2c1a2e1a5cf90eb8840c3d78be250239ced8","status":"unchanged","dimensions":[],"method_limitations":[],"coverage":{"status":"complete-within-inputs","left_status":"complete","right_status":"complete"},"left_catch":[{"flags":0,"try_offset":0,"try_length":3,"handler_offset":3,"handler_length":3,"class_token":"0x02000002","class_type":"Fixture.Empty1","class_type_identity_resolved":true,"filter_offset":null}],"right_catch":[{"flags":0,"try_offset":0,"try_length":3,"handler_offset":3,"handler_length":3,"class_token":"0x02000002","class_type":"Fixture.Empty1","class_type_identity_resolved":true,"filter_offset":null}],"left_coverage":{"state":"complete","issues":[]},"right_coverage":{"state":"complete","issues":[]}} {"label":"changed-catch-name","left_enclosing":"0x02000001","right_enclosing":"0x02000001","left_name_sha256":"f9d89a816674031ca4197eae020b85d138bf06e6bd2599c3b6f5e4cae561fb4e","right_name_sha256":"560b19aa95af8c2d71a6131061b1f305f70ec6cd1a691587c994e07d3ad57ea1","status":"changed","dimensions":["exception-target"],"method_limitations":[],"coverage":{"status":"complete-within-inputs","left_status":"complete","right_status":"complete"}} ``` The catch token is unchanged at `0x02000002`. Its enclosing TypeDef changes from `0x02000001` to `0x02000003`, and the inspection's retained digest changes. `src/dotnet/ManagedMemberInspector.ts:311–330` computes whether the nominal digest exists but projects only that boolean beside the flattened catch name. `src/domain/managed/managedMemberComparisonItems.ts:215–238` compares those names and booleans. The executed path service is `src/application/managed/ManagedMemberComparisonService.ts:104–160`, also reached by the CLI command at `src/cli/managedCommands.ts:133–147`. CLI/MCP transports and CLR loading were not executed. ## Typed outcome or recovery expected Retain the observed nominal catch-target difference as `exception-target`, or qualify the exception facet as unknown when its identity cannot be compared. Keep the identical-enclosing control unchanged and the own-name-change control changed. ## Analysis or suspected cause Source-derived cause: the producer already has a nesting-aware digest but reduces it to `class_type_identity_resolved`. Two different known nominal identities therefore become the same display string plus `true`. The comparison treats these reduced facts as equality. This is consistent with the observed changed digest and unchanged result; it does not depend on assigning persistent identity to the raw catch token. ## Additional context Related closed report [#6712](https://github.com/morluto/rea/issues/6712) concerned catch-token equality/relocation. This is a residual follow-up using a nested TypeDef: its enclosing identity changes while the replacement comparison's display name remains equal. The current baseline includes the name-based exception comparison. Targeted open and closed searches included `repo:morluto/rea is:issue in:title "catch"` and `repo:morluto/rea is:issue in:title "managed" "nested"`; [#5412](https://github.com/morluto/rea/issues/5412) instead concerns bare-name selection. Three possible comparison bases were considered: raw catch tokens, flattened names plus an availability flag, and retained nominal identity with unknowns when absent. Nominal identity is strongest because tokens are build-local and the flattened names demonstrably collide here. Independent critical review inspected the source, saved fixture, and controls before drafting. No implementation change was made. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: The executed production comparison path misses a known enclosing-only catch-type identity difference while reporting exact unchanged matching and complete input coverage; two controls distinguish the failure. - Workaround status: available - Workaround basis: The member inspection retains each caught TypeDef's enclosing token and different name_sha256, so a caller can manually compare the nominal target facts outside the reduced exception record.
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#4118
No description provided.