From 873f133c96d6611b88770d580817729fc34c5373 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Fri, 28 Aug 2026 09:46:50 -0500 Subject: [PATCH] feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities. --- CHANGELOG.md | 12 + CLAUDE.md | 3 +- README.md | 1 + __tests__/namespace-object-resolution.test.ts | 78 ++ __tests__/react-hook-handlers.test.ts | 136 +++ __tests__/react-native-bridge.test.ts | 127 +++ __tests__/rn-event-channel.test.ts | 68 ++ __tests__/store-exported-later.test.ts | 67 ++ __tests__/ui-steps-api.test.ts | 273 ++++++ __tests__/ui-steps-model.test.ts | 110 +++ codegraph-kernel/src/tsjs/extractors.rs | 30 +- codegraph-kernel/src/tsjs/fnref.rs | 7 +- codegraph-kernel/src/tsjs/mod.rs | 47 +- docs/design/codegraph-ui-design-spec.md | 37 + src/extraction/function-ref.ts | 15 +- src/extraction/tree-sitter.ts | 78 +- src/resolution/callback-synthesizer.ts | 31 +- src/resolution/frameworks/react-native.ts | 190 +++- src/resolution/import-resolver.ts | 112 ++- src/ui-server/api/index.ts | 9 + src/ui-server/api/screens.ts | 36 +- src/ui-server/api/steps.ts | 723 ++++++++++++++ src/ui-server/api/when.ts | 38 + ui/src/App.svelte | 3 + ui/src/components/TopBar.svelte | 3 +- ui/src/components/screens/ScreenEdge.svelte | 5 +- ui/src/components/steps/StepNode.svelte | 184 ++++ ui/src/lib/adapter.ts | 26 + ui/src/lib/api.ts | 22 +- ui/src/lib/navigation.ts | 24 + ui/src/lib/router.svelte.ts | 22 + ui/src/lib/screens-model.ts | 25 +- ui/src/lib/steps-model.ts | 238 +++++ ui/src/lib/wire.ts | 70 ++ ui/src/views/ScreensView.svelte | 6 +- ui/src/views/StepsView.svelte | 928 ++++++++++++++++++ 36 files changed, 3711 insertions(+), 73 deletions(-) create mode 100644 __tests__/namespace-object-resolution.test.ts create mode 100644 __tests__/react-hook-handlers.test.ts create mode 100644 __tests__/store-exported-later.test.ts create mode 100644 __tests__/ui-steps-api.test.ts create mode 100644 __tests__/ui-steps-model.test.ts create mode 100644 src/ui-server/api/steps.ts create mode 100644 ui/src/components/steps/StepNode.svelte create mode 100644 ui/src/lib/steps-model.ts create mode 100644 ui/src/views/StepsView.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index fefb77d..10d717e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- **A Steps tab in `codegraph ui` — what happens from here.** Pick a screen (or search any symbol and choose *What happens from here*) and the viewer draws everything it sets in motion as typed steps: the handlers wired to its taps and listeners, the calls that cross into native code, the native events that come back, the store actions it writes, and the calls that leave the app into the network, storage, the device or telemetry — one box per step, an arrow for every way one leads to the next, and on each arrow the condition under which it happens. The plumbing between two steps (hooks, helpers, the components in between) is folded into the arrow and listed in the side panel, exactly as the Screens tab folds a tap's chain into one transition. Any step is the next anchor, any link opens as a Flow strip, a cap the walk hit is announced on the step it hit it at, and the picture travels in the URL. React Native + Expo apps get the full picture today; any project gets handlers, stores and calls that leave the index. + +- **React Native apps: Swift native modules and their events connect end to end.** A JS call like `captureView.finalizeCaptureSession()` — where `captureView` is bound to `NativeModules.CaptureView` and the module is a Swift class exposed through an `RCT_EXTERN_MODULE` shim — now resolves to the Swift method itself instead of stopping at the constant, so `codegraph_explore`, the Flow strip and the Steps view follow the code into native. Native → JS events now also land on listeners written inline (`addListener('onZipComplete', (data) => { … })`), attributed to the component that registers them. Re-index after upgrading to pick the new edges up. + +### Fixes + +- **React handlers written with `useCallback` are now symbols.** `const handleSubmit = useCallback(() => {…}, [])` — the way nearly every handler in a React or React Native component is written — is extracted as a function named by its binding (also `React.useCallback`, `useEffectEvent`), so `onPress={handleSubmit}` and `addListener('x', handleSubmit)` resolve to it, its calls are its own rather than the component's, and a tap's handler shows up in `codegraph_explore`, the Screens tab and the Steps tab. A JSX attribute value (`onPress={handleSubmit}`, `renderItem={renderRow}`) and a handler a hook hands back in an object (`return { handleSubmit, handleRetake }`) are now function-as-value references from the component or hook, so the graph knows which functions are wired as handlers. + +- **Stores exported on a later line are read like any other.** `const useStore = create((set, get) => ({ … }))` followed by `export default useStore` (or `export { useStore }`) now has its actions extracted as functions, the same as an `export const` store — previously the two-statement form, common in React Native apps, left every action invisible. + +- **API objects exported as a default namespace resolve through to their functions.** `import Api from './api'` + `Api.upload()` where the module ends in `const Api = { upload, createFolder }; export default Api` now links the call to `upload` itself (through the object's own imports), and a default import of any const named by an `export default NAME` statement finds that const rather than guessing the file's first exported function. + - **A Screens tab in `codegraph ui` — the app the way its user meets it.** One box per screen, an arrow for every way of getting from one to another, and on each arrow the condition under which it happens. Click a screen and each of its transitions is labelled beside the screen at the other end of the line with the last condition checked before it happens — `→ isCollected` above `/object-detail` — laid out so that no two labels overlap and none sits under a line; hover a label, a line, or its row in the side panel for the whole condition and the chain the tap travels through (`HomeSearchResults → ItemCard → openObjectDetail`), with a link to each navigation call. A screen that returns to where it came from is drawn around the boxes rather than through them, shared chrome (a top bar rendered on ten screens) is one node a row above what it opens rather than the same arrows from every box, a screen that opens many others is wide enough to follow each line back to it and its lines take separate paths through the gap so they fan out instead of stacking, hovering picks the line nearest the pointer, and a helper that chooses the destination after login shows its fork. Projects whose graph holds screen navigation land on this tab. Expo Router apps today. - **The Map covers a multi-root project.** A React Native app's `ios/` beside its `src/` — or any second root holding a fifth of the code — is now on the picture, one level deeper, instead of the map silently drawing only the larger root. diff --git a/CLAUDE.md b/CLAUDE.md index 862e7d5..f888067 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the - `src/installer/` — see below. - `src/bin/codegraph.ts` — CLI (commander). Subcommands: `install`, `init`, `uninit`, `index`, `sync`, `status`, `query`, `files`, `context`, `affected`, `serve --mcp`. - `src/ui/` — terminal UI (shimmer progress, worker). +- `src/ui-server/` — the `codegraph ui` browser viewer's read-only JSON API (`api/`: one module per endpoint — `node`, `flow`, `map`, `screens`, `steps`, `deadcode`, `trails`…) and static server; the Svelte viewer itself lives in `ui/` (see `docs/design/codegraph-ui-design-spec.md`). `api/screens.ts` (the app as screens and transitions) and `api/steps.ts` (what happens from a screen or a symbol, as typed steps — screens, handlers, native bridge calls and events, store actions, calls that leave the index) share one fold: everything between two boxes is `via`, and the branch guards along it join into `when` (`graph/branch-guards.ts`, read at request time). ### NodeKind / EdgeKind @@ -152,7 +153,7 @@ Two functions in `src/mcp/tools.ts` scale explore with indexed file count. This ### Dynamic-dispatch coverage — the flow must EXIST in the graph end-to-end -Static tree-sitter extraction misses computed/indirect calls, so flows break at dynamic dispatch and the agent reads to reconstruct them. Synthesizers/resolvers bridge these so `codegraph_explore` connects them end-to-end (`src/resolution/callback-synthesizer.ts`, `src/resolution/frameworks/`). Channels today: callback/observer, EventEmitter, **React re-render** (`setState`→`render`), **JSX child** (`render`→child component), django ORM descriptor. All synthesized edges are `provenance:'heuristic'` with `metadata.synthesizedBy` + `registeredAt` (the wiring site), surfaced inline in `codegraph_explore`'s Flow section and the `codegraph_node` trail. +Static tree-sitter extraction misses computed/indirect calls, so flows break at dynamic dispatch and the agent reads to reconstruct them. Synthesizers/resolvers bridge these so `codegraph_explore` connects them end-to-end (`src/resolution/callback-synthesizer.ts`, `src/resolution/frameworks/`). Channels today: callback/observer, EventEmitter, **React re-render** (`setState`→`render`), **JSX child** (`render`→child component), **React Native native→JS events** (`sendEvent(withName:)` / JVM `emit` → the `addListener` handler, named or inline, `rn-event-channel`), django ORM descriptor. The JS→native direction is a *resolver* (`frameworks/react-native.ts`: `RCT_EXPORT_METHOD`, `RCT_EXTERN_MODULE` Swift shims, TurboModules), which trusts receiver evidence — an alias bound to `NativeModules.X` — over the import resolver. All synthesized edges are `provenance:'heuristic'` with `metadata.synthesizedBy` + `registeredAt` (the wiring site), surfaced inline in `codegraph_explore`'s Flow section and the `codegraph_node` trail. **Principle: partial coverage is WORSE than none.** Bridging one boundary but not the next reveals a hop the agent then drills + reads to finish. Measured on excalidraw: react-render alone *raised* reads to 5–7; only completing the flow (adding the jsx-child hop) dropped it to 0–1. **Always close the flow end-to-end and re-measure** — never ship a half-bridged flow. diff --git a/README.md b/README.md index 8e9e56c..81c4fbb 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,7 @@ What you get on that screen: - Click any file path to open the **file view**: everything that file depends on, its outline in source order, and everything that depends on it. Its **Source** tab shows the whole file with the same gutter markers, plus an arc in the left margin for every call that stays inside the file — the one place a file's internal call structure is legible, because source order does the layout. A 6,800-line file scrolls at full speed. - **Ask for a path.** Type "how does execute reach getFile" (or `execute -> getFile`) and you get the **flow**: one card per hop, each opened at the line that makes the next call. Hops that no static edge records — a callback, an interface dispatch, a React re-render — are drawn dashed and name where the handler was wired. "Read as flow" turns a walk you did by hand into the same strip. - **And when the path runs out, it says where.** A flow that doesn't get there ends in "Where the graph stops": the kind of dispatch that ended it (a computed member call, a `getattr`, a reflective invoke, a message bus), its line, the key when the source spells one out, and a shortlist of what could be on the other side — plus the name-only matches CodeGraph refused to follow, with their confidence. Nothing is guessed, and a flow that does connect never shows it. +- **What happens from here.** On an app with screens, the **Screens** tab draws one box per screen and an arrow for every way of getting from one to another, each labelled with the condition under which it happens. The **Steps** tab does the same for what happens *on* a screen: pick one (or any symbol) and you get its handlers, the calls that cross into native code, the native events that come back, the store actions it writes and the requests that leave the app, as typed steps with the plumbing between them folded into the arrows — the whole capture-to-upload flow of a React Native app on one picture, with every step a click from the next anchor or a Flow strip. - **The map**: the whole project at module granularity, laid out from the graph with dependencies pointing down — never drawn by hand, and the same picture every time. Cycles are listed rather than straightened away. - **Take the picture with you.** A flow strip or a map can be copied as an image straight into a pull-request comment, or saved as an SVG for a README — always in the light theme, whichever one you are reading in, with a caption saying what the picture is. The SVG is real text, so it stays sharp at any size and the names in it are selectable. - **Keep a walk.** Press **Save trail** on the trail bar, name it, and the path is kept — listed on the empty screen and on Entry points, above the suggestions, and reopened at the symbol you left with the whole walk restored. Steps are remembered by what they are, not where they sat, so a saved trail survives editing the code it describes; when something does move it says which step moved, which was renamed away, and how much of the walk still opens. Trails are plain JSON under `.codegraph/ui/trails/` (git already ignores it), and **Export** hands you the file if you would rather commit one. diff --git a/__tests__/namespace-object-resolution.test.ts b/__tests__/namespace-object-resolution.test.ts new file mode 100644 index 0000000..a361424 --- /dev/null +++ b/__tests__/namespace-object-resolution.test.ts @@ -0,0 +1,78 @@ +/** + * The default-export namespace object — `const UploadApi = { uploadARCapture }; + * export default UploadApi` — and a call through it from another file. Two + * things have to hold for `handleZipComplete → uploadARCapture` to exist: + * the default import must find the constant the `export default` statement + * names (it is not exported at its declaration), and the member must resolve + * to the binding the shorthand property carries, through the object's own + * imports. + */ +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'; + +describe('namespace object default exports', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-namespace-object-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function write(rel: string, content: string): void { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + it('resolves Api.member() to the function the shorthand property names', async () => { + write('package.json', '{"name":"app"}'); + write('src/api/frames.ts', 'export async function uploadARCapture(uri: string) {\n return uri\n}\n'); + write('src/api/folders.ts', 'export function createFolder(name: string) {\n return name\n}\n'); + write( + 'src/api/index.ts', + "import { uploadARCapture } from './frames'\n" + + "import { createFolder } from './folders'\n" + + 'function localHelper() {\n return 1\n}\n' + + 'const UploadApi = {\n uploadARCapture,\n makeFolder: createFolder,\n localHelper,\n}\n' + + 'export default UploadApi\n' + ); + write( + 'src/hooks.ts', + "import UploadApi from './api'\n" + + 'export function handleZipComplete(uri: string) {\n' + + ' UploadApi.makeFolder(uri)\n' + + ' UploadApi.localHelper()\n' + + ' return UploadApi.uploadARCapture(uri)\n' + + '}\n' + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const handler = cg.getNodesByName('handleZipComplete')[0]!; + const callees = cg.getCallees(handler.id).map((c) => c.node.name).sort(); + cg.close(); + expect(callees).toEqual(['createFolder', 'localHelper', 'uploadARCapture']); + }); + + it('a default import of a later-exported const finds that const, and a method inside it', async () => { + write('package.json', '{"name":"app"}'); + write( + 'src/store.ts', + 'const useStore = {\n read() {\n return 1\n },\n}\nexport function unrelated() {\n return 2\n}\nexport default useStore\n' + ); + write('src/use.ts', "import store from './store'\nexport function consume() {\n return store.read()\n}\n"); + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const consume = cg.getNodesByName('consume')[0]!; + const callees = cg.getCallees(consume.id).map((c) => c.node.name); + cg.close(); + // Without the `export default NAME` binding the default import guessed the + // first exported function (`unrelated`); now it is the object, and the + // member resolves inside it. + expect(callees).toEqual(['read']); + }); +}); diff --git a/__tests__/react-hook-handlers.test.ts b/__tests__/react-hook-handlers.test.ts new file mode 100644 index 0000000..2d361c4 --- /dev/null +++ b/__tests__/react-hook-handlers.test.ts @@ -0,0 +1,136 @@ +/** + * React handler hooks name the function they wrap. + * + * `const handleSubmit = useCallback(() => {…}, [])` is how nearly every + * handler in a React / React Native component is written, and the arrow is + * anonymous only syntactically — the declarator is the name every + * `onPress={handleSubmit}` and `addListener('x', handleSubmit)` uses. Without + * a node the handler's calls attribute to the component and the trigger of a + * flow (the tap, the native event) has nothing to resolve to. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +const refsFrom = (result: ReturnType, id: string) => + result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => r.referenceName); + +describe('useCallback handlers', () => { + it('extracts the wrapped arrow as a function named by the declarator, inside the component', () => { + const code = ` + import { useCallback, useMemo, useEffect } from 'react' + import { finalize, upload, log } from './api' + export default function ReviewScreen() { + const handleApprove = useCallback(() => { + finalize() + }, []) + const handleZip = useCallback(async (data: { uri: string }) => { + await upload(data.uri) + }, []) + const total = useMemo(() => 1 + 1, []) + useEffect(() => { + log('mounted') + }, []) + return + {/each} + {/if} + + {/if} + + +