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:
co-authored by
Claude Opus 5
parent
9acab0020f
commit
7b6704a70d
@@ -26,7 +26,7 @@
|
||||
* string can never say.
|
||||
*/
|
||||
|
||||
import { guardLabel, type BranchGuard } from '../../graph/branch-guards';
|
||||
import { guardLabel, type BranchGuard, type SiteLoop } from '../../graph/branch-guards';
|
||||
import type { WireNodeRef } from './wire';
|
||||
|
||||
// =============================================================================
|
||||
@@ -63,7 +63,17 @@ export type WireItem =
|
||||
* after this function returns (`later`), or calls started together
|
||||
* (`together`).
|
||||
*/
|
||||
| { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; by?: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true }
|
||||
| {
|
||||
kind: 'block';
|
||||
block: 'inline' | 'loop' | 'later' | 'together';
|
||||
by?: string;
|
||||
/** For a loop: whether it runs once per item or while a condition holds. */
|
||||
loop?: 'each' | 'while';
|
||||
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' };
|
||||
|
||||
@@ -91,6 +101,8 @@ export interface ProgramSite {
|
||||
within?: string;
|
||||
/** The conditions it runs under, outermost first. */
|
||||
guards: BranchGuard[];
|
||||
/** The loops it is written inside, outermost first. */
|
||||
loops?: SiteLoop[];
|
||||
/** What fires it, when something binds it — a callback runs LATER. */
|
||||
trigger?: { kind: string; name: string; of?: string | null };
|
||||
}
|
||||
@@ -151,50 +163,36 @@ function blockFor(input: ProgramInput, fn: string, path: readonly string[], stat
|
||||
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<WireItem, { kind: 'fork' }>; armKey: string; arm: WireArm; exit?: WireArmEnd }> = [];
|
||||
const blockAt = (depth: number): WireBlock => (depth === 0 ? root : stack[depth - 1]!.arm.body);
|
||||
/** The constructs open at the site being placed, outermost first. */
|
||||
const stack: Open[] = [];
|
||||
const bodyAt = (depth: number): WireBlock => (depth === 0 ? root : stack[depth - 1]!.body);
|
||||
|
||||
for (const site of sites) {
|
||||
const guards = site.guards;
|
||||
const scopes = scopesOf(site);
|
||||
|
||||
// The longest prefix of open forks the site still sits under, arm and all.
|
||||
// The longest run of open constructs the site still sits inside — same
|
||||
// construct AND, for a fork, the same arm of it.
|
||||
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) {
|
||||
while (keep < stack.length && keep < scopes.length && sameScope(stack[keep]!, scopes[keep]!)) keep++;
|
||||
// The construct 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.
|
||||
const open = stack[keep];
|
||||
const scope = scopes[keep];
|
||||
if (open && scope && open.branch === scope.branch && open.fork && scope.kind === 'guard') {
|
||||
stack.length = keep + 1;
|
||||
const open = stack[keep]!;
|
||||
open.armKey = armKey(guards[keep]!);
|
||||
open.arm = armFor(open.fork, guards[keep]!);
|
||||
open.armKey = armKey(scope.guard);
|
||||
open.body = armFor(open.fork, scope.guard).body;
|
||||
keep++;
|
||||
} else {
|
||||
stack.length = keep;
|
||||
}
|
||||
for (let i = keep; i < guards.length; i++) {
|
||||
const g = guards[i]!;
|
||||
// The decision as a reader says it, not as the guard stores it: a
|
||||
// disjunction keeps the parentheses that stop `a || b` from reading as
|
||||
// two ways of arriving (`guardLabel` is the one place that decides).
|
||||
const fork: Extract<WireItem, { kind: 'fork' }> = { kind: 'fork', on: guardLabel([{ ...g, negated: false }]), 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) });
|
||||
}
|
||||
for (let i = keep; i < scopes.length; i++) stack.push(openScope(bodyAt(i), scopes[i]!));
|
||||
|
||||
const item = itemFor(input, site, path, state);
|
||||
if (item !== null) {
|
||||
state.items++;
|
||||
place(blockAt(guards.length), item, site);
|
||||
place(bodyAt(scopes.length), item, site);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +201,66 @@ function blockFor(input: ProgramInput, fn: string, path: readonly string[], stat
|
||||
return root;
|
||||
}
|
||||
|
||||
/** A construct the reading is inside: a loop, or one arm of a fork. */
|
||||
interface Open {
|
||||
branch: string;
|
||||
/** Set for a fork; a loop has only its body. */
|
||||
fork?: Extract<WireItem, { kind: 'fork' }>;
|
||||
armKey?: string;
|
||||
/** Where items at this level go. */
|
||||
body: WireBlock;
|
||||
}
|
||||
|
||||
type Scope = { kind: 'guard'; branch: string; guard: BranchGuard } | { kind: 'loop'; branch: string; loop: SiteLoop };
|
||||
|
||||
/**
|
||||
* The constructs a site is written inside, outermost first: its guards and its
|
||||
* loops merged by where each one STARTS. Both were read by the same climb up
|
||||
* the same ancestors, and on one ancestor chain an outer construct always
|
||||
* begins before an inner one — so the positions alone rebuild the nesting,
|
||||
* without either reading having to know about the other.
|
||||
*/
|
||||
function scopesOf(site: ProgramSite): Scope[] {
|
||||
const guards: Scope[] = site.guards.map((guard) => ({ kind: 'guard' as const, branch: guard.branch, guard }));
|
||||
const loops: Scope[] = (site.loops ?? []).map((loop) => ({ kind: 'loop' as const, branch: loop.branch, loop }));
|
||||
if (loops.length === 0) return guards;
|
||||
return [...guards, ...loops].sort((a, b) => at(a.branch) - at(b.branch) || a.branch.localeCompare(b.branch));
|
||||
}
|
||||
|
||||
/** A `line:column` branch as one number, for ordering constructs by where they start. */
|
||||
function at(branch: string): number {
|
||||
const [line, column] = branch.split(':');
|
||||
return (Number(line) || 0) * 10000 + (Number(column) || 0);
|
||||
}
|
||||
|
||||
function sameScope(open: Open, scope: Scope): boolean {
|
||||
if (open.branch !== scope.branch) return false;
|
||||
if (scope.kind === 'loop') return !open.fork;
|
||||
return !!open.fork && open.armKey === armKey(scope.guard);
|
||||
}
|
||||
|
||||
/** Open a construct: a bracketed loop, or a fork with the arm this site is in. */
|
||||
function openScope(into: WireBlock, scope: Scope): Open {
|
||||
if (scope.kind === 'loop') {
|
||||
const block: WireItem = { kind: 'block', block: 'loop', by: scope.loop.text, loop: scope.loop.kind, body: [] };
|
||||
into.push(block);
|
||||
return { branch: scope.branch, body: (block as Extract<WireItem, { kind: 'block' }>).body };
|
||||
}
|
||||
const g = scope.guard;
|
||||
// The decision as a reader says it, not as the guard stores it: a
|
||||
// disjunction keeps the parentheses that stop `a || b` from reading as two
|
||||
// ways of arriving (`guardLabel` is the one place that decides).
|
||||
const fork: Extract<WireItem, { kind: 'fork' }> = { kind: 'fork', on: guardLabel([{ ...g, negated: false }]), 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: [] });
|
||||
}
|
||||
into.push(fork);
|
||||
return { branch: scope.branch, fork, armKey: armKey(g), body: armFor(fork, g).body };
|
||||
}
|
||||
|
||||
/** What one recorded site draws as. */
|
||||
function itemFor(input: ProgramInput, site: ProgramSite, path: readonly string[], state: Reading): WireItem | null {
|
||||
if (site.into) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user