Skip to content

Commit 4d1a2b3

Browse files
authored
feat(resolution): mixed iOS / React Native / Expo cross-language bridging (colbymchenry#430)
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:b:` selectors so they resolve to multi-part method definitions (gap discovered post-colbymchenry#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 colbymchenry#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)
1 parent 1821038 commit 4d1a2b3

22 files changed

Lines changed: 3786 additions & 6 deletions

.claude/skills/agent-eval/corpus.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,5 +74,24 @@
7474
{ "name": "Masonry", "repo": "https://github.com/SnapKit/Masonry", "size": "Small", "files": "~50", "question": "How does Masonry build and activate Auto Layout constraints from its block DSL?" },
7575
{ "name": "FMDB", "repo": "https://github.com/ccgus/fmdb", "size": "Medium", "files": "~80", "question": "How does FMDB execute a prepared SQL statement and bind parameters?" },
7676
{ "name": "SDWebImage", "repo": "https://github.com/SDWebImage/SDWebImage", "size": "Large", "files": "~400", "question": "How does SDWebImage download, cache, and decode an image for a UIImageView?" }
77+
],
78+
"Mixed iOS (Swift+ObjC)": [
79+
{ "name": "Charts", "repo": "https://github.com/danielgindi/Charts", "size": "Small", "files": "~270", "question": "How does the ChartsDemo ObjC demo controller drive the Swift Charts library to animate and notify a data update?" },
80+
{ "name": "realm-swift", "repo": "https://github.com/realm/realm-swift", "size": "Medium", "files": "~370", "question": "How does a Swift `Realm.write { realm.add(obj) }` reach the Objective-C persistence layer?" },
81+
{ "name": "wikipedia-ios", "repo": "https://github.com/wikimedia/wikipedia-ios", "size": "Large", "files": "~1700", "question": "How does tapping a search result reach the article-fetch network call across the Swift / ObjC boundary?" }
82+
],
83+
"React Native (legacy bridge + TurboModule)": [
84+
{ "name": "@react-native-async-storage", "repo": "https://github.com/react-native-async-storage/async-storage", "size": "Small", "files": "~60", "question": "How does `setItem` in JS reach the native `legacy_multiSet` implementation?" },
85+
{ "name": "react-native-svg", "repo": "https://github.com/software-mansion/react-native-svg", "size": "Medium", "files": "~700", "question": "How does a JS `Svg.getTotalLength(...)` reach the iOS / Android native implementation via TurboModule?" },
86+
{ "name": "react-native-firebase", "repo": "https://github.com/invertase/react-native-firebase", "size": "Large", "files": "~1100", "question": "How does a native iOS push notification reach the JS `messaging().onMessage(...)` listener?" }
87+
],
88+
"Expo Modules": [
89+
{ "name": "expo-haptics", "repo": "https://github.com/expo/expo/tree/main/packages/expo-haptics", "size": "Small", "files": "~15", "question": "How does `Haptics.notificationAsync(...)` in JS reach `UINotificationFeedbackGenerator` in the Swift Module?" },
90+
{ "name": "expo-camera", "repo": "https://github.com/expo/expo/tree/main/packages/expo-camera", "size": "Medium", "files": "~70", "question": "How does a JS `CameraView.takePictureAsync(options)` reach the native AVCaptureSession / CameraDevice call?" }
91+
],
92+
"React Native Fabric (view components)": [
93+
{ "name": "react-native-segmented-control", "repo": "https://github.com/react-native-segmented-control/segmented-control", "size": "Small", "files": "~25", "question": "How does JSX `<SegmentedControl onChange={cb}/>` reach the native onChange handler on iOS/Android?" },
94+
{ "name": "react-native-screens", "repo": "https://github.com/software-mansion/react-native-screens", "size": "Medium", "files": "~1200", "question": "How does JSX `<ScreenStack>` reach the native RNSScreenStackView component?" },
95+
{ "name": "react-native-skia", "repo": "https://github.com/Shopify/react-native-skia", "size": "Large", "files": "~1000", "question": "How does a `<SkiaPictureView/>` JSX usage reach the iOS / Android native renderer?" }
7796
]
7897
}

CHANGELOG.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,91 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8484
bridge) is **not** in scope for this entry — that's a separate effort
8585
tracked under the dynamic-dispatch coverage playbook.
8686

87+
- **Mixed iOS, React Native, and Expo cross-language bridging.** Real iOS
88+
and React Native codebases live across multiple languages — a Swift caller
89+
invokes an Objective-C selector that's been auto-bridged, JS calls into a
90+
native module via the React Native bridge, JSX delegates to a native view
91+
manager. Static tree-sitter extraction stops at each boundary. CodeGraph
92+
now bridges them so `trace` / `callers` / `callees` / `impact` connect
93+
end-to-end across the gap. Closes the iOS/RN parts of the request thread
94+
for #401. Bridges added:
95+
96+
- **Swift ↔ Objective-C.** Swift `@objc` auto-bridging rules
97+
(`func play(song:)` ↔ ObjC `-playWithSong:`, `init(name:, age:)`
98+
`-initWithName:age:`, `var x: T``-x`/`-setX:`, `@objc(custom:)`
99+
overrides) plus the Cocoa preposition-prefix forms that reverse-import
100+
natively (`objectForKey:`, `stringWithFormat:`, etc.). Validated on
101+
Charts (28 / 1 bridge edges objc→swift / swift→objc), realm-swift
102+
(36 / 1185), wikipedia-ios (52 / 983). The high-confidence direction
103+
is ObjC→Swift, since Swift callsites carry the bare method name only
104+
and many overlap with Cocoa built-ins.
105+
106+
- **React Native legacy bridge + TurboModules.** Parses
107+
`RCT_EXPORT_MODULE` / `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` (ObjC
108+
& ObjC++) and `@ReactMethod` (Java/Kotlin) declarations; treats
109+
`Native<X>.ts` TurboModule spec files as ground truth. A JS callsite
110+
of `NativeModules.X.fn(...)` or `import X from './NativeX'; X.fn(...)`
111+
resolves to the matching native method. Validated on AsyncStorage
112+
(8/8 precise), react-native-svg (9 TurboModule bridges to Java),
113+
react-native-firebase (18 precise after `RCTEventEmitter` built-in
114+
blocklist).
115+
116+
- **Native → JS event channel.** Synthesizes cross-language edges
117+
keyed by literal event name: ObjC `sendEventWithName:@"X"` /
118+
Swift `sendEvent(withName: "X", ...)` / Java/Kotlin `.emit("X", ...)`
119+
→ JS `new NativeEventEmitter(...).addListener("X", handler)`.
120+
Falls back to attributing the JS endpoint to an enclosing
121+
`constant`/`variable` for the very common
122+
`const Foo = { watchX(listener) { ... addListener('X', listener) } }`
123+
wrapper-API pattern. Validated on RNFirebase (3 push-notification
124+
flow edges) and RNGeolocation (2 location-event edges).
125+
126+
- **Expo Modules.** Parses Swift/Kotlin Expo DSL —
127+
`Module { Name("X"); Function("y") { ... }; AsyncFunction("z") { ... };
128+
Property("w") { ... } }` — and synthesizes `method` nodes named after
129+
each declaration. JS callsites of `requireNativeModule('X').y(...)`
130+
then resolve via existing name-match. Validated on expo-haptics
131+
(6 method nodes across Swift + Kotlin), expo-camera (41 covering the
132+
full SDK surface), and a 7-package Expo sweep (134 method nodes).
133+
134+
- **Fabric / Codegen + legacy Paper view components.** Parses TS
135+
`codegenNativeComponent<NativeProps>('Name', ...)` Codegen specs AND
136+
legacy `RCT_EXPORT_VIEW_PROPERTY` / `@ReactProp` view-manager
137+
macros. Emits a `component` node per declaration and a `property`
138+
node per declared prop, then a synthesizer links the component to
139+
its native impl class by convention-based name+suffix
140+
(`View`/`ComponentView`/`Manager`/`ViewManager`). The existing JSX
141+
synthesizer then connects consumer JSX `<MyView/>` → component →
142+
native class. Validated on react-native-segmented-control
143+
(legacy Paper — 1 component, 11 props, 4 bridges),
144+
react-native-screens (Codegen Fabric — 27 components, 272 props,
145+
68 bridges), and react-native-skia (hybrid, monorepo — 5 components,
146+
14 props, 15 bridges across Codegen TS specs + Android Java
147+
ViewManagers + iOS ObjC).
148+
149+
Each bridge emits `provenance:'heuristic'` edges with a stable
150+
`metadata.synthesizedBy:` channel name (`swift-objc-bridge`,
151+
`react-native-bridge`, `rn-event-channel`, `fabric-native-impl`,
152+
`expo-modules`) so an agent can tell at a glance how a cross-language
153+
hop got into the graph. Per-bridge precision blocklists prevent
154+
noisy over-linking on generic Cocoa names (`init`, `description`,
155+
`count`, …) and RN event-emitter built-ins (`addListener`, `remove`,
156+
…) that every NSObject / RCTEventEmitter subclass exposes.
157+
158+
Architectural fix surfaced during validation: the resolver's
159+
`initialize()` runs at CodeGraph construction (before any files are
160+
indexed), so framework resolvers whose `detect()` consults the
161+
indexed file list silently dropped themselves. `indexAll()` now
162+
re-initializes the resolver after extraction so all frameworks see
163+
the populated index — a pre-existing latent bug that also affected
164+
the UIKit and SwiftUI resolvers.
165+
166+
Out of scope for this round: bare JSI (non-TurboModule), dynamic
167+
bridge keys (`NativeModules[someVar]`), Android-Java extraction
168+
improvements beyond name-match (we use whatever the existing Java
169+
extractor produces). Anti-goals documented in
170+
`docs/design/mixed-ios-and-react-native-bridging.md`.
171+
87172
### Fixed
88173
- **Git worktrees no longer silently borrow another tree's index (#155).**
89174
When a worktree is nested inside the main checkout — exactly what agent

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ The gains scale with codebase size: on large repos the agent answers from the in
137137
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
138138
| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
139139
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 frameworks |
140+
| **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
140141
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
141142

142143
---
@@ -164,6 +165,35 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
164165

165166
---
166167

168+
## Mixed iOS / React Native / Expo bridging
169+
170+
Real iOS and React Native codebases live across multiple languages — a Swift caller invokes an Objective-C selector that's been auto-bridged, a JS file calls into a native module via the React Native bridge, a JSX component delegates to a native view manager. Static tree-sitter extraction stops at each language boundary. CodeGraph bridges them so `trace`, `callers`, `callees`, and `impact` connect end-to-end across the gap.
171+
172+
| Boundary | JS / Swift side | Native side | How |
173+
|---|---|---|---|
174+
| **Swift → ObjC** | Swift `obj.foo(bar:)` | ObjC selector `-fooWithBar:` | `@objc` auto-bridging rules (including init/property/protocol forms) + Cocoa preposition prefixes (`With`/`For`/`By`/`In`/`On`/`At`/…) |
175+
| **ObjC → Swift** | ObjC `[obj fooWithBar:]` | Swift `@objc func foo(bar:)` | Reverse-bridge name candidates; verifies `@objc` exposure from source |
176+
| **React Native legacy bridge** | JS `NativeModules.X.fn(...)` | ObjC `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` · Java/Kotlin `@ReactMethod` | Parses macro/annotation declarations to build a JS-name → native-method map |
177+
| **React Native TurboModules** | JS `import M from './NativeM'; M.fn(...)` | Native impl matching the Codegen spec | Treats the `Native<X>.ts` spec interface as ground truth |
178+
| **RN native → JS events** | JS `new NativeEventEmitter(...).addListener('e', cb)` | ObjC `[self sendEventWithName:@"e" body:...]` · Swift `sendEvent(withName: "e", ...)` · Java/Kotlin `.emit("e", ...)` | Synthesized cross-language event channel keyed by literal event name |
179+
| **Expo Modules** | JS `requireNativeModule('X').fn(...)` | Swift / Kotlin `Module { Name("X"); AsyncFunction("fn") { ... } }` | Parses the Expo DSL literals; synthetic method nodes resolve via existing name-match |
180+
| **Fabric view components** | JSX `<MyView prop={v}/>` | TS Codegen spec + native impl class | Spec → `component` node; convention-based name+suffix lookup (`View`/`ComponentView`/`Manager`/`ViewManager`) bridges to native |
181+
| **Legacy Paper view managers** | JSX `<MyView prop={v}/>` | ObjC `RCT_EXPORT_VIEW_PROPERTY` · Java/Kotlin `@ReactProp` | Same as Fabric — Paper-era declarations also produce `component` + `property` nodes |
182+
183+
**Validated on real codebases** (small + medium + large for each bridge):
184+
185+
| Bridge | Small | Medium | Large |
186+
|---|---|---|---|
187+
| Swift ↔ ObjC | [Charts](https://github.com/danielgindi/Charts) | [realm-swift](https://github.com/realm/realm-swift) | [Wikipedia-iOS](https://github.com/wikimedia/wikipedia-ios) |
188+
| RN legacy bridge | [AsyncStorage](https://github.com/react-native-async-storage/async-storage) | [react-native-svg](https://github.com/software-mansion/react-native-svg) | [react-native-firebase](https://github.com/invertase/react-native-firebase) |
189+
| RN native → JS events | [RNGeolocation](https://github.com/Agontuk/react-native-geolocation-service) || react-native-firebase |
190+
| Expo Modules | expo-haptics | expo-camera | expo SDK sweep (7 packages) |
191+
| Fabric / Paper views | [react-native-segmented-control](https://github.com/react-native-segmented-control/segmented-control) | [react-native-screens](https://github.com/software-mansion/react-native-screens) | [react-native-skia](https://github.com/Shopify/react-native-skia) |
192+
193+
Each bridge emits edges tagged `provenance:'heuristic'` with `metadata.synthesizedBy:` set to a stable channel name (e.g. `swift-objc-bridge`, `rn-event-channel`, `fabric-native-impl`, `expo-module-extract`), so the agent can tell at a glance how a hop got into the graph.
194+
195+
---
196+
167197
## Quick Start
168198

169199
### 1. Run the Installer

__tests__/expo-modules.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import * as fs from 'node:fs';
3+
import * as path from 'node:path';
4+
import * as os from 'node:os';
5+
import { CodeGraph } from '../src';
6+
import { expoModulesResolver } from '../src/resolution/frameworks/expo-modules';
7+
8+
describe('Expo Modules framework extractor', () => {
9+
it('extracts AsyncFunction / Function / Property literals as method nodes', () => {
10+
const source = `
11+
import ExpoModulesCore
12+
13+
public class HapticsModule: Module {
14+
public func definition() -> ModuleDefinition {
15+
Name("ExpoHaptics")
16+
17+
AsyncFunction("notificationAsync") { (notificationType: NotificationType) in
18+
// body
19+
}
20+
21+
AsyncFunction("impactAsync") { (style: ImpactStyle) in
22+
// body
23+
}
24+
25+
Function("synchronousThing") {
26+
return 1
27+
}
28+
29+
Property("isAvailable") {
30+
return true
31+
}
32+
}
33+
}
34+
`;
35+
const result = expoModulesResolver.extract?.('ios/HapticsModule.swift', source);
36+
expect(result).toBeDefined();
37+
const names = result!.nodes.map((n) => n.name);
38+
expect(names).toEqual(
39+
expect.arrayContaining(['notificationAsync', 'impactAsync', 'synchronousThing', 'isAvailable'])
40+
);
41+
expect(result!.nodes.every((n) => n.kind === 'method')).toBe(true);
42+
expect(result!.nodes.every((n) => n.qualifiedName.includes('ExpoHaptics.'))).toBe(true);
43+
});
44+
45+
it('falls back to the class name when the Module has no Name("X") literal', () => {
46+
const source = `
47+
public class BareModule: Module {
48+
public func definition() -> ModuleDefinition {
49+
Function("doX") { return 1 }
50+
}
51+
}
52+
`;
53+
const result = expoModulesResolver.extract?.('ios/BareModule.swift', source);
54+
// BareModule is used as the qualifier since there's no Name() literal.
55+
expect(result!.nodes[0]?.qualifiedName).toContain('BareModule.doX');
56+
});
57+
58+
it('returns no nodes for a Swift file that is not an Expo Module', () => {
59+
const source = `
60+
class Helper {
61+
func doX() { }
62+
}
63+
`;
64+
const result = expoModulesResolver.extract?.('Helper.swift', source);
65+
expect(result?.nodes).toHaveLength(0);
66+
});
67+
68+
it('also extracts from Kotlin module files', () => {
69+
const source = `
70+
class FooModule : Module() {
71+
override fun definition() = ModuleDefinition {
72+
Name("ExpoFoo")
73+
AsyncFunction("doAsync") { name: String -> name.uppercase() }
74+
Function("doSync") { 42 }
75+
}
76+
}
77+
`;
78+
const result = expoModulesResolver.extract?.('FooModule.kt', source);
79+
expect(result?.nodes.length).toBe(2);
80+
expect(result?.nodes.map((n) => n.name).sort()).toEqual(['doAsync', 'doSync']);
81+
expect(result?.nodes.every((n) => n.language === 'kotlin')).toBe(true);
82+
});
83+
});
84+
85+
describe('Expo Modules end-to-end — JS caller → native AsyncFunction', () => {
86+
let dir: string;
87+
88+
beforeEach(() => {
89+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-modules-fixture-'));
90+
});
91+
92+
afterEach(() => {
93+
fs.rmSync(dir, { recursive: true, force: true });
94+
});
95+
96+
it('JS callsite of a literal AsyncFunction("name") resolves to the native impl node', async () => {
97+
fs.writeFileSync(
98+
path.join(dir, 'package.json'),
99+
'{"dependencies":{"expo-modules-core":"^1.0.0"}}'
100+
);
101+
fs.mkdirSync(path.join(dir, 'ios'));
102+
fs.writeFileSync(
103+
path.join(dir, 'ios', 'HapticsModule.swift'),
104+
`
105+
import ExpoModulesCore
106+
public class HapticsModule: Module {
107+
public func definition() -> ModuleDefinition {
108+
Name("ExpoHaptics")
109+
AsyncFunction("uniqueExpoHapticCall") { in /* … */ }
110+
}
111+
}
112+
`
113+
);
114+
fs.mkdirSync(path.join(dir, 'src'));
115+
fs.writeFileSync(
116+
path.join(dir, 'src', 'index.ts'),
117+
`
118+
import { requireNativeModule } from 'expo-modules-core';
119+
const Haptics = requireNativeModule('ExpoHaptics');
120+
export async function impactAsync() {
121+
return await Haptics.uniqueExpoHapticCall();
122+
}
123+
`
124+
);
125+
126+
const cg = await CodeGraph.init(dir, { silent: true });
127+
await cg.indexAll();
128+
const db = (cg as any).db.db;
129+
130+
// The native method node should exist.
131+
const native = db
132+
.prepare(
133+
"SELECT * FROM nodes WHERE kind='method' AND name='uniqueExpoHapticCall' AND id LIKE 'expo-module:%'"
134+
)
135+
.all();
136+
expect(native).toHaveLength(1);
137+
138+
// And the JS callsite should produce a call edge targeting it.
139+
const callEdge = db
140+
.prepare(
141+
`SELECT t.name target, t.id target_id
142+
FROM edges e
143+
JOIN nodes s ON s.id = e.source
144+
JOIN nodes t ON t.id = e.target
145+
WHERE e.kind = 'calls'
146+
AND s.file_path LIKE '%index.ts'
147+
AND t.name = 'uniqueExpoHapticCall'`
148+
)
149+
.all();
150+
cg.close?.();
151+
expect(callEdge.length).toBeGreaterThanOrEqual(1);
152+
expect(callEdge[0].target_id.startsWith('expo-module:')).toBe(true);
153+
});
154+
});

__tests__/extraction.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3987,6 +3987,35 @@ void helperFunction(int count) {
39873987
expect(calls).toEqual(expect.arrayContaining(['NSLog', 'doWork', 'MyClass.shared', 'obj.greet']));
39883988
});
39893989

3990+
it('should reconstruct multi-keyword selectors at the call site so they resolve to the method definition', () => {
3991+
// Regression for the gap discovered post-#165: message_expression's
3992+
// multi-keyword form `[obj a:1 b:2]` was only emitting the first keyword,
3993+
// so calls never resolved to multi-part method definitions like
3994+
// `GET:parameters:headers:progress:success:failure:`. The call-site name
3995+
// must match the method-definition name with full keywords + trailing colons.
3996+
const code = `
3997+
@implementation Caller
3998+
- (void)demo {
3999+
NSMutableDictionary *d = [NSMutableDictionary new];
4000+
[d setObject:@"v" forKey:@"k"];
4001+
[d setObject:@"v2" forKey:@"k2" withRetry:@YES];
4002+
[self touchesBegan:nil withEvent:nil];
4003+
}
4004+
@end
4005+
`;
4006+
const result = extractFromSource('Caller.m', code);
4007+
const calls = result.unresolvedReferences
4008+
.filter((r) => r.referenceKind === 'calls')
4009+
.map((r) => r.referenceName);
4010+
expect(calls).toEqual(
4011+
expect.arrayContaining([
4012+
'd.setObject:forKey:',
4013+
'd.setObject:forKey:withRetry:',
4014+
'touchesBegan:withEvent:',
4015+
])
4016+
);
4017+
});
4018+
39904019
it('should not classify pure C headers with @end in comments as objc', () => {
39914020
const cHeader = '/* @end of file */\n#ifndef STDIO_H\nvoid printf(const char *);\n#endif\n';
39924021
expect(detectLanguage('stdio.h', cHeader)).toBe('c');

0 commit comments

Comments
 (0)