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
+108
View File
@@ -201,6 +201,114 @@ func f() {
});
});
describe('branch guards: the arms of one decision', () => {
/** The guards at the site, unjoined. */
async function guardsAt(src: string, needle: string, language: 'tsx' | 'typescript' | 'swift' = 'tsx') {
const line = lineOf(src, needle);
const column = src.split('\n')[line - 1]!.indexOf(needle);
return guardsInSource(src, language, line, column);
}
const ifElse = `
export async function authUser(req, res) {
const user = await User.findOne({ email })
if (user && (await user.matchPassword(password))) {
res.json({ token: generateToken(user._id) })
} else {
res.status(401)
throw new Error('Invalid email or password')
}
}`;
it('gives an if and its else the same branch, with negated flipped', async () => {
const yes = await guardsAt(ifElse, 'res.json');
const no = await guardsAt(ifElse, 'res.status');
expect(yes).toHaveLength(1);
expect(no).toHaveLength(1);
expect(yes[0]!.text).toBe(no[0]!.text);
expect(yes[0]!.negated).toBe(false);
expect(no[0]!.negated).toBe(true);
// The identity of the FORK, not of the arm: both arms of one `if`.
expect(yes[0]!.branch).toBe(no[0]!.branch);
expect(yes[0]!.branch).toMatch(/^\d+:\d+$/);
// The else arm ends by throwing; the then arm runs on.
expect(no[0]!.armExit).toBe('throw');
expect(yes[0]!.armExit).toBeUndefined();
});
const earlyExit = `
export async function createReview(req, res) {
const product = await Product.findById(req.params.id)
if (!product) {
res.status(404)
throw new Error('Product not found')
}
await product.save()
}`;
it('gives an early exit and the code it guards the same branch', async () => {
const inside = await guardsAt(earlyExit, 'res.status');
const after = await guardsAt(earlyExit, 'product.save');
expect(inside).toHaveLength(1);
expect(after).toHaveLength(1);
expect(inside[0]!.branch).toBe(after[0]!.branch);
expect(inside[0]!.negated).toBe(false);
expect(after[0]!.negated).toBe(true);
// The arm NOT taken throws — what the rail draws as the fork's terminal.
expect(after[0]!.form).toBe('guard');
expect(after[0]!.exit).toBe('throw');
expect(inside[0]!.armExit).toBe('throw');
});
const switched = `
export function route(kind) {
switch (kind) {
case 'a':
first()
break
case 'b':
second()
break
default:
other()
}
}`;
it('gives every case of one switch the same branch', async () => {
const a = await guardsAt(switched, 'first()');
const b = await guardsAt(switched, 'second()');
const d = await guardsAt(switched, 'other()');
expect(a[0]!.branch).toBe(b[0]!.branch);
expect(a[0]!.branch).toBe(d[0]!.branch);
expect([a[0]!.text, b[0]!.text, d[0]!.text]).toEqual(['kind === \'a\'', 'kind === \'b\'', 'kind: default']);
});
it('gives two try/catch blocks branches of their own', async () => {
const src = `
export async function save() {
try { await a() } catch (e) { first(e) }
try { await b() } catch (e) { second(e) }
}`;
const one = await guardsAt(src, 'first(e)');
const two = await guardsAt(src, 'second(e)');
expect(one[0]!.text).toBe('on error');
expect(two[0]!.text).toBe('on error');
expect(one[0]!.branch).not.toBe(two[0]!.branch);
});
it('reads a Swift guard as an exit', async () => {
const src = `
func load() {
guard let user = current else { return }
fetch(user)
}`;
const after = await guardsAt(src, 'fetch(user)', 'swift');
expect(after[0]!.form).toBe('guard');
expect(after[0]!.exit).toBe('return');
expect(after[0]!.branch).toMatch(/^\d+:\d+$/);
});
});
describe('branch guards: unsupported', () => {
it('reports no guards for a language without rules', async () => {
expect(supportsBranchGuards('ruby')).toBe(false);