feat(steps): a run of calls that happens once per item says so

A body drawn once, with nothing to say it repeats, is a quiet lie about the
order — so the reading now reads the loops a site is written inside, the same
way it reads its conditions: one climb up the same ancestors, per language,
`for` / `foreach` / `for … in` / `while` / `do` / `repeat`, with the header as
written (`item of items`, `queue.length > 0`) and where the loop starts.

Loops and forks nest in either direction, and neither reading knows about the
other, so the block builder merges them by where each construct BEGINS: on one
ancestor chain the outer one always starts first, which rebuilds the nesting
from the positions alone. A `for` inside an `if` and an `if` inside a `for` come
out the way the code has them.

With it, the per-framework readings are pinned: an Express handler with its
helper drawn inside the reply it builds, a FastAPI `raise HTTPException` ending
the arm it is in, a Spring early `return` as the other arm of its `if` (with
the comparison flipped, not wrapped), an ASP.NET handler's two outcomes, and a
Nest controller read on through the service it delegates to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
Colby McHenry
2026-08-29 13:40:05 -05:00
co-authored by Claude Opus 5
parent 9acab0020f
commit 7b6704a70d
9 changed files with 484 additions and 43 deletions
+10 -6
View File
@@ -41,7 +41,7 @@ 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 { BranchGuard, SiteTrigger } from '../../graph/branch-guards';
import type { BranchGuard, SiteLoop, 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';
@@ -383,6 +383,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
const calls = createSiteReader(cg, projectRoot, MAX_CALL_SITES);
/** 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);
/** The loops a site is written inside — a run of calls that happens once per item. */
const loopsAt = (caller: Node, site: { line?: number; column?: number }) => reader.loops(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);
@@ -553,7 +555,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
hop: HopSite,
guards: readonly BranchGuard[],
what: { step?: string; link?: string; into?: string },
trigger: WireStepTrigger | null = null
trigger: WireStepTrigger | null = null,
loops: readonly SiteLoop[] = []
): void => {
let sites = programs.get(fn.id);
if (!sites) {
@@ -567,6 +570,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
at: { line: hop.line, column: hop.column, end: hop.end },
...(hop.within ? { within: hop.within } : {}),
guards: [...guards],
...(loops.length > 0 ? { loops: [...loops] } : {}),
...(trigger ? { trigger } : {}),
});
};
@@ -754,7 +758,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
if (!target.first) target.first = hop;
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);
record(fold.node, local, guards, { step: target.id, link: id }, fired, await loopsAt(fold.node, at));
return true;
};
@@ -1145,7 +1149,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
const hop = fold.first ?? local;
if (!to.first) to.first = hop;
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);
record(fold.node, local, guards, { step: to.id, link: id }, a.trigger, await loopsAt(fold.node, at));
if (to.root !== null && !explored.has(to.id)) {
explored.add(to.id);
queue.push(to);
@@ -1183,7 +1187,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
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);
record(fold.node, local, guards, { step: known.id, link: id }, a.trigger, await loopsAt(fold.node, at));
}
continue;
}
@@ -1210,7 +1214,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
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);
record(fold.node, local, guards, { into: target.id }, a.trigger, await loopsAt(fold.node, at));
next.push({ node: target, chain: [...fold.chain, target], whens: [...fold.whens, guardLabel(guards)], first });
}
}