feat(ui): Steps for servers — route roots, server effects, request/decorator triggers, guards for Python/Java/Kotlin/C#/Go/C
- api/route-roots.ts: the symbol a route runs (references-edge handler, exported page component, or the route itself for an inline handler), shared by steps and screens; the bare Steps tab lists an API's endpoints by router file - api/effects.ts: database / response / queue / email / payments / cache / auth / process / network / storage / device / telemetry, matched on the call as written per language family, with model + read/write and the literal status on a response site - graph/branch-guards.ts: callSitesForFile (the whole member chain), memberTypesInTree, decoratorsForFile, request/decorator triggers with the middleware/guard chain; guard + argument rules for Python, Java, Kotlin, C#, Go and C - steps.ts: classify on the chain before trusting a name match, retarget this.x.y() by declared type, skip test doubles after the effect pre-check, project kind on the wire - viewer: kindWord/kindWords per project kind, endpoint chooser, response boxes labelled by status codes - python.ts: FastAPI detected from a monorepo sub-directory; is-test-file: samples/examples package paths are not tests - tests: ui-steps-api-servers, ui-effects, branch-guards-languages; spec §3.13 Servers paragraph, CHANGELOG, plan doc Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
co-authored by
Claude Fable 5
parent
5e06204deb
commit
950686def4
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Branch guards, call sites and decorators for the server languages — Python,
|
||||
* Java, Kotlin, C#, Go, C — read from source the way the Steps view reads
|
||||
* them. Every language gets the same four readings the JS rules give: the
|
||||
* conditions a site runs under (early exits before it included), what it is
|
||||
* passed, what is called as written, and what is written on its definition.
|
||||
*/
|
||||
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 type { Language } from '../src/types';
|
||||
|
||||
beforeAll(async () => {
|
||||
await initGrammars();
|
||||
});
|
||||
|
||||
function lineOf(src: string, needle: string): number {
|
||||
const i = src.split('\n').findIndex((l) => l.includes(needle));
|
||||
if (i < 0) throw new Error(`no line contains ${needle}`);
|
||||
return i + 1;
|
||||
}
|
||||
|
||||
async function labelAt(src: string, needle: string, language: Language): Promise<string> {
|
||||
const line = lineOf(src, needle);
|
||||
const column = src.split('\n')[line - 1]!.indexOf(needle);
|
||||
return guardLabel(await guardsInSource(src, language, line, column));
|
||||
}
|
||||
|
||||
async function siteAt(src: string, needle: string, language: Language) {
|
||||
const line = lineOf(src, needle);
|
||||
const column = src.split('\n')[line - 1]!.indexOf(needle);
|
||||
return callSiteInSource(src, language, line, column);
|
||||
}
|
||||
|
||||
describe('languages with rules', () => {
|
||||
it('names them', () => {
|
||||
for (const l of ['python', 'java', 'kotlin', 'csharp', 'go', 'c', 'cpp']) expect(supportsBranchGuards(l)).toBe(true);
|
||||
expect(supportsBranchGuards('ruby')).toBe(false);
|
||||
expect(supportsBranchGuards('php')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Python', () => {
|
||||
const src = `
|
||||
@router.post("/", dependencies=[Depends(auth)])
|
||||
def create_item(session: SessionDep, item_in: ItemCreate) -> Any:
|
||||
if not item_in.title:
|
||||
raise HTTPException(status_code=400, detail="no title")
|
||||
try:
|
||||
item = Item.model_validate(item_in, update={"owner_id": 1})
|
||||
except ValueError as e:
|
||||
return None
|
||||
if item.count > 0 and item.ok:
|
||||
session.add(item)
|
||||
elif item.count == 0:
|
||||
session.delete(item)
|
||||
else:
|
||||
pass
|
||||
match item.kind:
|
||||
case "a":
|
||||
session.commit()
|
||||
case _:
|
||||
pass
|
||||
x = a if cond else b
|
||||
for i in items:
|
||||
if i is None:
|
||||
continue
|
||||
session.refresh(i)
|
||||
return item
|
||||
`;
|
||||
it('reads if / elif / match / early exits / the ternary form / the loop guard', async () => {
|
||||
expect(await labelAt(src, 'raise HTTPException', 'python')).toBe('not item_in.title');
|
||||
expect(await labelAt(src, 'session.add(item)', 'python')).toBe('item_in.title && item.count > 0 and item.ok');
|
||||
expect(await labelAt(src, 'session.delete(item)', 'python')).toBe('item_in.title && !(item.count > 0 and item.ok) && item.count == 0');
|
||||
expect(await labelAt(src, 'session.commit()', 'python')).toBe('item_in.title && item.kind == "a"');
|
||||
expect(await labelAt(src, 'session.refresh(i)', 'python')).toBe('item_in.title && i is not None');
|
||||
expect(await labelAt(src, 'return None', 'python')).toBe('item_in.title && on error');
|
||||
});
|
||||
it('reads the call as written, with keyword arguments', async () => {
|
||||
expect(await siteAt(src, 'HTTPException(', 'python')).toMatchObject({ callee: 'HTTPException', args: 'status_code=400, detail="no title"' });
|
||||
expect(await siteAt(src, 'Item.model_validate', 'python')).toMatchObject({ callee: 'Item.model_validate', args: 'item_in, update={ "owner_id" }' });
|
||||
});
|
||||
it('reads the decorators on the definition', async () => {
|
||||
expect(await decoratorsInSource(src, 'python', lineOf(src, 'def create_item'))).toEqual({
|
||||
own: ['router.post("/", dependencies=[Depends(auth)])'],
|
||||
class: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Java', () => {
|
||||
const src = `
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class OwnerController {
|
||||
@PostMapping("/owners/new")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public String processCreationForm(@Valid Owner owner, BindingResult result) {
|
||||
if (result.hasErrors()) {
|
||||
return VIEWS;
|
||||
}
|
||||
try {
|
||||
this.owners.save(owner);
|
||||
} catch (IllegalStateException e) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "x");
|
||||
}
|
||||
switch (owner.kind) {
|
||||
case A: owners.delete(owner); break;
|
||||
default: return "b";
|
||||
}
|
||||
String s = cond ? a() : b();
|
||||
Owner o = new Owner("x", 3);
|
||||
return cond && !late ? "redirect:/owners/" + owner.getId() : "x";
|
||||
}
|
||||
}
|
||||
`;
|
||||
it('reads early exits, try/catch, switch and the ternary', async () => {
|
||||
expect(await labelAt(src, 'this.owners.save', 'java')).toBe('!result.hasErrors()');
|
||||
// A negated guard on one call with nested parentheses stays a bare `!`.
|
||||
const nested = 'class A {\n void f(Owner owner, int ownerId) {\n if (!Objects.equals(owner.getId(), ownerId)) {\n return;\n }\n owners.save(owner);\n }\n}\n';
|
||||
expect(await labelAt(nested, 'owners.save', 'java')).toBe('Objects.equals(owner.getId(), ownerId)');
|
||||
expect(await labelAt(src, 'new ResponseStatusException', 'java')).toBe('!result.hasErrors() && on error');
|
||||
expect(await labelAt(src, 'owners.delete(owner)', 'java')).toBe('!result.hasErrors() && owner.kind == A');
|
||||
expect(await labelAt(src, 'return "b"', 'java')).toBe('!result.hasErrors() && owner.kind: default');
|
||||
expect(await labelAt(src, 'a() : b()', 'java')).toBe('!result.hasErrors() && cond');
|
||||
expect(await labelAt(src, 'owner.getId()', 'java')).toBe('!result.hasErrors() && cond && !late');
|
||||
});
|
||||
it('reads the call as written', async () => {
|
||||
expect(await siteAt(src, 'new Owner(', 'java')).toMatchObject({ callee: 'Owner', args: '"x", 3' });
|
||||
expect(await siteAt(src, 'this.owners.save', 'java')).toMatchObject({ callee: 'this.owners.save', args: 'owner' });
|
||||
expect(await siteAt(src, 'new ResponseStatusException', 'java')).toMatchObject({ callee: 'ResponseStatusException', args: 'HttpStatus.NOT_FOUND, "x"' });
|
||||
});
|
||||
it('reads the annotations on the method and its class', async () => {
|
||||
expect(await decoratorsInSource(src, 'java', lineOf(src, 'public String processCreationForm'))).toEqual({
|
||||
own: ['PostMapping("/owners/new")', 'PreAuthorize("hasRole(\'ADMIN\')")'],
|
||||
class: ['RestController', 'RequestMapping("/api")'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kotlin', () => {
|
||||
const src = `
|
||||
@RestController
|
||||
class OwnerController(val owners: OwnerRepository) {
|
||||
@PostMapping("/owners/new")
|
||||
fun processCreationForm(@Valid owner: Owner, result: BindingResult): String {
|
||||
if (result.hasErrors()) {
|
||||
return VIEWS
|
||||
}
|
||||
try { owners.save(owner) } catch (e: IllegalStateException) { throw NotFound("x") }
|
||||
when (owner.kind) {
|
||||
A -> owners.delete(owner)
|
||||
else -> return "b"
|
||||
}
|
||||
val s = if (cond) a() else b()
|
||||
owner.let { owners.save(it) }
|
||||
return "redirect:/owners/"
|
||||
}
|
||||
}
|
||||
`;
|
||||
it('reads early exits, try/catch, when and the if-expression', async () => {
|
||||
expect(await labelAt(src, 'owners.save(owner)', 'kotlin')).toBe('!result.hasErrors()');
|
||||
expect(await labelAt(src, 'NotFound("x")', 'kotlin')).toBe('!result.hasErrors() && on error');
|
||||
expect(await labelAt(src, 'owners.delete(owner)', 'kotlin')).toBe('!result.hasErrors() && owner.kind == A');
|
||||
expect(await labelAt(src, 'return "b"', 'kotlin')).toBe('!result.hasErrors() && owner.kind: else');
|
||||
expect(await labelAt(src, 'a() else', 'kotlin')).toBe('!result.hasErrors() && cond');
|
||||
expect(await labelAt(src, 'b()', 'kotlin')).toBe('!result.hasErrors() && !cond');
|
||||
// A lambda is inline: the conditions around it are the conditions it runs under.
|
||||
expect(await labelAt(src, 'owners.save(it)', 'kotlin')).toBe('!result.hasErrors()');
|
||||
});
|
||||
it('reads the call as written', async () => {
|
||||
expect(await siteAt(src, 'owners.delete(owner)', 'kotlin')).toMatchObject({ callee: 'owners.delete', args: 'owner' });
|
||||
// A trailing lambda is `{ … }`, as Swift's closure is — not its body.
|
||||
const lambda = 'class A(val prefs: DataStore<P>) {\n suspend fun set(b: Boolean) {\n prefs.updateData { it.copy { bookmarked = b } }\n }\n}\n';
|
||||
expect(await siteAt(lambda, 'prefs.updateData', 'kotlin')).toMatchObject({ callee: 'prefs.updateData', args: '{ … }' });
|
||||
});
|
||||
it('reads the annotations', async () => {
|
||||
expect(await decoratorsInSource(src, 'kotlin', lineOf(src, 'fun processCreationForm'))).toEqual({
|
||||
own: ['PostMapping("/owners/new")'],
|
||||
class: ['RestController'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('C#', () => {
|
||||
const src = `
|
||||
[ApiController]
|
||||
public class TodoController : ControllerBase {
|
||||
[HttpPost("items")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Create([FromBody] Item item) {
|
||||
if (item == null) return BadRequest();
|
||||
try { await _context.Items.AddAsync(item); } catch (DbUpdateException e) { return Conflict(); }
|
||||
switch (item.Kind) { case 1: _bus.Publish(item); break; default: break; }
|
||||
var x = cond ? Ok(item) : NotFound();
|
||||
return item.Ok && !late ? Created("x", item) : StatusCode(500);
|
||||
}
|
||||
}
|
||||
`;
|
||||
it('reads early exits, try/catch, switch and the conditional', async () => {
|
||||
expect(await labelAt(src, '_context.Items.AddAsync', 'csharp')).toBe('item != null');
|
||||
expect(await labelAt(src, 'Conflict()', 'csharp')).toBe('item != null && on error');
|
||||
expect(await labelAt(src, '_bus.Publish', 'csharp')).toBe('item != null && item.Kind == 1');
|
||||
expect(await labelAt(src, 'Ok(item)', 'csharp')).toBe('item != null && cond');
|
||||
expect(await labelAt(src, 'NotFound()', 'csharp')).toBe('item != null && !cond');
|
||||
expect(await labelAt(src, 'Created("x"', 'csharp')).toBe('item != null && item.Ok && !late');
|
||||
expect(await labelAt(src, 'StatusCode(500)', 'csharp')).toBe('item != null && !(item.Ok && !late)');
|
||||
});
|
||||
it('reads the call as written', async () => {
|
||||
expect(await siteAt(src, '_context.Items.AddAsync', 'csharp')).toMatchObject({ callee: '_context.Items.AddAsync', args: 'item' });
|
||||
expect(await siteAt(src, 'Created("x"', 'csharp')).toMatchObject({ callee: 'Created', args: '"x", item' });
|
||||
});
|
||||
it('reads the attributes on the action and its controller', async () => {
|
||||
expect(await decoratorsInSource(src, 'csharp', lineOf(src, 'public async Task<IActionResult> Create'))).toEqual({
|
||||
own: ['HttpPost("items")', 'Authorize(Roles = "Admin")'],
|
||||
class: ['ApiController'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go', () => {
|
||||
const src = `
|
||||
package main
|
||||
func createUser(c *gin.Context) {
|
||||
if err := c.BindJSON(&u); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if u.Name == "" && !ok {
|
||||
c.AbortWithStatus(404)
|
||||
} else if u.Age > 3 {
|
||||
db.Create(&u)
|
||||
} else {
|
||||
db.Save(&u)
|
||||
}
|
||||
switch u.Kind {
|
||||
case "a":
|
||||
q.Publish("x", u)
|
||||
default:
|
||||
return
|
||||
}
|
||||
go worker(u)
|
||||
c.JSON(http.StatusCreated, u)
|
||||
}
|
||||
`;
|
||||
it('reads the idiomatic error guard flipped, else-if chains and the switch', async () => {
|
||||
expect(await labelAt(src, 'c.JSON(http.StatusBadRequest', 'go')).toBe('err != nil');
|
||||
expect(await labelAt(src, 'c.AbortWithStatus', 'go')).toBe('err == nil && u.Name == "" && !ok');
|
||||
expect(await labelAt(src, 'db.Create', 'go')).toBe('err == nil && !(u.Name == "" && !ok) && u.Age > 3');
|
||||
expect(await labelAt(src, 'db.Save', 'go')).toBe('err == nil && !(u.Name == "" && !ok) && !(u.Age > 3)');
|
||||
expect(await labelAt(src, 'q.Publish', 'go')).toBe('err == nil && u.Kind == "a"');
|
||||
expect(await labelAt(src, 'worker(u)', 'go')).toBe('err == nil');
|
||||
});
|
||||
it('reads the call as written, a composite literal as its type', async () => {
|
||||
expect(await siteAt(src, 'c.JSON(http.StatusBadRequest', 'go')).toMatchObject({ callee: 'c.JSON', args: 'http.StatusBadRequest, gin.H{…}' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('C', () => {
|
||||
const src = `
|
||||
int main(int argc, char **argv) {
|
||||
FILE *f = fopen(argv[1], "r");
|
||||
if (!f) { perror("open"); return 1; }
|
||||
if (argc > 2 && flag) fprintf(stderr, "x %d", argc);
|
||||
else exit(2);
|
||||
switch (argc) { case 1: fclose(f); break; default: break; }
|
||||
int x = argc ? read(fd, buf, 10) : 0;
|
||||
return 0;
|
||||
}
|
||||
`;
|
||||
it('reads the null-check guard, if/else, switch and the ternary', async () => {
|
||||
expect(await labelAt(src, 'perror(', 'c')).toBe('!f');
|
||||
expect(await labelAt(src, 'fprintf(', 'c')).toBe('f && argc > 2 && flag');
|
||||
expect(await labelAt(src, 'exit(2)', 'c')).toBe('f && !(argc > 2 && flag)');
|
||||
expect(await labelAt(src, 'fclose(f)', 'c')).toBe('f && argc == 1');
|
||||
expect(await labelAt(src, 'read(fd', 'c')).toBe('f && argc');
|
||||
});
|
||||
it('reads the call as written', async () => {
|
||||
expect(await siteAt(src, 'fprintf(', 'c')).toMatchObject({ callee: 'fprintf', args: 'stderr, "x %d", argc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('member types', () => {
|
||||
it('TypeScript: constructor parameter properties and typed fields', async () => {
|
||||
const src = `
|
||||
@Injectable()
|
||||
export class CatsService {
|
||||
private readonly log: Logger = new Logger()
|
||||
constructor(
|
||||
@InjectRepository(Cat) private readonly catsRepository: Repository<Cat>,
|
||||
private readonly mailer: MailerService,
|
||||
plain: string
|
||||
) {}
|
||||
async create(dto) {
|
||||
return this.catsRepository.save(dto)
|
||||
}
|
||||
}
|
||||
`;
|
||||
const types = await memberTypesInSource(src, 'typescript', lineOf(src, 'async create'));
|
||||
expect(Object.fromEntries(types)).toEqual({ log: 'Logger', catsRepository: 'Repository<Cat>', mailer: 'MailerService' });
|
||||
});
|
||||
it('Java: fields and constructor parameters', async () => {
|
||||
const src = `
|
||||
public class OwnerController {
|
||||
private final OwnerRepository owners;
|
||||
private VisitService visits;
|
||||
public OwnerController(OwnerRepository owners, Clock clock) { this.owners = owners; }
|
||||
public String create(Owner owner) { return owners.save(owner); }
|
||||
}
|
||||
`;
|
||||
const types = await memberTypesInSource(src, 'java', lineOf(src, 'public String create'));
|
||||
expect(Object.fromEntries(types)).toEqual({ owners: 'OwnerRepository', visits: 'VisitService', clock: 'Clock' });
|
||||
});
|
||||
it('Kotlin: the primary constructor and properties', async () => {
|
||||
const src = `
|
||||
class OwnerController(val owners: OwnerRepository, private val visits: VisitService, plain: String) {
|
||||
val clock: Clock = Clock.systemUTC()
|
||||
fun create(owner: Owner): String = owners.save(owner)
|
||||
}
|
||||
`;
|
||||
const types = await memberTypesInSource(src, 'kotlin', lineOf(src, 'fun create'));
|
||||
expect(Object.fromEntries(types)).toEqual({ owners: 'OwnerRepository', visits: 'VisitService', clock: 'Clock' });
|
||||
});
|
||||
it('C#: fields, properties and constructor parameters', async () => {
|
||||
const src = `
|
||||
public class OrderService : IOrderService {
|
||||
private readonly IRepository<Order> _orderRepository;
|
||||
public IEmailSender Mailer { get; }
|
||||
public OrderService(IRepository<Order> orderRepository, IUriComposer uriComposer) { _orderRepository = orderRepository; }
|
||||
public async Task Create(Order o) { await _orderRepository.AddAsync(o); }
|
||||
}
|
||||
`;
|
||||
const types = await memberTypesInSource(src, 'csharp', lineOf(src, 'public async Task Create'));
|
||||
expect(Object.fromEntries(types)).toEqual({ _orderRepository: 'IRepository<Order>', Mailer: 'IEmailSender', orderRepository: 'IRepository<Order>', uriComposer: 'IUriComposer' });
|
||||
});
|
||||
});
|
||||
@@ -203,8 +203,8 @@ func f() {
|
||||
|
||||
describe('branch guards: unsupported', () => {
|
||||
it('reports no guards for a language without rules', async () => {
|
||||
expect(supportsBranchGuards('python')).toBe(false);
|
||||
expect(await guardsInSource('def f():\n if x:\n go()\n', 'python', 3, 4)).toEqual([]);
|
||||
expect(supportsBranchGuards('ruby')).toBe(false);
|
||||
expect(await guardsInSource('def f\n if x\n go()\n end\nend\n', 'ruby', 3, 4)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,26 @@ import { describe, it, expect } from 'vitest';
|
||||
import { isTestFile } from '../src/search/query-utils';
|
||||
|
||||
describe('isTestFile', () => {
|
||||
it('flags test-support modules and doubles by directory name', () => {
|
||||
expect(isTestFile('core/data-test/src/main/kotlin/com/example/FakeUserDataRepository.kt')).toBe(true);
|
||||
expect(isTestFile('core/datastore-test/src/main/kotlin/com/example/InMemoryDataStore.kt')).toBe(true);
|
||||
expect(isTestFile('core/testing/src/main/kotlin/com/example/TestUserDataRepository.kt')).toBe(true);
|
||||
expect(isTestFile('pkg/testdata/fixture.go')).toBe(true);
|
||||
expect(isTestFile('src/__mocks__/api.ts')).toBe(true);
|
||||
expect(isTestFile('internal/testutil/helpers.go')).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT flag production code whose package path runs through a samples or examples segment', () => {
|
||||
// Only the project layout above `src/` decides; the package path below it never does.
|
||||
expect(isTestFile('core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/SyncUtilities.kt')).toBe(false);
|
||||
expect(isTestFile('feature/foryou/impl/src/main/kotlin/com/google/samples/apps/ForYouViewModel.kt')).toBe(false);
|
||||
expect(isTestFile('src/samples/demo.ts')).toBe(false);
|
||||
// …while a real examples folder in the layout still counts.
|
||||
expect(isTestFile('examples/basic/src/index.ts')).toBe(true);
|
||||
expect(isTestFile('packages/x/examples/basic.ts')).toBe(true);
|
||||
expect(isTestFile('benchmarks/run.py')).toBe(true);
|
||||
});
|
||||
|
||||
it('flags Kotlin test files and source sets', () => {
|
||||
expect(isTestFile('okhttp/src/jvmTest/kotlin/okhttp3/CallTest.kt')).toBe(true);
|
||||
expect(isTestFile('okhttp/src/commonTest/kotlin/okhttp3/CompressionInterceptorTest.kt')).toBe(true);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* The effects table (`src/ui-server/api/effects.ts`): what a call is when it
|
||||
* leaves the index, by the call as written, per language; the model and the
|
||||
* access a database call names; the status a response site sends.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyEffect, responseStatus } from '../src/ui-server/api/effects';
|
||||
|
||||
const c = (text: string, language?: string, extra: Partial<Parameters<typeof classifyEffect>[0]> = {}) =>
|
||||
classifyEffect({ text, kind: 'calls', language: language as never, project: 'api', ...extra });
|
||||
const n = (text: string, language?: string, extra: Partial<Parameters<typeof classifyEffect>[0]> = {}) =>
|
||||
classifyEffect({ text, kind: 'instantiates', language: language as never, project: 'api', ...extra });
|
||||
|
||||
describe('classifyEffect', () => {
|
||||
it('keeps the mobile app’s categories, with or without a language', () => {
|
||||
expect(c('client.post')?.category).toBe('network');
|
||||
expect(c('fetch', 'tsx')?.category).toBe('network');
|
||||
expect(c('AsyncStorage.setItem', 'tsx')?.category).toBe('storage');
|
||||
expect(c('Linking.openURL', 'tsx')?.category).toBe('device');
|
||||
expect(c('DdRum.addAction', 'tsx')?.category).toBe('telemetry');
|
||||
expect(c('Math.max', 'tsx')).toBeNull();
|
||||
expect(c('i18n.t', 'tsx')).toBeNull();
|
||||
expect(c('Object.create', 'typescript')).toBeNull();
|
||||
});
|
||||
|
||||
it('TypeScript servers: the database by the chain, the model and the access', () => {
|
||||
expect(c('prisma.article.findFirst', 'typescript')).toEqual({ category: 'database', model: 'article', access: 'read' });
|
||||
expect(c('this.prisma.user.create', 'typescript')).toEqual({ category: 'database', model: 'user', access: 'write' });
|
||||
expect(c('this.usersRepository.save', 'typescript')).toEqual({ category: 'database', model: 'users', access: 'write' });
|
||||
expect(c('this.catModel.find', 'typescript')).toEqual({ category: 'database', model: 'cat', access: 'read' });
|
||||
expect(c('db.insert', 'typescript')).toEqual({ category: 'database', access: 'write' });
|
||||
expect(c('knex', 'typescript', { args: "'users'" })).toBeNull();
|
||||
expect(c('User.findOne', 'typescript')).toEqual({ category: 'database', model: 'User', access: 'read' });
|
||||
expect(c('Promise.all', 'typescript')).toBeNull();
|
||||
});
|
||||
|
||||
it('TypeScript servers: responses, queues, email, payments, cache, auth', () => {
|
||||
expect(c('res.status(404).json', 'typescript')?.category).toBe('response');
|
||||
expect(c('res.json', 'typescript')?.category).toBe('response');
|
||||
expect(c('reply.code(201).send', 'typescript')?.category).toBe('response');
|
||||
expect(c('c.json', 'typescript')?.category).toBe('response');
|
||||
expect(n('NotFoundException', 'typescript')?.category).toBe('response');
|
||||
expect(n('UnprocessableEntityException', 'typescript')?.category).toBe('response');
|
||||
expect(n('HttpException', 'typescript')?.category).toBe('response');
|
||||
expect(n('Error', 'typescript')).toBeNull();
|
||||
expect(n('TypeError', 'typescript')).toBeNull();
|
||||
// In an app, an exception is an error, not a reply.
|
||||
expect(classifyEffect({ text: 'ValidationException', kind: 'instantiates', language: 'typescript', project: 'app' })).toBeNull();
|
||||
expect(c('this.emailQueue.add', 'typescript')?.category).toBe('queue');
|
||||
expect(c('queue.add', 'typescript')?.category).toBe('queue');
|
||||
expect(c('this.mailerService.sendMail', 'typescript')?.category).toBe('email');
|
||||
expect(c('resend.emails.send', 'typescript')?.category).toBe('email');
|
||||
expect(c('stripe.checkout.sessions.create', 'typescript')?.category).toBe('payments');
|
||||
expect(c('this.cacheManager.get', 'typescript')?.category).toBe('cache');
|
||||
expect(c('redis.setex', 'typescript')?.category).toBe('cache');
|
||||
expect(c('this.jwtService.signAsync', 'typescript')?.category).toBe('auth');
|
||||
expect(c('bcrypt.compare', 'typescript')?.category).toBe('auth');
|
||||
expect(c('jwt.verify', 'typescript')?.category).toBe('auth');
|
||||
expect(c('crypto.createHmac', 'typescript')?.category).toBe('auth');
|
||||
expect(c('crypto.createHash', 'typescript')).toBeNull();
|
||||
expect(c('crypto.randomBytes', 'typescript')).toBeNull();
|
||||
expect(c('s3.putObject', 'typescript')?.category).toBe('storage');
|
||||
expect(c('fs.writeFile', 'typescript')?.category).toBe('storage');
|
||||
expect(c('spawn', 'typescript')?.category).toBe('process');
|
||||
expect(c('process.exit', 'typescript')?.category).toBe('process');
|
||||
});
|
||||
|
||||
it('Python: SQLAlchemy / Django, FastAPI / Flask / Django responses, celery, files, processes', () => {
|
||||
expect(c('session.exec', 'python')).toEqual({ category: 'database', access: 'read' });
|
||||
expect(c('session.add', 'python')).toEqual({ category: 'database', access: 'write' });
|
||||
expect(c('session.commit', 'python')).toEqual({ category: 'database', access: 'write' });
|
||||
expect(c('User.objects.filter', 'python')).toEqual({ category: 'database', model: 'User', access: 'read' });
|
||||
expect(c('db.session.add', 'python')?.category).toBe('database');
|
||||
expect(c('HTTPException', 'python')?.category).toBe('response');
|
||||
expect(c('JSONResponse', 'python')?.category).toBe('response');
|
||||
expect(c('jsonify', 'python')?.category).toBe('response');
|
||||
expect(c('abort', 'python')?.category).toBe('response');
|
||||
expect(c('render', 'python')?.category).toBe('response');
|
||||
expect(c('send_email.delay', 'python')?.category).toBe('queue');
|
||||
expect(c('send_mail', 'python')?.category).toBe('email');
|
||||
expect(c('requests.post', 'python')?.category).toBe('network');
|
||||
expect(c('httpx.AsyncClient', 'python')?.category).toBe('network');
|
||||
expect(c('open', 'python')?.category).toBe('storage');
|
||||
expect(c('s3.upload_file', 'python')?.category).toBe('storage');
|
||||
expect(c('subprocess.run', 'python')?.category).toBe('process');
|
||||
expect(c('jwt.encode', 'python')?.category).toBe('auth');
|
||||
expect(c('pwd_context.verify', 'python')?.category).toBe('auth');
|
||||
expect(c('print', 'python')).toBeNull();
|
||||
expect(c('len', 'python')).toBeNull();
|
||||
expect(c('item.model_dump', 'python')).toBeNull();
|
||||
});
|
||||
|
||||
it('Java / Kotlin: repositories by name and by declared type, Spring responses, templates', () => {
|
||||
expect(c('owners.save', 'java', { receiverType: 'OwnerRepository' })).toEqual({ category: 'database', model: 'Owner', access: 'write' });
|
||||
expect(c('owners.findById', 'kotlin', { receiverType: 'OwnerRepository' })).toEqual({ category: 'database', model: 'Owner', access: 'read' });
|
||||
expect(c('this.ownerRepository.findAll', 'java')).toEqual({ category: 'database', model: 'owner', access: 'read' });
|
||||
expect(c('jdbcTemplate.update', 'java')?.category).toBe('database');
|
||||
expect(c('entityManager.persist', 'java')?.category).toBe('database');
|
||||
expect(c('ResponseEntity.ok', 'java')?.category).toBe('response');
|
||||
expect(c('ResponseEntity.status(HttpStatus.NOT_FOUND).body', 'java')?.category).toBe('response');
|
||||
expect(n('ResponseStatusException', 'java')?.category).toBe('response');
|
||||
expect(n('IllegalArgumentException', 'java')).toBeNull();
|
||||
expect(n('ResourceNotFoundException', 'java')?.category).toBe('response');
|
||||
expect(c('rabbitTemplate.convertAndSend', 'java')?.category).toBe('queue');
|
||||
expect(c('kafkaTemplate.send', 'java')?.category).toBe('queue');
|
||||
expect(c('applicationEventPublisher.publishEvent', 'java')?.category).toBe('queue');
|
||||
expect(c('mailSender.send', 'java')?.category).toBe('email');
|
||||
expect(c('restTemplate.getForObject', 'java')?.category).toBe('network');
|
||||
expect(c('webClient.get', 'java')?.category).toBe('network');
|
||||
expect(c('passwordEncoder.encode', 'java')?.category).toBe('auth');
|
||||
expect(c('redisTemplate.opsForValue', 'java')?.category).toBe('cache');
|
||||
expect(c('Files.write', 'java')?.category).toBe('storage');
|
||||
// Android: DataStore, SharedPreferences, Room DAOs, WorkManager.
|
||||
expect(c('userPreferences.updateData', 'kotlin')?.category).toBe('storage');
|
||||
expect(c('sharedPreferences.edit', 'kotlin')?.category).toBe('storage');
|
||||
expect(c('topicDao.upsertTopics', 'kotlin')).toEqual({ category: 'database', model: 'topic', access: 'write' });
|
||||
expect(c('workManager.enqueueUniqueWork', 'kotlin')?.category).toBe('queue');
|
||||
expect(c('viewModelScope.launch', 'kotlin')).toBeNull();
|
||||
expect(c('model.addAttribute', 'java')).toBeNull();
|
||||
expect(c('result.hasErrors', 'java')).toBeNull();
|
||||
expect(c('Objects.equals', 'java')).toBeNull();
|
||||
});
|
||||
|
||||
it('C#: EF Core / repositories, controller responses, MassTransit, Identity', () => {
|
||||
expect(c('_context.TodoItems.Add', 'csharp')).toEqual({ category: 'database', model: 'TodoItems', access: 'write' });
|
||||
expect(c('_context.SaveChangesAsync', 'csharp')).toEqual({ category: 'database', access: 'write' });
|
||||
expect(c('_orderRepository.AddAsync', 'csharp')).toEqual({ category: 'database', model: 'order', access: 'write' });
|
||||
expect(c('_basketRepository.FirstOrDefaultAsync', 'csharp')).toEqual({ category: 'database', model: 'basket', access: 'read' });
|
||||
expect(c('NotFound', 'csharp')?.category).toBe('response');
|
||||
expect(c('Ok', 'csharp')?.category).toBe('response');
|
||||
expect(c('TypedResults.NoContent', 'csharp')?.category).toBe('response');
|
||||
expect(c('Results.Created', 'csharp')?.category).toBe('response');
|
||||
expect(n('NotFoundException', 'csharp')?.category).toBe('response');
|
||||
expect(n('ArgumentNullException', 'csharp')).toBeNull();
|
||||
expect(c('_bus.Publish', 'csharp')?.category).toBe('queue');
|
||||
expect(c('_publishEndpoint.Publish', 'csharp')?.category).toBe('queue');
|
||||
expect(c('BackgroundJob.Enqueue', 'csharp')?.category).toBe('queue');
|
||||
expect(c('_emailSender.SendEmailAsync', 'csharp')?.category).toBe('email');
|
||||
expect(c('_httpClient.GetAsync', 'csharp')?.category).toBe('network');
|
||||
expect(c('_userManager.CreateAsync', 'csharp')?.category).toBe('auth');
|
||||
expect(c('_signInManager.PasswordSignInAsync', 'csharp')?.category).toBe('auth');
|
||||
expect(c('_cache.GetOrCreateAsync', 'csharp')?.category).toBe('cache');
|
||||
expect(c('File.ReadAllText', 'csharp')?.category).toBe('storage');
|
||||
expect(c('Guard.Against.Null', 'csharp')).toBeNull();
|
||||
expect(c('nameof', 'csharp')).toBeNull();
|
||||
expect(c('sender.Send', 'csharp')).toBeNull();
|
||||
});
|
||||
|
||||
it('Go: database/sql, gorm, gin responses, net/http, os', () => {
|
||||
expect(c('db.QueryRow', 'go')).toEqual({ category: 'database', access: 'read' });
|
||||
expect(c('db.Exec', 'go')).toEqual({ category: 'database', access: 'write' });
|
||||
expect(c('db.Create', 'go')?.category).toBe('database');
|
||||
expect(c('c.JSON', 'go')?.category).toBe('response');
|
||||
expect(c('c.AbortWithStatus', 'go')?.category).toBe('response');
|
||||
expect(c('http.Error', 'go')?.category).toBe('response');
|
||||
expect(c('w.WriteHeader', 'go')?.category).toBe('response');
|
||||
expect(c('http.Get', 'go')?.category).toBe('network');
|
||||
expect(c('client.Do', 'go')?.category).toBe('network');
|
||||
expect(c('os.ReadFile', 'go')?.category).toBe('storage');
|
||||
expect(c('exec.Command', 'go')?.category).toBe('process');
|
||||
expect(c('producer.Produce', 'go')?.category).toBe('queue');
|
||||
expect(c('jwt.NewWithClaims', 'go')?.category).toBe('auth');
|
||||
expect(c('fmt.Sprintf', 'go')).toBeNull();
|
||||
expect(c('errors.New', 'go')).toBeNull();
|
||||
});
|
||||
|
||||
it('C: files, sockets, processes', () => {
|
||||
expect(c('fopen', 'c')?.category).toBe('storage');
|
||||
expect(c('fprintf', 'c')?.category).toBe('storage');
|
||||
expect(c('write', 'c')?.category).toBe('storage');
|
||||
expect(c('socket', 'c')?.category).toBe('network');
|
||||
expect(c('connect', 'c')?.category).toBe('network');
|
||||
expect(c('curl_easy_perform', 'c')?.category).toBe('network');
|
||||
expect(c('fork', 'c')?.category).toBe('process');
|
||||
expect(c('exit', 'c')?.category).toBe('process');
|
||||
expect(c('pthread_create', 'c')?.category).toBe('process');
|
||||
expect(c('strlen', 'c')).toBeNull();
|
||||
expect(c('malloc', 'c')).toBeNull();
|
||||
expect(c('memcpy', 'c')).toBeNull();
|
||||
expect(c('serverLog', 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('Swift (Vapor), Ruby (Rails), PHP (Laravel)', () => {
|
||||
expect(c('Abort', 'swift')?.category).toBe('response');
|
||||
expect(c('Todo.query', 'swift')).toEqual({ category: 'database', model: 'Todo', access: 'read' });
|
||||
expect(c('todo.save', 'swift')?.category).toBe('database');
|
||||
expect(c('URLSession.shared.dataTask', 'swift')?.category).toBe('network');
|
||||
expect(c('render', 'ruby')?.category).toBe('response');
|
||||
expect(c('redirect_to', 'ruby')?.category).toBe('response');
|
||||
expect(c('User.find_by', 'ruby')).toEqual({ category: 'database', model: 'User', access: 'read' });
|
||||
expect(c('@user.save', 'ruby')?.category).toBe('database');
|
||||
expect(c('UserMailer.welcome', 'ruby')?.category).toBe('email');
|
||||
expect(c('HardJob.perform_later', 'ruby')?.category).toBe('queue');
|
||||
expect(c('User::find', 'php')).toEqual({ category: 'database', model: 'User', access: 'read' });
|
||||
expect(c('DB::table', 'php')?.category).toBe('database');
|
||||
expect(c('abort', 'php')?.category).toBe('response');
|
||||
expect(c('Mail::to', 'php')?.category).toBe('email');
|
||||
});
|
||||
|
||||
it('a language without rows for a family stays quiet', () => {
|
||||
expect(c('foo.bar', 'ruby')).toBeNull();
|
||||
expect(c('save', 'python')).toBeNull();
|
||||
expect(c('render', 'java')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('responseStatus', () => {
|
||||
it('reads the literal code out of the chain, the arguments, or the name', () => {
|
||||
expect(responseStatus('res.status(404).json', '{ error }')).toBe(404);
|
||||
expect(responseStatus('res.status', '404')).toBe(404);
|
||||
expect(responseStatus('res.sendStatus', '204')).toBe(204);
|
||||
expect(responseStatus('res.json', '{ user }')).toBeNull();
|
||||
expect(responseStatus('reply.code(201).send', 'user')).toBe(201);
|
||||
expect(responseStatus('res.redirect', "'/login'")).toBe(302);
|
||||
expect(responseStatus('NotFoundException', "'no such user'", 'instantiates')).toBe(404);
|
||||
expect(responseStatus('UnprocessableEntityException', '{ errors }', 'instantiates')).toBe(422);
|
||||
expect(responseStatus('HttpException', "'x', HttpStatus.FORBIDDEN", 'instantiates')).toBe(403);
|
||||
expect(responseStatus('HttpException', "'x', 418", 'instantiates')).toBe(418);
|
||||
expect(responseStatus('HTTPException', 'status_code=404, detail="no title"')).toBe(404);
|
||||
expect(responseStatus('abort', '404')).toBe(404);
|
||||
expect(responseStatus('JsonResponse', '{ "error" }, status=400')).toBe(400);
|
||||
expect(responseStatus('Http404', '')).toBe(404);
|
||||
expect(responseStatus('ResponseEntity.ok', 'body')).toBe(200);
|
||||
expect(responseStatus('ResponseEntity.notFound().build', '')).toBe(404);
|
||||
expect(responseStatus('ResponseEntity.status(HttpStatus.CREATED).body', 'saved')).toBe(201);
|
||||
expect(responseStatus('ResponseStatusException', 'HttpStatus.NOT_FOUND, "x"', 'instantiates')).toBe(404);
|
||||
expect(responseStatus('ResponseEntity', 'body, HttpStatus.CREATED', 'instantiates')).toBe(201);
|
||||
expect(responseStatus('NotFound', '')).toBe(404);
|
||||
expect(responseStatus('Ok', 'item')).toBe(200);
|
||||
expect(responseStatus('CreatedAtAction', 'nameof(Get), item')).toBe(201);
|
||||
expect(responseStatus('TypedResults.NoContent', '')).toBe(204);
|
||||
expect(responseStatus('StatusCode', '500')).toBe(500);
|
||||
expect(responseStatus('Results.Problem', '')).toBe(500);
|
||||
expect(responseStatus('c.JSON', 'http.StatusCreated, u')).toBe(201);
|
||||
expect(responseStatus('c.String', '200, "ok"')).toBe(200);
|
||||
expect(responseStatus('http.Error', 'w, msg, http.StatusInternalServerError')).toBe(500);
|
||||
expect(responseStatus('w.WriteHeader', 'http.StatusNotFound')).toBe(404);
|
||||
expect(responseStatus('c.AbortWithStatus', '404')).toBe(404);
|
||||
expect(responseStatus('Abort', '.notFound')).toBe(404);
|
||||
expect(responseStatus('Abort', '.badRequest, reason: "x"')).toBe(400);
|
||||
expect(responseStatus('redirect_to', 'root_path')).toBe(302);
|
||||
expect(responseStatus('render', 'json: user, status: :created')).toBeNull();
|
||||
expect(responseStatus('res.status', 'code')).toBeNull();
|
||||
expect(responseStatus('res.json', '')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* `GET /api/steps` on servers: an Express API, a NestJS API, a FastAPI service
|
||||
* and a Spring controller, in one indexed fixture, shaped to cross every
|
||||
* boundary an endpoint's picture has — the request and what runs before the
|
||||
* handler, the database, a queue, an email, and the responses with their
|
||||
* status codes. Mirrors `ui-steps-api.test.ts` (the mobile app).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
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 { routeRoots } from '../src/ui-server/api/route-roots';
|
||||
|
||||
let tmpDir: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
function write(rel: string, content: string): void {
|
||||
const full = path.join(tmpDir, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await initGrammars();
|
||||
await loadAllGrammars();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-steps-servers-'));
|
||||
write(
|
||||
'package.json',
|
||||
JSON.stringify({ name: 'api', dependencies: { express: '4', '@nestjs/core': '10', '@nestjs/common': '10', bullmq: '5', '@prisma/client': '5', typeorm: '0.3' } })
|
||||
);
|
||||
// ---- Express: a named handler behind middleware, and an inline handler.
|
||||
write('src/server/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
|
||||
write('src/server/queue.ts', "import { Queue } from 'bullmq'\nexport const emailQueue = new Queue('email')\n");
|
||||
write('src/server/errors.ts', 'export class NotFoundError extends Error {}\n');
|
||||
write('src/server/auth.ts', 'export function authenticate(req, res, next) {\n next()\n}\n');
|
||||
write('src/server/validate.ts', 'export function validate(schema) {\n return (req, res, next) => next()\n}\n');
|
||||
write(
|
||||
'src/server/users.service.ts',
|
||||
"import { prisma } from './db'\n" +
|
||||
"import { emailQueue } from './queue'\n" +
|
||||
"import { NotFoundError } from './errors'\n" +
|
||||
'export async function createUser(req, res) {\n' +
|
||||
' const user = await prisma.user.create({ data: { email: req.body.email, name: req.body.name } })\n' +
|
||||
" await emailQueue.add('welcome', { userId: user.id })\n" +
|
||||
' if (!user.verified) {\n' +
|
||||
' await sendVerification(user)\n' +
|
||||
' }\n' +
|
||||
' res.status(201).json(user)\n' +
|
||||
'}\n' +
|
||||
'export async function getUser(id: string) {\n' +
|
||||
' const user = await prisma.user.findUnique({ where: { id } })\n' +
|
||||
" if (!user) throw new NotFoundError('no such user')\n" +
|
||||
' return user\n' +
|
||||
'}\n' +
|
||||
'async function sendVerification(user) {\n' +
|
||||
' await transporter.sendMail({ to: user.email })\n' +
|
||||
'}\n'
|
||||
);
|
||||
write(
|
||||
'src/server/users.routes.ts',
|
||||
"import { Router } from 'express'\n" +
|
||||
"import { authenticate } from './auth'\n" +
|
||||
"import { validate } from './validate'\n" +
|
||||
"import { createUser, getUser } from './users.service'\n" +
|
||||
'const router = Router()\n' +
|
||||
"router.post('/users', authenticate, validate(userSchema), createUser)\n" +
|
||||
"router.get('/users/:id', authenticate, async (req, res) => {\n" +
|
||||
' const user = await getUser(req.params.id)\n' +
|
||||
' res.json(user)\n' +
|
||||
'})\n' +
|
||||
'export default router\n'
|
||||
);
|
||||
// ---- NestJS: guards on the class and the method, DI into a service, a queue consumer.
|
||||
write(
|
||||
'src/nest/cats.service.ts',
|
||||
"import { Injectable } from '@nestjs/common'\n" +
|
||||
"import { InjectRepository } from '@nestjs/typeorm'\n" +
|
||||
"import { Repository } from 'typeorm'\n" +
|
||||
"import { InjectQueue } from '@nestjs/bullmq'\n" +
|
||||
"import { Queue } from 'bullmq'\n" +
|
||||
"import { Cat } from './cat.entity'\n" +
|
||||
'@Injectable()\n' +
|
||||
'export class CatsService {\n' +
|
||||
' constructor(\n' +
|
||||
' @InjectRepository(Cat) private readonly catsRepository: Repository<Cat>,\n' +
|
||||
" @InjectQueue('cats') private readonly catsQueue: Queue\n" +
|
||||
' ) {}\n' +
|
||||
' async create(dto) {\n' +
|
||||
' const cat = await this.catsRepository.save(dto)\n' +
|
||||
" await this.catsQueue.add('index', { id: cat.id })\n" +
|
||||
' return cat\n' +
|
||||
' }\n' +
|
||||
' async findOne(id: string) {\n' +
|
||||
' return this.catsRepository.findOne({ where: { id } })\n' +
|
||||
' }\n' +
|
||||
'}\n'
|
||||
);
|
||||
write('src/nest/cat.entity.ts', "import { Entity } from 'typeorm'\n@Entity()\nexport class Cat {\n id: string\n}\n");
|
||||
write(
|
||||
'src/nest/cats.controller.ts',
|
||||
"import { Controller, Get, Post, Body, Param, UseGuards, NotFoundException } from '@nestjs/common'\n" +
|
||||
"import { AuthGuard } from '@nestjs/passport'\n" +
|
||||
"import { CatsService } from './cats.service'\n" +
|
||||
"import { RolesGuard } from './roles.guard'\n" +
|
||||
"@Controller('cats')\n" +
|
||||
"@UseGuards(AuthGuard('jwt'))\n" +
|
||||
'export class CatsController {\n' +
|
||||
' constructor(private readonly catsService: CatsService) {}\n' +
|
||||
' @Post()\n' +
|
||||
' @UseGuards(RolesGuard)\n' +
|
||||
' async create(@Body() dto: CreateCatDto) {\n' +
|
||||
' return this.catsService.create(dto)\n' +
|
||||
' }\n' +
|
||||
" @Get(':id')\n" +
|
||||
" async findOne(@Param('id') id: string) {\n" +
|
||||
' const cat = await this.catsService.findOne(id)\n' +
|
||||
" if (!cat) throw new NotFoundException('no cat')\n" +
|
||||
' return cat\n' +
|
||||
' }\n' +
|
||||
'}\n'
|
||||
);
|
||||
write('src/nest/roles.guard.ts', "import { Injectable } from '@nestjs/common'\n@Injectable()\nexport class RolesGuard {\n canActivate() { return true }\n}\n");
|
||||
write(
|
||||
'src/nest/cats.processor.ts',
|
||||
"import { Processor, Process } from '@nestjs/bull'\n" +
|
||||
"@Processor('cats')\n" +
|
||||
'export class CatsProcessor {\n' +
|
||||
" @Process('index')\n" +
|
||||
' async handleIndex(job) {\n' +
|
||||
' await searchClient.index(job.data)\n' +
|
||||
' }\n' +
|
||||
'}\n'
|
||||
);
|
||||
// ---- FastAPI: a dependency on the route, SQLModel, an HTTPException, a Celery task.
|
||||
write(
|
||||
'api/items.py',
|
||||
'from fastapi import APIRouter, Depends, HTTPException\n' +
|
||||
'from sqlmodel import select\n' +
|
||||
'from .deps import get_current_user, SessionDep\n' +
|
||||
'from .models import Item, ItemCreate\n' +
|
||||
'from .tasks import send_welcome\n' +
|
||||
'\n' +
|
||||
'router = APIRouter()\n' +
|
||||
'\n' +
|
||||
'@router.post("/items", dependencies=[Depends(get_current_user)])\n' +
|
||||
'def create_item(session: SessionDep, item_in: ItemCreate):\n' +
|
||||
' item = Item.model_validate(item_in)\n' +
|
||||
' session.add(item)\n' +
|
||||
' session.commit()\n' +
|
||||
' if item.price < 0:\n' +
|
||||
' raise HTTPException(status_code=422, detail="bad price")\n' +
|
||||
' send_welcome.delay(item.id)\n' +
|
||||
' return item\n'
|
||||
);
|
||||
write('api/deps.py', 'def get_current_user():\n return None\n\nSessionDep = None\n');
|
||||
write('api/models.py', 'class Item:\n pass\n\nclass ItemCreate:\n pass\n');
|
||||
write('api/tasks.py', 'from celery import shared_task\n\n@shared_task\ndef send_welcome(item_id):\n return item_id\n');
|
||||
write('api/main.py', 'from fastapi import FastAPI\nfrom .items import router\napp = FastAPI()\napp.include_router(router)\n');
|
||||
write('requirements.txt', 'fastapi\nsqlmodel\ncelery\n');
|
||||
// ---- Spring: a repository typed on a field, ResponseEntity replies, a guard annotation.
|
||||
write(
|
||||
'src/main/java/demo/OwnerController.java',
|
||||
'package demo;\n' +
|
||||
'import org.springframework.web.bind.annotation.*;\n' +
|
||||
'import org.springframework.http.*;\n' +
|
||||
'@RestController\n' +
|
||||
'@RequestMapping("/owners")\n' +
|
||||
'public class OwnerController {\n' +
|
||||
' private final OwnerRepository owners;\n' +
|
||||
' public OwnerController(OwnerRepository owners) { this.owners = owners; }\n' +
|
||||
' @PostMapping("/new")\n' +
|
||||
' @PreAuthorize("hasRole(\'ADMIN\')")\n' +
|
||||
' public ResponseEntity<Owner> create(@RequestBody Owner owner) {\n' +
|
||||
' if (owner.getName() == null) {\n' +
|
||||
' return ResponseEntity.badRequest().build();\n' +
|
||||
' }\n' +
|
||||
' Owner saved = owners.save(owner);\n' +
|
||||
' return ResponseEntity.status(HttpStatus.CREATED).body(saved);\n' +
|
||||
' }\n' +
|
||||
'}\n'
|
||||
);
|
||||
write(
|
||||
'src/main/java/demo/OwnerRepository.java',
|
||||
'package demo;\nimport org.springframework.data.jpa.repository.JpaRepository;\npublic interface OwnerRepository extends JpaRepository<Owner, Integer> {\n}\n'
|
||||
);
|
||||
write('src/main/java/demo/Owner.java', 'package demo;\npublic class Owner {\n private String name;\n public String getName() { return name; }\n}\n');
|
||||
cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const q = (params: Record<string, string>) => new URLSearchParams(params);
|
||||
const route = (name: string) => {
|
||||
const r = cg.getNodesByKind('route').find((r) => r.name === name);
|
||||
if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
|
||||
return r;
|
||||
};
|
||||
const effect = (p: Awaited<ReturnType<typeof buildSteps>>, category: string) => p.steps.find((s) => s.kind === 'effect' && s.effect?.category === category);
|
||||
|
||||
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'));
|
||||
expect(roots.get(route('POST /users').id)).toMatchObject({ inline: false, node: { name: 'createUser' } });
|
||||
expect(roots.get(route('GET /users/:id').id)).toMatchObject({ inline: true });
|
||||
expect(roots.get(route('POST /cats').id)?.node.qualifiedName).toContain('CatsController');
|
||||
expect(roots.get(route('POST /items').id)?.node.name).toBe('create_item');
|
||||
expect(roots.get(route('POST /owners/new').id)?.node.name).toBe('create');
|
||||
});
|
||||
it('calls the project an API', () => {
|
||||
expect(projectKind(cg.getNodesByKind('route'), 0)).toBe('api');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Express', () => {
|
||||
it('draws the handler’s database write, the queue job, the email, and the 201 — after the middleware', async () => {
|
||||
const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /users').id }));
|
||||
expect(p.project).toBe('api');
|
||||
const anchor = p.steps.find((s) => s.anchor)!;
|
||||
expect(anchor.kind).toBe('screen');
|
||||
expect(anchor.sub).toBe('createUser');
|
||||
expect(anchor.screen).toMatchObject({ path: 'POST /users', endpoint: true, inline: false, component: { name: 'createUser' } });
|
||||
expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/users', in: 'users.routes.ts', after: ['authenticate', 'validate(…)'] });
|
||||
|
||||
const db = effect(p, 'database')!;
|
||||
expect(db.label).toBe('prisma.user.create({ data })');
|
||||
expect(db.effect).toMatchObject({ model: 'user', access: 'write', by: { name: 'createUser' } });
|
||||
expect(db.sub).toBe('database · user · write · createUser');
|
||||
const queue = effect(p, 'queue')!;
|
||||
expect(queue.label).toBe("emailQueue.add('welcome', { userId })");
|
||||
const mail = effect(p, 'email')!;
|
||||
expect(mail.label).toBe('transporter.sendMail({ to })');
|
||||
const mailLink = p.links.find((l) => l.to === mail.id)!;
|
||||
expect(mailLink.via.map((v) => v.name)).toEqual(['sendVerification']);
|
||||
expect(mailLink.when).toBe('!user.verified');
|
||||
const res = effect(p, 'response')!;
|
||||
expect(res.label).toBe('201');
|
||||
expect(res.effect?.statuses).toEqual([201]);
|
||||
const resLink = p.links.find((l) => l.to === res.id)!;
|
||||
expect(resLink.sites[0]).toMatchObject({ text: 'res.status(201).json', args: 'user', status: 201 });
|
||||
});
|
||||
|
||||
it('walks an inline handler as the route itself, into the service’s read and its 404', async () => {
|
||||
const p = await buildSteps(cg, tmpDir, q({ anchor: route('GET /users/:id').id }));
|
||||
const anchor = p.steps.find((s) => s.anchor)!;
|
||||
expect(anchor.sub).toBe('inline handler · users.routes.ts');
|
||||
expect(anchor.trigger).toMatchObject({ kind: 'request', name: 'GET', of: '/users/:id', after: ['authenticate'] });
|
||||
const db = effect(p, 'database')!;
|
||||
expect(db.effect).toMatchObject({ model: 'user', access: 'read', by: { name: 'getUser' } });
|
||||
const res = effect(p, 'response')!;
|
||||
expect(res.label).toBe('404');
|
||||
const resLink = p.links.find((l) => l.to === res.id)!;
|
||||
expect(resLink.sites[0]).toMatchObject({ text: 'NotFoundError', status: 404, when: '!user' });
|
||||
expect(resLink.via.map((v) => v.name)).toEqual(['getUser']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NestJS', () => {
|
||||
it('reads the guards on the class and the method, follows DI into the repository and the queue', async () => {
|
||||
const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /cats').id }));
|
||||
const anchor = p.steps.find((s) => s.anchor)!;
|
||||
expect(anchor.sub).toBe('create');
|
||||
expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/cats', in: 'cats.controller.ts', after: ["UseGuards(AuthGuard('jwt'))", 'UseGuards(RolesGuard)'] });
|
||||
const db = effect(p, 'database')!;
|
||||
expect(db.label).toBe('this.catsRepository.save(dto)');
|
||||
expect(db.effect).toMatchObject({ model: 'cats', access: 'write', by: { name: 'create' } });
|
||||
const dbLink = p.links.find((l) => l.to === db.id)!;
|
||||
expect(dbLink.via.map((v) => v.name)).toEqual(['create']);
|
||||
const queue = effect(p, 'queue')!;
|
||||
expect(queue.label).toBe("this.catsQueue.add('index', { id })");
|
||||
});
|
||||
|
||||
it('a thrown exception is the 404 the request gets', async () => {
|
||||
const p = await buildSteps(cg, tmpDir, q({ anchor: route('GET /cats/:id').id }));
|
||||
const res = effect(p, 'response')!;
|
||||
expect(res.label).toBe('404');
|
||||
const resLink = p.links.find((l) => l.to === res.id)!;
|
||||
expect(resLink.sites[0]).toMatchObject({ text: 'NotFoundException', args: "'no cat'", status: 404, when: '!cat' });
|
||||
expect(effect(p, 'database')?.effect).toMatchObject({ access: 'read' });
|
||||
});
|
||||
|
||||
it('a queue consumer says the job that fires it', async () => {
|
||||
const p = await buildSteps(cg, tmpDir, q({ symbol: 'handleIndex' }));
|
||||
expect(p.steps.find((s) => s.anchor)?.trigger).toEqual({ kind: 'decorator', name: 'Process', of: "'index'", in: 'cats.processor.ts' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FastAPI', () => {
|
||||
it('reads the dependency on the route, the session writes, the 422 and the Celery task', async () => {
|
||||
const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /items').id }));
|
||||
const anchor = p.steps.find((s) => s.anchor)!;
|
||||
expect(anchor.sub).toBe('create_item');
|
||||
expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/items', in: 'items.py', after: ['Depends(get_current_user)'] });
|
||||
const db = effect(p, 'database')!;
|
||||
expect(db.effect?.apis).toEqual(['session.add', 'session.commit']);
|
||||
expect(db.effect).toMatchObject({ access: 'write' });
|
||||
const res = effect(p, 'response')!;
|
||||
expect(res.label).toBe('422');
|
||||
const resLink = p.links.find((l) => l.to === res.id)!;
|
||||
expect(resLink.sites[0]).toMatchObject({ text: 'HTTPException', args: 'status_code=422, detail="bad price"', status: 422, when: 'item.price < 0' });
|
||||
const queue = effect(p, 'queue')!;
|
||||
expect(queue.label).toBe('send_welcome.delay(item.id)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spring', () => {
|
||||
it('types the repository off the field, reads the annotation guard, and both replies with their codes', async () => {
|
||||
const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /owners/new').id }));
|
||||
const anchor = p.steps.find((s) => s.anchor)!;
|
||||
expect(anchor.sub).toBe('create');
|
||||
expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/owners/new', in: 'OwnerController.java', after: ["PreAuthorize(\"hasRole('ADMIN')\")"] });
|
||||
const db = effect(p, 'database')!;
|
||||
expect(db.label).toBe('owners.save(owner)');
|
||||
expect(db.effect).toMatchObject({ model: 'Owner', access: 'write' });
|
||||
const dbLink = p.links.find((l) => l.to === db.id)!;
|
||||
expect(dbLink.when).toBe('owner.getName() != null');
|
||||
const res = effect(p, 'response')!;
|
||||
expect(res.label).toBe('201 · 400');
|
||||
const rows = p.links.find((l) => l.to === res.id)!.sites.map((s) => [s.status, s.when]);
|
||||
expect(rows).toEqual([
|
||||
[400, 'owner.getName() == null'],
|
||||
[201, 'owner.getName() != null'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* rule, and the panel's two lists.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildStepsModel, kindWord, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model';
|
||||
import { buildStepsModel, countWords, kindWord, kindWords, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model';
|
||||
import { placeLabels } from '../ui/src/lib/screens-model';
|
||||
import type { WireNodeRef, WireStep, WireStepLink, WireStepsPayload } from '../ui/src/lib/wire';
|
||||
|
||||
@@ -25,6 +25,7 @@ function payload(steps: WireStep[], links: WireStepLink[]): WireStepsPayload {
|
||||
return {
|
||||
anchor: steps[0]!.node!,
|
||||
ambiguous: [],
|
||||
project: 'app',
|
||||
steps,
|
||||
links,
|
||||
depth: 8,
|
||||
@@ -115,3 +116,24 @@ describe('steps model', () => {
|
||||
expect(lists.leadsTo.map((l) => l.to)).toEqual([effect.id, store.id, home.id, store.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('words per project', () => {
|
||||
it('names the same box for an app, an API and a web app', () => {
|
||||
expect(kindWord('screen', 'app')).toBe('screen');
|
||||
expect(kindWord('screen', 'api')).toBe('endpoint');
|
||||
expect(kindWord('screen', 'web')).toBe('page');
|
||||
// A route that leads with a verb is an endpoint wherever it is.
|
||||
const endpoint = { id: 'r', kind: 'screen', anchor: false, node: null, label: 'POST /users', sub: 'createUser', depth: 1, cut: null, screen: { path: 'POST /users', component: null, endpoint: true, inline: false } } as const;
|
||||
expect(kindWord('screen', 'web', endpoint)).toBe('endpoint');
|
||||
expect(kindWords('store', 'api')).toEqual(['data call', 'data calls']);
|
||||
expect(kindWords('bridge', 'app')).toEqual(['native call', 'native calls']);
|
||||
expect(countWords(11, 'effect', 'api')).toBe('11 outside the index');
|
||||
expect(countWords(1, 'trigger')).toBe('1 handler');
|
||||
expect(countWords(3, 'trigger')).toBe('3 handlers');
|
||||
});
|
||||
it('says what fires a server-side step', () => {
|
||||
expect(triggerWords({ kind: 'request', name: 'POST', of: '/users', in: 'users.routes.ts', after: ['authenticate', 'validate(…)'] })).toBe('POST /users · after authenticate, validate(…)');
|
||||
expect(triggerWords({ kind: 'decorator', name: 'Process', of: "'email'", in: 'x.ts' })).toBe("@Process('email')");
|
||||
expect(triggerWords({ kind: 'load', name: 'GET', of: '/blog/[slug]', in: 'page.tsx' })).toBe('page load · /blog/[slug]');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user