[GH-ISSUE #3343] [Bug] UI action traces omit known Objective-C handlers for non-controller receivers #1211

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

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

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

The MCP tool trace_native_ui_action silently omits a uniquely matched Objective-C handler when the authored receiver is classified as a view, control, or unknown object. It returns no handler or dispatch edge, no unresolved dispatch edge, and complete ui_to_objc_dispatch coverage with zero examined and zero omitted. The same connection and symbol facts work when the receiver class name is classified as a view controller.

The reproduction ran through production MCP registration, SDK input validation, target admission, the composed workflow, and structured Evidence delivery with a simulated provider. Changing only the receiver class spelling and corresponding exact method symbol changes whether the otherwise equivalent authored connection reaches its implementation. These are simulated producer observations; actual ibtool, Hopper, and Ghidra were not run.

Reproduction

  1. Start from a clean checkout at the baseline and prepare the locked dependencies and runtime:
git clone https://github.com/morluto/rea.git rea-ui-handler-repro
cd rea-ui-handler-repro
git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063
npm ci
npm run build:cached
  1. Run the following command from the built checkout root. It supplies the complete ibtool-shaped input document inline to the existing graph decoder and simulates the provider observations; it creates no input files and needs no native engine. The server's environment is explicitly empty, and both MCP endpoints close after each invocation.
node --input-type=module <<'JS'
import {createRequire} from 'node:module';
import {pathToFileURL} from 'node:url';
import {resolve} from 'node:path';
const root=process.cwd(), require=createRequire(resolve(root,'package.json'));
const load=p=>import(pathToFileURL(resolve(root,p)));
const {Client,InMemoryTransport}=await import(pathToFileURL(require.resolve('@modelcontextprotocol/client')));
const {createServer}=await load('dist/server/createServer.js');
const {createAnalysisExecution}=await load('dist/application/AnalysisProvider.js');
const {buildInterfaceBuilderAnalysis,interfaceBuilderLimitsSchema}=await load('dist/domain/apple/interfaceBuilderGraph.js');
const {AnalysisCapabilityUnavailableError}=await load('dist/domain/analysisErrorCore.js');
const {ok,err}=await load('dist/domain/result.js');
const sha256='a'.repeat(64), provider={id:'fixture',name:'Fixture',version:'1'};
const observed=value=>ok(createAnalysisExecution(value,provider,{subject:{sha256,path:'/fixtures/Sample.app',format:'mach-o',architecture:'arm64'}}));
for(const className of ['BuildViewController','BuildView','BuildButton','BuildHandler']) {
  const ui=buildInterfaceBuilderAnalysis({targetSha256:sha256,toolVersion:'fixture',limits:interfaceBuilderLimitsSchema.parse({}),documents:[{
    relativePath:'Main.nib',archiveSha256:'b'.repeat(64),documentKind:'nib',raw:{
      'com.apple.ibtool.document.objects':{receiver:{customClass:className},button:{class:'UIButton'}},
      'com.apple.ibtool.document.connections':{button:[{type:'action',label:'buildTapped:',destinationId:'receiver'}]}
    }
  }]});
  const analysis={execute:async(operation)=>{
    if(operation==='decode_interface_builder') return observed(ui);
    if(operation==='inspect_native_dispatch_metadata'||operation==='procedure_references') return err(new AnalysisCapabilityUnavailableError('fixture',operation,'Unavailable in fixture'));
    if(operation==='list_names') return observed([{address:'0x1000',name:`-[${className} buildTapped:]`}]);
    if(operation==='procedure_callees') return observed([]);
    throw new Error(`Unexpected operation: ${operation}`);
  }};
  const server=createServer({kind:'fixed',analysis},{environment:{}});
  const client=new Client({name:'receiver-reproduction',version:'1'});
  const [clientTransport,serverTransport]=InMemoryTransport.createLinkedPair();
  try {
    await server.connect(serverTransport);
    await client.connect(clientTransport);
    const result=await client.callTool({name:'trace_native_ui_action',arguments:{action:'buildTapped:'}});
    if(result.isError) throw new Error(JSON.stringify(result));
    const envelope=result.structuredContent??JSON.parse(result.content.find(c=>c.type==='text').text);
    const trace=envelope.normalized_result;
    console.log(JSON.stringify({className,receiverKind:ui.graph.nodes.find(n=>n.attributes.interface_builder_object_id==='receiver').kind,handlers:trace.nodes.filter(n=>n.kind==='function').map(n=>n.location.address),dispatchEdges:trace.edges.filter(e=>e.relation==='objc_dispatch').length,unresolved:trace.unresolved.length,dispatchCoverage:trace.coverage.find(c=>c.facet==='ui_to_objc_dispatch')}));
  } finally {
    await client.close();
    await server.close();
  }
}
JS
  1. Compare the controller case with the view, control, and unknown receiver cases in the output. Each iteration calls the public MCP tool with {"name":"trace_native_ui_action","arguments":{"action":"buildTapped:"}}. The authored connection selects one receiver with a known custom class, and the provider name inventory contains exactly one matching instance method at 0x1000. The supplied Interface Builder graph and native observations carry the same target digest. Default traversal bounds are sufficient for this small graph.

The reported reproduction used an existing compiled runtime and installed locked dependencies. The clean-checkout installation and build commands above are preparation steps, not additional measured results.

Expected behavior

All four receiver classifications should retain the known handler at 0x1000 and its dispatch connection, with provenance appropriate to the symbol observation. A receiver's presentation kind must not suppress an authored connection to a uniquely matched class/selector implementation. If any form is unsupported, the trace must preserve an explicit unresolved edge and coverage limitation rather than claim that nothing was examined or omitted.

docs/native-investigation.md:210-217 promises joining UI wiring to uniquely matched class/selector implementations and preserving unavailable forms as unknown. src/contracts/enhancedToolContracts.ts:80-82 describes traversal through authored connections and encoded or symbolized Objective-C handlers without limiting the receiver to a controller.

Impact and scope

The supported impact assessment is P2. A supported static UI trace loses a known handler and its downstream analysis path for bounded receiver kinds, while its coverage conceals the omission. The integrated reproduction establishes the composed workflow defect; real native-provider occurrence was not measured.

Workaround status is unverified. The documented native-symbol seed can inspect a handler separately, but was not exercised in this reproduction and does not preserve the missing authored UI join. No workaround restoring that connection was demonstrated.

REA version

6.4.0 (main at 8dde6370d0)

Runtime and platform

Node.js v26.10.0; macOS 27.0.1 arm64. The source checkout had an existing compiled runtime and installed locked dependencies.

Target and artifact context

A simulated Mach-O arm64 app subject at /fixtures/Sample.app, an inline ibtool-shaped Main.nib document, and an Objective-C name inventory with one exact instance-method implementation at 0x1000. The graph and native observations share the target digest. The receiver custom classes are BuildViewController, BuildView, BuildButton, and BuildHandler; the connection's selector is buildTapped:. This production MCP integration reproduction does not establish a real-engine run or runtime execution.

Interface

MCP

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

BuildViewController: receiverKind=view_controller, handlers=["0x1000"], dispatchEdges=1, unresolved=0, dispatchCoverage={status:"complete",examined:1,omitted:0}
BuildView: receiverKind=view, handlers=[], dispatchEdges=0, unresolved=0, dispatchCoverage={status:"complete",examined:0,omitted:0}
BuildButton: receiverKind=control, handlers=[], dispatchEdges=0, unresolved=0, dispatchCoverage={status:"complete",examined:0,omitted:0}
BuildHandler: receiverKind=unknown, handlers=[], dispatchEdges=0, unresolved=0, dispatchCoverage={status:"complete",examined:0,omitted:0}

The executed controller control returns a handler and one dispatch edge. The other three iterations return neither, with no unresolved entry and complete dispatch coverage. Evidence provenance is observed, local verification is reproduced, and reproduction completeness is complete.

Typed outcome or recovery expected

Rerun the reproduction and require each receiver case to retain the unique 0x1000 handler and its authored dispatch connection. Keep the existing controller control working. Ambiguous or unavailable implementations must still produce unresolved observations rather than an arbitrary handler; restore joining without weakening identity validation or claiming runtime execution.

Analysis or suspected cause

Source-derived reasoning: src/domain/native/nativeInvestigationGraph.ts:301-323 restricts destination kinds and class matching; skipped destinations do not increment the coverage counters reported at src/domain/native/nativeInvestigationGraph.ts:412-424. The destination filter accepts view_controller, placeholder, and external_object, and its declared-class lookup requires view_controller. The view, control, and unknown classifications therefore bypass the join before it examines their known custom class and matching symbol.

Source-derived reachability: src/server/registerEnhancedTools.ts:190-200 registers the tool, and src/server/registerEnhancedTools.ts:215-249 dispatches admitted requests. src/application/native/NativeUiActionTrace.ts:28-75 validates the graph and matching target identity before invoking the join. The public MCP reproduction independently establishes the resulting omission.

Additional context

  • #3175 concerns keyed UIKit event connectors failing to become typed actions; this report starts with decoded authored action connections and loses the later native-handler join.
  • #2177, closed as completed, concerns keyed UIKit outlet field decoding; this report concerns an ibtool-shaped action receiver's native join after endpoint resolution succeeds.
  • #1503, closed as completed, concerns partial archive-decode coverage being promoted to complete; this report concerns a skipped dispatch join and the separate ui_to_objc_dispatch facet.
  • #2564, closed as completed, concerns optional symbol facts being rejected in name inventories; the inventory here succeeds and the handler is dropped because of receiver classification.
  • #2566, closed as completed, concerns suppression of native seed-resolution errors for an unmatched UI seed; this report has a matched authored action.
  • #2547 concerns hierarchy-completeness contamination between documents after truncation; this report uses one small document within the default bounds.
  • #483 and #480, closed as completed, define the broader UI route-tracing and archive-decoding capabilities; neither reports this receiver-kind restriction.
Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3343 ### 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 The MCP tool `trace_native_ui_action` silently omits a uniquely matched Objective-C handler when the authored receiver is classified as a view, control, or unknown object. It returns no handler or dispatch edge, no unresolved dispatch edge, and complete `ui_to_objc_dispatch` coverage with zero examined and zero omitted. The same connection and symbol facts work when the receiver class name is classified as a view controller. The reproduction ran through production MCP registration, SDK input validation, target admission, the composed workflow, and structured Evidence delivery with a simulated provider. Changing only the receiver class spelling and corresponding exact method symbol changes whether the otherwise equivalent authored connection reaches its implementation. These are simulated producer observations; actual ibtool, Hopper, and Ghidra were not run. ### Reproduction 1. Start from a clean checkout at the baseline and prepare the locked dependencies and runtime: ```sh git clone https://github.com/morluto/rea.git rea-ui-handler-repro cd rea-ui-handler-repro git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063 npm ci npm run build:cached ``` 2. Run the following command from the built checkout root. It supplies the complete ibtool-shaped input document inline to the existing graph decoder and simulates the provider observations; it creates no input files and needs no native engine. The server's environment is explicitly empty, and both MCP endpoints close after each invocation. ```sh node --input-type=module <<'JS' import {createRequire} from 'node:module'; import {pathToFileURL} from 'node:url'; import {resolve} from 'node:path'; const root=process.cwd(), require=createRequire(resolve(root,'package.json')); const load=p=>import(pathToFileURL(resolve(root,p))); const {Client,InMemoryTransport}=await import(pathToFileURL(require.resolve('@modelcontextprotocol/client'))); const {createServer}=await load('dist/server/createServer.js'); const {createAnalysisExecution}=await load('dist/application/AnalysisProvider.js'); const {buildInterfaceBuilderAnalysis,interfaceBuilderLimitsSchema}=await load('dist/domain/apple/interfaceBuilderGraph.js'); const {AnalysisCapabilityUnavailableError}=await load('dist/domain/analysisErrorCore.js'); const {ok,err}=await load('dist/domain/result.js'); const sha256='a'.repeat(64), provider={id:'fixture',name:'Fixture',version:'1'}; const observed=value=>ok(createAnalysisExecution(value,provider,{subject:{sha256,path:'/fixtures/Sample.app',format:'mach-o',architecture:'arm64'}})); for(const className of ['BuildViewController','BuildView','BuildButton','BuildHandler']) { const ui=buildInterfaceBuilderAnalysis({targetSha256:sha256,toolVersion:'fixture',limits:interfaceBuilderLimitsSchema.parse({}),documents:[{ relativePath:'Main.nib',archiveSha256:'b'.repeat(64),documentKind:'nib',raw:{ 'com.apple.ibtool.document.objects':{receiver:{customClass:className},button:{class:'UIButton'}}, 'com.apple.ibtool.document.connections':{button:[{type:'action',label:'buildTapped:',destinationId:'receiver'}]} } }]}); const analysis={execute:async(operation)=>{ if(operation==='decode_interface_builder') return observed(ui); if(operation==='inspect_native_dispatch_metadata'||operation==='procedure_references') return err(new AnalysisCapabilityUnavailableError('fixture',operation,'Unavailable in fixture')); if(operation==='list_names') return observed([{address:'0x1000',name:`-[${className} buildTapped:]`}]); if(operation==='procedure_callees') return observed([]); throw new Error(`Unexpected operation: ${operation}`); }}; const server=createServer({kind:'fixed',analysis},{environment:{}}); const client=new Client({name:'receiver-reproduction',version:'1'}); const [clientTransport,serverTransport]=InMemoryTransport.createLinkedPair(); try { await server.connect(serverTransport); await client.connect(clientTransport); const result=await client.callTool({name:'trace_native_ui_action',arguments:{action:'buildTapped:'}}); if(result.isError) throw new Error(JSON.stringify(result)); const envelope=result.structuredContent??JSON.parse(result.content.find(c=>c.type==='text').text); const trace=envelope.normalized_result; console.log(JSON.stringify({className,receiverKind:ui.graph.nodes.find(n=>n.attributes.interface_builder_object_id==='receiver').kind,handlers:trace.nodes.filter(n=>n.kind==='function').map(n=>n.location.address),dispatchEdges:trace.edges.filter(e=>e.relation==='objc_dispatch').length,unresolved:trace.unresolved.length,dispatchCoverage:trace.coverage.find(c=>c.facet==='ui_to_objc_dispatch')})); } finally { await client.close(); await server.close(); } } JS ``` 3. Compare the controller case with the view, control, and unknown receiver cases in the output. Each iteration calls the public MCP tool with `{"name":"trace_native_ui_action","arguments":{"action":"buildTapped:"}}`. The authored connection selects one receiver with a known custom class, and the provider name inventory contains exactly one matching instance method at `0x1000`. The supplied Interface Builder graph and native observations carry the same target digest. Default traversal bounds are sufficient for this small graph. The reported reproduction used an existing compiled runtime and installed locked dependencies. The clean-checkout installation and build commands above are preparation steps, not additional measured results. ### Expected behavior All four receiver classifications should retain the known handler at `0x1000` and its dispatch connection, with provenance appropriate to the symbol observation. A receiver's presentation kind must not suppress an authored connection to a uniquely matched class/selector implementation. If any form is unsupported, the trace must preserve an explicit unresolved edge and coverage limitation rather than claim that nothing was examined or omitted. `docs/native-investigation.md:210-217` promises joining UI wiring to uniquely matched class/selector implementations and preserving unavailable forms as unknown. `src/contracts/enhancedToolContracts.ts:80-82` describes traversal through authored connections and encoded or symbolized Objective-C handlers without limiting the receiver to a controller. ### Impact and scope The supported impact assessment is P2. A supported static UI trace loses a known handler and its downstream analysis path for bounded receiver kinds, while its coverage conceals the omission. The integrated reproduction establishes the composed workflow defect; real native-provider occurrence was not measured. Workaround status is unverified. The documented native-symbol seed can inspect a handler separately, but was not exercised in this reproduction and does not preserve the missing authored UI join. No workaround restoring that connection was demonstrated. ### REA version 6.4.0 (main at 8dde6370d0809ad04cda3342d4e6f290f48aa063) ### Runtime and platform Node.js v26.10.0; macOS 27.0.1 arm64. The source checkout had an existing compiled runtime and installed locked dependencies. ### Target and artifact context A simulated Mach-O arm64 app subject at `/fixtures/Sample.app`, an inline ibtool-shaped `Main.nib` document, and an Objective-C name inventory with one exact instance-method implementation at `0x1000`. The graph and native observations share the target digest. The receiver custom classes are `BuildViewController`, `BuildView`, `BuildButton`, and `BuildHandler`; the connection's selector is `buildTapped:`. This production MCP integration reproduction does not establish a real-engine run or runtime execution. ### Interface MCP ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence ```text BuildViewController: receiverKind=view_controller, handlers=["0x1000"], dispatchEdges=1, unresolved=0, dispatchCoverage={status:"complete",examined:1,omitted:0} BuildView: receiverKind=view, handlers=[], dispatchEdges=0, unresolved=0, dispatchCoverage={status:"complete",examined:0,omitted:0} BuildButton: receiverKind=control, handlers=[], dispatchEdges=0, unresolved=0, dispatchCoverage={status:"complete",examined:0,omitted:0} BuildHandler: receiverKind=unknown, handlers=[], dispatchEdges=0, unresolved=0, dispatchCoverage={status:"complete",examined:0,omitted:0} ``` The executed controller control returns a handler and one dispatch edge. The other three iterations return neither, with no unresolved entry and complete dispatch coverage. Evidence provenance is observed, local verification is reproduced, and reproduction completeness is complete. ### Typed outcome or recovery expected Rerun the reproduction and require each receiver case to retain the unique `0x1000` handler and its authored dispatch connection. Keep the existing controller control working. Ambiguous or unavailable implementations must still produce unresolved observations rather than an arbitrary handler; restore joining without weakening identity validation or claiming runtime execution. ### Analysis or suspected cause Source-derived reasoning: `src/domain/native/nativeInvestigationGraph.ts:301-323` restricts destination kinds and class matching; skipped destinations do not increment the coverage counters reported at `src/domain/native/nativeInvestigationGraph.ts:412-424`. The destination filter accepts `view_controller`, `placeholder`, and `external_object`, and its declared-class lookup requires `view_controller`. The view, control, and unknown classifications therefore bypass the join before it examines their known custom class and matching symbol. Source-derived reachability: `src/server/registerEnhancedTools.ts:190-200` registers the tool, and `src/server/registerEnhancedTools.ts:215-249` dispatches admitted requests. `src/application/native/NativeUiActionTrace.ts:28-75` validates the graph and matching target identity before invoking the join. The public MCP reproduction independently establishes the resulting omission. ### Additional context - [#3175](https://github.com/morluto/rea/issues/3175) concerns keyed UIKit event connectors failing to become typed actions; this report starts with decoded authored action connections and loses the later native-handler join. - [#2177](https://github.com/morluto/rea/issues/2177), closed as completed, concerns keyed UIKit outlet field decoding; this report concerns an ibtool-shaped action receiver's native join after endpoint resolution succeeds. - [#1503](https://github.com/morluto/rea/issues/1503), closed as completed, concerns partial archive-decode coverage being promoted to complete; this report concerns a skipped dispatch join and the separate `ui_to_objc_dispatch` facet. - [#2564](https://github.com/morluto/rea/issues/2564), closed as completed, concerns optional symbol facts being rejected in name inventories; the inventory here succeeds and the handler is dropped because of receiver classification. - [#2566](https://github.com/morluto/rea/issues/2566), closed as completed, concerns suppression of native seed-resolution errors for an unmatched UI seed; this report has a matched authored action. - [#2547](https://github.com/morluto/rea/issues/2547) concerns hierarchy-completeness contamination between documents after truncation; this report uses one small document within the default bounds. - [#483](https://github.com/morluto/rea/issues/483) and [#480](https://github.com/morluto/rea/issues/480), closed as completed, define the broader UI route-tracing and archive-decoding capabilities; neither reports this receiver-kind restriction.
Author
Owner

@dayvison2b commented on GitHub (Oct 15, 2026):

I’d like to take this one. I’ll dig in and open a PR once I have a fix.

<!-- gh-comment-id:6183668123 --> @dayvison2b commented on GitHub (Oct 15, 2026): I’d like to take this one. I’ll dig in and open a PR once I have a fix.
Author
Owner

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

Checked the reported fix on main at d87e73f2db.

Dispatch join iterates authored action nodes and resolves the observed receiver edge without filtering receiver kind to view_controller. It takes declared class from any resolved non-placeholder receiver, so views, controls and unknown-kind custom objects can match metadata. Missing receivers become explicit unresolved dispatch edges counted in coverage.

Implementation: nativeInvestigationGraph.ts, nativeInvestigationGraph.ts.

The relevant regression checks in nativeInvestigationGraph.test.ts passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.

<!-- gh-comment-id:6380770001 --> @coygeek commented on GitHub (Oct 24, 2026): Checked the reported fix on `main` at d87e73f2db50be72d33a4b3c7199af35518b441c. Dispatch join iterates authored action nodes and resolves the observed receiver edge without filtering receiver kind to view_controller. It takes declared class from any resolved non-placeholder receiver, so views, controls and unknown-kind custom objects can match metadata. Missing receivers become explicit unresolved dispatch edges counted in coverage. Implementation: [nativeInvestigationGraph.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/domain/native/nativeInvestigationGraph.ts#L303), [nativeInvestigationGraph.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/domain/native/nativeInvestigationGraph.ts#L340). The relevant regression checks in [nativeInvestigationGraph.test.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/domain/native/nativeInvestigationGraph.test.ts) passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.
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#1211
No description provided.