From 34ef71a1d50259b1fdd45f82d5dfa755b098633e Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sat, 29 Aug 2026 12:41:40 -0500 Subject: [PATCH] =?UTF-8?q?docs(plan):=20Steps=20in=20the=20code's=20order?= =?UTF-8?q?=20=E2=80=94=20a=20rail=20reading=20of=20a=20handler=20(sequenc?= =?UTF-8?q?e=20+=20forks)=20derived=20from=20the=20existing=20walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC --- docs/plans/2026-08-29-steps-in-code-order.md | 274 +++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 docs/plans/2026-08-29-steps-in-code-order.md diff --git a/docs/plans/2026-08-29-steps-in-code-order.md b/docs/plans/2026-08-29-steps-in-code-order.md new file mode 100644 index 0000000..5b58303 --- /dev/null +++ b/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 ` 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` (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`), 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.