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
+13
View File
@@ -19,6 +19,7 @@ import {
decoratorsForFile,
guardLabel,
guardsForFile,
loopsForFile,
memberTypesForFile,
siteKey,
supportsBranchGuards,
@@ -26,6 +27,7 @@ import {
type BranchGuard,
type CallSiteText,
type DefinitionDecorators,
type SiteLoop,
type SiteTrigger,
} from '../../graph/branch-guards';
import { resolveProjectFile } from '../security';
@@ -106,6 +108,8 @@ export interface SiteReader {
* What {@link SiteReader.when} joins; empty when unconditional or unreadable.
*/
guards(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<BranchGuard[]>;
/** The loops the site is written inside, outermost first — a run of calls that happens once per item. */
loops(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<SiteLoop[]>;
/** What the site passes, abbreviated (`'userEmail', values.email`); null when unreadable. '' for an empty list. */
args(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<string | null>;
/** What fires the site — the JSX prop, `on*` option or runs-later call it is written under; null when nothing binds it. */
@@ -175,6 +179,15 @@ export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites =
async when(caller, site) {
return guardLabel(await guards(caller, site));
},
async loops(caller, site) {
// Not counted against the budget: the tree is parsed for the site's
// guards anyway, and this is a second climb up the same nodes.
if (!site.line || !supportsBranchGuards(caller.language)) return [];
const file = resolve(caller);
if (!file) return [];
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
return (await loopsForFile(file.abs, file.language, [key])).get(siteKey(key)) ?? [];
},
async args(caller, site) {
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return null;
const file = resolve(caller);