[GH-ISSUE #3958] [Bug] Interface Builder decoding omits unresolved-class objects while claiming complete coverage #1564

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

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

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

Observed: both CLI commands exit 0. The Interface Builder result contains only the known UIView object with interface_builder_object_id: "3", reports documents[0].object_count: 1, and returns graph.truncated: false. Its object coverage facet is:

{"facet":"objects:Contents/Resources/Main.nib","status":"complete","reason":null,"examined":1,"omitted":0}

The generic archive inspector returns the missing object as id: 1, kind: "archived-object", class_id: 99, class_name: null, status: "unresolved-class", and preserves its label and original $class reference. The resolved-UID control returns both object IDs "1" and "3", object_count: 2, and complete object coverage with examined: 2. Object 1 is outside the hierarchy root in both runs, so root reachability does not explain the difference.

A separate observed variant sets the hierarchy root to object 1 while leaving its class reference dangling. The graph then creates an identity-only unknown hierarchy node for object 1, but loses its serialized label and still reports object_count: 1 and complete object coverage with zero omissions. A resolvable custom class name is retained correctly. Each initial variant and control was run once. The documented unresolved-input decode, generic inspection and resolved-UID control were then replayed successfully from this draft. The original unlabeled dangling-class fixture was also run once and exhibited the same inventory omission.

Reproduction

Run from the baseline checkout with its installed dependencies and built runtime. Use a disposable working directory and an isolated home directory:

export WORK="$(mktemp -d)"
export HOME="$WORK/home"
mkdir -p "$HOME"
node --input-type=module <<'NODE'
import { build } from 'plist';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const app = join(process.env.WORK, 'Fixture.app');
mkdirSync(join(app, 'Contents', 'Resources'), { recursive: true });
mkdirSync(join(app, 'Contents', 'MacOS'), { recursive: true });
const header = Buffer.alloc(32);
header.writeUInt32BE(0xfeedfacf, 0);
header.writeUInt32BE(0x0100000c, 4);
writeFileSync(join(app, 'Contents', 'MacOS', 'Fixture'), header);
writeFileSync(join(app, 'Contents', 'Info.plist'), build({ CFBundleExecutable: 'Fixture' }));
const archive = {
  $archiver: 'NSKeyedArchiver',
  $version: 100000,
  $objects: [
    '$null',
    { $class: { UID: 99 }, label: 'Unresolved object' },
    { $classname: 'UIView', $classes: ['UIView', 'NSObject'] },
    { $class: { UID: 2 }, 'NS.string': 'ok' }
  ],
  $top: { root: { UID: 3 } }
};
writeFileSync(join(app, 'Contents', 'Resources', 'Main.nib'), build(archive));
NODE
node scripts/rea.mjs decode-interface-builder "$WORK/Fixture.app" --json
node scripts/rea.mjs inspect-keyed-archive "$WORK/Fixture.app" Contents/Resources/Main.nib --json

As a discriminating control, change only the unresolved object's class UID to the existing descriptor and repeat the decode:

node --input-type=module <<'NODE'
import { build, parse } from 'plist';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const path = join(process.env.WORK, 'Fixture.app', 'Contents', 'Resources', 'Main.nib');
const archive = parse(readFileSync(path, 'utf8'));
archive.$objects[1].$class.UID = 2;
writeFileSync(path, build(archive));
NODE
node scripts/rea.mjs decode-interface-builder "$WORK/Fixture.app" --json

Expected behavior

The Interface Builder tool contract promises explicit unknowns for unrecognized archive classes. The repository also requires preservation of identity, omission and uncertainty, and says unresolved identity must remain unknown rather than absent. An accepted archived object with an unresolved class must therefore remain represented as unknown, with its available identity and fields preserved, or have its loss explicitly accounted for in partial object coverage. Complete coverage with zero omissions must not conceal its disappearance. This does not require scalar values, nil or class descriptors to become Interface Builder object nodes.

Impact and scope

An accepted archive with a malformed class reference produces a smaller object inventory and a false completeness signal. Analysts can miss serialized objects or fields. The demonstrated failure is bounded to this input condition; production frequency and real-application reach were not measured.

Workaround: The same input was successfully inspected through inspect-keyed-archive, which preserves the missing object's identity, unresolved class status and raw fields. This allows inspection of the missing evidence, but does not restore its Interface Builder graph representation.

REA version

rea 6.5.0 at source commit 4692bb3330; current-main source checked at 22ec8bdb2f

Runtime and platform

Node 24.18.0; macOS arm64

Target and artifact context

Synthetic Apple app bundle with a minimal Mach-O header and XML NSKeyedArchiver nib; one object has a dangling class UID 99. No target code or native provider runs.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

src/contracts/artifactToolContracts.ts:93 promises explicit unknowns for unrecognized archive classes; AGENTS.md requires preservation of identity, omission and uncertainty; docs/tool-design.md:52-55 says missing coverage remains unknown. src/domain/apple/interfaceBuilderKeyedArchive.ts:326-331,430-439 filters out unresolved class names before object and omission counting; src/domain/apple/interfaceBuilderGraph.ts:455-466 reports complete coverage. The production CLI decode omitted object 1, while inspect-keyed-archive preserved its unresolved identity and fields; changing only its class UID to a valid descriptor restored both objects.

Typed outcome or recovery expected

Preserve an explicit unknown object and its available fields, or account for its omission with partial coverage and a nonzero omitted count. Retain both objects for the resolved-UID control and verify the rooted unresolved variant.

Analysis or suspected cause

Source-derived: the decoder skips entries without a resolved class name before incrementing objectCount. If the omitted object is a hierarchy root, graph construction can add an identity-only unknown stub, but still loses serialized fields and gives false complete object coverage. A resolvable unfamiliar class is retained correctly.

Additional context

Related #2177 concerns omitted UIKit outlet connections, #3175 concerns event-connection action graphs, and #2547 concerns hierarchy completeness after earlier truncation. Their triggers and closure signals differ. Real-application prevalence and MCP execution were not tested.

Freshness check: the failing source is unchanged on current upstream main at 22ec8bdb2f. The only change among the cited files is unrelated CLI output-flag normalization. Runtime observations above are from the stated baseline; current-main persistence is source-derived.

Originally created by @coygeek on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3958 ### 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 Observed: both CLI commands exit 0. The Interface Builder result contains only the known `UIView` object with `interface_builder_object_id: "3"`, reports `documents[0].object_count: 1`, and returns `graph.truncated: false`. Its object coverage facet is: ```json {"facet":"objects:Contents/Resources/Main.nib","status":"complete","reason":null,"examined":1,"omitted":0} ``` The generic archive inspector returns the missing object as `id: 1`, `kind: "archived-object"`, `class_id: 99`, `class_name: null`, `status: "unresolved-class"`, and preserves its `label` and original `$class` reference. The resolved-UID control returns both object IDs `"1"` and `"3"`, `object_count: 2`, and complete object coverage with `examined: 2`. Object 1 is outside the hierarchy root in both runs, so root reachability does not explain the difference. A separate observed variant sets the hierarchy root to object 1 while leaving its class reference dangling. The graph then creates an identity-only unknown hierarchy node for object 1, but loses its serialized label and still reports `object_count: 1` and complete object coverage with zero omissions. A resolvable custom class name is retained correctly. Each initial variant and control was run once. The documented unresolved-input decode, generic inspection and resolved-UID control were then replayed successfully from this draft. The original unlabeled dangling-class fixture was also run once and exhibited the same inventory omission. ### Reproduction Run from the baseline checkout with its installed dependencies and built runtime. Use a disposable working directory and an isolated home directory: ```sh export WORK="$(mktemp -d)" export HOME="$WORK/home" mkdir -p "$HOME" node --input-type=module <<'NODE' import { build } from 'plist'; import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const app = join(process.env.WORK, 'Fixture.app'); mkdirSync(join(app, 'Contents', 'Resources'), { recursive: true }); mkdirSync(join(app, 'Contents', 'MacOS'), { recursive: true }); const header = Buffer.alloc(32); header.writeUInt32BE(0xfeedfacf, 0); header.writeUInt32BE(0x0100000c, 4); writeFileSync(join(app, 'Contents', 'MacOS', 'Fixture'), header); writeFileSync(join(app, 'Contents', 'Info.plist'), build({ CFBundleExecutable: 'Fixture' })); const archive = { $archiver: 'NSKeyedArchiver', $version: 100000, $objects: [ '$null', { $class: { UID: 99 }, label: 'Unresolved object' }, { $classname: 'UIView', $classes: ['UIView', 'NSObject'] }, { $class: { UID: 2 }, 'NS.string': 'ok' } ], $top: { root: { UID: 3 } } }; writeFileSync(join(app, 'Contents', 'Resources', 'Main.nib'), build(archive)); NODE node scripts/rea.mjs decode-interface-builder "$WORK/Fixture.app" --json node scripts/rea.mjs inspect-keyed-archive "$WORK/Fixture.app" Contents/Resources/Main.nib --json ``` As a discriminating control, change only the unresolved object's class UID to the existing descriptor and repeat the decode: ```sh node --input-type=module <<'NODE' import { build, parse } from 'plist'; import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const path = join(process.env.WORK, 'Fixture.app', 'Contents', 'Resources', 'Main.nib'); const archive = parse(readFileSync(path, 'utf8')); archive.$objects[1].$class.UID = 2; writeFileSync(path, build(archive)); NODE node scripts/rea.mjs decode-interface-builder "$WORK/Fixture.app" --json ``` ### Expected behavior The Interface Builder tool contract promises explicit unknowns for unrecognized archive classes. The repository also requires preservation of identity, omission and uncertainty, and says unresolved identity must remain unknown rather than absent. An accepted archived object with an unresolved class must therefore remain represented as unknown, with its available identity and fields preserved, or have its loss explicitly accounted for in partial object coverage. Complete coverage with zero omissions must not conceal its disappearance. This does not require scalar values, nil or class descriptors to become Interface Builder object nodes. ### Impact and scope An accepted archive with a malformed class reference produces a smaller object inventory and a false completeness signal. Analysts can miss serialized objects or fields. The demonstrated failure is bounded to this input condition; production frequency and real-application reach were not measured. Workaround: The same input was successfully inspected through `inspect-keyed-archive`, which preserves the missing object's identity, unresolved class status and raw fields. This allows inspection of the missing evidence, but does not restore its Interface Builder graph representation. ### REA version rea 6.5.0 at source commit 4692bb33300c7cb77b095b6cc0c33da861c1b860; current-main source checked at 22ec8bdb2f7d4626587f23b1eed4a207df600386 ### Runtime and platform Node 24.18.0; macOS arm64 ### Target and artifact context Synthetic Apple app bundle with a minimal Mach-O header and XML NSKeyedArchiver nib; one object has a dangling class UID 99. No target code or native provider runs. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence `src/contracts/artifactToolContracts.ts:93` promises explicit unknowns for unrecognized archive classes; `AGENTS.md` requires preservation of identity, omission and uncertainty; `docs/tool-design.md:52-55` says missing coverage remains unknown. `src/domain/apple/interfaceBuilderKeyedArchive.ts:326-331,430-439` filters out unresolved class names before object and omission counting; `src/domain/apple/interfaceBuilderGraph.ts:455-466` reports complete coverage. The production CLI decode omitted object 1, while inspect-keyed-archive preserved its unresolved identity and fields; changing only its class UID to a valid descriptor restored both objects. ### Typed outcome or recovery expected Preserve an explicit unknown object and its available fields, or account for its omission with partial coverage and a nonzero omitted count. Retain both objects for the resolved-UID control and verify the rooted unresolved variant. ### Analysis or suspected cause Source-derived: the decoder skips entries without a resolved class name before incrementing objectCount. If the omitted object is a hierarchy root, graph construction can add an identity-only unknown stub, but still loses serialized fields and gives false complete object coverage. A resolvable unfamiliar class is retained correctly. ### Additional context Related #2177 concerns omitted UIKit outlet connections, #3175 concerns event-connection action graphs, and #2547 concerns hierarchy completeness after earlier truncation. Their triggers and closure signals differ. Real-application prevalence and MCP execution were not tested. Freshness check: the failing source is unchanged on current upstream main at 22ec8bdb2f7d4626587f23b1eed4a207df600386. The only change among the cited files is unrelated CLI output-flag normalization. Runtime observations above are from the stated baseline; current-main persistence is source-derived.
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#1564
No description provided.