feat(ui): Steps for servers — route roots, server effects, request/decorator triggers, guards for Python/Java/Kotlin/C#/Go/C

- api/route-roots.ts: the symbol a route runs (references-edge handler, exported page component, or the route itself for an inline handler), shared by steps and screens; the bare Steps tab lists an API's endpoints by router file
- api/effects.ts: database / response / queue / email / payments / cache / auth / process / network / storage / device / telemetry, matched on the call as written per language family, with model + read/write and the literal status on a response site
- graph/branch-guards.ts: callSitesForFile (the whole member chain), memberTypesInTree, decoratorsForFile, request/decorator triggers with the middleware/guard chain; guard + argument rules for Python, Java, Kotlin, C#, Go and C
- steps.ts: classify on the chain before trusting a name match, retarget this.x.y() by declared type, skip test doubles after the effect pre-check, project kind on the wire
- viewer: kindWord/kindWords per project kind, endpoint chooser, response boxes labelled by status codes
- python.ts: FastAPI detected from a monorepo sub-directory; is-test-file: samples/examples package paths are not tests
- tests: ui-steps-api-servers, ui-effects, branch-guards-languages; spec §3.13 Servers paragraph, CHANGELOG, plan doc

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
Colby McHenry
2026-08-28 13:45:38 -05:00
co-authored by Claude Fable 5
parent 5e06204deb
commit 950686def4
22 changed files with 3869 additions and 190 deletions
@@ -0,0 +1,446 @@
# Steps & Screens for APIs and web apps — handoff
**Status:** plan, written 2026-08-28 at the end of the session that built the Steps view and the
readings it rests on (Expo + React Native app, `amniservices-mobile-app`). **Updated the same day, later
session: P0, P1, P2, P5 and P6 are built** (see the per-item notes marked *Built*); P3 (cross-tier
channels), P4 (Next.js as a Screens app) and P7's agent A/B numbers are open. Every claim about what a
resolver emits *today* was verified against the source on this date — re-verify before building on it,
the resolvers move. What was learned building it, beyond the plan: the index keeps only the LAST
segment of a deep member call (`create` for `prisma.user.create`) and name-matches it — often to the
wrong `create` at confidence 0.4 — so the Steps walk reads every call **as written** from the tree at
request time (`callSitesForFile`) and classifies on the chain; and the declared types of a class's
members (`private readonly usersService: UsersService`, `OwnerRepository owners`) are read from the
class body (`memberTypesInTree`) to send `this.usersService.findByEmail(…)` where the type says and to
call `owners.save` the database. Validation pictures: `gothinkster/node-express-realworld-example-app`,
`brocoders/nestjs-boilerplate`, `nestjs/nest/sample`, `fastapi/full-stack-fastapi-template`,
`Netflix/dispatch`, `spring-projects/spring-petclinic`, `spring-petclinic/spring-petclinic-kotlin`,
`dotnet-architecture/eShopOnWeb`, `jqlang/jq`, `redis/redis`, `android/nowinandroid`,
`Dimillian/IceCubesApp`, `TryGhost/Ghost` — each shot headlessly (`codegraph ui --no-open` + Playwright)
and read against the mobile app's picture.
**Goal.** The two pictures — **Screens** (`#/screens`, design spec §3.12) and **Steps**
(`#/steps`, §3.13) — must be as good on an Express / NestJS / Fastify API, a Next.js / React Router /
SvelteKit web app, and a monorepo that has both, as they are on the mobile app today. "As good" is
defined precisely in §1 below; it is not "draws something".
Companion reading, in this order: `docs/design/codegraph-ui-design-spec.md` §1 (principles), §3.12,
§3.13, §3.14; `CHANGELOG.md` `[Unreleased]` (the user-facing description of what shipped);
`docs/design/dynamic-dispatch-coverage-playbook.md` (the coverage rules and the validation method —
**"partial coverage is worse than none"** governs everything here); `CLAUDE.md` (tests, kernel, docs).
---
## 1. The bar: what "up to par" means
On the mobile app, selecting `/capture/review` and walking to the upload gives the reader, per link:
| Reading | Example | Where it comes from |
|---|---|---|
| **The step itself, typed** | `⇢ finalizeCaptureSession` (native call), `⇠ onZipComplete` (native event), `setZipUri` (store action), `axios.post(\`…/oauth/token\`, {…})` (leaves the index) | `src/ui-server/api/steps.ts` classification |
| **FIRES FROM** — what triggers it | `onSubmit · useFormik(…) in LoginButton`, `onPress · <Button>`, `addListener('onZipComplete')` | `graph/branch-guards.ts` `triggersForFile` — read at request time from the cached tree |
| **via** — the plumbing folded into the arrow | `via LoginButton → handleLogin`, `via uploadARCapture` | the walk's fold (`steps.ts`) |
| **WHEN** — the conditions, as words, one row per scenario | `WHEN NOT (busy \|\| late)` once, then `AND NOT user?.organization_id` · site, `AND user?.organization_id AND (…)` · site … | `guardsForFile` + `ui/src/lib/conditions.ts` (`scenarios`, `whenTokens`) |
| **with what** — the arguments as written | `SecureStore.setItemAsync('userEmail', values.email)`, `client.post('/frames', { uri })` | `callArgumentsForFile` |
| **Honesty** | other screens are boundaries (``), caps announced on the step they hit, synthesized hops dashed, a crossing needs evidence | `steps.ts` caps + `evidenced` rule |
An API or web project reaches the bar when its canonical flow — **request → guard/middleware →
handler → service → database → queue/email/other service → response** for an API, **page → data
fetch → user action → server action/route → database → redirect** for a web app — shows every one of
those readings on every link, on a real mid-size repo, with the caps and the boundaries behaving as
they do on the mobile app. Numbers to record per framework are in §7.
---
## 2. How the pictures work today — the facts they rest on
Read this before touching anything. Each picture is a pure function of a small set of graph facts;
extending the pictures to a new framework is almost entirely a matter of making the **same facts
exist** for it, plus wording.
### 2.1 Screens (`src/ui-server/api/screens.ts`, `ui/src/lib/screens-model.ts`)
| Needs | Today comes from |
|---|---|
| `route` nodes named by path (`/capture/review`) | `resolution/frameworks/expo-router.ts` (file-path routing); React Router / Next.js *pages* routes from `frameworks/react.ts` also exist but carry no navigation |
| route → the component that renders it (`calls` / `instantiates` edge out of the route) | expo-router resolver (default export of the screen file) |
| `navigates` edges from the function that pushes a path to the route it names, with `metadata.href` / `navMethod` | expo-router resolver (literal / template / pathname-object hrefs) + `resolution/expo-router-synthesizer.ts` (helper return values → `provenance: 'heuristic'`) |
| the attribution walk BACK from the navigation call to a screen's component, folding the chain into `via` | `screens.ts` (`attribute`, caps: 7 hops / 30 callers / 800 visited) |
| `when` per site | `createSiteReader(...).when` in `api/when.ts` → `guardsForFile` |
If there are no `navigates` edges the endpoint answers `routed: false` and the view says "No screen
navigation in this graph". **That is what every API and every non-Expo web app gets today.**
### 2.2 Steps (`src/ui-server/api/steps.ts`, `ui/src/lib/steps-model.ts`, `ui/src/views/StepsView.svelte`)
The walk: from the anchor, breadth-first over `calls` / `instantiates` / `navigates` /
function-as-value `references` (`metadata.fnRef`) / function→function `contains`, folding every node
that is not a step into `via`. A node **is** a step when it is one of:
| Kind | Evidence today | Server rule |
|---|---|---|
| `screen` | target is a `route` node | any edge into a route (a `navigates` edge in practice) |
| `trigger` (handler) | a function passed as a value (`fnRef`), **or** called from under an event binding — JSX prop, `on*` option, runs-later callback (`triggerInTree`) — and not a component, not a store action | `steps.ts` classification, `looksLikeComponent` |
| `bridge` ⇢ | language family changes JS→native **and** the edge is evidenced: `metadata.bridge === 'react-native'`, `resolvedBy === 'framework'`, or `provenance: 'heuristic'` | `crossing()` + `evidenced`; a plain name-matched cross-family call is dropped |
| `event` ⇠ | native→JS, evidenced (`synthesizedBy: 'rn-event-channel'`) | same |
| `store` | function in a store **file** (`STORE_FILE` regex: `stores?/`, `storage/`, `.store.ts`, `.slice.ts`…) — file-name evidence, the legend says so | `isStoreFile` |
| `effect` | an unresolved call, or a call that resolved to a `constant`/`variable`, whose text matches the curated `EFFECTS` table: `network`, `storage`, `device`, `telemetry` | `effectCategory`; one box per (function, category), `apis[]` listed |
Boundaries and caps (all announced on the step, `cut`): another **screen** (`through=1` enters it), a
native event landing in a **component** of another screen, depth (8 default, ≤14), fan-out per node
(80), folded nodes per step (300), steps per picture (120 default, ≤400); hubs (fan-in ≥ 40) and shared
chrome (a component rendered by ≥ 5 parents) are dead ends counted in `truncated`. HOC wrappers
(`memo(X)`) are seen through via the file-scope function reference within the wrapper's lines.
**Where the anchor's root comes from:** for a `route` anchor the walk starts at the component the
route renders — found as the first `calls`/`instantiates` edge OUT of the route node (`componentOf`
in `buildSteps`). This is correct for Expo Router and **wrong for every API framework** (§3, P0).
### 2.3 The request-time readings (`src/graph/branch-guards.ts`)
Nothing about these is stored in the index; they parse the file (LRU of 8 trees, 256 KB cap) and
answer per call site `(line, column)`:
- `guardsForFile` → the branch conditions (JS-family + Swift rules; a disjunctive guard keeps its parens).
- `callArgumentsForFile` → the argument list abbreviated (strings whole, objects as keys, `[…]`, `() => …`, `f(…)`, Swift labels).
- `triggersForFile` → `{ kind: 'prop' | 'option' | 'callback', name, of }`: JSX attribute (event prop, or any prop given a function), `on*` object key (with the call it configures, through arrays), argument of a runs-later callee (`LATER_CALLEES`). Named handlers (`const handleX = useCallback(…)`) are boundaries.
All three are **JS-family only** (`supportsBranchGuards`), Swift for guards. Python / Java / Go / Ruby
/ PHP have no rules — an API in those languages gets no WHEN, no arguments, no FIRES FROM (§3, P5).
### 2.4 The rest of the surface
- Wire types are mirrored by hand in `ui/src/lib/wire.ts`; the adapter method `steps` is **optional**
(`ui/src/lib/adapter.ts`), `NavigationDriver.stepsHref` is **required** (a host driver must add it).
- Conditions vocabulary: `ui/src/lib/conditions.ts` (`WHEN`/`AND`/`OR`/`NOT` tokens, `scenarios`, common-prefix factoring). Both views use it.
- Tests: `__tests__/ui-steps-api.test.ts` (real RN + Expo fixture, end to end — **copy its shape for every new framework**), `ui-steps-model.test.ts`, `ui-conditions.test.ts`, `branch-guards.test.ts` (guards, arguments, triggers), `ui-screens-model.test.ts`, `expo-router.test.ts` (routed fixture + `buildScreens`).
- **Kernel parity:** TypeScript/JS *extraction* runs in the Rust kernel (`codegraph-kernel/src/tsjs/`); the TS extractor is the wasm fallback. Any extractor change → mirror in Rust, `npm run build:kernel`, test on both paths (`CODEGRAPH_KERNEL=0` for wasm) plus `kernel-tsjs-parity.test.ts`. Resolvers, synthesizers and the request-time readings are TS-only — no parity work.
- Verify visually: `npm run build` → `codegraph index` in the target project → `codegraph ui --no-open --port 4747 <project path>` → `GET /api/steps?symbol=<route name>` → headless playwright (`createRequire` from a repo that has it; `waitUntil: 'load'`, not `networkidle` — the viewer holds an SSE stream). See the auto-memory note `codegraph-viewer-workflow`.
---
## 3. What an API / web project gives us today (verified 2026-08-28)
| Framework | Route nodes | Route → handler | Navigation | Notes |
|---|---|---|---|---|
| **Express / Koa** (`frameworks/express.ts`) | `GET /path` from `app|router.METHOD('/path', …)` | **named handler**: a `references` edge route → handler (last argument; earlier arguments = middleware, **not linked**). **Inline arrow handler**: the route node itself gets `calls` edges to every function its body calls (regex; `RESERVED_CALLS` filtered) — the route *is* the handler | none | `router.use('/prefix', sub)` mounting is not prepended to paths (label only) |
| **NestJS** (`frameworks/nestjs.ts`) | `GET /users/:id` = `@Controller` prefix + `@Get` path; also GraphQL `@Query/@Mutation`, `@MessagePattern/@EventPattern`, `@SubscribeMessage` | `references` edge route → the decorated method; DI `this.svc.method()` resolves via receiver type (playbook: "no dynamic-dispatch hole") | none | `@UseGuards/@UseInterceptors/@UsePipes`, `@OnEvent`, `@Process/@Processor`, `@Cron` are **not** modelled — no guard chain, no event/queue channel |
| **Next.js** (`frameworks/react.ts`) | `pages/**` and `app/**` files with `export default` → a route named by path (`/blog/:slug`) | none (the page component is the default export in the same file, not linked from the route) | none — no `navigates` for `<Link href>`, `router.push`, `redirect()` | `app/api/**/route.ts` handlers (`export async function GET`) are **not** routes; server actions (`'use server'`) unknown; `middleware.ts` unknown |
| **React Router** | `<Route path component={C}/>` / `element={<C/>}`, object data-router (literal form) | `references` to the component | none | |
| **SvelteKit / Vue / Nuxt / Astro** | file routes | `svelteKitLoadEdges`, `vueTemplateEdges`, Pinia/Vuex channels | none | |
| **FastAPI / Django / Flask / Spring / Laravel / Rails / Gin / Axum** | routes + handler edges (resolvers) | yes | — | **no WHEN / arguments / triggers** (language rules missing) |
What the viewer does with that today: **Entry points** lists every route with its handler (this is the
"Screens" of an API today); **Screens** answers "no screen navigation" for all of them; **Steps**
anchored on an API route finds no root (`componentOf` looks for `calls`/`instantiates` out of the route;
Express named / Nest give `references`; an Express inline route's *first callee* becomes the root —
wrong) and draws the anchor alone. So the first task is small and unblocking.
Synthesizer channels that already exist and matter here (`resolution/callback-synthesizer.ts`):
`eventEmitterEdges` (JS `.on('x', fn)` ↔ `.emit('x')`), `springEventEdges`, `laravelEventEdges`,
`celeryDispatchEdges`, `sidekiqDispatchEdges`, `mediatrDispatchEdges`, `reduxThunkEdges`,
`rtkQueryEdges`, `objectRegistryEdges`, `ginMiddlewareChainEdges`, `svelteKitLoadEdges`. There is **no**
channel for: BullMQ / Bull (`queue.add('job')` ↔ `@Process('job')` / `new Worker('q', fn)`), Nest
`EventEmitter2` (`emit('x')` ↔ `@OnEvent('x')`), socket.io / Nest gateways, **client `fetch` → server
route**, tRPC, Next server actions. Those are the cross-tier hops — the API equivalent of the RN
bridge — and they are where a web app's "capture → upload" story breaks today.
---
## 4. The mapping — same pictures, same words, different facts
Keep the visual language exactly (spec §2 and §3.13): boxes, labelled arches, one accent, dashed = a
place the graph cannot follow into, accent rule = the code crosses a boundary. Only the *evidence* and
the *words* change.
| Mobile app (built) | HTTP API | Web app (Next.js / React Router / SvelteKit) |
|---|---|---|
| **screen** `/capture/review` — a box; other screens are boundaries | **endpoint** `POST /users` — a box; another endpoint reached by an internal HTTP call is a boundary | **page** `/blog/[slug]` — a box; another page reached by `<Link>` / `router.push` / `redirect()` is a boundary — this is the Screens picture proper |
| the entry screen `/`; Screens = transitions between screens | no entry; **Entry points** is the list. A "Routes" picture (endpoints + calls between them) only if a repo actually has inter-endpoint calls — measure before building | `/` (or the root layout); Screens = `<Link>` / `router.push` / `redirect` / `<a href>` between pages (P4) |
| **handler** `handleLogin` — FIRES FROM `onSubmit · useFormik(…)` | **handler** `createUser` — FIRES FROM `POST /users` **after** `authenticate, validate(schema)` (Express middleware args), `@UseGuards(JwtGuard)` (Nest); a queue consumer FIRES FROM `@Process('email')` / `new Worker('email')`; a cron FIRES FROM `@Cron('0 * * * *')`; an event listener FIRES FROM `@OnEvent('user.created')` | a page's data fetch FIRES FROM **page load** (`getServerSideProps`, RSC render, `load()`); a client handler FIRES FROM `onSubmit · <form>` / `action={createPost}`; a server action FIRES FROM the form/handler that calls it |
| **⇢ native call** (JS→Swift, RN bridge evidence) | **⇢ another tier**: outbound HTTP to another service (`fetch('https://…')` = effect `network`; to **our own** route with a literal path = a link to that endpoint box), queue publish (`queue.add('email', {…})` → ⇢ the consumer) | **⇢ server**: client `fetch('/api/users')` → the `route.ts` handler; a server action call from a client component; a tRPC mutation → its procedure |
| **⇠ native event** (`sendEvent(withName:)` → listener) | **⇠ from a queue / bus**: the consumer landing (`@Process('email')`), an event landing (`@OnEvent`), a websocket message landing | **⇠ from the server**: SSE / websocket / push landing in a client handler; `revalidatePath` (announce, don't draw) |
| **store action** (by store file) | **data**: an ORM / repository / query call — `prisma.user.findMany({ where, select })`, `this.userRepo.save(user)`, `User.findOne(…)`, `knex('users').insert(…)`, `db.query(sql)` — evidence = the receiver's import origin (prisma / typeorm / mongoose / drizzle / knex / pg / mysql2 / sequelize / kysely) or a known repository type; **the model or table comes from the receiver or the first argument**, read vs write from the method name | same as API on the server side; on the client, a store (Zustand / Redux / React Query cache) as today |
| **outside the index**: `network`, `storage`, `device`, `telemetry` | add **`database`** (above), **`queue`** (bull/bullmq `add`, `sqs.send`, `kafka.produce`, `pubsub.publish`), **`email`** (nodemailer, sendgrid, resend, ses), **`payments`** (stripe, braintree), **`cache`** (redis / ioredis / memcached / `cache.set`), **`auth`** (jwt sign/verify, bcrypt/argon), **`response`** (below), `storage` gains S3 / GCS / fs | same, plus `response` = `NextResponse.json`, `redirect()`, `notFound()` |
| — | **response** as a step: every `res.status(404).json({ error })`, `throw new NotFoundException(…)`, `reply.code(201).send(…)`, `return c.json(…)`, `raise HTTPException(…)` is a scenario row with its **WHEN** and its **arguments** (the body). Together they are the endpoint's contract *as the code has it* — the single most valuable reading for an API, and it falls out of the existing scenario rows once `response` is an effect category | `redirect('/login')` is both a response and a navigation (draw as the navigation) |
| **WHEN** (guards, words, scenario rows) | same — plus the guard/middleware chain is the *shared prefix* said once (`FIRES FROM POST /users after authenticate`) | same |
| **with what** (arguments) | same; especially `res.status(404).json({ error })`, `prisma.user.create({ data: { email, name } })`, `fetch(\`/api/users/${id}\`, { method: 'POST' })` | same |
| **via** (folded plumbing) | controller → service → repository chains fold into `via` as hooks do today; the panel promotes it (`--ink-2`) | same |
Words in the legend and the panel switch on the anchor: when the anchor's route name leads with an HTTP
verb (`splitRouteName` in `api/routes.ts`), `screen` reads **endpoint**, `store` reads **data**,
`bridge` reads **crosses a tier**, `event` reads **arrives from a queue / bus / the server**. Keep
`kindWord()` in `steps-model.ts` as the one place that decides.
---
## 5. Work plan, in order
Each item: what, where, the evidence rule (never guess — a wrong edge is worse than none), the test,
and what "done" looks like on the picture. Do them in this order; P0 unblocks everything, P1P3 make an
API picture worth looking at, P4 makes a web app a Screens app, P5 widens the languages, P6 is words,
P7 is the proof.
### P0 — The root of an API route (small, unblocking)
*Built* — `src/ui-server/api/route-roots.ts` (`routeRoots`, shared by `steps.ts` and `screens.ts`), the
chooser lists endpoints by router file, `WireStep.screen` gained `endpoint` / `inline`; test
`__tests__/ui-steps-api-servers.test.ts` (Express named + inline, Nest, FastAPI, Spring in one fixture).
*Where:* `src/ui-server/api/steps.ts` (`buildSteps`, the `componentOf` map), and the same map in
`screens.ts` for consistency.
*Rule:* the root of a route anchor is, in order: (1) the target of the route's `references` edge whose
target is a function/method (Express named handler, Nest method, React Router component); (2) the
route's `calls`/`instantiates` target **only when it is a component** (`looksLikeComponent`, Expo/React
pages); (3) the route node itself when it carries `calls` edges and nothing else (Express inline arrow —
walk its callees as if the route were the handler; label the anchor `POST /users` and say "inline
handler" in the sub line). Cross-check with the routing manifest (`cg.getRoutingManifest`,
`api/routes.ts` resolves `handlerId` by file+line+name) and prefer it when both exist.
*Also:* the Steps chooser (`StepsView.svelte`, the `!asked` branch) lists **routes** from `/api/routes`
when `/api/screens` is not routed — grouped by router file, `METHOD path`, most-connected first.
*Test:* extend `ui-steps-api.test.ts` with an Express fixture (one named-handler route with middleware
args, one inline-arrow route) and a Nest fixture (controller with `@Controller('users')` +
`@Get(':id')` + `@Post()`; a service injected via constructor; a repository). Assert the root, the first
row, and that the walk reaches the service and the repository call.
*Done when:* `#/steps?symbol=POST%20/users` on the fixture draws the handler's steps, not the anchor alone.
### P1 — Effects for servers: `database`, `response`, `queue`, `email`, `payments`, `cache`, `auth`
*Built* — `src/ui-server/api/effects.ts` (`classifyEffect`, `responseStatus`; rules per language family,
`process` and Android rows added beyond the plan; `effect.model` / `access`, `site.status`, a response box
labelled by its codes); tests `__tests__/ui-effects.test.ts`. Matching is on the call as written and on the
receiver's declared type when the call leaves the index through it — see the status note at the top.
*Where:* `EFFECTS` in `steps.ts` (make it a module of its own, `api/effects.ts`, with a table per
category and unit tests — it is about to grow); `stepSub`/legend words in `steps-model.ts` / `StepsView.svelte`.
*Rules:*
- `database`: the receiver is a **known ORM client** — decide by the reference text *and* the import
origin of the receiver's binding when the graph has it (`prisma.*` where `prisma` is imported from
`@prisma/client` or a project file that constructs `new PrismaClient()`; `this.repo`/`this.*Repository`
typed `Repository<T>` (TypeORM); `Model.find*/create/update*/delete*` on a Mongoose model; `knex(…)`,
`db.select/insert/update/delete` (Drizzle), `pool.query`/`client.query` (pg), `sequelize`/`Model.*`,
`kysely`). The step's label is the call with its arguments as today; add `effect.model` = the model /
table when it can be read (`prisma.user` → `user`; `Repository<User>` → `User`; `knex('users')` →
`users`; raw SQL: first table after `FROM|INTO|UPDATE|JOIN`), and `effect.access = 'read' | 'write'`
from the method name (`find*/get*/count/aggregate/select` vs `create/update/upsert/delete/save/insert/remove`).
Box: `prisma.user.create({ data })` / sub `data · write · user · createUser`.
- `response`: `res.status(…).json|send|end`, `res.json|send|sendStatus|redirect|render`,
`reply.code|send`, `c.json|text|redirect` (Hono), `NextResponse.json|redirect`, `throw new
*Exception(…)` / `throw new HttpError(…)` / `next(err)`, Python `raise HTTPException`, Spring
`ResponseEntity.*`, Go `c.JSON(…)`/`http.Error`. One box per (function, `response`) with `apis[]` as
today — **but the panel's scenario rows are the contract**, so keep every site with its WHEN and
arguments. Read the status code out of the arguments when literal (`status(404)`) and put it on the
site (`site.status`) so a row can say `404 · { error }`.
- `queue`, `email`, `payments`, `cache`, `auth`: receiver/method tables like `network` today. Keep the
table curated and documented; false positives here are visible noise.
*Test:* `api/effects.test.ts` over the table; extend the P0 fixtures with a Prisma create, a
`res.status(404).json`, a `throw new NotFoundException`, a `queue.add('email', {…})`.
*Done when:* `POST /users` shows `prisma.user.create({ data })`, `queue.add('email', {…})`, and the
`response` box whose rows read `WHEN NOT user → 404 · { error: 'not found' }` / `always → 201 · user`.
### P2 — Triggers for servers: the request, the guard chain, jobs, events, cron
*Built* — `request` / `decorator` trigger kinds with `after` (the chain); Express-family middleware from the
registration's arguments, guard decorators from `decoratorsForFile` (the index keeps no decorators), FastAPI
`dependencies=[…]`; consumer decorators on a function anchored by name. The queue-consumer *reachability*
(producer → `@Process`) is P3's.
*Where:* `triggerInTree` in `graph/branch-guards.ts` gains a `decorator` form; `steps.ts` sets the
anchor's / handler's trigger from the **route registration**, not from a JSX prop.
*Rules:*
- The trigger of a route's handler is the route itself: `{ kind: 'request', name: 'POST', of: '/users' }`
→ `FIRES FROM POST /users`. The middleware / guard chain is read at the **registration site**: Express —
every argument before the handler in `app.post('/users', authenticate, validate(schema), createUser)`
(the resolver already knows the site line; read the arguments with `callArgumentsForFile` and drop the
last); Nest — `@UseGuards(...)`, `@UseInterceptors(...)`, `@UsePipes(...)` on the method **and** on the
class (class-level applies to every method); Fastify `{ preHandler: [...] }`; Koa `router.post(path,
mw, handler)`; Hono `app.post(path, mw, handler)`. Render as `FIRES FROM POST /users · after
authenticate, validate(…)` and put the chain on the link (`trigger.after: string[]`). Global
`app.use(mw)` before the route is a chain element too — read in file order, announce it as "global".
- Queue consumers, event listeners, cron, message patterns, websocket handlers as triggers: Nest
decorators `@Process('x')`, `@OnEvent('x')`, `@Cron(expr)`, `@MessagePattern('x')`,
`@SubscribeMessage('x')`; Bull/BullMQ `queue.process('x', fn)` / `new Worker('q', fn)`; node-cron
`cron.schedule(expr, fn)`; socket.io `socket.on('x', fn)`; Kafka/SQS consumers. The `option` and
`callback` forms already cover several of these (`process('x', fn)` = callback of `process` with first
literal `'x'` → add the names to `LATER_CALLEES`); decorators need the new form: climb from the site to
the decorated method/class and read `decorator` nodes (`@Name(args)`).
*Test:* `branch-guards.test.ts` `triggers` block: Express registration with middleware, Nest guards on
class and method, `@Process`, `@Cron`, `queue.process`, `socket.on`.
*Done when:* the handler box's sub line reads `POST /users · after authenticate, validate(…)` and a
consumer reads `FIRES FROM @Process('email')`.
### P3 — Cross-tier channels (the RN bridge, for the web)
*Where:* new synthesizers in `resolution/callback-synthesizer.ts` (register in the channel list with a
language gate), or a resolver for the resolvable ones; each tagged `provenance: 'heuristic'`,
`synthesizedBy`, `registeredAt`. **Close both directions before shipping any of them** (playbook).
1. **HTTP call → own route.** A JS/TS call `fetch('/api/users/…')`, `axios.post('/api/users')`,
`api.get('/users')` (a project axios instance with a literal `baseURL`) whose path literal (or
template with `${…}` segments as `:param`) matches a route node `METHOD path` in the same index
(method from the call: `fetch(url, { method: 'POST' })`, `axios.post`, else GET). Prefix-aware:
Express `router.use('/api', usersRouter)` mounts (P0's resolver gap — fix the label there too),
Next `app/api/**/route.ts` (P4). Edge: caller → route, kind `calls`, `metadata.tier: 'client→server'`,
confidence by how much of the path was literal. **Steps then draws the route as a `bridge` box
(`⇢ POST /api/users`) and, with `through=1`, walks on into the handler** — the capture→upload story
for a web app. Evidence bar: the path must be literal enough to match exactly one route; a bare
`fetch(url)` with a variable url produces nothing.
2. **Next server actions** (P4 prerequisite): a function in a `'use server'` file, or marked with the
directive, called from a client component / passed as `action={fn}` → the call edge exists already
(it is a normal import); mark it `tier: 'client→server'` at resolution (the callee's file has the
directive) so Steps classifies it as `bridge` with evidence.
3. **tRPC**: `trpc.users.create.useMutation()` / `.mutate(…)` ↔ `router({ users: router({ create:
procedure.mutation(…) }) })`: match the dotted path against the router object keys (object-literal
member resolution exists: `resolveObjectLiteralMember`). Client → procedure handler, `tier`.
4. **Queues / buses**: BullMQ `queue.add('job', …)` ↔ `@Process('job')` / `worker = new Worker('q',
fn)`; Nest `EventEmitter2.emit('x')` ↔ `@OnEvent('x')`; socket.io `server.emit('x')` ↔ `socket.on('x')`
and Nest `@SubscribeMessage('x')`. Same shape as `rnEventEdges` (literal on both sides, fan-out cap,
`event` metadata); Steps classifies the landing as `event` ⇠ when the edge is synthesized and crosses
into a handler — extend `crossing()` to accept a `tier`/`channel` marker, since both sides are TS.
*Test:* a monorepo fixture (`apps/web` Next page with a `fetch('/api/users')` + `apps/api` Express
`app.post('/api/users')`), a BullMQ producer/consumer, a Nest `emit`/`@OnEvent` pair. Assert the edges
(source, target, metadata) and that `buildSteps` from the page reaches the database effect **through**
the route with `through=1`.
*Done when:* from the web app's page, the picture reads `page → handler → ⇢ POST /api/users … →
prisma.user.create → response`, dashed where synthesized, with `registeredAt` in the panel.
### P4 — Next.js as a Screens app
*Where:* `resolution/frameworks/react.ts` (split a `nextjs.ts` out of it — the pages/app routing is
already there), a `next-router-synthesizer.ts` modelled on `expo-router-synthesizer.ts`.
*Rules:*
- Routes: App Router `app/**/page.{tsx,jsx,js}` → page route named by path (`(group)` stripped,
`[slug]` → `:slug`, `[...all]`, parallel/intercepting routes announced not modelled);
`app/**/route.ts` exports `GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS` → one route node each, `METHOD
/api/…`, with a `references` edge to the exported function; Pages Router `pages/**` and `pages/api/**`
(default export = handler; method from `req.method` switches — announce as `ANY`). `layout.tsx`,
`loading.tsx`, `error.tsx` are not routes; `middleware.ts` with `config.matcher` is a global guard (P2 chain).
- Route → component: the page file's default export (`defaultExportName` exists in expo-router's
resolver — reuse) → `calls` edge, exactly as Expo Router.
- `navigates`: `<Link href="/x">` (JSX attribute literal / template / object `{ pathname }`),
`router.push|replace('/x')` from `next/navigation` and `next/router`, `redirect('/x')` /
`permanentRedirect` (server), `NextResponse.redirect(new URL('/x', req.url))` in middleware and route
handlers, `<a href="/x">` to an internal path, `revalidatePath('/x')` (announce as a *refresh*, not a
navigation). Helper return values through the existing return-value synthesizer pattern.
- Triggers on a page: the page's server work FIRES FROM **page load** (`{ kind: 'load', name: 'GET',
of: '/blog/[slug]' }`) — the RSC body, `getServerSideProps`, `generateMetadata`; client handlers as today.
*Test:* an `expo-router.test.ts`-shaped Next fixture: two pages, a `<Link>`, a `router.push` behind a
condition, a `redirect()` in a server action, a `route.ts` `POST`; assert routes, `navigates` metadata
(`href`, `navMethod`), `buildScreens` (`routed: true`, the transition with its `when` and `via`), and
`buildSteps` from a page reaching the server action (``) and the Prisma call.
*Done when:* a Next app lands on the Screens tab like the mobile app does, and a page's Steps picture
shows load-time data, handlers, server actions and route handlers as boundaries.
### P5 — WHEN / arguments / triggers for Python, Java, Go (then Ruby, PHP, C#)
*Built* for Python, Java, Kotlin, C#, Go, C / C++ / Objective-C (guards, arguments, the call as written,
decorators, member types); Ruby and PHP still yield nothing. Test `__tests__/branch-guards-languages.test.ts`.
*Where:* `graph/branch-guards.ts` — `Rules` per language for guards (`if`/`elif`/`else`, early
`return`/`raise`/`continue`, `try`/`except`, `match`; Java `if`/`switch`/`throw`; Go `if err != nil {
return }` as the idiomatic early exit, `switch`/`select`); argument containers (`argument_list`,
`keyword_argument` → `name=value`); triggers (FastAPI `@router.post('/x', dependencies=[Depends(auth)])`,
Flask `@app.route`, Django URLconf + `@login_required`; Spring `@PreAuthorize`, `@Transactional`;
Gin middleware chain — `ginMiddlewareChainEdges` already knows it). Request-time only — no kernel work.
`supportsBranchGuards` widens per language as rules land; a language without rules must still yield
*nothing*, never a wrong label (§1 principle 6).
*Test:* `branch-guards.test.ts` blocks per language, mirroring the JS ones.
*Done when:* a FastAPI route's Steps picture carries the same four readings as an Express one.
### P6 — Words and the chooser
*Built* — `project` on the wire, `kindWord` / `kindWords` / `countWords`, the legend per project kind, the
endpoint chooser. The Screens tab stays hidden for an API (no `navigates`).
*Where:* `ui/src/lib/steps-model.ts` (`kindWord`, `stepSub`, `stepLabel`), `StepsView.svelte` legend
and summary, `api/steps.ts` (`WireStepsPayload.project: 'app' | 'api' | 'web'` decided from the route
names and the frameworks detected, so the viewer does not guess).
- Endpoint boxes: `POST /users` mono, sub = handler name · file (like a screen's component). Response
boxes: dashed like other effects, label the status codes when literal (`404 · 201`).
- Kind words per project kind (§4 table). The legend re-words itself from the same table. Keep the
sentence-case, no-tracking rule (spec §2) — capitals are only the condition keywords.
- The chooser: routes grouped by router file, then pages; "most connected" by fan-out of the handler.
- The Screens tab for an API: hide it (as today when `routed: false`) unless P3's inter-endpoint links
produce a picture with more than a handful of arrows — measure on the validation repos first; the
Entry points view is the honest list until then.
### P7 — Validation set and the numbers
Small fixtures live in the tests. For the real bar, index these and record the results in
`docs/design/dynamic-dispatch-coverage-playbook.md` (new rows) exactly as the playbook asks
(**≥3 flow prompts × small/medium/large, node count stable, synthesized-edge precision spot-check,
agent A/B with `--model sonnet`, ≥2 runs per arm**):
| Framework | Small | Medium / large | Canonical flow to draw |
|---|---|---|---|
| Express | `gothinkster/node-express-realworld-example-app` | `TryGhost/Ghost` | `POST /api/articles` → `auth` → handler → service → DB → 201 / 422 |
| NestJS | `nestjs/nest/sample/01-cats-app` (and `sample/*`) | `immich-app/immich`, `amplication/amplication` | `POST /assets` → `@UseGuards(Auth)` → controller → service → repository → queue job → `@Process` consumer |
| Next.js | `vercel/next.js/examples/*` (app-dir + prisma) | `calcom/cal.com` (Next + Prisma + tRPC), `twentyhq/twenty` (Nest + Next monorepo — the cross-tier story) | page load → data → form action → server action / route handler → DB → `redirect` |
| FastAPI | `tiangolo/full-stack-fastapi-template` | — | `POST /users` → `Depends(get_current_user)` → CRUD → session commit → `HTTPException` rows |
| Spring | `spring-projects/spring-petclinic` | — | controller → service → JPA repository → view / `ResponseEntity` |
Acceptance per framework: the canonical flow drawn end to end with all four readings on every link;
boundaries where they should be; no picture over the caps at depth 8 on the small repo; every
synthesized edge in the picture spot-checked against the source; the agent A/B not regressing the
control repos.
---
## 6. Conventions and gotchas (learned the hard way this session)
- **Extractor changes need the Rust twin** (§2.4). A TS-only extractor patch silently does nothing on
a machine with the kernel binary staged — tests pass under `CODEGRAPH_KERNEL=0` and fail by default.
- **Function-as-value capture is what makes handlers visible**: JSX attribute values, `on*` options,
object shorthand members (`return { handleX }`) are capture sites (`TS_JS_SPEC.dispatch` in
`extraction/function-ref.ts`, mirrored in `codegraph-kernel/src/tsjs/fnref.rs`). The gate is
"defined in this file or imported" — a handler that comes out of a hook destructure in another file is
found through `contains`, and P0's route handlers through `references`. When a handler folds that
should be a box, check which of these it fell through.
- **Evidence over inference.** A cross-family edge without `resolvedBy: 'framework'`, `bridge`, or
`provenance: 'heuristic'` is name-matcher noise (`arr.flat()` landing on a Swift `flat`) and Steps
drops it. Keep that rule for tiers: a `fetch` with a variable URL is nothing, not a guess.
- **Two passes per fold** (classify arrivals, then fold the rest) so a node that is a step is never also
folded through its `contains` edge; keep `defines …` sites out of the rows when a call site exists.
- **Readings are per site.** `WireStepSite.when`, `.args`, `.trigger` — the link's `when`/`trigger` is
only the summary. Scenario rows and the common-prefix factoring are in `ui/src/lib/conditions.ts`.
- **Caps are announced, never silent** (`cut`, `truncated`). A new cap must say so on the step it hits.
- **Docs to update with every change**: the design spec section, `CHANGELOG.md` `[Unreleased]` in the
user-facing style the file prescribes (no paths / symbol names / numbers), `CLAUDE.md` if a module or a
rule is added. The coverage playbook gets a row per validated framework.
- **The UI package seam**: `ui/src/lib/adapter.ts` (`steps` optional), `navigation.ts`
(`stepsHref` required — the Pro app's driver must add it), `check-ui-package.mjs` prunes the app shell;
nothing outside `adapter.ts` may reach the network.
- **Known flake**: `__tests__/mcp-daemon.test.ts` "daemon idle-times-out" fails under full-suite load
(~1 in 3 runs) and passes alone. Not related to any of this.
- **Do not commit or push** unless asked; the session's work is uncommitted on `main`'s working tree of
`~/Development/CodeGraph/codegraph` (27 modified, 12 new files as of this writing) — branch first
(`feature/…`) when you do.
---
## 7. Open questions for the maintainer
1. **A "Routes" picture for pure APIs, or Entry points as the list?** Recommendation: measure
inter-endpoint links on the validation repos after P3; build the picture only if it has arrows.
2. **`response` as steps** (recommended: yes — the contract-as-code reading) vs. folded into the handler.
3. **How much schema on `database` boxes**: model + read/write from the call (cheap, proposed) vs.
fields from the ORM schema (Prisma `schema.prisma`, TypeORM entities) — a later, separate reading.
4. **Project kind on the wire** (`app | api | web`) decided server-side from routes + frameworks, or a
viewer toggle? Recommended: server-side, with the viewer allowed to override in the URL.