feat(ui): Steps for servers — route roots, server effects, request/decorator triggers, guards for Python/Java/Kotlin/C#/Go/C

- api/route-roots.ts: the symbol a route runs (references-edge handler, exported page component, or the route itself for an inline handler), shared by steps and screens; the bare Steps tab lists an API's endpoints by router file
- api/effects.ts: database / response / queue / email / payments / cache / auth / process / network / storage / device / telemetry, matched on the call as written per language family, with model + read/write and the literal status on a response site
- graph/branch-guards.ts: callSitesForFile (the whole member chain), memberTypesInTree, decoratorsForFile, request/decorator triggers with the middleware/guard chain; guard + argument rules for Python, Java, Kotlin, C#, Go and C
- steps.ts: classify on the chain before trusting a name match, retarget this.x.y() by declared type, skip test doubles after the effect pre-check, project kind on the wire
- viewer: kindWord/kindWords per project kind, endpoint chooser, response boxes labelled by status codes
- python.ts: FastAPI detected from a monorepo sub-directory; is-test-file: samples/examples package paths are not tests
- tests: ui-steps-api-servers, ui-effects, branch-guards-languages; spec §3.13 Servers paragraph, CHANGELOG, plan doc

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
Colby McHenry
2026-08-28 13:45:38 -05:00
co-authored by Claude Fable 5
parent 5e06204deb
commit 950686def4
22 changed files with 3869 additions and 190 deletions
+165 -44
View File
@@ -21,8 +21,10 @@
import KindGlyph from '../components/KindGlyph.svelte';
import {
canDrawSteps,
fetchRoutes,
fetchScreens,
fetchSteps,
type WireRoute,
type WireScreen,
type WireStepLink,
type WireStepsPayload,
@@ -35,6 +37,7 @@
import {
buildStepsModel,
kindWord,
kindWords,
stepNeighbourhood,
stepPairId,
stepViaText,
@@ -62,8 +65,30 @@
let viewport = $state<Viewport | undefined>(undefined);
const HOVER_REACH = 10;
/** The chooser's list, when the view opens without an anchor. */
/** The chooser's lists, when the view opens without an anchor: the screens of an app, else the endpoints of an API. */
let screens = $state<WireScreen[] | null>(null);
let routes = $state<WireRoute[] | null>(null);
/** What the chooser offers: null while reading. */
const chooser = $derived.by<'screens' | 'routes' | 'none' | null>(() => {
if (screens === null) return null;
if (screens.length > 0) return 'screens';
if (routes === null) return null;
return routes.length > 0 ? 'routes' : 'none';
});
/** Endpoints by the file they are registered in — the router file is how a reader groups them — biggest first, in registration order within. */
function routeGroups(list: WireRoute[]): Array<{ file: string; entries: WireRoute[] }> {
const byFile = new Map<string, WireRoute[]>();
for (const r of list) {
const group = byFile.get(r.routeFile) ?? [];
group.push(r);
byFile.set(r.routeFile, group);
}
return [...byFile]
.map(([file, entries]) => ({ file, entries: [...entries].sort((a, b) => a.routeLine - b.routeLine) }))
.sort((a, b) => b.entries.length - a.entries.length || a.file.localeCompare(b.file));
}
const LEGEND_KEY = 'codegraph-ui:steps-legend';
let legendOpen = $state(readLegendOpen());
@@ -82,7 +107,16 @@
}
});
const FIT = { fitViewOptions: { padding: 0.1, maxZoom: 1, minZoom: 0.4 } };
/**
* The fit. A picture of a few boxes is centred — and the key, bottom left,
* would sit on its second row; it is fitted to the right of the key instead.
* A picture of many boxes is fitted to the whole stage, as the Screens view's.
*/
const fitOptions = $derived(
model !== null && model.layout.nodes.length <= 24 && legendOpen
? { padding: { left: '440px', top: '32px', right: '32px', bottom: '32px' }, maxZoom: 1, minZoom: 0.4 }
: { padding: 0.1, maxZoom: 1, minZoom: 0.4 }
);
const nodeTypes = { step: StepNode };
const edgeTypes = { screen: ScreenEdge };
const DEPTHS = [4, 6, 8, 10, 12];
@@ -107,11 +141,19 @@
loading = false;
error = null;
fetchScreens(controller.signal)
.then((next) => {
.then(async (next) => {
screens = next.routed ? next.screens : [];
// No screens: an API's endpoints are its places to start from.
if (next.routed) {
routes = [];
return;
}
const found = await fetchRoutes({ limit: 300 }, controller.signal);
routes = found.routed ? found.entries : [];
})
.catch(() => {
screens = [];
screens = screens ?? [];
routes = routes ?? [];
});
return () => controller.abort();
}
@@ -162,6 +204,7 @@
data: {
layout: node,
info: model.nodes.get(node.id)!,
project: payload?.project ?? 'app',
selected: selected === node.id,
dimmed: neighbours !== null && !neighbours.has(node.id),
onSelect: (id: string) => {
@@ -333,20 +376,29 @@
{:else if !asked}
<div class="state chooser">
<h2>What happens from where?</h2>
<p>
Pick a screen and this view draws everything it sets in motion — its handlers, the calls that
cross into native code, the events that come back, the state it writes, the requests that leave
the app — one box per step, an arrow for every way one leads to the next, and on each arrow the
condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
</p>
{#if screens === null}
<p class="dim">Reading screens…</p>
{:else if screens.length === 0}
<p class="dim">
No screens in this graph. Open a symbol from the search box and follow <i>What happens from here</i>,
or link here directly with <span class="mono">#/steps?symbol=&lt;name&gt;</span>.
{#if chooser === 'routes'}
<p>
Pick an endpoint and this view draws everything it sets in motion — its handler and what runs
before it, the calls into the database, a queue, another service, and every response it can
send — one box per step, an arrow for every way one leads to the next, and on each arrow the
condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
</p>
{:else}
<p>
Pick a screen and this view draws everything it sets in motion — its handlers, the calls that
cross into native code, the events that come back, the state it writes, the requests that leave
the app — one box per step, an arrow for every way one leads to the next, and on each arrow the
condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
</p>
{/if}
{#if chooser === null}
<p class="dim">Reading {screens === null ? 'screens' : 'endpoints'}</p>
{:else if chooser === 'none'}
<p class="dim">
No screens or endpoints in this graph. Open a symbol from the search box and follow <i>What happens from here</i>,
or link here directly with <span class="mono">#/steps?symbol=&lt;name&gt;</span>.
</p>
{:else if chooser === 'screens' && screens !== null}
<div class="chooser-list">
{#each [...screens].sort((a, b) => b.outgoing + b.incoming - (a.outgoing + a.incoming) || a.path.localeCompare(b.path)) as screen (screen.id)}
<a class="pick mono" href={stepsHref({ anchor: screen.id })}
@@ -354,6 +406,17 @@
>
{/each}
</div>
{:else if routes !== null}
{#each routeGroups(routes) as group (group.file)}
<div class="group-h"><span class="mono">{group.file}</span><span class="dim">{group.entries.length}</span></div>
<div class="chooser-list">
{#each group.entries as route (route.routeId)}
<a class="pick mono" href={stepsHref({ anchor: route.routeId })}
>{route.url} <span class="dim sans">{route.handler}</span></a
>
{/each}
</div>
{/each}
{/if}
</div>
{:else if error !== null}
@@ -370,7 +433,7 @@
{nodeTypes}
{edgeTypes}
fitView
{...FIT}
fitViewOptions={fitOptions}
bind:viewport
minZoom={0.2}
maxZoom={3}
@@ -398,22 +461,58 @@
<span class="k-box k-anchor mono"><span class="mark"></span>start</span>
<span>Where the picture starts; each row down is one more step away</span>
</div>
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A store action — a function in a store file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">api</span>
<span>A call that leaves the index: the network, storage, the device, telemetry</span>
</div>
{#if payload.project === 'api'}
<div class="lrow">
<span class="k-box mono">POST /x</span>
<span>An endpoint — its verb and path — or a handler: a function a request, a job, an event or a schedule fires; its line says which</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses a tier: a call into another service or a job put on a queue (⇢), or a job, an event, a message arriving (⇠)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A data call — a function in a store or state file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">db</span>
<span>A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network</span>
</div>
{:else if payload.project === 'web'}
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A store action — a function in a store file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">api</span>
<span>A call that leaves the index: the network, the database, the response, storage, a queue, email</span>
</div>
{:else}
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A store action — a function in a store file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">api</span>
<span>A call that leaves the index: the network, storage, the device, telemetry</span>
</div>
{/if}
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
<span>Leads to — the plumbing between the two is folded into the line</span>
@@ -448,7 +547,7 @@
{#if link.sites.length > 1}<span class="dim">{link.sites.length} ways</span>{/if}
<span class="when">{@render words(conditionTokens(link.when))}</span>
{#if link.label}<span class="dim">{link.label}</span>{/if}
{#if link.sites[0]}<span class="mono">{siteWords(link.sites[0])}</span>{/if}
{#if link.sites[0]}<span class="mono">{#if link.sites[0].status}<b class="status">{link.sites[0].status}</b> · {/if}{siteWords(link.sites[0])}</span>{/if}
</div>
{/each}
{#if hoveredInfo.links.length > 5}<div class="dim">+{hoveredInfo.links.length - 5} more</div>{/if}
@@ -463,7 +562,7 @@
<div class="head">
<div>
<div class="mono big">{selectedInfo.label}</div>
<div class="sub dim">{kindWord(selectedInfo.step.kind)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
<div class="sub dim">{kindWord(selectedInfo.step.kind, payload.project, selectedInfo.step)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
{#if selectedInfo.step.trigger}
<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(selectedInfo.step.trigger)} <span class="dim">in {selectedInfo.step.trigger.in}</span></div>
{/if}
@@ -494,7 +593,7 @@
<button class="clear" onclick={() => (selected = null)}>clear</button>
</div>
{#if selectedInfo.step.cut === 'screen'}
<p class="dim note">Another screen — a chapter of its own. Start here to see what happens on it, or continue through screens from the summary.</p>
<p class="dim note">Another {kindWord('screen', payload.project, selectedInfo.step)} — a chapter of its own. Start here to see what happens on it, or continue through {kindWords('screen', payload.project)[1]} from the summary.</p>
{:else if selectedInfo.step.cut === 'component'}
<p class="dim note">The event lands in a component of another screen — a picture of its own. Start here to see it, or continue through screens from the summary.</p>
{:else if selectedInfo.step.cut !== null}
@@ -511,6 +610,9 @@
{#if selectedInfo.step.effect && selectedInfo.step.effect.apis.length > 1}
<p class="dim note mono">{selectedInfo.step.effect.apis.join(' · ')}</p>
{/if}
{#if selectedInfo.step.effect?.category === 'response'}
<p class="dim note">The endpoints contract as the code has it: each row below is one way it answers, with the condition it answers under.</p>
{/if}
{#if selectedInfo.step.events && selectedInfo.step.events.length > 1}
<p class="dim note mono">{selectedInfo.step.events.join(' · ')}</p>
{/if}
@@ -551,9 +653,9 @@
{/if}
{#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
{#if href}
<a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
<a class="site" {href}>{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
{:else}
<span class="site">{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
<span class="site">{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
{/if}
</div>
{/each}
@@ -593,9 +695,9 @@
{/if}
{#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
{#if href}
<a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
<a class="site" {href}>{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
{:else}
<span class="site">{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
<span class="site">{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
{/if}
</div>
{/each}
@@ -638,14 +740,15 @@
<p>
<label class="opt">
<input type="checkbox" checked={payload.through} onchange={(e) => navigate(rewrite({ through: (e.currentTarget as HTMLInputElement).checked }))} />
Continue through screens
Continue through {kindWords('screen', payload.project)[1]}
</label>
<span class="dim">— otherwise another screen is drawn as a boundary, and is a click from being the next anchor.</span>
<span class="dim">— otherwise another {kindWord('screen', payload.project)} is drawn as a boundary, and is a click from being the next anchor.</span>
</p>
<p class="counts">
{#each ['screen', 'trigger', 'bridge', 'event', 'store', 'effect'] as const as kind (kind)}
{#if model.counts[kind] > 0}
<span><b>{model.counts[kind]}</b> {kindWord(kind)}{model.counts[kind] === 1 ? '' : 's'}</span>
{@const words = kindWords(kind, payload.project)}
<span><b>{model.counts[kind]}</b> {model.counts[kind] === 1 ? words[0] : words[1]}</span>
{/if}
{/each}
</p>
@@ -665,7 +768,7 @@
{/if}
<h4>Most connected</h4>
{#each [...payload.steps].sort((a, b) => (model.layout.nodes.find((n) => n.id === b.id)?.ports.top.length ?? 0) + (model.layout.nodes.find((n) => n.id === b.id)?.ports.bottom.length ?? 0) - ((model.layout.nodes.find((n) => n.id === a.id)?.ports.top.length ?? 0) + (model.layout.nodes.find((n) => n.id === a.id)?.ports.bottom.length ?? 0))).slice(0, 8) as step (step.id)}
<button class="peer mono" onclick={() => (selected = step.id)}>{model.nodes.get(step.id)?.label ?? step.label} <span class="dim sans">{kindWord(step.kind)}</span></button>
<button class="peer mono" onclick={() => (selected = step.id)}>{model.nodes.get(step.id)?.label ?? step.label} <span class="dim sans">{kindWord(step.kind, payload.project, step)}</span></button>
{/each}
{/if}
</aside>
@@ -730,6 +833,19 @@
margin-top: 12px;
border-top: 1px solid var(--rule-soft);
}
/* A router file heading over its endpoints; the list under it keeps its own top rule. */
.group-h {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-top: 18px;
font-size: 11.5px;
color: var(--ink-2);
}
.group-h + .chooser-list {
margin-top: 6px;
}
.pick {
display: block;
padding: 7px 8px;
@@ -984,6 +1100,11 @@
text-decoration: none;
overflow-wrap: anywhere;
}
/* A response's status code leads its row: the number is the fact. */
.status {
color: var(--ink);
font-weight: 600;
}
a.site:hover {
text-decoration: underline;
}