diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c89aa..8a43dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features -- **The Steps tab reads a handler in the code's own order.** A picture of what an endpoint sets in motion put the lookup, the token signing, the 200 and the 401 side by side, because each is one step from the anchor — true, and not how the code reads. Now a handler, an endpoint or any function opens as a rail instead: the calls top to bottom in the order they are written, a fork where the code forks — an `if`, a `switch`, a `try`, an early exit — with its arms side by side under the condition, an arm that answers the request, returns or throws ending right there, a helper drawn where it is called, and a body that repeats saying so (`for each item of items`). A call written inside another call's arguments comes first, so the token is signed before the reply that carries it. Work registered to run later (`later · then`) and calls started together (`together · Promise.all`) say so rather than pretending to be a sequence. A screen still opens as before — its handlers fire on events and have no order between them — and either reading is one click, or one `&view=order` / `&view=tree` in the link, away. Nothing to re-index: it is read from the source at request time, and where the conditions cannot be read the rail is a plain sequence rather than an invented structure. +- **The Steps tab draws a handler in the order its code runs.** The picture of what an endpoint sets in motion put the lookup, the token signing, the 200 and the 401 side by side, because each is one step from the anchor — true, and not how the code reads. Now a handler, an endpoint or any function opens as the same picture laid out by *when* things happen: a line means **and then**, so the 200 sits below the token signing it is built from and the 401 branches off the check that chose it. Where the code forks — an `if`, a `switch`, a `try`, an early exit — the line says what has to hold, and an arm that answers the request, returns or throws simply has nothing leaving it. A call written inside another call's arguments happens first, so the token is signed before the reply that carries it. A helper is drawn where it is called (`via generateToken`), a body that repeats says so (`for each item of items`), and work registered to run later (`later · then`) or started at once (`together · Promise.all`) says that rather than pretending to be a sequence. A screen still opens as before — its handlers fire on events and have no order between them — and either reading is one click, or one `&view=order` / `&view=tree` in the link, away. Nothing to re-index: it is read from the source at request time, and where the conditions cannot be read the picture is a plain sequence rather than an invented structure. - **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are screens bound to the component they export; ``, an internal ``, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading. diff --git a/CLAUDE.md b/CLAUDE.md index e7d2d23..4232e88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the - `src/installer/` — see below. - `src/bin/codegraph.ts` — CLI (commander). Subcommands: `install`, `init`, `uninit`, `index`, `sync`, `status`, `query`, `files`, `context`, `affected`, `serve --mcp`. - `src/ui/` — terminal UI (shimmer progress, worker). -- `src/ui-server/` — the `codegraph ui` browser viewer's read-only JSON API (`api/`: one module per endpoint — `node`, `flow`, `map`, `screens`, `steps`, `deadcode`, `trails`…) and static server; the Svelte viewer itself lives in `ui/` (see `docs/design/codegraph-ui-design-spec.md`). `api/screens.ts` (the app as screens and transitions) and `api/steps.ts` (what happens from a screen, an endpoint or a symbol, as typed steps — screens, handlers, native bridge calls and events, store actions, calls that leave the index) share one fold: everything between two boxes is `via`, and the branch guards along it join into `when` (`graph/branch-guards.ts`, read at request time). `api/program.ts` is the SECOND reading of that same walk (spec §3.13.1): the anchor's body in the code's order — items in source order, a fork wherever two sites are arms of one decision, a helper drawn in place, an arm that answers or leaves ending there. It is pure over the records `steps.ts` keeps while it walks (`ProgramSite`), so the rail and the tree can never hold different steps; what makes the fold possible is that a guard names the DECISION it belongs to (`BranchGuard.branch`), not only its own words. Two helpers sit beside them: `api/route-roots.ts` (where a route's code starts — the handler a resolver named, the page a screen file exports, or the route itself for an inline handler; one rule for every framework) and `api/effects.ts` (the curated table of calls that leave the index — database / response / queue / email / payments / cache / auth / process / network / storage / device / telemetry — matched on the call **as written**, per language family, plus the model / read-write and the response status). `graph/branch-guards.ts` reads, from one cached tree per file, the conditions a site runs under (each with the branching construct it belongs to and how its arm leaves), the loops it is written inside, what it passes, the call as written (the index keeps only the last segment of a deep member chain), the decorators on a definition and the declared types of a class's members — for JS/TS, Swift, Python, Java, Kotlin, C#, Go and C; a language without rules yields nothing, never a wrong label. `steps.ts`'s `crossing()` reads an edge's `tier` / `channel` marker before the languages, so a synthesized cross-tier hop between two TS files draws as a bridge (an endpoint reached over HTTP — a boundary like another screen, entered with `through=1`) or an event (a job, an event, a message arriving); a Next server action is marked at request time from its `'use server'` directive (`api/when.ts`'s `directive`). +- `src/ui-server/` — the `codegraph ui` browser viewer's read-only JSON API (`api/`: one module per endpoint — `node`, `flow`, `map`, `screens`, `steps`, `deadcode`, `trails`…) and static server; the Svelte viewer itself lives in `ui/` (see `docs/design/codegraph-ui-design-spec.md`). `api/screens.ts` (the app as screens and transitions) and `api/steps.ts` (what happens from a screen, an endpoint or a symbol, as typed steps — screens, handlers, native bridge calls and events, store actions, calls that leave the index) share one fold: everything between two boxes is `via`, and the branch guards along it join into `when` (`graph/branch-guards.ts`, read at request time). `api/program.ts` is the SECOND reading of that same walk (spec §3.13.1): the anchor's body as a block tree — items in source order, a fork wherever two sites are arms of one decision, a helper drawn in place, an arm that answers or leaves ending there — which `ui/src/lib/program-model.ts` turns into the canvas's graph of what happens NEXT (a line means "and then", a row down is one more thing already done). It is pure over the records `steps.ts` keeps while it walks (`ProgramSite`), so the rail and the tree can never hold different steps; what makes the fold possible is that a guard names the DECISION it belongs to (`BranchGuard.branch`), not only its own words. Two helpers sit beside them: `api/route-roots.ts` (where a route's code starts — the handler a resolver named, the page a screen file exports, or the route itself for an inline handler; one rule for every framework) and `api/effects.ts` (the curated table of calls that leave the index — database / response / queue / email / payments / cache / auth / process / network / storage / device / telemetry — matched on the call **as written**, per language family, plus the model / read-write and the response status). `graph/branch-guards.ts` reads, from one cached tree per file, the conditions a site runs under (each with the branching construct it belongs to and how its arm leaves), the loops it is written inside, what it passes, the call as written (the index keeps only the last segment of a deep member chain), the decorators on a definition and the declared types of a class's members — for JS/TS, Swift, Python, Java, Kotlin, C#, Go and C; a language without rules yields nothing, never a wrong label. `steps.ts`'s `crossing()` reads an edge's `tier` / `channel` marker before the languages, so a synthesized cross-tier hop between two TS files draws as a bridge (an endpoint reached over HTTP — a boundary like another screen, entered with `through=1`) or an event (a job, an event, a message arriving); a Next server action is marked at request time from its `'use server'` directive (`api/when.ts`'s `directive`). ### NodeKind / EdgeKind diff --git a/__tests__/ui-program-model.test.ts b/__tests__/ui-program-model.test.ts index 7fb5067..c59c6fb 100644 --- a/__tests__/ui-program-model.test.ts +++ b/__tests__/ui-program-model.test.ts @@ -1,13 +1,18 @@ /** - * What the rail SAYS. The block tree the server sends is turned into rows of - * boxes and words here (`ui/src/lib/program-model.ts`); this pins the words — - * which is the part a reader actually meets. + * The Steps picture in the code's order: the graph of what happens next. + * + * The server folds the walk into blocks and forks (`api/program.ts`); this + * turns that into the canvas's graph — one edge per "and then", carrying the + * condition where the code branched, and a row per step counted by how much + * has to happen before it. What is pinned here is exactly that: the shape of + * the picture, which is the thing a reader looks at. */ import { describe, it, expect } from 'vitest'; -import { joinTokens } from '../ui/src/lib/conditions'; -import { armWords, buildRailModel, endWords, groupLabel } from '../ui/src/lib/program-model'; -import type { WireArm, WireBlock, WireItem, WireStep, WireStepsPayload } from '../ui/src/lib/wire'; +import { buildOrderModel, lineWords, orderGraph, runWords } from '../ui/src/lib/program-model'; +import type { WireArm, WireBlock, WireItem, WireProgram, WireStep, WireStepsPayload } from '../ui/src/lib/wire'; + +/* ------------------------------------------------------------ material -- */ const step = (id: string, over: Partial = {}): WireStep => ({ id, @@ -15,18 +20,20 @@ const step = (id: string, over: Partial = {}): WireStep => ({ anchor: false, node: null, label: id, - sub: `response · handler`, + sub: 'response · handler', depth: 1, cut: null, ...over, }); +const arm = (when: string, body: WireBlock, over: Partial = {}): WireArm => ({ when, ends: null, body, ...over }); + function payload(steps: WireStep[], root: WireBlock): WireStepsPayload { return { - anchor: { id: 'a', kind: 'route', name: 'POST /login', qualifiedName: 'POST /login', file: 'r.js', line: 1, endLine: 1, language: 'javascript', test: false }, + anchor: { id: 'anchor', kind: 'route', name: 'POST /login', qualifiedName: 'POST /login', file: 'r.js', line: 1, endLine: 1, language: 'javascript', test: false }, ambiguous: [], project: 'api', - steps, + steps: [step('anchor', { kind: 'anchor', anchor: true, label: 'POST /login' }), ...steps], links: [], program: { root, truncated: 0 }, defaultView: 'order', @@ -39,94 +46,148 @@ function payload(steps: WireStep[], root: WireBlock): WireStepsPayload { }; } -const arm = (when: string, over: Partial = {}): WireArm => ({ when, ends: null, body: [], ...over }); +/** The graph as `from → to` lines, each with what has to hold. */ +function shape(root: WireBlock): string[] { + const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor'); + return g.edges.map((e) => `${e.from} → ${e.to}${e.when ? ` · ${lineWords(e)}` : ''}${e.runs.length ? ` [${e.runs.join(', ')}]` : ''}`); +} -describe('the rail’s words', () => { - it('says the decision once, and each arm only which side it is', () => { +function rowsOf(root: WireBlock): Record { + const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor'); + return Object.fromEntries(g.depth); +} + +/* --------------------------------------------------------------- tests -- */ + +describe('the picture in the code’s order', () => { + it('puts one step after the next', () => { + expect(shape([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual(['anchor → a', 'a → b']); + expect(rowsOf([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual({ anchor: 0, a: 1, b: 2 }); + }); + + it('branches both arms off the step before the fork, and says what has to hold', () => { + // proshop's login: look the user up, then sign+answer 200, else answer 401. const on = 'user && (await user.matchPassword(password))'; - const fork: WireItem = { - kind: 'fork', - form: 'if', - on, - arms: [arm(on, { ends: 'reply', body: [{ kind: 'step', step: '200' }] }), arm(`!(${on})`, { not: true, ends: 'reply', body: [{ kind: 'step', step: '401' }] })], - }; - const model = buildRailModel(payload([step('200'), step('401')], [fork])); - expect(model).toHaveLength(1); - const rail = model[0]!; - if (rail.kind !== 'fork') throw new Error('expected a fork'); - expect(joinTokens(rail.words)).toBe('user AND (await user.matchPassword(password))'); - expect(rail.arms.map((a) => joinTokens(a.words))).toEqual(['WHEN', 'WHEN NOT']); - expect(rail.arms.map((a) => a.ends)).toEqual(['answers here', 'answers here']); + const root: WireBlock = [ + { kind: 'step', step: 'findOne' }, + { + kind: 'fork', + form: 'if', + on, + arms: [ + arm(on, [{ kind: 'block', block: 'inline', body: [{ kind: 'step', step: 'sign' }] }, { kind: 'step', step: '200' }], { ends: 'reply' }), + arm(`!(${on})`, [{ kind: 'step', step: '401' }], { not: true, ends: 'reply' }), + ], + }, + ]; + expect(shape(root)).toEqual([ + 'anchor → findOne', + 'findOne → sign · WHEN user AND (await user.matchPassword… [via a helper]', + 'sign → 200', + 'findOne → 401 · WHEN NOT (user && (await user.matchPass…', + ]); + // The 200 sits a row BELOW the signing, which is the whole point. + expect(rowsOf(root)).toEqual({ anchor: 0, findOne: 1, sign: 2, '200': 3, '401': 2 }); }); - it('keeps a disjunction whole rather than reading it as two ways of arriving', () => { - // `!image || unlimitedCollection` is ONE condition, and the parentheses - // `guardLabel` puts round it are what stop the OR from splitting it. - const on = '(!image || unlimitedCollection)'; - const model = buildRailModel(payload([], [{ kind: 'fork', form: 'if', on, arms: [arm(on)] }])); - const rail = model[0]!; - if (rail.kind !== 'fork') throw new Error('expected a fork'); - expect(joinTokens(rail.words)).toBe('(!image || unlimitedCollection)'); - }); - - it('gives a switch’s arms their own conditions', () => { - const fork: WireItem = { - kind: 'fork', - form: 'switch', - on: 'kind', - arms: [arm("kind === 'a'"), arm('kind: default')], - }; - const model = buildRailModel(payload([], [fork])); - const rail = model[0]!; - if (rail.kind !== 'fork') throw new Error('expected a fork'); - expect(joinTokens(rail.words)).toBe('kind'); - expect(rail.arms.map((a) => joinTokens(a.words))).toEqual(["WHEN kind === 'a'", 'WHEN kind: default']); - }); - - it('lets a try say `on error` once', () => { - expect(armWords('try', 'on error', arm('on error'))).toEqual([]); - const model = buildRailModel(payload([], [{ kind: 'fork', form: 'try', on: 'on error', arms: [arm('on error')] }])); - const rail = model[0]!; - if (rail.kind !== 'fork') throw new Error('expected a fork'); - expect(joinTokens(rail.words)).toBe('on error'); - }); - - it('says how each arm leaves', () => { - expect(endWords('reply')).toBe('answers here'); - expect(endWords('return')).toBe('returns here'); - expect(endWords('throw')).toBe('throws here'); - expect(endWords('exit')).toBe('leaves here'); - }); - - it('names each kind of bracketed run', () => { - const via = { id: 'f', kind: 'function' as const, name: 'generateToken', qualifiedName: 'generateToken', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false }; - expect(groupLabel({ kind: 'block', block: 'inline', via, body: [] })).toBe('via generateToken'); - expect(groupLabel({ kind: 'block', block: 'inline', body: [] })).toBe('via a helper'); - expect(groupLabel({ kind: 'block', block: 'later', by: 'then', body: [] })).toBe('later · then'); - expect(groupLabel({ kind: 'block', block: 'loop', by: 'item of items', body: [] })).toBe('for each item of items'); - expect(groupLabel({ kind: 'block', block: 'together', by: 'Promise.all', body: [] })).toBe('together · Promise.all'); - }); - - it('carries a box’s two lines and where the call is written', () => { - const model = buildRailModel( - payload([step('200', { label: '200', sub: 'response · authUser' })], [{ kind: 'step', step: '200', within: 'res.json' }]) - ); - const rail = model[0]!; - if (rail.kind !== 'step') throw new Error('expected a step'); - expect(rail.info?.label).toBe('200'); - expect(rail.info?.sub).toBe('response · authUser'); - expect(rail.within).toBe('res.json'); - }); - - it('says where the reading stopped', () => { - const model = buildRailModel(payload([], [{ kind: 'cut', why: 'folded' }, { kind: 'cut', why: 'depth' }])); - expect(model.map((i) => (i.kind === 'cut' ? i.text : ''))).toEqual([ - 'reads back into itself — the rest is the same code again', - 'as deep as this reading goes — start at a step below to read on', + it('rejoins after an arm that runs on, and stops at one that ends', () => { + const root: WireBlock = [ + { kind: 'step', step: 'lookup' }, + { + kind: 'fork', + form: 'if', + on: 'ready', + arms: [arm('ready', [{ kind: 'step', step: 'inside' }]), arm('!ready', [{ kind: 'step', step: 'bail' }], { not: true, ends: 'return' })], + }, + { kind: 'step', step: 'after' }, + ]; + expect(shape(root)).toEqual([ + 'anchor → lookup', + 'lookup → inside · WHEN ready', + 'lookup → bail · WHEN NOT ready', + 'inside → after', ]); }); - it('is empty when there is no body to read', () => { - expect(buildRailModel({ ...payload([], []), program: null })).toEqual([]); + it('runs on either way past an `if` with no else', () => { + const root: WireBlock = [ + { kind: 'step', step: 'lookup' }, + { kind: 'fork', form: 'if', on: 'verified', arms: [arm('verified', [{ kind: 'step', step: 'mail' }])] }, + { kind: 'step', step: 'reply' }, + ]; + expect(shape(root)).toEqual([ + 'anchor → lookup', + 'lookup → mail · WHEN verified', + 'mail → reply', + 'lookup → reply', + ]); + }); + + it('reads on into what a step sets in motion before the next step', () => { + const root: WireBlock = [ + { kind: 'step', step: 'save', body: [{ kind: 'step', step: 'write' }] }, + { kind: 'step', step: 'reply' }, + ]; + expect(shape(root)).toEqual(['anchor → save', 'save → write', 'write → reply']); + }); + + it('says the run a line happens inside', () => { + const via = { id: 'f', kind: 'function' as const, name: 'generateToken', qualifiedName: 'generateToken', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false }; + expect(shape([{ kind: 'block', block: 'inline', via, body: [{ kind: 'step', step: 'sign' }] }])).toEqual([ + 'anchor → sign [via generateToken]', + ]); + expect(shape([{ kind: 'block', block: 'loop', by: 'item of items', loop: 'each', body: [{ kind: 'step', step: 'save' }] }])).toEqual([ + 'anchor → save [for each item of items]', + ]); + }); + + it('carries on past a helper that answers on every path', () => { + // express-realworld: `login()` throws on each guard and returns on one; the + // handler's own `res.json` still follows the call. + const root: WireBlock = [ + { + kind: 'block', + block: 'inline', + body: [{ kind: 'fork', form: 'if', on: 'bad', arms: [arm('bad', [{ kind: 'step', step: '422' }], { ends: 'reply' })] }], + }, + { kind: 'step', step: '200' }, + ]; + expect(shape(root)).toEqual(['anchor → 422 · WHEN bad [via a helper]', 'anchor → 200']); + }); + + it('lets nothing float: a step the fold could not place follows the anchor', () => { + const g = orderGraph({ root: [{ kind: 'cut', why: 'folded' }], truncated: 1 } as WireProgram, 'anchor'); + expect(g.edges).toEqual([]); + }); + + it('settles the rows of a step reached twice rather than looping', () => { + const root: WireBlock = [{ kind: 'step', step: 'db' }, { kind: 'step', step: 'check' }, { kind: 'step', step: 'db' }]; + expect(shape(root)).toEqual(['anchor → db', 'db → check', 'check → db']); + expect(rowsOf(root).db).toBeGreaterThan(0); + }); + + it('names each kind of run', () => { + const via = { id: 'f', kind: 'function' as const, name: 'gen', qualifiedName: 'gen', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false }; + const block = (over: Partial>) => runWords({ kind: 'block', block: 'inline', body: [], ...over } as Extract); + expect(block({ via })).toBe('via gen'); + expect(block({})).toBe('via a helper'); + expect(block({ block: 'later', by: 'then' })).toBe('later · then'); + expect(block({ block: 'loop', by: 'item of items', loop: 'each' })).toBe('for each item of items'); + expect(block({ block: 'loop', by: 'queue.length', loop: 'while' })).toBe('again while queue.length'); + expect(block({ block: 'together', by: 'Promise.all' })).toBe('together · Promise.all'); + }); + + it('builds a picture the canvas can draw, and nothing when there is no body', () => { + const model = buildOrderModel( + payload([step('findOne'), step('200')], [{ kind: 'step', step: 'findOne' }, { kind: 'step', step: '200' }]) + ); + expect(model).not.toBeNull(); + expect([...model!.nodes.keys()].sort()).toEqual(['200', 'anchor', 'findOne']); + expect(model!.layout.nodes).toHaveLength(3); + // The anchor is on top: layer 0 is the bottom. + const layer = (id: string) => model!.layout.nodes.find((n) => n.id === id)!.layer; + expect(layer('anchor')).toBeGreaterThan(layer('findOne')); + expect(layer('findOne')).toBeGreaterThan(layer('200')); + expect(buildOrderModel({ ...payload([], []), program: null })).toBeNull(); }); }); diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index e07145f..eb4323d 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -571,29 +571,38 @@ one is fitted to the whole stage. `Picture` (`screens-model.ts`) is the structur the shared machinery works over; `steps-model.ts` builds one. Pure model tests: `ui-steps-model.test.ts`; the endpoint against a real RN + Expo fixture: `ui-steps-api.test.ts`. -#### 3.13.1 In order — the same walk as the code reads (`&view=order`) +#### 3.13.1 In order — the same picture, laid out by when things happen (`&view=order`) Rows-by-distance is the right picture for a screen, where handlers fire on events and nothing orders them. It is the wrong one for a handler: on proshop's `POST /api/users/login` the tree puts `User.findOne`, `jwt.sign`, `200` and `401` side by side — each is one step from the anchor — when the code says *look the user up, then IF the password matches sign a token and answer 200, ELSE answer 401*, and the signing happens INSIDE the reply that carries it. So the same -walk has a second reading: the anchor, then its body top to bottom. +walk has a second reading, on **the same canvas, with the same boxes**: only the graph changes. ``` -● POST /api/users/login · authUser FIRES FROM POST /api/users/login -│ User.findOne({ email }) database · User · read · authUser -│ user AND (await user.matchPassword(password)) -│ ┌ WHEN ───────────────────────┐ ┌ WHEN NOT ──────────┐ -│ │ via generateToken · inside res.json(…) │ -│ │ jwt.sign({ id }, …) auth │ │ 401 response │ -│ │ 200 response │ │ answers here │ -│ │ answers here │ └────────────────────┘ + POST /api/users/login · authUser + │ + User.findOne({ email }) + ┌───────────────────┴────────────────────┐ + → WHEN user AND (await …) → WHEN NOT (user && …) + │ │ + jwt.sign({ id }, …) auth 401 response + │ + 200 response ``` +**A line means "and then", not "leads to"** — that is the whole difference from the other reading, and the key says so. +A row down is one more thing that has already happened, so the `200` sits below the `jwt.sign` it is built from and the +`401` branches at the fork. Where the code forks the line carries what has to hold, **drawn at rest** rather than only +for a selected box (`placeLabels(model, selected, atRest)`) — on this picture the conditions ARE the content. An arm +that answers, returns or throws simply has nothing leaving it. + **What it is made of.** Every hop the walk makes is recorded where the code writes it — the step it reached (or the helper it folded into), the call's position and span, the branch guards, the loops, what fires it — by the SAME pass that makes the links, so the two readings can never hold different steps (`WireStepsPayload.program`, built by `src/ui-server/api/program.ts` from the records `steps.ts` keeps; `ProgramSite` is one such record). `buildProgram` is -pure over them: no graph, no source, no control-flow graph. +pure over them: no graph, no source, no control-flow graph. `ui/src/lib/program-model.ts` then walks that block tree +carrying a set of *tails* — the steps a next step would follow — and emits one edge per "and then"; the row of a step is +the longest run of them from the anchor, settled by relaxation because a step reached twice can make the graph cyclic. **What makes the fold possible** is that a guard names the DECISION it belongs to and not only its own words (`BranchGuard.branch` — where the branching construct starts): the `if` and the `else` of one statement carry the same @@ -601,38 +610,27 @@ branch with `negated` flipped, an early exit carries the branch of the `if` that carries the branch of the switch, and two `try`/`catch` blocks in one function stay apart. Two sites are arms of ONE fork when they agree on the branch and disagree on the arm — which a joined condition string can never say. A guard also carries how the arm it is in leaves (`armExit`) and, for an early exit, how the arm that was not taken leaves -(`exit`), so an arm ends with the word the code uses. +(`exit`); that is `WireArm.ends`, and an arm that ends is an arm nothing leaves. -**The items** (`WireItem`): a **step**, where the code writes it — with `inside res.json(…)` when it is written in +**The block tree** (`WireItem`): a **step**, where the code writes it — with `inside res.json(…)` when it is written in another call's arguments, its own body under it when the walk entered it, and `again` when the same function has -already been read (a function is read once in a rail, however many times it is called); a **fork** — `if` / `switch` / -`ternary` / `try` / an early exit — carrying its condition once, with an arm per side, each ending `reply` / `return` / -`throw` / `exit` when it stops there; a bracketed **block** — a helper drawn in place (`via generateToken`), a body that -runs per item (`for each item of items`, read by `loopsForFile`, which loops and forks nest by which construct BEGINS -first), work registered to run later (`later · then`), calls started together (`together · Promise.all`); and a **cut** -where the reading stopped. Source order is execution order for straight-line code and for arguments before their call -(so the token is signed before the reply); where it is not — a callback, concurrency — the block says so rather than -pretending. +already been read (a function is read once per picture, however many times it is called); a **fork** — `if` / `switch` / +`ternary` / `try` / an early exit — carrying its condition once, with an arm per side; a **run** that is not plain +sequence, said on the line into it — a helper drawn in place (`via generateToken`), a body that repeats (`for each item +of items`, read by `loopsForFile`; loops and forks nest by which construct BEGINS first), work registered to run later +(`later · then`), calls started together (`together · Promise.all`); and a **cut** where the reading stopped. Source +order is execution order for straight-line code and for arguments before their call; where it is not — a callback, +concurrency — the line says so rather than pretending. A helper that answers on one path still returns on another, so +the code after the call follows the call; what comes after is not inside it. **Honest by construction.** A fork exists only where a guard was READ: a language without rules, or a file that changed -since the index sync, reads as a plain sequence rather than an invented structure. Every cap is announced -(`program.truncated`). - -**The view.** `ui/src/lib/program-model.ts` decides the words, `StepsRail.svelte` + `RailBlock.svelte` draw them — no -layout engine, a column of boxes with a hairline down its left and a fork as a row of arm columns. The box is -`StepBox.svelte`, the canvas's box exactly (the canvas wraps it in handles; the rail lets it size to its words), and so -are the click, the double-click-to-start-here and the panel. The fork's head says the decision once and its arms say -only which side they are — **WHEN** / **WHEN NOT** — except a `switch`, whose arms each have a case to say, and a -`try`, which says `on error` once. **An early exit is drawn as a guard clause, not as a branch** — a fork with nothing on -one side (`if (!user) return`) is one line, the condition and where the code leaves (`userWithTeam.length === 0 -· returns here`), with everything below it running because it did not; a rail of guard clauses would otherwise step -right once per guard and a handler with four of them would read as four nested branches. `StepsKey.svelte` is the key: -floating over the canvas, last in the document on the rail, which scrolls and cannot have things sitting on it. +since the index sync, draws a plain sequence rather than an invented structure. Every cap is announced +(`program.truncated`), and nothing floats — a step the fold could not place follows the anchor unconditionally. **Which reading opens** travels in the URL (`&view=order` / `&view=tree`) and the summary offers both; without one the answer's own `defaultView` decides — the code's order for a handler, an endpoint or any function, the tree for a -screen. Tests: `ui-steps-program.test.ts` (the fold, over hand-made records), `ui-program-model.test.ts` (the words), -and one `in order` reading per framework in `ui-steps-api-servers.test.ts`. +screen. Tests: `ui-steps-program.test.ts` (the fold, over hand-made records), `ui-program-model.test.ts` (the graph and +its rows), and one `in order` reading per framework in `ui-steps-api-servers.test.ts`. ## 4. Libraries and versions - Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges, diff --git a/docs/plans/2026-08-29-steps-in-code-order.md b/docs/plans/2026-08-29-steps-in-code-order.md index d9e359a..0b061ca 100644 --- a/docs/plans/2026-08-29-steps-in-code-order.md +++ b/docs/plans/2026-08-29-steps-in-code-order.md @@ -16,6 +16,13 @@ spec §3.13.1 is the description of what was built. (§4.1.7 only said this for boundaries under `through`). - **`WireItem`'s blocks are one kind with a discriminator** (`block: 'inline' | 'loop' | 'later' | 'together'`) rather than four item kinds, and they carry facts (`by`, `via`, `loop`) rather than words — the viewer says them. +- **The reading is drawn on the CANVAS, not as a nested document.** §4.2's rail — a column of boxes with forks as rows + of arm columns — was built first and rejected on sight by the maintainer: *"this is very hard to read… go back to the + way it looked, but since the 200 and 401 come after the signing of the jwt those should branch out of it."* The right + picture is the same Svelte Flow canvas with the same boxes, laid out by WHEN things happen: a line means **and then**, + a row down is one more thing already done, and the fork's condition rides on the line (drawn at rest — on this + picture the conditions are the content). `ui/src/lib/program-model.ts` turns the block tree into that graph; + `StepsRail`/`RailBlock` are gone. - **Loops needed their own reading** (`loopsForFile`), and loops and forks nest by which construct BEGINS first, since neither reading knows about the other. - The open questions of §7 were answered: order for functions/endpoints and the tree for screens (1); read each @@ -310,6 +317,12 @@ Read against the source, endpoint by endpoint. Every reading below is the one th | `fastapi/full-stack-fastapi-template` | `POST /login/access-token` | `via authenticate` → `session.exec`, `not db_user` → return, `not verified` → return, `updated_password_hash` → `session.add`/`commit`/`refresh`; then `not user` → `400`, `elif not user.is_active` → `400`; then `via create_access_token` → `jwt.encode` | right, after the `elif` fix | | `amniservices-mobile-app` | `/capture/review` | unchanged: 87 steps, 160 links, the tree, `defaultView: 'tree'` | the regression check | +Each of those was read first as the rejected rail and then as the canvas graph; the readings are the same, the picture +is not. What the canvas gives that the rail could not: proshop's login fits the sketch the maintainer drew +(`User.findOne` → the fork → `jwt.sign` → `200` | `401`), and nest-boilerplate's login reads down the page — +`findByEmail` → `WHEN NOT user → 422` | `WHEN user AND user.provider === …` → `bcrypt.compare` → `WHEN +isValidPassword` → `sessionRepository.create` → `together · Promise.all` → `jwtService.signAsync`. + **Three defects the pictures caught, all in the walk and both readings** — a hop's span taken from a call that was not the one asked for (an inline Express handler's edges carry the route's line, so the registration's span swallowed the body); a name-match the call as written disproves (`crypto.createHash('sha256').update(…)` followed into the caller's diff --git a/ui/src/components/steps/RailBlock.svelte b/ui/src/components/steps/RailBlock.svelte deleted file mode 100644 index 01bd599..0000000 --- a/ui/src/components/steps/RailBlock.svelte +++ /dev/null @@ -1,216 +0,0 @@ - - -{#snippet words(tokens: WordToken[])} - {#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}{t.text}{:else}{t.text}{/if}{/each} -{/snippet} - -
- {#each items as item, i (i)} - {#if item.kind === 'step'} -
- {#if item.within}inside {item.within}(…){/if} - {#if item.info} - onSelect(item.id)} - onStart={canStart(item.id) ? () => onStart(item.id) : undefined} - /> - {:else} - a step the picture left out - {/if} - {#if item.again}as above{/if} -
- {#if item.body.length > 0} -
- -
- {/if} - {:else if item.kind === 'fork'} - {@const guard = item.arms.length <= 2 && item.arms[0]?.body.length === 0 && item.arms[0]?.ends !== null} - {#if guard} - -
- {@render words(item.words)} - {item.arms[0]!.ends} -
- {#if item.arms[1] && item.arms[1].body.length > 0} - - {/if} - {#if item.arms[1]?.ends}
{item.arms[1].ends}
{/if} - {:else} -
-
{@render words(item.words)}
-
- {#each item.arms as arm, a (a)} -
-
{@render words(arm.words)}
- {#if arm.body.length > 0} - - {/if} - {#if arm.ends}
{arm.ends}
{/if} -
- {/each} -
-
- {/if} - {:else if item.kind === 'group'} -
-
- {item.label}{#if item.within} · inside {item.within}(…){/if}{#if item.again} · read above{/if} -
- {#if item.body.length > 0} - - {/if} -
- {:else} -
{item.text}
- {/if} - {/each} -
- - diff --git a/ui/src/components/steps/StepsKey.svelte b/ui/src/components/steps/StepsKey.svelte index 37ca1bc..97fbfac 100644 --- a/ui/src/components/steps/StepsKey.svelte +++ b/ui/src/components/steps/StepsKey.svelte @@ -87,16 +87,20 @@ {/if} {#if order}
- WHEN x - A fork — an if, a switch, a try or an early exit — with its arms side by side under the condition + + And then — the step at the other end happens after this one; the plumbing between them is folded into the line
- answers here - The arm stops there: it answers the request, returns or throws, and nothing below it runs + WHEN x + Where the code forks — an if, a switch, a try, an early exit: what has to hold for the step at the other end. No label = it happens either way
via x - A helper drawn where it is called; later runs after this returns, together starts at once, for each repeats + Written inside a helper drawn where it is called; later runs after this returns, together starts at once, for each repeats +
+
+ + Established by a synthesized hop (an event channel, a callback, a helper's return value)
name … diff --git a/ui/src/components/steps/StepsRail.svelte b/ui/src/components/steps/StepsRail.svelte deleted file mode 100644 index d62ddcf..0000000 --- a/ui/src/components/steps/StepsRail.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - -
-
- onSelect(anchor.id)} - /> - {#if anchor.step.trigger} -
FIRES FROM {triggerWords(anchor.step.trigger)}
- {/if} -
- {#if items.length === 0} -

Nothing in the index happens in this symbol's body — the picture has no order to read.

- {:else} -
- -
- {/if} - {#if truncated > 0} -

- {truncated} place{truncated === 1 ? '' : 's'} the reading stopped: code it had already read, or as deep as it goes. - Start at a step to read on from there. -

- {/if} - {@render children?.()} -
- - diff --git a/ui/src/lib/program-model.ts b/ui/src/lib/program-model.ts index 6600329..be5878d 100644 --- a/ui/src/lib/program-model.ts +++ b/ui/src/lib/program-model.ts @@ -1,175 +1,58 @@ /** - * The Steps view's second reading, as the rail draws it. + * The Steps picture in the code's ORDER — the same canvas, laid out by when + * things happen rather than by how far they are from the anchor. * - * The server answers with the anchor's body folded into blocks and forks - * (`api/program.ts`); this turns that into what a reader sees — the boxes of - * the picture in the code's order, the conditions as words, and one line for - * every place the reading has to be honest about not being plain sequence - * (work registered to run later, calls started together, a helper already read - * above). Nothing here is geometry: the rail is a column of boxes with a - * hairline down its left, and a fork is a row of columns, so the browser lays - * it out and this file only decides what each thing SAYS. + * The tree's rows are distance: on proshop's login `User.findOne`, `jwt.sign`, + * `200` and `401` are each one step out of the handler, so they land in one + * row. True, and not the flow — the token is signed while the reply is being + * built, so the `200` comes AFTER it, and the `401` is the other side of the + * same `if`. This model draws that: * - * The words are the ones the rest of the view uses: `steps-model.ts` for a - * box's two lines and the vocabulary a project is read in, `conditions.ts` for - * WHEN / AND / OR / NOT. A fork carries its condition once, on its head; an - * arm then says only which side it is — *when* and *when not* — except in a - * `switch`, where each arm has a condition of its own to say. + * ``` + * POST /api/users/login + * | + * User.findOne + * +-------------+--------------+ + * WHEN user AND (await ...) WHEN NOT (...) + * | | + * jwt.sign 401 + * | + * 200 + * ``` + * + * **A line here means "then", not "calls"** — that is the whole difference from + * the other reading, and the key says so. It comes from the block tree the + * server folds out of the walk (`api/program.ts`): items in source order, forks + * where the code forks, an arm that answers or leaves ending there. Walking that + * tree with a set of *tails* — the steps a next step would follow — gives one + * edge per "and then", carrying the arm's condition where the code branched. + * + * Everything else is the canvas's: the same boxes, the same layout engine, the + * same pills, hover and panel. Only the graph changes. */ -import { conditionTokens, whenTokens, type WordToken } from './conditions'; -import { stepLabel, stepSub, type ProjectKind, type StepNodeInfo } from './steps-model'; -import type { WireArm, WireArmEnd, WireBlock, WireItem, WireNodeRef, WireStep, WireStepsPayload } from './wire'; +import { conditionTokens, joinTokens, type WordToken } from './conditions'; +import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model'; +import { samplePolyline, trackedCurves, EDGE_LABEL_MAX, SCREEN_LAYER_GAP, type Point } from './screens-model'; +import { stepLabel, stepSub, type StepEdgeInfo, type StepNodeInfo, type StepsModel } from './steps-model'; +import type { WireArm, WireBlock, WireItem, WireMapLink, WireMapModule, WireStep, WireStepsPayload } from './wire'; + +/* ----------------------------------------------------------------- words -- */ /** The construct a fork came from. */ export type ForkForm = Extract['form']; -/** A step of the picture, where the code writes it. */ -export interface RailStep { - kind: 'step'; - id: string; - /** The link it arrived on — the panel's rows for this site. */ - link: string | null; - /** The box's words; null when the step is not in the picture (a cap removed it). */ - info: StepNodeInfo | null; - /** The call this one is written inside the arguments of — `res.json`. */ - within: string | null; - /** What it does, when the walk read on into it. */ - body: RailItem[]; - /** It happens here too, and was read above. */ - again: boolean; -} - -export interface RailArm { - /** WHEN / WHEN NOT, or a case's own condition. */ - words: WordToken[]; - /** What the arm's last line says when it stops there: `answers`, `returns`, `throws`, `leaves`. */ - ends: string | null; - body: RailItem[]; -} - -export interface RailFork { - kind: 'fork'; - /** The decision, in the conditions vocabulary. */ - words: WordToken[]; - /** The word for the construct: `if`, `switch`, `try`. */ - form: string; - arms: RailArm[]; -} - -/** A run that is not plain sequence, bracketed and labelled. */ -export interface RailGroup { - kind: 'group'; - block: 'inline' | 'loop' | 'later' | 'together'; - /** `via generateToken`, `for each item of items`, `later · then`, `together`. */ - label: string; - /** The helper drawn here, for its link to the symbol view. */ - via: WireNodeRef | null; - within: string | null; - again: boolean; - body: RailItem[]; -} - -export interface RailCut { - kind: 'cut'; - text: string; -} - -export type RailItem = RailStep | RailFork | RailGroup | RailCut; - /** - * The rail for a payload: its anchor's body in the code's order, or an empty - * list when the server had nothing to read (a screen, an unreadable file). + * What has to hold for an arm to be the one taken, in the words the rest of the + * view uses. Said in FULL on the line, because a line on a canvas has no head + * above it to refer back to: `WHEN user AND (await …)`, `WHEN NOT (…)`. */ -export function buildRailModel(payload: WireStepsPayload): RailItem[] { - if (!payload.program) return []; - const steps = new Map(payload.steps.map((s) => [s.id, s])); - return block(payload.program.root, steps, payload.project); +export function whenTokens(when: string): WordToken[] { + return conditionTokens(when); } -function block(items: WireBlock, steps: Map, project: ProjectKind): RailItem[] { - return items.map((item) => one(item, steps, project)); -} - -function one(item: WireItem, steps: Map, project: ProjectKind): RailItem { - switch (item.kind) { - case 'step': { - const step = steps.get(item.step); - return { - kind: 'step', - id: item.step, - link: item.link ?? null, - info: step ? { id: step.id, step, label: stepLabel(step), sub: stepSub(step, project) } : null, - within: item.within ?? null, - body: item.body ? block(item.body, steps, project) : [], - again: item.again === true, - }; - } - case 'fork': - return { - kind: 'fork', - // The head is the decision itself, said once; the arms say only which - // side of it they are, so the head carries no WHEN of its own. - words: whenTokens(item.on), - form: item.form === 'switch' ? 'switch' : item.form === 'try' ? 'try' : 'if', - arms: item.arms.map((arm) => ({ - words: armWords(item.form, item.on, arm), - ends: arm.ends === null ? null : endWords(arm.ends), - body: block(arm.body, steps, project), - })), - }; - case 'block': - return { - kind: 'group', - block: item.block, - label: groupLabel(item), - via: item.via ?? null, - within: item.within ?? null, - again: item.again === true, - body: block(item.body, steps, project), - }; - default: - return { - kind: 'cut', - text: - item.why === 'folded' - ? 'reads back into itself — the rest is the same code again' - : 'as deep as this reading goes — start at a step below to read on', - }; - } -} - -/** - * What an arm says. The fork already carries the condition, so the two sides of - * an `if` say only which side they are; a `switch` arm has a condition of its - * own, and so does an arm the reading could not match to the head. - */ -export function armWords(form: ForkForm, on: string, arm: WireArm): WordToken[] { - // A case has a condition of its own to say; the one arm of a `try` is the - // head (`on error`) and says nothing twice. - if (form === 'switch') return conditionTokens(arm.when); - if (form === 'try') return []; - if (arm.not === true) return [{ kw: true, text: 'WHEN' }, { kw: true, text: 'NOT' }]; - if (arm.when === on) return [{ kw: true, text: 'WHEN' }]; - return conditionTokens(arm.when); -} - -/** How an arm leaves, as a reader says it. */ -export function endWords(end: WireArmEnd): string { - switch (end) { - case 'reply': - return 'answers here'; - case 'return': - return 'returns here'; - case 'throw': - return 'throws here'; - default: - return 'leaves here'; - } -} - -/** The words on a bracketed run. */ -export function groupLabel(item: Extract): string { +/** The words on a run that is not plain sequence — said on the line into it. */ +export function runWords(item: Extract): string { switch (item.block) { case 'inline': return item.via ? `via ${item.via.name}` : 'via a helper'; @@ -182,3 +65,233 @@ export function groupLabel(item: Extract): string { return item.by ? `together · ${item.by}` : 'together'; } } + +/* ----------------------------------------------------------------- graph -- */ + +/** One "and then": the step it follows, the step that happens, and what had to hold. */ +export interface OrderEdge { + from: string; + to: string; + /** The conditions on the way — the arms of the forks crossed, joined by ` && `. */ + when: string; + /** `via generateToken`, `for each item of items`, `later · then` — the run it happens inside. */ + runs: string[]; +} + +/** Where a next step would follow from, and under what. */ +interface Tail { + id: string; + when: string[]; + runs: string[]; +} + +export interface OrderGraph { + edges: OrderEdge[]; + /** How many things happen before each step: its row. */ + depth: Map; +} + +/** + * The block tree as a graph of what happens next. `anchor` is where the reading + * starts, so the first thing in the body follows it. + */ +export function orderGraph(program: NonNullable, anchor: string): OrderGraph { + const edges: OrderEdge[] = []; + const seen = new Set([anchor]); + const at = new Map(); + + const join = (from: string, to: string, tail: Tail): void => { + if (from === to) return; + const key = `${from} ${to}`; + const when = tail.when.filter((w, i) => w && tail.when.indexOf(w) === i).join(' && '); + const found = at.get(key); + if (found) { + // Two ways to the same step: the picture keeps both conditions, the way + // a link with several sites does. + if (when !== found.when) found.when = !when || !found.when ? '' : `${found.when} || ${when}`; + for (const r of tail.runs) if (!found.runs.includes(r)) found.runs.push(r); + return; + } + const edge: OrderEdge = { from, to, when, runs: [...tail.runs] }; + at.set(key, edge); + edges.push(edge); + }; + + const flow = (block: WireBlock, incoming: readonly Tail[], runs: readonly string[]): Tail[] => { + let tails: Tail[] = [...incoming]; + for (const item of block) { + if (item.kind === 'step') { + seen.add(item.step); + for (const t of tails) join(t.id, item.step, { ...t, runs: [...t.runs, ...runs] }); + // What the step itself sets in motion happens inside it, so the next + // thing in the block follows THAT, not the box. + let inner: Tail[] = [{ id: item.step, when: [], runs: [] }]; + if (item.body && item.body.length > 0) inner = flow(item.body, inner, []); + tails = inner; + } else if (item.kind === 'fork') { + const out: Tail[] = []; + for (const arm of item.arms) { + // The arm's condition as the SOURCE has it: the words are made once, + // at the end, or two ways of arriving would each carry their own WHEN. + const entry = tails.map((t) => ({ id: t.id, when: [...t.when, arm.when], runs: [...t.runs, ...runs] })); + // An arm that answers, returns or throws does not rejoin — nothing + // leaves the last box in it, which is what says so on a canvas. + const armTails = flow(arm.body, entry, []); + if (arm.ends === null) out.push(...armTails); + } + // An `if` with no `else` runs on either way; a fork with both sides + // covered runs on only through the arms that did not end. + if (item.arms.length < 2) out.push(...tails.map((t) => ({ ...t, runs: [...t.runs, ...runs] }))); + tails = out; + } else if (item.kind === 'block') { + const label = runWords(item); + const inner = flow(item.body, tails, [...runs, label]); + // A helper that answers on one path still returns on another: the code + // after the call follows the call, not nothing. And what comes after + // the call is not inside it — a tail that fell through the block drops + // the block's own words on the way out. + tails = (inner.length > 0 ? inner : tails).map((t) => (t.runs.includes(label) ? { ...t, runs: t.runs.filter((r) => r !== label) } : t)); + } + } + return tails; + }; + + flow(program.root, [{ id: anchor, when: [], runs: [] }], []); + + // Nothing the reading holds may float: a step the walk drew but the fold + // could not place follows the anchor, unconditionally. + for (const id of seen) { + if (id !== anchor && !edges.some((e) => e.to === id)) join(anchor, id, { id: anchor, when: [], runs: [] }); + } + + return { edges, depth: rows(anchor, seen, edges) }; +} + +/** + * The row each step sits on: the longest run of "and then" from the anchor to + * it, so a step never draws above something that has to happen first. Settled + * by relaxation rather than a topological sort, because a step reached twice + * (`session.add` before and after a check) can make the graph cyclic. + */ +function rows(anchor: string, nodes: ReadonlySet, edges: readonly OrderEdge[]): Map { + const depth = new Map(); + for (const id of nodes) depth.set(id, 0); + depth.set(anchor, 0); + for (let pass = 0; pass < nodes.size; pass++) { + let moved = false; + for (const e of edges) { + const next = (depth.get(e.from) ?? 0) + 1; + if (next > (depth.get(e.to) ?? 0)) { + depth.set(e.to, next); + moved = true; + } + } + if (!moved) break; + } + return depth; +} + +/* ----------------------------------------------------------------- build -- */ + +/** Points a curve is sampled at for hit-testing (as the other reading's). */ +const HIT_SAMPLES = 24; + +/** + * The same picture as `buildStepsModel`, laid out in the code's order. Null + * when the anchor has no body to read — the view then offers the tree. + */ +export function buildOrderModel(payload: WireStepsPayload): StepsModel | null { + if (!payload.program) return null; + const anchorStep = payload.steps.find((s) => s.anchor); + if (!anchorStep) return null; + const graph = orderGraph(payload.program, anchorStep.id); + + const nodes = new Map(); + const modules: WireMapModule[] = []; + const counts: StepsModel['counts'] = { anchor: 0, screen: 0, trigger: 0, bridge: 0, event: 0, store: 0, effect: 0 }; + const degree = new Map(); + for (const e of graph.edges) { + degree.set(e.from, (degree.get(e.from) ?? 0) + 1); + degree.set(e.to, (degree.get(e.to) ?? 0) + 1); + } + const byId = new Map(payload.steps.map((s) => [s.id, s])); + for (const id of [anchorStep.id, ...graph.depth.keys()]) { + if (nodes.has(id)) continue; + const step: WireStep | undefined = byId.get(id); + if (!step) continue; + counts[step.kind]++; + const info: StepNodeInfo = { id, step, label: stepLabel(step), sub: stepSub(step, payload.project) }; + nodes.set(id, info); + modules.push({ + id, + label: info.label, + files: 1, + symbols: degree.get(id) ?? 0, + languages: [], + test: false, + generated: 0, + generatedFiles: [], + facade: false, + fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] }, + }); + } + + const links: WireMapLink[] = []; + const edges = new Map(); + for (const e of graph.edges) { + if (!nodes.has(e.from) || !nodes.has(e.to)) continue; + const key = linkId({ source: e.from, target: e.to }); + if (edges.has(key)) continue; + links.push({ source: e.from, target: e.to, count: 1, declared: 1, byKind: [{ kind: 'calls', count: 1 }], topPairs: [] }); + // The panel and the tooltip still read the walk's own links — the sites, + // the `via` chain, what fires it — for the step the line arrives at. + const behind = payload.links.filter((l) => l.to === e.to); + edges.set(key, { + id: key, + from: e.from, + to: e.to, + links: behind, + label: lineWords(e), + synthesized: behind.length > 0 && behind.every((l) => l.synthesized), + kind: behind[0]?.kind ?? 'calls', + }); + } + + // Row 0 is the bottom, so the anchor — nothing happens before it — is on top. + const deepest = Math.max(0, ...graph.depth.values()); + const layering = (ids: string[]): Map => + new Map(ids.map((id) => [id, deepest - (graph.depth.get(id) ?? deepest)])); + + const layout: MapLayout = buildMapLayout( + { modules, links }, + { + includeTests: true, + minWeight: 0, + sizing: (m) => { + const info = nodes.get(m.id); + return { label: info?.label ?? m.id, meta: info?.sub ?? '' }; + }, + layering, + order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER, + layerGap: SCREEN_LAYER_GAP, + portPitch: PORT_PITCH, + ports: 'directional', + } + ); + const curves = trackedCurves(layout, SCREEN_LAYER_GAP); + const polylines = new Map(); + for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES)); + return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts }; +} + +/** + * What a line says: the whole condition the step at its end runs under — this + * picture's lines ARE its conditions, so they are not shortened to the last + * clause the way the other reading's are — else the run it happens inside + * (`via generateToken`), and nothing at all when the code simply goes on. + */ +export function lineWords(e: OrderEdge): string { + if (!e.when) return e.runs.length > 0 ? e.runs[e.runs.length - 1]! : ''; + const text = joinTokens(whenTokens(e.when)); + return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…` : text; +} diff --git a/ui/src/lib/screens-model.ts b/ui/src/lib/screens-model.ts index b4af9b3..3d29355 100644 --- a/ui/src/lib/screens-model.ts +++ b/ui/src/lib/screens-model.ts @@ -757,18 +757,22 @@ function layPill( * pill: the pill for a hovered edge that is not the selected screen's is * placed separately by {@link hoverPill}. */ -export function placeLabels(model: Picture, selected: string | null): PillLayout { +export function placeLabels(model: Picture, selected: string | null, atRest = false): PillLayout { const pills = new Map(); - if (selected === null) return { pills, hidden: 0 }; + if (selected === null && !atRest) return { pills, hidden: 0 }; const nodes = new Map(model.layout.nodes.map((n) => [n.id, n])); const lanes = laneCount(model.layerGap); const bounds = { width: model.layout.width, height: model.layout.height }; const taken: Rect[] = model.layout.nodes.map((n) => ({ x: n.x, y: n.y, w: n.width, h: n.height })); + // At rest, only the selected screen's lines are labelled — a picture with a + // label on every line is unreadable, and the reader has asked about one box. + // A picture whose labels ARE its content says so (`atRest`): the Steps view + // in the code's order, where the conditions on the lines are the flow. const candidates = model.layout.edges - .filter((e) => e.source === selected || e.target === selected) + .filter((e) => atRest || e.source === selected || e.target === selected) .map((edge) => { - const end: 'source' | 'target' = edge.source === selected ? 'target' : 'source'; + const end: 'source' | 'target' = selected !== null && edge.target === selected ? 'source' : 'target'; const far = nodes.get(end === 'source' ? edge.source : edge.target); const anchor = far ? portPoint(far, edge.id, end) : { x: 0, y: 0 }; return { edge, end, anchor }; diff --git a/ui/src/views/StepsView.svelte b/ui/src/views/StepsView.svelte index e1cb026..46fdc13 100644 --- a/ui/src/views/StepsView.svelte +++ b/ui/src/views/StepsView.svelte @@ -17,7 +17,6 @@ import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte'; import '@xyflow/svelte/dist/style.css'; import StepNode from '../components/steps/StepNode.svelte'; - import StepsRail from '../components/steps/StepsRail.svelte'; import StepsKey from '../components/steps/StepsKey.svelte'; import ScreenEdge from '../components/screens/ScreenEdge.svelte'; import KindGlyph from '../components/KindGlyph.svelte'; @@ -46,7 +45,7 @@ triggerWords, type StepsModel, } from '../lib/steps-model'; - import { buildRailModel } from '../lib/program-model'; + import { buildOrderModel } from '../lib/program-model'; interface Props { anchor: string | null; @@ -192,37 +191,24 @@ return () => controller.abort(); }); - const model = $derived(payload === null ? null : buildStepsModel(payload)); - /** * Which reading is on screen. The URL wins; otherwise the answer's own * default — the code's order for a handler, an endpoint or any function, the * tree for a screen, where handlers fire on events and have nothing to order. */ const readAs = $derived<'order' | 'tree'>(reading ?? payload?.defaultView ?? 'tree'); - const rail = $derived(payload === null || readAs !== 'order' ? [] : buildRailModel(payload)); - /** The rail can be asked for and have nothing to show: say so rather than drawing an empty page. */ - const railReadable = $derived(payload?.program != null); - /** The steps on the selected step's own lines — everything else on the rail is dimmed. */ - const litOnRail = $derived.by(() => { - if (payload === null || selected === null) return null; - const set = new Set([selected]); - for (const l of payload.links) { - if (l.from === selected) set.add(l.to); - if (l.to === selected) set.add(l.from); - } - return set; - }); - /** A step with a symbol behind it can become the next anchor. */ - function canStart(id: string): boolean { - const step = payload?.steps.find((s) => s.id === id); - return !!step?.node && !step.anchor; - } - function selectOnRail(id: string): void { - selected = selected === id ? null : id; - hovered = null; - panelHot = null; - } + + /** + * The picture. Both readings are the same canvas over the same boxes; what + * differs is the graph — in the code's order a line means "and then" and the + * rows are how much has already happened, in the tree it means "leads to" and + * the rows are distance from the anchor. + */ + const model = $derived( + payload === null ? null : (readAs === 'order' ? buildOrderModel(payload) : null) ?? buildStepsModel(payload) + ); + /** The order can be asked for and have nothing to read: the view then says so. */ + const orderReadable = $derived(payload?.program != null); const neighbours = $derived.by(() => { if (model === null || selected === null) return null; @@ -234,7 +220,9 @@ return set; }); - const pills = $derived(model === null ? null : placeLabels(model, selected)); + // In the code's order the conditions ON the lines are the picture: they are + // drawn at rest, not only for the step the reader selected. + const pills = $derived(model === null ? null : placeLabels(model, selected, readAs === 'order')); const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null); const focusPill = $derived.by(() => { if (model === null || focusId === null || pills?.pills.has(focusId)) return null; @@ -349,7 +337,7 @@ } function onStageMove(event: MouseEvent): void { - if (model === null || stage === null || readAs === 'order') return; + if (model === null || stage === null) return; const target = event.target as Element | null; if (target?.closest('.spill')) return; if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) { @@ -491,37 +479,15 @@
{:else if loading && payload === null}

Walking from the anchor…

- {:else if model !== null && payload !== null && readAs === 'order'} - {#if railReadable} - startHere(id)} - {canStart} - > - (legendOpen = next)} - /> - - {:else} -
-

This has no body to read in order

-

- Nothing the picture holds is written inside this symbol — a screen renders handlers that fire on - events, and they have no order between them. Read it as what it sets in motion instead. -

-

What it sets in motion →

-
- {/if} + {:else if model !== null && payload !== null && readAs === 'order' && !orderReadable} +
+

This has no body to read in order

+

+ Nothing the picture holds is written inside this symbol — a screen renders handlers that fire on + events, and they have no order between them. Read it as what it sets in motion instead. +

+

What it sets in motion →

+
{:else if model !== null && payload !== null} (legendOpen = next)} /> + {#if payload !== null && model !== null && (readAs === 'tree' || orderReadable)} + (legendOpen = next)} + /> {/if} @@ -781,11 +753,11 @@

{#if readAs === 'order'}

- The anchor is at the top, then its body in the code's own order: the - calls as they are written, a fork where the code forks with its arms side by side, a helper drawn - where it is called, and an arm that answers, returns or throws ending there. A call written inside - another call's arguments comes first — the token is signed before the reply that carries it. Click - a step for its sites and conditions; a step is the next anchor. + The anchor is at the top, and each row down is what happens next: a line + means and then, and where the code forks the line says what has to hold. A call written inside + another call's arguments happens first — the token is signed before the reply that carries it — and an + arm that answers, returns or throws simply has nothing leaving it. Click a step for its sites and the + whole condition; a step is the next anchor.

{:else}