diff --git a/__tests__/ui-steps-program.test.ts b/__tests__/ui-steps-program.test.ts new file mode 100644 index 0000000..30fd089 --- /dev/null +++ b/__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 { + 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 { + 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 { + const line = nextLine++; + return { into: fn, at: { line, column: 0, end: { line, column: 40 } }, guards, ...extra }; +} + +function program(sites: Record, replies: string[] = [], into: Record = {}) { + 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; + 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; + 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']); + }); +}); diff --git a/src/ui-server/api/program.ts b/src/ui-server/api/program.ts new file mode 100644 index 0000000..a3beeae --- /dev/null +++ b/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; + /** 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; +} + +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; 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 = { 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 = { + 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 = { + 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, 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; +} diff --git a/src/ui-server/api/steps.ts b/src/ui-server/api/steps.ts index 29cced9..f9868ae 100644 --- a/src/ui-server/api/steps.ts +++ b/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 => { const args = await argsAt(caller, at); @@ -524,6 +540,36 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS const steps = new Map(); const links = new Map(); + /** + * 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>(); + 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(); @@ -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; 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(); + 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, diff --git a/ui/src/lib/wire.ts b/ui/src/lib/wire.ts index 0ebb31c..3cf3a3d 100644 --- a/ui/src/lib/wire.ts +++ b/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. */