Files
codegraph/docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md
T
Colby McHenryandClaude Opus 5 22f92a828b docs(steps): the in-order reading, and what validating it found
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
2026-08-29 13:48:04 -05:00

46 KiB
Raw Blame History

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: P0P6 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. P0P6 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.tsguardsForFile

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 buildcodegraph 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
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, 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)

Builtsrc/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

Builtsrc/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.* 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.useruser; 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

Builtrequest / 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)

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).

  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

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.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.tsRules 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_argumentname=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

Builtproject 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

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, 12 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/articlesauth → 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 /usersDepends(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.