[GH-ISSUE #5893] [Bug] UI dispatch coverage reports complete when an action destination is unresolved #3572

Closed
opened 2026-10-31 00:41:59 +00:00 by bsakaguchi · 1 comment
Owner

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

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 reports ui_to_objc_dispatch coverage as complete, with zero examined and zero omitted, when its single authored action has a selector but its destination object is missing. The same action with its receiver present produces one examined dispatch and a handler edge.

The original unresolved destination remains in trace.unresolved with reason destination_object_missing_or_external. The defect is that the dispatch coverage facet does not account for that action, even though its mapping remains unresolved.

Reproduction

From the baseline checkout with locked dependencies installed and the runtime already built, run node --input-type=module with the following script as standard input. This exact script was executed once, making one production MCP request per case. It uses the actual Interface Builder graph builder, public MCP registration, SDK transport and tool workflow, with simulated provider observations. It does not invoke ibtool, Hopper, Ghidra or target code.

import assert from 'node:assert/strict';
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { createServer } from './dist/server/createServer.js';
import { createAnalysisExecution } from './dist/application/AnalysisProvider.js';
import { buildInterfaceBuilderAnalysis, interfaceBuilderLimitsSchema } from './dist/domain/apple/interfaceBuilderGraph.js';
import { AnalysisCapabilityUnavailableError } from './dist/domain/analysisErrorCore.js';
import { ok, err } from './dist/domain/result.js';

const sha256 = 'a'.repeat(64);
const provider = { id: 'fixture', name: 'Fixture', version: '1' };
const observed = result => ok(createAnalysisExecution(result, provider, {
  subject: { sha256, path: '/fixtures/Example.app', format: 'mach-o', architecture: 'arm64' },
}));
for (const receiverPresent of [true, false]) {
  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': {
          button: { class: 'UIButton' },
          ...(receiverPresent ? { receiver: { customClass: 'BuildViewController' } } : {}),
        },
        'com.apple.ibtool.document.connections': {
          button: [{ type: 'action', label: 'buildTapped:', destinationId: 'receiver' }],
        },
      },
    }],
  });
  assert.equal(ui.graph.nodes.filter(n => n.kind === 'action').length, 1);
  const analysis = { execute: async operation => {
    if (operation === 'decode_interface_builder') return observed(ui);
    if (operation === 'list_names') return observed([{ address: '0x1000', name: '-[BuildViewController buildTapped:]' }]);
    if (operation === 'inspect_native_dispatch_metadata' || operation === 'procedure_references')
      return err(new AnalysisCapabilityUnavailableError('fixture', operation, 'Unavailable in fixture'));
    if (operation === 'procedure_callees') return observed([]);
    throw new Error(`Unexpected operation: ${operation}`);
  }};
  const server = createServer({ kind: 'fixed', analysis }, { environment: {} });
  const client = new Client({ name: 'dispatch-coverage-example', 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:' } });
    assert.notEqual(result.isError, true);
    const trace = result.structuredContent.normalized_result;
    const coverage = trace.coverage.find(c => c.facet === 'ui_to_objc_dispatch');
    const unresolvedDestinations = trace.unresolved.filter(e => e.reason === 'destination_object_missing_or_external').length;
    assert.equal(coverage.status, 'complete');
    assert.equal(coverage.examined, receiverPresent ? 1 : 0);
    assert.equal(coverage.omitted, 0);
    assert.equal(unresolvedDestinations, receiverPresent ? 0 : 1);
    console.log(JSON.stringify({ receiverPresent, coverage,
      objcDispatchEdges: trace.edges.filter(e => e.relation === 'objc_dispatch').length,
      unresolvedDestinations,
      connectionCoverage: trace.coverage.filter(c => c.facet.startsWith('connections:')),
    }));
  } finally {
    await client.close();
    await server.close();
  }
}

The assertions describe the currently observed behavior. Both cases contain one action with selector buildTapped: and the same native method inventory. Only the presence of the receiver object differs.

Expected behavior

An authored action with an unresolved receiver should contribute an explicit limitation to ui_to_objc_dispatch coverage. It should report partial coverage with the unresolved mapping accounted for, rather than complete coverage with zero examined and zero omitted. It need not infer a handler from a selector when the receiver is unavailable.

The coverage schema explains that coverage prevents missing graph parts from being read as absent. The tool-design contract requires completeness to follow examined records and exhaustiveness. The static trace documentation describes authored UI dispatch joins and preserving unavailable forms as unknown.

Impact and scope

P2: a caller relying on this facet gets a misleading completeness signal for a bounded input condition. The missing receiver prevents a justified dispatch mapping, but the facet makes no allowance for it. The receiver-present control demonstrates that the same authored action is normally within the dispatch facet's scope.

A demonstrated manual workaround is to inspect trace.unresolved and the retained action/selector evidence: the missing-destination reason survives in the public result. Real-application frequency and real-provider behavior were not measured. No target files are changed and no target code executes.

REA version

rea-agents 6.7.0, source revision 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

Node.js v26.12.0; macOS 27.0.1 arm64. Existing built runtime and installed locked dependencies; simulated native providers.

Target and artifact context

One inline ibtool-shaped Main.nib document, one button action named buildTapped:, and either a present or absent BuildViewController receiver. The simulated Mach-O arm64 subject and UI graph share the same target digest. The native symbol inventory contains one instance method at 0x1000.

Interface

MCP

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Exact output from the two executed MCP calls:

{"receiverPresent":true,"coverage":{"facet":"ui_to_objc_dispatch","status":"complete","reason":null,"examined":1,"omitted":0},"objcDispatchEdges":1,"unresolvedDestinations":0,"connectionCoverage":[{"facet":"connections:Main.nib","status":"complete","reason":null,"examined":1,"omitted":0}]}
{"receiverPresent":false,"coverage":{"facet":"ui_to_objc_dispatch","status":"complete","reason":null,"examined":0,"omitted":0},"objcDispatchEdges":0,"unresolvedDestinations":1,"connectionCoverage":[{"facet":"connections:Main.nib","status":"complete","reason":null,"examined":1,"omitted":0}]}

In the failing case the one unresolved destination is retained, but the dispatch facet reports complete and omitted: 0. Per-document connection coverage also reports complete because the original action connection is retained; that facet is distinct from mapping it to a native implementation.

Typed outcome or recovery expected

Keep the original unresolved destination and its provenance. Account for this authored action in partial dispatch coverage, with a meaningful unresolved or omission reason. Keep the receiver-present control working with one dispatch edge. These are proposed restoration checks; no fix was applied.

Analysis or suspected cause

Source-derived: the action producer creates the selector and an unresolved destination edge. The dispatch join searches only observed receiver edges and continues before incrementing examined when none exists. Its coverage calculation therefore sees zero examined and zero resolved and reports complete coverage with zero omissions.

The shared trace workflow invokes this join after input graph and target-identity validation, then includes its coverage in the returned trace. The MCP execution above establishes public reachability independently of these source references.

Additional context

Related #3343 and PR #3828 concern present receivers classified as view, control or unknown. This report is limited to an absent receiver object. The PR's observed-destination requirement and early continue remain, so its receiver-kind expansion does not resolve this case.

Other distinct reports concern archive object admission (#3958), binding connector admission (#5590), segue-template admission (#5695), outlet-collection endpoint interpretation (#5692), ownerless-connector accounting (PR #2484), and archive-decode coverage propagation (PR #1513). Here the action and its unresolved connection already exist and survive in the trace.

A successful fetch of upstream main at c324d31b45fcab77e4ed7320ed60f0bf5a4ba188 showed no changes from the baseline in the graph join, Interface Builder graph builder or UI trace workflow. Current-main persistence is source-derived; runtime results above are from the baseline checkout. The first affected version is unknown.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5893 ### 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` reports `ui_to_objc_dispatch` coverage as `complete`, with zero examined and zero omitted, when its single authored action has a selector but its destination object is missing. The same action with its receiver present produces one examined dispatch and a handler edge. The original unresolved destination remains in `trace.unresolved` with reason `destination_object_missing_or_external`. The defect is that the dispatch coverage facet does not account for that action, even though its mapping remains unresolved. ### Reproduction From the baseline checkout with locked dependencies installed and the runtime already built, run `node --input-type=module` with the following script as standard input. This exact script was executed once, making one production MCP request per case. It uses the actual Interface Builder graph builder, public MCP registration, SDK transport and tool workflow, with simulated provider observations. It does not invoke ibtool, Hopper, Ghidra or target code. ```js import assert from 'node:assert/strict'; import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import { createServer } from './dist/server/createServer.js'; import { createAnalysisExecution } from './dist/application/AnalysisProvider.js'; import { buildInterfaceBuilderAnalysis, interfaceBuilderLimitsSchema } from './dist/domain/apple/interfaceBuilderGraph.js'; import { AnalysisCapabilityUnavailableError } from './dist/domain/analysisErrorCore.js'; import { ok, err } from './dist/domain/result.js'; const sha256 = 'a'.repeat(64); const provider = { id: 'fixture', name: 'Fixture', version: '1' }; const observed = result => ok(createAnalysisExecution(result, provider, { subject: { sha256, path: '/fixtures/Example.app', format: 'mach-o', architecture: 'arm64' }, })); for (const receiverPresent of [true, false]) { 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': { button: { class: 'UIButton' }, ...(receiverPresent ? { receiver: { customClass: 'BuildViewController' } } : {}), }, 'com.apple.ibtool.document.connections': { button: [{ type: 'action', label: 'buildTapped:', destinationId: 'receiver' }], }, }, }], }); assert.equal(ui.graph.nodes.filter(n => n.kind === 'action').length, 1); const analysis = { execute: async operation => { if (operation === 'decode_interface_builder') return observed(ui); if (operation === 'list_names') return observed([{ address: '0x1000', name: '-[BuildViewController buildTapped:]' }]); if (operation === 'inspect_native_dispatch_metadata' || operation === 'procedure_references') return err(new AnalysisCapabilityUnavailableError('fixture', operation, 'Unavailable in fixture')); if (operation === 'procedure_callees') return observed([]); throw new Error(`Unexpected operation: ${operation}`); }}; const server = createServer({ kind: 'fixed', analysis }, { environment: {} }); const client = new Client({ name: 'dispatch-coverage-example', 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:' } }); assert.notEqual(result.isError, true); const trace = result.structuredContent.normalized_result; const coverage = trace.coverage.find(c => c.facet === 'ui_to_objc_dispatch'); const unresolvedDestinations = trace.unresolved.filter(e => e.reason === 'destination_object_missing_or_external').length; assert.equal(coverage.status, 'complete'); assert.equal(coverage.examined, receiverPresent ? 1 : 0); assert.equal(coverage.omitted, 0); assert.equal(unresolvedDestinations, receiverPresent ? 0 : 1); console.log(JSON.stringify({ receiverPresent, coverage, objcDispatchEdges: trace.edges.filter(e => e.relation === 'objc_dispatch').length, unresolvedDestinations, connectionCoverage: trace.coverage.filter(c => c.facet.startsWith('connections:')), })); } finally { await client.close(); await server.close(); } } ``` The assertions describe the currently observed behavior. Both cases contain one action with selector `buildTapped:` and the same native method inventory. Only the presence of the receiver object differs. ### Expected behavior An authored action with an unresolved receiver should contribute an explicit limitation to `ui_to_objc_dispatch` coverage. It should report partial coverage with the unresolved mapping accounted for, rather than complete coverage with zero examined and zero omitted. It need not infer a handler from a selector when the receiver is unavailable. The [coverage schema](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/native/nativeInvestigationGraph.ts#L100-L108) explains that coverage prevents missing graph parts from being read as absent. The [tool-design contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L172-L175) requires completeness to follow examined records and exhaustiveness. The [static trace documentation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/native-investigation.md#L210-L217) describes authored UI dispatch joins and preserving unavailable forms as unknown. ### Impact and scope P2: a caller relying on this facet gets a misleading completeness signal for a bounded input condition. The missing receiver prevents a justified dispatch mapping, but the facet makes no allowance for it. The receiver-present control demonstrates that the same authored action is normally within the dispatch facet's scope. A demonstrated manual workaround is to inspect `trace.unresolved` and the retained action/selector evidence: the missing-destination reason survives in the public result. Real-application frequency and real-provider behavior were not measured. No target files are changed and no target code executes. ### REA version `rea-agents` 6.7.0, source revision `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform Node.js v26.12.0; macOS 27.0.1 arm64. Existing built runtime and installed locked dependencies; simulated native providers. ### Target and artifact context One inline ibtool-shaped `Main.nib` document, one button action named `buildTapped:`, and either a present or absent `BuildViewController` receiver. The simulated Mach-O arm64 subject and UI graph share the same target digest. The native symbol inventory contains one instance method at `0x1000`. ### Interface MCP ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Exact output from the two executed MCP calls: ```jsonl {"receiverPresent":true,"coverage":{"facet":"ui_to_objc_dispatch","status":"complete","reason":null,"examined":1,"omitted":0},"objcDispatchEdges":1,"unresolvedDestinations":0,"connectionCoverage":[{"facet":"connections:Main.nib","status":"complete","reason":null,"examined":1,"omitted":0}]} {"receiverPresent":false,"coverage":{"facet":"ui_to_objc_dispatch","status":"complete","reason":null,"examined":0,"omitted":0},"objcDispatchEdges":0,"unresolvedDestinations":1,"connectionCoverage":[{"facet":"connections:Main.nib","status":"complete","reason":null,"examined":1,"omitted":0}]} ``` In the failing case the one unresolved destination is retained, but the dispatch facet reports `complete` and `omitted: 0`. Per-document connection coverage also reports complete because the original action connection is retained; that facet is distinct from mapping it to a native implementation. ### Typed outcome or recovery expected Keep the original unresolved destination and its provenance. Account for this authored action in partial dispatch coverage, with a meaningful unresolved or omission reason. Keep the receiver-present control working with one dispatch edge. These are proposed restoration checks; no fix was applied. ### Analysis or suspected cause Source-derived: the [action producer](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/apple/interfaceBuilderGraph.ts#L327-L377) creates the selector and an unresolved destination edge. The [dispatch join](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/native/nativeInvestigationGraph.ts#L303-L324) searches only observed receiver edges and continues before incrementing `examined` when none exists. Its [coverage calculation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/native/nativeInvestigationGraph.ts#L417-L432) therefore sees zero examined and zero resolved and reports complete coverage with zero omissions. The [shared trace workflow](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/native/NativeUiActionTrace.ts#L64-L86) invokes this join after input graph and target-identity validation, then [includes its coverage in the returned trace](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/native/NativeUiActionTrace.ts#L230-L249). The MCP execution above establishes public reachability independently of these source references. ### Additional context Related #3343 and PR #3828 concern present receivers classified as view, control or unknown. This report is limited to an absent receiver object. The PR's observed-destination requirement and early continue remain, so its receiver-kind expansion does not resolve this case. Other distinct reports concern archive object admission (#3958), binding connector admission (#5590), segue-template admission (#5695), outlet-collection endpoint interpretation (#5692), ownerless-connector accounting (PR #2484), and archive-decode coverage propagation (PR #1513). Here the action and its unresolved connection already exist and survive in the trace. A successful fetch of upstream main at `c324d31b45fcab77e4ed7320ed60f0bf5a4ba188` showed no changes from the baseline in the graph join, Interface Builder graph builder or UI trace workflow. Current-main persistence is source-derived; runtime results above are from the baseline checkout. The first affected version is unknown.
Author
Owner

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

Source at d87e73f2db50be72d33a4b3c7199af35518b441c now counts the authored action before receiver lookup and records an unresolved dispatch. The missing-receiver regression asserts partial, examined: 1, omitted: 1; a receiver-present control retains its handler edge.

I ran the cited regression file on this revision; all passed as part of the focused 28-file, 603-test run. Provider-facing checks here use controlled fixtures; this does not claim a run with the real analysis engine. Closing as completed on main.

<!-- gh-comment-id:6380649230 --> @coygeek commented on GitHub (Oct 24, 2026): Source at `d87e73f2db50be72d33a4b3c7199af35518b441c` now [counts the authored action before receiver lookup and records an unresolved dispatch](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/domain/native/nativeInvestigationGraph.ts#L303-L338). The [missing-receiver regression](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/domain/native/nativeInvestigationGraph.test.ts#L411-L457) asserts `partial`, `examined: 1`, `omitted: 1`; a [receiver-present control](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/domain/native/nativeInvestigationGraph.test.ts#L259-L333) retains its handler edge. I ran the cited regression file on this revision; all passed as part of the focused 28-file, 603-test run. Provider-facing checks here use controlled fixtures; this does not claim a run with the real analysis engine. 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#3572
No description provided.