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
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { initGrammars } from '../src/extraction/grammars';
|
||||
import { callSiteInSource, decoratorsInSource, guardsInSource, guardLabel, memberTypesInSource, supportsBranchGuards } from '../src/graph/branch-guards';
|
||||
import { callSiteInSource, decoratorsInSource, guardsInSource, guardLabel, loopsInSource, memberTypesInSource, supportsBranchGuards } from '../src/graph/branch-guards';
|
||||
import type { Language } from '../src/types';
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -334,3 +334,118 @@ public class OrderService : IOrderService {
|
||||
expect(Object.fromEntries(types)).toEqual({ _orderRepository: 'IRepository<Order>', Mailer: 'IEmailSender', orderRepository: 'IRepository<Order>', uriComposer: 'IUriComposer' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('loops a site is written inside', () => {
|
||||
/** The loop headers at the site, outermost first, as `<kind> <text>`. */
|
||||
async function loopsAt(src: string, needle: string, language: Language) {
|
||||
const line = lineOf(src, needle);
|
||||
const column = src.split('\n')[line - 1]!.indexOf(needle);
|
||||
return (await loopsInSource(src, language, line, column)).map((l) => `${l.kind} ${l.text}`);
|
||||
}
|
||||
|
||||
it('reads a JS for-of and a while', async () => {
|
||||
const src = `
|
||||
function run(items) {
|
||||
for (const item of items) {
|
||||
save(item)
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
drain()
|
||||
}
|
||||
}`;
|
||||
expect(await loopsAt(src, 'save(item)', 'javascript')).toEqual(['each item of items']);
|
||||
expect(await loopsAt(src, 'drain()', 'javascript')).toEqual(['while queue.length > 0']);
|
||||
});
|
||||
|
||||
it('reads nested loops outermost first', async () => {
|
||||
const src = `
|
||||
function run(rows) {
|
||||
for (const row of rows) {
|
||||
for (const cell of row) {
|
||||
draw(cell)
|
||||
}
|
||||
}
|
||||
}`;
|
||||
expect(await loopsAt(src, 'draw(cell)', 'javascript')).toEqual(['each row of rows', 'each cell of row']);
|
||||
});
|
||||
|
||||
it('reads nothing for a site outside every loop', async () => {
|
||||
const src = `
|
||||
function run(items) {
|
||||
begin()
|
||||
for (const item of items) { save(item) }
|
||||
}`;
|
||||
expect(await loopsAt(src, 'begin()', 'javascript')).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads a Python for and a while', async () => {
|
||||
const src = `
|
||||
def run(items):
|
||||
for item in items:
|
||||
save(item)
|
||||
while pending:
|
||||
drain()
|
||||
`;
|
||||
expect(await loopsAt(src, 'save(item)', 'python')).toEqual(['each item in items']);
|
||||
expect(await loopsAt(src, 'drain()', 'python')).toEqual(['while pending']);
|
||||
});
|
||||
|
||||
it('reads a Java enhanced for', async () => {
|
||||
const src = `
|
||||
class A {
|
||||
void run(List<Item> items) {
|
||||
for (Item item : items) {
|
||||
save(item);
|
||||
}
|
||||
}
|
||||
}`;
|
||||
expect(await loopsAt(src, 'save(item)', 'java')).toEqual(['each Item item : items']);
|
||||
});
|
||||
|
||||
it('reads a Go range loop', async () => {
|
||||
const src = `
|
||||
func run(items []Item) {
|
||||
for _, item := range items {
|
||||
save(item)
|
||||
}
|
||||
}`;
|
||||
expect(await loopsAt(src, 'save(item)', 'go')).toEqual(['each _, item := range items']);
|
||||
});
|
||||
|
||||
it('reads a C# foreach', async () => {
|
||||
const src = `
|
||||
class A {
|
||||
void Run(List<Item> items) {
|
||||
foreach (var item in items) {
|
||||
Save(item);
|
||||
}
|
||||
}
|
||||
}`;
|
||||
// The binding word is noise in a header a person reads: `var` goes.
|
||||
expect(await loopsAt(src, 'Save(item)', 'csharp')).toEqual(['each item in items']);
|
||||
});
|
||||
|
||||
it('reads a Swift for-in', async () => {
|
||||
const src = `
|
||||
func run(items: [Item]) {
|
||||
for item in items {
|
||||
save(item)
|
||||
}
|
||||
}`;
|
||||
expect(await loopsAt(src, 'save(item)', 'swift')).toEqual(['each item in items']);
|
||||
});
|
||||
|
||||
it('reads a Kotlin for', async () => {
|
||||
const src = `
|
||||
fun run(items: List<Item>) {
|
||||
for (item in items) {
|
||||
save(item)
|
||||
}
|
||||
}`;
|
||||
expect(await loopsAt(src, 'save(item)', 'kotlin')).toEqual(['each item in items']);
|
||||
});
|
||||
|
||||
it('reads nothing for a language without rules', async () => {
|
||||
expect(await loopsInSource('def f\n xs.each { save }\nend\n', 'ruby', 2, 2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||||
import { buildSteps, projectKind } from '../src/ui-server/api/steps';
|
||||
import type { WireBlock } from '../src/ui-server/api/program';
|
||||
import { routeRoots } from '../src/ui-server/api/route-roots';
|
||||
|
||||
let tmpDir: string;
|
||||
@@ -279,6 +280,110 @@ const route = (name: string) => {
|
||||
};
|
||||
const effect = (p: Awaited<ReturnType<typeof buildSteps>>, category: string) => p.steps.find((s) => s.kind === 'effect' && s.effect?.category === category);
|
||||
|
||||
/**
|
||||
* The `program` reading, one line per item, indented — the rail as a reader
|
||||
* meets it. A step prints its label, a fork its condition and each arm's, a
|
||||
* bracketed run its words.
|
||||
*/
|
||||
function inOrder(p: Awaited<ReturnType<typeof buildSteps>>): string[] {
|
||||
const label = (id: string) => p.steps.find((s) => s.id === id)?.label ?? id;
|
||||
const out: string[] = [];
|
||||
const walk = (block: WireBlock, indent: string): void => {
|
||||
for (const item of block) {
|
||||
if (item.kind === 'step') {
|
||||
out.push(`${indent}${label(item.step)}${item.again ? ' (again)' : ''}`);
|
||||
if (item.body) walk(item.body, `${indent} `);
|
||||
} else if (item.kind === 'fork') {
|
||||
out.push(`${indent}${item.form} ${item.on}`);
|
||||
for (const arm of item.arms) {
|
||||
out.push(`${indent} ${arm.when}${arm.ends ? ` → ${arm.ends}` : ''}`);
|
||||
walk(arm.body, `${indent} `);
|
||||
}
|
||||
} else if (item.kind === 'block') {
|
||||
out.push(`${indent}${item.block} ${item.via?.name ?? item.by ?? ''}`.trimEnd());
|
||||
walk(item.body, `${indent} `);
|
||||
} else out.push(`${indent}cut ${item.why}`);
|
||||
}
|
||||
};
|
||||
if (p.program) walk(p.program.root, '');
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('in the code’s order', () => {
|
||||
it('reads an Express handler as it is written, helper and all', async () => {
|
||||
// `create` → `emailQueue.add` → `if (!user.verified) sendVerification(user)`
|
||||
// → `res.status(201).json({ token: signToken(user.id) })`. The token is
|
||||
// signed INSIDE the reply, so it is drawn before the 201, not beside it.
|
||||
const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /users').id }));
|
||||
expect(p.defaultView).toBe('order');
|
||||
expect(inOrder(p)).toEqual([
|
||||
'prisma.user.create({ data })',
|
||||
"emailQueue.add('welcome', { userId })",
|
||||
'if !user.verified',
|
||||
' !user.verified',
|
||||
' inline sendVerification',
|
||||
' transporter.sendMail({ to })',
|
||||
'inline signToken',
|
||||
' jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn })',
|
||||
'201',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a Python handler, its raise ending the arm it is in', async () => {
|
||||
// `session.add` / `session.commit` are one database box drawn at both its
|
||||
// sites; `raise HTTPException(422)` is an early exit, so the arm that
|
||||
// raises answers there and the Celery job is on the other one.
|
||||
const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /items').id }));
|
||||
expect(inOrder(p)).toEqual([
|
||||
'session.add +1',
|
||||
'session.add +1',
|
||||
'if item.price < 0',
|
||||
' item.price < 0 → reply',
|
||||
' 422',
|
||||
' !(item.price < 0)',
|
||||
' send_welcome.delay(item.id)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a Java handler, its early return as the fork’s other arm', async () => {
|
||||
// `if (owner.getName() == null) return badRequest();` — one comparison
|
||||
// flips rather than wrapping, so the arm that runs on says `!= null`.
|
||||
const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /owners/new').id }));
|
||||
expect(inOrder(p)).toEqual([
|
||||
'if owner.getName() == null',
|
||||
' owner.getName() == null → reply',
|
||||
' 400',
|
||||
' owner.getName() != null → reply',
|
||||
' owners.save(owner)',
|
||||
' 201',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a C# handler’s two outcomes', async () => {
|
||||
const p = await buildSteps(cg, tmpDir, q({ anchor: route('PUT /api/TodoItems/{id}').id }));
|
||||
expect(inOrder(p)).toEqual([
|
||||
'if id != command.Id',
|
||||
' id != command.Id → reply',
|
||||
' 400',
|
||||
' id == command.Id → reply',
|
||||
' 204',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a Nest handler through the service it delegates to', async () => {
|
||||
const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /cats').id }));
|
||||
expect(inOrder(p)).toEqual(['inline create', ' this.catsRepository.save(dto)', ' handleIndex']);
|
||||
});
|
||||
|
||||
it('has no order to read for a screen, and says so', async () => {
|
||||
// A route with an inline handler still has a body; what has none is a
|
||||
// symbol nothing in the picture happens inside.
|
||||
const p = await buildSteps(cg, tmpDir, q({ symbol: 'RolesGuard' }));
|
||||
expect(p.program).toBeNull();
|
||||
expect(p.defaultView).toBe('tree');
|
||||
});
|
||||
});
|
||||
|
||||
describe('route roots', () => {
|
||||
it('names the handler an API route runs, the route itself for an inline handler', () => {
|
||||
const roots = routeRoots(cg, cg.getNodesByKind('route'));
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { BranchGuard } from '../src/graph/branch-guards';
|
||||
import type { BranchGuard, SiteLoop } from '../src/graph/branch-guards';
|
||||
import { buildProgram, type ProgramInput, type ProgramSite, type WireBlock, type WireItem } from '../src/ui-server/api/program';
|
||||
|
||||
/* ------------------------------------------------------------ material -- */
|
||||
@@ -27,6 +27,11 @@ function at(step: string, guards: BranchGuard[] = [], extra: Partial<ProgramSite
|
||||
return { step, link: `l:${step}`, at: { line, column: 0, end: { line, column: 40 } }, guards, ...extra };
|
||||
}
|
||||
|
||||
/** A loop the site is written inside. */
|
||||
function loop(text: string, kind: SiteLoop['kind'] = 'each', branch = `l:${text}`): SiteLoop {
|
||||
return { text, kind, branch };
|
||||
}
|
||||
|
||||
/** A site that folds into a helper. */
|
||||
function into(fn: string, guards: BranchGuard[] = [], extra: Partial<ProgramSite> = {}): ProgramSite {
|
||||
const line = nextLine++;
|
||||
@@ -235,6 +240,28 @@ describe('buildProgram', () => {
|
||||
expect(shape(p!.root)).toEqual(['together Promise.all', ' a inside Promise.all', ' b inside Promise.all', 'c']);
|
||||
});
|
||||
|
||||
it('says a run of calls happens once per item', () => {
|
||||
const p = program({
|
||||
root: [at('before'), at('each', [], { loops: [loop('item of items')] }), at('after')],
|
||||
});
|
||||
expect(shape(p!.root)).toEqual(['before', 'loop item of items', ' each', 'after']);
|
||||
});
|
||||
|
||||
it('nests a loop and a fork by which one is written outside the other', () => {
|
||||
// `for (…) { if (ready) { go() } }` — the loop starts first, so it is
|
||||
// outside; the guard's own branch position is what decides, not its order
|
||||
// in the chain.
|
||||
const inner = g('ready', { branch: '9:4' });
|
||||
const p = program({ root: [at('go', [inner], { loops: [loop('item of items', 'each', '8:2')] })] });
|
||||
expect(shape(p!.root)).toEqual(['loop item of items', ' if ready', ' arm ready', ' go']);
|
||||
|
||||
// `if (ready) { for (…) { go() } }` — the same two constructs, the other
|
||||
// way round, told apart by where each begins.
|
||||
const outer = g('ready', { branch: '8:2' });
|
||||
const q = program({ root: [at('go', [outer], { loops: [loop('item of items', 'each', '9:4')] })] });
|
||||
expect(shape(q!.root)).toEqual(['if ready', ' arm ready', ' loop item of items', ' go']);
|
||||
});
|
||||
|
||||
it('closes a fork when the code leaves it', () => {
|
||||
const cond = g('ready');
|
||||
const p = program({ root: [at('inside', [cond]), at('after')] });
|
||||
|
||||
Reference in New Issue
Block a user