Spec §3.13.1 describes the rail as built: what it is made of (records the same pass makes as the links), what makes the fold possible (a guard naming the decision it belongs to), the items, and the words. `CLAUDE.md` names `api/program.ts` and what the guard reader now returns. `CHANGELOG.md` gets the user-facing feature and the three fixes under it. Both plans now say what happened: the 2026-08-29 plan carries a BUILT header with where the build differs from it (a guard's `branch`, reading a function once per rail, blocks as one kind carrying facts, loops needing a reading of their own), the answers to its open questions, and a §8 recording the six endpoints read against their source — plus the two gaps left open on purpose, a mongoose `product.save()` the effects table does not know and the nested `const handleX = async () => …` that is still not a node. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
46 KiB
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
sessions: P0–P6 are built (see the per-item notes marked Built); P7 has its first agent A/B (proshop, small) and
the medium / large rows are open.
Next: BUILT (2026-08-29). docs/plans/2026-08-29-steps-in-code-order.md — the Steps tab reading a handler in the
code's order (a rail with forks) instead of the tree by distance; a derivation from this walk, no new walk. P0–P6 are
in (src/ui-server/api/program.ts, ui/src/lib/program-model.ts, spec §3.13.1); validating it against four real
servers also fixed three defects in THIS walk — a hop's span read from the wrong call, a name-match the call as
written disproves, and a value with one references edge going unlent the file's calls. 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 methodstepsis optional (ui/src/lib/adapter.ts),NavigationDriver.stepsHrefis required (a host driver must add it). - Conditions vocabulary:
ui/src/lib/conditions.ts(WHEN/AND/OR/NOTtokens,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=0for wasm) pluskernel-tsjs-parity.test.ts. Resolvers, synthesizers and the request-time readings are TS-only — no parity work. - Verify visually:
npm run build→codegraph indexin the target project →codegraph ui --no-open --port 4747 <project path>→GET /api/steps?symbol=<route name>→ headless playwright (createRequirefrom a repo that has it;waitUntil: 'load', notnetworkidle— the viewer holds an SSE stream). See the auto-memory notecodegraph-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 |
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); FastAPI APIRouter(prefix=) + literal include_router(prefix=) composed since 2026-08-28 (python.ts postExtract) |
yes | — | WHEN / arguments / triggers built for Python, Java, Kotlin, C#, Go, C (P5) |
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, P1–P3 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
per status since 2026-08-29 — the outcome is the box's identity, the line's pill its condition); 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.*whereprismais imported from@prisma/clientor a project file that constructsnew PrismaClient();this.repo/this.*RepositorytypedRepository<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; addeffect.model= the model / table when it can be read (prisma.user→user;Repository<User>→User;knex('users')→users; raw SQL: first table afterFROM|INTO|UPDATE|JOIN), andeffect.access = 'read' | 'write'from the method name (find*/get*/count/aggregate/selectvscreate/update/upsert/delete/save/insert/remove). Box:prisma.user.create({ data })/ subdata · 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), Pythonraise HTTPException, SpringResponseEntity.*, Goc.JSON(…)/http.Error. One box per (function,response) withapis[]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 say404 · { error }.queue,email,payments,cache,auth: receiver/method tables likenetworktoday. 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 inapp.post('/users', authenticate, validate(schema), createUser)(the resolver already knows the site line; read the arguments withcallArgumentsForFileand drop the last); Nest —@UseGuards(...),@UseInterceptors(...),@UsePipes(...)on the method and on the class (class-level applies to every method); Fastify{ preHandler: [...] }; Koarouter.post(path, mw, handler); Honoapp.post(path, mw, handler). Render asFIRES FROM POST /users · after authenticate, validate(…)and put the chain on the link (trigger.after: string[]). Globalapp.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/BullMQqueue.process('x', fn)/new Worker('q', fn); node-croncron.schedule(expr, fn); socket.iosocket.on('x', fn); Kafka/SQS consumers. Theoptionandcallbackforms already cover several of these (process('x', fn)= callback ofprocesswith first literal'x'→ add the names toLATER_CALLEES); decorators need the new form: climb from the site to the decorated method/class and readdecoratornodes (@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)
Built (2026-08-28, later session) — src/resolution/tier-synthesizer.ts (one pass, three channels: http-client with
tier: 'client→server', queue-job, event-bus for a bus and for sockets both ways; registered before the emitter pass), Next
server actions marked at request time from the 'use server' directive (api/when.ts directive), crossing() in steps.ts
reading tier / channel, an endpoint reached across a tier drawn as a bridge box that is a boundary like a screen, a channel's
call never also an effect, a top-level new Worker landing on its constant with the file-scope calls lent to it; Express mounts
(app.use('/api', router), nested, by import or require) composed onto route names in postExtract, and the chained
router.route('/x').get(h).put(h2) form extracted; e2e/ counts as a test directory. Test: __tests__/ui-steps-cross-tier.test.ts.
Later the same day: FastAPI APIRouter(prefix=) + literal include_router(prefix=) composed (python.ts postExtract;
fastapi/full-stack-fastapi-template 23 routes now read GET /items/{id} instead of GET /; its settings.API_V1_STR
mount is skipped, not guessed) and the ASP.NET endpoint-group form (csharp.ts: groupBuilder.MapPost(Handler[, "path"])
under the class, the app's $"/api/{groupName}" head read in postExtract, RoutePrefix honoured — the
jasontaylordev/CleanArchitecture shape). Verified on bradtraversy/proshop_mern (30 routes, 23 client→route edges, every one correct on inspection; login reads
login → ⇢ POST /api/users/login (authUser) → User.findOne({ email }) → 401 rows → jwt.sign via generateToken, which needed
routeRoots to accept a function-valued constant — const authUser = asyncHandler(async (req, res) => …) — and the walk to lend
such a value the file-scope calls and unresolved refs within its lines; and framework detection to read a workspace's
package.json (frameworks/package-deps.ts), proshop keeping react in frontend/) and nestjs/nest
(sample/26-queues transcode → @Process('transcode'), sample/30-event-emitter → @OnEvent; the integration/*/e2e
helpers no longer count). Not built: tRPC (a procedure's inline handler is not a node — extractor work with the kernel twin).
Gap found: a nested const handleSubmit = async (e) => … inside a component is not a node (only function declarations and
useCallback-bound arrows are — tree-sitter.ts reactHookBoundName), so such a handler's fetch attributes to the component and
the link carries no FIRES FROM; the fix is an extractor rule for a nested arrow bound by a declarator, in TS and in the Rust kernel.
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).
- HTTP call → own route. A JS/TS call
fetch('/api/users/…'),axios.post('/api/users'),api.get('/users')(a project axios instance with a literalbaseURL) whose path literal (or template with${…}segments as:param) matches a route nodeMETHOD pathin the same index (method from the call:fetch(url, { method: 'POST' }),axios.post, else GET). Prefix-aware: Expressrouter.use('/api', usersRouter)mounts (P0's resolver gap — fix the label there too), Nextapp/api/**/route.ts(P4). Edge: caller → route, kindcalls,metadata.tier: 'client→server', confidence by how much of the path was literal. Steps then draws the route as abridgebox (⇢ POST /api/users) and, withthrough=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 barefetch(url)with a variable url produces nothing. - Next server actions (P4 prerequisite): a function in a
'use server'file, or marked with the directive, called from a client component / passed asaction={fn}→ the call edge exists already (it is a normal import); mark ittier: 'client→server'at resolution (the callee's file has the directive) so Steps classifies it asbridgewith evidence. - 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. - Queues / buses: BullMQ
queue.add('job', …)↔@Process('job')/worker = new Worker('q', fn); NestEventEmitter2.emit('x')↔@OnEvent('x'); socket.ioserver.emit('x')↔socket.on('x')and Nest@SubscribeMessage('x'). Same shape asrnEventEdges(literal on both sides, fan-out cap,eventmetadata); Steps classifies the landing asevent⇠ when the edge is synthesized and crosses into a handler — extendcrossing()to accept atier/channelmarker, 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
Built (2026-08-28, later session) — frameworks/nextjs.ts split out of react.ts (App Router pages and route.ts handlers,
Pages Router pages and pages/api, (group) stripped, [slug] → :slug, [...all] → :all*, parallel / intercepting
routes skipped; a page's calls ref to its default export via defaultExportName; resolve() claiming router.push|replace| prefetch, redirect, permanentRedirect, NextResponse.redirect(new URL(…)) through the Expo href readers — now exported —
against a Next-only route table gated on the app's root), next-router-synthesizer.ts (<Link href>, internal <a href> →
dashed navigates from the component), scoreMatch accepting :param / :all*, the load trigger and project: 'web' for a
Next page in steps.ts, { status: 201 } read off the call site for response rows. Test __tests__/nextjs.test.ts.
Route-handler references resolve by name with the same-file preference (GET / POST are common names). Not built: revalidatePath
as a refresh, middleware.ts config.matcher as a global guard, a helper's return value as a destination (Expo has it).
Verified on leerob/next-saas-starter: 8 pages + 4 endpoints, Screens routed (12 screens, 15 links), <Link>s in the
layout, redirect() in the actions, NextResponse.redirect in the middleware and the checkout handler all bound. Two
gaps it showed, both the wrapped-arrow idiom: export const signIn = validatedAction(schema, async (data) => { … }) holds
its redirect on the FILE node (Screens now re-attributes a file-scope navigation to the value spanning it), and
useActionState(signIn, …) leaves no function-as-value edge (a plain call argument — Screens now falls back to the
functions that MENTION the value in the files importing it, read from the source; the principled fix is an extractor
fnRef rule for useActionState / useFormState / startTransition arguments, with the Rust kernel twin).
With both: 12 screens, 17 links, 8 origins — /sign-in → /dashboard via Login > signIn WHEN userWithTeam.length !== 0 && isPasswordValid && redirectTo !== 'checkout', /dashboard/security → /sign-in via deleteAccount WHEN isPasswordValid.
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.tsexportsGET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS→ one route node each,METHOD /api/…, with areferencesedge to the exported function; Pages Routerpages/**andpages/api/**(default export = handler; method fromreq.methodswitches — announce asANY).layout.tsx,loading.tsx,error.tsxare not routes;middleware.tswithconfig.matcheris a global guard (P2 chain). - Route → component: the page file's default export (
defaultExportNameexists in expo-router's resolver — reuse) →callsedge, exactly as Expo Router. navigates:<Link href="/x">(JSX attribute literal / template / object{ pathname }),router.push|replace('/x')fromnext/navigationandnext/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 /usersmono, 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
First numbers (2026-08-28, later session) — the deterministic half is in the playbook §6 rows (Next.js, MERN, Nest
channels, FastAPI prefixed routers, ASP.NET endpoint groups: node counts, edge precision spot-checks, pictures). One agent
A/B so far, bradtraversy/proshop_mern (small, Express + React), scripts/agent-eval/run-all.sh, Sonnet/high, 2 runs per
arm, a daemon pre-warmed before each with-run, the CLI shim on (0 leaks): "How does submitting the login form reach the
database, and what does the API respond with when the password is wrong?" — with codegraph 14s / 14s, 2 tool calls, 0 Read,
0 Grep, 1–2 explores, the full path named (submitHandler → login → POST /api/users/login → authUser → User.findOne → matchPassword → 401); without 18s / 37s, 14 / 8 tool calls, 7 / 1 Read plus Bash cats and a subagent. Tokens 153k/165k vs
237k/369k. The pass bar (§4 of the playbook) holds on the small repo. Still open: the medium / large rows (Ghost,
immich, cal.com / twenty), ≥3 prompts per framework, and a control repo. The driver lives in the session scratchpad
(ab-proshop.sh: pre-warm serve --mcp with CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS high and CODEGRAPH_WASM_RELAUNCHED=1,
then run-all.sh per run with its own AGENT_EVAL_OUT); re-create it from the memory note.
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=0and 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.dispatchinextraction/function-ref.ts, mirrored incodegraph-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 throughcontains, and P0's route handlers throughreferences. 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, orprovenance: 'heuristic'is name-matcher noise (arr.flat()landing on a Swiftflat) and Steps drops it. Keep that rule for tiers: afetchwith 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
containsedge; keepdefines …sites out of the rows when a call site exists. - Readings are per site.
WireStepSite.when,.args,.trigger— the link'swhen/triggeris only the summary. Scenario rows and the common-prefix factoring are inui/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.mdif 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(stepsoptional),navigation.ts(stepsHrefrequired — the Pro app's driver must add it),check-ui-package.mjsprunes the app shell; nothing outsideadapter.tsmay 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
- 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.
responseas steps (recommended: yes — the contract-as-code reading) vs. folded into the handler.- How much schema on
databaseboxes: model + read/write from the call (cheap, proposed) vs. fields from the ORM schema (Prismaschema.prisma, TypeORM entities) — a later, separate reading. - 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.