[GH-ISSUE #5218] [Bug] UIKit view-controller subclasses decode as controls #3194

Closed
opened 2026-10-31 00:37:42 +00:00 by bsakaguchi · 0 comments
Owner

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

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 exits 0 but returns kind: "control" for UINavigationController, UITabBarController, and UISearchController in an accepted keyed archive. Their original class_name values remain correct. The same input correctly returns view_controller for UIViewController and control for UIButton.

Reproduction

Use a checkout at the source baseline below with the pinned Node.js runtime, locked dependencies, and compiled CLI available (npm ci and npm run build:cached prepare these). Save this as reproduce.py in an empty disposable directory, then run python3 reproduce.py /path/to/rea /path/to/node, passing absolute paths to that checkout and Node executable. The script was executed once in this check and reproduced the rows below; the preparation commands are prerequisites, not additional measured results.

from pathlib import Path
import json, os, plistlib, struct, subprocess, sys

repo = Path(sys.argv[1]).resolve()
node = sys.argv[2]
root = Path.cwd()
app = root / 'Fixture.app'
resources = app / 'Contents' / 'Resources'
resources.mkdir(parents=True, exist_ok=True)
executables = app / 'Contents' / 'MacOS'
executables.mkdir(exist_ok=True)
(executables / 'Fixture').write_bytes(struct.pack('>II', 0xfeedfacf, 0x0100000c) + bytes(24))
(app / 'Contents' / 'Info.plist').write_bytes(plistlib.dumps({'CFBundleExecutable': 'Fixture'}))
classes = ['UINavigationController', 'UITabBarController', 'UISearchController', 'UIViewController', 'UIButton']
objects = ['$null'] + [{'$class': {'CF$UID': i + 6}} for i in range(5)]
for name in classes:
    bases = [] if name == 'UIViewController' else ['UIControl', 'UIView'] if name == 'UIButton' else ['UIViewController']
    objects.append({'$classname': name, '$classes': [name] + bases + ['UIResponder', 'NSObject']})
objects += [{'$class': {'CF$UID': 12}, 'NS.objects': [{'CF$UID': i} for i in range(1, 6)]}, {'$classname': 'NSArray', '$classes': ['NSArray', 'NSObject']}]
archive = {'$archiver': 'NSKeyedArchiver', '$version': 100000, '$objects': objects, '$top': {'root': {'CF$UID': 11}}}
(resources / 'Main.nib').write_bytes(plistlib.dumps(archive, sort_keys=False))
for name in ['home', 'config', 'cache', 'temp']:
    (root / name).mkdir(exist_ok=True)
env = {'PATH': os.path.dirname(node) + ':/usr/bin:/bin', 'HOME': str(root / 'home'), 'XDG_CONFIG_HOME': str(root / 'config'), 'XDG_CACHE_HOME': str(root / 'cache'), 'TMPDIR': str(root / 'temp'), 'REA_LOG_LEVEL': 'silent'}
result = subprocess.run([node, str(repo / 'scripts' / 'rea.mjs'), 'decode-interface-builder', str(app), '--json'], cwd=repo, env=env, text=True, capture_output=True, timeout=30)
(root / 'stdout.json').write_text(result.stdout)
(root / 'stderr.txt').write_text(result.stderr)
print('CLI exit:', result.returncode)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
# The CLI envelope exposes the Evidence as data.
evidence = payload.get('data', payload)
normalized = evidence['normalized_result']
rows = [{'class_name': n['attributes']['class_name'], 'kind': n['kind']} for n in normalized['graph']['nodes'] if n['attributes'].get('class_name') in classes]
print(json.dumps(rows, indent=2))
(root / 'rows.json').write_text(json.dumps(rows, indent=2))
assert rows == [{'class_name': c, 'kind': 'control' if c != 'UIViewController' else 'view_controller'} for c in classes]
print('Observed allegation plus both controls confirmed.')

The fixture is a rooted NSKeyedArchiver XML plist with five class instances and an NSArray root referencing them. Each affected class descriptor explicitly includes UIViewController in $classes. The script creates isolated home/config/cache/temp directories and never executes the synthetic target header.

Expected behavior

The three affected objects should return kind: "view_controller", retaining their class names. UIViewController must remain view_controller, and UIButton must remain control.

The decoder's object schema distinguishes these categories. Apple's documentation identifies UINavigationController, UITabBarController, and UISearchController as UIViewController subclasses. This expectation concerns these concrete classes, not every class containing controller in its name.

Impact and scope

Callers receive incorrect semantic node kinds for these three UIKit classes. Class names and all five named objects survive; archive/object/class/hierarchy coverage reports complete. Real-application frequency and downstream effects were not measured. No supported operation restoring the graph kinds was demonstrated.

REA version

Tested source/current main: 39275420ea18ad803eccfcc2702865b98ed36e44. Latest published release source checked: rea-agents-6.7.0, commit 27dc42d10249f0410904a75c0997842eb03b931c. The complete classifier/parser and graph-builder files are byte-identical between those revisions; release applicability is source-derived, and the release runtime was not executed.

Runtime and platform

Node.js 24.18.0; macOS 27.0.1 arm64; source checkout with prepared locked dependencies and compiled artifacts. No Hopper/Ghidra provider was used.

Target and artifact context

Synthetic .app bundle with Info.plist, a 32-byte Mach-O arm64 recognition header, and Contents/Resources/Main.nib. This is a parser-accepted fixture, not an Xcode-compiled archive or a UIKit-instantiated application.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Observed CLI exit: 0. Extracted graph rows:

[
  {"class_name": "UINavigationController", "kind": "control"},
  {"class_name": "UITabBarController", "kind": "control"},
  {"class_name": "UISearchController", "kind": "control"},
  {"class_name": "UIViewController", "kind": "view_controller"},
  {"class_name": "UIButton", "kind": "control"}
]

Archive decoding reports examined: 1, omitted: 0; object and class coverage each report examined: 6, omitted: 0 (including NSArray); hierarchy coverage reports examined: 5, omitted: 0. Each status is complete. Evidence provenance: observed; local verification: reproduced; reproduction completeness: complete for this synthetic input.

Typed outcome or recovery expected

With the same accepted archive, retain exit 0, all five named objects, their class names, and complete coverage while returning view_controller for the three affected classes and UIViewController. Keep UIButton as control; omitting rows or reclassifying all controls would not restore this contract.

Analysis or suspected cause

Source-derived: the classifier recognizes literal viewcontroller/windowcontroller before testing the substring control. The three affected names miss the former branch and match the latter. The graph projection preserves that kind.

Additional context

Related #3343 concerns native-handler joins after decoding. This report's independent closure signal is the decoder's returned object kinds. Real UIKit instantiation, Xcode production of this fixture, and release runtime reproduction remain untested.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The public decoder returns incorrect semantic graph kinds for three concrete UIKit classes in an accepted archive; demonstrated impact is bounded to wrong graph data, with real-application frequency unmeasured.
  • Workaround status: unverified
  • Workaround basis: Preserved class names permit manual recognition, but no supported operation restoring the graph kinds was demonstrated.
Originally created by @coygeek on GitHub (Oct 19, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5218 ### 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` exits 0 but returns `kind: "control"` for `UINavigationController`, `UITabBarController`, and `UISearchController` in an accepted keyed archive. Their original `class_name` values remain correct. The same input correctly returns `view_controller` for `UIViewController` and `control` for `UIButton`. ### Reproduction Use a checkout at the source baseline below with the pinned Node.js runtime, locked dependencies, and compiled CLI available (`npm ci` and `npm run build:cached` prepare these). Save this as `reproduce.py` in an empty disposable directory, then run `python3 reproduce.py /path/to/rea /path/to/node`, passing absolute paths to that checkout and Node executable. The script was executed once in this check and reproduced the rows below; the preparation commands are prerequisites, not additional measured results. ```python from pathlib import Path import json, os, plistlib, struct, subprocess, sys repo = Path(sys.argv[1]).resolve() node = sys.argv[2] root = Path.cwd() app = root / 'Fixture.app' resources = app / 'Contents' / 'Resources' resources.mkdir(parents=True, exist_ok=True) executables = app / 'Contents' / 'MacOS' executables.mkdir(exist_ok=True) (executables / 'Fixture').write_bytes(struct.pack('>II', 0xfeedfacf, 0x0100000c) + bytes(24)) (app / 'Contents' / 'Info.plist').write_bytes(plistlib.dumps({'CFBundleExecutable': 'Fixture'})) classes = ['UINavigationController', 'UITabBarController', 'UISearchController', 'UIViewController', 'UIButton'] objects = ['$null'] + [{'$class': {'CF$UID': i + 6}} for i in range(5)] for name in classes: bases = [] if name == 'UIViewController' else ['UIControl', 'UIView'] if name == 'UIButton' else ['UIViewController'] objects.append({'$classname': name, '$classes': [name] + bases + ['UIResponder', 'NSObject']}) objects += [{'$class': {'CF$UID': 12}, 'NS.objects': [{'CF$UID': i} for i in range(1, 6)]}, {'$classname': 'NSArray', '$classes': ['NSArray', 'NSObject']}] archive = {'$archiver': 'NSKeyedArchiver', '$version': 100000, '$objects': objects, '$top': {'root': {'CF$UID': 11}}} (resources / 'Main.nib').write_bytes(plistlib.dumps(archive, sort_keys=False)) for name in ['home', 'config', 'cache', 'temp']: (root / name).mkdir(exist_ok=True) env = {'PATH': os.path.dirname(node) + ':/usr/bin:/bin', 'HOME': str(root / 'home'), 'XDG_CONFIG_HOME': str(root / 'config'), 'XDG_CACHE_HOME': str(root / 'cache'), 'TMPDIR': str(root / 'temp'), 'REA_LOG_LEVEL': 'silent'} result = subprocess.run([node, str(repo / 'scripts' / 'rea.mjs'), 'decode-interface-builder', str(app), '--json'], cwd=repo, env=env, text=True, capture_output=True, timeout=30) (root / 'stdout.json').write_text(result.stdout) (root / 'stderr.txt').write_text(result.stderr) print('CLI exit:', result.returncode) assert result.returncode == 0, result.stderr payload = json.loads(result.stdout) # The CLI envelope exposes the Evidence as data. evidence = payload.get('data', payload) normalized = evidence['normalized_result'] rows = [{'class_name': n['attributes']['class_name'], 'kind': n['kind']} for n in normalized['graph']['nodes'] if n['attributes'].get('class_name') in classes] print(json.dumps(rows, indent=2)) (root / 'rows.json').write_text(json.dumps(rows, indent=2)) assert rows == [{'class_name': c, 'kind': 'control' if c != 'UIViewController' else 'view_controller'} for c in classes] print('Observed allegation plus both controls confirmed.') ``` The fixture is a rooted NSKeyedArchiver XML plist with five class instances and an `NSArray` root referencing them. Each affected class descriptor explicitly includes `UIViewController` in `$classes`. The script creates isolated home/config/cache/temp directories and never executes the synthetic target header. ### Expected behavior The three affected objects should return `kind: "view_controller"`, retaining their class names. `UIViewController` must remain `view_controller`, and `UIButton` must remain `control`. The decoder's [object schema](https://github.com/morluto/rea/blob/39275420ea18ad803eccfcc2702865b98ed36e44/src/domain/apple/interfaceBuilderKeyedArchive.ts#L7-L23) distinguishes these categories. Apple's documentation identifies [UINavigationController](https://developer.apple.com/documentation/uikit/uinavigationcontroller), [UITabBarController](https://developer.apple.com/documentation/uikit/uitabbarcontroller), and [UISearchController](https://developer.apple.com/documentation/uikit/uisearchcontroller) as `UIViewController` subclasses. This expectation concerns these concrete classes, not every class containing `controller` in its name. ### Impact and scope Callers receive incorrect semantic node kinds for these three UIKit classes. Class names and all five named objects survive; archive/object/class/hierarchy coverage reports complete. Real-application frequency and downstream effects were not measured. No supported operation restoring the graph kinds was demonstrated. ### REA version Tested source/current `main`: `39275420ea18ad803eccfcc2702865b98ed36e44`. Latest published release source checked: `rea-agents-6.7.0`, commit `27dc42d10249f0410904a75c0997842eb03b931c`. The complete classifier/parser and graph-builder files are byte-identical between those revisions; release applicability is source-derived, and the release runtime was not executed. ### Runtime and platform Node.js 24.18.0; macOS 27.0.1 arm64; source checkout with prepared locked dependencies and compiled artifacts. No Hopper/Ghidra provider was used. ### Target and artifact context Synthetic `.app` bundle with `Info.plist`, a 32-byte Mach-O arm64 recognition header, and `Contents/Resources/Main.nib`. This is a parser-accepted fixture, not an Xcode-compiled archive or a UIKit-instantiated application. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Observed CLI exit: `0`. Extracted graph rows: ```json [ {"class_name": "UINavigationController", "kind": "control"}, {"class_name": "UITabBarController", "kind": "control"}, {"class_name": "UISearchController", "kind": "control"}, {"class_name": "UIViewController", "kind": "view_controller"}, {"class_name": "UIButton", "kind": "control"} ] ``` Archive decoding reports `examined: 1, omitted: 0`; object and class coverage each report `examined: 6, omitted: 0` (including `NSArray`); hierarchy coverage reports `examined: 5, omitted: 0`. Each status is `complete`. Evidence provenance: observed; local verification: reproduced; reproduction completeness: complete for this synthetic input. ### Typed outcome or recovery expected With the same accepted archive, retain exit 0, all five named objects, their class names, and complete coverage while returning `view_controller` for the three affected classes and `UIViewController`. Keep `UIButton` as `control`; omitting rows or reclassifying all controls would not restore this contract. ### Analysis or suspected cause Source-derived: the [classifier](https://github.com/morluto/rea/blob/39275420ea18ad803eccfcc2702865b98ed36e44/src/domain/apple/interfaceBuilderKeyedArchive.ts#L489-L525) recognizes literal `viewcontroller`/`windowcontroller` before testing the substring `control`. The three affected names miss the former branch and match the latter. The [graph projection](https://github.com/morluto/rea/blob/39275420ea18ad803eccfcc2702865b98ed36e44/src/domain/apple/interfaceBuilderGraph.ts#L130-L148) preserves that kind. ### Additional context Related [#3343](https://github.com/morluto/rea/issues/3343) concerns native-handler joins after decoding. This report's independent closure signal is the decoder's returned object kinds. Real UIKit instantiation, Xcode production of this fixture, and release runtime reproduction remain untested. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: The public decoder returns incorrect semantic graph kinds for three concrete UIKit classes in an accepted archive; demonstrated impact is bounded to wrong graph data, with real-application frequency unmeasured. - Workaround status: unverified - Workaround basis: Preserved class names permit manual recognition, but no supported operation restoring the graph kinds was demonstrated.
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#3194
No description provided.