mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-05 08:53:34 +08:00
Implements the design from `docs/design/mixed-ios-and-react-native-bridging.md`.
Closes the cross-language flow gap so `trace` / `callers` / `callees` / `impact` connect end-to-end across language boundaries in real iOS, React Native, and Expo codebases.
## Bridges shipped
| Boundary | Mechanism | Real-codebase validation |
|---|---|---|
| **Swift ↔ Objective-C** | Resolver applying Apple's @objc auto-bridging name math + Cocoa preposition prefixes | Charts (S, 269) · realm-swift (M, 369) · wikipedia-ios (L, 1734) |
| **React Native legacy bridge** | Resolver parsing `RCT_EXPORT_MODULE` / `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` (ObjC) + `@ReactMethod` (Java/Kotlin) | AsyncStorage (S, ~60) · react-native-svg (M, ~700) · react-native-firebase (L, ~1100) |
| **React Native TurboModules** | Resolver treating `Native<X>.ts` spec interface as ground truth | via RNSvg + RNFirebase subsets |
| **Native → JS events** | Synthesizer matching native `sendEventWithName:`/`emit(...)` to JS `addListener('e', handler)` keyed by literal event name; falls back to enclosing constant/variable for wrapper-API parameter handlers | RNGeolocation (S) · RNFirebase (L) |
| **Expo Modules** | Framework extract synthesizes `method` nodes from Swift/Kotlin `Module { Name("X"); Function("y") { ... } }` DSL | expo-haptics (S, 14) · expo-camera (M, 72) · ExpoSweep (L, 332, 7 packages) |
| **Fabric + legacy Paper view components** | Extract `component` + `property` nodes from Codegen `codegenNativeComponent<Props>('Name', ...)` specs AND legacy `RCT_EXPORT_VIEW_PROPERTY` / `@ReactProp` macros, then synthesize component → native class by name+suffix convention | react-native-segmented-control (S, legacy) · react-native-screens (M, Codegen) · react-native-skia (L, hybrid monorepo) |
## Bug fixes surfaced along the way
- `tree-sitter.ts` message_expression — multi-keyword ObjC call sites now reconstruct `a🅱️` selectors so they resolve to multi-part method definitions (gap discovered post-#165; 0 → 84 call edges to `GET:parameters:...` style methods on AFNetworking).
- `src/index.ts` resolver lifecycle — `indexAll()` now re-initializes the resolver after extraction so framework `detect()` sees the populated index. Pre-existing latent bug that affected UIKit and SwiftUI resolvers too.
- `src/extraction/index.ts` `buildDetectionContext` — added `listDirectories` so framework detect() can probe monorepo subpackages uniformly (fix needed for react-native-skia detection).
## Regression check on 5 control repos
| Repo | Result |
|---|---|
| Express (small JS) | ✅ unchanged — 266 routes, express framework detected |
| Excalidraw (medium TS/React) | ✅ 9284 nodes (CLAUDE.md baseline ~9290); canonical `trace(mutateElement, renderStaticScene)` returns the flow |
| Django realworld (Python) | ✅ django framework detected, 16 routes |
| Spring petclinic (Java) | ✅ spring framework detected, 17 routes |
| Texture (pure ObjC, large) | ✅ exactly matches #165 baseline: 4702 methods, 894 classes, 808/808 file coverage, 913 multi-keyword selectors, 55 protocols, 1036 properties |
## Tests
928 passing (+87 net new bridge tests across the 5 channels); 2 pre-existing skips. The mcp-staleness-banner / watcher parallel flakiness is unchanged by this work (different test fails each run, all pass in isolation; pre-existing on main).
## Documentation
- README: new 'Mixed iOS / React Native / Expo bridging' section with the per-boundary table and validation-corpus links.
- CHANGELOG `[Unreleased]`: full entry per bridge with measurements.
- `docs/design/mixed-ios-and-react-native-bridging.md`: the design doc (§8 measurements filled in across §8a-§8g).
- `docs/design/dynamic-dispatch-coverage-playbook.md` §6 coverage matrix: six new rows.
- `.claude/skills/agent-eval/corpus.json`: four new sections covering 15 real GitHub repos for the eval harness.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
/**
|
|
* Swift ↔ Objective-C bridge resolver.
|
|
*
|
|
* Closes the cross-language flow gap in mixed iOS codebases. The pure
|
|
* bridging name math lives in `../swift-objc-bridge.ts`; this file wires
|
|
* it into the resolution pipeline.
|
|
*
|
|
* **Two directions to close:**
|
|
*
|
|
* 1. **Swift call → ObjC method** — A Swift caller writes
|
|
* `imageDownloader.download(url:completion:)`. Tree-sitter-swift parses
|
|
* this as a call_expression whose callee identifier is `download`
|
|
* (parameter labels live in the argument list, not the callee). The
|
|
* name-matcher tries to find any node named `download` and fails (no
|
|
* Swift method by that name in this project; the ObjC implementation is
|
|
* `-downloadURL:completion:`). We catch it here: from the bare Swift
|
|
* name `download`, look up ObjC methods whose bridged Swift base name
|
|
* would be `download` (using `swiftBaseNamesForObjcSelector`'s reverse
|
|
* map, precomputed once per session).
|
|
*
|
|
* 2. **ObjC call → Swift method** — An ObjC caller writes
|
|
* `[swiftThing fooWithBar:42]`. Tree-sitter-objc parses this as a
|
|
* message_expression with selector `fooWithBar:` (after the multi-
|
|
* keyword fix in this branch). The name-matcher tries to find a node
|
|
* named `fooWithBar:` — no Swift node has colons in its name, so it
|
|
* fails. We catch it: from the ObjC selector, derive candidate Swift
|
|
* base names (`['fooWithBar', 'foo']`), and look up Swift methods
|
|
* named those.
|
|
*
|
|
* **Provenance:** every edge produced here is recorded as a framework-
|
|
* resolved reference (`resolvedBy: 'framework'`) with `confidence: 0.7`
|
|
* (matches the django ORM dynamic-dispatch precedent — not exact, but
|
|
* deterministic from the bridging rule).
|
|
*/
|
|
import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
|
|
import type { Node } from '../../types';
|
|
import {
|
|
swiftBaseNamesForObjcSelector,
|
|
isObjcExposed,
|
|
} from '../swift-objc-bridge';
|
|
|
|
/**
|
|
* Memoized "Swift base name → ObjC method nodes" map.
|
|
*
|
|
* Built lazily on first `resolve()` per resolver instance — the resolver is
|
|
* recreated when the index is rebuilt, so this naturally invalidates with
|
|
* the graph. Keyed by ResolutionContext identity so multiple projects sharing
|
|
* a process (the daemon) don't bleed maps between them.
|
|
*/
|
|
const objcByCandidateSwiftBase: WeakMap<
|
|
ResolutionContext,
|
|
Map<string, Node[]>
|
|
> = new WeakMap();
|
|
|
|
/**
|
|
* Build the reverse-bridge map: for every ObjC method node in the graph,
|
|
* compute the Swift base names that would auto-bridge to its selector and
|
|
* record the node under each.
|
|
*
|
|
* Runs once per resolver lifetime; the cost scales linearly with the count
|
|
* of ObjC method nodes. On Wikipedia-iOS (~2500 files, ~25k ObjC methods)
|
|
* this is a few hundred ms — much cheaper than re-parsing source on each
|
|
* unresolved ref.
|
|
*/
|
|
/**
|
|
* Names that are too generic to bridge with any precision. These are common
|
|
* Cocoa / NSObject conventions that almost every ObjC class implements; if a
|
|
* Swift caller writes `init()` or `description`, mapping it to an arbitrary
|
|
* project-local ObjC method of the same name produces noise, not signal.
|
|
*
|
|
* Critically, refs of these names virtually always resolve via the regular
|
|
* name-matcher (every project has many `init` nodes) — skipping them here
|
|
* just keeps the bridge from competing with name-match on already-handled
|
|
* refs.
|
|
*/
|
|
const GENERIC_NAMES = new Set([
|
|
'init',
|
|
'description',
|
|
'debugDescription',
|
|
'hash',
|
|
'isEqual',
|
|
'isEqualTo',
|
|
'copy',
|
|
'mutableCopy',
|
|
'class',
|
|
'self',
|
|
'count',
|
|
'length',
|
|
'value',
|
|
'name',
|
|
'data',
|
|
'string',
|
|
'object',
|
|
'add',
|
|
'remove',
|
|
'update',
|
|
'load',
|
|
'save',
|
|
'reload',
|
|
'cancel',
|
|
'start',
|
|
'stop',
|
|
'pause',
|
|
'resume',
|
|
'close',
|
|
'open',
|
|
'show',
|
|
'hide',
|
|
'toString',
|
|
'dealloc',
|
|
'release',
|
|
'retain',
|
|
'autorelease',
|
|
]);
|
|
|
|
function buildObjcMap(context: ResolutionContext): Map<string, Node[]> {
|
|
const cached = objcByCandidateSwiftBase.get(context);
|
|
if (cached) return cached;
|
|
|
|
const map = new Map<string, Node[]>();
|
|
const objcMethods = context
|
|
.getNodesByKind('method')
|
|
.filter((n) => n.language === 'objc');
|
|
for (const node of objcMethods) {
|
|
const candidates = swiftBaseNamesForObjcSelector(node.name);
|
|
for (const c of candidates) {
|
|
// Skip the trivial case where the Swift base name equals the ObjC
|
|
// method name verbatim (no colons) — the regular name-matcher
|
|
// already handles that and our map would just duplicate the work.
|
|
if (c === node.name && !node.name.includes(':')) continue;
|
|
// Skip generic Cocoa names (init, description, etc.) — they would
|
|
// false-positive against any project-local ObjC method of the same
|
|
// name. The regular name-matcher handles them.
|
|
if (GENERIC_NAMES.has(c)) continue;
|
|
const arr = map.get(c);
|
|
if (arr) arr.push(node);
|
|
else map.set(c, [node]);
|
|
}
|
|
}
|
|
objcByCandidateSwiftBase.set(context, map);
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Window of source text around a Swift declaration used by `isObjcExposed`
|
|
* to spot `@objc` / `@nonobjc` annotations. Read line above + the
|
|
* declaration line — Swift attributes typically sit on the preceding line
|
|
* (`@objc` on a line of its own) or inline.
|
|
*/
|
|
const SOURCE_PROBE_LINES = 3;
|
|
|
|
/**
|
|
* Read a small window of source ending at `node.startLine`, used to
|
|
* inspect Swift attribute annotations attached to a declaration. Returns
|
|
* an empty string if the source can't be read.
|
|
*/
|
|
function declarationSourceWindow(node: Node, context: ResolutionContext): string {
|
|
const content = context.readFile(node.filePath);
|
|
if (!content) return '';
|
|
const lines = content.split(/\r?\n/);
|
|
const startIdx = Math.max(0, node.startLine - 1 - SOURCE_PROBE_LINES);
|
|
const endIdx = Math.min(lines.length, node.startLine);
|
|
return lines.slice(startIdx, endIdx).join('\n');
|
|
}
|
|
|
|
/**
|
|
* Try to resolve a Swift caller's bare reference to an ObjC implementation.
|
|
*
|
|
* Strategy: look up the ObjC reverse-bridge map for nodes whose Swift base
|
|
* name would match. Return the first match (matches the existing
|
|
* single-target resolution contract).
|
|
*/
|
|
function resolveSwiftCallToObjc(
|
|
ref: UnresolvedRef,
|
|
context: ResolutionContext
|
|
): ResolvedRef | null {
|
|
// Swift call sites of `obj.foo(bar:)` reach the resolver as either bare
|
|
// name `foo` (tree-sitter-swift) or qualified `obj.foo` — strip prefix.
|
|
const rawName = ref.referenceName.includes('.')
|
|
? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
|
|
: ref.referenceName;
|
|
|
|
const map = buildObjcMap(context);
|
|
const candidates = map.get(rawName);
|
|
if (!candidates || candidates.length === 0) return null;
|
|
|
|
// Prefer ObjC methods whose corresponding Swift declaration isn't itself
|
|
// present (so we don't wrongly redirect a Swift call to ObjC when a Swift
|
|
// method of the same name is the real target — that's the in-language case
|
|
// and should already be resolved by the name-matcher). Since this resolver
|
|
// runs AFTER exact-match, any matching Swift node would already have won;
|
|
// so a candidate reaching us is a legitimate cross-language hit.
|
|
const target = candidates[0];
|
|
if (!target) return null;
|
|
return {
|
|
original: ref,
|
|
targetNodeId: target.id,
|
|
confidence: 0.6,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Try to resolve an ObjC caller's selector reference to a Swift `@objc`
|
|
* implementation.
|
|
*
|
|
* Strategy: derive candidate Swift base names from the selector via
|
|
* `swiftBaseNamesForObjcSelector`. For each, look up Swift methods named
|
|
* that and verify with a source-window check that the declaration is
|
|
* `@objc`-exposed (filters out false matches where a Swift function
|
|
* happens to share the name but isn't bridged).
|
|
*/
|
|
function resolveObjcCallToSwift(
|
|
ref: UnresolvedRef,
|
|
context: ResolutionContext
|
|
): ResolvedRef | null {
|
|
// ObjC call sites get receiver-prefixed when the receiver isn't self/super
|
|
// (see tree-sitter.ts message_expression handling): `[obj foo:bar:]`
|
|
// becomes `obj.foo:bar:`. Strip the receiver prefix to recover the raw
|
|
// selector for the bridge math.
|
|
const rawSelector = ref.referenceName.includes('.')
|
|
? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
|
|
: ref.referenceName;
|
|
|
|
// Bridge math only applies to selector-shape names (contain `:`).
|
|
if (!rawSelector.includes(':')) return null;
|
|
|
|
const candidates = swiftBaseNamesForObjcSelector(rawSelector);
|
|
for (const candidate of candidates) {
|
|
const matches = context
|
|
.getNodesByName(candidate)
|
|
.filter((n) => n.language === 'swift' && (n.kind === 'method' || n.kind === 'function'));
|
|
for (const match of matches) {
|
|
const window = declarationSourceWindow(match, context);
|
|
if (isObjcExposed(window)) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: match.id,
|
|
confidence: 0.6,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export const swiftObjcBridgeResolver: FrameworkResolver = {
|
|
name: 'swift-objc-bridge',
|
|
// Applies to both languages — bridging crosses the boundary.
|
|
languages: ['swift', 'objc'],
|
|
|
|
/**
|
|
* Detect: this resolver is relevant when the project has both Swift and
|
|
* Objective-C source. Either-side-only projects don't need bridging
|
|
* (and the empty reverse-map would be a no-op anyway).
|
|
*/
|
|
detect(context) {
|
|
const files = context.getAllFiles();
|
|
let hasSwift = false;
|
|
let hasObjc = false;
|
|
for (const f of files) {
|
|
if (f.endsWith('.swift')) hasSwift = true;
|
|
else if (f.endsWith('.m') || f.endsWith('.mm')) hasObjc = true;
|
|
if (hasSwift && hasObjc) return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
/**
|
|
* Let selector-shape references (anything containing a `:`) through the
|
|
* resolver's name-exists pre-filter — no Swift node has a colon in its
|
|
* name, so without this opt-in those refs would be dropped before
|
|
* `resolve()` sees them. Also opt-in `setX:`-style names that aren't
|
|
* otherwise declared symbols, in case the Swift side is a property.
|
|
*/
|
|
claimsReference(name) {
|
|
if (name.includes(':')) return true;
|
|
// Bare names without colons are handled by the regular name-exists
|
|
// pre-filter — no need to opt them in here.
|
|
return false;
|
|
},
|
|
|
|
/**
|
|
* Route based on which language the caller is in. The two directions are
|
|
* symmetric in shape but very different in implementation (forward
|
|
* direction uses the precomputed reverse-bridge map; reverse direction
|
|
* uses the deterministic name-derivation).
|
|
*/
|
|
resolve(ref, context) {
|
|
if (ref.language === 'swift') {
|
|
return resolveSwiftCallToObjc(ref, context);
|
|
}
|
|
if (ref.language === 'objc') {
|
|
return resolveObjcCallToSwift(ref, context);
|
|
}
|
|
return null;
|
|
},
|
|
};
|