[GH-ISSUE #3348] [Bug] Managed member comparison reports changed method headers as unchanged #1216

Closed
opened 2026-10-16 21:02:55 +00:00 by bsakaguchi · 0 comments
Owner

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

Before submitting

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

Area

Native analysis or decompilation

Observed behavior

compare-managed-members reports a method as unchanged when its observed init_locals or max_stack value changes and its signature and CIL bytes remain equal. Both inspections expose the changed header facts, but comparison returns an empty dimensions array, increments summary.unchanged, and reports complete coverage without identifying the omitted header comparison.

All three CLI operations in the initialization-flag reproduction exit with status 0. Inspection reports body.status: "present" on both sides, with init_locals: false on the left and init_locals: true on the right. Comparison returns an exact-il-signature match with exact confidence and classifies the method as unchanged. The method's limitations array is empty, and the overall limitations do not mention omitted method-header comparisons.

A separately executed variant kept init_locals unchanged and changed max_stack from 8 to 16. It produced the same erroneous unchanged method result. No target code was executed; these observations establish a lost static difference, with no measured runtime behavior change.

Reproduction

  1. Start from a clean checkout of 8dde6370d0809ad04cda3342d4e6f290f48aa063. From the repository root, install the locked dependencies and build the CLI:
npm ci
npm run build:cached
  1. Create two PE/CLI files with the repository fixture builder, inspect both, and compare them. Each method has a fat header and a single ret instruction. The only differing byte controls the initialization flag:
node --input-type=module <<'NODE'
import { writeFileSync } from 'node:fs';
import { buildManagedPeFixture } from './scripts/fixtures/managed/pe.mjs';
for (const [name, flags] of [['sample-left.dll', 0x3003], ['sample-right.dll', 0x3013]]) {
  const body = Buffer.alloc(13);
  body.writeUInt16LE(flags, 0);
  body.writeUInt16LE(8, 2);
  body.writeUInt32LE(1, 4);
  body[12] = 0x2a;
  writeFileSync(name, buildManagedPeFixture({ ilBody: body }));
}
NODE
node scripts/rea.mjs inspect-managed-members sample-left.dll --format json > sample-left.json
node scripts/rea.mjs inspect-managed-members sample-right.dll --format json > sample-right.json
node scripts/rea.mjs compare-managed-members sample-left.dll sample-right.dll --format json > sample-comparison.json
node --input-type=module <<'NODE'
import { readFileSync } from 'node:fs';
const read = name => JSON.parse(readFileSync(name, 'utf8')).normalized_result;
for (const name of ['sample-left.json', 'sample-right.json']) {
  const body = read(name).methods[0].body;
  console.log(name, JSON.stringify({ status: body.status, init_locals: body.init_locals, max_stack: body.max_stack }));
}
const comparison = read('sample-comparison.json');
console.log(JSON.stringify({ summary: comparison.summary, method: comparison.methods[0], coverage: comparison.coverage }));
NODE
  1. Exercise the maximum-stack variant and the file-to-itself control. The variant changes only the stack value from 8 to 16, preserving init_locals: false and the same CIL bytes. The initialization-flag commands above are the exact reproduction; these commands spell out the separately reported variant and control:
node --input-type=module <<'NODE'
import { writeFileSync } from 'node:fs';
import { buildManagedPeFixture } from './scripts/fixtures/managed/pe.mjs';
const body = Buffer.alloc(13);
body.writeUInt16LE(0x3003, 0);
body.writeUInt16LE(16, 2);
body.writeUInt32LE(1, 4);
body[12] = 0x2a;
writeFileSync('sample-stack.dll', buildManagedPeFixture({ ilBody: body }));
NODE
node scripts/rea.mjs inspect-managed-members sample-stack.dll --format json > sample-stack.json
node scripts/rea.mjs compare-managed-members sample-left.dll sample-stack.dll --format json > sample-stack-comparison.json
node scripts/rea.mjs compare-managed-members sample-left.dll sample-left.dll --format json > sample-control-comparison.json
node --input-type=module <<'NODE'
import { readFileSync } from 'node:fs';
const read = name => JSON.parse(readFileSync(name, 'utf8')).normalized_result;
for (const name of ['sample-left.json', 'sample-stack.json']) {
  const body = read(name).methods[0].body;
  console.log(name, JSON.stringify({ status: body.status, init_locals: body.init_locals, max_stack: body.max_stack }));
}
for (const name of ['sample-stack-comparison.json', 'sample-control-comparison.json']) {
  const comparison = read(name);
  console.log(name, JSON.stringify({ summary: comparison.summary, method: comparison.methods[0], coverage: comparison.coverage }));
}
NODE

Expected behavior

docs/managed-code-analysis.md:243-244 states that comparison preserves observed differences, structural inferences, and unknowns separately. Comparison should retain observed header differences separately from the signature/CIL matching basis. A changed initialization flag or maximum-stack value should not yield an unqualified unchanged method result.

The decoded-CIL fingerprint table at docs/managed-code-analysis.md:389-390 explicitly excludes headers from both CIL hashes. Those exclusions define the hashes' byte boundaries; they do not establish equality of independently observed header fields. Keep the equal signature and CIL hashes intact while exposing the header difference.

Impact and scope

P2, supported. A supported build-comparison workflow silently omits observed method-header changes and presents the affected method as unchanged. The demonstrated scope is header-only changes with equal signatures and CIL bytes. No runtime consequence was measured.

A workaround is available: inspect both artifacts with inspect-managed-members and compare the returned header fields directly. The executed inspections retain the differing values without modifying or executing the targets.

REA version

6.4.0 (main at 8dde6370d0)

Runtime and platform

Node.js v26.10.0; macOS 27.0.1; arm64. The reproduction uses source-owned byte fixtures and the built CLI, with no .NET runtime or external analysis provider.

Target and artifact context

Two byte-built PE/CLI files generated through scripts/fixtures/managed/pe.mjs, each containing a method with a fat header and one ret instruction. The initialization-flag pair differs only in the byte controlling that flag. The maximum-stack variant changes 8 to 16 with the initialization flag unchanged. The fixture also contains a field, which accounts for the second unchanged summary item.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Selected fields from the observed initialization-flag comparison:

All three CLI operations: exit 0
Left inspection: body.status="present", init_locals=false
Right inspection: body.status="present", init_locals=true
{
  "summary": { "unchanged": 2, "changed": 0, "added": 0, "removed": 0, "unknown": 0 },
  "method_status": "unchanged",
  "dimensions": [],
  "match": { "status": "matched", "basis": "exact-il-signature", "confidence": "exact" },
  "coverage": { "status": "complete-within-inputs", "left_status": "complete", "right_status": "complete" }
}
Method limitations: []
Overall limitations do not mention omitted method-header comparisons.
Maximum-stack variant: max_stack changes from 8 to 16; method result remains unchanged.

Typed outcome or recovery expected

Run the initialization-flag reproduction and require the changed header fact to remain visible in comparison, with the affected method distinguished from an unchanged method. Keep the equal signature and CIL hashes intact, since their documented byte boundaries are correct. Exercise the 8-to-16 maximum-stack variant and the file-to-itself control. The variant must expose its changed header fact, and the control must remain unchanged.

Analysis or suspected cause

Source-derived reasoning: src/domain/managed/managedMemberComparisonItems.ts:51-97 derives method dimensions without comparing the observed initialization or stack values. The independently retained header facts therefore do not contribute a comparison dimension, even though the CIL hashes correctly exclude headers under docs/managed-code-analysis.md:389-390. This omission is consistent with the observed empty dimensions and unchanged classification.

Additional context

Related open issue #3199 concerns high-confidence structural remapping of methods with unequal constants; it retains the CIL difference, while this report concerns omitted header-only differences with equal signatures and CIL.

Related closed issue #558 concerns definite comparison results from unavailable or partial observations; both inspections here are complete and retain the differing header fields.

Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3348 ### 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 Native analysis or decompilation ### Observed behavior `compare-managed-members` reports a method as `unchanged` when its observed `init_locals` or `max_stack` value changes and its signature and CIL bytes remain equal. Both inspections expose the changed header facts, but comparison returns an empty `dimensions` array, increments `summary.unchanged`, and reports complete coverage without identifying the omitted header comparison. All three CLI operations in the initialization-flag reproduction exit with status 0. Inspection reports `body.status: "present"` on both sides, with `init_locals: false` on the left and `init_locals: true` on the right. Comparison returns an `exact-il-signature` match with exact confidence and classifies the method as unchanged. The method's `limitations` array is empty, and the overall limitations do not mention omitted method-header comparisons. A separately executed variant kept `init_locals` unchanged and changed `max_stack` from 8 to 16. It produced the same erroneous unchanged method result. No target code was executed; these observations establish a lost static difference, with no measured runtime behavior change. ### Reproduction 1. Start from a clean checkout of `8dde6370d0809ad04cda3342d4e6f290f48aa063`. From the repository root, install the locked dependencies and build the CLI: ```sh npm ci npm run build:cached ``` 2. Create two PE/CLI files with the repository fixture builder, inspect both, and compare them. Each method has a fat header and a single `ret` instruction. The only differing byte controls the initialization flag: ```sh node --input-type=module <<'NODE' import { writeFileSync } from 'node:fs'; import { buildManagedPeFixture } from './scripts/fixtures/managed/pe.mjs'; for (const [name, flags] of [['sample-left.dll', 0x3003], ['sample-right.dll', 0x3013]]) { const body = Buffer.alloc(13); body.writeUInt16LE(flags, 0); body.writeUInt16LE(8, 2); body.writeUInt32LE(1, 4); body[12] = 0x2a; writeFileSync(name, buildManagedPeFixture({ ilBody: body })); } NODE node scripts/rea.mjs inspect-managed-members sample-left.dll --format json > sample-left.json node scripts/rea.mjs inspect-managed-members sample-right.dll --format json > sample-right.json node scripts/rea.mjs compare-managed-members sample-left.dll sample-right.dll --format json > sample-comparison.json node --input-type=module <<'NODE' import { readFileSync } from 'node:fs'; const read = name => JSON.parse(readFileSync(name, 'utf8')).normalized_result; for (const name of ['sample-left.json', 'sample-right.json']) { const body = read(name).methods[0].body; console.log(name, JSON.stringify({ status: body.status, init_locals: body.init_locals, max_stack: body.max_stack })); } const comparison = read('sample-comparison.json'); console.log(JSON.stringify({ summary: comparison.summary, method: comparison.methods[0], coverage: comparison.coverage })); NODE ``` 3. Exercise the maximum-stack variant and the file-to-itself control. The variant changes only the stack value from 8 to 16, preserving `init_locals: false` and the same CIL bytes. The initialization-flag commands above are the exact reproduction; these commands spell out the separately reported variant and control: ```sh node --input-type=module <<'NODE' import { writeFileSync } from 'node:fs'; import { buildManagedPeFixture } from './scripts/fixtures/managed/pe.mjs'; const body = Buffer.alloc(13); body.writeUInt16LE(0x3003, 0); body.writeUInt16LE(16, 2); body.writeUInt32LE(1, 4); body[12] = 0x2a; writeFileSync('sample-stack.dll', buildManagedPeFixture({ ilBody: body })); NODE node scripts/rea.mjs inspect-managed-members sample-stack.dll --format json > sample-stack.json node scripts/rea.mjs compare-managed-members sample-left.dll sample-stack.dll --format json > sample-stack-comparison.json node scripts/rea.mjs compare-managed-members sample-left.dll sample-left.dll --format json > sample-control-comparison.json node --input-type=module <<'NODE' import { readFileSync } from 'node:fs'; const read = name => JSON.parse(readFileSync(name, 'utf8')).normalized_result; for (const name of ['sample-left.json', 'sample-stack.json']) { const body = read(name).methods[0].body; console.log(name, JSON.stringify({ status: body.status, init_locals: body.init_locals, max_stack: body.max_stack })); } for (const name of ['sample-stack-comparison.json', 'sample-control-comparison.json']) { const comparison = read(name); console.log(name, JSON.stringify({ summary: comparison.summary, method: comparison.methods[0], coverage: comparison.coverage })); } NODE ``` ### Expected behavior `docs/managed-code-analysis.md:243-244` states that comparison preserves observed differences, structural inferences, and unknowns separately. Comparison should retain observed header differences separately from the signature/CIL matching basis. A changed initialization flag or maximum-stack value should not yield an unqualified unchanged method result. The decoded-CIL fingerprint table at `docs/managed-code-analysis.md:389-390` explicitly excludes headers from both CIL hashes. Those exclusions define the hashes' byte boundaries; they do not establish equality of independently observed header fields. Keep the equal signature and CIL hashes intact while exposing the header difference. ### Impact and scope P2, supported. A supported build-comparison workflow silently omits observed method-header changes and presents the affected method as unchanged. The demonstrated scope is header-only changes with equal signatures and CIL bytes. No runtime consequence was measured. A workaround is available: inspect both artifacts with `inspect-managed-members` and compare the returned header fields directly. The executed inspections retain the differing values without modifying or executing the targets. ### REA version 6.4.0 (main at 8dde6370d0809ad04cda3342d4e6f290f48aa063) ### Runtime and platform Node.js v26.10.0; macOS 27.0.1; arm64. The reproduction uses source-owned byte fixtures and the built CLI, with no .NET runtime or external analysis provider. ### Target and artifact context Two byte-built PE/CLI files generated through `scripts/fixtures/managed/pe.mjs`, each containing a method with a fat header and one `ret` instruction. The initialization-flag pair differs only in the byte controlling that flag. The maximum-stack variant changes 8 to 16 with the initialization flag unchanged. The fixture also contains a field, which accounts for the second unchanged summary item. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Selected fields from the observed initialization-flag comparison: ```text All three CLI operations: exit 0 Left inspection: body.status="present", init_locals=false Right inspection: body.status="present", init_locals=true { "summary": { "unchanged": 2, "changed": 0, "added": 0, "removed": 0, "unknown": 0 }, "method_status": "unchanged", "dimensions": [], "match": { "status": "matched", "basis": "exact-il-signature", "confidence": "exact" }, "coverage": { "status": "complete-within-inputs", "left_status": "complete", "right_status": "complete" } } Method limitations: [] Overall limitations do not mention omitted method-header comparisons. Maximum-stack variant: max_stack changes from 8 to 16; method result remains unchanged. ``` ### Typed outcome or recovery expected Run the initialization-flag reproduction and require the changed header fact to remain visible in comparison, with the affected method distinguished from an unchanged method. Keep the equal signature and CIL hashes intact, since their documented byte boundaries are correct. Exercise the 8-to-16 maximum-stack variant and the file-to-itself control. The variant must expose its changed header fact, and the control must remain unchanged. ### Analysis or suspected cause Source-derived reasoning: `src/domain/managed/managedMemberComparisonItems.ts:51-97` derives method dimensions without comparing the observed initialization or stack values. The independently retained header facts therefore do not contribute a comparison dimension, even though the CIL hashes correctly exclude headers under `docs/managed-code-analysis.md:389-390`. This omission is consistent with the observed empty dimensions and unchanged classification. ### Additional context Related open issue [#3199](https://github.com/morluto/rea/issues/3199) concerns high-confidence structural remapping of methods with unequal constants; it retains the CIL difference, while this report concerns omitted header-only differences with equal signatures and CIL. Related closed issue [#558](https://github.com/morluto/rea/issues/558) concerns definite comparison results from unavailable or partial observations; both inspections here are complete and retain the differing header fields.
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#1216
No description provided.