mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 06:53:52 +08:00
* fix(viewer): a repeated id no longer blanks Entry points or a component's Symbol view The viewer keys its lists by id and a repeat stops the page drawing. The routes list was a row per (route, edge), so an inline handler came back once per call in its body; it is now one row per route — its bound handler, or the route itself as an inline handler. A Vue/Svelte/Astro component's members listed its script's symbols twice, plus the component and its file; each member is now listed once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(viewer): Steps reads a function full of checks in one pass The in-order reading kept every way forward through an `if` with no `else` twice — under its condition and without — so eighty checks that draw nothing (jsoup's `parse`) made 2^80 ways and the page died on a stack overflow. Ways waiting at the same place are merged, keeping the conditions they all share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
488 lines
20 KiB
TypeScript
488 lines
20 KiB
TypeScript
/**
|
|
* The Steps picture in the code's ORDER — the same canvas, laid out by when
|
|
* things happen rather than by how far they are from the anchor.
|
|
*
|
|
* The tree's rows are distance: on proshop's login `User.findOne`, `jwt.sign`,
|
|
* `200` and `401` are each one step out of the handler, so they land in one
|
|
* row. True, and not the flow — the token is signed while the reply is being
|
|
* built, so the `200` comes AFTER it, and the `401` is the other side of the
|
|
* same `if`. This model draws that:
|
|
*
|
|
* ```
|
|
* POST /api/users/login
|
|
* |
|
|
* User.findOne
|
|
* +-------------+--------------+
|
|
* WHEN user AND (await ...) WHEN NOT (...)
|
|
* | |
|
|
* jwt.sign 401
|
|
* |
|
|
* 200
|
|
* ```
|
|
*
|
|
* **A line here means "then", not "calls"** — that is the whole difference from
|
|
* the other reading, and the key says so. It comes from the block tree the
|
|
* server folds out of the walk (`api/program.ts`): items in source order, forks
|
|
* where the code forks, an arm that answers or leaves ending there. Walking that
|
|
* tree with a set of *tails* — the steps a next step would follow — gives one
|
|
* edge per "and then", carrying the arm's condition where the code branched.
|
|
*
|
|
* Everything else is the canvas's: the same boxes, the same layout engine, the
|
|
* same pills, hover and panel. Only the graph changes.
|
|
*/
|
|
|
|
import { conditionTokens, joinTokens, whenWords, type WordToken } from './conditions';
|
|
import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
|
|
import { samplePolyline, trackedCurves, EDGE_LABEL_MAX, SCREEN_LAYER_GAP, type Point } from './screens-model';
|
|
import { armWords, stepLabel, stepSub, type StepEdgeInfo, type StepForkInfo, type StepNodeInfo, type StepsModel } from './steps-model';
|
|
import type { WireArm, WireBlock, WireItem, WireMapLink, WireMapModule, WireStep, WireStepsPayload } from './wire';
|
|
|
|
/* ----------------------------------------------------------------- words -- */
|
|
|
|
/** The construct a fork came from. */
|
|
export type ForkForm = Extract<WireItem, { kind: 'fork' }>['form'];
|
|
|
|
/**
|
|
* What has to hold for an arm to be the one taken, in the words the rest of the
|
|
* view uses. Said in FULL on the line, because a line on a canvas has no head
|
|
* above it to refer back to: `WHEN user AND (await …)`, `WHEN NOT (…)`.
|
|
*/
|
|
export function whenTokens(when: string): WordToken[] {
|
|
return conditionTokens(when);
|
|
}
|
|
|
|
/** The words on a run that is not plain sequence — said on the line into it. */
|
|
export function runWords(item: Extract<WireItem, { kind: 'block' }>): string {
|
|
switch (item.block) {
|
|
case 'inline':
|
|
return item.via ? `via ${item.via.name}` : 'via a helper';
|
|
case 'loop':
|
|
if (!item.by) return item.loop === 'while' ? 'again and again' : 'for each item';
|
|
return item.loop === 'while' ? `again while ${item.by}` : `for each ${item.by}`;
|
|
case 'later':
|
|
return item.by ? `later · ${item.by}` : 'later';
|
|
default:
|
|
return item.by ? `together · ${item.by}` : 'together';
|
|
}
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- graph -- */
|
|
|
|
/** One "and then": the step it follows, the step that happens, and what had to hold. */
|
|
export interface OrderEdge {
|
|
from: string;
|
|
to: string;
|
|
/** The conditions on the way — the arms of the forks crossed, joined by ` && `. */
|
|
when: string;
|
|
/** `via generateToken`, `for each item of items`, `later · then` — the run it happens inside. */
|
|
runs: string[];
|
|
/** A line out of a decision's point: which arm this is — `yes`, `no`, a case's value. */
|
|
arm?: string;
|
|
}
|
|
|
|
/** Where a next step would follow from, and under what. */
|
|
interface Tail {
|
|
id: string;
|
|
when: string[];
|
|
runs: string[];
|
|
arm?: string;
|
|
}
|
|
|
|
/**
|
|
* A decision drawn as a point of its own: a fork two or more of whose arms
|
|
* lead somewhere. Its condition is said once, on the point; each line out
|
|
* says only which arm it is.
|
|
*/
|
|
export interface OrderFork {
|
|
id: string;
|
|
on: string;
|
|
form: ForkForm;
|
|
}
|
|
|
|
export interface OrderGraph {
|
|
edges: OrderEdge[];
|
|
/** How many things happen before each step: its row. */
|
|
depth: Map<string, number>;
|
|
/** The decisions drawn as points, in the order the reading met them. */
|
|
forks: OrderFork[];
|
|
}
|
|
|
|
/**
|
|
* The block tree as a graph of what happens next. `anchor` is where the reading
|
|
* starts, so the first thing in the body follows it.
|
|
*/
|
|
export function orderGraph(program: NonNullable<WireStepsPayload['program']>, anchor: string): OrderGraph {
|
|
const edges: OrderEdge[] = [];
|
|
const seen = new Set<string>([anchor]);
|
|
const at = new Map<string, OrderEdge>();
|
|
const forks: OrderFork[] = [];
|
|
|
|
const join = (from: string, to: string, tail: Tail): void => {
|
|
if (from === to) return;
|
|
const key = `${from} ${to}`;
|
|
const when = tail.when.filter((w, i) => w && tail.when.indexOf(w) === i).join(' && ');
|
|
const found = at.get(key);
|
|
if (found) {
|
|
// Two ways to the same step: the picture keeps both conditions, the way
|
|
// a link with several sites does.
|
|
if (when !== found.when) found.when = !when || !found.when ? '' : `${found.when} || ${when}`;
|
|
for (const r of tail.runs) if (!found.runs.includes(r)) found.runs.push(r);
|
|
// Reached as two different arms of one decision: it happens either way,
|
|
// and the line stops claiming a side.
|
|
if (found.arm !== tail.arm) delete found.arm;
|
|
return;
|
|
}
|
|
const edge: OrderEdge = { from, to, when, runs: [...tail.runs], ...(tail.arm !== undefined ? { arm: tail.arm } : {}) };
|
|
at.set(key, edge);
|
|
edges.push(edge);
|
|
};
|
|
|
|
const flow = (block: WireBlock, incoming: readonly Tail[], runs: readonly string[]): Tail[] => {
|
|
let tails: Tail[] = [...incoming];
|
|
for (const item of block) {
|
|
if (item.kind === 'step') {
|
|
seen.add(item.step);
|
|
for (const t of tails) join(t.id, item.step, { ...t, runs: [...t.runs, ...runs] });
|
|
// What the step itself sets in motion happens inside it, so the next
|
|
// thing in the block follows THAT, not the box.
|
|
let inner: Tail[] = [{ id: item.step, when: [], runs: [] }];
|
|
if (item.body && item.body.length > 0) inner = flow(item.body, inner, []);
|
|
tails = inner;
|
|
} else if (item.kind === 'fork') {
|
|
// A decision two or more of whose arms lead somewhere diverges from a
|
|
// POINT: the condition said once, each line out an arm — instead of
|
|
// two lines that each carry the whole predicate, one of them negated,
|
|
// with nothing saying they are the same choice. A fork with one drawn
|
|
// arm keeps the plain line: an early exit reads as a guard clause.
|
|
const drawn = item.arms.filter((a) => hasStep(a.body)).length;
|
|
const fork: OrderFork | null =
|
|
drawn >= 2 && tails.length > 0 ? { id: `fork:${forks.length}`, on: item.on, form: item.form } : null;
|
|
if (fork !== null) {
|
|
forks.push(fork);
|
|
for (const t of tails) join(t.id, fork.id, { ...t, runs: [...t.runs, ...runs] });
|
|
}
|
|
const out: Tail[] = [];
|
|
for (const arm of item.arms) {
|
|
// The arm's condition as the SOURCE has it: the words are made once,
|
|
// at the end, or two ways of arriving would each carry their own WHEN.
|
|
const entry: Tail[] =
|
|
fork !== null && hasStep(arm.body)
|
|
? // From the point: the line says the arm; the arm's own
|
|
// condition rides along for the hover.
|
|
[{ id: fork.id, when: [arm.when], runs: [...runs], arm: armWord(item, arm) }]
|
|
: tails.map((t) => ({ id: t.id, when: [...t.when, arm.when], runs: [...t.runs, ...runs] }));
|
|
// An arm that answers, returns or throws does not rejoin — nothing
|
|
// leaves the last box in it, which is what says so on a canvas.
|
|
const armTails = flow(arm.body, entry, []);
|
|
if (arm.ends === null) for (const t of armTails) out.push(t);
|
|
}
|
|
// An `if` with no `else` runs on either way; a fork with both sides
|
|
// covered runs on only through the arms that did not end.
|
|
if (item.arms.length < 2) for (const t of tails) out.push({ ...t, runs: [...t.runs, ...runs] });
|
|
tails = mergeTails(out);
|
|
} else if (item.kind === 'block') {
|
|
const label = runWords(item);
|
|
const inner = flow(item.body, tails, [...runs, label]);
|
|
// A helper that answers on one path still returns on another: the code
|
|
// after the call follows the call, not nothing. And what comes after
|
|
// the call is not inside it — a tail that fell through the block drops
|
|
// the block's own words on the way out.
|
|
tails = (inner.length > 0 ? inner : tails).map((t) => (t.runs.includes(label) ? { ...t, runs: t.runs.filter((r) => r !== label) } : t));
|
|
}
|
|
}
|
|
return tails;
|
|
};
|
|
|
|
flow(program.root, [{ id: anchor, when: [], runs: [] }], []);
|
|
|
|
// Nothing the reading holds may float: a step the walk drew but the fold
|
|
// could not place follows the anchor, unconditionally.
|
|
for (const id of seen) {
|
|
if (id !== anchor && !edges.some((e) => e.to === id)) join(anchor, id, { id: anchor, when: [], runs: [] });
|
|
}
|
|
|
|
const ids = new Set(seen);
|
|
for (const f of forks) ids.add(f.id);
|
|
return { edges, depth: rows(anchor, ids, edges), forks };
|
|
}
|
|
|
|
/**
|
|
* Ways on that wait at the same place are one place to go on from. Without
|
|
* this, an `if` with no `else` — or a fork whose arms draw nothing — doubles
|
|
* every way it crosses (once under its condition, once without), and a body
|
|
* with eighty such checks (jsoup's `parse`) never finished: the page died
|
|
* overflowing the stack. The merged way keeps what every way to it had to
|
|
* hold; a condition only some of them needed is dropped, so the line
|
|
* under-claims rather than claims a side it does not have.
|
|
*/
|
|
function mergeTails(tails: readonly Tail[]): Tail[] {
|
|
const byPlace = new Map<string, Tail>();
|
|
for (const t of tails) {
|
|
const key = `${t.id}\0${t.arm ?? ''}\0${t.runs.join('\0')}`;
|
|
const found = byPlace.get(key);
|
|
if (found) found.when = found.when.filter((w) => t.when.includes(w));
|
|
else byPlace.set(key, { ...t, when: [...t.when], runs: [...t.runs] });
|
|
}
|
|
return [...byPlace.values()];
|
|
}
|
|
|
|
/** Whether anything in this block draws a box — a fork with one drawn arm is a guard clause, not a point. */
|
|
function hasStep(block: WireBlock): boolean {
|
|
return block.some((item) =>
|
|
item.kind === 'step'
|
|
? true
|
|
: item.kind === 'fork'
|
|
? item.arms.some((a) => hasStep(a.body))
|
|
: item.kind === 'block'
|
|
? hasStep(item.body)
|
|
: false
|
|
);
|
|
}
|
|
|
|
/** The word on a line out of a decision's point — the same one the tree's arms say. */
|
|
function armWord(fork: Extract<WireItem, { kind: 'fork' }>, arm: WireArm): string {
|
|
return armWords({ on: fork.on, arm: arm.when, form: fork.form, not: arm.not });
|
|
}
|
|
|
|
/**
|
|
* The row each step sits on: the longest run of "and then" from the anchor to
|
|
* it, so a step never draws above something that has to happen first.
|
|
*
|
|
* A step reached twice (`session.add` before and after a check, a logout
|
|
* helper the code comes back to) makes the graph cyclic, and relaxing over a
|
|
* cycle never settles — it adds a row on every pass until the pass bound, so
|
|
* sixteen boxes spread over sixty rows and the picture is a mostly-empty
|
|
* ribbon no fit can open on. So the lines that close a cycle are dropped
|
|
* first: a line back to something already on the way here cannot be what
|
|
* decides its row. The longest path over what remains settles by relaxation,
|
|
* and no picture is ever taller than it has boxes.
|
|
*/
|
|
function rows(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderEdge[]): Map<string, number> {
|
|
const forward = withoutBackEdges(anchor, nodes, edges);
|
|
const depth = new Map<string, number>();
|
|
for (const id of nodes) depth.set(id, 0);
|
|
depth.set(anchor, 0);
|
|
for (let pass = 0; pass < nodes.size; pass++) {
|
|
let moved = false;
|
|
for (const e of forward) {
|
|
const next = (depth.get(e.from) ?? 0) + 1;
|
|
if (next > (depth.get(e.to) ?? 0)) {
|
|
depth.set(e.to, next);
|
|
moved = true;
|
|
}
|
|
}
|
|
if (!moved) break;
|
|
}
|
|
return depth;
|
|
}
|
|
|
|
/**
|
|
* The edges minus the ones that close a cycle — those whose end is still open
|
|
* on the way in, found by one walk from the anchor (then from anything it
|
|
* does not reach), so the reading's own order decides which way round a cycle
|
|
* is the forward one.
|
|
*/
|
|
function withoutBackEdges(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderEdge[]): OrderEdge[] {
|
|
const out = new Map<string, OrderEdge[]>();
|
|
for (const e of edges) {
|
|
const list = out.get(e.from);
|
|
if (list) list.push(e);
|
|
else out.set(e.from, [e]);
|
|
}
|
|
/** 1 = open on the way in, 2 = done with. */
|
|
const state = new Map<string, 1 | 2>();
|
|
const back = new Set<OrderEdge>();
|
|
for (const root of [anchor, ...nodes]) {
|
|
if (state.has(root)) continue;
|
|
state.set(root, 1);
|
|
const stack: Array<{ id: string; next: number }> = [{ id: root, next: 0 }];
|
|
while (stack.length > 0) {
|
|
const top = stack[stack.length - 1]!;
|
|
const list = out.get(top.id) ?? [];
|
|
if (top.next >= list.length) {
|
|
state.set(top.id, 2);
|
|
stack.pop();
|
|
continue;
|
|
}
|
|
const edge = list[top.next++]!;
|
|
const seen = state.get(edge.to);
|
|
if (seen === 1) back.add(edge);
|
|
else if (seen === undefined) {
|
|
state.set(edge.to, 1);
|
|
stack.push({ id: edge.to, next: 0 });
|
|
}
|
|
}
|
|
}
|
|
return edges.filter((e) => !back.has(e));
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- build -- */
|
|
|
|
/** Points a curve is sampled at for hit-testing (as the other reading's). */
|
|
const HIT_SAMPLES = 24;
|
|
|
|
/**
|
|
* The same picture as `buildStepsModel`, laid out in the code's order. Null
|
|
* when the anchor has no body to read — the view then offers the tree.
|
|
*/
|
|
export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
|
|
if (!payload.program) return null;
|
|
const anchorStep = payload.steps.find((s) => s.anchor);
|
|
if (!anchorStep) return null;
|
|
const graph = orderGraph(payload.program, anchorStep.id);
|
|
|
|
const nodes = new Map<string, StepNodeInfo>();
|
|
const forks = new Map<string, StepForkInfo>();
|
|
const modules: WireMapModule[] = [];
|
|
const counts: StepsModel['counts'] = { anchor: 0, screen: 0, trigger: 0, bridge: 0, event: 0, store: 0, effect: 0 };
|
|
const degree = new Map<string, number>();
|
|
for (const e of graph.edges) {
|
|
degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
|
|
degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
|
|
}
|
|
const byId = new Map(payload.steps.map((s) => [s.id, s]));
|
|
for (const id of [anchorStep.id, ...graph.depth.keys()]) {
|
|
if (nodes.has(id)) continue;
|
|
const step: WireStep | undefined = byId.get(id);
|
|
if (!step) continue;
|
|
counts[step.kind]++;
|
|
const info: StepNodeInfo = { id, step, label: stepLabel(step), sub: stepSub(step, payload.project) };
|
|
nodes.set(id, info);
|
|
modules.push({
|
|
id,
|
|
label: info.label,
|
|
files: 1,
|
|
symbols: degree.get(id) ?? 0,
|
|
languages: [],
|
|
test: false,
|
|
generated: 0,
|
|
generatedFiles: [],
|
|
facade: false,
|
|
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
|
|
dependents: { files: 0, modules: 0 },
|
|
});
|
|
}
|
|
// Each decision is a point of its own on the canvas: a small box asking the
|
|
// condition once, where the arms diverge.
|
|
for (const f of graph.forks) {
|
|
const label = forkLabel(f.on);
|
|
forks.set(f.id, { id: f.id, on: f.on, form: f.form, label });
|
|
modules.push({
|
|
id: f.id,
|
|
label,
|
|
files: 0,
|
|
symbols: degree.get(f.id) ?? 0,
|
|
languages: [],
|
|
test: false,
|
|
generated: 0,
|
|
generatedFiles: [],
|
|
facade: false,
|
|
fileList: { total: 0, shown: 0, truncated: false, items: [] },
|
|
dependents: { files: 0, modules: 0 },
|
|
});
|
|
}
|
|
const drawn = (id: string): boolean => nodes.has(id) || forks.has(id);
|
|
|
|
const links: WireMapLink[] = [];
|
|
const edges = new Map<string, StepEdgeInfo>();
|
|
for (const e of graph.edges) {
|
|
if (!drawn(e.from) || !drawn(e.to)) continue;
|
|
const key = linkId({ source: e.from, target: e.to });
|
|
if (edges.has(key)) continue;
|
|
links.push({ source: e.from, target: e.to, count: 1, declared: 1, byKind: [{ kind: 'calls', count: 1 }], topPairs: [] });
|
|
// The panel and the tooltip still read the walk's own links — the sites,
|
|
// the `via` chain, what fires it — for the step the line arrives at.
|
|
const behind = payload.links.filter((l) => l.to === e.to);
|
|
edges.set(key, {
|
|
id: key,
|
|
from: e.from,
|
|
to: e.to,
|
|
links: behind,
|
|
label: lineWords(e),
|
|
synthesized: behind.length > 0 && behind.every((l) => l.synthesized),
|
|
kind: behind[0]?.kind ?? 'calls',
|
|
});
|
|
}
|
|
|
|
// A point sits where its arms are: the leftmost of them, in the code's order.
|
|
const forkOrder = new Map<string, number>();
|
|
for (const e of graph.edges) {
|
|
if (!forks.has(e.from)) continue;
|
|
const order = byId.get(e.to)?.order;
|
|
if (order === undefined) continue;
|
|
forkOrder.set(e.from, Math.min(forkOrder.get(e.from) ?? Number.MAX_SAFE_INTEGER, order));
|
|
}
|
|
|
|
// Row 0 is the bottom, so the anchor — nothing happens before it — is on top.
|
|
const deepest = Math.max(0, ...graph.depth.values());
|
|
const layering = (ids: string[]): Map<string, number> =>
|
|
new Map(ids.map((id) => [id, deepest - (graph.depth.get(id) ?? deepest)]));
|
|
|
|
const layout: MapLayout = buildMapLayout(
|
|
{ modules, links },
|
|
{
|
|
includeTests: true,
|
|
minWeight: 0,
|
|
sizing: (m) => {
|
|
const fork = forks.get(m.id);
|
|
if (fork) return { label: fork.label, meta: '' };
|
|
const info = nodes.get(m.id);
|
|
// Size for the ` …` a cut step wears and the anchor's ● mark, or the
|
|
// CSS ellipsis eats the name's tail.
|
|
const cut = info?.step.cut != null ? ' …' : '';
|
|
const mark = info?.step.anchor ? '● ' : '';
|
|
return { label: mark + (info?.label ?? m.id) + cut, meta: info?.sub ?? '' };
|
|
},
|
|
layering,
|
|
order: (id) => nodes.get(id)?.step.order ?? forkOrder.get(id) ?? Number.MAX_SAFE_INTEGER,
|
|
layerGap: SCREEN_LAYER_GAP,
|
|
portPitch: PORT_PITCH,
|
|
ports: 'directional',
|
|
}
|
|
);
|
|
const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
|
|
const polylines = new Map<string, Point[]>();
|
|
for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
|
|
// The order reading needs no regions: its rows already say when. Its
|
|
// decisions are points between steps, not captions under a box. It keeps
|
|
// every line: a reading of when things happen is the lines, and its rows are
|
|
// short enough that they stay local — `stubbed` empty leaves the filter be.
|
|
return {
|
|
layout,
|
|
nodes,
|
|
edges,
|
|
layerGap: SCREEN_LAYER_GAP,
|
|
curves,
|
|
polylines,
|
|
counts,
|
|
regions: null,
|
|
regionEntries: null,
|
|
forks,
|
|
decisions: [],
|
|
stubs: new Map(),
|
|
stubbed: new Set(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* What a line says: the arm it is, when it leaves a decision's point — the
|
|
* point asks, the line answers — else the whole condition the step at its end
|
|
* runs under (this picture's lines ARE its conditions, so they are not
|
|
* shortened to the last clause the way the other reading's are), else the run
|
|
* it happens inside (`via generateToken`), and nothing at all when the code
|
|
* simply goes on.
|
|
*/
|
|
export function lineWords(e: OrderEdge): string {
|
|
if (e.arm) return e.arm;
|
|
if (!e.when) return e.runs.length > 0 ? e.runs[e.runs.length - 1]! : '';
|
|
const text = joinTokens(whenTokens(e.when));
|
|
return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…` : text;
|
|
}
|
|
|
|
/** The point's words: the condition once, as the view says conditions, asking. */
|
|
function forkLabel(on: string): string {
|
|
const text = whenWords(on);
|
|
if (!text) return '?';
|
|
return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…?` : `${text}?`;
|
|
}
|