From 6d46a01d699e2b59bca1aab9e8665821920ca8c9 Mon Sep 17 00:00:00 2001 From: David Veszelovszki Date: Sat, 13 Jun 2026 23:40:47 +0200 Subject: [PATCH] feat(tauri): cross-language IPC bridge resolver (#772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Joins Tauri's TS<->Rust IPC boundary that tree-sitter can't see, for both conventions: - Typed (tauri-specta): `commands.fooBar()` / `events.fooBar.listen()` are member accesses the JS extractor already emits a ref for, joined in `resolve()` to the Rust `#[tauri::command] fn foo_bar` / `#[derive(...Event)] struct FooBar` (call-site granularity). - Raw: `invoke('foo_bar')` / `listen('foo-bar')` / `once(...)` carry the wire name as a string literal the extractor drops, so `extract()` surfaces it as a `calls` ref attributed to the file node (file granularity — a fn-node id is a sha256 of path:kind:name:line a string scan can't rebuild). Across tauri-specta's snake/camel/Pascal/kebab name conversions. Edges marked `metadata.resolvedBy:'framework'`, confidence 0.7 commands / 0.6 events, exact name-match wins a tie; over-matching is harmless (resolve() only joins names that hit a real command/event). Detected via `tauri.conf.json[5]` (root or nested monorepo), an `@tauri-apps/api` dependency, or any `tauri::command` in tracked Rust. Validated on three real apps: Cmdr (tauri-specta, Tauri 2 + Svelte 5, 25k nodes) -> `callers(get_mcp_port)` "no callers" -> 4, 291 distinct commands gain TS callers, events bridge across the case change; clash-verge-rev (raw invoke) -> 76 of 79 commands bridge via its cmds.ts wrapper, all spot-checked real commands; pot-desktop (raw invoke) -> 3/3 literal invokes. `impact` and `explore` span the wire too. 23 unit + fixture-integration tests (incl. a raw-invoke end-to-end); full suite green (1558). - src/resolution/frameworks/tauri.ts the resolver (resolve + extract) - src/resolution/frameworks/index.ts registration - __tests__/tauri-ipc-bridge.test.ts unit + end-to-end fixture tests - CHANGELOG.md, docs/design/dynamic-dispatch-coverage-playbook.md records --- CHANGELOG.md | 1 + __tests__/tauri-ipc-bridge.test.ts | 463 ++++++++++++++++++ .../dynamic-dispatch-coverage-playbook.md | 1 + src/resolution/frameworks/index.ts | 4 + src/resolution/frameworks/tauri.ts | 308 ++++++++++++ 5 files changed, 777 insertions(+) create mode 100644 __tests__/tauri-ipc-bridge.test.ts create mode 100644 src/resolution/frameworks/tauri.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3113c059d..658cd418f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- **CodeGraph now traces Tauri's Rust ↔ JavaScript IPC boundary.** In a [Tauri](https://tauri.app) app the frontend reaches the Rust backend through a runtime IPC hop, so `callers` and `impact` on a `#[tauri::command]` function came back empty even when the entire frontend called it — the call crosses both a language boundary and a name change (the `tauri-specta` bindings turn `get_mcp_port` into `getMcpPort`). CodeGraph now bridges it: TypeScript / JavaScript / Svelte callers of `commands.fooBar()` (and raw `invoke('foo_bar')`) resolve to their Rust `#[tauri::command]` handler, and `events.fooBar.listen(...)` (and raw `listen('foo-bar')`) resolve to the Rust `#[derive(…Event)]` struct behind them, across the `snake_case` / `camelCase` / `PascalCase` / `kebab-case` conventions tauri-specta uses. It's picked up automatically from a `tauri.conf.json`, an `@tauri-apps/api` dependency, or any `#[tauri::command]` in the Rust sources (monorepo layouts included). Validated across three real apps: a `tauri-specta` app (Tauri 2 + Svelte 5) where 291 commands now surface their frontend callers, plus two raw-`invoke` apps where the string wire names resolve too (76 of 79 commands in one). (#772) - New `codegraph daemon` command (alias `daemons`) — an interactive manager for the background daemons. It shows what's running (your current project's daemon first, pre-selected), and you arrow-key to one and press enter to stop it, or pick "Stop all". Previously the only way to shut a daemon down was to hunt for its pid and `kill` it by hand. (#845) - Checking your installed version is now easy to reach however you guess at it: `codegraph version`, `codegraph -v`, and `codegraph -version` all print it, alongside the existing `codegraph --version`. (#864) - The CodeGraph MCP server now self-heals if its main thread ever locks up. A lightweight watchdog notices when the process has stopped responding and stops it so a fresh one starts on your next request — it can no longer sit pinned at 100% CPU with no way to recover. Tune the detection window with `CODEGRAPH_WATCHDOG_TIMEOUT_MS`, or turn it off entirely with `CODEGRAPH_NO_WATCHDOG=1`. (#850) diff --git a/__tests__/tauri-ipc-bridge.test.ts b/__tests__/tauri-ipc-bridge.test.ts new file mode 100644 index 000000000..01e7cbc19 --- /dev/null +++ b/__tests__/tauri-ipc-bridge.test.ts @@ -0,0 +1,463 @@ +import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Node, Language } from '../src/types'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; +import { tauriBridgeResolver } from '../src/resolution/frameworks/tauri'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +function makeContext(nodes: Node[], fileContents: Record = {}): ResolutionContext { + const byName = new Map(); + const byFile = new Map(); + for (const n of nodes) { + const arr = byName.get(n.name); + if (arr) arr.push(n); + else byName.set(n.name, [n]); + const fArr = byFile.get(n.filePath); + if (fArr) fArr.push(n); + else byFile.set(n.filePath, [n]); + } + const allFiles = new Set( + [...nodes.map((n) => n.filePath), ...Object.keys(fileContents)] + ); + return { + getNodesInFile: (fp) => byFile.get(fp) ?? [], + getNodesByName: (name) => byName.get(name) ?? [], + getNodesByQualifiedName: () => [], + getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind), + getNodesByLowerName: () => [], + fileExists: (fp) => allFiles.has(fp), + readFile: (fp) => fileContents[fp] ?? null, + getProjectRoot: () => '/test', + getAllFiles: () => Array.from(allFiles), + getImportMappings: () => [], + }; +} + +function rustFn(name: string, filePath: string, startLine = 10): Node { + return { + id: `rust:${filePath}:${name}:${startLine}`, + kind: 'function', + name, + qualifiedName: `${filePath}::${name}`, + filePath, + language: 'rust', + startLine, + endLine: startLine + 5, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + } as Node; +} + +function rustStruct(name: string, filePath: string, startLine = 10): Node { + return { + id: `rust:${filePath}:${name}:${startLine}`, + kind: 'struct', + name, + qualifiedName: `${filePath}::${name}`, + filePath, + language: 'rust', + startLine, + endLine: startLine + 5, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + } as Node; +} + +function ref(name: string, language: Language, filePath: string): UnresolvedRef { + return { + fromNodeId: `caller:${filePath}`, + referenceName: name, + referenceKind: 'calls', + line: 1, + column: 0, + filePath, + language, + }; +} + +describe('Tauri IPC bridge resolver', () => { + describe('detect()', () => { + it('returns true when tauri.conf.json exists at root', () => { + const ctx = makeContext([], { 'tauri.conf.json': '{}' }); + expect(tauriBridgeResolver.detect(ctx)).toBe(true); + }); + + it('returns true when tauri.conf.json exists in a subdirectory', () => { + const ctx = makeContext([], { + 'apps/desktop/src-tauri/tauri.conf.json': '{}', + }); + expect(tauriBridgeResolver.detect(ctx)).toBe(true); + }); + + it('returns true when package.json depends on @tauri-apps/api', () => { + const ctx = makeContext([], { + 'package.json': '{"dependencies":{"@tauri-apps/api":"^2.0.0"}}', + }); + expect(tauriBridgeResolver.detect(ctx)).toBe(true); + }); + + it('returns false when no Tauri signals are present', () => { + const ctx = makeContext([], { + 'package.json': '{"dependencies":{"react":"^18.0"}}', + }); + expect(tauriBridgeResolver.detect(ctx)).toBe(false); + }); + }); + + describe('commands', () => { + it('resolves typed camelCase call to snake_case Rust command', () => { + const node = rustFn('get_mcp_port', 'src/commands/settings.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/commands/settings.rs': + '#[tauri::command]\npub async fn get_mcp_port() -> u16 { 0 }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('getMcpPort', 'typescript', 'src/settings.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + expect(result?.resolvedBy).toBe('framework'); + }); + + it('resolves receiver-qualified call (commands.getMcpPort)', () => { + const node = rustFn('get_mcp_port', 'src/commands/settings.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/commands/settings.rs': + '#[tauri::command]\npub async fn get_mcp_port() -> u16 { 0 }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('commands.getMcpPort', 'typescript', 'src/settings.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('resolves raw invoke with snake_case wire name', () => { + const node = rustFn('list_directory', 'src/commands/fs.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/commands/fs.rs': + '#[tauri::command]\npub async fn list_directory(path: String) -> Vec { vec![] }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('list_directory', 'typescript', 'src/fs.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('handles commands with multiple attributes (specta + tauri::command)', () => { + const node = rustFn('get_settings', 'src/commands/settings.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/commands/settings.rs': + '#[specta::specta]\n#[tauri::command]\npub async fn get_settings() -> Settings { todo!() }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('getSettings', 'typescript', 'src/app.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('returns null for a Rust-language caller', () => { + const node = rustFn('get_mcp_port', 'src/commands/settings.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/commands/settings.rs': + '#[tauri::command]\npub async fn get_mcp_port() -> u16 { 0 }\n', + }); + expect( + tauriBridgeResolver.resolve(ref('get_mcp_port', 'rust', 'src/other.rs'), ctx) + ).toBeNull(); + }); + + it('returns null when no matching command exists', () => { + const ctx = makeContext([], { + 'src-tauri/tauri.conf.json': '{}', + }); + expect( + tauriBridgeResolver.resolve(ref('nonExistent', 'typescript', 'src/app.ts'), ctx) + ).toBeNull(); + }); + }); + + describe('events', () => { + it('resolves typed camelCase event listener to Rust Event struct', () => { + const node = rustStruct('VolumeSpaceChanged', 'src/space_poller.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/space_poller.rs': + '#[derive(Clone, serde::Serialize, tauri_specta::Event)]\npub struct VolumeSpaceChanged { pub path: String }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('volumeSpaceChanged', 'typescript', 'src/volumes.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + expect(result?.resolvedBy).toBe('framework'); + }); + + it('resolves receiver-qualified event (events.volumeSpaceChanged)', () => { + const node = rustStruct('VolumeSpaceChanged', 'src/space_poller.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/space_poller.rs': + '#[derive(Clone, serde::Serialize, tauri_specta::Event)]\npub struct VolumeSpaceChanged { pub path: String }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('events.volumeSpaceChanged', 'typescript', 'src/volumes.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('resolves raw kebab-case event listener', () => { + const node = rustStruct('VolumeSpaceChanged', 'src/space_poller.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/space_poller.rs': + '#[derive(Clone, serde::Serialize, tauri_specta::Event)]\npub struct VolumeSpaceChanged { pub path: String }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('volume-space-changed', 'typescript', 'src/volumes.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('returns null for a Rust-language listener', () => { + const node = rustStruct('VolumeSpaceChanged', 'src/space_poller.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/space_poller.rs': + '#[derive(Clone, serde::Serialize, tauri_specta::Event)]\npub struct VolumeSpaceChanged { pub path: String }\n', + }); + expect( + tauriBridgeResolver.resolve( + ref('VolumeSpaceChanged', 'rust', 'src/other.rs'), + ctx + ) + ).toBeNull(); + }); + + it('handles bare Event derive (use tauri_specta::Event in scope)', () => { + const node = rustStruct('AccentColorChanged', 'src/theme.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/theme.rs': + 'use tauri_specta::Event;\n' + + '#[derive(Clone, Serialize, Event)]\n' + + 'pub struct AccentColorChanged { pub color: String }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('accentColorChanged', 'typescript', 'src/theme.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('respects #[tauri_specta(event_name = "...")] override', () => { + const node = rustStruct('LowDiskSpacePayload', 'src/space_poller.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/space_poller.rs': + '#[derive(Clone, Serialize, tauri_specta::Event)]\n' + + '#[tauri_specta(event_name = "low-disk-space")]\n' + + 'pub struct LowDiskSpacePayload { pub path: String }\n', + }); + // The override kebab name should resolve. + const result = tauriBridgeResolver.resolve( + ref('low-disk-space', 'typescript', 'src/space.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + }); + + describe('extract() — raw invoke/listen wire names', () => { + it('emits a reference for invoke(\'snake_name\') attributed to the file node', () => { + const result = tauriBridgeResolver.extract!( + 'src/api.ts', + "import { invoke } from '@tauri-apps/api/core';\n" + + "export const detect = (t) => invoke('lang_detect', { text: t });\n" + ); + expect(result.nodes).toEqual([]); + expect(result.references).toHaveLength(1); + const ref = result.references[0]!; + expect(ref.referenceName).toBe('lang_detect'); + expect(ref.fromNodeId).toBe('file:src/api.ts'); + expect(ref.referenceKind).toBe('calls'); + expect(ref.language).toBe('typescript'); + }); + + it('emits references for invoke(...), listen(...), and once(...)', () => { + const refs = tauriBridgeResolver.extract!( + 'src/x.tsx', + "invoke('get_port');\n" + + "listen('volume-space-changed', cb);\n" + + "once('app-ready', cb);\n" + ).references.map((r) => r.referenceName); + expect(refs).toEqual(['get_port', 'volume-space-changed', 'app-ready']); + }); + + it('skips dynamic (non-literal) names', () => { + const refs = tauriBridgeResolver.extract!( + 'src/x.ts', + 'invoke(cmdName);\ninvoke(`evt_${id}`);\n' + ).references; + expect(refs).toEqual([]); + }); + + it('returns nothing for a non-JS file', () => { + const result = tauriBridgeResolver.extract!('src/main.rs', "invoke('x')"); + expect(result.references).toEqual([]); + }); + }); +}); + +describe('Tauri IPC end-to-end', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tauri-ipc-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + it('links a TS typed command call to a Rust #[tauri::command] fn', async () => { + // Minimal Tauri project structure. + const srcTauri = path.join(dir, 'src-tauri', 'src'); + fs.mkdirSync(srcTauri, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src-tauri', 'tauri.conf.json'), + '{"identifier":"test"}' + ); + fs.writeFileSync( + path.join(dir, 'src-tauri', 'Cargo.toml'), + '[package]\nname = "test"\nversion = "0.1.0"\n' + ); + fs.writeFileSync( + path.join(srcTauri, 'main.rs'), + '#[tauri::command]\npub async fn get_mcp_port() -> u16 { 9090 }\n' + ); + fs.writeFileSync( + path.join(dir, 'package.json'), + '{"dependencies":{"@tauri-apps/api":"^2.0.0"}}' + ); + fs.writeFileSync( + path.join(dir, 'settings.ts'), + 'import { commands } from "./bindings";\n' + + 'export async function loadPort() { return commands.getMcpPort(); }\n' + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + // The Rust command fn should be in the graph. + const rustNodes = db.prepare( + "SELECT * FROM nodes WHERE name='get_mcp_port' AND language='rust'" + ).all(); + expect(rustNodes.length).toBeGreaterThan(0); + + // The TS call site should have an edge to the Rust fn. + const edges = db.prepare( + `SELECT e.* FROM edges e + JOIN nodes s ON s.id=e.source + JOIN nodes t ON t.id=e.target + WHERE t.name='get_mcp_port' AND t.language='rust' + AND s.language IN ('typescript','tsx','javascript') + AND e.kind IN ('calls','references')` + ).all(); + + cg.close?.(); + expect(edges.length).toBeGreaterThan(0); + }); + + it('links a TS typed event listener to a Rust Event struct', async () => { + const srcTauri = path.join(dir, 'src-tauri', 'src'); + fs.mkdirSync(srcTauri, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src-tauri', 'tauri.conf.json'), + '{"identifier":"test"}' + ); + fs.writeFileSync( + path.join(dir, 'src-tauri', 'Cargo.toml'), + '[package]\nname = "test"\nversion = "0.1.0"\n' + ); + fs.writeFileSync( + path.join(srcTauri, 'lib.rs'), + '#[derive(Clone, serde::Serialize, tauri_specta::Event)]\n' + + 'pub struct VolumeSpaceChanged {\n' + + ' pub path: String,\n' + + ' pub available: u64,\n' + + '}\n' + ); + fs.writeFileSync( + path.join(dir, 'package.json'), + '{"dependencies":{"@tauri-apps/api":"^2.0.0"}}' + ); + fs.writeFileSync( + path.join(dir, 'volumes.ts'), + 'import { events } from "./bindings";\n' + + 'events.volumeSpaceChanged.listen((e) => console.log(e));\n' + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + // The Rust Event struct should be in the graph. + const rustNodes = db.prepare( + "SELECT * FROM nodes WHERE name='VolumeSpaceChanged' AND language='rust'" + ).all(); + expect(rustNodes.length).toBeGreaterThan(0); + + cg.close?.(); + }); + + it('links a raw invoke(\'wire_name\') call to a Rust #[tauri::command] fn', async () => { + // The non-specta convention: the wire name is a string literal, so the JS + // extractor never emits it as a reference. extract() must surface it. + const srcTauri = path.join(dir, 'src-tauri', 'src'); + fs.mkdirSync(srcTauri, { recursive: true }); + fs.writeFileSync(path.join(dir, 'src-tauri', 'tauri.conf.json'), '{"identifier":"test"}'); + fs.writeFileSync( + path.join(dir, 'src-tauri', 'Cargo.toml'), + '[package]\nname = "test"\nversion = "0.1.0"\n' + ); + fs.writeFileSync( + path.join(srcTauri, 'main.rs'), + '#[tauri::command]\npub fn lang_detect(text: String) -> String { text }\n' + ); + fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"@tauri-apps/api":"^2.0.0"}}'); + fs.writeFileSync( + path.join(dir, 'detect.ts'), + "import { invoke } from '@tauri-apps/api/core';\n" + + "export const detect = (t: string) => invoke('lang_detect', { text: t });\n" + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + const edges = db.prepare( + `SELECT e.* FROM edges e + JOIN nodes t ON t.id=e.target + WHERE t.name='lang_detect' AND t.language='rust' + AND json_extract(e.metadata,'$.resolvedBy')='framework'` + ).all(); + + cg.close?.(); + expect(edges.length).toBeGreaterThan(0); + }); +}); diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index 213942a84..7fe49943a 100644 --- a/docs/design/dynamic-dispatch-coverage-playbook.md +++ b/docs/design/dynamic-dispatch-coverage-playbook.md @@ -269,6 +269,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started. | ObjC/Java/Kotlin → JS | React Native event emitters | native `sendEventWithName:`/`emit(...)` → JS `addListener('e', handler)` | S (cross-lang channel) | ✅ **rn-event-channel synthesizer** — matches ObjC `sendEventWithName:@"X"`, Swift `sendEvent(withName: "X", ...)`, and JVM `.emit("X", ...)` to JS `addListener('X', handler)` keyed by literal event name. Same fan-out cap (`EVENT_FANOUT_CAP=6`) as in-language channel. **Subscribe-wrapper fallback** for RN-library APIs (`const Foo = { watchX(listener) { addListener('e', listener) } }`) — when the handler arg is a parameter, falls back to the enclosing function and then the enclosing `constant`/`variable` (reachability-correct attribution to the JS API surface). RNFirebase L **3 push-notification flow edges** (UIApplicationDelegate → JS `onMessage`/`onNotificationOpenedApp`), RNGeolocation S **2 location-event edges** (Swift `onLocationChange`/`onLocationError` → JS `Geolocation`). 🔬 inline arrow handlers `addListener('e', d => …)` (anonymous frontier) | | JS × Swift/Kotlin | Expo Modules | JS `requireNativeModule('X').fn(...)` → Swift/Kotlin `Function("fn") { ... }` | R (extract → synthetic method nodes) | ✅ **expo-modules framework extractor** — parses Swift/Kotlin `Module { Name("X"); Function("y") { ... }; AsyncFunction("z") { ... }; Property("w") { ... } }` literals and synthesizes `method` nodes named after each declaration. JS callsites resolve via existing name-matcher (no separate `resolve()` needed). expo-haptics S **6 method nodes** (`notificationAsync`, `impactAsync`, `selectionAsync` × Swift + Kotlin), expo-camera M **41** (full SDK surface incl. `takePictureAsync`, `record`, `scanFromURLAsync`, view props `width`/`height`), expo SDK sweep L **134** (7 packages, 72 Swift + 62 Kotlin). Same-name JS wrappers in the package itself shadow the native names (`CameraView.tsx`'s `pausePreview` wraps native `pausePreview`); external consumer apps bridge through to native directly. 🔬 closure body extraction (the Function trailing closure isn't a body-range node yet) | | JS × native | React Native Fabric / Codegen + legacy Paper view components | JSX `` → Codegen spec → native class (or Paper `RCT_EXPORT_VIEW_PROPERTY` / `@ReactProp`) | R (extract) + S (native-impl) + JSX | ✅ **fabric-view extractor + fabric-native-impl synthesizer** — extractor parses **both** modern Codegen TS specs (`codegenNativeComponent('Name', ...)`) **and** legacy Paper view-manager macros (`RCT_EXPORT_VIEW_PROPERTY` on ObjC, `@ReactProp` on Java/Kotlin). Emits a `component` node per declaration + a `property` node per declared prop. Synthesizer links the component to its native impl class by RN's convention-based name+suffix (`exact`/`View`/`ComponentView`/`Manager`/`ViewManager`). Combined with `reactJsxChildEdges`, full consumer flow: JSX `` → fabric `component` → native class. Validated on RNSegmentedControl S **(legacy Paper) 1 component + 11 props + 4 bridges**, RNScreens M **(pure Codegen) 27 components + 272 props + 68 bridges** (was 0 before Phase 6), RNSkia L **(hybrid + monorepo) 5 + 14 + 15 across Codegen TS + Android Java + iOS ObjC**. **Monorepo detect** added: probes `packages//package.json` etc. via `listDirectories` when the root manifest is a workspace declaration (was the gating bug on RNSkia). 🔬 Fabric event-handler props (`onTap={cb}`) — JSX attribute extraction needed | +| Rust × JS/TS | Tauri IPC (tauri-specta + raw) | TS `commands.fooBar()` / `invoke('foo_bar')` → Rust `#[tauri::command] fn foo_bar`; TS `events.fooBar.listen()` / `listen('foo-bar')` → Rust `#[derive(…Event)] struct FooBar` | R + X | ✅ **Tauri cross-language IPC bridge** — `frameworks/tauri.ts` joins the TS↔Rust wire boundary tree-sitter can't see (two languages, name-mangled by tauri-specta's `snake_case`→`camelCase` for commands and `PascalCase`→`kebab-case` for events). Indexes every `#[tauri::command]` fn (handles stacked `#[specta::specta]` + attribute ordering) by both its `camelCase` and `snake_case` wire name, and every `#[derive(…Event)]` struct (incl. bare `Event` with `use tauri_specta::Event` in scope, and `#[tauri_specta(event_name="…")]` overrides) by camel/kebab/Pascal/snake. Two TS-side shapes: the **typed** `commands.*` / `events.*` member access is already a reference the JS extractor emits, joined in `resolve()` (call-site granularity); the **raw** `invoke('x')` / `listen('y')` / `once('y')` wire name is a string literal the extractor drops, so `extract()` surfaces it as a `calls` ref attributed to the file node (file granularity — a fn-node id is a sha256 of `path:kind:name:line` a string scan can't rebuild). Confidence 0.7 commands / 0.6 events (name-match wins ties), marked `metadata.resolvedBy:'framework'` like the RN/Fabric bridges; over-matching is harmless (resolve() only joins names hitting a real command/event). Detects via `tauri.conf.json[5]` (root or nested monorepo), an `@tauri-apps/api` dep, or any `tauri::command` in tracked Rust. Validated on **three real apps**: Cmdr L (tauri-specta, Tauri 2 + Svelte 5, 25k nodes) — `callers(get_mcp_port)` **"no callers"→4**, **291 distinct commands** gain framework-tagged TS callers (distinct from codegraph's pre-existing `exact-match` noise), event `accentColorChanged.listen → AccentColorChanged` bridges; **clash-verge-rev M** (raw `invoke`) — **76 of 79 commands** bridge through its `cmds.ts` wrapper, spot-checked all real `#[tauri::command]`s; **pot-desktop S** (raw `invoke`) — 3/3 literal invokes (`lang_detect`, `run_binary`, `reload_store`). **Agent A/B (new-vs-baseline, Sonnet, clash-verge-rev + Cmdr): no free-choice read-reduction** — graph correctness is MCP-verified (`codegraph_callers(get_mcp_port)` returns the real TS consumers through the actual tool), but a free-choice agent greps "who-calls-X" by reflex and Tauri's wire name is the same string on both sides (raw) or paired on one `bindings.ts` line (typed `getMcpPort: () => __TAURI_INVOKE('get_mcp_port')`), so grep bridges it without the graph. The win is in codegraph-only / `impact` / `explore` use, not against a grep-free agent — adoption-gated like the Spring/MyBatis rows. 🔬 raw edges are file-granular not call-site; runtime-built names (`app.emit(var)`, `viewer:file-changed:`, `invoke(varName)`) are intentionally skipped (no literal to read) | (Verify the exact supported set against `src/extraction/languages/` and `src/resolution/frameworks/` before starting — this table is a starting point.) diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 4fc3c3a5b..281c54a70 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -26,6 +26,7 @@ import { swiftObjcBridgeResolver } from './swift-objc'; import { reactNativeBridgeResolver } from './react-native'; import { expoModulesResolver } from './expo-modules'; import { fabricViewResolver } from './fabric'; +import { tauriBridgeResolver } from './tauri'; /** * All registered framework resolvers @@ -68,6 +69,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ expoModulesResolver, // React Native Fabric / Codegen view components — TS spec → component nodes fabricViewResolver, + // Tauri IPC — TS commands/events ↔ Rust #[tauri::command] / Event structs + tauriBridgeResolver, ]; /** @@ -143,3 +146,4 @@ export { swiftObjcBridgeResolver } from './swift-objc'; export { reactNativeBridgeResolver } from './react-native'; export { expoModulesResolver } from './expo-modules'; export { fabricViewResolver } from './fabric'; +export { tauriBridgeResolver } from './tauri'; diff --git a/src/resolution/frameworks/tauri.ts b/src/resolution/frameworks/tauri.ts new file mode 100644 index 000000000..8417f1e32 --- /dev/null +++ b/src/resolution/frameworks/tauri.ts @@ -0,0 +1,308 @@ +/** + * Tauri IPC cross-language bridge resolver. + * + * Joins TypeScript command/event callsites to their Rust handlers: + * + * **Commands** (TS call -> Rust fn): + * - Typed (tauri-specta): `commands.fooBar(...)` where `fooBar` is the + * camelCase form of a `#[tauri::command] fn foo_bar`. + * - Raw: `invoke('foo_bar', ...)` using the exact snake_case wire name. + * + * **Events** (TS listen -> Rust emit): + * - Typed: `events.fooBar.listen(...)` or wrapper functions. + * - Raw: `listen('foo-bar', ...)` using the kebab-case wire name. + * + * The resolver only redirects JS/TS callers to Rust targets; Rust-side + * references resolve through the normal Rust extractor. + * + * The wire join is a heuristic name inference (the real link is a runtime IPC + * hop, not an AST edge), so it runs through `resolve()` like the React Native / + * Fabric native bridges: the edge carries `metadata.resolvedBy: 'framework'` + * with a sub-1.0 confidence, and an exact name-match always wins a tie. (The + * `provenance:'heuristic'` column is reserved for `callback-synthesizer.ts`, + * which fabricates edges for dynamic dispatch that has no statically-resolvable + * name; a Tauri command/event name IS statically joinable, so it belongs on the + * resolver path.) + */ +import type { Language, Node } from '../../types'; +import { + FrameworkExtractionResult, + FrameworkResolver, + ResolutionContext, + UnresolvedRef, +} from '../types'; + +// -- Name conversion utilities ------------------------------------------------ + +/** snake_case -> camelCase (e.g. `get_mcp_port` -> `getMcpPort`). */ +function snakeToCamel(s: string): string { + return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); +} + +/** kebab-case -> snake_case (e.g. `volume-space-changed` -> `volume_space_changed`). */ +function kebabToSnake(s: string): string { + return s.replace(/-/g, '_'); +} + +/** PascalCase -> snake_case (e.g. `VolumeSpaceChanged` -> `volume_space_changed`). */ +function pascalToSnake(s: string): string { + return s.replace(/([A-Z])/g, (c, _, i) => (i > 0 ? '_' : '') + c.toLowerCase()); +} + +// -- Rust-side index ---------------------------------------------------------- + +interface TauriCommand { + /** snake_case Rust fn name (the wire name). */ + rustName: string; + /** The graph node for the Rust fn. */ + node: Node; +} + +interface TauriEvent { + /** snake_case form of the event struct name. */ + rustName: string; + /** The graph node for the Rust struct/enum. */ + node: Node; +} + +/** Per-context lazy cache. */ +const indexCache: WeakMap< + ResolutionContext, + { commands: Map; events: Map } +> = new WeakMap(); + +/** + * Scan all Rust files for `#[tauri::command]` functions and event structs, + * building lookup maps keyed by the JS-visible name. + */ +function buildIndex(context: ResolutionContext) { + const cached = indexCache.get(context); + if (cached) return cached; + + const commands = new Map(); + const events = new Map(); + + for (const file of context.getAllFiles()) { + if (!file.endsWith('.rs')) continue; + const source = context.readFile(file); + if (!source) continue; + + // Commands: #[tauri::command] (possibly preceded by #[specta::specta]) + // followed by `fn name`. + if (source.includes('tauri::command')) { + const nodes = context.getNodesInFile(file); + // Attributes can appear in either order. Match the fn that follows + // an attribute block containing #[tauri::command]. + const cmdRegex = /(?:#\[[^\]]*\]\s*)*#\[tauri::command[^\]]*\]\s*(?:#\[[^\]]*\]\s*)*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/g; + let m: RegExpExecArray | null; + while ((m = cmdRegex.exec(source)) !== null) { + const rustName = m[1]!; + const camelName = snakeToCamel(rustName); + const node = nodes.find( + (n) => n.name === rustName && (n.kind === 'function' || n.kind === 'method') + ); + if (!node) continue; + const entry: TauriCommand = { rustName, node }; + commands.set(camelName, entry); + commands.set(rustName, entry); + } + } + + // Events: struct deriving Event (tauri_specta::Event, tauri::Event, or + // bare Event when `use tauri_specta::Event` is in scope). + if (source.includes('Event')) { + const eventRegex = + /#\[derive\([^\)]*\b(?:tauri_specta::|tauri::)?Event\b[^\)]*\)\]\s*(?:#\[[^\]]*\]\s*)*(?:pub\s+)?struct\s+(\w+)/g; + let m: RegExpExecArray | null; + while ((m = eventRegex.exec(source)) !== null) { + const structName = m[1]!; + + // Check for #[tauri_specta(event_name = "...")] override between + // the derive and the struct keyword. + const blockBefore = source.slice( + Math.max(0, m.index - 200), + m.index + m[0].length + ); + const overrideMatch = blockBefore.match( + /#\[tauri_specta\s*\(\s*event_name\s*=\s*"([^"]+)"\s*\)\]/ + ); + + const snakeName = pascalToSnake(structName); + const camelName = snakeToCamel(snakeName); + const kebabName = overrideMatch?.[1] ?? snakeName.replace(/_/g, '-'); + const nodes = context.getNodesInFile(file); + const node = nodes.find( + (n) => n.name === structName && (n.kind === 'struct' || n.kind === 'enum') + ); + if (!node) continue; + const entry: TauriEvent = { rustName: structName, node }; + events.set(camelName, entry); + events.set(kebabName, entry); + events.set(structName, entry); + events.set(snakeName, entry); + } + } + } + + const result = { commands, events }; + indexCache.set(context, result); + return result; +} + +// -- Extraction (raw invoke/listen wire-name references) ---------------------- + +const JS_EXT_TO_LANGUAGE: Record = { + '.ts': 'typescript', + '.mts': 'typescript', + '.cts': 'typescript', + '.tsx': 'tsx', + '.js': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.jsx': 'jsx', + '.svelte': 'svelte', + '.vue': 'typescript', +}; + +function jsLanguageForFile(filePath: string): Language | null { + const dot = filePath.lastIndexOf('.'); + if (dot < 0) return null; + return JS_EXT_TO_LANGUAGE[filePath.slice(dot).toLowerCase()] ?? null; +} + +/** + * Raw Tauri IPC uses string wire names the JS extractor never emits as + * references: `invoke('foo_bar')`, `listen('foo-bar')`, `once('foo-bar')`. The + * command/event name is a string argument, so the only call edge extraction + * records is to `invoke`/`listen` itself, and `resolve()` never sees the wire + * name. (The typed `commands.fooBar()` / `events.fooBar.listen()` path doesn't + * need this: those are member accesses the JS extractor already emits a `fooBar` + * reference for.) This scan surfaces each wire name as a `calls` reference so + * `resolve()` can join it to the Rust handler / Event struct. + * + * Matches the `invoke`/`listen`/`once` API name on an identifier boundary + * (`\b`), with optional generics and a string-literal first argument. A + * dynamic name (`invoke(`${x}`)`) fails the literal match and is skipped. + * Over-matching is harmless: `resolve()` only joins names that hit a real + * command/event, so a stray `arr.includes(...)`-style call resolves to nothing. + */ +const WIRE_CALL_RE = /\b(?:invoke|listen|once)\s*(?:<[^>(]*>)?\s*\(\s*(['"`])([\w./:-]+)\1/g; + +/** + * Attributed to the file node (`file:`, a stable un-hashed id) rather + * than the enclosing function: a function's node id is a sha256 of + * `path:kind:name:line`, which a string scan can't reconstruct without + * re-parsing. File granularity still answers "which files use this command" for + * `callers` / `impact` — coarser than the typed path's call-site edge, but + * accurate and robust. + */ +function extractWireReferences(filePath: string, source: string): UnresolvedRef[] { + const language = jsLanguageForFile(filePath); + if (!language) return []; + + const refs: UnresolvedRef[] = []; + const fromNodeId = `file:${filePath}`; + WIRE_CALL_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = WIRE_CALL_RE.exec(source)) !== null) { + const wireName = m[2]!; + // Line/column of the wire-name literal, for the edge's location. + const upto = source.slice(0, m.index); + const line = upto.split('\n').length; + const lastNl = upto.lastIndexOf('\n'); + refs.push({ + fromNodeId, + referenceName: wireName, + referenceKind: 'calls', + line, + column: m.index - lastNl - 1, + filePath, + language, + }); + } + return refs; +} + +// -- Resolver ----------------------------------------------------------------- + +const TS_LANGUAGES = new Set(['javascript', 'typescript', 'tsx', 'jsx', 'svelte']); + +export const tauriBridgeResolver: FrameworkResolver = { + name: 'tauri-ipc', + languages: ['javascript', 'typescript', 'tsx', 'jsx', 'svelte', 'rust'], + + detect(context) { + // tauri.conf.json (or tauri.conf.json5) is the definitive marker at root. + if (context.fileExists('tauri.conf.json') || context.fileExists('tauri.conf.json5')) { + return true; + } + // Nested Tauri app (e.g. monorepo): check for src-tauri/tauri.conf.json if tracked, + // though json files might not be tracked. + const files = context.getAllFiles(); + for (const f of files) { + if (f.endsWith('tauri.conf.json') || f.endsWith('tauri.conf.json5')) return true; + } + // Fallback 1: package.json at root depends on @tauri-apps/api. + const pkg = context.readFile('package.json'); + if (pkg && /"@tauri-apps\/api"/.test(pkg)) return true; + + // Fallback 2 (Definitive for monorepos): any tracked Rust file uses tauri::command. + for (const f of files) { + if (!f.endsWith('.rs')) continue; + const src = context.readFile(f); + if (src && src.includes('tauri::command')) return true; + } + + return false; + }, + + extract(filePath, source): FrameworkExtractionResult { + // Only the raw `invoke`/`listen`/`once` wire-name references; the typed + // `commands.*` / `events.*` path resolves through the JS extractor's own + // member references. No framework nodes. + if (!source.includes('invoke') && !source.includes('listen') && !source.includes('once')) { + return { nodes: [], references: [] }; + } + return { nodes: [], references: extractWireReferences(filePath, source) }; + }, + + resolve(ref, context) { + // Only redirect JS/TS callers. + if (!TS_LANGUAGES.has(ref.language)) return null; + + const { commands, events } = buildIndex(context); + + // Strip receiver prefix: `commands.getMcpPort` -> `getMcpPort`, + // `events.volumeSpaceChanged` -> `volumeSpaceChanged`. + const name = ref.referenceName.includes('.') + ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1) + : ref.referenceName; + + // Try command lookup first. + let entry = commands.get(name); + // For raw invoke('snake_name'), also try kebab -> snake conversion. + if (!entry) entry = commands.get(kebabToSnake(name)); + if (entry) { + return { + original: ref, + targetNodeId: entry.node.id, + confidence: 0.7, + resolvedBy: 'framework', + }; + } + + // Try event lookup. + let evt = events.get(name); + if (!evt) evt = events.get(kebabToSnake(name)); + if (evt) { + return { + original: ref, + targetNodeId: evt.node.id, + confidence: 0.6, + resolvedBy: 'framework', + }; + } + + return null; + }, +};