feat(steps): one reply box per outcome

A reply's identity is its status, not its call: a handler answering 200 or 401 draws two boxes (id per function, response, status), so each line from the handler carries its own condition on the picture — the Screens view's idiom — and the anchor's Leads-to list reads as the contract; replies whose status the code does not spell out share one box labelled by the call. Panel note, spec §3.13, CHANGELOG, plan; servers test asserts the ASP.NET and Spring outcomes per box.

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-29 12:11:40 -05:00
co-authored by Claude Fable 5
parent 02430ccc32
commit 46e3e7aaa0
6 changed files with 46 additions and 35 deletions
+4 -2
View File
@@ -20,7 +20,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **The Steps tab follows a web app across its tiers.** A page's `fetch('/api/users', { method: 'POST' })` (or an `axios` / `ky` / `got` / `$fetch` call, including one through a project instance made with `axios.create({ baseURL })`) now reaches the route that serves it in the same index — drawn as a crossing to the server (`⇢ POST /api/users`) with the handler named on the box and the registration site in the panel, a boundary by default and entered with *Continue through*, so the picture reads page → handler → the endpoint → its database write → its response. A job put on a BullMQ / Bull queue lands on the `@Process` method, `Worker` or `queue.process` handler that consumes it; a NestJS `EventEmitter2` event lands on its `@OnEvent` listeners (globs included); a socket message crosses from a client's `socket.emit` to the gateway's `@SubscribeMessage` and back from the server's `emit` to the component that registered `socket.on`; and a Next.js server action called from a client file is a crossing to the server by its `'use server'` directive. Each of these is a synthesized hop — dashed, with where it was wired up — and `codegraph_explore`'s Flow section names them too. Only a literal path or event name pairs: a variable url, a path no route serves, or one two routes serve alike produce nothing. Re-index to pick the new edges up.
- **The Steps tab now draws an API as well as an app.** Anchor on an endpoint — `POST /users` in Express, NestJS, Fastify, FastAPI, Flask, Django, Spring (Java or Kotlin), ASP.NET, Vapor or Gin — and the viewer starts at the handler the route runs (or at the route itself when the handler is an inline arrow), says what fires it (`FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the registration, or the guard decorators on the method and its class, or a FastAPI `dependencies=[…]`), and draws what the request sets in motion: the database calls with the model and whether they read or write (`prisma.user.create({ data })` · `database · user · write`), jobs put on a queue, emails, payments, cache reads, token checks, calls to other services, files and processes — and the **responses**, one box per handler whose label is the status codes it can send (`201 · 404`) and whose panel rows are the endpoint's contract as the code has it: `WHEN NOT user → 404 · NotFoundException('no such user')`, `always → 201 · res.status(201).json(user)`. A queue consumer or a scheduled job anchored by name says the decorator that fires it (`@Process('email')`). The legend, the panel and the chooser use the project's own words — endpoint, data call, another tier — and the bare Steps tab lists an API's endpoints by router file when there are no screens. Re-index is not needed: everything new is read from the source at request time.
- **The Steps tab now draws an API as well as an app.** Anchor on an endpoint — `POST /users` in Express, NestJS, Fastify, FastAPI, Flask, Django, Spring (Java or Kotlin), ASP.NET, Vapor or Gin — and the viewer starts at the handler the route runs (or at the route itself when the handler is an inline arrow), says what fires it (`FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the registration, or the guard decorators on the method and its class, or a FastAPI `dependencies=[…]`), and draws what the request sets in motion: the database calls with the model and whether they read or write (`prisma.user.create({ data })` · `database · user · write`), jobs put on a queue, emails, payments, cache reads, token checks, calls to other services, files and processes — and the **responses**, one box per status a handler can send (`201`, `404`) whose panel rows are the endpoint's contract as the code has it: `WHEN NOT user → 404 · NotFoundException('no such user')`, `always → 201 · res.status(201).json(user)`. A queue consumer or a scheduled job anchored by name says the decorator that fires it (`@Process('email')`). The legend, the panel and the chooser use the project's own words — endpoint, data call, another tier — and the bare Steps tab lists an API's endpoints by router file when there are no screens. Re-index is not needed: everything new is read from the source at request time.
- **Calls are read as written, so the database is the database.** The index keeps only the last segment of a deep member call (`create` for `prisma.user.create`), and a bare name matches by name alone — often to the wrong `create`. The Steps walk now reads each call from the source as written, classifies `prisma.user.create`, `this.usersRepository.save`, `session.commit`, `owners.save` and `_context.TodoItems.Add` by the whole chain and by the receiver's declared type (`OwnerRepository owners`, `private readonly usersService: UsersService`, `val owners: OwnerRepository`, read from the class body), and follows `this.usersService.findByEmail(…)` into the class the type names instead of the name-only guess. A hop resolved this way says so in the panel.
@@ -46,7 +46,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **A server action written through a wrapper starts its transition on the right page.** `export const signIn = validatedAction(schema, async (data) => { … redirect('/dashboard') })` — the arrow inside is no symbol of its own — now belongs to `signIn` on the Screens tab, and `signIn` is attributed to the page whose component hands it to `useActionState(signIn, …)`, read from the source when the graph holds no such edge.
- **A reply that sets no status is a 200.** `res.json(user)`, `res.send(…)`, `reply.send(…)`, `NextResponse.json(…)`, a `JSONResponse` or `jsonify(…)` with no status in the chain now count as `200`, so an endpoint's response box reads `200 · 401` instead of `401` alone and the success row carries its code; a status set by the statement just before (`res.status(202); res.json(user)`) is that reply's. An Express handler written inline at the registration keeps its own replies too — they were filtered out with the framework noise.
- **Each way an endpoint answers is its own box.** A handler that replies `200` or `401` now draws two reply boxes instead of one `200 · 401`, so each line from the handler carries its own condition on the picture — `→ user && (await user.matchPassword(password))` into the 200, `→ NOT (…)` into the 401 — the way a screen's transitions do; replies whose status the code does not spell out share one box labelled by the call.
- **A reply that sets no status is a 200.** `res.json(user)`, `res.send(…)`, `reply.send(…)`, `NextResponse.json(…)`, a `JSONResponse` or `jsonify(…)` with no status in the chain now count as `200`, so an endpoint's success reply has a box of its own beside the `401`'s and its row carries its code; a status set by the statement just before (`res.status(202); res.json(user)`) is that reply's. An Express handler written inline at the registration keeps its own replies too — they were filtered out with the framework noise.
- **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.
+11 -12
View File
@@ -423,12 +423,12 @@ describe('ASP.NET endpoint groups', () => {
const anchor = p.steps.find((s) => s.anchor)!;
expect(anchor.sub).toBe('UpdateTodoItem');
expect(anchor.trigger).toMatchObject({ kind: 'request', name: 'PUT', of: '/api/TodoItems/{id}' });
const res = effect(p, 'response')!;
expect(res.label).toBe('204 · 400');
const rows = p.links.find((l) => l.to === res.id)!.sites.map((s) => [s.status, s.when]);
expect(rows).toEqual([
[400, 'id != command.Id'],
[204, 'id == command.Id'],
// One box per outcome, each line carrying its own condition.
const replies = p.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'response');
const outcomes = replies.map((s) => [s.label, p.links.find((l) => l.to === s.id)!.when]).sort();
expect(outcomes).toEqual([
['204', 'id == command.Id'],
['400', 'id != command.Id'],
]);
});
});
@@ -444,12 +444,11 @@ describe('Spring', () => {
expect(db.effect).toMatchObject({ model: 'Owner', access: 'write' });
const dbLink = p.links.find((l) => l.to === db.id)!;
expect(dbLink.when).toBe('owner.getName() != null');
const res = effect(p, 'response')!;
expect(res.label).toBe('201 · 400');
const rows = p.links.find((l) => l.to === res.id)!.sites.map((s) => [s.status, s.when]);
expect(rows).toEqual([
[400, 'owner.getName() == null'],
[201, 'owner.getName() != null'],
const replies = p.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'response');
const outcomes = replies.map((s) => [s.label, s.effect!.statuses, p.links.find((l) => l.to === s.id)!.when]).sort();
expect(outcomes).toEqual([
['201', [201], 'owner.getName() != null'],
['400', [400], 'owner.getName() == null'],
]);
});
});
+7 -4
View File
@@ -513,10 +513,13 @@ leaves the index through it (`OwnerRepository owners` in a Spring controller, `R
from the class body, `graph/branch-guards.ts`'s `memberTypesInTree`, the index keeps none of it); and, in a project with
endpoints, on a thrown web exception (`throw new NotFoundException(…)`, `raise HTTPException(…)`). The same declared type
sends `this.usersService.findByEmail(…)` into the class the type names instead of the name-only guess the graph holds — the
panel says `by the receiver's declared type` on that hop. A **response** box is the endpoint's contract as the code has it:
its label is the status codes its sites send when they are literal (`201 · 404`, read out of `status(404)`,
`HttpStatus.CREATED`, `http.StatusNotFound`, `status_code=422`, `NotFoundException`, `TypedResults.NoContent`, `.notFound`),
and the panel prints one row per site — `WHEN NOT user → 404 · NotFoundException('no such user')`. The payload says what the
panel says `by the receiver's declared type` on that hop. A **response** box is one outcome of the endpoint's contract as the
code has it — one box per (function, status), so a handler answering 200 or 404 is two boxes and each line into them carries
its own condition on the picture, the Screens view's idiom; its label is the status when it is literal (read out of
`status(404)`, `HttpStatus.CREATED`, `http.StatusNotFound`, `status_code=422`, `NotFoundException`, `TypedResults.NoContent`,
`.notFound`, `{ status: 201 }`, a `res.status(202)` the statement before, or the 200 a body-sending reply implies), and the
panel prints one row per site — `WHEN NOT user → 404 · NotFoundException('no such user')`; the sites whose status the code does
not spell out share one box labelled by their call. The payload says what the
index is a picture of (`project: 'app' | 'api' | 'web'`, from the routes: endpoints make an API, endpoints beside pages or
navigation a web app) and the viewer's words follow it in one place (`kindWord` / `kindWords` in `steps-model.ts`):
endpoint / page / screen, data call / store action, a call to another tier / to the server / a native call; a route that
@@ -211,7 +211,7 @@ row, and that the walk reaches the service and the repository call.
*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
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
+22 -15
View File
@@ -547,12 +547,16 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
// One box per (function, category): `uploadARCapture` makes one network
// call, three storage calls and three telemetry calls — three boxes, each
// listing its calls, not seven.
// listing its calls, not seven. A reply is the exception: its identity is
// the outcome, so `authUser` answering 200 or 401 is two boxes — each
// line into them then carries its own condition on the picture, the
// Screens view's idiom — and the sites whose status cannot be read share
// one `response` box labelled by the call.
const effectSub = (e: NonNullable<WireStep['effect']>, by: Node): string =>
[e.category, e.model, e.access, by.name].filter((x): x is string => !!x).join(' · ');
const effectStep = (by: Node, ref: { referenceName: string; line: number }, effect: Effect, depth: number): StepRecord | null => {
const effectStep = (by: Node, ref: { referenceName: string; line: number }, effect: Effect, depth: number, status: number | null = null): StepRecord | null => {
const category = effect.category;
const id = `effect:${by.id}:${category}`;
const id = status !== null ? `effect:${by.id}:${category}:${status}` : `effect:${by.id}:${category}`;
const existing = steps.get(id);
if (existing) {
const e = existing.effect!;
@@ -631,20 +635,21 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
args,
});
if (effect === null) return false;
const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, step.depth + 1);
// A reply's status, read before its box exists — the box is per outcome.
// `NextResponse.json(user, { status: 201 })`: the code sits in an object
// the abbreviation reduced to its keys; the site reader kept it. And a
// body-sending reply that sets none is a 200, so a success has a box of
// its own beside the 401's.
const status =
effect.category === 'response'
? (responseStatus(text, args, ref.referenceKind) ?? (usable && typeof site.status === 'number' ? site.status : null) ?? implicitResponseStatus(text))
: null;
const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, step.depth + 1, status);
if (target === null) return true;
const when = await whenAt(fold.node, at);
const wireSite: WireStepSite = { file: posix(fold.node.filePath), line: ref.line, text, when: '' };
if (args !== null) wireSite.args = args;
if (effect.category === 'response') {
// `NextResponse.json(user, { status: 201 })`: the code sits in an object
// the abbreviation reduced to its keys; the site reader kept it.
// — and a body-sending reply that sets none is a 200, so a success row
// says so beside the 401s.
const status =
responseStatus(text, args, ref.referenceKind) ?? (usable && typeof site.status === 'number' ? site.status : null) ?? implicitResponseStatus(text);
if (status !== null) wireSite.status = status;
}
if (status !== null) wireSite.status = status;
link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, trigger ?? (await triggerAt(fold.node, at)));
return true;
};
@@ -1099,8 +1104,10 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
for (const step of steps.values()) {
if (step.kind !== 'effect' || !step.effect) continue;
const sites = sitesByStep.get(step.id) ?? [];
// A response box is the endpoint's contract: the status codes it can
// send, when they are literal, are its label; the rows say when.
// A response box is one outcome of the endpoint's contract: its status,
// when literal, is its label (one per box by construction); the rows say
// when. The box of unreadable statuses holds none and is labelled by its
// call below.
if (step.effect.category === 'response') {
const statuses = [...new Set(sites.map((s) => s.status).filter((x): x is number => typeof x === 'number'))].sort((a, b) => a - b);
if (statuses.length > 0) {
+1 -1
View File
@@ -635,7 +635,7 @@
<p class="dim note mono">{selectedInfo.step.effect.apis.join(' · ')}</p>
{/if}
{#if selectedInfo.step.effect?.category === 'response'}
<p class="dim note">The endpoints contract as the code has it: each row below is one way it answers, with the condition it answers under.</p>
<p class="dim note">{selectedInfo.step.effect.statuses?.length ? 'One way the endpoint answers — each row below is a site that sends it, with the condition it answers under; its other outcomes are the boxes beside it.' : 'Replies whose status the code does not spell out — each row below is one, with the condition it answers under.'}</p>
{/if}
{#if selectedInfo.step.events && selectedInfo.step.events.length > 1}
<p class="dim note mono">{selectedInfo.step.events.join(' · ')}</p>