Kaynağa Gözat

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
Colby McHenry 6 gün önce
ebeveyn
işleme
46e3e7aaa0

+ 4 - 2
CHANGELOG.md

@@ -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
__tests__/ui-steps-api-servers.test.ts

@@ -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
docs/design/codegraph-ui-design-spec.md

@@ -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

+ 1 - 1
docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md

@@ -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
src/ui-server/api/steps.ts

@@ -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
ui/src/views/StepsView.svelte

@@ -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 endpoint’s 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>