Parcourir la source

docs(plan): Steps in the code's order — a rail reading of a handler (sequence + forks) derived from the existing walk

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
Colby McHenry il y a 1 semaine
Parent
commit
34ef71a1d5
1 fichiers modifiés avec 274 ajouts et 0 suppressions
  1. 274 0
      docs/plans/2026-08-29-steps-in-code-order.md

+ 274 - 0
docs/plans/2026-08-29-steps-in-code-order.md

@@ -0,0 +1,274 @@
+# Steps, in the code's order — plan
+
+**Status:** plan, written 2026-08-29 on `feature/steps-servers` (tip `5797d7e`), for a fresh session. Nothing
+below is built. The Steps tab as it stands (spec §3.13, `src/ui-server/api/steps.ts`, `ui/src/views/StepsView.svelte`)
+is the base; everything here is a second *reading* of the same walk, not a new walk.
+
+**The ask, in the maintainer's words:** on proshop's `POST /api/users/login` the picture draws `User.findOne · jwt.sign ·
+200 · 401` in one row under the handler. That is true — all four are one step from `authUser` — and it is not what a
+person wants from a handler. They want *the flow of the code*: first the lookup, then **if** the password matches, sign a
+token and answer 200, **else** answer 401. "I think this should show the flow/order of the code. That would be way more
+useful to me." Build that.
+
+Read first, in this order: spec §3.13 and §3.14 (`docs/design/codegraph-ui-design-spec.md`), then
+`docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md` §2 and §6 (how the pictures work and the gotchas), then the
+auto-memory note `steps-for-apis-2026-08-28` (the two facts that made Steps work, the screenshot harness), then the files in
+§3 below. `CLAUDE.md` for tests, docs and the no-kernel-work rule (this plan is all request-time: no extractor changes).
+
+---
+
+## 1. The reading to build
+
+For `authUser` in `bradtraversy/proshop_mern` (`backend/controllers/userController.js`):
+
+```js
+const user = await User.findOne({ email })
+if (user && (await user.matchPassword(password))) {
+  res.json({ _id: user._id, …, token: generateToken(user._id) })
+} else {
+  res.status(401)
+  throw new Error('Invalid email or password')
+}
+```
+
+the picture reads top to bottom, as a rail that forks where the code forks:
+
+```
+● POST /api/users/login · authUser          FIRES FROM POST /api/users/login
+│
+├─ User.findOne({ email })                  database · User · read
+│
+╞═ user && (await user.matchPassword(password))
+│    ┌─ WHEN ──────────────────────────┐   ┌─ WHEN NOT ─────────────────────────┐
+│    │ jwt.sign({ id }, …)  auth        │   │ 401 · res.status(401)   response    │
+│    │   via generateToken · inside     │   │   then throw new Error('Invalid …') │
+│    │   res.json(…)                    │   └─────────────────────────────────────┘
+│    │ 200 · res.json({ _id, …, token })│
+│    └─────────────────────────────────┘
+```
+
+Every reading the Steps tab has today survives on each box — kind, `FIRES FROM`, `via`, `WHEN`, what it passes, the status,
+`inside res.json(…)` — but the *arrangement* is the code's: **sequence down the rail, branches as forks, an arm that
+replies (or returns / throws) ends there, arms that fall through rejoin.** A helper the walk folded (`generateToken`) is
+drawn *in place*: its own steps appear where it is called, marked `via generateToken`. A boundary (another endpoint the
+handler calls, another screen) sits in the sequence where the call is, still not entered unless `through` asks.
+
+The reading is the **outline of a function**, not a control-flow graph: statements in source order, `if` / `else` /
+`switch` / `try` / early exits as forks, loops as a marked block (the body once, "for each …"), callbacks that run later
+(`.then`, `setTimeout`, `useEffect`) as a marked block ("later"), `Promise.all([a(), b()])` as siblings marked "together".
+Source order is execution order for straight-line code and for arguments before their call; where it is not (callbacks,
+concurrency) the block says so rather than pretending.
+
+**What stays:** the existing tree ("what it sets in motion", rows = distance) remains as the other reading, one toggle
+away — it is the right picture for a screen, where handlers fire on events and have no order. **Default:** in order when
+the anchor's root is a function / method (a handler, an endpoint, any symbol); the tree when the anchor is a screen.
+Both read the same URL (`&view=order` / `&view=tree`), the same panel, the same double-click and `Start here →`.
+
+---
+
+## 2. Why the current picture cannot say it
+
+`buildSteps` is breadth-first from the anchor's root: a node is a *step* when it is a screen, a handler, a bridge, an
+event, a store action or an effect; everything else folds into the link's `via`. Rows are distance from the anchor.
+Since 2026-08-29 a row is ordered by the position of the hop that first reached each step, with a hop written inside
+another call's arguments before that call (`WireStep.order`, `hopCompare` in `steps.ts`) — that is how `jwt.sign` sits
+left of `200`. The link carries the whole guard chain as one `when` string and the enclosing call as `within`.
+
+So the walk already knows, per step, **where** it is reached (line, column, span) and **under what** (the guard chain).
+What it throws away is the *structure* between siblings: which guards are the same `if`, which arm is the `else` of
+which, what ends an arm. The tree cannot draw `authUser → 200 under jwt.sign` because that would be a lie (a `jwt.sign →
+200` edge); the rail can, because a rail is not a graph of causes but the order of statements.
+
+---
+
+## 3. Facts to build on (verified 2026-08-29; re-verify, these files move)
+
+| Need | Where it is today | Notes |
+|---|---|---|
+| The steps, links, sites, `via`, `when`, `trigger`, `status`, `order`, `within` | `src/ui-server/api/steps.ts` `buildSteps` → `WireStepsPayload` (mirrored in `ui/src/lib/wire.ts`) | one box per (function, category), per status for replies; boundaries `cut: 'screen'`; `through=1` |
+| Per-site position + span + enclosing call | `graph/branch-guards.ts` `callSitesForFile` → `CallSiteText { callee, args, argList, span, within, status }` | `span` = the call's start/end; `within` = the call whose arguments contain it (stops at a function / block) |
+| Per-site guard chain, structured | `guardsForFile` → `BranchGuard[]` (`text`, `negated`, `form`, `line`), joined by `guardLabel` into the `when` string | **the plan needs the array, not the string** — `SiteReader.when` returns the string; add `guards(caller, site): BranchGuard[]` beside it (`api/when.ts`) |
+| The rules that produce guards, per language | `RULES_BY_LANGUAGE` / `interface Rules` in `branch-guards.ts`: `boundaries`, `inlineFunctions`, `bindingParents`, `blocks`, `enclosing`, `earlyExits` | JS/TS, Swift, Python, Java, Kotlin, C#, Go, C; an early `return` / `throw` / `raise` before a site is already a negated guard in that site's chain (`earlyExits`) |
+| What fires a site (later-running callbacks) | `triggersForFile` → `SiteTrigger { kind: 'prop' \| 'option' \| 'callback', name, of }`; `LATER_CALLEES` | `kind: 'callback'` of `then` / `setTimeout` / `useEffect` / `addListener` = runs later |
+| The hop that first reached a step | `HopSite` in `steps.ts` (`file`, `line`, `column`, `end`, `within`), carried on `Fold.first`, sorted by `hopCompare` / `hopInside` | this is the position the rail places a step at |
+| Words for conditions | `ui/src/lib/conditions.ts`: `clauses`, `splitTop`, `conditionTokens`, `whenTokens`, `commonTokens`, `scenarios` | WHEN / AND / OR / NOT as words; scenario rows per site |
+| Box words and looks | `ui/src/lib/steps-model.ts` (`kindWord`, `stepLabel`, `stepSub`, `triggerWords`), `ui/src/components/steps/StepNode.svelte` | reuse the box verbatim; only the arrangement is new |
+| The view, URL, panel | `ui/src/views/StepsView.svelte` (`rewrite`, `navigate`, the panel with `Start here →`, the summary's `through` checkbox), `ui/src/lib/navigation.ts` `stepsHref` / `StepsHrefOptions`, `ui/src/lib/router.svelte.ts`, `App.svelte` | add `view` to the options and the route |
+| Tests to copy the shape of | `__tests__/ui-steps-api-servers.test.ts` (four frameworks in one fixture), `ui-steps-cross-tier.test.ts`, `nextjs.test.ts`, `ui-steps-model.test.ts` (pure model), `ui-conditions.test.ts` | |
+| Headless pictures | the auto-memory note: `shoot.mjs` (Playwright over `codegraph ui --no-open`, one shot per hash URL; **never `npm run build` while a `codegraph ui` runs**); `scripts/try-repo.sh <preset>` to clone + index + open | proshop, express-realworld, nest-boilerplate, next-saas-starter, fastapi-template are the presets |
+
+The walk's caps and budgets (`MAX_CALL_SITES`, `MAX_WHEN_SITES`, fold depth, fan-out, steps per picture) apply unchanged;
+the rail draws the same steps the tree does, so it costs one more structured read per site, nothing more.
+
+---
+
+## 4. Design
+
+### 4.1 The program: a block tree derived from the walk
+
+Add to the payload (same request, `view=order` asks for it; the tree's `steps` / `links` stay so the panel works):
+
+```ts
+/** The anchor's body as the code reads: items in order, forks where it forks. */
+interface WireProgram { root: WireBlock; truncated: number }
+type WireBlock = WireItem[];
+type WireItem =
+  | { kind: 'step'; step: string /* WireStep.id */; link: string /* WireStepLink.id */; site: WireStepSite; via: WireNodeRef[]; within?: string }
+  | { kind: 'fork'; on: string /* the condition as written */; form: 'if' | 'switch' | 'ternary' | 'try' | 'exit' | 'guard'; arms: WireArm[] }
+  | { kind: 'loop'; on: string /* `item of items` */; body: WireBlock }
+  | { kind: 'later'; by: string /* `then` / `useEffect` */; body: WireBlock }
+  | { kind: 'together'; body: WireBlock }            // Promise.all / Promise.allSettled arguments
+  | { kind: 'inline'; via: WireNodeRef; when: string; body: WireBlock }   // a folded helper, drawn where it is called
+  | { kind: 'cut'; why: WireStep['cut'] };           // a cap or a boundary, where the walk stopped
+interface WireArm { when: string /* this arm's own clause: `…` or `NOT …`, or `case 'x'` */; ends: 'reply' | 'return' | 'throw' | null; body: WireBlock }
+```
+
+**How it is built (server, `src/ui-server/api/program.ts`, called from `buildSteps` when asked):**
+
+1. **Collect the sites per function.** For the anchor's root and every function the walk folded into (the `via` chain
+   members) or entered (a step with a root), take every hop and effect site the walk made *from that function*: the
+   step it reached (or the helper it folded into), the site's position (`span.start`, column-exact), and its structured
+   guard chain (`guards(caller, site)` — the new `SiteReader` method — outermost first). One structure per function,
+   keyed by the function's node id; the walk already visits each function once, so record while walking rather than
+   re-walking.
+2. **Order** each function's sites by `hopCompare` (source position; a site inside another site's arguments first).
+3. **Fold the guard chains into forks.** Walk the ordered sites keeping a stack of open forks. For a site with guard
+   chain `g1…gn`: the longest prefix shared with the open stack stays open; deeper open forks close; each remaining
+   guard opens a fork (for an `if` — `form: 'if'`, `on: g.text`) with the arm this site is in (`when: g.negated ? NOT g.text
+   : g.text`). A later site whose chain has the *same* guard (same `line`, same `text`) with the opposite `negated`
+   joins the **same fork as its other arm** — that is how `else` and the `||`-arm are found without a CFG; a `switch`
+   guard (`form: 'switch'`) opens one arm per `case` text. A negated guard that `earlyExits` produced (a `return` / `throw`
+   before the site) is a fork whose *other* arm is the exit: `arms: [{ when: g.text, ends: 'return' | 'throw', body: [] },
+   { when: NOT g.text, body: […] }]` — draw the exit as a terminal, not a box.
+4. **End an arm.** An arm whose last item is a reply step (`effect.category === 'response'`) or whose guard came from a
+   `throw` / `return` ends (`ends`); the rail does not rejoin below it.
+5. **Inline the folds.** A hop that folded into a helper becomes `{ kind: 'inline', via: helper, body: program(helper) }` at
+   the hop's position, recursively, to the walk's fold depth; the helper's own guards are relative to its body (the
+   fold's outer `whens` are already on the position in the caller). A step reached through a helper appears **only** in
+   the inline block (never also at the top level).
+6. **Later and together.** A site whose trigger is `kind: 'callback'` of a `LATER_CALLEES` callee, or that sits inside an
+   inline function (the `Rules.inlineFunctions` climb) passed to `.then` / `setTimeout` / `useEffect`, goes under a
+   `later` item at the position of the registering call; sites inside `Promise.all([...])` arguments go under `together`.
+   Loops: a site inside a `for` / `while` / `forEach` body goes under `loop` (the guard reader knows the loop node types
+   per language — `Rules.blocks` includes loop bodies; add the loop header text as `on`).
+7. **Boundaries and cuts** are `step` items (a screen / endpoint step, `cut: 'screen'`) — the rail shows them where the
+   call is; `through` enters them by appending their program as an `inline` block, as the tree enters them today.
+
+This is a derivation from data the walk has; the only new reading is the structured guard array. **No CFG, no
+extractor, no kernel work.** It is honest by construction: a fork exists only where a guard was read; a site with an
+unreadable chain (a language without rules, a drifted file) sits in sequence with no fork, which is the same silence
+the tree shows today.
+
+### 4.2 The view
+
+`ui/src/views/StepsView.svelte` gains the mode; the rail is a new component `ui/src/components/steps/StepsRail.svelte`
+(or `SequenceView`) rendered instead of the Svelte Flow canvas when `view === 'order'`. No layout engine: the rail is
+flex / grid — a column of boxes; a fork is a row of arm columns under a condition pill, each arm a column; a rejoin is a
+hairline back to the rail; an arm that `ends` stops at its terminal (the reply box, or a small `return` / `throw` mark
+in `--ink-3`). `loop`, `later`, `together`, `inline` are bracketed blocks with a left rule and a one-word label in
+`--ink-3` (`for each item`, `later · then`, `together`, `via generateToken`). Boxes are `StepNode`'s markup and styles
+verbatim (kind classes, accent rule for bridge / event, dashed effect, `⇢` / `⇠`), sized to content — reuse the
+component with a `rail` prop or extract its inner markup; the click / double-click contract is identical (select →
+panel; double-click → `Start here`). Keep the **pill** words: the fork's condition in the conditions vocabulary
+(`conditionTokens`), `WHEN` / `WHEN NOT` on the arms, ≤ 36 chars with `…` and the full text on hover, as §3.12 pills.
+
+Words (`kindWord` stays): the toggle reads **"Read as: in order · what it sets in motion"**; the legend for the rail:
+"Boxes read top to bottom in the code's order; a fork is an `if`, `switch`, `try` or an early exit, its arms side by
+side; an arm that replies, returns or throws ends there; `via x` is a helper drawn where it is called; `later` runs
+after the handler returns." Sentence case, no tracking (spec §2).
+
+The panel is unchanged — the same `WireStep` / `WireStepLink` shapes, `Start here →`, `Open as a flow →`, the scenario
+rows. The summary keeps `depth`, `through`, the counts; `Continue through` enters boundaries in the rail too.
+
+### 4.3 URL and defaults
+
+`StepsHrefOptions.view?: 'order' | 'tree'`; the router parses `view`; `rewrite()` carries it; the default when absent:
+`order` for a function / method / endpoint anchor, `tree` for a screen anchor (the anchor's root kind decides, server
+side, in the payload as `defaultView` so the viewer never guesses). The Screens tab's double-click and the chooser open
+the default.
+
+---
+
+## 5. Work plan, in order
+
+**P0 — structured guards on the reader.** `api/when.ts` `SiteReader.guards(caller, site): Promise<BranchGuard[]>` (the
+array `guardsForFile` already computes; `when` keeps returning the joined string). Test: `branch-guards.test.ts` already
+covers the chains; add one assertion on the array for an `if` / `else` pair sharing `line` and `text` with `negated`
+flipped, and for an early `return`.
+
+**P1 — record per-function sites during the walk.** In `buildSteps`, alongside `link()`, push `{ fn, stepId, linkId,
+hop: HopSite, guards: BranchGuard[], trigger }` into a per-function list (`programs: Map<fnId, SiteRecord[]>`), for hops
+out of every fold node (steps *and* folds — a fold into a helper is a record in the caller and the helper's own
+records are the helper's). Cost: one `guards` read per hop, within `MAX_WHEN_SITES`.
+
+**P2 — the block builder.** `src/ui-server/api/program.ts`: `buildProgram(records, roots) → WireProgram`, pure over the
+records (no graph access), unit-tested on hand-made records: straight line; `if` / `else`; early return; nested ifs;
+`switch`; `try` / `catch`; a helper inlined; a site inside arguments; `later`; `together`; a loop; a cap. Then wire it
+into the payload behind `view=order`.
+
+**P3 — the rail.** `StepsRail.svelte`, the toggle, `view` in the URL, the legend and summary words, the default per
+anchor kind. Snapshot-free tests: the pure layout of arms is trivial, so test the *model* (`ui/src/lib/program-model.ts`:
+the block → rows/columns with the pills' words) in `__tests__/ui-program-model.test.ts`, and drive one real picture with
+the harness (P5).
+
+**P4 — languages beyond JS.** Nothing to add for guards (P5 of the previous plan built them for Python, Java, Kotlin,
+C#, Go, C); check `later` / `loop` / `together` node types per language in `Rules` and add the loop-header text. Test:
+extend `ui-steps-api-servers.test.ts` with an `in order` assertion per framework (FastAPI's `raise HTTPException` is an
+early exit; Spring's `if (…) return ResponseEntity.badRequest()` too).
+
+**P5 — validation pictures.** With the harness: proshop `POST /api/users/login` (the §1 reading), `POST
+/api/products/:id/reviews` (three outcomes: `!product` → 404 early exit, `alreadyReviewed` → 400, else create + 201),
+express-realworld `POST /api/users/login`, nest-boilerplate `POST /api/v1/auth/email/login` (guards, DI, thrown
+exceptions), next-saas-starter `signIn` (a server action: validation → lookup → `redirect`), fastapi-template `POST
+/login/access-token`. Read each against the source; a fork that is not in the code, or an order that is not the code's,
+is a bug, not a style choice. Re-shoot the mobile app (`/capture/review`) to confirm the tree mode is untouched.
+
+**P6 — docs.** Spec §3.13 gains "3.13.1 In order" with the §1 rendering and the rules of §4.1; `CHANGELOG.md`
+`[Unreleased]` gets one user-facing bullet ("The Steps tab reads a handler in the code's order …"); `CLAUDE.md`'s
+`src/ui-server/` note names `api/program.ts`; the previous plan's status header points here.
+
+*Done when:* proshop's login reads as §1 in the browser, the reviews endpoint shows its three arms with the 404 as an
+early exit, and the tree mode is byte-for-byte the picture it is today.
+
+---
+
+## 6. Gotchas, known from building the tree
+
+- **Source order is execution order only for straight-line code.** Arguments before their call (the nesting rule,
+  already in `hopCompare`); `await` does not reorder; a callback registered now runs later (`later`); `Promise.all` is
+  concurrent (`together`). Do not draw an order the code does not fix — say `later` / `together` instead.
+- **A nested `const handleX = async () => …` is not a node** (only `function` declarations and `useCallback`-bound
+  arrows are); its body's sites attribute to the enclosing component, and the guard climb treats the inline arrow as
+  transparent. The rail inherits that: such a handler's sites appear in the component's program. The fix is an
+  extractor rule with the Rust kernel twin — out of scope here; note it where it shows.
+- **The reply box is per (function, status)** since `ca9a7fd`; a status set by the statement before the reply
+  (`res.status(401); throw …`) is that reply's (`statusSetBefore`), and a bare `res.json` is a 200
+  (`implicitResponseStatus`). In the rail the 401 arm reads `401 · res.status(401)` then `throw new Error(…)` as its
+  terminal — the throw is the arm's `ends`, read from the early-exit guard on the *next* site, or from the site's own
+  statement's successor; either way it is a mark, not a step.
+- **Site keys.** A site is `(line, column)`; synthesized edges carry the call's column (the tier synthesizer sets it);
+  an edge with no column lands on the first non-blank column of the row, which for `const x = await fetch(…)` is
+  `const` — `callAt` climbs to the first call from there. Pass the callee name (`want`) when you know it.
+- **Budgets.** `MAX_WHEN_SITES` / `MAX_CALL_SITES` bound the tree reads per request; the rail adds one `guards` read per
+  hop — the same sites the `when` read already parsed, so it is cheap, but count it. A drifted file yields no guards
+  (`hasDriftedOnDisk`): the rail then shows sequence without forks, and should say "conditions not read: the file
+  changed since the index".
+- **Two passes per fold** (spec §3.13): a node that is a step is never also folded through its `contains` edge; the
+  program's records must come from the same pass that made the link, or a step appears twice.
+- **The index keeps only the last segment of a deep member call**; every call text in the rail must come from
+  `callSitesForFile` (as the tree's do), never from the edge's `refName`.
+- **Never `npm run build` while a `codegraph ui` server is running** (it loads modules lazily and hangs); the harness in
+  the memory note kills its server group on exit for that reason.
+
+---
+
+## 7. Open questions for the maintainer
+
+1. **Default reading per anchor** — the plan says *in order* for functions / endpoints, *tree* for screens. Or *in
+   order* everywhere, with a screen's program being its render body plus each handler's program under `later ·
+   onPress`?
+2. **How far to inline.** A helper drawn in place at every call is faithful but wide; the tree's fold depth (8) may be
+   too deep for a rail. Proposal: inline to depth 3, then a `cut` item with `Start here →`.
+3. **Loops.** Body once with `for each …`, or unrolled never. Proposal: once, marked.
+4. **Should the rail replace the pills' "→ …x" placement rule?** The rail's forks carry the condition once, on the
+   fork; the arm's `WHEN` / `WHEN NOT` is the pill. The scenario rows in the panel stay as they are.