[PR #2251] [CLOSED] fix(apple): decode UIKit outlets from keyed archives #5116

Closed
opened 2026-10-31 01:04:06 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/morluto/rea/pull/2251
Author: @XiaoBinGan
Created: 10/12/2026
Status: ❌ Closed

Base: main ← Head: fix/apple-keyed-archive-uikit-outlets


📝 Commits (1)

  • 4377d41 fix(apple): decode UIKit outlets from keyed archives

📊 Changes

3 files changed (+171 additions, -10 deletions)

View changed files

📝 src/domain/apple/interfaceBuilderGraph.test.ts (+134 -0)
📝 src/domain/apple/interfaceBuilderGraph.ts (+19 -5)
📝 src/domain/apple/interfaceBuilderKeyedArchive.ts (+18 -5)

📄 Description

Fixes #2177.

Summary

decode_interface_builder reported no outlet for a valid, rooted UIKit keyed-archive nib containing a UIRuntimeOutletConnection, and its connection coverage reported complete with zero examined connections and zero omissions.

The keyed-archive parser recognizes a connector by class name and reads its owner, destination and label from AppKit's NSSource / NSDestination / NSLabel keys, plus the unprefixed variant. UIKit's runtime connections archive those same roles as UISource / UIDestination / UILabel, so the owner lookup found nothing and the connector was discarded by an early continue that recorded no omission. The archived connector object is still present in the graph with its raw fields, but no outlet node or outlet_to edge is created, and the coverage facet derives complete from the resulting empty connection list.

The same aliases are already applied at the NIBArchive projection boundary for NIBArchive object tables, so this was format knowledge implemented at one producer and missing at the keyed-archive owning parser.

Problem and expected behavior

For the reported rooted fixture, the public CLI reports:

{
  "connection_count": 0,
  "outlets": [],
  "coverage": [
    { "facet": "connections:Main.nib", "status": "complete", "reason": null, "examined": 0, "omitted": 0 }
  ]
}

The connector is object UID 3, class UIRuntimeOutletConnection, holding UISource → UID 1, UIDestination → UID 2 and UILabel → UID 7 ("view"). parseKeyedArchive consults only source, NSSource, from and owner for the source, so source === null and the connector never reaches connection.parse. Because omittedConnections is only incremented at the 40 000-connection cap, buildInterfaceBuilderAnalysis sees zero connections and zero omissions and truthfully reports nothing while claiming completeness.

Expected behavior is connection_count: 1, one outlet named view, a contains edge from object UID 1, an observed outlet_to edge to object UID 2, and coverage that accounts for the connection. AppKit's NS-prefixed and unprefixed control behavior must not change, and a malformed or source-less record must not become a fabricated resolved connection.

Change and scope

Two parts, both in the domain layer, split by ownership.

Decoding the outlet — parseKeyedArchive now derives uiKit = /^UIRuntime\w*Connection$/u from the connector class and selects UISource, UIDestination and UILabel instead of the AppKit keys. The class gate mirrors the merged NIBArchive projection, keeps AppKit and unprefixed records byte-for-byte identical, and avoids a UIKit class preferring a stray NSLabel. The record-reuse, action-inversion and authored-ID behaviour is untouched.

Recording the omission — a recognized connector that still cannot name its owner is now counted in a distinct unresolvedConnections rather than skipped silently. An unresolved source cannot be represented as a connection at all, because connection.source_id is a non-empty string and the outlet's contains edge requires a source node; unlike an unresolved destination, which the graph already keeps as an unresolved edge. buildInterfaceBuilderAnalysis surfaces it as partial coverage with the reason connection_unresolved, includes it in the facet's omitted count, in connection_count, and in the graph's omitted-connection total.

A separate counter rather than reusing omittedConnections is deliberate: that field means "dropped by the connection cap", and its coverage reason is connection_limit_reached. Conflating the two would report an untrue reason for one of them. The reason field accepts a nullable string, so no schema changes.

Contract and boundary impact

  • Semantic owner and earliest changed stage: connection interpretation inside the keyed-archive domain parser, and coverage derivation inside buildInterfaceBuilderAnalysis. The artifact adapter, NIBArchive decoder and view-hierarchy projection are untouched.
  • CLI and MCP/tool-catalog contract: tool and command names, inputs, output schemas and error codes are unchanged. connection_count, the connections:<path> coverage facet and the outlet nodes/edges carry the corrected values.
  • Provider, target-format or platform compatibility: keyed-archive property lists only. AppKit NS-prefixed, unprefixed and NIBArchive records keep their current results.
  • Evidence, artifact, provenance or reconstruction contract: the outlet node and both edges are produced from the archive's own resolved references with the existing evidence and resolution: "observed" semantics; artifact bytes are unmodified. No new attribute or ID convention is introduced.
  • Process execution, authorization, cleanup or containment impact: none. Static decoding of caller-supplied bytes.
  • Generated metadata, package or installation impact: none.

Evidence and regression coverage

The new UIKit keyed archive connectors block builds the reported rooted archive, including its UINibConnectionsKey / UINibObjectsKey / UINibTopLevelObjectsKey roots and CF$UID references, and drives both parseInterfaceBuilderRecords and buildInterfaceBuilderAnalysis.

  • reads a UIKit runtime outlet from UI-prefixed keys asserts connector identity archive:3, kind outlet, owner UID 1, destination UID 2 and label view; the outlet node's name; the observed outlet_to edge to the UID 2 object node; the contains edge from the UID 1 object node; and complete coverage with examined: 1, omitted: 0 and truncated: false.
  • reports a recognized connector with no resolvable owner as unresolved is the control the issue asks for: a source-less record produces no fabricated outlet and no fabricated edge, and coverage becomes partial with reason connection_unresolved and omitted: 1.

I confirmed both halves are independently pinned by reverting each in turn: dropping the UIKit aliases fails the first case with expected [] to deeply equal [ ObjectContaining{…} ], while leaving the omission counter out fails the second with expected +0 to be 1.

Verification performed on macOS 26.5.1 arm64 with Node.js 24.18.0 and npm 11.16.0:

  • npm run test:focused over the Interface Builder graph, keyed-archive, catalog, NibArchive, KeyedArchiveReader and application suites plus the Interface Builder bundle, keyed-archive selection and app-bundle CLI boundary lanes: 129 passed, excluding the pre-existing ibtool failure below.
  • npm run check:fast: passed.
  • npm run verify:test-discovery: all 944 test files discovered exactly once.
  • The issue's own Python reproduction against the built CLI on this branch: connection_count: 1, outlets: ["view"], and connections:Main.nib coverage complete with examined: 1, omitted: 0. At the affected revision the same script prints connection_count: 0, outlets: [] and complete with examined: 0, which is the reported JSON.

Unavailable coverage, and one item a reviewer should confirm:

  • decodes an Xcode-compiled storyboard NIB and recovers its UI routes in tests/boundary/filesystem/interfaceBuilderBundle.test.ts fails on an unmodified main here because it needs full Xcode ibtool; this host selects only CommandLineTools. The nibNativeHierarchy boundary lane has the same prerequisite.
  • npm run verify:interface-builder could not be run for the same reason. Its fixture declares two connections, both with resolvable targets, and the new count only changes when a recognized connector's owner cannot be resolved; I read the fixture and the gate rather than executing them, so this should be confirmed on a macOS host with Xcode before merge.
  • No Windows or Linux run, no real UIKit-produced archive, and no iOS device or UINib run. The regression fixture is the reporter's portable archive rather than native encoder output.

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/morluto/rea/pull/2251 **Author:** [@XiaoBinGan](https://github.com/XiaoBinGan) **Created:** 10/12/2026 **Status:** ❌ Closed **Base:** `main` ← **Head:** `fix/apple-keyed-archive-uikit-outlets` --- ### 📝 Commits (1) - [`4377d41`](https://github.com/morluto/rea/commit/4377d4106e2c82ef63f4bb3d1d24d2cb778bc4c0) fix(apple): decode UIKit outlets from keyed archives ### 📊 Changes **3 files changed** (+171 additions, -10 deletions) <details> <summary>View changed files</summary> 📝 `src/domain/apple/interfaceBuilderGraph.test.ts` (+134 -0) 📝 `src/domain/apple/interfaceBuilderGraph.ts` (+19 -5) 📝 `src/domain/apple/interfaceBuilderKeyedArchive.ts` (+18 -5) </details> ### 📄 Description Fixes #2177. ## Summary `decode_interface_builder` reported no outlet for a valid, rooted UIKit keyed-archive nib containing a `UIRuntimeOutletConnection`, and its connection coverage reported `complete` with zero examined connections and zero omissions. The keyed-archive parser recognizes a connector by class name and reads its owner, destination and label from AppKit's `NSSource` / `NSDestination` / `NSLabel` keys, plus the unprefixed variant. UIKit's runtime connections archive those same roles as `UISource` / `UIDestination` / `UILabel`, so the owner lookup found nothing and the connector was discarded by an early `continue` that recorded no omission. The archived connector object is still present in the graph with its raw fields, but no outlet node or `outlet_to` edge is created, and the coverage facet derives `complete` from the resulting empty connection list. The same aliases are already applied at the NIBArchive projection boundary for `NIBArchive` object tables, so this was format knowledge implemented at one producer and missing at the keyed-archive owning parser. ## Problem and expected behavior For the reported rooted fixture, the public CLI reports: ```json { "connection_count": 0, "outlets": [], "coverage": [ { "facet": "connections:Main.nib", "status": "complete", "reason": null, "examined": 0, "omitted": 0 } ] } ``` The connector is object UID 3, class `UIRuntimeOutletConnection`, holding `UISource` → UID 1, `UIDestination` → UID 2 and `UILabel` → UID 7 (`"view"`). [`parseKeyedArchive`](https://github.com/morluto/rea/blob/c22111eca2c4bc84a084f12711f405b3e7422a40/src/domain/apple/interfaceBuilderKeyedArchive.ts#L346-L385) consults only `source`, `NSSource`, `from` and `owner` for the source, so `source === null` and the connector never reaches `connection.parse`. Because [`omittedConnections`](https://github.com/morluto/rea/blob/c22111eca2c4bc84a084f12711f405b3e7422a40/src/domain/apple/interfaceBuilderKeyedArchive.ts#L384) is only incremented at the 40 000-connection cap, [`buildInterfaceBuilderAnalysis`](https://github.com/morluto/rea/blob/c22111eca2c4bc84a084f12711f405b3e7422a40/src/domain/apple/interfaceBuilderGraph.ts#L419-L460) sees zero connections and zero omissions and truthfully reports nothing while claiming completeness. Expected behavior is `connection_count: 1`, one outlet named `view`, a `contains` edge from object UID 1, an observed `outlet_to` edge to object UID 2, and coverage that accounts for the connection. AppKit's `NS`-prefixed and unprefixed control behavior must not change, and a malformed or source-less record must not become a fabricated resolved connection. ## Change and scope Two parts, both in the domain layer, split by ownership. **Decoding the outlet** — [`parseKeyedArchive`](https://github.com/XiaoBinGan/rea/blob/4377d4106e2c82ef63f4bb3d1d24d2cb778bc4c0/src/domain/apple/interfaceBuilderKeyedArchive.ts#L355-L393) now derives `uiKit = /^UIRuntime\w*Connection$/u` from the connector class and selects [`UISource`](https://github.com/XiaoBinGan/rea/blob/4377d4106e2c82ef63f4bb3d1d24d2cb778bc4c0/src/domain/apple/interfaceBuilderKeyedArchive.ts#L360-L363), [`UIDestination`](https://github.com/XiaoBinGan/rea/blob/4377d4106e2c82ef63f4bb3d1d24d2cb778bc4c0/src/domain/apple/interfaceBuilderKeyedArchive.ts#L379-L382) and [`UILabel`](https://github.com/XiaoBinGan/rea/blob/4377d4106e2c82ef63f4bb3d1d24d2cb778bc4c0/src/domain/apple/interfaceBuilderKeyedArchive.ts#L386-L391) instead of the AppKit keys. The class gate mirrors the merged NIBArchive projection, keeps AppKit and unprefixed records byte-for-byte identical, and avoids a UIKit class preferring a stray `NSLabel`. The record-reuse, action-inversion and authored-ID behaviour is untouched. **Recording the omission** — a recognized connector that still cannot name its owner is now counted in a distinct [`unresolvedConnections`](https://github.com/XiaoBinGan/rea/blob/4377d4106e2c82ef63f4bb3d1d24d2cb778bc4c0/src/domain/apple/interfaceBuilderKeyedArchive.ts#L366-L372) rather than skipped silently. An unresolved source cannot be represented as a connection at all, because `connection.source_id` is a non-empty string and the outlet's `contains` edge requires a source node; unlike an unresolved destination, which the graph already keeps as an `unresolved` edge. [`buildInterfaceBuilderAnalysis`](https://github.com/XiaoBinGan/rea/blob/4377d4106e2c82ef63f4bb3d1d24d2cb778bc4c0/src/domain/apple/interfaceBuilderGraph.ts#L405-L415) surfaces it as `partial` coverage with the reason `connection_unresolved`, includes it in the facet's `omitted` count, in `connection_count`, and in the graph's omitted-connection total. A separate counter rather than reusing `omittedConnections` is deliberate: that field means "dropped by the connection cap", and its coverage reason is `connection_limit_reached`. Conflating the two would report an untrue reason for one of them. The `reason` field accepts a nullable string, so no schema changes. ## Contract and boundary impact - **Semantic owner and earliest changed stage:** connection interpretation inside the keyed-archive domain parser, and coverage derivation inside `buildInterfaceBuilderAnalysis`. The artifact adapter, NIBArchive decoder and view-hierarchy projection are untouched. - **CLI and MCP/tool-catalog contract:** tool and command names, inputs, output schemas and error codes are unchanged. `connection_count`, the `connections:<path>` coverage facet and the outlet nodes/edges carry the corrected values. - **Provider, target-format or platform compatibility:** keyed-archive property lists only. AppKit `NS`-prefixed, unprefixed and NIBArchive records keep their current results. - **Evidence, artifact, provenance or reconstruction contract:** the outlet node and both edges are produced from the archive's own resolved references with the existing evidence and `resolution: "observed"` semantics; artifact bytes are unmodified. No new attribute or ID convention is introduced. - **Process execution, authorization, cleanup or containment impact:** none. Static decoding of caller-supplied bytes. - **Generated metadata, package or installation impact:** none. ## Evidence and regression coverage The new [`UIKit keyed archive connectors`](https://github.com/XiaoBinGan/rea/blob/4377d4106e2c82ef63f4bb3d1d24d2cb778bc4c0/src/domain/apple/interfaceBuilderGraph.test.ts#L513-L645) block builds the reported rooted archive, including its `UINibConnectionsKey` / `UINibObjectsKey` / `UINibTopLevelObjectsKey` roots and `CF$UID` references, and drives both `parseInterfaceBuilderRecords` and `buildInterfaceBuilderAnalysis`. - [`reads a UIKit runtime outlet from UI-prefixed keys`](https://github.com/XiaoBinGan/rea/blob/4377d4106e2c82ef63f4bb3d1d24d2cb778bc4c0/src/domain/apple/interfaceBuilderGraph.test.ts#L573-L621) asserts connector identity `archive:3`, kind `outlet`, owner UID `1`, destination UID `2` and label `view`; the outlet node's name; the observed `outlet_to` edge to the UID 2 object node; the `contains` edge from the UID 1 object node; and `complete` coverage with `examined: 1`, `omitted: 0` and `truncated: false`. - [`reports a recognized connector with no resolvable owner as unresolved`](https://github.com/XiaoBinGan/rea/blob/4377d4106e2c82ef63f4bb3d1d24d2cb778bc4c0/src/domain/apple/interfaceBuilderGraph.test.ts#L623-L644) is the control the issue asks for: a source-less record produces no fabricated outlet and no fabricated edge, and coverage becomes `partial` with reason `connection_unresolved` and `omitted: 1`. I confirmed both halves are independently pinned by reverting each in turn: dropping the UIKit aliases fails the first case with `expected [] to deeply equal [ ObjectContaining{…} ]`, while leaving the omission counter out fails the second with `expected +0 to be 1`. Verification performed on macOS 26.5.1 arm64 with Node.js 24.18.0 and npm 11.16.0: - `npm run test:focused` over the Interface Builder graph, keyed-archive, catalog, NibArchive, KeyedArchiveReader and application suites plus the Interface Builder bundle, keyed-archive selection and app-bundle CLI boundary lanes: 129 passed, excluding the pre-existing `ibtool` failure below. - `npm run check:fast`: passed. - `npm run verify:test-discovery`: all 944 test files discovered exactly once. - The issue's own Python reproduction against the built CLI on this branch: `connection_count: 1`, `outlets: ["view"]`, and `connections:Main.nib` coverage `complete` with `examined: 1`, `omitted: 0`. At the affected revision the same script prints `connection_count: 0`, `outlets: []` and `complete` with `examined: 0`, which is the reported JSON. Unavailable coverage, and one item a reviewer should confirm: - `decodes an Xcode-compiled storyboard NIB and recovers its UI routes` in `tests/boundary/filesystem/interfaceBuilderBundle.test.ts` fails on an unmodified `main` here because it needs full Xcode `ibtool`; this host selects only CommandLineTools. The `nibNativeHierarchy` boundary lane has the same prerequisite. - `npm run verify:interface-builder` could not be run for the same reason. Its fixture declares two connections, both with resolvable targets, and the new count only changes when a recognized connector's owner cannot be resolved; I read the fixture and the gate rather than executing them, so this should be confirmed on a macOS host with Xcode before merge. - No Windows or Linux run, no real UIKit-produced archive, and no iOS device or `UINib` run. The regression fixture is the reporter's portable archive rather than native encoder output. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
bsakaguchi 2026-10-31 01:04:06 +00:00
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#5116
No description provided.