Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/skills/agent-eval/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,24 @@
{ "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?" },
{ "name": "FMDB", "repo": "https://github.com/ccgus/fmdb", "size": "Medium", "files": "~80", "question": "How does FMDB execute a prepared SQL statement and bind parameters?" },
{ "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?" }
],
"Mixed iOS (Swift+ObjC)": [
{ "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?" },
{ "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?" },
{ "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?" }
],
"React Native (legacy bridge + TurboModule)": [
{ "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?" },
{ "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?" },
{ "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?" }
],
"Expo Modules": [
{ "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?" },
{ "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?" }
],
"React Native Fabric (view components)": [
{ "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?" },
{ "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?" },
{ "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?" }
]
}
85 changes: 85 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,91 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
bridge) is **not** in scope for this entry — that's a separate effort
tracked under the dynamic-dispatch coverage playbook.

- **Mixed iOS, React Native, and Expo cross-language bridging.** Real iOS
and React Native codebases live across multiple languages — a Swift caller
invokes an Objective-C selector that's been auto-bridged, JS calls into a
native module via the React Native bridge, JSX delegates to a native view
manager. Static tree-sitter extraction stops at each boundary. CodeGraph
now bridges them so `trace` / `callers` / `callees` / `impact` connect
end-to-end across the gap. Closes the iOS/RN parts of the request thread
for #401. Bridges added:

- **Swift ↔ Objective-C.** Swift `@objc` auto-bridging rules
(`func play(song:)` ↔ ObjC `-playWithSong:`, `init(name:, age:)` ↔
`-initWithName:age:`, `var x: T` ↔ `-x`/`-setX:`, `@objc(custom:)`
overrides) plus the Cocoa preposition-prefix forms that reverse-import
natively (`objectForKey:`, `stringWithFormat:`, etc.). Validated on
Charts (28 / 1 bridge edges objc→swift / swift→objc), realm-swift
(36 / 1185), wikipedia-ios (52 / 983). The high-confidence direction
is ObjC→Swift, since Swift callsites carry the bare method name only
and many overlap with Cocoa built-ins.

- **React Native legacy bridge + TurboModules.** Parses
`RCT_EXPORT_MODULE` / `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` (ObjC
& ObjC++) and `@ReactMethod` (Java/Kotlin) declarations; treats
`Native<X>.ts` TurboModule spec files as ground truth. A JS callsite
of `NativeModules.X.fn(...)` or `import X from './NativeX'; X.fn(...)`
resolves to the matching native method. Validated on AsyncStorage
(8/8 precise), react-native-svg (9 TurboModule bridges to Java),
react-native-firebase (18 precise after `RCTEventEmitter` built-in
blocklist).

- **Native → JS event channel.** Synthesizes cross-language edges
keyed by literal event name: ObjC `sendEventWithName:@"X"` /
Swift `sendEvent(withName: "X", ...)` / Java/Kotlin `.emit("X", ...)`
→ JS `new NativeEventEmitter(...).addListener("X", handler)`.
Falls back to attributing the JS endpoint to an enclosing
`constant`/`variable` for the very common
`const Foo = { watchX(listener) { ... addListener('X', listener) } }`
wrapper-API pattern. Validated on RNFirebase (3 push-notification
flow edges) and RNGeolocation (2 location-event edges).

- **Expo Modules.** Parses Swift/Kotlin Expo DSL —
`Module { Name("X"); Function("y") { ... }; AsyncFunction("z") { ... };
Property("w") { ... } }` — and synthesizes `method` nodes named after
each declaration. JS callsites of `requireNativeModule('X').y(...)`
then resolve via existing name-match. Validated on expo-haptics
(6 method nodes across Swift + Kotlin), expo-camera (41 covering the
full SDK surface), and a 7-package Expo sweep (134 method nodes).

- **Fabric / Codegen + legacy Paper view components.** Parses TS
`codegenNativeComponent<NativeProps>('Name', ...)` Codegen specs AND
legacy `RCT_EXPORT_VIEW_PROPERTY` / `@ReactProp` view-manager
macros. Emits a `component` node per declaration and a `property`
node per declared prop, then a synthesizer links the component to
its native impl class by convention-based name+suffix
(`View`/`ComponentView`/`Manager`/`ViewManager`). The existing JSX
synthesizer then connects consumer JSX `<MyView/>` → component →
native class. Validated on react-native-segmented-control
(legacy Paper — 1 component, 11 props, 4 bridges),
react-native-screens (Codegen Fabric — 27 components, 272 props,
68 bridges), and react-native-skia (hybrid, monorepo — 5 components,
14 props, 15 bridges across Codegen TS specs + Android Java
ViewManagers + iOS ObjC).

Each bridge emits `provenance:'heuristic'` edges with a stable
`metadata.synthesizedBy:` channel name (`swift-objc-bridge`,
`react-native-bridge`, `rn-event-channel`, `fabric-native-impl`,
`expo-modules`) so an agent can tell at a glance how a cross-language
hop got into the graph. Per-bridge precision blocklists prevent
noisy over-linking on generic Cocoa names (`init`, `description`,
`count`, …) and RN event-emitter built-ins (`addListener`, `remove`,
…) that every NSObject / RCTEventEmitter subclass exposes.

Architectural fix surfaced during validation: the resolver's
`initialize()` runs at CodeGraph construction (before any files are
indexed), so framework resolvers whose `detect()` consults the
indexed file list silently dropped themselves. `indexAll()` now
re-initializes the resolver after extraction so all frameworks see
the populated index — a pre-existing latent bug that also affected
the UIKit and SwiftUI resolvers.

Out of scope for this round: bare JSI (non-TurboModule), dynamic
bridge keys (`NativeModules[someVar]`), Android-Java extraction
improvements beyond name-match (we use whatever the existing Java
extractor produces). Anti-goals documented in
`docs/design/mixed-ios-and-react-native-bridging.md`.

### Fixed
- **Git worktrees no longer silently borrow another tree's index (#155).**
When a worktree is nested inside the main checkout — exactly what agent
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ The gains scale with codebase size: on large repos the agent answers from the in
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 frameworks |
| **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 |
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |

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

---

## Mixed iOS / React Native / Expo bridging

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.

| Boundary | JS / Swift side | Native side | How |
|---|---|---|---|
| **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`/…) |
| **ObjC → Swift** | ObjC `[obj fooWithBar:]` | Swift `@objc func foo(bar:)` | Reverse-bridge name candidates; verifies `@objc` exposure from source |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |

**Validated on real codebases** (small + medium + large for each bridge):

| Bridge | Small | Medium | Large |
|---|---|---|---|
| Swift ↔ ObjC | [Charts](https://github.com/danielgindi/Charts) | [realm-swift](https://github.com/realm/realm-swift) | [Wikipedia-iOS](https://github.com/wikimedia/wikipedia-ios) |
| 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) |
| RN native → JS events | [RNGeolocation](https://github.com/Agontuk/react-native-geolocation-service) | — | react-native-firebase |
| Expo Modules | expo-haptics | expo-camera | expo SDK sweep (7 packages) |
| 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) |

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.

---

## Quick Start

### 1. Run the Installer
Expand Down
154 changes: 154 additions & 0 deletions __tests__/expo-modules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
import { expoModulesResolver } from '../src/resolution/frameworks/expo-modules';

describe('Expo Modules framework extractor', () => {
it('extracts AsyncFunction / Function / Property literals as method nodes', () => {
const source = `
import ExpoModulesCore

public class HapticsModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoHaptics")

AsyncFunction("notificationAsync") { (notificationType: NotificationType) in
// body
}

AsyncFunction("impactAsync") { (style: ImpactStyle) in
// body
}

Function("synchronousThing") {
return 1
}

Property("isAvailable") {
return true
}
}
}
`;
const result = expoModulesResolver.extract?.('ios/HapticsModule.swift', source);
expect(result).toBeDefined();
const names = result!.nodes.map((n) => n.name);
expect(names).toEqual(
expect.arrayContaining(['notificationAsync', 'impactAsync', 'synchronousThing', 'isAvailable'])
);
expect(result!.nodes.every((n) => n.kind === 'method')).toBe(true);
expect(result!.nodes.every((n) => n.qualifiedName.includes('ExpoHaptics.'))).toBe(true);
});

it('falls back to the class name when the Module has no Name("X") literal', () => {
const source = `
public class BareModule: Module {
public func definition() -> ModuleDefinition {
Function("doX") { return 1 }
}
}
`;
const result = expoModulesResolver.extract?.('ios/BareModule.swift', source);
// BareModule is used as the qualifier since there's no Name() literal.
expect(result!.nodes[0]?.qualifiedName).toContain('BareModule.doX');
});

it('returns no nodes for a Swift file that is not an Expo Module', () => {
const source = `
class Helper {
func doX() { }
}
`;
const result = expoModulesResolver.extract?.('Helper.swift', source);
expect(result?.nodes).toHaveLength(0);
});

it('also extracts from Kotlin module files', () => {
const source = `
class FooModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoFoo")
AsyncFunction("doAsync") { name: String -> name.uppercase() }
Function("doSync") { 42 }
}
}
`;
const result = expoModulesResolver.extract?.('FooModule.kt', source);
expect(result?.nodes.length).toBe(2);
expect(result?.nodes.map((n) => n.name).sort()).toEqual(['doAsync', 'doSync']);
expect(result?.nodes.every((n) => n.language === 'kotlin')).toBe(true);
});
});

describe('Expo Modules end-to-end — JS caller → native AsyncFunction', () => {
let dir: string;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-modules-fixture-'));
});

afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});

it('JS callsite of a literal AsyncFunction("name") resolves to the native impl node', async () => {
fs.writeFileSync(
path.join(dir, 'package.json'),
'{"dependencies":{"expo-modules-core":"^1.0.0"}}'
);
fs.mkdirSync(path.join(dir, 'ios'));
fs.writeFileSync(
path.join(dir, 'ios', 'HapticsModule.swift'),
`
import ExpoModulesCore
public class HapticsModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoHaptics")
AsyncFunction("uniqueExpoHapticCall") { in /* … */ }
}
}
`
);
fs.mkdirSync(path.join(dir, 'src'));
fs.writeFileSync(
path.join(dir, 'src', 'index.ts'),
`
import { requireNativeModule } from 'expo-modules-core';
const Haptics = requireNativeModule('ExpoHaptics');
export async function impactAsync() {
return await Haptics.uniqueExpoHapticCall();
}
`
);

const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;

// The native method node should exist.
const native = db
.prepare(
"SELECT * FROM nodes WHERE kind='method' AND name='uniqueExpoHapticCall' AND id LIKE 'expo-module:%'"
)
.all();
expect(native).toHaveLength(1);

// And the JS callsite should produce a call edge targeting it.
const callEdge = db
.prepare(
`SELECT t.name target, t.id target_id
FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE e.kind = 'calls'
AND s.file_path LIKE '%index.ts'
AND t.name = 'uniqueExpoHapticCall'`
)
.all();
cg.close?.();
expect(callEdge.length).toBeGreaterThanOrEqual(1);
expect(callEdge[0].target_id.startsWith('expo-module:')).toBe(true);
});
});
29 changes: 29 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3987,6 +3987,35 @@ void helperFunction(int count) {
expect(calls).toEqual(expect.arrayContaining(['NSLog', 'doWork', 'MyClass.shared', 'obj.greet']));
});

it('should reconstruct multi-keyword selectors at the call site so they resolve to the method definition', () => {
// Regression for the gap discovered post-#165: message_expression's
// multi-keyword form `[obj a:1 b:2]` was only emitting the first keyword,
// so calls never resolved to multi-part method definitions like
// `GET:parameters:headers:progress:success:failure:`. The call-site name
// must match the method-definition name with full keywords + trailing colons.
const code = `
@implementation Caller
- (void)demo {
NSMutableDictionary *d = [NSMutableDictionary new];
[d setObject:@"v" forKey:@"k"];
[d setObject:@"v2" forKey:@"k2" withRetry:@YES];
[self touchesBegan:nil withEvent:nil];
}
@end
`;
const result = extractFromSource('Caller.m', code);
const calls = result.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => r.referenceName);
expect(calls).toEqual(
expect.arrayContaining([
'd.setObject:forKey:',
'd.setObject:forKey:withRetry:',
'touchesBegan:withEvent:',
])
);
});

it('should not classify pure C headers with @end in comments as objc', () => {
const cHeader = '/* @end of file */\n#ifndef STDIO_H\nvoid printf(const char *);\n#endif\n';
expect(detectLanguage('stdio.h', cHeader)).toBe('c');
Expand Down
Loading