Ver código fonte

feat(steps): the same walk, read in the code's order

The picture answers "what does this set in motion", a row per distance from
the anchor. On proshop's login that puts `User.findOne`, `jwt.sign`, `200` and
`401` side by side — all one step out — when the code says: look the user up,
then IF the password matches sign a token and answer 200, ELSE answer 401. The
signing is not beside the 200, it happens INSIDE the reply it is part of.

So the walk now records what happens in each function where the code writes it
— the step reached (or the helper folded into), the call's position and span,
and the branch guards, structured — and `api/program.ts` folds those records
into the anchor's body: items in source order, a fork wherever two sites are
arms of one decision, a helper drawn in place at its call, an arm that answers
the request or leaves ending there. A call written inside another call's
arguments comes first, so the token is signed before the reply that carries it.

It is a derivation, not a second walk: the records are made by the pass that
makes the links, so the two readings can never hold different steps. A fork
exists only where a guard was READ — a language without rules, or a file that
changed since the index, reads as a plain sequence rather than an invented
structure. The reading opens each function once, however many times it is
called (`again`), and is capped like everything else here.

The payload carries it as `program`, with `defaultView`: the code's order for a
handler, an endpoint or any function; the tree for a screen, where handlers
fire on events and have no order between them.

Measured on a 87-step / 160-link screen: +3% wall clock, +95 KB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
Colby McHenry 6 dias atrás
pai
commit
b02e192ffa

+ 242 - 0
__tests__/ui-steps-program.test.ts

@@ -0,0 +1,242 @@
+/**
+ * The Steps view's second reading: the anchor's body in the code's order.
+ *
+ * `buildProgram` is pure over the records the walk makes — no graph, no
+ * source — so this suite hands it records by hand and reads the block tree
+ * back. The end-to-end reading over real fixtures is in
+ * `ui-steps-api-servers.test.ts`; what is pinned here is the FOLD: which sites
+ * become arms of one decision, what ends an arm, and where a helper is drawn.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { BranchGuard } from '../src/graph/branch-guards';
+import { buildProgram, type ProgramInput, type ProgramSite, type WireBlock, type WireItem } from '../src/ui-server/api/program';
+
+/* ------------------------------------------------------------ material -- */
+
+let nextLine = 1;
+
+/** A guard, with the fields the fold reads: which decision, which arm, how the arm leaves. */
+function g(text: string, opts: Partial<BranchGuard> = {}): BranchGuard {
+  return { text, negated: false, form: 'if', line: 1, branch: `b:${text}`, ...opts };
+}
+
+/** A site at the next line, reaching a step. */
+function at(step: string, guards: BranchGuard[] = [], extra: Partial<ProgramSite> = {}): ProgramSite {
+  const line = nextLine++;
+  return { step, link: `l:${step}`, at: { line, column: 0, end: { line, column: 40 } }, guards, ...extra };
+}
+
+/** A site that folds into a helper. */
+function into(fn: string, guards: BranchGuard[] = [], extra: Partial<ProgramSite> = {}): ProgramSite {
+  const line = nextLine++;
+  return { into: fn, at: { line, column: 0, end: { line, column: 40 } }, guards, ...extra };
+}
+
+function program(sites: Record<string, ProgramSite[]>, replies: string[] = [], into: Record<string, string> = {}) {
+  nextLine = 1;
+  const input: ProgramInput = {
+    sites: new Map(Object.entries(sites)),
+    root: 'root',
+    node: (id) => ({ id, kind: 'function', name: id, qualifiedName: id, file: 'a.ts', line: 1, endLine: 2, language: 'typescript', test: false }),
+    step: (id) => ({ reply: replies.includes(id), into: into[id] ?? null }),
+  };
+  return buildProgram(input);
+}
+
+/** The shape of a block, one line per item, indented — what a reader would see. */
+function shape(block: WireBlock, indent = ''): string[] {
+  const out: string[] = [];
+  for (const item of block) {
+    if (item.kind === 'step') {
+      out.push(`${indent}${item.step}${item.again ? ' (again)' : ''}${item.within ? ` inside ${item.within}` : ''}`);
+      if (item.body) out.push(...shape(item.body, `${indent}  `));
+    } else if (item.kind === 'fork') {
+      out.push(`${indent}${item.form} ${item.on}`);
+      for (const arm of item.arms) {
+        out.push(`${indent}  arm ${arm.when}${arm.ends ? ` ends:${arm.ends}` : ''}`);
+        out.push(...shape(arm.body, `${indent}    `));
+      }
+    } else if (item.kind === 'block') {
+      out.push(`${indent}${item.block} ${item.label}${item.again ? ' (again)' : ''}`);
+      out.push(...shape(item.body, `${indent}  `));
+    } else out.push(`${indent}cut ${item.why}`);
+  }
+  return out;
+}
+
+/* --------------------------------------------------------------- tests -- */
+
+describe('buildProgram', () => {
+  it('reads a straight line in the code’s order', () => {
+    const p = program({ root: [at('a'), at('b'), at('c')] });
+    expect(shape(p!.root)).toEqual(['a', 'b', 'c']);
+  });
+
+  it('is nothing when the anchor has no body to read', () => {
+    expect(program({})).toBeNull();
+    expect(buildProgram({ sites: new Map(), root: null, node: () => null, step: () => null })).toBeNull();
+  });
+
+  it('makes an if and its else two arms of ONE fork', () => {
+    const cond = 'user && ok';
+    const p = program({
+      root: [at('lookup'), at('sign', [g(cond)]), at('200', [g(cond)]), at('401', [g(cond, { negated: true, form: 'else' })])],
+    });
+    expect(shape(p!.root)).toEqual([
+      'lookup',
+      'if user && ok',
+      '  arm user && ok',
+      '    sign',
+      '    200',
+      '  arm !(user && ok)',
+      '    401',
+    ]);
+    const fork = p!.root[1] as Extract<WireItem, { kind: 'fork' }>;
+    expect(fork.arms).toHaveLength(2);
+  });
+
+  it('ends an arm that answers the request', () => {
+    const cond = 'user';
+    const p = program(
+      { root: [at('200', [g(cond)]), at('401', [g(cond, { negated: true, form: 'else' })])] },
+      ['200', '401']
+    );
+    expect(shape(p!.root)).toEqual(['if user', '  arm user ends:reply', '    200', '  arm !user ends:reply', '    401']);
+  });
+
+  it('draws an early exit as the fork’s other arm, with how it leaves', () => {
+    // `if (!product) { res.status(404); throw }` then the rest — the guard on
+    // the code AFTER carries the same branch, negated, and how the exit left.
+    const p = program({
+      root: [
+        at('404', [g('!product', { branch: 'b:1' })]),
+        at('save', [g('!product', { negated: true, form: 'guard', branch: 'b:1', exit: 'throw' })]),
+      ],
+    }, ['404']);
+    expect(shape(p!.root)).toEqual(['if !product', '  arm !product ends:reply', '    404', '  arm product', '    save']);
+  });
+
+  it('draws an early exit whose arm holds nothing as a terminal', () => {
+    const p = program({ root: [at('go', [g('busy', { negated: true, form: 'guard', exit: 'return' })])] });
+    expect(shape(p!.root)).toEqual(['if busy', '  arm busy ends:return', '  arm !busy', '    go']);
+  });
+
+  it('nests forks the way the code nests them', () => {
+    const outer = g('product', { branch: 'b:outer' });
+    const inner = g('reviewed', { branch: 'b:inner' });
+    const p = program(
+      {
+        root: [
+          at('400', [outer, inner]),
+          at('201', [outer, { ...inner, negated: true, form: 'guard', exit: 'throw' }]),
+          at('404', [{ ...outer, negated: true, form: 'else' }]),
+        ],
+      },
+      ['400', '201', '404']
+    );
+    expect(shape(p!.root)).toEqual([
+      'if product',
+      '  arm product',
+      '    if reviewed',
+      '      arm reviewed ends:reply',
+      '        400',
+      '      arm !reviewed ends:reply',
+      '        201',
+      '  arm !product ends:reply',
+      '    404',
+    ]);
+  });
+
+  it('puts every case of one switch under one fork', () => {
+    const branch = 'b:switch';
+    const p = program({
+      root: [
+        at('a', [g("kind === 'a'", { form: 'case', branch })]),
+        at('b', [g("kind === 'b'", { form: 'case', branch })]),
+        at('d', [g('kind: default', { form: 'case', branch })]),
+      ],
+    });
+    expect(shape(p!.root)).toEqual([
+      "switch kind === 'a'",
+      "  arm kind === 'a'",
+      '    a',
+      "  arm kind === 'b'",
+      '    b',
+      '  arm kind: default',
+      '    d',
+    ]);
+  });
+
+  it('keeps two try/catch blocks apart', () => {
+    const p = program({
+      root: [
+        at('first', [g('on error', { form: 'catch', branch: 'b:try1' })]),
+        at('second', [g('on error', { form: 'catch', branch: 'b:try2' })]),
+      ],
+    });
+    expect(shape(p!.root)).toEqual(['try on error', '  arm on error', '    first', 'try on error', '  arm on error', '    second']);
+  });
+
+  it('draws a folded helper where it is called, and says what it is inside', () => {
+    const p = program({
+      root: [into('helper', [], { within: 'res.json' }), at('200')],
+      helper: [at('sign')],
+    });
+    expect(shape(p!.root)).toEqual(['inline via helper', '  sign', '200']);
+    const block = p!.root[0] as Extract<WireItem, { kind: 'block' }>;
+    expect(block.within).toBe('res.json');
+    expect(block.via?.name).toBe('helper');
+  });
+
+  it('puts a call written inside another call’s arguments first', () => {
+    // `res.json({ token: generateToken(…) })` spans lines 14–21 and the token is
+    // signed on line 19: the signing happens BEFORE the reply it is part of.
+    const reply: ProgramSite = { step: '200', at: { line: 14, column: 4, end: { line: 21, column: 6 } }, guards: [] };
+    const signed: ProgramSite = { step: 'sign', at: { line: 19, column: 13, end: { line: 19, column: 34 } }, guards: [] };
+    const p = program({ root: [reply, signed] });
+    expect(shape(p!.root)).toEqual(['sign', '200']);
+  });
+
+  it('reads a function once, however many times it is called', () => {
+    const p = program({
+      root: [into('helper'), at('x'), into('helper')],
+      helper: [at('work')],
+    });
+    expect(shape(p!.root)).toEqual(['inline via helper', '  work', 'x', 'inline via helper (again)']);
+  });
+
+  it('reads on into a step the walk entered, and stops at one it did not', () => {
+    // A step explores from its own function: `store`'s is `storeFn`, whose
+    // sites are its body. A boundary — another screen, an effect — has none.
+    const entered = program({ root: [at('store')], storeFn: [at('write')] }, [], { store: 'storeFn' });
+    expect(shape(entered!.root)).toEqual(['store', '  write']);
+    const boundary = program({ root: [at('store')], storeFn: [at('write')] });
+    expect(shape(boundary!.root)).toEqual(['store']);
+  });
+
+  it('says a helper that calls itself was already read', () => {
+    const p = program({ root: [into('a')], a: [at('x'), into('a')] });
+    expect(shape(p!.root)).toEqual(['inline via a', '  x', '  inline via a (again)']);
+  });
+
+  it('puts work registered to run later in a block of its own', () => {
+    const p = program({
+      root: [at('now'), at('afterwards', [], { trigger: { kind: 'callback', name: 'then', of: null } })],
+    });
+    expect(shape(p!.root)).toEqual(['now', 'later later · then', '  afterwards']);
+  });
+
+  it('puts calls started together in one block', () => {
+    const p = program({
+      root: [at('a', [], { within: 'Promise.all' }), at('b', [], { within: 'Promise.all' }), at('c')],
+    });
+    expect(shape(p!.root)).toEqual(['together together', '  a inside Promise.all', '  b inside Promise.all', 'c']);
+  });
+
+  it('closes a fork when the code leaves it', () => {
+    const cond = g('ready');
+    const p = program({ root: [at('inside', [cond]), at('after')] });
+    expect(shape(p!.root)).toEqual(['if ready', '  arm ready', '    inside', 'after']);
+  });
+});

+ 349 - 0
src/ui-server/api/program.ts

@@ -0,0 +1,349 @@
+/**
+ * The anchor's body as the code reads it — the Steps view's second reading.
+ *
+ * `steps.ts` walks FORWARD from the anchor and draws what it sets in motion,
+ * a row per distance. That 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 login, `User.findOne`, `jwt.sign`, `200` and `401` are each one
+ * step from the anchor and land side by side, when the code says *first the
+ * lookup, then IF the password matches sign a token and answer 200, ELSE
+ * answer 401* — and the signing happens INSIDE the reply it is part of.
+ *
+ * This file turns the same walk into that reading: items in source order,
+ * forks where the code forks, an arm that replies or leaves ending there, and
+ * a folded helper drawn in place at the call. It is a pure function of the
+ * records the walk made ({@link ProgramSite}) — no graph, no source, no
+ * control-flow graph. A fork exists only where a guard was READ, so a language
+ * without rules, or a file that drifted since the index, yields a plain
+ * sequence rather than an invented structure.
+ *
+ * What makes the fold possible is that a guard names the DECISION it belongs
+ * to and not just its own words (`BranchGuard.branch`): the `if` and the
+ * `else` of one statement carry the same branch with `negated` flipped, an
+ * early exit carries the branch of the `if` that returned, and every case of a
+ * switch carries the branch of the switch. 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.
+ */
+
+import { guardLabel, type BranchGuard } from '../../graph/branch-guards';
+import type { WireNodeRef } from './wire';
+
+// =============================================================================
+// Wire shapes
+// =============================================================================
+
+/** How an arm of a fork leaves, when it does — the rail stops there. */
+export type WireArmEnd = 'reply' | 'return' | 'throw' | 'exit';
+
+export interface WireArm {
+  /** This arm's own condition, in the words the rest of the view uses. */
+  when: string;
+  /** How it leaves: it answers the request, returns, or throws. Null = it runs on. */
+  ends: WireArmEnd | null;
+  body: WireBlock;
+}
+
+export type WireBlock = WireItem[];
+
+export type WireItem =
+  /**
+   * A step of the picture, where the code writes it. `body` is what it does,
+   * when the walk entered it; `again` says it happens here too and was read
+   * above — a function is read ONCE in a rail, however many times it is called.
+   */
+  | { kind: 'step'; step: string; link?: string; within?: string; body?: WireBlock; again?: true }
+  /** A decision: `if` / `else`, a `switch`, a ternary, a `try`, or an early exit. */
+  | { kind: 'fork'; on: string; form: 'if' | 'switch' | 'ternary' | 'try'; arms: WireArm[] }
+  /**
+   * A run of items that is not plain sequence: a helper drawn where it is
+   * called (`inline`), a body that runs for each item (`loop`), work that runs
+   * after this function returns (`later`), or calls started together
+   * (`together`).
+   */
+  | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; label: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true }
+  /** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
+  | { kind: 'cut'; why: 'folded' | 'depth' };
+
+export interface WireProgram {
+  root: WireBlock;
+  /** Items the reading could not place — a recursion or a cap it hit. */
+  truncated: number;
+}
+
+// =============================================================================
+// What the walk records
+// =============================================================================
+
+/** One thing that happens in one function, and where the code writes it. */
+export interface ProgramSite {
+  /** The step reached here, when one is. */
+  step?: string;
+  /** The link that step arrived on — the panel's row for this site. */
+  link?: string;
+  /** The helper the walk folded into here; its own sites are its body. */
+  into?: string;
+  /** Where the call is written: its start, and the end of its span. */
+  at: { line: number; column: number; end: { line: number; column: number } };
+  /** The call this one is written inside the arguments of (`res.json`). */
+  within?: string;
+  /** The conditions it runs under, outermost first. */
+  guards: BranchGuard[];
+  /** What fires it, when something binds it — a callback runs LATER. */
+  trigger?: { kind: string; name: string; of?: string | null };
+}
+
+export interface ProgramInput {
+  /** The sites of each function, by the function's node id. */
+  sites: ReadonlyMap<string, readonly ProgramSite[]>;
+  /** Where the reading starts: the anchor's root function. */
+  root: string | null;
+  /** A folded helper, for the words on the block it opens. */
+  node(id: string): WireNodeRef | null;
+  /**
+   * What a step is, for the two things the reading needs to know: whether it
+   * ANSWERS (a reply ends its arm), and the function to read on into when the
+   * walk entered it (null for a boundary, an effect, or a step of its own
+   * chapter).
+   */
+  step(id: string): { reply: boolean; into: string | null } | null;
+}
+
+// =============================================================================
+// The fold
+// =============================================================================
+
+/** Callbacks whose argument runs after this function returns, not where it is written. */
+const LATER_OF = /^(?:then|catch|finally|setTimeout|setInterval|setImmediate|queueMicrotask|requestAnimationFrame|useEffect|useLayoutEffect|addListener|addEventListener|on|once|subscribe|nextTick|process\.nextTick)$/;
+
+/** Calls whose arguments are started TOGETHER, not one after the other. */
+const TOGETHER = /^(?:Promise\.(?:all|allSettled|any|race)|asyncio\.gather|Task\.WhenAll|Task\.WhenAny)$/;
+
+/** How deep a helper may be drawn inside a helper before the reading says so. */
+const MAX_INLINE = 8;
+/** Items in one reading. A rail past this is not a reading any more. */
+const MAX_ITEMS = 1200;
+
+/**
+ * What the reading has already said, so it says it once: a function whose body
+ * has been drawn is drawn as a bare box (or a bare `via`) everywhere else it is
+ * called, marked `again`. Without this a helper called from five arms is
+ * expanded five times and a picture of 87 steps becomes four thousand items.
+ */
+interface Reading {
+  truncated: number;
+  items: number;
+  read: Set<string>;
+}
+
+export function buildProgram(input: ProgramInput): WireProgram | null {
+  if (input.root === null) return null;
+  const state: Reading = { truncated: 0, items: 0, read: new Set([input.root]) };
+  const root = blockFor(input, input.root, [input.root], state);
+  return root.length === 0 ? null : { root, truncated: state.truncated };
+}
+
+/** One function's body, in the code's order. */
+function blockFor(input: ProgramInput, fn: string, path: readonly string[], state: Reading): WireBlock {
+  const sites = [...(input.sites.get(fn) ?? [])].sort(compareSites);
+  if (sites.length === 0) return [];
+
+  const root: WireBlock = [];
+  /** The forks open at the site being placed, outermost first. */
+  const stack: Array<{ branch: string; fork: Extract<WireItem, { kind: 'fork' }>; armKey: string; arm: WireArm; exit?: WireArmEnd }> = [];
+  const blockAt = (depth: number): WireBlock => (depth === 0 ? root : stack[depth - 1]!.arm.body);
+
+  for (const site of sites) {
+    const guards = site.guards;
+
+    // The longest prefix of open forks the site still sits under, arm and all.
+    let keep = 0;
+    while (keep < stack.length && keep < guards.length && stack[keep]!.branch === guards[keep]!.branch && stack[keep]!.armKey === armKey(guards[keep]!)) {
+      keep++;
+    }
+    // The level after it may still be the SAME decision taken the other way —
+    // an `else`, another `case`, the code after an early exit. That keeps the
+    // fork and opens its other arm; anything deeper is closed either way.
+    if (keep < stack.length && keep < guards.length && stack[keep]!.branch === guards[keep]!.branch) {
+      stack.length = keep + 1;
+      const open = stack[keep]!;
+      open.armKey = armKey(guards[keep]!);
+      open.arm = armFor(open.fork, guards[keep]!);
+      keep++;
+    } else {
+      stack.length = keep;
+    }
+    for (let i = keep; i < guards.length; i++) {
+      const g = guards[i]!;
+      const fork: Extract<WireItem, { kind: 'fork' }> = { kind: 'fork', on: g.text, form: formOf(g), arms: [] };
+      // An early exit is a fork whose OTHER arm left before this site could
+      // run: `if (!product) throw` — the throw is written first, so it is the
+      // first arm, and it is empty because nothing in the picture happens there.
+      if (g.form === 'guard' && g.negated) {
+        fork.arms.push({ when: guardLabel([{ ...g, negated: false }]), ends: g.exit ?? 'return', body: [] });
+      }
+      blockAt(i).push(fork);
+      stack.push({ branch: g.branch, fork, armKey: armKey(g), arm: armFor(fork, g) });
+    }
+
+    const item = itemFor(input, site, path, state);
+    if (item !== null) {
+      state.items++;
+      place(blockAt(guards.length), item, site);
+    }
+  }
+
+  // An arm that answers the request, or whose code leaves, stops there.
+  seal(input, root);
+  return root;
+}
+
+/** What one recorded site draws as. */
+function itemFor(input: ProgramInput, site: ProgramSite, path: readonly string[], state: Reading): WireItem | null {
+  if (site.into) {
+    // A helper the walk folded: drawn where it is called, its own body inside.
+    const via = input.node(site.into);
+    const block: Extract<WireItem, { kind: 'block' }> = {
+      kind: 'block',
+      block: 'inline',
+      label: via ? `via ${via.name}` : 'via a helper',
+      ...(via ? { via } : {}),
+      ...(site.within ? { within: site.within } : {}),
+      body: [],
+    };
+    if (!open(site.into, path, state)) {
+      block.again = true;
+      return block;
+    }
+    block.body = blockFor(input, site.into, [...path, site.into], state);
+    return block.body.length === 0 ? null : block;
+  }
+  if (!site.step) return null;
+  const step = input.step(site.step);
+  const item: Extract<WireItem, { kind: 'step' }> = {
+    kind: 'step',
+    step: site.step,
+    ...(site.link ? { link: site.link } : {}),
+    ...(site.within ? { within: site.within } : {}),
+  };
+  // A step the walk entered reads on into what it does — the same steps the
+  // tree draws a row below, here under the box that reaches them.
+  if (step?.into) {
+    if (open(step.into, path, state)) {
+      const body = blockFor(input, step.into, [...path, step.into], state);
+      if (body.length > 0) item.body = body;
+    } else if (input.sites.has(step.into)) item.again = true;
+  }
+  return item;
+}
+
+/**
+ * Whether this reading may open a function's body here: not if it is already
+ * open on the way in (a helper that calls itself), not if it has been read
+ * somewhere else in this picture, and not past the depth or the size the
+ * reading allows.
+ */
+function open(fn: string, path: readonly string[], state: Reading): boolean {
+  if (path.includes(fn) || state.read.has(fn)) return false;
+  if (path.length >= MAX_INLINE || state.items >= MAX_ITEMS) {
+    state.truncated++;
+    return false;
+  }
+  state.read.add(fn);
+  return true;
+}
+
+/**
+ * Put an item in its block, opening the run it belongs to: work registered to
+ * run later, and calls started together, are not the sequence they are written
+ * in and say so rather than pretending.
+ */
+function place(block: WireBlock, item: WireItem, site: ProgramSite): void {
+  const run = runFor(site);
+  if (run === null) {
+    block.push(item);
+    return;
+  }
+  const last = block[block.length - 1];
+  if (last && last.kind === 'block' && last.block === run.block && last.label === run.label) {
+    last.body.push(item);
+    return;
+  }
+  block.push({ kind: 'block', block: run.block, label: run.label, body: [item] });
+}
+
+/** The run a site belongs to — `later · then`, `together` — or null for plain sequence. */
+function runFor(site: ProgramSite): { block: 'later' | 'together'; label: string } | null {
+  const fires = site.trigger;
+  if (fires && fires.kind === 'callback' && LATER_OF.test(fires.name)) return { block: 'later', label: `later · ${fires.name}` };
+  if (site.within && TOGETHER.test(site.within)) return { block: 'together', label: 'together' };
+  return null;
+}
+
+/** An arm of a fork by its condition, reusing the one already open for it. */
+function armFor(fork: Extract<WireItem, { kind: 'fork' }>, g: BranchGuard): WireArm {
+  const when = guardLabel([g]);
+  const found = fork.arms.find((a) => a.when === when);
+  if (found) return found;
+  const arm: WireArm = { when, ends: g.armExit ?? null, body: [] };
+  fork.arms.push(arm);
+  return arm;
+}
+
+/** `!` and the condition — the arm, not the decision: an `if` and its `else` differ here and nowhere else. */
+function armKey(g: BranchGuard): string {
+  return `${g.negated ? '!' : ''}${g.text}`;
+}
+
+function formOf(g: BranchGuard): 'if' | 'switch' | 'ternary' | 'try' {
+  switch (g.form) {
+    case 'case':
+      return 'switch';
+    case 'ternary':
+      return 'ternary';
+    case 'catch':
+      return 'try';
+    default:
+      return 'if';
+  }
+}
+
+/**
+ * How each arm ends, decided after its body is known: an arm whose last item
+ * answers the request ends with the reply — the strongest thing a reader can
+ * be told about an endpoint's arm — and otherwise the arm keeps how its code
+ * leaves, read at the site.
+ */
+function seal(input: ProgramInput, block: WireBlock): void {
+  for (const item of block) {
+    if (item.kind === 'fork') {
+      for (const arm of item.arms) {
+        seal(input, arm.body);
+        if (repliesLast(input, arm.body)) arm.ends = 'reply';
+      }
+    } else if (item.kind === 'block') seal(input, item.body);
+    else if (item.kind === 'step' && item.body) seal(input, item.body);
+  }
+}
+
+/** Whether the last thing a block does is answer the request. */
+function repliesLast(input: ProgramInput, block: WireBlock): boolean {
+  const last = block[block.length - 1];
+  if (!last) return false;
+  if (last.kind === 'step') return input.step(last.step)?.reply === true;
+  if (last.kind === 'block') return repliesLast(input, last.body);
+  return false;
+}
+
+/** Source order: a call written inside another's arguments runs first; then by position. */
+function compareSites(a: ProgramSite, b: ProgramSite): number {
+  if (inside(a.at, b.at)) return -1;
+  if (inside(b.at, a.at)) return 1;
+  return a.at.line - b.at.line || a.at.column - b.at.column;
+}
+
+function inside(x: ProgramSite['at'], y: ProgramSite['at']): boolean {
+  const afterStart = x.line > y.line || (x.line === y.line && x.column > y.column);
+  const beforeEnd = x.line < y.end.line || (x.line === y.end.line && x.column < y.end.column);
+  return afterStart && beforeEnd;
+}

+ 107 - 21
src/ui-server/api/steps.ts

@@ -41,8 +41,10 @@ import type CodeGraph from '../../index';
 import type { Edge, Language, Node, UnresolvedReference } from '../../types';
 import { badRequest, intParam, notFound } from './respond';
 import { createSiteReader } from './when';
-import type { SiteTrigger } from '../../graph/branch-guards';
+import type { BranchGuard, SiteTrigger } from '../../graph/branch-guards';
+import { buildProgram, type ProgramSite, type WireProgram } from './program';
 import { classifyEffect, implicitResponseStatus, responseStatus, type Effect } from './effects';
+import { guardLabel } from '../../graph/branch-guards';
 import { looksLikeComponent, routeRoots } from './route-roots';
 import { nextRouteForFile } from '../../resolution/frameworks/nextjs';
 import { splitRouteName } from './routes';
@@ -180,6 +182,19 @@ export interface WireStepsPayload {
   project: 'app' | 'api' | 'web';
   steps: WireStep[];
   links: WireStepLink[];
+  /**
+   * The same walk read in the code's ORDER: the anchor's body as a rail that
+   * forks where the code forks. Built from the same records the links are, so
+   * the two readings hold the same steps; null when the anchor has no body to
+   * read (nothing was recorded).
+   */
+  program: WireProgram | null;
+  /**
+   * Which reading to open with: the code's order for a handler, an endpoint or
+   * any function; the tree for a screen, where handlers fire on events and
+   * have no order between them. The URL's `view` overrides it.
+   */
+  defaultView: 'order' | 'tree';
   depth: number;
   limit: number;
   /** Screens reached from the anchor were entered rather than drawn as boundaries. */
@@ -366,7 +381,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
 
   const reader = createSiteReader(cg, projectRoot, MAX_WHEN_SITES);
   const calls = createSiteReader(cg, projectRoot, MAX_CALL_SITES);
-  const whenAt = (caller: Node, site: { line?: number; column?: number }) => reader.when(caller, site);
+  /** The conditions a site runs under, structured — one read, joined where a string is wanted. */
+  const guardsAt = (caller: Node, site: { line?: number; column?: number }) => reader.guards(caller, site);
   const argsAt = (caller: Node, site: { line?: number; column?: number }) => reader.args(caller, site);
   const withArgs = async (site: WireStepSite, caller: Node, at: { line?: number; column?: number }): Promise<WireStepSite> => {
     const args = await argsAt(caller, at);
@@ -524,6 +540,36 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
 
   const steps = new Map<string, StepRecord>();
   const links = new Map<string, WireStepLink>();
+  /**
+   * What happens in each function, in the code's own order — the rail's
+   * material, recorded by the SAME pass that makes the links so the two
+   * readings can never hold different steps. Keyed by the function's node id,
+   * then by the site's position and what it reaches: a helper folded from two
+   * different steps is walked twice and must not be written twice.
+   */
+  const programs = new Map<string, Map<string, ProgramSite>>();
+  const record = (
+    fn: Node,
+    hop: HopSite,
+    guards: readonly BranchGuard[],
+    what: { step?: string; link?: string; into?: string },
+    trigger: WireStepTrigger | null = null
+  ): void => {
+    let sites = programs.get(fn.id);
+    if (!sites) {
+      sites = new Map();
+      programs.set(fn.id, sites);
+    }
+    const key = `${hop.line}:${hop.column}:${what.step ?? what.into ?? ''}`;
+    if (sites.has(key)) return;
+    sites.set(key, {
+      ...what,
+      at: { line: hop.line, column: hop.column, end: hop.end },
+      ...(hop.within ? { within: hop.within } : {}),
+      guards: [...guards],
+      ...(trigger ? { trigger } : {}),
+    });
+  };
   const truncated = { steps: 0, hubs: 0, chrome: 0 };
   let effectScans = 0;
   const fanIn = new Map<string, number>();
@@ -689,19 +735,26 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
         : null;
     const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, step.depth + 1, status);
     if (target === null) return true;
-    const when = await whenAt(fold.node, at);
+    const guards = await guardsAt(fold.node, at);
+    const when = guardLabel(guards);
     const wireSite: WireStepSite = { file: posix(fold.node.filePath), line: ref.line, text, when: '' };
     if (args !== null) wireSite.args = args;
     if (status !== null) wireSite.status = status;
-    const hop: HopSite = fold.first ?? {
+    // Where the call is written HERE — in this function, at this line. The
+    // rail places the step by it; the tree's row order uses the hop out of the
+    // step's root, which is the same position when nothing was folded.
+    const local: HopSite = {
       file: fold.node.filePath,
       line: site?.span?.start.line ?? ref.line,
       column: site?.span?.start.column ?? ref.column ?? 0,
       end: site?.span?.end ?? { line: ref.line, column: ref.column ?? 0 },
       within: site?.within ?? null,
     };
+    const hop: HopSite = fold.first ?? local;
     if (!target.first) target.first = hop;
-    link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, trigger ?? (await triggerAt(fold.node, at)), hop.within);
+    const fired = trigger ?? (await triggerAt(fold.node, at));
+    const id = link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, fired, hop.within);
+    record(fold.node, local, guards, { step: target.id, link: id }, fired);
     return true;
   };
 
@@ -715,7 +768,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     edge: Edge | null,
     trigger: WireStepTrigger | null = null,
     within: string | null = null
-  ): void => {
+  ): string => {
     const meta = (edge?.metadata ?? {}) as Record<string, unknown>;
     const synthesized = edge?.provenance === 'heuristic';
     const confidence = typeof meta.confidence === 'number' ? meta.confidence : null;
@@ -729,7 +782,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     const structural = (s: WireStepSite) => s.text.startsWith('defines ');
     const existing = links.get(id);
     if (existing) {
-      if (structural(stamped) && existing.sites.some((s) => !structural(s))) return;
+      if (structural(stamped) && existing.sites.some((s) => !structural(s))) return id;
       if (!structural(stamped) && existing.sites.every(structural)) existing.sites.length = 0;
       // One statement, two references (`res.status(201)` and its `.json(…)`):
       // the outer call is the site, the inner one folds into it.
@@ -744,7 +797,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
         if (!when || !existing.when) existing.when = '';
         else if (!existing.when.split(' || ').includes(when)) existing.when = `${existing.when} || ${when}`;
       }
-      return;
+      return id;
     }
     links.set(id, {
       id,
@@ -761,6 +814,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
       ...(within ? { within } : {}),
     });
     if (trigger && to.kind === 'trigger' && !to.trigger) to.trigger = trigger;
+    return id;
   };
 
   /** What fires a site, with the function it is written in. */
@@ -1072,7 +1126,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
             if (t) to.trigger = t;
           }
           const at = { line: a.e.line, column: a.e.column };
-          const when = await whenAt(fold.node, at);
+          const guards = await guardsAt(fold.node, at);
+          const when = guardLabel(guards);
           // A call-shaped hop says what it passes; a navigation already says
           // its href, a handler binding and a native event channel pass
           // nothing. A hop over a synthesized channel is a call in the source
@@ -1086,9 +1141,11 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           // Where this step is first reached from: the hop out of the root
           // this fold descends from, else this site — its position orders the row.
           const isCallHop = a.e.kind === 'calls' || a.e.kind === 'instantiates' || a.e.kind === 'navigates';
-          const hop = fold.first ?? (isCallHop ? await hopAt(fold.node, at, a.target.name) : pointHop(fold.node, at));
+          const local = isCallHop ? await hopAt(fold.node, at, a.target.name) : pointHop(fold.node, at);
+          const hop = fold.first ?? local;
           if (!to.first) to.first = hop;
-          link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger, hop.within);
+          const id = link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger, hop.within);
+          record(fold.node, local, guards, { step: to.id, link: id }, a.trigger);
           if (to.root !== null && !explored.has(to.id)) {
             explored.add(to.id);
             queue.push(to);
@@ -1122,9 +1179,11 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           if (known) {
             if (known.id !== step.id) {
               const at = { line: e.line, column: e.column };
-              const when = await whenAt(fold.node, at);
-              const hop = fold.first ?? (await hopAt(fold.node, at, target.name));
-              link(step, known, 'calls', fold.chain, [...fold.whens, when], await withArgs(a.site, fold.node, at), e, a.trigger, hop.within);
+              const guards = await guardsAt(fold.node, at);
+              const local = await hopAt(fold.node, at, target.name);
+              const hop = fold.first ?? local;
+              const id = link(step, known, 'calls', fold.chain, [...fold.whens, guardLabel(guards)], await withArgs(a.site, fold.node, at), e, a.trigger, hop.within);
+              record(fold.node, local, guards, { step: known.id, link: id }, a.trigger);
             }
             continue;
           }
@@ -1144,13 +1203,15 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
             continue;
           }
           visited.add(target.id);
-          const when = await whenAt(fold.node, { line: e.line, column: e.column });
-          const first =
-            fold.first ??
-            (e.kind === 'calls' || e.kind === 'instantiates'
-              ? await hopAt(fold.node, { line: e.line, column: e.column }, target.name)
-              : pointHop(fold.node, { line: e.line, column: e.column }));
-          next.push({ node: target, chain: [...fold.chain, target], whens: [...fold.whens, when], first });
+          const at = { line: e.line, column: e.column };
+          const guards = await guardsAt(fold.node, at);
+          const local =
+            e.kind === 'calls' || e.kind === 'instantiates' ? await hopAt(fold.node, at, target.name) : pointHop(fold.node, at);
+          const first = fold.first ?? local;
+          // The helper is drawn where it is CALLED: its own records are its
+          // body, and this is the site the rail nests them under.
+          record(fold.node, local, guards, { into: target.id }, a.trigger);
+          next.push({ node: target, chain: [...fold.chain, target], whens: [...fold.whens, guardLabel(guards)], first });
         }
       }
       frontier = next;
@@ -1201,12 +1262,37 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
   }
 
   const ordered = [...steps.values()].sort((a, b) => a.depth - b.depth || (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
+
+  // The second reading: the same steps in the code's order. A step the walk
+  // ENTERED reads on into its own body; a boundary (another screen, an
+  // endpoint across a tier) does not — it is a chapter of its own, exactly as
+  // on the picture.
+  const nodesById = new Map<string, Node>();
+  for (const s of steps.values()) if (s.root) nodesById.set(s.root.id, s.root);
+  const program = buildProgram({
+    sites: new Map([...programs].map(([fn, sites]) => [fn, [...sites.values()]])),
+    root: first.root?.id ?? null,
+    node: (id) => {
+      const found = nodesById.get(id) ?? cg.getNode(id);
+      return found ? toNodeRef(found) : null;
+    },
+    step: (id) => {
+      const s = steps.get(id);
+      if (!s) return null;
+      return { reply: s.effect?.category === 'response', into: s.cut === null && s.root ? s.root.id : null };
+    },
+  });
+
   return {
     anchor: toNodeRef(anchor),
     ambiguous,
     project,
     steps: ordered.map(({ root: _root, first: _first, ...step }) => step),
     links: [...links.values()].sort((a, b) => a.id.localeCompare(b.id)),
+    program,
+    // A screen is a set of handlers with no order between them; anything with a
+    // body — a handler, an endpoint, any function — reads in the code's order.
+    defaultView: program !== null && !(first.kind === 'screen' && !first.screen?.endpoint) ? 'order' : 'tree',
     depth: depthCap,
     limit,
     through,

+ 47 - 0
ui/src/lib/wire.ts

@@ -806,6 +806,46 @@ export interface WireStepLink {
   trigger?: WireStepTrigger;
 }
 
+/* ------------------------------------------- the same walk, in the code's order -- */
+
+/** How an arm of a fork leaves, when it does — the rail stops there. */
+export type WireArmEnd = 'reply' | 'return' | 'throw' | 'exit';
+
+export interface WireArm {
+  /** This arm's own condition, in the words the rest of the view uses. */
+  when: string;
+  /** How it leaves: it answers the request, returns, or throws. Null = it runs on. */
+  ends: WireArmEnd | null;
+  body: WireBlock;
+}
+
+export type WireBlock = WireItem[];
+
+export type WireItem =
+  /**
+   * A step of the picture, where the code writes it. `body` is what it does,
+   * when the walk entered it; `again` says it happens here too and was read
+   * above — a function is read ONCE in a rail, however many times it is called.
+   */
+  | { kind: 'step'; step: string; link?: string; within?: string; body?: WireBlock; again?: true }
+  /** A decision: `if` / `else`, a `switch`, a ternary, a `try`, or an early exit. */
+  | { kind: 'fork'; on: string; form: 'if' | 'switch' | 'ternary' | 'try'; arms: WireArm[] }
+  /**
+   * A run of items that is not plain sequence: a helper drawn where it is
+   * called (`inline`), a body that runs for each item (`loop`), work that runs
+   * after this function returns (`later`), or calls started together
+   * (`together`).
+   */
+  | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; label: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true }
+  /** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
+  | { kind: 'cut'; why: 'folded' | 'depth' };
+
+export interface WireProgram {
+  root: WireBlock;
+  /** Items the reading could not place — a recursion or a cap it hit. */
+  truncated: number;
+}
+
 export interface WireStepsPayload {
   anchor: WireNodeRef;
   /** Other symbols that share the anchor's name, when it was given by name. */
@@ -814,6 +854,13 @@ export interface WireStepsPayload {
   project: 'app' | 'api' | 'web';
   steps: WireStep[];
   links: WireStepLink[];
+  /**
+   * The same walk read in the code's ORDER — the anchor's body as a rail that
+   * forks where the code forks. Null when the anchor has no body to read.
+   */
+  program: WireProgram | null;
+  /** Which reading to open with; the URL's `view` overrides it. */
+  defaultView: 'order' | 'tree';
   depth: number;
   limit: number;
   /** Screens reached from the anchor were entered rather than drawn as boundaries. */