feat(steps): guards say which decision they belong to, and how an arm leaves

A joined `when` string cannot tell an `if` from its `else`: two sites read as
opposite conditions, and nothing says they are the two arms of ONE decision.
The reading a rail needs is the structure, so each guard now carries it:

- `branch` — where the branching construct starts (`line:column`). Both arms of
  an `if`, every case of a `switch`, an early exit and the code it guards share
  it; two `try`/`catch` blocks in one function no longer collapse into one.
- `armExit` — how the arm the site is in leaves, when it always does (`return`,
  `throw`, or `exit` for a `panic` / `exit()` the rules count but no keyword
  names), read from the arm's last statement.
- `exit` — for an early exit, how the arm that was NOT taken leaves.

`SiteReader.guards()` returns the array; `when` is now `guardLabel` over it, so
a caller that wants both pays for one read. Nothing else changes: `guardLabel`
ignores the new fields and every existing label is byte-identical.

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:08:41 -05:00
co-authored by Claude Opus 5
parent fc149b7d74
commit 482690b62d
3 changed files with 257 additions and 30 deletions
+23 -7
View File
@@ -23,6 +23,7 @@ import {
siteKey,
supportsBranchGuards,
triggersForFile,
type BranchGuard,
type CallSiteText,
type DefinitionDecorators,
type SiteTrigger,
@@ -98,6 +99,13 @@ export async function annotateWhen(cg: CodeGraph, projectRoot: string, batches:
export interface SiteReader {
/** The conditions the site runs under, joined; '' when unconditional or unreadable. */
when(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<string>;
/**
* The same conditions, outermost first, unjoined — each with the branching
* construct it belongs to, so two sites can be told to be the two arms of
* ONE `if` rather than two conditions that happen to read as opposites.
* What {@link SiteReader.when} joins; empty when unconditional or unreadable.
*/
guards(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<BranchGuard[]>;
/** 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. */
@@ -149,15 +157,23 @@ export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites =
}
return file;
};
// Named rather than a method, because `createWhenReader` hands `when` out
// detached: it must not depend on `this`.
const guards = async (
caller: { filePath: string; language: Language },
site: { line?: number; column?: number }
): Promise<BranchGuard[]> => {
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return [];
const file = resolve(caller);
if (!file) return [];
sites++;
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
return (await guardsForFile(file.abs, file.language, [key])).get(siteKey(key)) ?? [];
};
return {
guards,
async when(caller, site) {
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return '';
const file = resolve(caller);
if (!file) return '';
sites++;
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
const g = (await guardsForFile(file.abs, file.language, [key])).get(siteKey(key));
return g ? guardLabel(g) : '';
return guardLabel(await guards(caller, site));
},
async args(caller, site) {
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return null;