[GH-ISSUE #5720] [Bug] decode-interface-builder reports keyed AppKit action connectors as UI controls #3469

Closed
opened 2026-10-31 00:40:50 +00:00 by bsakaguchi · 2 comments
Owner

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

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

decode-interface-builder reports a genuine AppKit NSNibControlConnector as both a UI control and an action when decoding a Foundation keyed archive. Its clicked: action and endpoints are recovered correctly; the extra UI control is false. An otherwise equivalent archive without the connector retains the same button and view without that extra control.

Reproduction

From the built source checkout, run this on macOS with Xcode command-line tools, Swift, Python 3 and jq. It serializes genuine AppKit objects with NSKeyedArchiver, builds a disposable one-nib app, and runs static inspection with isolated configuration. The fixture app is never launched. Independently reproduced at the baseline; the complete script was also replayed against the current-main revision below.

set -eu
work_dir="$(mktemp -d)"
mkdir -p "$work_dir/home" "$work_dir/cfg" "$work_dir/data" "$work_dir/cache" \
  "$work_dir/state" "$work_dir/tmp" "$work_dir/Fixture.app/Contents/MacOS" \
  "$work_dir/Fixture.app/Contents/Resources"
printf '#import <AppKit/NSNibControlConnector.h>\n' > "$work_dir/Bridge.h"
cat > "$work_dir/make.swift" <<'SWIFT'
import AppKit
let output = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
let button = NSButton(frame: .zero)
button.title = "Tap"
let target = NSView(frame: .zero)
let connector = NSNibControlConnector()
connector.source = button
connector.destination = target
connector.label = "clicked:"
for (name, root): (String, Any) in [("WithConnector", [button, target, connector] as NSArray), ("WithoutConnector", [button, target] as NSArray)] {
    let archiver = NSKeyedArchiver(requiringSecureCoding: false)
    archiver.outputFormat = .xml
    archiver.encode(root, forKey: NSKeyedArchiveRootObjectKey)
    archiver.finishEncoding()
    try archiver.encodedData.write(to: output.appendingPathComponent(name + ".nib"))
}
print("connector is NSControl:", connector.isKind(of: NSControl.self))
print("button is NSControl:", button.isKind(of: NSControl.self))
SWIFT
xcrun swiftc -import-objc-header "$work_dir/Bridge.h" \
  -module-cache-path "$work_dir/module-cache" "$work_dir/make.swift" \
  -o "$work_dir/make-fixtures"
"$work_dir/make-fixtures" "$work_dir"
printf 'int main(void){return 0;}\n' > "$work_dir/demo.c"
xcrun clang "$work_dir/demo.c" -o "$work_dir/Fixture.app/Contents/MacOS/Demo"
cat > "$work_dir/Fixture.app/Contents/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict><key>CFBundleExecutable</key><string>Demo</string><key>CFBundleIdentifier</key><string>dev.example.fixture</string><key>CFBundlePackageType</key><string>APPL</string></dict></plist>
PLIST
for mode in WithConnector WithoutConnector; do
  cp "$work_dir/$mode.nib" "$work_dir/Fixture.app/Contents/Resources/Main.nib"
  env -i PATH="$PATH" HOME="$work_dir/home" XDG_CONFIG_HOME="$work_dir/cfg" \
    XDG_DATA_HOME="$work_dir/data" XDG_CACHE_HOME="$work_dir/cache" \
    XDG_STATE_HOME="$work_dir/state" TMPDIR="$work_dir/tmp" REA_LOG_LEVEL=silent \
    node scripts/rea.mjs decode-interface-builder "$work_dir/Fixture.app" --json \
    > "$work_dir/$mode.json" 2> "$work_dir/$mode.stderr"
  printf '%s\n' "$mode"
  jq '.normalized_result | {
    object_count: .documents[0].object_count,
    connection_count: .documents[0].connection_count,
    control_classes: [.graph.nodes[] | select(.kind == "control") | .attributes.class_name],
    actions: [.graph.nodes[] | select(.kind == "action") | {name, attributes}],
    action_edges: [.graph.edges[] | select(.relation == "target_action") | {from,to,resolution}]
  }' "$work_dir/$mode.json"
done
python3 - "$work_dir/WithConnector.nib" <<'PYTHON'
import plistlib, sys
archive = plistlib.load(open(sys.argv[1], 'rb'))
table = archive['$objects']
for uid, obj in enumerate(table):
    if not isinstance(obj, dict) or '$class' not in obj:
        continue
    descriptor = table[obj['$class']['CF$UID']]
    if descriptor['$classname'] == 'NSNibControlConnector':
        print('connector:', uid, descriptor, obj)
PYTHON

Expected behavior

A serialized action connector must not appear as a UI control. Retain the clicked: action from the button to the view and the genuine NSButton control. Any retained helper representation must distinguish it from a UI control; this does not require removing every archive helper or imposing a particular total object count.

The command advertises keyed nib objects and connections in src/contracts/artifactToolContracts.ts:92-95 and docs/native-investigation.md:383-396 at the tested baseline. The keyed reader recognizes this connector class, and the sibling NIBArchive projection excludes AppKit connectors from its object inventory (src/artifacts/apple/InterfaceBuilderAnalysis.ts:512-522). AppKit's own runtime identifies this connector as not an NSControl.

Impact and scope

P2: control enumeration includes a connection helper in this bounded synthetic Foundation fixture. Wider application prevalence was not measured. The action graph remains usable. A verified manual workaround is to exclude the explicit class_name: NSNibControlConnector from control queries while retaining the separate action and endpoints; this leaves the two button-related control nodes in the observed output.

REA version

rea-agents 6.7.0; independently verified baseline 9d8e7803326a382cbbb3ffdb900f5e93e5442d68. Submission-stage recheck: freshly fetched origin/main at 9258df02cc414112d4fa91a425f0d599f43e2357, with locked dependencies and npm run build:cached, followed by replay of the complete reproduction above.

Runtime and platform

Node.js v26.12.0; macOS 27.0.1; arm64. The reproduction uses Swift/AppKit and a C compiler; no optional analysis provider is needed.

Target and artifact context

Synthetic XML NSKeyedArchiver nibs containing actual NSButton, NSView and NSNibControlConnector instances, inside a disposable app bundle with a minimal Mach-O executable. Target bytes are statically inspected; no third-party application or analysis database is required.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Both CLI calls exit 0 with empty stderr. The keyed archive's original XML and AppKit runtime class checks independently identify the connector and its endpoints:

connector is NSControl: false
button is NSControl: true
WithConnector:
  object_count: 7; connection_count: 1
  control classes: NSButton, NSButtonCell, NSNibControlConnector
  action: clicked:, archive:16, source button UID 2, destination view UID 14
  target_action edges: button -> action -> target, resolution observed
WithoutConnector:
  object_count: 6; connection_count: 0
  control classes: NSButton, NSButtonCell
  no actions or target_action edges

The original XML identifies UID 16 as NSNibControlConnector, with $classes: [NSNibControlConnector, NSNibConnector, NSObject]; NSSource references UID 2, NSDestination references UID 14, and NSLabel references the clicked: string. Other Foundation helpers explain additional object records; the seven-object count does not imply seven UI objects. Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete for this fixture.

Typed outcome or recovery expected

Proposed restoration check: the connector case contains no UI control with class NSNibControlConnector, while retaining the clicked: action with the same button source and view destination and retaining the genuine button. The connector-free case retains its button without acquiring an action. Removing all controls or dropping the action does not restore this contract.

Analysis or suspected cause

Source-derived reasoning: at the baseline, src/domain/apple/interfaceBuilderKeyedArchive.ts:379-421 admits the resolved archived instance into objects and independently projects its connection. Its classifier at lines 526-560 treats a class name containing control as a control. src/domain/apple/interfaceBuilderGraph.ts:133-148 preserves that kind. Thus the same connector yields a false control and a correct action. The original XML confirms connector ancestry rather than a UI control ancestry. No fix was implemented or verified.

Additional context

Related #5218 and open PR #5228 concern UIKit view-controller subclasses. This report is limited to an AppKit connection helper, whose declared chain contains none of the UI bases tested by #5228; that PR explicitly retains the name heuristic when no such base matches. PR #5114 concerns authored className collisions, absent here. Merged #900 and #905 recover action endpoints/direction; those actions already work in this reproduction. #5590 concerns binding connection accounting, not this false control.

Sibling reports #5692 (outlet-collection membership), #5693 (top-level hierarchy) and #5695 (segue connections) have separate restoration checks. Related #5540, #5541 and #5638 concern omission reasons, boolean polarity and UID identity. No last known good version or regression introduction was established. MCP and shipping app resources were not tested. Prepared with AI assistance and independent verification.

Originally created by @coygeek on GitHub (Oct 20, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5720 ### 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 `decode-interface-builder` reports a genuine AppKit `NSNibControlConnector` as both a UI `control` and an `action` when decoding a Foundation keyed archive. Its `clicked:` action and endpoints are recovered correctly; the extra UI control is false. An otherwise equivalent archive without the connector retains the same button and view without that extra control. ### Reproduction From the built source checkout, run this on macOS with Xcode command-line tools, Swift, Python 3 and jq. It serializes genuine AppKit objects with `NSKeyedArchiver`, builds a disposable one-nib app, and runs static inspection with isolated configuration. The fixture app is never launched. Independently reproduced at the baseline; the complete script was also replayed against the current-main revision below. ```sh set -eu work_dir="$(mktemp -d)" mkdir -p "$work_dir/home" "$work_dir/cfg" "$work_dir/data" "$work_dir/cache" \ "$work_dir/state" "$work_dir/tmp" "$work_dir/Fixture.app/Contents/MacOS" \ "$work_dir/Fixture.app/Contents/Resources" printf '#import <AppKit/NSNibControlConnector.h>\n' > "$work_dir/Bridge.h" cat > "$work_dir/make.swift" <<'SWIFT' import AppKit let output = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) let button = NSButton(frame: .zero) button.title = "Tap" let target = NSView(frame: .zero) let connector = NSNibControlConnector() connector.source = button connector.destination = target connector.label = "clicked:" for (name, root): (String, Any) in [("WithConnector", [button, target, connector] as NSArray), ("WithoutConnector", [button, target] as NSArray)] { let archiver = NSKeyedArchiver(requiringSecureCoding: false) archiver.outputFormat = .xml archiver.encode(root, forKey: NSKeyedArchiveRootObjectKey) archiver.finishEncoding() try archiver.encodedData.write(to: output.appendingPathComponent(name + ".nib")) } print("connector is NSControl:", connector.isKind(of: NSControl.self)) print("button is NSControl:", button.isKind(of: NSControl.self)) SWIFT xcrun swiftc -import-objc-header "$work_dir/Bridge.h" \ -module-cache-path "$work_dir/module-cache" "$work_dir/make.swift" \ -o "$work_dir/make-fixtures" "$work_dir/make-fixtures" "$work_dir" printf 'int main(void){return 0;}\n' > "$work_dir/demo.c" xcrun clang "$work_dir/demo.c" -o "$work_dir/Fixture.app/Contents/MacOS/Demo" cat > "$work_dir/Fixture.app/Contents/Info.plist" <<'PLIST' <?xml version="1.0" encoding="UTF-8"?> <plist version="1.0"><dict><key>CFBundleExecutable</key><string>Demo</string><key>CFBundleIdentifier</key><string>dev.example.fixture</string><key>CFBundlePackageType</key><string>APPL</string></dict></plist> PLIST for mode in WithConnector WithoutConnector; do cp "$work_dir/$mode.nib" "$work_dir/Fixture.app/Contents/Resources/Main.nib" env -i PATH="$PATH" HOME="$work_dir/home" XDG_CONFIG_HOME="$work_dir/cfg" \ XDG_DATA_HOME="$work_dir/data" XDG_CACHE_HOME="$work_dir/cache" \ XDG_STATE_HOME="$work_dir/state" TMPDIR="$work_dir/tmp" REA_LOG_LEVEL=silent \ node scripts/rea.mjs decode-interface-builder "$work_dir/Fixture.app" --json \ > "$work_dir/$mode.json" 2> "$work_dir/$mode.stderr" printf '%s\n' "$mode" jq '.normalized_result | { object_count: .documents[0].object_count, connection_count: .documents[0].connection_count, control_classes: [.graph.nodes[] | select(.kind == "control") | .attributes.class_name], actions: [.graph.nodes[] | select(.kind == "action") | {name, attributes}], action_edges: [.graph.edges[] | select(.relation == "target_action") | {from,to,resolution}] }' "$work_dir/$mode.json" done python3 - "$work_dir/WithConnector.nib" <<'PYTHON' import plistlib, sys archive = plistlib.load(open(sys.argv[1], 'rb')) table = archive['$objects'] for uid, obj in enumerate(table): if not isinstance(obj, dict) or '$class' not in obj: continue descriptor = table[obj['$class']['CF$UID']] if descriptor['$classname'] == 'NSNibControlConnector': print('connector:', uid, descriptor, obj) PYTHON ``` ### Expected behavior A serialized action connector must not appear as a UI control. Retain the `clicked:` action from the button to the view and the genuine `NSButton` control. Any retained helper representation must distinguish it from a UI control; this does not require removing every archive helper or imposing a particular total object count. The command advertises keyed nib objects and connections in `src/contracts/artifactToolContracts.ts:92-95` and `docs/native-investigation.md:383-396` at the tested baseline. The keyed reader recognizes this connector class, and the sibling NIBArchive projection excludes AppKit connectors from its object inventory (`src/artifacts/apple/InterfaceBuilderAnalysis.ts:512-522`). AppKit's own runtime identifies this connector as not an `NSControl`. ### Impact and scope P2: control enumeration includes a connection helper in this bounded synthetic Foundation fixture. Wider application prevalence was not measured. The action graph remains usable. A verified manual workaround is to exclude the explicit `class_name: NSNibControlConnector` from control queries while retaining the separate action and endpoints; this leaves the two button-related control nodes in the observed output. ### REA version `rea-agents` 6.7.0; independently verified baseline `9d8e7803326a382cbbb3ffdb900f5e93e5442d68`. Submission-stage recheck: freshly fetched `origin/main` at `9258df02cc414112d4fa91a425f0d599f43e2357`, with locked dependencies and `npm run build:cached`, followed by replay of the complete reproduction above. ### Runtime and platform Node.js v26.12.0; macOS 27.0.1; arm64. The reproduction uses Swift/AppKit and a C compiler; no optional analysis provider is needed. ### Target and artifact context Synthetic XML NSKeyedArchiver nibs containing actual `NSButton`, `NSView` and `NSNibControlConnector` instances, inside a disposable app bundle with a minimal Mach-O executable. Target bytes are statically inspected; no third-party application or analysis database is required. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Both CLI calls exit 0 with empty stderr. The keyed archive's original XML and AppKit runtime class checks independently identify the connector and its endpoints: ```text connector is NSControl: false button is NSControl: true WithConnector: object_count: 7; connection_count: 1 control classes: NSButton, NSButtonCell, NSNibControlConnector action: clicked:, archive:16, source button UID 2, destination view UID 14 target_action edges: button -> action -> target, resolution observed WithoutConnector: object_count: 6; connection_count: 0 control classes: NSButton, NSButtonCell no actions or target_action edges ``` The original XML identifies UID 16 as `NSNibControlConnector`, with `$classes: [NSNibControlConnector, NSNibConnector, NSObject]`; `NSSource` references UID 2, `NSDestination` references UID 14, and `NSLabel` references the `clicked:` string. Other Foundation helpers explain additional object records; the seven-object count does not imply seven UI objects. Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete for this fixture. ### Typed outcome or recovery expected Proposed restoration check: the connector case contains no UI `control` with class `NSNibControlConnector`, while retaining the `clicked:` action with the same button source and view destination and retaining the genuine button. The connector-free case retains its button without acquiring an action. Removing all controls or dropping the action does not restore this contract. ### Analysis or suspected cause Source-derived reasoning: at the baseline, `src/domain/apple/interfaceBuilderKeyedArchive.ts:379-421` admits the resolved archived instance into objects and independently projects its connection. Its classifier at lines 526-560 treats a class name containing `control` as a control. `src/domain/apple/interfaceBuilderGraph.ts:133-148` preserves that kind. Thus the same connector yields a false control and a correct action. The original XML confirms connector ancestry rather than a UI control ancestry. No fix was implemented or verified. ### Additional context Related #5218 and open PR #5228 concern UIKit view-controller subclasses. This report is limited to an AppKit connection helper, whose declared chain contains none of the UI bases tested by #5228; that PR explicitly retains the name heuristic when no such base matches. PR #5114 concerns authored `className` collisions, absent here. Merged #900 and #905 recover action endpoints/direction; those actions already work in this reproduction. #5590 concerns binding connection accounting, not this false control. Sibling reports #5692 (outlet-collection membership), #5693 (top-level hierarchy) and #5695 (segue connections) have separate restoration checks. Related #5540, #5541 and #5638 concern omission reasons, boolean polarity and UID identity. No last known good version or regression introduction was established. MCP and shipping app resources were not tested. Prepared with AI assistance and independent verification.
Author
Owner

@matbrgz commented on GitHub (Oct 21, 2026):

Cross-referencing with canonical issue #5540 (decode-interface-builder misattributes object-limit omissions to the connection budget) and sibling NIB decoding reports #5541, #5590, #5692, #5693, #5695, #5721.

Consolidated under Cluster AX (Compiled Interface Builder / NIB Decoding & Object Limit Anomalies) in tracker #4330.

<!-- gh-comment-id:6304920006 --> @matbrgz commented on GitHub (Oct 21, 2026): Cross-referencing with canonical issue #5540 (`decode-interface-builder misattributes object-limit omissions to the connection budget`) and sibling NIB decoding reports #5541, #5590, #5692, #5693, #5695, #5721. Consolidated under **Cluster AX (Compiled Interface Builder / NIB Decoding & Object Limit Anomalies)** in tracker #4330.
Author
Owner

@coygeek commented on GitHub (Oct 21, 2026):

Independently reproduced an additional trigger with an ibtool-compiled AppKit XIB at 9d8e7803326a382cbbb3ffdb900f5e93e5442d68 (rea-agents 6.7.0). These minimal object declarations inside a Cocoa XIB's <objects> reproduce it:

<arrayController id="array-model"/>
<userDefaultsController id="defaults-model">
  <userDefaults key="defaults" id="defaults-store"/>
</userDefaultsController>
<scrollView id="scroll-area" hasHorizontalScroller="NO" hasVerticalScroller="YES">
  <rect key="frame" x="0" y="0" width="180" height="120"/>
  <clipView key="contentView" id="clip-area">
    <rect key="frame" x="1" y="1" width="163" height="118"/>
  </clipView>
  <scroller key="verticalScroller" verticalHuggingPriority="750" horizontal="NO" id="vertical-scroll">
    <rect key="frame" x="164" y="1" width="15" height="118"/>
  </scroller>
</scrollView>
xcrun ibtool --compile Kinds.nib Kinds.xib
xcrun ibtool --objects --connections --output-format xml1 Kinds.xib
# Put Kinds.nib at Fixture.app/Contents/Resources/Main.nib in an inert app bundle.
node scripts/rea.mjs decode-interface-builder Fixture.app --json

With isolated HOME/XDG configuration, compilation and decoding exit 0. Source-side ibtool identifies NSArrayController, NSUserDefaultsController, and NSScroller; REA's corresponding nodes are respectively kind: "control", "control", and "unknown". An independent AppKit runtime ancestry check gives NSArrayController → NSObjectController → NSController → NSObject, NSUserDefaultsController → NSController → NSObject, and NSScroller → NSControl → NSView → NSResponder → NSObject. The controllers should not count as UI controls, and the actual scroller should retain its control kind. Source-derived cause is the same classifyObject heuristic in src/domain/apple/interfaceBuilderKeyedArchive.ts: control matches the controllers' names, while NSScroller matches none of its control terms. This extends the existing false-positive case to compiled XIBs and exposes a false negative from the same classifier. Fetched main has changes under src/artifacts/apple; verified at baseline; main not re-run.

<!-- gh-comment-id:6305434785 --> @coygeek commented on GitHub (Oct 21, 2026): Independently reproduced an additional trigger with an `ibtool`-compiled AppKit XIB at `9d8e7803326a382cbbb3ffdb900f5e93e5442d68` (`rea-agents` 6.7.0). These minimal object declarations inside a Cocoa XIB's `<objects>` reproduce it: ```xml <arrayController id="array-model"/> <userDefaultsController id="defaults-model"> <userDefaults key="defaults" id="defaults-store"/> </userDefaultsController> <scrollView id="scroll-area" hasHorizontalScroller="NO" hasVerticalScroller="YES"> <rect key="frame" x="0" y="0" width="180" height="120"/> <clipView key="contentView" id="clip-area"> <rect key="frame" x="1" y="1" width="163" height="118"/> </clipView> <scroller key="verticalScroller" verticalHuggingPriority="750" horizontal="NO" id="vertical-scroll"> <rect key="frame" x="164" y="1" width="15" height="118"/> </scroller> </scrollView> ``` ```sh xcrun ibtool --compile Kinds.nib Kinds.xib xcrun ibtool --objects --connections --output-format xml1 Kinds.xib # Put Kinds.nib at Fixture.app/Contents/Resources/Main.nib in an inert app bundle. node scripts/rea.mjs decode-interface-builder Fixture.app --json ``` With isolated HOME/XDG configuration, compilation and decoding exit 0. Source-side `ibtool` identifies `NSArrayController`, `NSUserDefaultsController`, and `NSScroller`; REA's corresponding nodes are respectively `kind: "control"`, `"control"`, and `"unknown"`. An independent AppKit runtime ancestry check gives `NSArrayController → NSObjectController → NSController → NSObject`, `NSUserDefaultsController → NSController → NSObject`, and `NSScroller → NSControl → NSView → NSResponder → NSObject`. The controllers should not count as UI controls, and the actual scroller should retain its control kind. Source-derived cause is the same `classifyObject` heuristic in `src/domain/apple/interfaceBuilderKeyedArchive.ts`: `control` matches the controllers' names, while `NSScroller` matches none of its control terms. This extends the existing false-positive case to compiled XIBs and exposes a false negative from the same classifier. Fetched main has changes under `src/artifacts/apple`; verified at baseline; main not re-run.
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#3469
No description provided.