Răsfoiți Sursa

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
Colby McHenry 1 săptămână în urmă
părinte
comite
950686def4

+ 12 - 0
CHANGELOG.md

@@ -14,12 +14,24 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- **The Steps tab now draws an API as well as an app.** Anchor on an endpoint — `POST /users` in Express, NestJS, Fastify, FastAPI, Flask, Django, Spring (Java or Kotlin), ASP.NET, Vapor or Gin — and the viewer starts at the handler the route runs (or at the route itself when the handler is an inline arrow), says what fires it (`FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the registration, or the guard decorators on the method and its class, or a FastAPI `dependencies=[…]`), and draws what the request sets in motion: the database calls with the model and whether they read or write (`prisma.user.create({ data })` · `database · user · write`), jobs put on a queue, emails, payments, cache reads, token checks, calls to other services, files and processes — and the **responses**, one box per handler whose label is the status codes it can send (`201 · 404`) and whose panel rows are the endpoint's contract as the code has it: `WHEN NOT user → 404 · NotFoundException('no such user')`, `always → 201 · res.status(201).json(user)`. A queue consumer or a scheduled job anchored by name says the decorator that fires it (`@Process('email')`). The legend, the panel and the chooser use the project's own words — endpoint, data call, another tier — and the bare Steps tab lists an API's endpoints by router file when there are no screens. Re-index is not needed: everything new is read from the source at request time.
+
+- **Calls are read as written, so the database is the database.** The index keeps only the last segment of a deep member call (`create` for `prisma.user.create`), and a bare name matches by name alone — often to the wrong `create`. The Steps walk now reads each call from the source as written, classifies `prisma.user.create`, `this.usersRepository.save`, `session.commit`, `owners.save` and `_context.TodoItems.Add` by the whole chain and by the receiver's declared type (`OwnerRepository owners`, `private readonly usersService: UsersService`, `val owners: OwnerRepository`, read from the class body), and follows `this.usersService.findByEmail(…)` into the class the type names instead of the name-only guess. A hop resolved this way says so in the panel.
+
+- **Conditions and arguments for Python, Java, Kotlin, C#, Go and C.** The `when` on a call — `if` / `elif` / `else`, `switch` / `when` / `match`, the ternary and Kotlin's `if` expression, `try` / `except` / `catch`, `and` / `or`, and the early exits before it (`if err != nil { return }` reads as `err == nil`, `if not item.title: raise` as `item.title`) — and what each call passes (`HTTPException(status_code=422, detail="bad price")`, `c.JSON(http.StatusCreated, gin.H{…})`) are now read for those languages too, in `codegraph ui`'s rails, Flow strip and Steps tab and in `codegraph_explore`'s Flow section. As before: read from the source as it stands, never stored, and a language without rules yields nothing rather than a wrong label.
+
 - **A Steps tab in `codegraph ui` — what happens from here.** Pick a screen (or search any symbol and choose *What happens from here*) and the viewer draws everything it sets in motion as typed steps: the handlers wired to its taps and listeners, the calls that cross into native code, the native events that come back, the store actions it writes, and the calls that leave the app into the network, storage, the device or telemetry — one box per step, an arrow for every way one leads to the next, and on each arrow the condition under which it happens. The plumbing between two steps (hooks, helpers, the components in between) is folded into the arrow and listed in the side panel, exactly as the Screens tab folds a tap's chain into one transition — and every call the panel lists says what it passes, read from the source as written (`SecureStore.setItemAsync('userEmail', values.email)`, `axios.post('/auth/login', { email, password })`), so a step is not just *that* something was stored or sent but *what*. And each handler says what fires it — the JSX prop and its element (`onPress · <Button>`), the option it is written under (`onSubmit · useFormik(…)`), the listener or effect it runs from — read from the source at the call site, so `onPress={() => handleLogin(values)}` and Formik's `onSubmit` make `handleLogin` a step of its own with the event on the arrow into it. Any step is the next anchor, any link opens as a Flow strip, a cap the walk hit is announced on the step it hit it at, and the picture travels in the URL. React Native + Expo apps get the full picture today; any project gets handlers, stores and calls that leave the index.
 
 - **React Native apps: Swift native modules and their events connect end to end.** A JS call like `captureView.finalizeCaptureSession()` — where `captureView` is bound to `NativeModules.CaptureView` and the module is a Swift class exposed through an `RCT_EXTERN_MODULE` shim — now resolves to the Swift method itself instead of stopping at the constant, so `codegraph_explore`, the Flow strip and the Steps view follow the code into native. Native → JS events now also land on listeners written inline (`addListener('onZipComplete', (data) => { … })`), attributed to the component that registers them. Re-index after upgrading to pick the new edges up.
 
 ### Fixes
 
+- **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does.
+
+- **A FastAPI service that lives in one directory of a monorepo is detected.** `backend/pyproject.toml` and `backend/app/main.py` count, not only files at the repository root — the official full-stack template's routes now appear in Entry points and the Steps tab.
+
+- **The Steps walk stays out of test doubles.** A production handler that calls an interface method is no longer followed into `TestUserDataRepository` (or any implementation in a test folder); the counts in the summary no longer say "outside the indexs".
+
 - **React handlers written with `useCallback` are now symbols.** `const handleSubmit = useCallback(() => {…}, [])` — the way nearly every handler in a React or React Native component is written — is extracted as a function named by its binding (also `React.useCallback`, `useEffectEvent`), so `onPress={handleSubmit}` and `addListener('x', handleSubmit)` resolve to it, its calls are its own rather than the component's, and a tap's handler shows up in `codegraph_explore`, the Screens tab and the Steps tab. A JSX attribute value (`onPress={handleSubmit}`, `renderItem={renderRow}`) and a handler a hook hands back in an object (`return { handleSubmit, handleRetake }`) are now function-as-value references from the component or hook, so the graph knows which functions are wired as handlers.
 
 - **Stores exported on a later line are read like any other.** `const useStore = create((set, get) => ({ … }))` followed by `export default useStore` (or `export { useStore }`) now has its actions extracted as functions, the same as an `export const` store — previously the two-statement form, common in React Native apps, left every action invisible.

+ 1 - 1
CLAUDE.md

@@ -86,7 +86,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the
 - `src/installer/` — see below.
 - `src/bin/codegraph.ts` — CLI (commander). Subcommands: `install`, `init`, `uninit`, `index`, `sync`, `status`, `query`, `files`, `context`, `affected`, `serve --mcp`.
 - `src/ui/` — terminal UI (shimmer progress, worker).
-- `src/ui-server/` — the `codegraph ui` browser viewer's read-only JSON API (`api/`: one module per endpoint — `node`, `flow`, `map`, `screens`, `steps`, `deadcode`, `trails`…) and static server; the Svelte viewer itself lives in `ui/` (see `docs/design/codegraph-ui-design-spec.md`). `api/screens.ts` (the app as screens and transitions) and `api/steps.ts` (what happens from a screen or a symbol, as typed steps — screens, handlers, native bridge calls and events, store actions, calls that leave the index) share one fold: everything between two boxes is `via`, and the branch guards along it join into `when` (`graph/branch-guards.ts`, read at request time).
+- `src/ui-server/` — the `codegraph ui` browser viewer's read-only JSON API (`api/`: one module per endpoint — `node`, `flow`, `map`, `screens`, `steps`, `deadcode`, `trails`…) and static server; the Svelte viewer itself lives in `ui/` (see `docs/design/codegraph-ui-design-spec.md`). `api/screens.ts` (the app as screens and transitions) and `api/steps.ts` (what happens from a screen, an endpoint or a symbol, as typed steps — screens, handlers, native bridge calls and events, store actions, calls that leave the index) share one fold: everything between two boxes is `via`, and the branch guards along it join into `when` (`graph/branch-guards.ts`, read at request time). Two helpers sit beside them: `api/route-roots.ts` (where a route's code starts — the handler a resolver named, the page a screen file exports, or the route itself for an inline handler; one rule for every framework) and `api/effects.ts` (the curated table of calls that leave the index — database / response / queue / email / payments / cache / auth / process / network / storage / device / telemetry — matched on the call **as written**, per language family, plus the model / read-write and the response status). `graph/branch-guards.ts` reads, from one cached tree per file, the conditions a site runs under, what it passes, the call as written (the index keeps only the last segment of a deep member chain), the decorators on a definition and the declared types of a class's members — for JS/TS, Swift, Python, Java, Kotlin, C#, Go and C; a language without rules yields nothing, never a wrong label.
 
 ### NodeKind / EdgeKind
 

+ 336 - 0
__tests__/branch-guards-languages.test.ts

@@ -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' });
+  });
+});

+ 2 - 2
__tests__/branch-guards.test.ts

@@ -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([]);
   });
 });
 

+ 20 - 0
__tests__/is-test-file.test.ts

@@ -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);

+ 246 - 0
__tests__/ui-effects.test.ts

@@ -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();
+  });
+});

+ 332 - 0
__tests__/ui-steps-api-servers.test.ts

@@ -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'],
+    ]);
+  });
+});

+ 23 - 1
__tests__/ui-steps-model.test.ts

@@ -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]');
+  });
+});

+ 48 - 2
docs/design/codegraph-ui-design-spec.md

@@ -482,6 +482,37 @@ hubs (fan-in ≥ 40) and shared chrome (a component rendered by ≥ 5 parents 
 attributes navigations rather than deciding what to walk into) are dead ends, counted in `truncated`. A step several
 events land on says `⇠ first +N` and lists them in the panel.
 
+**Servers (Express, NestJS, Fastify, Koa, Hono, FastAPI, Flask, Django, Spring, ASP.NET, Vapor, Gin).** The same picture over
+the same machinery; only the facts and the words change (`src/ui-server/api/route-roots.ts`, `effects.ts`,
+`docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md` §4). A route anchor's walk starts at the symbol the route runs —
+in order of evidence, the target of the route's `references` edge (the handler every server resolver names; a class for a
+ViewSet, whose methods the walk then enters), the component a screen file exports, or the route itself when the handler is
+an inline arrow (`inline handler · users.routes.ts` under the path) — and the box's second line is the handler's name. The
+anchor says what fires it: `FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the
+registration site (Express, Koa, Hono, Fastify), the guard / interceptor / role decorators on the method and on its class
+(Nest, Spring, ASP.NET, Django), a FastAPI `dependencies=[…]`; a function anchored by name says the job, event, message or
+schedule written on it (`@Process('email')`, `@Scheduled(…)`, `@KafkaListener(…)`). Effects gain the categories a request
+sets in motion — `database` (with the model / table when the call names one and read vs write from the method:
+`database · user · write · createUser`), `response`, `queue`, `email`, `payments`, `cache`, `auth`, `process`, and `storage`
+grown to files and buckets — matched on the call **as written**, the whole member chain read from the source at request
+time (`prisma.article.findFirst`, `this.jwtService.signAsync`, `res.status(404).json`), because the index keeps only the
+last segment of a deep chain and a bare `create` matched by name is a guess; on the receiver's declared type when the call
+leaves the index through it (`OwnerRepository owners` in a Spring controller, `Repository<Cat>` in a Nest service — read
+from the class body, `graph/branch-guards.ts`'s `memberTypesInTree`, the index keeps none of it); and, in a project with
+endpoints, on a thrown web exception (`throw new NotFoundException(…)`, `raise HTTPException(…)`). The same declared type
+sends `this.usersService.findByEmail(…)` into the class the type names instead of the name-only guess the graph holds — the
+panel says `by the receiver's declared type` on that hop. A **response** box is the endpoint's contract as the code has it:
+its label is the status codes its sites send when they are literal (`201 · 404`, read out of `status(404)`,
+`HttpStatus.CREATED`, `http.StatusNotFound`, `status_code=422`, `NotFoundException`, `TypedResults.NoContent`, `.notFound`),
+and the panel prints one row per site — `WHEN NOT user → 404 · NotFoundException('no such user')`. The payload says what the
+index is a picture of (`project: 'app' | 'api' | 'web'`, from the routes: endpoints make an API, endpoints beside pages or
+navigation a web app) and the viewer's words follow it in one place (`kindWord` / `kindWords` in `steps-model.ts`):
+endpoint / page / screen, data call / store action, a call to another tier / to the server / a native call; a route that
+leads with a verb is an endpoint wherever it is. The legend re-words itself the same way; the bare tab lists an API's
+endpoints grouped by router file when there are no screens. A production walk never enters a test double (`isTestPath`),
+and a repository-shaped method the walk cannot enter (an interface's, the ORM's) is the database. Conditions and arguments
+are read for Python, Java, Kotlin, C#, Go and C as for JS and Swift (§3.14); a language without rules yields nothing.
+
 Rows = distance from the anchor as the server counted it (first discovery), anchor on top with the entry mark. Boxes:
 the §3.12 screen box for a screen or a handler; **bridge / event** add a 3px `--accent` left rule (the language
 changes under the code) and lead with `⇢` / `⇠ <event name>`; **store** sits on `--paper-2`; **effect** is dashed
@@ -489,7 +520,9 @@ changes under the code) and lead with `⇢` / `⇠ <event name>`; **store** sits
 pills, tooltip and the panel's hover contract are §3.12's verbatim; the panel adds *Start here →* (re-anchor) on any
 step with a symbol, *Open as a flow →* on any link whose ends are both symbols (`#/flow?from=&to=`), a depth `<select>`
 (4–12) that rewrites the URL, per-kind counts, and the `truncated` notes. The bare tab (`#/steps`) is a chooser: the
-project's screens by connectivity, or a hint to search. `Picture` (`screens-model.ts`) is the structural interface
+project's screens by connectivity, else its endpoints by router file, or a hint to search. A picture of at most 24 boxes
+is fitted to the right of the key (a per-side `fitView` padding) so its second row never sits under the legend; a larger
+one is fitted to the whole stage. `Picture` (`screens-model.ts`) is the structural interface
 the shared machinery works over; `steps-model.ts` builds one. Pure model tests: `ui-steps-model.test.ts`; the
 endpoint against a real RN + Expo fixture: `ui-steps-api.test.ts`.
 
@@ -546,11 +579,24 @@ the same question about the same graph.
   them. **Prepared, not published**: `"private": true` is the guard and `scripts/pack-npm.sh` only packs it under
   `CODEGRAPH_PACK_UI=1`.
 
+**Beyond the mobile app.** The Steps picture reaches the same bar on an HTTP API — Express, NestJS, FastAPI,
+Spring (Java / Kotlin), ASP.NET and the rest (§3.13, "Servers"); the Screens picture still rests on Expo Router's
+facts. What a web app (Next.js, React Router, SvelteKit) has instead, the cross-tier channels (client `fetch` → own
+route, queues, server actions) and the ordered plan for them are `docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md`
+(P3, P4 and the validation numbers open; P0, P1, P2, P5, P6 built).
+
 ### 3.14 Conditions, as a reader says them (`ui/src/lib/conditions.ts`)
 A `when` arrives from the graph as code joined by OUR operators — guards along a chain joined with ` && `, a negated
 guard wrapped `!(…)`, a link's several call sites joined with ` || ` — and those joins render as words: **WHEN**,
 **AND**, **OR**, **NOT**, set in capitals at weight 600 in the condition's own mono (no tracking — they are words in a
-sentence, not labels), so the joins read at a glance and the code between them reads as code. The code inside one
+sentence, not labels), so the joins read at a glance and the code between them reads as code. The rules behind them
+(`graph/branch-guards.ts`) cover JavaScript / TypeScript, Swift, Python, Java, Kotlin, C#, Go and C / C++: `if` /
+`elif` / `else`, `switch` / `when` / `match` / `select`, the ternary and Kotlin's `if` expression, `try` / `except` /
+`catch` (`on error`), `&&` / `||` / `and` / `or`, and the early exits before the site — a negated single comparison flips
+instead of wrapping (`if err != nil { return }` reads as `err == nil`, `if not item.title: raise` as `item.title`). The
+same trees answer what a call passes (`callSitesForFile`: Python `name=value`, C# `name: value`, a Go composite literal as
+`gin.H{…}`), the call as written (the whole member chain), the decorators / annotations / attributes on a definition and
+on its class, and the declared types of a class's members. The code inside one
 guard stays code (`isUploadInProgress || elapsed < 5000` is what the source
 says; a guard that is itself a disjunction keeps its parentheses, `graph/branch-guards.ts` adds them). A link with
 several call sites is several **scenarios**, never one long condition: the panel prints the clauses every site shares

+ 446 - 0
docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md

@@ -0,0 +1,446 @@
+# Steps & Screens for APIs and web apps — handoff
+
+**Status:** plan, written 2026-08-28 at the end of the session that built the Steps view and the
+readings it rests on (Expo + React Native app, `amniservices-mobile-app`). **Updated the same day, later
+session: P0, P1, P2, P5 and P6 are built** (see the per-item notes marked *Built*); P3 (cross-tier
+channels), P4 (Next.js as a Screens app) and P7's agent A/B numbers are open. Every claim about what a
+resolver emits *today* was verified against the source on this date — re-verify before building on it,
+the resolvers move. What was learned building it, beyond the plan: the index keeps only the LAST
+segment of a deep member call (`create` for `prisma.user.create`) and name-matches it — often to the
+wrong `create` at confidence 0.4 — so the Steps walk reads every call **as written** from the tree at
+request time (`callSitesForFile`) and classifies on the chain; and the declared types of a class's
+members (`private readonly usersService: UsersService`, `OwnerRepository owners`) are read from the
+class body (`memberTypesInTree`) to send `this.usersService.findByEmail(…)` where the type says and to
+call `owners.save` the database. Validation pictures: `gothinkster/node-express-realworld-example-app`,
+`brocoders/nestjs-boilerplate`, `nestjs/nest/sample`, `fastapi/full-stack-fastapi-template`,
+`Netflix/dispatch`, `spring-projects/spring-petclinic`, `spring-petclinic/spring-petclinic-kotlin`,
+`dotnet-architecture/eShopOnWeb`, `jqlang/jq`, `redis/redis`, `android/nowinandroid`,
+`Dimillian/IceCubesApp`, `TryGhost/Ghost` — each shot headlessly (`codegraph ui --no-open` + Playwright)
+and read against the mobile app's picture.
+
+**Goal.** The two pictures — **Screens** (`#/screens`, design spec §3.12) and **Steps**
+(`#/steps`, §3.13) — must be as good on an Express / NestJS / Fastify API, a Next.js / React Router /
+SvelteKit web app, and a monorepo that has both, as they are on the mobile app today. "As good" is
+defined precisely in §1 below; it is not "draws something".
+
+Companion reading, in this order: `docs/design/codegraph-ui-design-spec.md` §1 (principles), §3.12,
+§3.13, §3.14; `CHANGELOG.md` `[Unreleased]` (the user-facing description of what shipped);
+`docs/design/dynamic-dispatch-coverage-playbook.md` (the coverage rules and the validation method —
+**"partial coverage is worse than none"** governs everything here); `CLAUDE.md` (tests, kernel, docs).
+
+---
+
+## 1. The bar: what "up to par" means
+
+On the mobile app, selecting `/capture/review` and walking to the upload gives the reader, per link:
+
+| Reading | Example | Where it comes from |
+|---|---|---|
+| **The step itself, typed** | `⇢ finalizeCaptureSession` (native call), `⇠ onZipComplete` (native event), `setZipUri` (store action), `axios.post(\`…/oauth/token\`, {…})` (leaves the index) | `src/ui-server/api/steps.ts` classification |
+| **FIRES FROM** — what triggers it | `onSubmit · useFormik(…) in LoginButton`, `onPress · <Button>`, `addListener('onZipComplete')` | `graph/branch-guards.ts` `triggersForFile` — read at request time from the cached tree |
+| **via** — the plumbing folded into the arrow | `via LoginButton → handleLogin`, `via uploadARCapture` | the walk's fold (`steps.ts`) |
+| **WHEN** — the conditions, as words, one row per scenario | `WHEN NOT (busy \|\| late)` once, then `AND NOT user?.organization_id` · site, `AND user?.organization_id AND (…)` · site … | `guardsForFile` + `ui/src/lib/conditions.ts` (`scenarios`, `whenTokens`) |
+| **with what** — the arguments as written | `SecureStore.setItemAsync('userEmail', values.email)`, `client.post('/frames', { uri })` | `callArgumentsForFile` |
+| **Honesty** | other screens are boundaries (`…`), caps announced on the step they hit, synthesized hops dashed, a crossing needs evidence | `steps.ts` caps + `evidenced` rule |
+
+An API or web project reaches the bar when its canonical flow — **request → guard/middleware →
+handler → service → database → queue/email/other service → response** for an API, **page → data
+fetch → user action → server action/route → database → redirect** for a web app — shows every one of
+those readings on every link, on a real mid-size repo, with the caps and the boundaries behaving as
+they do on the mobile app. Numbers to record per framework are in §7.
+
+---
+
+## 2. How the pictures work today — the facts they rest on
+
+Read this before touching anything. Each picture is a pure function of a small set of graph facts;
+extending the pictures to a new framework is almost entirely a matter of making the **same facts
+exist** for it, plus wording.
+
+### 2.1 Screens (`src/ui-server/api/screens.ts`, `ui/src/lib/screens-model.ts`)
+
+| Needs | Today comes from |
+|---|---|
+| `route` nodes named by path (`/capture/review`) | `resolution/frameworks/expo-router.ts` (file-path routing); React Router / Next.js *pages* routes from `frameworks/react.ts` also exist but carry no navigation |
+| route → the component that renders it (`calls` / `instantiates` edge out of the route) | expo-router resolver (default export of the screen file) |
+| `navigates` edges from the function that pushes a path to the route it names, with `metadata.href` / `navMethod` | expo-router resolver (literal / template / pathname-object hrefs) + `resolution/expo-router-synthesizer.ts` (helper return values → `provenance: 'heuristic'`) |
+| the attribution walk BACK from the navigation call to a screen's component, folding the chain into `via` | `screens.ts` (`attribute`, caps: 7 hops / 30 callers / 800 visited) |
+| `when` per site | `createSiteReader(...).when` in `api/when.ts` → `guardsForFile` |
+
+If there are no `navigates` edges the endpoint answers `routed: false` and the view says "No screen
+navigation in this graph". **That is what every API and every non-Expo web app gets today.**
+
+### 2.2 Steps (`src/ui-server/api/steps.ts`, `ui/src/lib/steps-model.ts`, `ui/src/views/StepsView.svelte`)
+
+The walk: from the anchor, breadth-first over `calls` / `instantiates` / `navigates` /
+function-as-value `references` (`metadata.fnRef`) / function→function `contains`, folding every node
+that is not a step into `via`. A node **is** a step when it is one of:
+
+| Kind | Evidence today | Server rule |
+|---|---|---|
+| `screen` | target is a `route` node | any edge into a route (a `navigates` edge in practice) |
+| `trigger` (handler) | a function passed as a value (`fnRef`), **or** called from under an event binding — JSX prop, `on*` option, runs-later callback (`triggerInTree`) — and not a component, not a store action | `steps.ts` classification, `looksLikeComponent` |
+| `bridge` ⇢ | language family changes JS→native **and** the edge is evidenced: `metadata.bridge === 'react-native'`, `resolvedBy === 'framework'`, or `provenance: 'heuristic'` | `crossing()` + `evidenced`; a plain name-matched cross-family call is dropped |
+| `event` ⇠ | native→JS, evidenced (`synthesizedBy: 'rn-event-channel'`) | same |
+| `store` | function in a store **file** (`STORE_FILE` regex: `stores?/`, `storage/`, `.store.ts`, `.slice.ts`…) — file-name evidence, the legend says so | `isStoreFile` |
+| `effect` | an unresolved call, or a call that resolved to a `constant`/`variable`, whose text matches the curated `EFFECTS` table: `network`, `storage`, `device`, `telemetry` | `effectCategory`; one box per (function, category), `apis[]` listed |
+
+Boundaries and caps (all announced on the step, `cut`): another **screen** (`through=1` enters it), a
+native event landing in a **component** of another screen, depth (8 default, ≤14), fan-out per node
+(80), folded nodes per step (300), steps per picture (120 default, ≤400); hubs (fan-in ≥ 40) and shared
+chrome (a component rendered by ≥ 5 parents) are dead ends counted in `truncated`. HOC wrappers
+(`memo(X)`) are seen through via the file-scope function reference within the wrapper's lines.
+
+**Where the anchor's root comes from:** for a `route` anchor the walk starts at the component the
+route renders — found as the first `calls`/`instantiates` edge OUT of the route node (`componentOf`
+in `buildSteps`). This is correct for Expo Router and **wrong for every API framework** (§3, P0).
+
+### 2.3 The request-time readings (`src/graph/branch-guards.ts`)
+
+Nothing about these is stored in the index; they parse the file (LRU of 8 trees, 256 KB cap) and
+answer per call site `(line, column)`:
+
+- `guardsForFile` → the branch conditions (JS-family + Swift rules; a disjunctive guard keeps its parens).
+- `callArgumentsForFile` → the argument list abbreviated (strings whole, objects as keys, `[…]`, `() => …`, `f(…)`, Swift labels).
+- `triggersForFile` → `{ kind: 'prop' | 'option' | 'callback', name, of }`: JSX attribute (event prop, or any prop given a function), `on*` object key (with the call it configures, through arrays), argument of a runs-later callee (`LATER_CALLEES`). Named handlers (`const handleX = useCallback(…)`) are boundaries.
+
+All three are **JS-family only** (`supportsBranchGuards`), Swift for guards. Python / Java / Go / Ruby
+/ PHP have no rules — an API in those languages gets no WHEN, no arguments, no FIRES FROM (§3, P5).
+
+### 2.4 The rest of the surface
+
+- Wire types are mirrored by hand in `ui/src/lib/wire.ts`; the adapter method `steps` is **optional**
+  (`ui/src/lib/adapter.ts`), `NavigationDriver.stepsHref` is **required** (a host driver must add it).
+- Conditions vocabulary: `ui/src/lib/conditions.ts` (`WHEN`/`AND`/`OR`/`NOT` tokens, `scenarios`, common-prefix factoring). Both views use it.
+- Tests: `__tests__/ui-steps-api.test.ts` (real RN + Expo fixture, end to end — **copy its shape for every new framework**), `ui-steps-model.test.ts`, `ui-conditions.test.ts`, `branch-guards.test.ts` (guards, arguments, triggers), `ui-screens-model.test.ts`, `expo-router.test.ts` (routed fixture + `buildScreens`).
+- **Kernel parity:** TypeScript/JS *extraction* runs in the Rust kernel (`codegraph-kernel/src/tsjs/`); the TS extractor is the wasm fallback. Any extractor change → mirror in Rust, `npm run build:kernel`, test on both paths (`CODEGRAPH_KERNEL=0` for wasm) plus `kernel-tsjs-parity.test.ts`. Resolvers, synthesizers and the request-time readings are TS-only — no parity work.
+- Verify visually: `npm run build` → `codegraph index` in the target project → `codegraph ui --no-open --port 4747 <project path>` → `GET /api/steps?symbol=<route name>` → headless playwright (`createRequire` from a repo that has it; `waitUntil: 'load'`, not `networkidle` — the viewer holds an SSE stream). See the auto-memory note `codegraph-viewer-workflow`.
+
+---
+
+## 3. What an API / web project gives us today (verified 2026-08-28)
+
+| Framework | Route nodes | Route → handler | Navigation | Notes |
+|---|---|---|---|---|
+| **Express / Koa** (`frameworks/express.ts`) | `GET /path` from `app|router.METHOD('/path', …)` | **named handler**: a `references` edge route → handler (last argument; earlier arguments = middleware, **not linked**). **Inline arrow handler**: the route node itself gets `calls` edges to every function its body calls (regex; `RESERVED_CALLS` filtered) — the route *is* the handler | none | `router.use('/prefix', sub)` mounting is not prepended to paths (label only) |
+| **NestJS** (`frameworks/nestjs.ts`) | `GET /users/:id` = `@Controller` prefix + `@Get` path; also GraphQL `@Query/@Mutation`, `@MessagePattern/@EventPattern`, `@SubscribeMessage` | `references` edge route → the decorated method; DI `this.svc.method()` resolves via receiver type (playbook: "no dynamic-dispatch hole") | none | `@UseGuards/@UseInterceptors/@UsePipes`, `@OnEvent`, `@Process/@Processor`, `@Cron` are **not** modelled — no guard chain, no event/queue channel |
+| **Next.js** (`frameworks/react.ts`) | `pages/**` and `app/**` files with `export default` → a route named by path (`/blog/:slug`) | none (the page component is the default export in the same file, not linked from the route) | none — no `navigates` for `<Link href>`, `router.push`, `redirect()` | `app/api/**/route.ts` handlers (`export async function GET`) are **not** routes; server actions (`'use server'`) unknown; `middleware.ts` unknown |
+| **React Router** | `<Route path component={C}/>` / `element={<C/>}`, object data-router (literal form) | `references` to the component | none | |
+| **SvelteKit / Vue / Nuxt / Astro** | file routes | `svelteKitLoadEdges`, `vueTemplateEdges`, Pinia/Vuex channels | none | |
+| **FastAPI / Django / Flask / Spring / Laravel / Rails / Gin / Axum** | routes + handler edges (resolvers) | yes | — | **no WHEN / arguments / triggers** (language rules missing) |
+
+What the viewer does with that today: **Entry points** lists every route with its handler (this is the
+"Screens" of an API today); **Screens** answers "no screen navigation" for all of them; **Steps**
+anchored on an API route finds no root (`componentOf` looks for `calls`/`instantiates` out of the route;
+Express named / Nest give `references`; an Express inline route's *first callee* becomes the root —
+wrong) and draws the anchor alone. So the first task is small and unblocking.
+
+Synthesizer channels that already exist and matter here (`resolution/callback-synthesizer.ts`):
+`eventEmitterEdges` (JS `.on('x', fn)` ↔ `.emit('x')`), `springEventEdges`, `laravelEventEdges`,
+`celeryDispatchEdges`, `sidekiqDispatchEdges`, `mediatrDispatchEdges`, `reduxThunkEdges`,
+`rtkQueryEdges`, `objectRegistryEdges`, `ginMiddlewareChainEdges`, `svelteKitLoadEdges`. There is **no**
+channel for: BullMQ / Bull (`queue.add('job')` ↔ `@Process('job')` / `new Worker('q', fn)`), Nest
+`EventEmitter2` (`emit('x')` ↔ `@OnEvent('x')`), socket.io / Nest gateways, **client `fetch` → server
+route**, tRPC, Next server actions. Those are the cross-tier hops — the API equivalent of the RN
+bridge — and they are where a web app's "capture → upload" story breaks today.
+
+---
+
+## 4. The mapping — same pictures, same words, different facts
+
+Keep the visual language exactly (spec §2 and §3.13): boxes, labelled arches, one accent, dashed = a
+place the graph cannot follow into, accent rule = the code crosses a boundary. Only the *evidence* and
+the *words* change.
+
+| Mobile app (built) | HTTP API | Web app (Next.js / React Router / SvelteKit) |
+|---|---|---|
+| **screen** `/capture/review` — a box; other screens are boundaries | **endpoint** `POST /users` — a box; another endpoint reached by an internal HTTP call is a boundary | **page** `/blog/[slug]` — a box; another page reached by `<Link>` / `router.push` / `redirect()` is a boundary — this is the Screens picture proper |
+| the entry screen `/`; Screens = transitions between screens | no entry; **Entry points** is the list. A "Routes" picture (endpoints + calls between them) only if a repo actually has inter-endpoint calls — measure before building | `/` (or the root layout); Screens = `<Link>` / `router.push` / `redirect` / `<a href>` between pages (P4) |
+| **handler** `handleLogin` — FIRES FROM `onSubmit · useFormik(…)` | **handler** `createUser` — FIRES FROM `POST /users` **after** `authenticate, validate(schema)` (Express middleware args), `@UseGuards(JwtGuard)` (Nest); a queue consumer FIRES FROM `@Process('email')` / `new Worker('email')`; a cron FIRES FROM `@Cron('0 * * * *')`; an event listener FIRES FROM `@OnEvent('user.created')` | a page's data fetch FIRES FROM **page load** (`getServerSideProps`, RSC render, `load()`); a client handler FIRES FROM `onSubmit · <form>` / `action={createPost}`; a server action FIRES FROM the form/handler that calls it |
+| **⇢ native call** (JS→Swift, RN bridge evidence) | **⇢ another tier**: outbound HTTP to another service (`fetch('https://…')` = effect `network`; to **our own** route with a literal path = a link to that endpoint box), queue publish (`queue.add('email', {…})` → ⇢ the consumer) | **⇢ server**: client `fetch('/api/users')` → the `route.ts` handler; a server action call from a client component; a tRPC mutation → its procedure |
+| **⇠ native event** (`sendEvent(withName:)` → listener) | **⇠ from a queue / bus**: the consumer landing (`@Process('email')`), an event landing (`@OnEvent`), a websocket message landing | **⇠ from the server**: SSE / websocket / push landing in a client handler; `revalidatePath` (announce, don't draw) |
+| **store action** (by store file) | **data**: an ORM / repository / query call — `prisma.user.findMany({ where, select })`, `this.userRepo.save(user)`, `User.findOne(…)`, `knex('users').insert(…)`, `db.query(sql)` — evidence = the receiver's import origin (prisma / typeorm / mongoose / drizzle / knex / pg / mysql2 / sequelize / kysely) or a known repository type; **the model or table comes from the receiver or the first argument**, read vs write from the method name | same as API on the server side; on the client, a store (Zustand / Redux / React Query cache) as today |
+| **outside the index**: `network`, `storage`, `device`, `telemetry` | add **`database`** (above), **`queue`** (bull/bullmq `add`, `sqs.send`, `kafka.produce`, `pubsub.publish`), **`email`** (nodemailer, sendgrid, resend, ses), **`payments`** (stripe, braintree), **`cache`** (redis / ioredis / memcached / `cache.set`), **`auth`** (jwt sign/verify, bcrypt/argon), **`response`** (below), `storage` gains S3 / GCS / fs | same, plus `response` = `NextResponse.json`, `redirect()`, `notFound()` |
+| — | **response** as a step: every `res.status(404).json({ error })`, `throw new NotFoundException(…)`, `reply.code(201).send(…)`, `return c.json(…)`, `raise HTTPException(…)` is a scenario row with its **WHEN** and its **arguments** (the body). Together they are the endpoint's contract *as the code has it* — the single most valuable reading for an API, and it falls out of the existing scenario rows once `response` is an effect category | `redirect('/login')` is both a response and a navigation (draw as the navigation) |
+| **WHEN** (guards, words, scenario rows) | same — plus the guard/middleware chain is the *shared prefix* said once (`FIRES FROM POST /users after authenticate`) | same |
+| **with what** (arguments) | same; especially `res.status(404).json({ error })`, `prisma.user.create({ data: { email, name } })`, `fetch(\`/api/users/${id}\`, { method: 'POST' })` | same |
+| **via** (folded plumbing) | controller → service → repository chains fold into `via` as hooks do today; the panel promotes it (`--ink-2`) | same |
+
+Words in the legend and the panel switch on the anchor: when the anchor's route name leads with an HTTP
+verb (`splitRouteName` in `api/routes.ts`), `screen` reads **endpoint**, `store` reads **data**,
+`bridge` reads **crosses a tier**, `event` reads **arrives from a queue / bus / the server**. Keep
+`kindWord()` in `steps-model.ts` as the one place that decides.
+
+---
+
+## 5. Work plan, in order
+
+Each item: what, where, the evidence rule (never guess — a wrong edge is worse than none), the test,
+and what "done" looks like on the picture. Do them in this order; P0 unblocks everything, P1–P3 make an
+API picture worth looking at, P4 makes a web app a Screens app, P5 widens the languages, P6 is words,
+P7 is the proof.
+
+### P0 — The root of an API route (small, unblocking)
+
+*Built* — `src/ui-server/api/route-roots.ts` (`routeRoots`, shared by `steps.ts` and `screens.ts`), the
+chooser lists endpoints by router file, `WireStep.screen` gained `endpoint` / `inline`; test
+`__tests__/ui-steps-api-servers.test.ts` (Express named + inline, Nest, FastAPI, Spring in one fixture).
+
+*Where:* `src/ui-server/api/steps.ts` (`buildSteps`, the `componentOf` map), and the same map in
+`screens.ts` for consistency.
+
+*Rule:* the root of a route anchor is, in order: (1) the target of the route's `references` edge whose
+target is a function/method (Express named handler, Nest method, React Router component); (2) the
+route's `calls`/`instantiates` target **only when it is a component** (`looksLikeComponent`, Expo/React
+pages); (3) the route node itself when it carries `calls` edges and nothing else (Express inline arrow —
+walk its callees as if the route were the handler; label the anchor `POST /users` and say "inline
+handler" in the sub line). Cross-check with the routing manifest (`cg.getRoutingManifest`,
+`api/routes.ts` resolves `handlerId` by file+line+name) and prefer it when both exist.
+
+*Also:* the Steps chooser (`StepsView.svelte`, the `!asked` branch) lists **routes** from `/api/routes`
+when `/api/screens` is not routed — grouped by router file, `METHOD path`, most-connected first.
+
+*Test:* extend `ui-steps-api.test.ts` with an Express fixture (one named-handler route with middleware
+args, one inline-arrow route) and a Nest fixture (controller with `@Controller('users')` +
+`@Get(':id')` + `@Post()`; a service injected via constructor; a repository). Assert the root, the first
+row, and that the walk reaches the service and the repository call.
+
+*Done when:* `#/steps?symbol=POST%20/users` on the fixture draws the handler's steps, not the anchor alone.
+
+### P1 — Effects for servers: `database`, `response`, `queue`, `email`, `payments`, `cache`, `auth`
+
+*Built* — `src/ui-server/api/effects.ts` (`classifyEffect`, `responseStatus`; rules per language family,
+`process` and Android rows added beyond the plan; `effect.model` / `access`, `site.status`, a response box
+labelled by its codes); tests `__tests__/ui-effects.test.ts`. Matching is on the call as written and on the
+receiver's declared type when the call leaves the index through it — see the status note at the top.
+
+*Where:* `EFFECTS` in `steps.ts` (make it a module of its own, `api/effects.ts`, with a table per
+category and unit tests — it is about to grow); `stepSub`/legend words in `steps-model.ts` / `StepsView.svelte`.
+
+*Rules:*
+- `database`: the receiver is a **known ORM client** — decide by the reference text *and* the import
+  origin of the receiver's binding when the graph has it (`prisma.*` where `prisma` is imported from
+  `@prisma/client` or a project file that constructs `new PrismaClient()`; `this.repo`/`this.*Repository`
+  typed `Repository<T>` (TypeORM); `Model.find*/create/update*/delete*` on a Mongoose model; `knex(…)`,
+  `db.select/insert/update/delete` (Drizzle), `pool.query`/`client.query` (pg), `sequelize`/`Model.*`,
+  `kysely`). The step's label is the call with its arguments as today; add `effect.model` = the model /
+  table when it can be read (`prisma.user` → `user`; `Repository<User>` → `User`; `knex('users')` →
+  `users`; raw SQL: first table after `FROM|INTO|UPDATE|JOIN`), and `effect.access = 'read' | 'write'`
+  from the method name (`find*/get*/count/aggregate/select` vs `create/update/upsert/delete/save/insert/remove`).
+  Box: `prisma.user.create({ data })` / sub `data · write · user · createUser`.
+- `response`: `res.status(…).json|send|end`, `res.json|send|sendStatus|redirect|render`,
+  `reply.code|send`, `c.json|text|redirect` (Hono), `NextResponse.json|redirect`, `throw new
+  *Exception(…)` / `throw new HttpError(…)` / `next(err)`, Python `raise HTTPException`, Spring
+  `ResponseEntity.*`, Go `c.JSON(…)`/`http.Error`. One box per (function, `response`) with `apis[]` as
+  today — **but the panel's scenario rows are the contract**, so keep every site with its WHEN and
+  arguments. Read the status code out of the arguments when literal (`status(404)`) and put it on the
+  site (`site.status`) so a row can say `404 · { error }`.
+- `queue`, `email`, `payments`, `cache`, `auth`: receiver/method tables like `network` today. Keep the
+  table curated and documented; false positives here are visible noise.
+
+*Test:* `api/effects.test.ts` over the table; extend the P0 fixtures with a Prisma create, a
+`res.status(404).json`, a `throw new NotFoundException`, a `queue.add('email', {…})`.
+
+*Done when:* `POST /users` shows `prisma.user.create({ data })`, `queue.add('email', {…})`, and the
+`response` box whose rows read `WHEN NOT user → 404 · { error: 'not found' }` / `always → 201 · user`.
+
+### P2 — Triggers for servers: the request, the guard chain, jobs, events, cron
+
+*Built* — `request` / `decorator` trigger kinds with `after` (the chain); Express-family middleware from the
+registration's arguments, guard decorators from `decoratorsForFile` (the index keeps no decorators), FastAPI
+`dependencies=[…]`; consumer decorators on a function anchored by name. The queue-consumer *reachability*
+(producer → `@Process`) is P3's.
+
+*Where:* `triggerInTree` in `graph/branch-guards.ts` gains a `decorator` form; `steps.ts` sets the
+anchor's / handler's trigger from the **route registration**, not from a JSX prop.
+
+*Rules:*
+- The trigger of a route's handler is the route itself: `{ kind: 'request', name: 'POST', of: '/users' }`
+  → `FIRES FROM POST /users`. The middleware / guard chain is read at the **registration site**: Express —
+  every argument before the handler in `app.post('/users', authenticate, validate(schema), createUser)`
+  (the resolver already knows the site line; read the arguments with `callArgumentsForFile` and drop the
+  last); Nest — `@UseGuards(...)`, `@UseInterceptors(...)`, `@UsePipes(...)` on the method **and** on the
+  class (class-level applies to every method); Fastify `{ preHandler: [...] }`; Koa `router.post(path,
+  mw, handler)`; Hono `app.post(path, mw, handler)`. Render as `FIRES FROM POST /users · after
+  authenticate, validate(…)` and put the chain on the link (`trigger.after: string[]`). Global
+  `app.use(mw)` before the route is a chain element too — read in file order, announce it as "global".
+- Queue consumers, event listeners, cron, message patterns, websocket handlers as triggers: Nest
+  decorators `@Process('x')`, `@OnEvent('x')`, `@Cron(expr)`, `@MessagePattern('x')`,
+  `@SubscribeMessage('x')`; Bull/BullMQ `queue.process('x', fn)` / `new Worker('q', fn)`; node-cron
+  `cron.schedule(expr, fn)`; socket.io `socket.on('x', fn)`; Kafka/SQS consumers. The `option` and
+  `callback` forms already cover several of these (`process('x', fn)` = callback of `process` with first
+  literal `'x'` → add the names to `LATER_CALLEES`); decorators need the new form: climb from the site to
+  the decorated method/class and read `decorator` nodes (`@Name(args)`).
+
+*Test:* `branch-guards.test.ts` `triggers` block: Express registration with middleware, Nest guards on
+class and method, `@Process`, `@Cron`, `queue.process`, `socket.on`.
+
+*Done when:* the handler box's sub line reads `POST /users · after authenticate, validate(…)` and a
+consumer reads `FIRES FROM @Process('email')`.
+
+### P3 — Cross-tier channels (the RN bridge, for the web)
+
+*Where:* new synthesizers in `resolution/callback-synthesizer.ts` (register in the channel list with a
+language gate), or a resolver for the resolvable ones; each tagged `provenance: 'heuristic'`,
+`synthesizedBy`, `registeredAt`. **Close both directions before shipping any of them** (playbook).
+
+1. **HTTP call → own route.** A JS/TS call `fetch('/api/users/…')`, `axios.post('/api/users')`,
+   `api.get('/users')` (a project axios instance with a literal `baseURL`) whose path literal (or
+   template with `${…}` segments as `:param`) matches a route node `METHOD path` in the same index
+   (method from the call: `fetch(url, { method: 'POST' })`, `axios.post`, else GET). Prefix-aware:
+   Express `router.use('/api', usersRouter)` mounts (P0's resolver gap — fix the label there too),
+   Next `app/api/**/route.ts` (P4). Edge: caller → route, kind `calls`, `metadata.tier: 'client→server'`,
+   confidence by how much of the path was literal. **Steps then draws the route as a `bridge` box
+   (`⇢ POST /api/users`) and, with `through=1`, walks on into the handler** — the capture→upload story
+   for a web app. Evidence bar: the path must be literal enough to match exactly one route; a bare
+   `fetch(url)` with a variable url produces nothing.
+2. **Next server actions** (P4 prerequisite): a function in a `'use server'` file, or marked with the
+   directive, called from a client component / passed as `action={fn}` → the call edge exists already
+   (it is a normal import); mark it `tier: 'client→server'` at resolution (the callee's file has the
+   directive) so Steps classifies it as `bridge` with evidence.
+3. **tRPC**: `trpc.users.create.useMutation()` / `.mutate(…)` ↔ `router({ users: router({ create:
+   procedure.mutation(…) }) })`: match the dotted path against the router object keys (object-literal
+   member resolution exists: `resolveObjectLiteralMember`). Client → procedure handler, `tier`.
+4. **Queues / buses**: BullMQ `queue.add('job', …)` ↔ `@Process('job')` / `worker = new Worker('q',
+   fn)`; Nest `EventEmitter2.emit('x')` ↔ `@OnEvent('x')`; socket.io `server.emit('x')` ↔ `socket.on('x')`
+   and Nest `@SubscribeMessage('x')`. Same shape as `rnEventEdges` (literal on both sides, fan-out cap,
+   `event` metadata); Steps classifies the landing as `event` ⇠ when the edge is synthesized and crosses
+   into a handler — extend `crossing()` to accept a `tier`/`channel` marker, since both sides are TS.
+
+*Test:* a monorepo fixture (`apps/web` Next page with a `fetch('/api/users')` + `apps/api` Express
+`app.post('/api/users')`), a BullMQ producer/consumer, a Nest `emit`/`@OnEvent` pair. Assert the edges
+(source, target, metadata) and that `buildSteps` from the page reaches the database effect **through**
+the route with `through=1`.
+
+*Done when:* from the web app's page, the picture reads `page → handler → ⇢ POST /api/users … →
+prisma.user.create → response`, dashed where synthesized, with `registeredAt` in the panel.
+
+### P4 — Next.js as a Screens app
+
+*Where:* `resolution/frameworks/react.ts` (split a `nextjs.ts` out of it — the pages/app routing is
+already there), a `next-router-synthesizer.ts` modelled on `expo-router-synthesizer.ts`.
+
+*Rules:*
+- Routes: App Router `app/**/page.{tsx,jsx,js}` → page route named by path (`(group)` stripped,
+  `[slug]` → `:slug`, `[...all]`, parallel/intercepting routes announced not modelled);
+  `app/**/route.ts` exports `GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS` → one route node each, `METHOD
+  /api/…`, with a `references` edge to the exported function; Pages Router `pages/**` and `pages/api/**`
+  (default export = handler; method from `req.method` switches — announce as `ANY`). `layout.tsx`,
+  `loading.tsx`, `error.tsx` are not routes; `middleware.ts` with `config.matcher` is a global guard (P2 chain).
+- Route → component: the page file's default export (`defaultExportName` exists in expo-router's
+  resolver — reuse) → `calls` edge, exactly as Expo Router.
+- `navigates`: `<Link href="/x">` (JSX attribute literal / template / object `{ pathname }`),
+  `router.push|replace('/x')` from `next/navigation` and `next/router`, `redirect('/x')` /
+  `permanentRedirect` (server), `NextResponse.redirect(new URL('/x', req.url))` in middleware and route
+  handlers, `<a href="/x">` to an internal path, `revalidatePath('/x')` (announce as a *refresh*, not a
+  navigation). Helper return values through the existing return-value synthesizer pattern.
+- Triggers on a page: the page's server work FIRES FROM **page load** (`{ kind: 'load', name: 'GET',
+  of: '/blog/[slug]' }`) — the RSC body, `getServerSideProps`, `generateMetadata`; client handlers as today.
+
+*Test:* an `expo-router.test.ts`-shaped Next fixture: two pages, a `<Link>`, a `router.push` behind a
+condition, a `redirect()` in a server action, a `route.ts` `POST`; assert routes, `navigates` metadata
+(`href`, `navMethod`), `buildScreens` (`routed: true`, the transition with its `when` and `via`), and
+`buildSteps` from a page reaching the server action (`⇢`) and the Prisma call.
+
+*Done when:* a Next app lands on the Screens tab like the mobile app does, and a page's Steps picture
+shows load-time data, handlers, server actions and route handlers as boundaries.
+
+### P5 — WHEN / arguments / triggers for Python, Java, Go (then Ruby, PHP, C#)
+
+*Built* for Python, Java, Kotlin, C#, Go, C / C++ / Objective-C (guards, arguments, the call as written,
+decorators, member types); Ruby and PHP still yield nothing. Test `__tests__/branch-guards-languages.test.ts`.
+
+*Where:* `graph/branch-guards.ts` — `Rules` per language for guards (`if`/`elif`/`else`, early
+`return`/`raise`/`continue`, `try`/`except`, `match`; Java `if`/`switch`/`throw`; Go `if err != nil {
+return }` as the idiomatic early exit, `switch`/`select`); argument containers (`argument_list`,
+`keyword_argument` → `name=value`); triggers (FastAPI `@router.post('/x', dependencies=[Depends(auth)])`,
+Flask `@app.route`, Django URLconf + `@login_required`; Spring `@PreAuthorize`, `@Transactional`;
+Gin middleware chain — `ginMiddlewareChainEdges` already knows it). Request-time only — no kernel work.
+`supportsBranchGuards` widens per language as rules land; a language without rules must still yield
+*nothing*, never a wrong label (§1 principle 6).
+
+*Test:* `branch-guards.test.ts` blocks per language, mirroring the JS ones.
+
+*Done when:* a FastAPI route's Steps picture carries the same four readings as an Express one.
+
+### P6 — Words and the chooser
+
+*Built* — `project` on the wire, `kindWord` / `kindWords` / `countWords`, the legend per project kind, the
+endpoint chooser. The Screens tab stays hidden for an API (no `navigates`).
+
+*Where:* `ui/src/lib/steps-model.ts` (`kindWord`, `stepSub`, `stepLabel`), `StepsView.svelte` legend
+and summary, `api/steps.ts` (`WireStepsPayload.project: 'app' | 'api' | 'web'` decided from the route
+names and the frameworks detected, so the viewer does not guess).
+
+- Endpoint boxes: `POST /users` mono, sub = handler name · file (like a screen's component). Response
+  boxes: dashed like other effects, label the status codes when literal (`404 · 201`).
+- Kind words per project kind (§4 table). The legend re-words itself from the same table. Keep the
+  sentence-case, no-tracking rule (spec §2) — capitals are only the condition keywords.
+- The chooser: routes grouped by router file, then pages; "most connected" by fan-out of the handler.
+- The Screens tab for an API: hide it (as today when `routed: false`) unless P3's inter-endpoint links
+  produce a picture with more than a handful of arrows — measure on the validation repos first; the
+  Entry points view is the honest list until then.
+
+### P7 — Validation set and the numbers
+
+Small fixtures live in the tests. For the real bar, index these and record the results in
+`docs/design/dynamic-dispatch-coverage-playbook.md` (new rows) exactly as the playbook asks
+(**≥3 flow prompts × small/medium/large, node count stable, synthesized-edge precision spot-check,
+agent A/B with `--model sonnet`, ≥2 runs per arm**):
+
+| Framework | Small | Medium / large | Canonical flow to draw |
+|---|---|---|---|
+| Express | `gothinkster/node-express-realworld-example-app` | `TryGhost/Ghost` | `POST /api/articles` → `auth` → handler → service → DB → 201 / 422 |
+| NestJS | `nestjs/nest/sample/01-cats-app` (and `sample/*`) | `immich-app/immich`, `amplication/amplication` | `POST /assets` → `@UseGuards(Auth)` → controller → service → repository → queue job → `@Process` consumer |
+| Next.js | `vercel/next.js/examples/*` (app-dir + prisma) | `calcom/cal.com` (Next + Prisma + tRPC), `twentyhq/twenty` (Nest + Next monorepo — the cross-tier story) | page load → data → form action → server action / route handler → DB → `redirect` |
+| FastAPI | `tiangolo/full-stack-fastapi-template` | — | `POST /users` → `Depends(get_current_user)` → CRUD → session commit → `HTTPException` rows |
+| Spring | `spring-projects/spring-petclinic` | — | controller → service → JPA repository → view / `ResponseEntity` |
+
+Acceptance per framework: the canonical flow drawn end to end with all four readings on every link;
+boundaries where they should be; no picture over the caps at depth 8 on the small repo; every
+synthesized edge in the picture spot-checked against the source; the agent A/B not regressing the
+control repos.
+
+---
+
+## 6. Conventions and gotchas (learned the hard way this session)
+
+- **Extractor changes need the Rust twin** (§2.4). A TS-only extractor patch silently does nothing on
+  a machine with the kernel binary staged — tests pass under `CODEGRAPH_KERNEL=0` and fail by default.
+- **Function-as-value capture is what makes handlers visible**: JSX attribute values, `on*` options,
+  object shorthand members (`return { handleX }`) are capture sites (`TS_JS_SPEC.dispatch` in
+  `extraction/function-ref.ts`, mirrored in `codegraph-kernel/src/tsjs/fnref.rs`). The gate is
+  "defined in this file or imported" — a handler that comes out of a hook destructure in another file is
+  found through `contains`, and P0's route handlers through `references`. When a handler folds that
+  should be a box, check which of these it fell through.
+- **Evidence over inference.** A cross-family edge without `resolvedBy: 'framework'`, `bridge`, or
+  `provenance: 'heuristic'` is name-matcher noise (`arr.flat()` landing on a Swift `flat`) and Steps
+  drops it. Keep that rule for tiers: a `fetch` with a variable URL is nothing, not a guess.
+- **Two passes per fold** (classify arrivals, then fold the rest) so a node that is a step is never also
+  folded through its `contains` edge; keep `defines …` sites out of the rows when a call site exists.
+- **Readings are per site.** `WireStepSite.when`, `.args`, `.trigger` — the link's `when`/`trigger` is
+  only the summary. Scenario rows and the common-prefix factoring are in `ui/src/lib/conditions.ts`.
+- **Caps are announced, never silent** (`cut`, `truncated`). A new cap must say so on the step it hits.
+- **Docs to update with every change**: the design spec section, `CHANGELOG.md` `[Unreleased]` in the
+  user-facing style the file prescribes (no paths / symbol names / numbers), `CLAUDE.md` if a module or a
+  rule is added. The coverage playbook gets a row per validated framework.
+- **The UI package seam**: `ui/src/lib/adapter.ts` (`steps` optional), `navigation.ts`
+  (`stepsHref` required — the Pro app's driver must add it), `check-ui-package.mjs` prunes the app shell;
+  nothing outside `adapter.ts` may reach the network.
+- **Known flake**: `__tests__/mcp-daemon.test.ts` "daemon idle-times-out" fails under full-suite load
+  (~1 in 3 runs) and passes alone. Not related to any of this.
+- **Do not commit or push** unless asked; the session's work is uncommitted on `main`'s working tree of
+  `~/Development/CodeGraph/codegraph` (27 modified, 12 new files as of this writing) — branch first
+  (`feature/…`) when you do.
+
+---
+
+## 7. Open questions for the maintainer
+
+1. **A "Routes" picture for pure APIs, or Entry points as the list?** Recommendation: measure
+   inter-endpoint links on the validation repos after P3; build the picture only if it has arrows.
+2. **`response` as steps** (recommended: yes — the contract-as-code reading) vs. folded into the handler.
+3. **How much schema on `database` boxes**: model + read/write from the call (cheap, proposed) vs.
+   fields from the ORM schema (Prisma `schema.prisma`, TypeORM entities) — a later, separate reading.
+4. **Project kind on the wire** (`app | api | web`) decided server-side from routes + frameworks, or a
+   viewer toggle? Recommended: server-side, with the viewer allowed to override in the URL.

+ 1055 - 26
src/graph/branch-guards.ts

@@ -57,7 +57,7 @@ const JS_FAMILY: ReadonlySet<Language> = new Set(['typescript', 'javascript', 't
 
 /** Languages with walk rules below. Others yield no guards (never a wrong one). */
 export function supportsBranchGuards(language: Language | string | undefined | null): boolean {
-  return !!language && (JS_FAMILY.has(language as Language) || language === 'swift');
+  return !!language && RULES_BY_LANGUAGE.has(language as Language);
 }
 
 /**
@@ -77,9 +77,25 @@ function renderGuard(g: BranchGuard): string {
   // `!x` negated reads back as `x`; a simple operand takes a bare `!`;
   // anything with operators is parenthesised so the negation is unambiguous.
   if (/^!(?![=])/.test(g.text) && isSimpleOperand(g.text.slice(1))) return g.text.slice(1);
+  if (/^not\s+/.test(g.text) && isSimpleOperand(g.text.slice(4).trim())) return g.text.slice(4).trim();
+  // One comparison flips instead of wrapping: the reader of a Go `if err !=
+  // nil { return }` wants `err == nil`, not `!(err != nil)`.
+  const flipped = flipComparison(g.text);
+  if (flipped !== null) return flipped;
   return isSimpleOperand(g.text) ? `!${g.text}` : `!(${g.text})`;
 }
 
+/** `a != b` → `a == b`, `x is None` → `x is not None`; null when the text is not one plain comparison. */
+function flipComparison(text: string): string | null {
+  if (/&&|\|\||\band\b|\bor\b|\?/.test(text)) return null;
+  if (hasTopLevelOr(text)) return null;
+  const m = /^([^=!<>]+?)\s*(===|!==|==|!=|\bis not\b|\bis\b)\s*([^=!<>]+)$/.exec(text);
+  if (!m) return null;
+  const flip: Record<string, string> = { '===': '!==', '!==': '===', '==': '!=', '!=': '==', is: 'is not', 'is not': 'is' };
+  const op = flip[m[2]!];
+  return op ? `${m[1]!.trim()} ${op} ${m[3]!.trim()}` : null;
+}
+
 /** A `||` outside every bracket and string — the condition is a disjunction as written. */
 function hasTopLevelOr(text: string): boolean {
   let depth = 0;
@@ -100,7 +116,22 @@ function hasTopLevelOr(text: string): boolean {
 }
 
 function isSimpleOperand(text: string): boolean {
-  return /^[\w$.?!]+(?:\([^()]*\))?$/.test(text) && !/[=<>]/.test(text);
+  // A name, a member chain, or one call on it — `Objects.equals(owner.getId(), id)`
+  // included: the parens must balance and nothing may sit outside them.
+  if (/[=<>]/.test(text) || /\s(?:&&|\|\||and|or)\s/.test(text)) return false;
+  const m = /^([\w$.?!]+)(\(.*\))?$/s.exec(text);
+  if (!m) return false;
+  if (!m[2]) return true;
+  let depth = 0;
+  for (let i = 0; i < m[2].length; i++) {
+    const ch = m[2][i]!;
+    if (ch === '(') depth++;
+    else if (ch === ')') {
+      depth--;
+      if (depth === 0 && i < m[2].length - 1) return false;
+    }
+  }
+  return depth === 0;
 }
 
 // =============================================================================
@@ -179,10 +210,16 @@ export interface CallSite {
   line: number;
   /** 0-based; null/undefined = the first non-blank column of the line. */
   column?: number | null;
+  /**
+   * The callee's last segment, when known (`json` for `res.status(201).json(…)`):
+   * a position at the start of a chain sits on the innermost call, and the
+   * climb continues to the call that is actually this one.
+   */
+  callee?: string;
 }
 
 export function siteKey(site: CallSite): string {
-  return `${site.line}:${typeof site.column === 'number' ? site.column : ''}`;
+  return `${site.line}:${typeof site.column === 'number' ? site.column : ''}${site.callee ? `:${site.callee}` : ''}`;
 }
 
 /**
@@ -252,7 +289,21 @@ export function guardsForFileSync(
 }
 
 /** The languages with rules here — what {@link warmBranchGuardGrammars} loads. */
-export const BRANCH_GUARD_LANGUAGES: readonly Language[] = ['typescript', 'tsx', 'javascript', 'jsx', 'swift'];
+export const BRANCH_GUARD_LANGUAGES: readonly Language[] = [
+  'typescript',
+  'tsx',
+  'javascript',
+  'jsx',
+  'swift',
+  'python',
+  'java',
+  'kotlin',
+  'csharp',
+  'go',
+  'c',
+  'cpp',
+  'objc',
+];
 
 // =============================================================================
 // Call arguments — what a site passes
@@ -264,7 +315,16 @@ const MAX_ARGS_TEXT = 96;
 const MAX_ARG_TEXT = 40;
 /** Object keys listed before `…` stands for the rest. */
 const MAX_OBJECT_KEYS = 4;
-const CALL_TYPES: ReadonlySet<string> = new Set(['call_expression', 'new_expression']);
+/** Call nodes, across the grammars with rules here: JS, Swift, Python, Java, Kotlin, C#, Go, C. */
+const CALL_TYPES: ReadonlySet<string> = new Set([
+  'call_expression',
+  'new_expression',
+  'call',
+  'method_invocation',
+  'object_creation_expression',
+  'invocation_expression',
+  'constructor_invocation',
+]);
 const ARGUMENT_CONTAINERS: ReadonlySet<string> = new Set(['arguments', 'value_arguments', 'argument_list']);
 const STRING_TYPES: ReadonlySet<string> = new Set([
   'string',
@@ -272,10 +332,38 @@ const STRING_TYPES: ReadonlySet<string> = new Set([
   'line_string_literal',
   'multi_line_string_literal',
   'raw_string_literal',
+  'string_literal',
+  'interpreted_string_literal',
+  'concatenated_string',
+  'verbatim_string_literal',
+  'interpolated_string_expression',
+  'char_literal',
+]);
+const OBJECT_TYPES: ReadonlySet<string> = new Set(['object', 'object_expression', 'dictionary', 'anonymous_object_creation_expression']);
+const ARRAY_TYPES: ReadonlySet<string> = new Set([
+  'array',
+  'array_literal',
+  'dictionary_literal',
+  'list',
+  'tuple',
+  'set',
+  'list_comprehension',
+  'array_creation_expression',
+  'array_initializer',
+  'initializer_list',
+  'collection_expression',
+  'collection_literal',
+]);
+const FUNCTION_TYPES: ReadonlySet<string> = new Set([
+  'arrow_function',
+  'function_expression',
+  'function',
+  'lambda',
+  'lambda_expression',
+  'func_literal',
+  'anonymous_function',
+  'anonymous_method_expression',
 ]);
-const OBJECT_TYPES: ReadonlySet<string> = new Set(['object', 'object_expression']);
-const ARRAY_TYPES: ReadonlySet<string> = new Set(['array', 'array_literal', 'dictionary_literal']);
-const FUNCTION_TYPES: ReadonlySet<string> = new Set(['arrow_function', 'function_expression', 'function']);
 
 /**
  * The arguments a call site passes, as written, abbreviated to what a reader
@@ -329,25 +417,61 @@ export function callArgumentsInTree(
   line: number,
   column: number | null
 ): string | null {
+  return callSiteInTree(root, source, line, column)?.args ?? null;
+}
+
+/** One call site, both halves: what is called, as written, and what it is passed. */
+export interface CallSiteText {
+  /**
+   * The callee as written, normalised: `prisma.article.findFirst`,
+   * `this.owners.findById().orElseThrow`, `res.status().json` — member
+   * chains kept whole (the index keeps only the last segment of a deep
+   * chain), argument lists emptied, `await`/`new` dropped, `?.` as `.`.
+   */
+  callee: string;
+  /** The argument list, abbreviated as {@link callArgumentsForFile} says. */
+  args: string;
+  /** The same arguments one by one — a registration site's middleware chain is `argList.slice(1, -1)`. */
+  argList: string[];
+}
+
+/** Longest callee text kept before it is cut. */
+const MAX_CALLEE_TEXT = 96;
+
+/** The call node a site belongs to: climb from the callee to the call. A few levels cover a member chain. */
+function callAt(root: SyntaxNode, source: string, line: number, column: number | null, callee?: string): SyntaxNode | null {
   const row = line - 1;
-  const col = column ?? firstNonBlankColumn(source, row);
-  const start = innermostAt(root, row, col);
-  if (!start) return null;
-  // The site's position is on the callee (`setItemAsync` in
-  // `SecureStore.setItemAsync(…)`): climb to the call it belongs to. A few
-  // levels cover a member chain; further up would be another statement.
-  let call: SyntaxNode | null = null;
-  let node: SyntaxNode | null = start;
-  for (let up = 0; node && up < 6; up++, node = node.parent) {
-    if (CALL_TYPES.has(node.type)) {
-      call = node;
-      break;
+  // The recorded column may sit a character off the callee (a 1-based
+  // column, the space before `prisma`): a near miss is tried before giving up.
+  const columns = column === null ? [firstNonBlankColumn(source, row)] : [column, column + 1, Math.max(0, column - 1), firstNonBlankColumn(source, row)];
+  const want = callee ? callee.split(/[.:]/).pop() ?? callee : null;
+  for (const col of columns) {
+    const start = innermostAt(root, row, col);
+    if (!start) continue;
+    let node: SyntaxNode | null = start;
+    let first: SyntaxNode | null = null;
+    for (let up = 0; node && up < 10; up++, node = node.parent) {
+      if (!CALL_TYPES.has(node.type)) continue;
+      if (!first) first = node;
+      if (want === null) return node;
+      // A chain's position is its start: `res.status(201).json(…)` at `res`
+      // meets `res.status(…)` first; the call that is THIS one names `json`.
+      const container = argumentsOf(node);
+      const text = container ? calleeChainText(node, container) : '';
+      if ((text.replace(/\([^()]*\)/g, '').split(/[.:]/).pop() ?? '') === want) return node;
     }
+    if (first) return first;
   }
+  return null;
+}
+
+export function callSiteInTree(root: SyntaxNode, source: string, line: number, column: number | null, want?: string): CallSiteText | null {
+  const call = callAt(root, source, line, column, want);
   if (!call) return null;
   const container = argumentsOf(call);
   if (!container) return null;
-  if (container.type === 'lambda_literal') return '{ … }';
+  const callee = calleeChainText(call, container);
+  if (container.type === 'lambda_literal') return { callee, args: '{ … }', argList: ['{ … }'] };
   const parts: string[] = [];
   for (let i = 0; i < container.namedChildCount; i++) {
     const c = container.namedChild(i);
@@ -355,7 +479,68 @@ export function callArgumentsInTree(
     parts.push(abbreviateArgument(c, source));
   }
   const text = parts.join(', ');
-  return text.length > MAX_ARGS_TEXT ? `${text.slice(0, MAX_ARGS_TEXT - 1)}…` : text;
+  return { callee, args: text.length > MAX_ARGS_TEXT ? `${text.slice(0, MAX_ARGS_TEXT - 1)}…` : text, argList: parts };
+}
+
+/** The text of a call before its arguments, normalised to a member chain. */
+function calleeChainText(call: SyntaxNode, container: SyntaxNode): string {
+  // Kotlin and Swift wrap the arguments in a `call_suffix`; the callee is
+  // everything before that suffix.
+  let end = container.startIndex;
+  const suffix = container.parent && container.parent.type === 'call_suffix' ? container.parent : null;
+  if (suffix) end = suffix.startIndex;
+  let text = collapse(call.text.slice(0, Math.max(0, end - call.startIndex)));
+  text = text.replace(/^(?:await|new|yield|return)\s+/, '').replace(/^(?:await|new)\s+/, '');
+  // Empty every nested argument list, innermost first: `a(b(c)).d` → `a().d`
+  // — keeping one short literal or name (`res.status(404).json`,
+  // `ResponseEntity.status(HttpStatus.NOT_FOUND).body`), which is the fact a
+  // reader of the chain wants.
+  for (let i = 0; i < 6 && /\([^()]*\)/.test(text); i++) {
+    text = text.replace(/\(([^()]*)\)/g, (_m, inner: string) => (/^\s*[\w.]{1,28}\s*$/.test(inner) ? `(${inner.trim()})` : '()'));
+  }
+  text = text
+    .replace(/\?\./g, '.')
+    .replace(/!\./g, '.')
+    .replace(/\s+/g, '')
+    .replace(/<[^<>]*>/g, '')
+    .replace(/\([^()]*\)$/, '');
+  return text.length > MAX_CALLEE_TEXT ? `${text.slice(0, MAX_CALLEE_TEXT - 1)}…` : text;
+}
+
+/** Both halves of every site, keyed by {@link siteKey}, from one cached tree. */
+export async function callSitesForFile(
+  absPath: string,
+  language: Language,
+  sites: readonly CallSite[]
+): Promise<Map<string, CallSiteText>> {
+  const out = new Map<string, CallSiteText>();
+  if (!supportsBranchGuards(language) || sites.length === 0) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  for (const site of sites) {
+    const key = siteKey(site);
+    if (out.has(key)) continue;
+    const found = callSiteInTree(cached.tree.rootNode, cached.source, site.line, site.column ?? null, site.callee);
+    if (found !== null) out.set(key, found);
+  }
+  return out;
+}
+
+/** {@link callSitesForFile} over source text — the test surface. */
+export async function callSiteInSource(
+  source: string,
+  language: Language,
+  line: number,
+  column: number | null
+): Promise<CallSiteText | null> {
+  if (!supportsBranchGuards(language)) return null;
+  const tree = await parse(source, language);
+  if (!tree) return null;
+  try {
+    return callSiteInTree(tree.rootNode, source, line, column);
+  } finally {
+    tree.delete();
+  }
 }
 
 /** The node holding a call's arguments: the `arguments` field, a container child, or Swift's `call_suffix` contents. */
@@ -379,6 +564,24 @@ function argumentsOf(call: SyntaxNode): SyntaxNode | null {
 
 function abbreviateArgument(node: SyntaxNode, source: string): string {
   const type = node.type;
+  // Python `name=value`, C# `name: value` — the name is half the meaning.
+  if (type === 'keyword_argument') {
+    const name = node.childForFieldName('name');
+    const value = node.childForFieldName('value');
+    return `${name?.text ?? ''}=${value ? abbreviateArgument(value, source) : ''}`;
+  }
+  if (type === 'argument') {
+    // C#: an `argument` wraps the expression, optionally with a name.
+    const name = node.childForFieldName('name');
+    const inner = lastNamed(node);
+    const value = inner ? abbreviateArgument(inner, source) : cut(collapse(node.text), MAX_ARG_TEXT);
+    return name && inner && name.id !== inner.id ? `${name.text}: ${value}` : value;
+  }
+  // Go `gin.H{"error": err}` / `User{Name: n}`: the type, then the braces.
+  if (type === 'composite_literal') {
+    const t = node.childForFieldName('type');
+    return `${t ? cut(collapse(t.text), 24) : ''}{…}`;
+  }
   if (STRING_TYPES.has(type)) return cut(collapse(node.text), MAX_ARG_TEXT);
   if (OBJECT_TYPES.has(type)) return objectKeys(node, source);
   if (ARRAY_TYPES.has(type)) return '[…]';
@@ -406,7 +609,7 @@ function abbreviateArgument(node: SyntaxNode, source: string): string {
     }
     return named.length > 0 ? abbreviateArgument(named[named.length - 1]!, source) : cut(collapse(node.text), MAX_ARG_TEXT);
   }
-  if (type === 'lambda_argument' || type === 'trailing_closure') return '{ … }';
+  if (type === 'lambda_argument' || type === 'trailing_closure' || type === 'annotated_lambda') return '{ … }';
   return cut(collapse(node.text), MAX_ARG_TEXT);
 }
 
@@ -446,11 +649,20 @@ function objectKeys(node: SyntaxNode, source: string): string {
  * (`useEffect`, `setTimeout`, `addListener('x')`, `.then`).
  */
 export interface SiteTrigger {
-  kind: 'prop' | 'option' | 'callback';
-  /** `onPress`, `onSubmit`, `useEffect`, `addListener`. */
+  /**
+   * `prop` / `option` / `callback` are read at the site (below). `request`
+   * (a route: `name` the verb, `of` the path), `decorator` (`@Process('email')`:
+   * `name` the decorator, `of` its literal argument) and `load` (a page's own
+   * load-time work) are set by the Steps endpoint from the registration
+   * site, not read from the tree.
+   */
+  kind: 'prop' | 'option' | 'callback' | 'request' | 'decorator' | 'load';
+  /** `onPress`, `onSubmit`, `useEffect`, `addListener`, `POST`, `Process`. */
   name: string;
   /** `Button` for a prop, `useFormik` for an option, the first string argument for a callback; null when unknown. */
   of: string | null;
+  /** What runs before it fires — the middleware / guard chain at the registration site, in order. */
+  after?: string[];
 }
 
 /** Callees whose function argument runs LATER — a callback, not a call. Matched on the last segment. */
@@ -654,7 +866,8 @@ export function guardsInTree(
   }
   let node: SyntaxNode | null = innermostAt(root, row, col);
   if (!node) return [];
-  const rules: Rules = language === 'swift' ? SWIFT : JS;
+  const rules = RULES_BY_LANGUAGE.get(language);
+  if (!rules) return [];
   const found: BranchGuard[] = [];
 
   // Innermost → outermost. `found` is reversed at the end, so within one level
@@ -736,6 +949,9 @@ function condText(node: SyntaxNode | null | undefined): string {
   // `(x)` — the parens are the statement's, not the condition's.
   while (n.type === 'parenthesized_expression' && n.namedChildCount === 1) n = n.namedChild(0)!;
   const text = n.text.replace(/\s+/g, ' ').trim();
+  // A bare keyword is a grammar's error recovery (`let` from an `if let` it
+  // could not parse), never a condition a reader can use.
+  if (/^(?:let|var|case|try|await|guard|if|else|some|any)$/.test(text)) return '';
   return text.length > MAX_TEXT ? text.slice(0, MAX_TEXT - 1) + '…' : text;
 }
 
@@ -894,6 +1110,7 @@ function swiftConditions(node: SyntaxNode): { node: SyntaxNode | null; text: str
   const last = parts[parts.length - 1]!;
   const raw = node.text.slice(first.startIndex - node.startIndex, last.endIndex - node.startIndex);
   const text = raw.replace(/\s+/g, ' ').trim();
+  if (/^(?:let|var|case|try|await)$/.test(text)) return { node: null, text: '' };
   return { node: first, text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT - 1) + '…' : text };
 }
 
@@ -972,3 +1189,815 @@ function indexOf(parent: SyntaxNode, child: SyntaxNode): number {
   for (let i = 0; i < parent.childCount; i++) if (parent.child(i)!.id === child.id) return i;
   return -1;
 }
+
+/** The named children of a node, in order. */
+function namedChildren(node: SyntaxNode): SyntaxNode[] {
+  const out: SyntaxNode[] = [];
+  for (let i = 0; i < node.namedChildCount; i++) out.push(node.namedChild(i)!);
+  return out;
+}
+
+/**
+ * Shared shape of "an early exit before the site": the preceding statements
+ * of the block that are an `if` with no else whose body always leaves.
+ */
+function guardsBefore(
+  parent: SyntaxNode,
+  child: SyntaxNode,
+  out: BranchGuard[],
+  isIf: (s: SyntaxNode) => boolean,
+  hasElse: (s: SyntaxNode) => boolean,
+  body: (s: SyntaxNode) => SyntaxNode | null,
+  alwaysExits: (b: SyntaxNode | null) => boolean,
+  condition: (s: SyntaxNode) => { node: SyntaxNode | null; text: string }
+): void {
+  const before = precedingSiblings(parent, child);
+  for (let i = before.length - 1; i >= 0; i--) {
+    const s = before[i]!;
+    if (!isIf(s) || hasElse(s)) continue;
+    if (!alwaysExits(body(s))) continue;
+    const c = condition(s);
+    push(out, guard('guard', c.node, true, c.text));
+  }
+}
+
+// ------------------------------------------------------------------- Python --
+
+const PY_EXITS = new Set(['return_statement', 'raise_statement', 'break_statement', 'continue_statement']);
+
+function pyAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (PY_EXITS.has(node.type)) return true;
+  if (node.type === 'block') return pyAlwaysExits(lastNamed(node));
+  return false;
+}
+
+function pyOperator(node: SyntaxNode): string {
+  const field = node.childForFieldName('operator');
+  if (field) return field.text;
+  for (let i = 0; i < node.childCount; i++) {
+    const c = node.child(i)!;
+    if (!c.isNamed && (c.text === 'and' || c.text === 'or')) return c.text;
+  }
+  return '';
+}
+
+const PYTHON: Rules = {
+  boundaries: new Set(['function_definition', 'class_definition', 'module']),
+  inlineFunctions: new Set(['lambda']),
+  bindingParents: new Set(['assignment', 'augmented_assignment']),
+  blocks: new Set(['block', 'module']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (child.type === 'elif_clause' || child.type === 'else_clause') {
+          // An elif/else arm runs when the `if` and every earlier elif failed.
+          push(out, guard('else', cond, true));
+          for (const s of precedingSiblings(parent, child)) {
+            if (s.type === 'elif_clause') push(out, guard('else', s.childForFieldName('condition'), true));
+          }
+        }
+        return;
+      }
+      case 'elif_clause': {
+        if (isField(parent, 'consequence', child)) push(out, guard('if', parent.childForFieldName('condition'), false));
+        return;
+      }
+      case 'conditional_expression': {
+        // `a if cond else b`: children are [a, cond, b].
+        const kids = namedChildren(parent);
+        if (kids.length < 3) return;
+        if (child.id === kids[0]!.id) push(out, guard('ternary', kids[1], false));
+        else if (child.id === kids[2]!.id) push(out, guard('ternary', kids[1], true));
+        return;
+      }
+      case 'case_clause': {
+        if (isField(parent, 'consequence', child)) {
+          const stmt = parent.parent?.parent;
+          const subject = condText(stmt?.childForFieldName('subject'));
+          const pattern = namedChildren(parent).find((n) => n.type === 'case_pattern');
+          const value = pattern ? condText(pattern) : '';
+          const isDefault = value === '_' || value === '';
+          const text = isDefault ? (subject ? `${subject}: default` : 'default') : subject ? `${subject} == ${value}` : value;
+          push(out, guard('case', pattern ?? null, false, text));
+        }
+        return;
+      }
+      case 'boolean_operator': {
+        if (!isField(parent, 'right', child)) return;
+        const op = pyOperator(parent);
+        const left = parent.childForFieldName('left');
+        if (op === 'and') push(out, guard('and', left, false));
+        else if (op === 'or') push(out, guard('or', left, true));
+        return;
+      }
+      case 'except_clause':
+      case 'except_group_clause':
+        if (child.type === 'block') push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_statement',
+      (s) => namedChildren(s).some((n) => n.type === 'elif_clause' || n.type === 'else_clause'),
+      (s) => s.childForFieldName('consequence'),
+      pyAlwaysExits,
+      (s) => ({ node: s.childForFieldName('condition'), text: condText(s.childForFieldName('condition')) })
+    );
+  },
+};
+
+// --------------------------------------------------------------------- Java --
+
+const JAVA_EXITS = new Set(['return_statement', 'throw_statement', 'break_statement', 'continue_statement', 'yield_statement']);
+
+function javaAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (JAVA_EXITS.has(node.type)) return true;
+  if (node.type === 'block') return javaAlwaysExits(lastNamed(node));
+  return false;
+}
+
+/** `case A:` / `case A ->` / `default` labels of a Java switch group or rule, as one condition text. */
+function javaCaseText(labels: SyntaxNode[], subject: string): string {
+  const values = labels.map((l) => namedChildren(l).map((n) => condText(n)).filter(Boolean).join(', ')).filter(Boolean);
+  if (values.length === 0) return subject ? `${subject}: default` : 'default';
+  const value = values.join(', ');
+  return subject ? `${subject} == ${value}` : value;
+}
+
+const JAVA: Rules = {
+  boundaries: new Set([
+    'method_declaration',
+    'constructor_declaration',
+    'class_declaration',
+    'class_body',
+    'interface_declaration',
+    'enum_declaration',
+    'record_declaration',
+    'program',
+  ]),
+  inlineFunctions: new Set(['lambda_expression']),
+  bindingParents: new Set(['variable_declarator', 'assignment_expression', 'field_declaration']),
+  blocks: new Set(['block', 'switch_block_statement_group', 'program', 'constructor_body']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('else', cond, true));
+        return;
+      }
+      case 'ternary_expression': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('ternary', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('ternary', cond, true));
+        return;
+      }
+      case 'switch_block_statement_group':
+      case 'switch_rule': {
+        if (child.type === 'switch_label') return;
+        const stmt = parent.parent?.parent;
+        const subject = condText(stmt?.childForFieldName('condition'));
+        const labels = namedChildren(parent).filter((n) => n.type === 'switch_label');
+        push(out, guard('case', labels[0] ?? null, false, javaCaseText(labels, subject)));
+        return;
+      }
+      case 'binary_expression': {
+        if (!isField(parent, 'right', child)) return;
+        const op = parent.childForFieldName('operator')?.text;
+        const left = parent.childForFieldName('left');
+        if (op === '&&') push(out, guard('and', left, false));
+        else if (op === '||') push(out, guard('or', left, true));
+        return;
+      }
+      case 'catch_clause':
+        if (isField(parent, 'body', child)) push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_statement',
+      (s) => !!s.childForFieldName('alternative'),
+      (s) => s.childForFieldName('consequence'),
+      javaAlwaysExits,
+      (s) => ({ node: s.childForFieldName('condition'), text: condText(s.childForFieldName('condition')) })
+    );
+  },
+};
+
+// ------------------------------------------------------------------- Kotlin --
+
+function ktAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (node.type === 'jump_expression') return true;
+  if (node.type === 'control_structure_body' || node.type === 'statements') return ktAlwaysExits(lastNamed(node));
+  return false;
+}
+
+/** The two arms of a Kotlin `if`: the bodies, in order (then, else). */
+function ktArms(ifExpr: SyntaxNode): SyntaxNode[] {
+  return namedChildren(ifExpr).filter((n) => n.type === 'control_structure_body');
+}
+
+const KOTLIN: Rules = {
+  boundaries: new Set([
+    'function_declaration',
+    'secondary_constructor',
+    'class_declaration',
+    'class_body',
+    'object_declaration',
+    'getter',
+    'setter',
+    'source_file',
+  ]),
+  inlineFunctions: new Set(['lambda_literal', 'anonymous_function']),
+  bindingParents: new Set(['property_declaration', 'assignment']),
+  blocks: new Set(['statements', 'function_body', 'source_file']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_expression': {
+        const kids = namedChildren(parent);
+        const cond = kids[0] ?? null;
+        if (cond && child.id === cond.id) return;
+        const arms = ktArms(parent);
+        if (arms[0] && child.id === arms[0].id) push(out, guard('if', cond, false));
+        else if (arms[1] && child.id === arms[1].id) push(out, guard('else', cond, true));
+        return;
+      }
+      case 'when_entry': {
+        if (child.type !== 'control_structure_body') return;
+        const when = parent.parent;
+        const subject = condText(namedChildren(when!).find((n) => n.type === 'when_subject')).replace(/^\((.*)\)$/, '$1');
+        const conds = namedChildren(parent).filter((n) => n.type === 'when_condition');
+        if (conds.length === 0) push(out, guard('case', null, false, subject ? `${subject}: else` : 'else'));
+        else {
+          const value = conds.map((c) => condText(c)).join(', ');
+          push(out, guard('case', conds[0]!, false, subject ? `${subject} == ${value}` : value));
+        }
+        return;
+      }
+      case 'conjunction_expression':
+      case 'disjunction_expression': {
+        const kids = namedChildren(parent);
+        if (kids.length < 2 || child.id !== kids[kids.length - 1]!.id) return;
+        const left = kids[0]!;
+        if (parent.type === 'conjunction_expression') push(out, guard('and', left, false));
+        else push(out, guard('or', left, true));
+        return;
+      }
+      case 'catch_block':
+        if (child.type === 'statements') push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_expression',
+      (s) => ktArms(s).length > 1,
+      (s) => ktArms(s)[0] ?? null,
+      ktAlwaysExits,
+      (s) => {
+        const cond = namedChildren(s)[0] ?? null;
+        return { node: cond, text: condText(cond) };
+      }
+    );
+  },
+};
+
+// ----------------------------------------------------------------------- C# --
+
+const CS_EXITS = new Set(['return_statement', 'throw_statement', 'break_statement', 'continue_statement']);
+
+function csAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (CS_EXITS.has(node.type)) return true;
+  if (node.type === 'block') return csAlwaysExits(lastNamed(node));
+  return false;
+}
+
+const CSHARP: Rules = {
+  boundaries: new Set([
+    'method_declaration',
+    'constructor_declaration',
+    'local_function_statement',
+    'class_declaration',
+    'struct_declaration',
+    'record_declaration',
+    'interface_declaration',
+    'declaration_list',
+    'property_declaration',
+    'accessor_declaration',
+    'compilation_unit',
+  ]),
+  inlineFunctions: new Set(['lambda_expression', 'anonymous_method_expression']),
+  bindingParents: new Set(['variable_declarator', 'assignment_expression', 'equals_value_clause']),
+  blocks: new Set(['block', 'switch_section', 'compilation_unit']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('else', cond, true));
+        return;
+      }
+      case 'conditional_expression': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('ternary', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('ternary', cond, true));
+        return;
+      }
+      case 'switch_section': {
+        const isLabel = (n: SyntaxNode) => /pattern$|switch_label$/.test(n.type);
+        if (isLabel(child)) return;
+        const stmt = parent.parent?.parent;
+        const subject = condText(stmt?.childForFieldName('value'));
+        const labels = namedChildren(parent).filter(isLabel);
+        const value = labels.map((l) => condText(l)).filter(Boolean).join(', ');
+        const text = value === '' ? (subject ? `${subject}: default` : 'default') : subject ? `${subject} == ${value}` : value;
+        push(out, guard('case', labels[0] ?? null, false, text));
+        return;
+      }
+      case 'switch_expression_arm': {
+        if (!isField(parent, 'expression', child)) return;
+        const subject = condText(parent.parent?.childForFieldName('value'));
+        const pattern = parent.childForFieldName('pattern');
+        const value = condText(pattern);
+        const text = value === '_' || value === '' ? (subject ? `${subject}: default` : 'default') : subject ? `${subject} == ${value}` : value;
+        push(out, guard('case', pattern, false, text));
+        return;
+      }
+      case 'binary_expression': {
+        if (!isField(parent, 'right', child)) return;
+        const op = parent.childForFieldName('operator')?.text;
+        const left = parent.childForFieldName('left');
+        if (op === '&&') push(out, guard('and', left, false));
+        else if (op === '||') push(out, guard('or', left, true));
+        return;
+      }
+      case 'catch_clause':
+        if (isField(parent, 'body', child)) push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_statement',
+      (s) => !!s.childForFieldName('alternative'),
+      (s) => s.childForFieldName('consequence'),
+      csAlwaysExits,
+      (s) => ({ node: s.childForFieldName('condition'), text: condText(s.childForFieldName('condition')) })
+    );
+  },
+};
+
+// ----------------------------------------------------------------------- Go --
+
+const GO_EXITS = new Set(['return_statement', 'break_statement', 'continue_statement', 'goto_statement']);
+
+function goAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (GO_EXITS.has(node.type)) return true;
+  if (node.type === 'block') return goAlwaysExits(lastNamed(node));
+  if (node.type === 'expression_statement') {
+    const call = node.namedChild(0);
+    const fn = call?.type === 'call_expression' ? call.childForFieldName('function')?.text : '';
+    return fn === 'panic' || fn === 'os.Exit' || fn === 'log.Fatal' || fn === 'log.Fatalf' || fn === 'log.Fatalln';
+  }
+  return false;
+}
+
+const GO: Rules = {
+  boundaries: new Set(['function_declaration', 'method_declaration', 'source_file']),
+  inlineFunctions: new Set(['func_literal']),
+  bindingParents: new Set(['short_var_declaration', 'var_spec', 'assignment_statement', 'const_spec']),
+  blocks: new Set(['block', 'expression_case', 'default_case', 'type_case', 'communication_case', 'source_file']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('else', cond, true));
+        return;
+      }
+      case 'expression_case':
+      case 'type_case':
+      case 'communication_case': {
+        const value = parent.childForFieldName('value') ?? parent.childForFieldName('type') ?? parent.childForFieldName('communication');
+        if (value && child.id === value.id) return;
+        const stmt = parent.parent;
+        const subject = condText(stmt?.childForFieldName('value'));
+        const v = condText(value);
+        if (parent.type === 'communication_case') push(out, guard('case', value, false, v));
+        else push(out, guard('case', value, false, subject ? `${subject} == ${v}` : v));
+        return;
+      }
+      case 'default_case': {
+        const stmt = parent.parent;
+        const subject = condText(stmt?.childForFieldName('value'));
+        push(out, guard('case', stmt?.childForFieldName('value'), false, subject ? `${subject}: default` : 'default'));
+        return;
+      }
+      case 'binary_expression': {
+        if (!isField(parent, 'right', child)) return;
+        const op = parent.childForFieldName('operator')?.text;
+        const left = parent.childForFieldName('left');
+        if (op === '&&') push(out, guard('and', left, false));
+        else if (op === '||') push(out, guard('or', left, true));
+        return;
+      }
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_statement',
+      (s) => !!s.childForFieldName('alternative'),
+      (s) => s.childForFieldName('consequence'),
+      goAlwaysExits,
+      (s) => ({ node: s.childForFieldName('condition'), text: condText(s.childForFieldName('condition')) })
+    );
+  },
+};
+
+// -------------------------------------------------------------------- C/C++ --
+
+const C_EXITS = new Set(['return_statement', 'break_statement', 'continue_statement', 'goto_statement', 'throw_statement']);
+
+function cAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (C_EXITS.has(node.type)) return true;
+  if (node.type === 'compound_statement') return cAlwaysExits(lastNamed(node));
+  if (node.type === 'expression_statement') {
+    const call = node.namedChild(0);
+    const fn = call?.type === 'call_expression' ? call.childForFieldName('function')?.text : '';
+    return fn === 'exit' || fn === '_exit' || fn === 'abort' || fn === 'longjmp';
+  }
+  return false;
+}
+
+const C: Rules = {
+  boundaries: new Set([
+    'function_definition',
+    'class_specifier',
+    'struct_specifier',
+    'namespace_definition',
+    'translation_unit',
+    'field_declaration_list',
+  ]),
+  inlineFunctions: new Set(['lambda_expression']),
+  bindingParents: new Set(['init_declarator', 'assignment_expression']),
+  blocks: new Set(['compound_statement', 'case_statement', 'translation_unit']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (isField(parent, 'alternative', child) || child.type === 'else_clause') push(out, guard('else', cond, true));
+        return;
+      }
+      case 'conditional_expression': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('ternary', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('ternary', cond, true));
+        return;
+      }
+      case 'case_statement': {
+        const value = parent.childForFieldName('value');
+        if (value && child.id === value.id) return;
+        const stmt = parent.parent?.parent;
+        const subject = condText(stmt?.childForFieldName('condition'));
+        if (!value) push(out, guard('case', stmt?.childForFieldName('condition'), false, subject ? `${subject}: default` : 'default'));
+        else {
+          const v = condText(value);
+          push(out, guard('case', value, false, subject ? `${subject} == ${v}` : v));
+        }
+        return;
+      }
+      case 'binary_expression': {
+        if (!isField(parent, 'right', child)) return;
+        const op = parent.childForFieldName('operator')?.text;
+        const left = parent.childForFieldName('left');
+        if (op === '&&') push(out, guard('and', left, false));
+        else if (op === '||') push(out, guard('or', left, true));
+        return;
+      }
+      case 'catch_clause':
+        if (isField(parent, 'body', child)) push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    guardsBefore(
+      parent,
+      child,
+      out,
+      (s) => s.type === 'if_statement',
+      (s) => !!s.childForFieldName('alternative') || namedChildren(s).some((n) => n.type === 'else_clause'),
+      (s) => s.childForFieldName('consequence'),
+      cAlwaysExits,
+      (s) => ({ node: s.childForFieldName('condition'), text: condText(s.childForFieldName('condition')) })
+    );
+  },
+};
+
+/** The rules per language. A language absent here yields no guards — never a wrong one. */
+const RULES_BY_LANGUAGE: ReadonlyMap<Language, Rules> = new Map<Language, Rules>([
+  ['typescript', JS],
+  ['tsx', JS],
+  ['javascript', JS],
+  ['jsx', JS],
+  ['swift', SWIFT],
+  ['python', PYTHON],
+  ['java', JAVA],
+  ['kotlin', KOTLIN],
+  ['csharp', CSHARP],
+  ['go', GO],
+  ['c', C],
+  ['cpp', C],
+  ['objc', C],
+]);
+
+// =============================================================================
+// Decorators — what is written on a definition
+// =============================================================================
+
+/**
+ * The decorators / annotations / attributes on the definition at a line, and
+ * on the class that holds it: `UseGuards(AuthGuard('jwt'))`,
+ * `PreAuthorize("hasRole('ADMIN')")`, `HttpPost("items")`, `Process('email')`.
+ * Text as written, without the `@` or the brackets, whitespace collapsed,
+ * capped. The index keeps no decorators, so they are read here at request
+ * time like the guards.
+ */
+export interface DefinitionDecorators {
+  own: string[];
+  /** The enclosing class's, when the definition is a member. */
+  class: string[];
+}
+
+const DEFINITION_TYPES: ReadonlySet<string> = new Set([
+  'function_declaration',
+  'function_definition',
+  'method_definition',
+  'method_declaration',
+  'constructor_declaration',
+  'class_declaration',
+  'class_definition',
+  'decorated_definition',
+  'local_function_statement',
+  'lexical_declaration',
+  'variable_declaration',
+  'public_field_definition',
+]);
+const CLASS_TYPES: ReadonlySet<string> = new Set(['class_declaration', 'class_definition', 'class', 'object_declaration', 'struct_declaration', 'record_declaration']);
+const DECORATOR_TYPES: ReadonlySet<string> = new Set(['decorator', 'annotation', 'marker_annotation', 'attribute']);
+const MAX_DECORATOR_TEXT = 80;
+
+export async function decoratorsForFile(
+  absPath: string,
+  language: Language,
+  lines: readonly number[]
+): Promise<Map<number, DefinitionDecorators>> {
+  const out = new Map<number, DefinitionDecorators>();
+  if (!supportsBranchGuards(language) || lines.length === 0) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  for (const line of lines) {
+    if (out.has(line)) continue;
+    const found = decoratorsInTree(cached.tree.rootNode, cached.source, line);
+    if (found !== null) out.set(line, found);
+  }
+  return out;
+}
+
+/** {@link decoratorsForFile} over source text — the test surface. */
+export async function decoratorsInSource(source: string, language: Language, line: number): Promise<DefinitionDecorators | null> {
+  if (!supportsBranchGuards(language)) return null;
+  const tree = await parse(source, language);
+  if (!tree) return null;
+  try {
+    return decoratorsInTree(tree.rootNode, source, line);
+  } finally {
+    tree.delete();
+  }
+}
+
+export function decoratorsInTree(root: SyntaxNode, source: string, line: number): DefinitionDecorators | null {
+  const row = line - 1;
+  const col = firstNonBlankColumn(source, row);
+  let node: SyntaxNode | null = innermostAt(root, row, col);
+  if (!node) return null;
+  // Up to the definition the line belongs to.
+  let definition: SyntaxNode | null = null;
+  for (let up = 0; node && up < 12; up++, node = node.parent) {
+    if (DEFINITION_TYPES.has(node.type)) {
+      definition = node;
+      break;
+    }
+  }
+  if (!definition) return null;
+  // A Python decorated function is the child of the node that holds the decorators.
+  const holder = definition.parent && definition.parent.type === 'decorated_definition' ? definition.parent : definition;
+  const own = decoratorsOn(holder);
+  let cls: SyntaxNode | null = holder.parent;
+  for (let up = 0; cls && up < 6 && !CLASS_TYPES.has(cls.type); up++) cls = cls.parent;
+  const clsHolder = cls && cls.parent && cls.parent.type === 'decorated_definition' ? cls.parent : cls;
+  return { own, class: clsHolder ? decoratorsOn(clsHolder) : [] };
+}
+
+/** Decorator texts on one definition node: its own leading decorator children, its modifiers/attribute lists, or the siblings before it. */
+function decoratorsOn(definition: SyntaxNode): string[] {
+  const out: string[] = [];
+  const add = (n: SyntaxNode) => {
+    if (n.type === 'attribute_list') {
+      for (const a of namedChildren(n)) if (a.type === 'attribute') out.push(decoratorText(a));
+      return;
+    }
+    if (DECORATOR_TYPES.has(n.type)) out.push(decoratorText(n));
+  };
+  for (const c of namedChildren(definition)) {
+    if (c.type === 'modifiers') for (const m of namedChildren(c)) add(m);
+    else add(c);
+  }
+  // JS: decorators are siblings that precede the member in the class body.
+  if (out.length === 0 && definition.parent) {
+    const before = precedingSiblings(definition.parent, definition);
+    for (let i = before.length - 1; i >= 0; i--) {
+      const s = before[i]!;
+      if (s.type !== 'decorator') break;
+      out.unshift(decoratorText(s));
+    }
+  }
+  return out;
+}
+
+function decoratorText(node: SyntaxNode): string {
+  let text = collapse(node.text).replace(/^@\s*/, '');
+  if (node.type === 'attribute_list') text = text.replace(/^\[|\]$/g, '');
+  return cut(text, MAX_DECORATOR_TEXT);
+}
+
+
+// =============================================================================
+// Member types — what a class declares its members to be
+// =============================================================================
+
+/**
+ * The declared types of a class's members, read from the tree: the
+ * constructor's parameter properties (`private readonly usersService:
+ * UsersService`), its fields (`private final OwnerRepository owners`,
+ * `val owners: OwnerRepository`, `private readonly IRepo _repo`), its typed
+ * properties. The index keeps no type for these, and a member call the
+ * extractor kept only the last segment of (`this.usersService.findByEmail`
+ * → `findByEmail`) resolves by name alone; the declared type is what says
+ * where it really goes, and whether it leaves the index.
+ *
+ * Keyed by member name, the type as written without generics'
+ * arguments (`Repository<Cat>` → `Repository<Cat>` is kept whole; callers
+ * strip what they need).
+ */
+export async function memberTypesForFile(absPath: string, language: Language, line: number): Promise<Map<string, string>> {
+  const out = new Map<string, string>();
+  if (!supportsBranchGuards(language)) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  return memberTypesInTree(cached.tree.rootNode, cached.source, line);
+}
+
+/** {@link memberTypesForFile} over source text — the test surface. */
+export async function memberTypesInSource(source: string, language: Language, line: number): Promise<Map<string, string>> {
+  if (!supportsBranchGuards(language)) return new Map();
+  const tree = await parse(source, language);
+  if (!tree) return new Map();
+  try {
+    return memberTypesInTree(tree.rootNode, source, line);
+  } finally {
+    tree.delete();
+  }
+}
+
+const CLASS_BODY_TYPES: ReadonlySet<string> = new Set(['class_body', 'declaration_list', 'field_declaration_list']);
+
+export function memberTypesInTree(root: SyntaxNode, source: string, line: number): Map<string, string> {
+  const out = new Map<string, string>();
+  const row = line - 1;
+  let node: SyntaxNode | null = innermostAt(root, row, firstNonBlankColumn(source, row));
+  let cls: SyntaxNode | null = null;
+  for (let up = 0; node && up < 16; up++, node = node.parent) {
+    if (CLASS_TYPES.has(node.type)) {
+      cls = node;
+      break;
+    }
+  }
+  if (!cls) return out;
+  const typeText = (n: SyntaxNode | null | undefined): string => (n ? collapse(n.text).replace(/^:\s*/, '').trim() : '');
+  const put = (name: string | null | undefined, type: string) => {
+    if (name && type && !out.has(name)) out.set(name, type);
+  };
+  const visitParams = (params: SyntaxNode | null) => {
+    if (!params) return;
+    for (const p of namedChildren(params)) {
+      // TS: `private readonly x: T` (a parameter property); Kotlin: `val x: T`; C#/Java: `T x` — a field of the same name may follow.
+      if (p.type === 'required_parameter' || p.type === 'optional_parameter') {
+        if (!namedChildren(p).some((c) => c.type === 'accessibility_modifier' || c.type === 'override_modifier') && !/^\s*(?:public|private|protected|readonly)\b/.test(p.text)) continue;
+        put(p.childForFieldName('pattern')?.text, typeText(p.childForFieldName('type')));
+      } else if (p.type === 'class_parameter') {
+        const kids = namedChildren(p);
+        const name = kids.find((c) => c.type === 'simple_identifier');
+        const type = kids.find((c) => c.type === 'user_type' || c.type === 'nullable_type');
+        if (kids.some((c) => c.type === 'binding_pattern_kind')) put(name?.text, typeText(type));
+      } else if (p.type === 'parameter' || p.type === 'formal_parameter') {
+        put(p.childForFieldName('name')?.text, typeText(p.childForFieldName('type')));
+      }
+    }
+  };
+  // Kotlin's primary constructor sits on the class node itself.
+  for (const c of namedChildren(cls)) if (c.type === 'primary_constructor') visitParams(namedChildren(c).find((n) => n.type === 'class_parameters') ?? c);
+  const body = namedChildren(cls).find((c) => CLASS_BODY_TYPES.has(c.type)) ?? cls.childForFieldName('body');
+  if (!body) return out;
+  for (const m of namedChildren(body)) {
+    switch (m.type) {
+      case 'public_field_definition':
+      case 'field_definition':
+        put(m.childForFieldName('name')?.text, typeText(m.childForFieldName('type')));
+        break;
+      case 'method_definition':
+        if (m.childForFieldName('name')?.text === 'constructor') visitParams(m.childForFieldName('parameters'));
+        break;
+      case 'field_declaration': {
+        // Java: `type` + `declarator`; C#: a `variable_declaration` inside.
+        const type = m.childForFieldName('type');
+        if (type) {
+          for (const d of namedChildren(m)) if (d.type === 'variable_declarator') put(d.childForFieldName('name')?.text, typeText(type));
+        } else {
+          const decl = namedChildren(m).find((c) => c.type === 'variable_declaration');
+          const t = decl?.childForFieldName('type');
+          for (const d of decl ? namedChildren(decl) : []) if (d.type === 'variable_declarator') put(d.childForFieldName('name')?.text, typeText(t));
+        }
+        break;
+      }
+      case 'property_declaration': {
+        // C#: `type` + `name`; Kotlin: `variable_declaration (name) (type)`.
+        const csType = m.childForFieldName('type');
+        if (csType) put(m.childForFieldName('name')?.text, typeText(csType));
+        else {
+          const decl = namedChildren(m).find((c) => c.type === 'variable_declaration');
+          const kids = decl ? namedChildren(decl) : [];
+          const name = kids.find((c) => c.type === 'simple_identifier');
+          const type = kids.find((c) => c.type === 'user_type' || c.type === 'nullable_type');
+          put(name?.text, typeText(type));
+        }
+        break;
+      }
+      case 'constructor_declaration':
+        visitParams(m.childForFieldName('parameters'));
+        break;
+      default:
+        break;
+    }
+  }
+  return out;
+}

+ 16 - 0
src/resolution/frameworks/python.ts

@@ -242,6 +242,22 @@ export const fastapiResolver: FrameworkResolver = {
       const content = context.readFile(file);
       if (content && content.includes('FastAPI(')) return true;
     }
+    // A service that is one directory of a monorepo (`backend/pyproject.toml`,
+    // `backend/app/main.py`): its manifest or its app object sits below the root.
+    let looked = 0;
+    for (const file of context.getAllFiles()) {
+      const norm = file.replace(/\\/g, '/');
+      const base = norm.slice(norm.lastIndexOf('/') + 1);
+      if (base === 'requirements.txt' || base === 'pyproject.toml' || base === 'requirements-dev.txt') {
+        const content = context.readFile(file);
+        if (content && /\bfastapi\b/i.test(content)) return true;
+        if (++looked >= 40) break;
+      } else if ((base === 'main.py' || base === 'app.py' || base === 'api.py') && norm.split('/').length <= 4) {
+        const content = context.readFile(file);
+        if (content && content.includes('FastAPI(')) return true;
+        if (++looked >= 40) break;
+      }
+    }
     return false;
   },
 

+ 11 - 2
src/search/query-utils.ts

@@ -338,7 +338,11 @@ export function isTestPath(filePath: string): boolean {
     // CamelCase test source-set dirs (Kotlin Multiplatform / Gradle / Xcode):
     // jvmTest/, commonTest/, androidTest/, iosTest/, integrationTest/. Capital-led
     // so "latest/" / "manifest/" are not matched.
-    /(?:^|\/)[A-Za-z0-9]*(?:Test|Tests|Spec)\//.test(filePath)
+    /(?:^|\/)[A-Za-z0-9]*(?:Test|Tests|Spec)\//.test(filePath) ||
+    // Test-support modules and doubles by directory name: Gradle's
+    // `core/data-test/`, `core/datastore-test/`, `:testing`; Go's `testdata/`;
+    // `testutil(s)/`, `test-utils/`, `fakes/`, `mocks/`, `__mocks__/`.
+    /(?:^|\/)(?:[\w.]+[-_]test(?:s|ing)?|testdata|testutils?|test[-_]utils?|fakes?|mocks?|__mocks__|stubs)\//.test(lower)
   ) {
     return true;
   }
@@ -355,8 +359,13 @@ function matchesNonProductionDir(lowerPath: string): boolean {
     'integration', 'sample', 'samples', 'example', 'examples',
     'fixture', 'fixtures', 'benchmark', 'benchmarks', 'demo', 'demos',
   ];
+  // Only the project layout above a `src/` counts, never the package path
+  // below it: `core/data/src/main/kotlin/com/google/samples/apps/…` is a
+  // Google sample by name and production code by layout.
+  const src = lowerPath.indexOf('/src/');
+  const scope = src >= 0 ? lowerPath.slice(0, src + 1) : lowerPath.startsWith('src/') ? '' : lowerPath;
   for (const dir of dirs) {
-    if (lowerPath.includes('/' + dir + '/') || lowerPath.startsWith(dir + '/')) {
+    if (scope.includes('/' + dir + '/') || scope.startsWith(dir + '/')) {
       return true;
     }
   }

+ 509 - 0
src/ui-server/api/effects.ts

@@ -0,0 +1,509 @@
+/**
+ * Effects — calls that leave the index and change something outside the
+ * process — for the Steps view. A curated table, deliberately: "any call into
+ * a package" is every `Date` and `Math.max`, and a box for each would bury
+ * the ones that matter. Matched on the CALL AS WRITTEN — the whole member
+ * chain read from the source at request time (`prisma.article.findFirst`,
+ * `this.jwtService.signAsync`, `res.status(404).json`), because the index
+ * keeps only the last segment of a deep chain — and, for a few families, on
+ * the declared type of the receiver when the graph has it (`OwnerRepository
+ * owners` → `owners.save` is the database).
+ *
+ * The categories are the reader's, not a library's: what a request sets in
+ * motion is a database write, a response, a job on a queue, an email, a
+ * charge, a cache read, a token check, a call to another service, a file, a
+ * process. `response` is the endpoint's contract as the code has it — every
+ * `res.status(404).json(…)`, `throw new NotFoundException(…)`,
+ * `raise HTTPException(…)`, `ResponseEntity.notFound()` — and each site
+ * carries the status code when it is literal.
+ *
+ * Language gates keep a rule from firing where it means something else: a
+ * bare `open(…)` is a file in Python and nothing in particular in TypeScript;
+ * `session.get` is the ORM in Python and a request in the browser. A rule
+ * without a gate applies everywhere. Order matters — the first match wins —
+ * so the specific rows sit above the general ones.
+ */
+
+import type { Language } from '../../types';
+
+export type EffectCategory =
+  | 'network'
+  | 'storage'
+  | 'device'
+  | 'telemetry'
+  | 'database'
+  | 'response'
+  | 'queue'
+  | 'email'
+  | 'payments'
+  | 'cache'
+  | 'auth'
+  | 'process';
+
+export type ProjectKind = 'app' | 'api' | 'web';
+
+type Family = 'js' | 'py' | 'jvm' | 'cs' | 'go' | 'c' | 'rb' | 'php' | 'swift' | 'rs';
+
+const FAMILIES: Record<Family, ReadonlySet<Language>> = {
+  js: new Set<Language>(['javascript', 'typescript', 'tsx', 'jsx']),
+  py: new Set<Language>(['python']),
+  jvm: new Set<Language>(['java', 'kotlin', 'scala']),
+  cs: new Set<Language>(['csharp']),
+  go: new Set<Language>(['go']),
+  c: new Set<Language>(['c', 'cpp', 'objc']),
+  rb: new Set<Language>(['ruby']),
+  php: new Set<Language>(['php']),
+  swift: new Set<Language>(['swift']),
+  rs: new Set<Language>(['rust']),
+};
+
+export interface EffectRule {
+  category: EffectCategory;
+  test: RegExp;
+  /** Families the rule applies to; absent = every language. */
+  only?: readonly Family[];
+  /** Only for `new X(…)` / a constructor call. */
+  instantiates?: boolean;
+}
+
+/** Built-in receivers a `Type.op` rule must never take for a model. */
+const BUILTIN_RECEIVERS = /^(?:Object|Array|Promise|Date|Map|Set|WeakMap|WeakSet|Reflect|Buffer|JSON|Math|Number|String|Symbol|Error|BigInt|Intl|Atomics|Proxy|Function|Boolean|RegExp|globalThis|window|document|console|process|List|Dict|Optional|Collections|Arrays|Objects|Stream|Task|Enumerable|Convert|Guid|DateTime|TimeSpan|Path|Regex)$/;
+
+const ORM_STATIC_OPS =
+  '(?:find|findOne|findAll|findById|findByPk|findMany|findFirst|findUnique|findOrCreate|findAndCountAll|create|createMany|update|updateOne|updateMany|upsert|delete|deleteOne|deleteMany|destroy|save|insert|insertMany|aggregate|count|countDocuments|bulkCreate|bulkWrite|exists|distinct|where|query|all|first|last|pluck|find_by|find_each|find_or_create_by|create!|update!|destroy_all|delete_all|update_all|insert_all|upsert|includes|joins|order|limit|scope|select)';
+
+/** A method that reads or writes a store — what makes `userModel.find` the database and `viewModel.load` nothing. */
+const DB_OP =
+  '(?:find\\w*|get|getOne|getMany|getAll|getById|count\\w*|aggregate|create\\w*|update\\w*|upsert|delete\\w*|remove\\w*|save\\w*|insert\\w*|exists|query\\w*|execute\\w*|exec|raw|persist|merge|flush|first\\w*|last\\w*|all|any\\w*|toList\\w*|toArray\\w*|select\\w*|where|orderBy|distinct|paginate|truncate|drop|Add\\w*|Remove\\w*|Update\\w*|Find\\w*|First\\w*|Single\\w*|ToList\\w*|Count\\w*|Any\\w*|Create|Save|Delete|Where|Get|Select|Query\\w*|Exec\\w*|InsertOne|InsertMany|FindOne|UpdateOne|DeleteOne|DeleteMany|ReplaceOne|CountDocuments|bulk\\w*|batch\\w*)';
+
+/** The table. Order matters: first match wins. */
+export const EFFECT_RULES: ReadonlyArray<EffectRule> = [
+  // ---------------------------------------------------------------- response --
+  {
+    category: 'response',
+    test: /^(?:res|response|reply|rep|ctx|c|context)(?:\.(?:status|sendStatus|code|type|header|headers|set|append|cookie|clearCookie|vary|location|links|format))*\.(?:status|json|jsonp|send|sendStatus|end|redirect|render|sendFile|download|attachment|write|writeHead|code|type|header|set|cookie|body|text|html|notFound|stream|file|view|throw|assert)$/,
+    only: ['js'],
+  },
+  { category: 'response', test: /^(?:NextResponse|Response)\.(?:json|redirect|rewrite|next|error)$|^(?:createError|createHttpError|httpErrors\.\w+|Boom\.\w+|boom\.\w+|HttpError|HTTPError)$/, only: ['js'] },
+  { category: 'response', test: /^(?:NextResponse|Response)$/, only: ['js'], instantiates: true },
+  {
+    category: 'response',
+    test: /^(?:HTTPException|JSONResponse|Response|RedirectResponse|StreamingResponse|FileResponse|HTMLResponse|PlainTextResponse|ORJSONResponse|jsonify|abort|make_response|redirect|render_template|send_file|send_from_directory|JsonResponse|HttpResponse|HttpResponseRedirect|HttpResponsePermanentRedirect|HttpResponseNotFound|HttpResponseBadRequest|HttpResponseForbidden|HttpResponseNotAllowed|HttpResponseServerError|Http404|render|get_object_or_404|get_list_or_404|NotFound|PermissionDenied|ValidationError|AuthenticationFailed|NotAuthenticated|ParseError|MethodNotAllowed|Throttled|APIException|status\.HTTP_\w+)$/,
+    only: ['py'],
+  },
+  { category: 'response', test: /^ResponseEntity(?:\.\w+)*$|^ResponseStatusException$|^(?:ServerResponse|Mono\.just\(ResponseEntity)/, only: ['jvm'] },
+  {
+    category: 'response',
+    test: /^(?:Ok|NotFound|BadRequest|Created|CreatedAtAction|CreatedAtRoute|NoContent|Unauthorized|Forbid|Accepted|AcceptedAtAction|Problem|StatusCode|Conflict|UnprocessableEntity|Redirect|RedirectPermanent|RedirectToAction|RedirectToPage|RedirectToRoute|LocalRedirect|View|PartialView|Json|File|PhysicalFile|Content|Challenge|SignIn|SignOut|ValidationProblem|Page)$|^(?:Results|TypedResults)\.\w+$|^(?:Response|HttpContext\.Response)\.(?:WriteAsync|WriteAsJsonAsync|Redirect|StatusCode)$/,
+    only: ['cs'],
+  },
+  {
+    category: 'response',
+    test: /^(?:c|ctx|g)\.(?:JSON|IndentedJSON|PureJSON|AsciiJSON|SecureJSON|JSONP|XML|YAML|TOML|ProtoBuf|String|HTML|Data|DataFromReader|File|FileAttachment|Redirect|Render|Status|AbortWithStatus|AbortWithStatusJSON|AbortWithError|Abort|Error|SendString|SendStatus|Send|Blob|Stream|NoContent|Attachment|Format)$|^http\.(?:Error|Redirect|NotFound|ServeFile|ServeContent)$|^(?:w|rw|res|writer)\.(?:WriteHeader|Write|Header)$|^(?:render|json|xml)\.\w+$/,
+    only: ['go'],
+  },
+  { category: 'response', test: /^(?:Abort|Response|HTTPStatus\.\w+|req\.redirect|request\.redirect)$/, only: ['swift'] },
+  { category: 'response', test: /^(?:render|redirect_to|redirect_back|head|respond_to|respond_with|send_data|send_file|render_to_string)$/, only: ['rb'] },
+  { category: 'response', test: /^(?:response|abort|abort_if|abort_unless|redirect|view|back|json)(?:\(\)->\w+)?$/, only: ['php'] },
+  { category: 'response', test: /^(?:HttpResponse|Json|StatusCode|Redirect|NamedFile|HttpResponseBuilder)(?:::\w+)*$/, only: ['rs'] },
+
+  // ---------------------------------------------------------------- database --
+  { category: 'database', test: /^(?:this\.)?(?:prisma|db|database|orm|em|entityManager|dataSource|queryRunner|knex|kysely|sequelize|mongoose|drizzle|sql|pool|pg|pgClient|conn|connection|repo|repository|collection|trx|tx|typeorm|dbClient|mongo|mongoClient)\.(?:\w+\.)?\$?\w+$/, only: ['js'] },
+  { category: 'database', test: /^(?:this\.)?_?\w*(?:Repository|Repo|Dao|DAO|Mapper|EntityManager|DataSource|Knex|Prisma|Kysely|Drizzle|Sequelize|DbContext|DbSet)\.(?:\w+\.)?\w+$/, only: ['js', 'jvm', 'cs', 'go', 'rs', 'swift', 'rb'] },
+  { category: 'database', test: new RegExp(`^(?:this\\.)?_?\\w*(?:Model|Entity|Collection|Table|Db|DB|Database|Datastore)\\.(?:\\w+\\.)?${DB_OP}$`), only: ['js', 'jvm', 'cs', 'go', 'rs', 'swift', 'rb'] },
+  { category: 'database', test: new RegExp(`^(?!${BUILTIN_RECEIVERS.source.slice(1, -1)}\\b)[A-Z]\\w*\\.${ORM_STATIC_OPS}$`), only: ['js', 'rb', 'swift', 'php'] },
+  { category: 'database', test: /^(?:self\.)?(?:\w*_)?(?:session|db|database|engine|cursor|conn|connection|Session)\.(?:session\.)?(?:query|add|add_all|commit|execute|executemany|exec|delete|refresh|flush|rollback|get|merge|scalars?|scalar_one\w*|one|one_or_none|first|all|begin|close|expunge|bulk_\w+|select|insert|update|fetchone|fetchall|fetchmany|create_all|drop_all|run_sync)$/, only: ['py'] },
+  { category: 'database', test: /^[A-Z]\w*\.(?:objects|query|_default_manager)(?:\.\w+)*$|^\w+\.objects\.\w+$|^(?:select|insert|update|delete|text|func\.\w+|bulk_create|bulk_update|get_object_or_404)$|^\w+\.(?:save|delete|refresh_from_db|get_or_create|update_or_create|filter|exclude|annotate|aggregate|values|values_list|select_related|prefetch_related|bulk_create|bulk_update|create|update|count|exists)$/, only: ['py'] },
+  { category: 'database', test: /^(?:this\.)?(?:\w*[rR]epository|\w*[rR]epo|\w*Dao|\w*DAO|\w*Mapper|jdbcTemplate|namedParameterJdbcTemplate|jdbc|entityManager|em|session|sessionFactory|mongoTemplate|mongoOperations|r2dbcEntityTemplate|databaseClient|criteriaBuilder|query|typedQuery|nativeQuery|jpaRepository|crudRepository|dsl|dslContext|create|template)\.(?:\w+\.)*\w+$/, only: ['jvm'] },
+  { category: 'database', test: /^(?:this\.)?_?(?:\w*[rR]epository|\w*[rR]epo|\w*Dao|\w*[cC]ontext|dbContext|db|_db|_context|connection|_connection|conn|_conn|collection|_collection|session|_session|unitOfWork|_unitOfWork|uow|_uow|dbSet|_dbSet)\.(?:\w+\.)*\w+$/, only: ['cs'] },
+  { category: 'database', test: /\.(?:SaveChanges|SaveChangesAsync|ToListAsync|ToArrayAsync|FirstOrDefaultAsync|SingleOrDefaultAsync|FirstAsync|SingleAsync|AnyAsync|CountAsync|ExecuteUpdateAsync|ExecuteDeleteAsync|ExecuteSqlRawAsync|ExecuteSqlAsync|FromSqlRaw|FromSql|AddAsync|AddRangeAsync|FindAsync|QueryAsync|QueryFirstOrDefaultAsync|QuerySingleAsync|ExecuteAsync|ExecuteScalarAsync|InsertOneAsync|InsertManyAsync|ReplaceOneAsync|UpdateOneAsync|DeleteOneAsync|DeleteManyAsync)$/, only: ['cs'] },
+  { category: 'database', test: /^(?:db|DB|tx|conn|pool|dbConn|client|repo|store|coll|collection|session|s\.db|r\.db|h\.db|s\.DB|h\.DB|a\.db|app\.db|q|queries|s\.queries|gorm|sqlx|dbx)\.(?:\w+\.)*(?:Query\w*|Exec\w*|Prepare\w*|Begin\w*|Create|First|Find|Save|Delete|Where|Updates?|Model|Table|Raw|Scan|Get|Select|NamedExec|InsertOne|InsertMany|FindOne|UpdateOne|UpdateMany|DeleteOne|DeleteMany|ReplaceOne|CountDocuments|Count|Take|Last|Preload|Joins|Order|Limit|Offset|Transaction|AutoMigrate|Migrate|Insert|Update|Upsert|Aggregate|Distinct|Pluck|Rows|Row|Set|Get\w+|List\w+|Create\w+|Update\w+|Delete\w+)$/, only: ['go'] },
+  { category: 'database', test: /^[A-Z]\w*\.(?:query|find|create|all|first|last|save|delete|update)$|^\w+\.(?:save|create|delete|update|query)$|^(?:req|request)\.db\.\w+$/, only: ['swift'] },
+  { category: 'database', test: /^(?:@?\w+)\.(?:save|save!|update|update!|update_attributes|destroy|destroy!|reload|touch|increment!|decrement!)$|^ActiveRecord::Base\.\w+$|^\w+\.(?:where|find|find_by|find_each|first|last|all|create|create!|pluck|count|exists\?|order|includes|joins|delete_all|update_all|insert_all|upsert_all|find_or_create_by)$/, only: ['rb'] },
+  { category: 'database', test: /^[A-Z]\w*::(?:find|findOrFail|findMany|create|firstOrCreate|updateOrCreate|where|whereIn|all|first|firstOrFail|query|insert|update|destroy|truncate|count|with|select|orderBy|paginate)$|^DB::\w+$|^\$\w+->(?:save|delete|update|create|fill|refresh|forceDelete|restore|increment|decrement|touch)$/, only: ['php'] },
+  { category: 'database', test: /^(?:sqlx|diesel|sea_orm|mongodb)(?:::\w+)*$|^\w+(?:::\w+)*::(?:find|find_by_id|insert|update|delete|save|find_many|find_one|filter)$|^(?:conn|pool|tx|db|client)\.(?:execute|query\w*|prepare|begin|commit|rollback|fetch\w*)$/, only: ['rs'] },
+  { category: 'database', test: /^(?:sqlite3_\w+|mysql_\w+|PQ\w+|SQLExec\w*|SQLPrepare|SQLFetch\w*|redis\w*Command|mongoc_\w+)$/, only: ['c'] },
+
+  // ------------------------------------------------------------------- queue --
+  { category: 'queue', test: /^(?:this\.)?(?:\w*[qQ]ueue\w*)\.(?:add|addBulk|process|createJob|send|sendMessage|publish|push|enqueue)$|^(?:this\.)?(?:sqs|sqsClient|sns|snsClient|pubsub|topic|producer|kafka|kafkaProducer|rabbit|channel|amqp|nats|nc|eventBridge|bus|messageBus|eventBus|agenda|boss|pgBoss|inngest|trigger|client\.queue)\.(?:\w+\.)*(?:send|sendMessage|sendMessageBatch|publish|publishMessage|produce|emit|add|schedule|now|enqueue|sendToQueue|put|dispatch|trigger|createJob|createSchedule|invoke|batch)$|^(?:agenda|boss|pgBoss|inngest)\.\w+$/, only: ['js'] },
+  { category: 'queue', test: /^(?:SendMessageCommand|PublishCommand|PutEventsCommand|SendMessageBatchCommand|InvokeCommand)$/, only: ['js'], instantiates: true },
+  { category: 'queue', test: /^\w+\.(?:delay|apply_async|send_task|si|s|enqueue|enqueue_call|enqueue_in|enqueue_at|send_message|send_message_batch|basic_publish|publish|produce|put_events|invoke)$|^(?:celery|app|current_app)\.send_task$|^(?:sqs|sns|producer|channel|queue|q|redis_queue|dramatiq|huey)\.\w+$/, only: ['py'] },
+  { category: 'queue', test: /^(?:this\.)?(?:\w*[tT]emplate|\w*[pP]ublisher|\w*[pP]roducer|\w*[qQ]ueue\w*|sqsClient|snsClient|amazonSQS|amazonSNS|eventBus|messageBus|bus|channel|rabbitTemplate|kafkaTemplate|jmsTemplate|streamBridge|eventPublisher|applicationEventPublisher)\.(?:send\w*|convertAndSend|publish\w*|publishEvent|produce|sendMessage|put\w*|emit|dispatch|enqueue)$/, only: ['jvm'] },
+  { category: 'queue', test: /^(?:this\.)?_?(?:bus|publishEndpoint|sendEndpoint|producer|queue\w*|channel|messageSession|serviceBus|serviceBusSender|topicClient|queueClient|eventGrid|BackgroundJob|RecurringJob|BackgroundJobClient|jobClient|_jobs|jobs)\.(?:\w+\.)*(?:Publish\w*|Send\w*|Produce\w*|Enqueue\w*|Schedule\w*|AddOrUpdate|BasicPublish|SendMessageAsync|SendMessagesAsync|Create\w*Message|Dispatch\w*|Trigger\w*)$/, only: ['cs'] },
+  { category: 'queue', test: /^(?:\w+\.)*(?:Publish|PublishMsg|PublishAsync|SendMessage|SendMessageWithContext|Produce|ProduceSync|Enqueue|EnqueueContext|PutEvents|WriteMessages|Emit|Dispatch|Schedule|SendMsg)$/, only: ['go'] },
+  { category: 'queue', test: /^\w+\.(?:perform_later|perform_async|perform_in|perform_at|deliver_later|set|publish|enqueue)$|^Sidekiq::Client\.\w+$/, only: ['rb'] },
+  { category: 'queue', test: /^(?:dispatch|dispatch_now|dispatch_sync|Queue::\w+|Bus::\w+|Event::dispatch|event|broadcast)$|^\w+::dispatch(?:Sync|Now|AfterResponse)?$|^\$\w+->dispatch$/, only: ['php'] },
+
+  // ------------------------------------------------------------------- email --
+  { category: 'email', test: /^(?:this\.)?(?:\w*[mM]ail\w*|\w*[tT]ransporter|sgMail|sendgrid|resend|mailgun|postmark|ses|sesClient|sesv2|smtp|nodemailer|courier|brevo|sendinblue|mailjet|mandrill|loops|plunk)\.(?:\w+\.)*(?:send\w*|sendMail|sendEmail|sendTemplate|create|deliver|emails\.send|transactional\w*)$|^(?:SendEmailCommand|SendTemplatedEmailCommand|SendRawEmailCommand)$/, only: ['js'] },
+  { category: 'email', test: /^(?:send_mail|send_mass_mail|mail_admins|mail_managers|EmailMessage|EmailMultiAlternatives|mail\.send|mail\.send_message|smtplib\.SMTP|smtplib\.SMTP_SSL|ses\.send_email|ses\.send_raw_email|sg\.send|sendgrid\.\w+|resend\.Emails\.send|postmark\.\w+)$|^\w+\.send_(?:email|mail|message)$|^(?:smtp|server|mailer|email_client)\.(?:sendmail|send_message|send|login|starttls)$/, only: ['py'] },
+  { category: 'email', test: /^(?:this\.)?(?:\w*[mM]ailSender|\w*[mM]ailer|mailService|emailService|sesClient|amazonSimpleEmailService|transport|Transport)\.(?:send\w*|deliver)$|^Transport\.send$/, only: ['jvm'] },
+  { category: 'email', test: /^(?:this\.)?_?(?:emailSender|emailService|mailService|mailer|smtpClient|sendGridClient|sesClient|fluentEmail|email)\.(?:Send\w*)$|^Email\.(?:From|Send\w*)$/, only: ['cs'] },
+  { category: 'email', test: /^(?:smtp\.SendMail|mail\.Send\w*|\w+\.SendEmail\w*|\w+\.SendMail|ses\.SendEmail|sg\.Send|mg\.Send)$/, only: ['go'] },
+  { category: 'email', test: /^\w+Mailer\.\w+$|^\w+\.(?:deliver_now|deliver_later|deliver)$|^Mail\.deliver$/, only: ['rb'] },
+  { category: 'email', test: /^Mail::(?:to|send|raw|queue|bcc|cc)$|^Notification::send$|^\$\w+->notify$/, only: ['php'] },
+
+  // ---------------------------------------------------------------- payments --
+  { category: 'payments', test: /^(?:this\.)?(?:stripe|Stripe|braintree|paypal|PayPal|square|razorpay|paddle|Paddle|adyen|mollie|chargebee|recurly|lemonSqueezy|lemonsqueezy|checkout|gateway|paymentGateway|paymentsClient|paymentService|_paymentService|stripeClient|stripeService|_stripe)\b/ },
+
+  // ------------------------------------------------------------------- cache --
+  { category: 'cache', test: /^(?:this\.)?(?:redis|redisClient|ioredis|cache|cacheManager|cacheService|memcached|memcache|kv|KV|upstash|_cache|_distributedCache|_memoryCache|distributedCache|memoryCache|redisTemplate|stringRedisTemplate|jedis|lettuce|redisson|rdb|rc|cacheClient|caches|Cache|env\.\w*KV\w*)\.(?:\w+\.)*(?:get\w*|set\w*|del\w*|delete\w*|remove\w*|incr\w*|decr\w*|expire\w*|exists|has|hget\w*|hset\w*|hdel|hgetall|lpush|rpush|lpop|rpop|sadd|srem|smembers|zadd|zrange\w*|zrem|wrap|mget|mset|setex|setnx|ttl|keys|flush\w*|reset|clear|store|put\w*|evict|invalidate\w*|GetOrCreate\w*|GetString\w*|SetString\w*|GetOrSet\w*|Refresh\w*|Get|Set|Del|Delete|Exists|Expire|Incr|Decr|HGet\w*|HSet\w*|Publish|Subscribe|TryGetValue|CreateEntry|opsForValue|opsForHash|opsForList|opsForSet|Fetch|fetch|read|write|delete_pattern|delete_many|get_many|set_many|touch|add|remember|rememberForever|forget|pull|increment|decrement|forever|tags|memoize)$/ },
+
+  // -------------------------------------------------------------------- auth --
+  { category: 'auth', test: /^(?:this\.)?(?:jwt|jsonwebtoken|jose|SignJWT|jwtVerify|bcrypt|bcryptjs|argon2|scrypt|passport|jwtService|_jwtService|tokenService|authService\.sign\w*|auth\.api|auth\.\w+|betterAuth|clerk|clerkClient|supabase\.auth|firebase\.auth|admin\.auth|getAuth|getServerSession|getSession|auth|lucia|nextauth|NextAuth|verifyIdToken|verifyToken|signToken|createSession|invalidateSession|oauth2|OAuth2Client|okta|auth0|cognito|CognitoIdentityServiceProvider|Keychain|crypto\.timingSafeEqual|crypto\.pbkdf2\w*|crypto\.scrypt\w*|crypto\.createHmac)\b(?:\.(?:\w+\.)*\w+)?$/, only: ['js'] },
+  { category: 'auth', test: /^(?:jwt|pyjwt|jose|bcrypt|argon2|passlib|pwd_context|password_hasher|hashers|check_password|make_password|authenticate|login|logout|login_required|get_user|verify_password|get_password_hash|create_access_token|create_refresh_token|decode_token|OAuth2PasswordBearer|HTTPBearer|HTTPBasic|Depends\(get_current_user\)|secrets\.\w+|hmac\.\w+|hashlib\.\w+|itsdangerous|serializer\.dumps|serializer\.loads|token_urlsafe|oauth|authlib|social_core|allauth)(?:\.(?:\w+\.)*\w+)?$/, only: ['py'] },
+  { category: 'auth', test: /^(?:this\.)?(?:passwordEncoder|bCryptPasswordEncoder|encoder|jwtUtil\w*|jwtService|jwtProvider|tokenProvider|tokenService|jwtDecoder|jwtEncoder|Jwts|JWT|Jwt|authenticationManager|authManager|SecurityContextHolder|securityContext|userDetailsService|BCrypt|Keys|Algorithm|Argon2\w*|SCrypt\w*|Pbkdf2\w*|MessageDigest|Mac|SecureRandom|keycloak|oauth2\w*|OAuth2\w*|clientRegistrationRepository|authorizedClientService)\.(?:\w+\.)*\w+$/, only: ['jvm'] },
+  { category: 'auth', test: /^(?:this\.)?_?(?:userManager|signInManager|roleManager|tokenHandler|jwtHandler|JwtSecurityTokenHandler|tokenService|jwtService|authService|authenticationService|passwordHasher|PasswordHasher|HttpContext\.SignInAsync|HttpContext\.SignOutAsync|HttpContext\.AuthenticateAsync|HttpContext\.ChallengeAsync|context\.SignInAsync|context\.SignOutAsync|identityService|_identityService|currentUser|_currentUser|user\.Identity|User\.Identity|User\.Claims|User\.IsInRole|BCrypt|Argon2|Rfc2898DeriveBytes|RandomNumberGenerator|SHA256|HMACSHA256|KeyDerivation|Convert\.ToBase64String)\.(?:\w+\.)*\w+$|^(?:JwtSecurityToken|JwtSecurityTokenHandler|SymmetricSecurityKey|SigningCredentials|ClaimsIdentity|ClaimsPrincipal|AuthenticationProperties)$/, only: ['cs'] },
+  { category: 'auth', test: /^(?:jwt|jose|paseto|bcrypt|argon2|scrypt|pbkdf2|hmac|sha256|oauth2|oidc|auth|authz|casbin|token|session|sessions|securecookie|gothic|goth)\.(?:\w+\.)*\w+$|^\w+\.(?:SignedString|ParseWithClaims|GenerateFromPassword|CompareHashAndPassword|Verify|VerifyToken|GenerateToken|ValidateToken|Authenticate|Authorize|Login|Logout|CheckPassword|HashPassword)$/, only: ['go'] },
+  { category: 'auth', test: /^(?:BCrypt::Password\.\w+|JWT\.(?:encode|decode)|sign_in|sign_out|sign_in_and_redirect|authenticate_user!|authenticate_with_http_token|authenticate_or_request_with_http_basic|current_user|has_secure_password|Devise\.\w+|warden\.\w+|OmniAuth\.\w+|Doorkeeper\.\w+|SecureRandom\.\w+|Digest::\w+\.\w+)$|^\w+\.(?:authenticate|authenticate!|regenerate_token|generate_token|valid_password\?)$/, only: ['rb'] },
+  { category: 'auth', test: /^(?:Auth::\w+|Hash::\w+|Password::\w+|Crypt::\w+|Gate::\w+|Socialite::\w+|Passport::\w+|Sanctum::\w+|JWTAuth::\w+|password_hash|password_verify|\$request->user|\$user->createToken|\$user->tokens)(?:\(\)->\w+)*$/, only: ['php'] },
+  { category: 'auth', test: /^(?:jsonwebtoken|bcrypt|argon2|scrypt|pbkdf2|hmac|sha2|ring|rustls|oauth2|openidconnect|jwt_simple|biscuit|paseto)(?:::\w+)*$/, only: ['rs'] },
+  { category: 'auth', test: /^(?:crypt|getpwnam|getpwuid|setuid|setgid|seteuid|PAM_\w+|pam_\w+|SSL_CTX_\w+|EVP_\w+|HMAC|RAND_bytes|BN_\w+|X509_\w+|gnutls_\w+)$/, only: ['c'] },
+
+  // ----------------------------------------------------------------- storage --
+  { category: 'storage', test: /^(?:AsyncStorage|SecureStore|MMKV|localStorage|sessionStorage|indexedDB|UserDefaults|Keychain|KeychainAccess|FileSystem|RNFS|FileManager|fs|fsp|fs\.promises|promises|path\.write|Deno\.(?:writeFile|readFile|writeTextFile|readTextFile|remove|mkdir|open|create|stat)|Bun\.(?:write|file))\b/ },
+  { category: 'storage', test: /^(?:this\.)?(?:s3|s3Client|S3|storage|bucket|gcs|blob|blobClient|blobService|containerClient|cloudinary|uploader|uploadthing|utapi|supabase\.storage|firebase\.storage|storageRef|ref|getStorage|minio|minioClient|r2|R2|env\.\w*(?:BUCKET|R2)\w*|sharp|multer)\.(?:\w+\.)*(?:putObject|getObject|deleteObject|listObjects\w*|upload\w*|download\w*|send|file|save|delete|remove|getSignedUrl|createSignedUrl|createReadStream|createWriteStream|put|get|head|copy|move|exists|list|write\w*|read\w*|toFile|toBuffer|from|upload_stream|destroy|createPresignedPost|presign\w*|bucket|object)$|^(?:PutObjectCommand|GetObjectCommand|DeleteObjectCommand|CopyObjectCommand|HeadObjectCommand|ListObjectsV2Command|CreateMultipartUploadCommand|UploadPartCommand|Upload)$/, only: ['js'] },
+  { category: 'storage', test: /^(?:open|os\.(?:remove|unlink|rename|replace|makedirs|mkdir|rmdir|removedirs|listdir|scandir|walk|chmod|chown|stat|path\.exists|path\.isfile|path\.isdir|path\.getsize|symlink|link|truncate|utime|fsync)|shutil\.\w+|tempfile\.\w+|Path\(\w*\)\.(?:write_text|write_bytes|read_text|read_bytes|unlink|mkdir|rmdir|rename|replace|touch|exists|iterdir|glob|rglob|open)|\w+\.(?:write_text|write_bytes|read_text|read_bytes|unlink|mkdir|rmdir|touch)|boto3\.(?:client|resource)|s3\.(?:upload_file|upload_fileobj|download_file|download_fileobj|put_object|get_object|delete_object|list_objects\w*|head_object|copy_object|generate_presigned_url|create_bucket)|s3_client\.\w+|bucket\.(?:upload_file|download_file|put_object|delete_objects|objects)|default_storage\.\w+|storage\.\w+|FileSystemStorage|blob\.(?:upload_from_\w+|download_as_\w+|download_to_\w+|delete|exists)|bucket\.blob|gcs\.\w+|blob_client\.\w+|container_client\.\w+|json\.dump|pickle\.dump|pickle\.load|json\.load|csv\.writer|csv\.reader|zipfile\.ZipFile|tarfile\.open|gzip\.open|aiofiles\.open|anyio\.open_file|shelve\.open|sqlite3\.connect|dbm\.open)$/, only: ['py'] },
+  { category: 'storage', test: /^(?:this\.)?(?:\w*[dD]ataStore|\w*[pP]references|\w*[pP]refs|sharedPreferences|prefs|editor)\.(?:updateData|edit|data|apply|commit|getString|putString|getInt|putInt|getLong|putLong|getBoolean|putBoolean|getFloat|putFloat|getStringSet|putStringSet|remove|clear|contains)$|^(?:this\.)?(?:context|applicationContext|appContext|ctx)\.(?:openFileOutput|openFileInput|getSharedPreferences|deleteFile|getFilesDir|getCacheDir|getExternalFilesDir|getDatabasePath|deleteDatabase|contentResolver\.\w+)$|^(?:this\.)?(?:contentResolver|resolver)\.(?:query|insert|update|delete|openInputStream|openOutputStream)$/, only: ['jvm'] },
+  { category: 'queue', test: /^(?:this\.)?(?:\w*[wW]orkManager|\w*[sS]cheduler|jobScheduler|alarmManager|\w*AlarmManager)\.(?:enqueue\w*|beginWith|beginUniqueWork|cancel\w*|schedule\w*|set\w*|setExact\w*|setRepeating|setInexactRepeating)$/, only: ['jvm'] },
+  { category: 'storage', test: /^(?:Files|Paths|File|FileUtils|IOUtils|FileSystems|Channels|FileChannel)\.\w+$|^(?:this\.)?(?:s3Client|amazonS3|s3|storage|gcsStorage|blobClient|blobServiceClient|containerClient|minioClient|storageService|fileService|fileStorage|resourceLoader|resource)\.(?:\w+\.)*\w+$|^(?:FileInputStream|FileOutputStream|FileReader|FileWriter|RandomAccessFile|BufferedWriter|BufferedReader|PrintWriter|PutObjectRequest|GetObjectRequest|DeleteObjectRequest|ObjectMetadata)$/, only: ['jvm'] },
+  { category: 'storage', test: /^(?:File|Directory|Path\.(?:Combine)?|FileInfo|DirectoryInfo|FileStream|StreamWriter|StreamReader|BinaryWriter|BinaryReader|ZipFile|ZipArchive|IsolatedStorageFile)(?:\.\w+)*$|^(?:this\.)?_?(?:s3Client|blobClient|blobServiceClient|containerClient|blobContainer|storage|storageService|fileStorage|fileService|fileProvider|_fileProvider|amazonS3|minio|minioClient)\.(?:\w+\.)*\w+$|^(?:FileStream|StreamWriter|StreamReader|BinaryWriter|BinaryReader|PutObjectRequest|GetObjectRequest|DeleteObjectRequest|TransferUtility)$/, only: ['cs'] },
+  { category: 'storage', test: /^(?:os|ioutil|io|filepath|bufio|afero|fs)\.(?:Open|OpenFile|Create|CreateTemp|ReadFile|WriteFile|Remove|RemoveAll|MkdirAll|Mkdir|MkdirTemp|Rename|Stat|Lstat|ReadDir|Chmod|Chown|Truncate|Symlink|Link|Readlink|Copy|CopyN|WriteString|Walk|WalkDir|Glob|NewWriter|NewReader|TempDir|TempFile|ReadAll)$|^(?:f|file|fd|w|writer)\.(?:Write\w*|Read\w*|Close|Sync|Seek|Truncate|WriteString)$|^(?:s3|s3Client|svc|uploader|downloader|storage|bucket|client|minioClient|blob)\.(?:PutObject\w*|GetObject\w*|DeleteObject\w*|ListObjects\w*|HeadObject\w*|CopyObject\w*|Upload\w*|Download\w*|Object|Bucket|Write|NewWriter|NewReader|FPutObject|FGetObject|PresignedGetObject|PresignedPutObject)$/, only: ['go'] },
+  { category: 'storage', test: /^(?:fopen|freopen|fclose|fread|fwrite|fgets|fputs|fgetc|fputc|fscanf|fprintf|fflush|fseek|ftell|rewind|open|openat|creat|close|read|write|pread|pwrite|lseek|unlink|unlinkat|remove|rename|renameat|mkdir|mkdirat|rmdir|stat|fstat|lstat|fstatat|access|chmod|fchmod|chown|fchown|truncate|ftruncate|fsync|fdatasync|opendir|fdopendir|readdir|closedir|rewinddir|mmap|munmap|msync|flock|fcntl|dup|dup2|pipe|mkstemp|mkdtemp|tmpfile|realpath|readlink|symlink|link|utime|utimes|futimes|sendfile|copy_file_range|ioctl|CreateFile\w*|ReadFile|WriteFile|CloseHandle|DeleteFile\w*|MoveFile\w*|CopyFile\w*|CreateDirectory\w*|RemoveDirectory\w*|FindFirstFile\w*|FindNextFile\w*|GetFileAttributes\w*|SetFilePointer\w*|FlushFileBuffers|std::ofstream|std::ifstream|std::fstream|ofstream|ifstream|fstream|std::filesystem::\w+|filesystem::\w+|fs::\w+)$/, only: ['c'] },
+  { category: 'storage', test: /^(?:File|Dir|FileUtils|IO|Pathname|Tempfile)\.\w+$|^(?:Aws::S3::\w+|S3_BUCKET\.\w+|ActiveStorage::\w+|\w+\.attach|\w+\.purge|\w+\.purge_later)$/, only: ['rb'] },
+  { category: 'storage', test: /^(?:Storage::\w+|File::\w+|file_put_contents|file_get_contents|fopen|fwrite|fread|fclose|unlink|mkdir|rmdir|rename|copy|move_uploaded_file|\$request->file|\$file->store\w*|\$file->move)(?:\(\)->\w+)*$/, only: ['php'] },
+  { category: 'storage', test: /^(?:std::fs|fs|tokio::fs|File|OpenOptions|std::io::Write|BufWriter|BufReader)(?:::\w+)*$|^\w+\.(?:write_all|read_to_string|read_to_end|sync_all|flush)$/, only: ['rs'] },
+
+  // ----------------------------------------------------------------- network --
+  {
+    category: 'network',
+    test: /^(?:fetch|axios|ky|got|superagent|XMLHttpRequest|WebSocket|EventSource|undici|request|needle|phin|ofetch|\$fetch|useFetch|useAsyncData|wretch|redaxios)$|^(?:this\.)?(?:axios|api|client|http|https|httpClient|apiClient|instance|request|agent|graphql|apollo|apolloClient|urql|trpc|supabase|octokit|github|gh|openai|anthropic|ai|slack|twilio|sdk|\w+Client|\w+Api|\w+API|\w+Sdk|\w+SDK|\$http|\$axios|\$api|api\.\w+|routes\.\w+|server\.\w+)\.(?:\w+\.)*(?:get|post|put|patch|delete|head|options|request|query|mutate|mutation|rpc|invoke|call|send|fetch|create|list|retrieve|update|del|remove|upload|download|stream|connect|subscribe|emit|generate|complete|chat\.completions\.create|messages\.create|embeddings\.create|images\.generate|run|search|execute|useQuery|useMutation|useInfiniteQuery|prefetchQuery|fetchQuery|ensureQueryData)$|^URLSession(?:\.|$)|^(?:Alamofire|AF)\.|\.(?:dataTask|uploadTask|downloadTask)$|^(?:io|socket|ws|wss|pusher|ably|centrifuge|mqtt|mqttClient|nc|nats|grpc|grpcClient|stub)\.(?:emit|send|publish|connect|request|call|to|in|of|subscribe|unsubscribe|invoke)$|^(?:io|WebSocket|EventSource|XMLHttpRequest|Pusher|Ably|Centrifuge)$/,
+    only: ['js'],
+  },
+  { category: 'network', test: /^(?:requests|httpx|aiohttp|urllib\.request|urllib3|http\.client|httplib2|treq|niquests|pycurl|websockets|websocket|socketio|sio|grpc|stub|channel|zmq|paho|mqtt|client|http_client|api_client|api|session_client|async_client|_client|self\.client|self\.http|self\.session_client|slack_client|openai|anthropic|boto3\.client|lambda_client|sqs_client|sns_client|ses_client|ec2|s3)\.(?:\w+\.)*(?:get|post|put|patch|delete|head|options|request|send|fetch|urlopen|open|ClientSession|AsyncClient|Client|Session|stream|connect|emit|invoke|call|create|chat\.completions\.create|messages\.create|completions\.create|embeddings\.create|generate|retrieve|list|update|search|query)$|^(?:urlopen|Request|websockets\.connect|aiohttp\.ClientSession|httpx\.AsyncClient|httpx\.Client|requests\.Session|socket\.socket|socket\.create_connection|grpc\.insecure_channel|grpc\.secure_channel|grpc\.aio\.insecure_channel)$/, only: ['py'] },
+  { category: 'network', test: /^(?:this\.)?(?:restTemplate|webClient|httpClient|client|okHttpClient|retrofit|feignClient|\w*[cC]lient|\w*Feign|\w*Api|\w*Stub|\w*BlockingStub|\w*AsyncStub|graphQlClient|restClient|RestClient|WebClient|HttpClient|HttpRequest|Unirest|Jsoup|template)\.(?:\w+\.)*(?:getForObject|getForEntity|postForObject|postForEntity|postForLocation|exchange|execute|put|delete|patchForObject|get|post|patch|head|options|send|sendAsync|newCall|retrieve|bodyToMono|bodyToFlux|create|builder|newBuilder|newHttpClient|connect|call|invoke|newRequest|uri|method|request|body|block|subscribe|fetch|execute\w*|list\w*|get\w+|create\w+|update\w+|delete\w+)$|^(?:Socket|ServerSocket|URL|HttpURLConnection|HttpsURLConnection|DatagramSocket|WebSocketClient|StompSession|ManagedChannelBuilder)$/, only: ['jvm'] },
+  { category: 'network', test: /^(?:this\.)?_?(?:httpClient|client|http|httpClientFactory|\w*[cC]lient|\w*Api|graphQLClient|restClient|flurl|\w+\.WithOAuthBearerToken)\.(?:\w+\.)*(?:GetAsync|PostAsync|PutAsync|PatchAsync|DeleteAsync|SendAsync|GetStringAsync|GetStreamAsync|GetByteArrayAsync|GetFromJsonAsync|PostAsJsonAsync|PutAsJsonAsync|PatchAsJsonAsync|DeleteFromJsonAsync|Send|CreateClient|GetJsonAsync|PostJsonAsync|ReceiveJson|ReceiveString|InvokeAsync|SendCoreAsync|StartAsync|ConnectAsync|Get\w+Async|Post\w+Async|Put\w+Async|Delete\w+Async|List\w+Async|Create\w+Async|Update\w+Async|Invoke\w+Async|Execute\w*Async)$|^(?:HttpClient|HttpRequestMessage|WebClient|HttpWebRequest|TcpClient|UdpClient|Socket|ClientWebSocket|HubConnection|HubConnectionBuilder|GrpcChannel|RestClient|RestRequest|FlurlClient)$/, only: ['cs'] },
+  { category: 'network', test: /^(?:http|client|httpClient|c|hc|resty|req|grpc|net|websocket|ws|conn|nc|nats|mqtt|redis|rdb|\w+Client|\w+client|svc|api|sdk)\.(?:\w+\.)*(?:Get|Post|PostForm|Put|Patch|Delete|Head|Do|NewRequest|NewRequestWithContext|R|Dial|DialContext|DialTLS|Listen|ListenAndServe|Invoke|NewStream|Connect|Send|Recv|Request|Call|Write\w*|Read\w*|Publish|Subscribe|SendMessage|Ping|Execute|Fetch|Query|Mutate|Invoke\w*|Get\w+|Post\w+|Put\w+|Delete\w+|List\w+|Create\w+|Update\w+|Describe\w+)$|^(?:http|net|grpc|websocket|resty|fasthttp|gorequest|req)\.(?:Get|Post|PostForm|Head|Dial|DialContext|Listen|ListenAndServe|ListenAndServeTLS|NewClient|NewRequest|Serve|DefaultDialer\.Dial|Upgrade)$/, only: ['go'] },
+  { category: 'network', test: /^(?:socket|connect|bind|listen|accept|accept4|send|sendto|sendmsg|recv|recvfrom|recvmsg|getaddrinfo|gethostbyname|getnameinfo|inet_pton|inet_ntop|setsockopt|getsockopt|shutdown|select|poll|epoll_create\w*|epoll_ctl|epoll_wait|kqueue|kevent|curl_easy_init|curl_easy_setopt|curl_easy_perform|curl_easy_cleanup|curl_multi_\w+|SSL_new|SSL_connect|SSL_accept|SSL_read|SSL_write|SSL_shutdown|SSL_free|BIO_\w+|WSAStartup|WSASocket\w*|WSASend|WSARecv|WSACleanup|closesocket|ioctlsocket|http_\w+|uv_tcp_\w+|uv_udp_\w+|uv_connect|uv_listen|uv_read_start|uv_write|evhttp_\w+|bufferevent_\w+|nng_\w+|zmq_\w+|MHD_\w+|mg_\w+|lws_\w+|ares_\w+|anetTcpConnect|anetTcpServer|anetAccept|anetRead|anetWrite|connSocket\w*|connConnect|connWrite|connRead|connAccept|connListen|aeCreateFileEvent|aeDeleteFileEvent)$/, only: ['c'] },
+  { category: 'network', test: /^(?:Net::HTTP(?:\.\w+)*|HTTParty\.\w+|Faraday(?:\.\w+)*|RestClient\.\w+|HTTP\.\w+|Excon\.\w+|Typhoeus\.\w+|OpenURI\.open_uri|URI\.open|open-uri|Socket\.\w+|TCPSocket\.\w+|WebSocket::\w+|ActionCable\.server\.broadcast|\w+Channel\.broadcast_to|\w+Channel\.broadcast|\w+\.broadcast)$|^\w+\.(?:get|post|put|patch|delete|head|request)$/, only: ['rb'] },
+  { category: 'network', test: /^(?:Http::\w+|Http::\w+::\w+|curl_init|curl_exec|curl_setopt\w*|curl_close|file_get_contents|fsockopen|stream_socket_client|socket_create|socket_connect|socket_send|socket_recv|\$client->(?:request|get|post|put|patch|delete|send|sendAsync|requestAsync)|\$guzzle->\w+|\$http->\w+)(?:\(\)->\w+)*$/, only: ['php'] },
+  { category: 'network', test: /^(?:reqwest|hyper|ureq|isahc|surf|tonic|tungstenite|tokio_tungstenite|websocket|TcpStream|TcpListener|UdpSocket|Client|ClientBuilder|Request|awc)(?:::\w+)*$|^\w+\.(?:get|post|put|patch|delete|head|send|execute|connect|bind|send_to|recv_from|write_all|read_to_end)$/, only: ['rs'] },
+  { category: 'network', test: /^URLSession(?:\.|$)|^(?:Alamofire|AF)\.|\.(?:dataTask|uploadTask|downloadTask|webSocketTask|data|upload|download|responseDecodable|responseJSON|responseData)$|^(?:NWConnection|NWListener|NWBrowser|URLSessionWebSocketTask|WebSocket|Starscream|SocketManager|SocketIOClient|Socket)\b|^(?:this\.|self\.)?(?:client|api|apiClient|http|httpClient|networkService|network|session)\.(?:get|post|put|patch|delete|request|send|fetch|perform|execute|call|data|upload|download)$/, only: ['swift'] },
+
+  // ------------------------------------------------------------------ device --
+  { category: 'device', test: /^(?:Linking|Share|Clipboard|Notifications|Camera|ImagePicker|MediaLibrary|Haptics|Alert|Vibration|Location|Geolocation|Permissions|UIApplication|AVCaptureSession|AVAudioSession|CLLocationManager|UNUserNotificationCenter|Battery|Brightness|Sensors|Accelerometer|Gyroscope|Magnetometer|Pedometer|Contacts|Calendar|LocalAuthentication|BiometricAuth|DocumentPicker|Print|ScreenOrientation|StatusBar|BackHandler|Appearance|Dimensions|PixelRatio|Keyboard|PushNotification|PushNotificationIOS|messaging|Bluetooth|BleManager|NfcManager|navigator\.\w+|window\.(?:open|print|alert|confirm|prompt)|Notification|speechSynthesis|WebAuthn|Intent|intent|context\.startActivity|startActivity|startService|sendBroadcast|registerReceiver|NotificationManager|notificationManager|NotificationCompat|LocationManager|locationManager|fusedLocationClient|SensorManager|sensorManager|CameraX|cameraProvider|MediaPlayer|mediaPlayer|AudioManager|audioManager|Vibrator|vibrator|ClipboardManager|clipboardManager|UIDevice|UIPasteboard|UIImpactFeedbackGenerator|UINotificationFeedbackGenerator|AVAudioPlayer|AVPlayer|CMMotionManager|PHPhotoLibrary|UIImagePickerController|LAContext|WKWebView|Process\.Start|Environment\.Exit|Clipboard\.\w+|Console\.\w+)\b/ },
+
+  // --------------------------------------------------------------- telemetry --
+  { category: 'telemetry', test: /^(?:DdRum|DdLogs|DdTrace|DdSdkReactNative|CustomerIO|Sentry|Bugsnag|analytics|Analytics|crashlytics|Crashlytics|mixpanel|Mixpanel|amplitude|Amplitude|posthog|PostHog|LDClient|ldClient|Datadog|datadog|datadogRum|datadogLogs|newrelic|NewRelic|honeycomb|Honeycomb|segment|Segment|statsd|StatsD|metrics|Metrics|meter|Meter|meterRegistry|MeterRegistry|counter|histogram|tracer|Tracer|otel|opentelemetry|trace\.getTracer|span|Span|appInsights|TelemetryClient|_telemetryClient|telemetryClient|telemetry|_telemetry|Telemetry|Application\.Insights|logtail|Logtail|rollbar|Rollbar|raven|Raven|prometheus|Prometheus|promClient|prom|registry|Registry|sentry_sdk|capture_exception|capture_message|statsd_client|dogstatsd|MetricRegistry|Micrometer|Timer|Counter|Gauge|Histogram|Summary)\b(?:\.(?:\w+\.)*\w+)?$/ },
+
+  // ----------------------------------------------------------------- process --
+  { category: 'process', test: /^(?:child_process|spawn|spawnSync|exec|execSync|execFile|execFileSync|fork|process\.exit|process\.kill|process\.abort|Deno\.(?:run|exit|Command|kill)|Bun\.(?:spawn|spawnSync|\$)|\$`|execa|execaSync|\$|zx|Worker|worker_threads|cluster\.fork|os\.setPriority|pm2\.\w+)$/, only: ['js'] },
+  { category: 'process', test: /^(?:subprocess\.(?:run|call|check_call|check_output|Popen|getoutput|getstatusoutput)|os\.(?:system|popen|execv|execve|execvp|execl|execlp|spawn\w*|fork|forkpty|kill|killpg|_exit|abort|nice|setsid|setuid|setgid|waitpid|wait)|sys\.exit|exit|quit|multiprocessing\.(?:Process|Pool)|Process|Pool|signal\.signal|signal\.alarm|pty\.spawn|asyncio\.create_subprocess_\w+|sh\.\w+|plumbum\.\w+|pexpect\.\w+|importlib\.import_module|__import__|ctypes\.\w+|cffi\.\w+)$/, only: ['py'] },
+  { category: 'process', test: /^(?:Runtime\.getRuntime\(\)\.exec|Runtime\.getRuntime\(\)\.halt|Runtime\.getRuntime|runtime\.exec|ProcessBuilder|System\.exit|System\.loadLibrary|System\.load|Thread\.sleep|Thread|Executors\.\w+|executor\.\w+|ForkJoinPool\.\w+|CompletableFuture\.\w+|Runtime\.exit|Runtime\.halt|exitProcess|ProcessHandle\.\w+|Signal\.\w+|thread|Timer|ScheduledExecutorService)(?:\.\w+)*$/, only: ['jvm'] },
+  { category: 'process', test: /^(?:Process\.(?:Start|Kill|GetProcesses\w*|GetCurrentProcess)|Environment\.(?:Exit|FailFast)|AppDomain\.\w+|Thread\.(?:Sleep|Start)|Task\.(?:Run|Factory\.StartNew|Delay)|ThreadPool\.\w+|Assembly\.(?:Load\w*)|Activator\.CreateInstance\w*|Marshal\.\w+|NativeLibrary\.\w+|ProcessStartInfo)$/, only: ['cs'] },
+  { category: 'process', test: /^(?:exec\.(?:Command|CommandContext|LookPath)|os\.(?:Exit|StartProcess|FindProcess|Getpid|Getenv|Setenv|Executable)|syscall\.\w+|signal\.(?:Notify|NotifyContext|Stop|Ignore|Reset)|log\.(?:Fatal\w*|Panic\w*)|runtime\.(?:GC|Goexit|GOMAXPROCS)|plugin\.Open|debug\.SetGCPercent|cmd\.(?:Run|Start|Output|CombinedOutput|Wait|Kill|StdoutPipe|StdinPipe|StderrPipe))$/, only: ['go'] },
+  { category: 'process', test: /^(?:fork|vfork|clone|execv|execve|execvp|execvpe|execl|execle|execlp|posix_spawn\w*|system|popen|pclose|waitpid|wait|wait3|wait4|waitid|kill|killpg|raise|signal|sigaction|sigprocmask|sigsuspend|sigwait|alarm|setitimer|pause|exit|_exit|_Exit|abort|atexit|quick_exit|setsid|setpgid|setpgrp|getpid|getppid|daemon|nice|setpriority|setrlimit|getrlimit|chroot|setuid|setgid|seteuid|setegid|setgroups|pthread_create|pthread_join|pthread_cancel|pthread_kill|pthread_detach|pthread_exit|thrd_create|thrd_join|dlopen|dlsym|dlclose|dlerror|LoadLibrary\w*|GetProcAddress|FreeLibrary|CreateProcess\w*|CreateThread|ExitProcess|ExitThread|TerminateProcess|TerminateThread|ShellExecute\w*|WinExec|WaitForSingleObject|WaitForMultipleObjects|sched_yield|sched_setaffinity|prctl|ptrace|uv_spawn|uv_process_kill|redisFork|bioCreateBackgroundJob|bioSubmitJob)$/, only: ['c'] },
+  { category: 'process', test: /^(?:system|spawn|exec|fork|Process\.\w+|Open3\.\w+|Kernel\.(?:system|spawn|exec|exit|exit!|abort|at_exit)|exit|exit!|abort|at_exit|Signal\.trap|trap|Thread\.new|Thread\.start|IO\.popen|PTY\.spawn|`)$/, only: ['rb'] },
+  { category: 'process', test: /^(?:exec|shell_exec|system|passthru|proc_open|popen|pcntl_\w+|posix_\w+|exit|die|Process::\w+|Artisan::\w+|new Process|Process)$/, only: ['php'] },
+  { category: 'process', test: /^(?:std::process|process|Command|std::thread|thread|tokio::spawn|tokio::process|spawn|rayon|libc)(?:::\w+)*$|^\w+\.(?:spawn|output|status|wait|kill)$/, only: ['rs'] },
+];
+
+/**
+ * A plain instantiation of an exception the framework will turn into a
+ * response. Only in a project with endpoints: in an app, `new
+ * ValidationError` is an error, not a reply.
+ */
+const EXCEPTION_RESPONSE = /(?:^|[.:])(?:\w+Exception|\w*HttpError|ApiError|\w+ApiError|HttpProblem|ProblemDetails|Abort|ResponseStatusException|ErrorResponse|\w+ErrorResponse|HTTPError|HTTPException|APIException|Http\d{3}|\w*(?:NotFound|BadRequest|Unauthorized|Unauthenticated|Forbidden|Conflict|Validation|Unprocessable|TooManyRequests|Gone|NotAllowed|MethodNotAllowed|Unsupported|RequestTimeout|InternalServer|ServiceUnavailable|PaymentRequired|PreconditionFailed|NotAcceptable|NotImplemented|BadGateway|RateLimit)Error)$/;
+const NOT_A_RESPONSE = /^(?:Error|TypeError|RangeError|SyntaxError|ReferenceError|EvalError|URIError|AggregateError|Exception|RuntimeException|IllegalArgumentException|IllegalStateException|NullPointerException|IndexOutOfBoundsException|UnsupportedOperationException|ArgumentException|ArgumentNullException|ArgumentOutOfRangeException|InvalidOperationException|NotImplementedException|NotSupportedException|ValueError|TypeError|KeyError|IndexError|RuntimeError|NotImplementedError|AssertionError|StopIteration|InterruptedException|IOException|FileNotFoundException|ClassNotFoundException|NoSuchElementException|NumberFormatException|CloneNotSupportedException|ExecutionException|TimeoutException|OperationCanceledException|TaskCanceledException|ObjectDisposedException|FormatException|OverflowException|DivideByZeroException|JsonException|SerializationException|ParseException|DateTimeParseException|MalformedURLException|URISyntaxException|SQLException|DataAccessException|DbUpdateException|ConcurrencyException|EntityNotFoundException|NoResultException|OptimisticLockException)$/;
+
+/** Receiver types that say what a call into them is, when the graph declared one. */
+const RECEIVER_TYPE_RULES: ReadonlyArray<{ category: EffectCategory; test: RegExp }> = [
+  { category: 'database', test: /(?:Repository|Repo|Dao|DAO|Mapper|EntityManager|DataSource|DbContext|DbSet|SessionFactory|JdbcTemplate|NamedParameterJdbcTemplate|MongoTemplate|MongoOperations|R2dbcEntityTemplate|DatabaseClient|PrismaClient|PrismaService|Knex|Kysely|Drizzle|Sequelize|ConnectionPool|MongoCollection|Datastore|IDbConnection|IRepository|IReadRepository|IUnitOfWork|UnitOfWork|DSLContext|EntityManagerFactory|SessionFactory|AsyncSession)(?:<[^>]*>)?$/ },
+  { category: 'network', test: /(?:HttpClient|RestTemplate|WebClient|RestClient|OkHttpClient|Retrofit|AxiosInstance|Axios|IHttpClientFactory|HttpClientFactory|GraphQLClient|GraphQlClient|WebSocketClient|StompSession|ManagedChannel|BlockingStub|AsyncStub|FeignClient|ApolloClient)(?:<[^>]*>)?$/ },
+  { category: 'auth', test: /(?:JwtService|JwtDecoder|JwtEncoder|PasswordEncoder|AuthenticationManager|UserManager|SignInManager|RoleManager|JwtSecurityTokenHandler|IPasswordHasher|PasswordHasher|KeycloakClient|OAuth2AuthorizedClientService)(?:<[^>]*>)?$/ },
+  { category: 'queue', test: /(?:Queue|IQueue|Producer|IProducer|Publisher|IPublisher|IPublishEndpoint|ISendEndpoint|IBus|IMessageBus|MessageBus|EventBus|IEventBus|Channel|KafkaTemplate|RabbitTemplate|JmsTemplate|StreamBridge|SqsClient|SnsClient|AmazonSQS|AmazonSNS|ApplicationEventPublisher|EventEmitter2|IBackgroundJobClient|BackgroundJobClient|Agenda|PgBoss)(?:<[^>]*>)?$/ },
+  { category: 'email', test: /(?:MailerService|JavaMailSender|MailSender|IEmailSender|SendGridClient|SESClient|AmazonSimpleEmailService|Transporter|Resend|PostmarkClient)(?:<[^>]*>)?$/ },
+  { category: 'payments', test: /(?:Stripe|StripeClient|BraintreeGateway|PayPalClient|Razorpay|Adyen|Mollie|Paddle|Chargebee)(?:<[^>]*>)?$/ },
+  { category: 'cache', test: /(?:CacheManager|IMemoryCache|IDistributedCache|RedisTemplate|StringRedisTemplate|Redis|RedisClient|Jedis|Lettuce|RedissonClient|MemcachedClient|IConnectionMultiplexer|ConnectionMultiplexer|IDatabase)(?:<[^>]*>)?$/ },
+  { category: 'storage', test: /(?:S3Client|AmazonS3|BlobClient|BlobServiceClient|BlobContainerClient|MinioClient|IFileProvider|Cloudinary|FileSystem|IFileSystem)(?:<[^>]*>)?$/ },
+];
+
+export interface EffectInput {
+  /** The call as written (the whole member chain), or the reference name the index kept. */
+  text: string;
+  kind: 'calls' | 'instantiates';
+  language?: Language | null;
+  project?: ProjectKind;
+  /** The declared type of the receiver, when the graph has it (`OwnerRepository`). */
+  receiverType?: string | null;
+  /** The argument list, abbreviated, when it was read — where the model of a `knex('users')` comes from. */
+  args?: string | null;
+}
+
+export interface Effect {
+  category: EffectCategory;
+  /** The table / model / collection, when it can be read off the call: `user`, `Owner`, `TodoItems`. */
+  model?: string;
+  /** Read or write, from the method name. Only for `database`. */
+  access?: 'read' | 'write';
+}
+
+function familiesOf(language: Language | null | undefined): Family[] {
+  if (!language) return [];
+  const out: Family[] = [];
+  for (const [family, set] of Object.entries(FAMILIES) as Array<[Family, ReadonlySet<Language>]>) if (set.has(language)) out.push(family);
+  return out;
+}
+
+/** Normalise the call text the rules see: `await`/`new` dropped, `?.` as `.`, `this.` kept. */
+export function normaliseCall(text: string): string {
+  return text
+    .replace(/^\s*(?:await|new|yield|return|throw)\s+/, '')
+    .replace(/^\s*(?:await|new)\s+/, '')
+    .replace(/\?\./g, '.')
+    .replace(/!\./g, '.')
+    .replace(/\s+/g, '')
+    .replace(/<[^<>]*>/g, '');
+}
+
+/**
+ * What a call is, when it is one of the things the table names. `null` for
+ * everything else — a plain call into a library is not an effect.
+ */
+export function classifyEffect(input: EffectInput): Effect | null {
+  // Rules see the chain without its argument lists: `res.status(404).json`
+  // is `res.status.json`, `ResponseEntity.status(HttpStatus.NOT_FOUND).body`
+  // is `ResponseEntity.status.body`.
+  const text = normaliseCall(input.text).replace(/\([^()]*\)/g, '');
+  if (text === '') return null;
+  const families = familiesOf(input.language);
+  const project = input.project ?? 'app';
+
+  for (const rule of EFFECT_RULES) {
+    if (rule.only && !rule.only.some((f) => families.includes(f))) {
+      // An ungated language (no family) still gets the JS rows — the table
+      // grew up on them and the old tests call with no language.
+      if (input.language) continue;
+      if (!rule.only.includes('js')) continue;
+    }
+    if (rule.instantiates && input.kind !== 'instantiates') continue;
+    if (rule.test.test(text)) return withShape(rule.category, text, input.args ?? null, input.language ?? null, null);
+  }
+
+  // The receiver's declared type — a library's, never a class of the project
+  // (the caller checks): `OwnerRepository owners` makes `owners.save` the
+  // database when no row above knew the name.
+  if (input.receiverType) {
+    const type = input.receiverType.replace(/^(?:readonly|private|public|protected|final|static)\s+/g, '').trim();
+    for (const rule of RECEIVER_TYPE_RULES) {
+      if (rule.test.test(type)) return withShape(rule.category, text, input.args ?? null, input.language ?? null, type);
+    }
+  }
+
+  // A thrown web exception is a response, in a project that has endpoints.
+  if (project !== 'app' && (input.kind === 'instantiates' || families.includes('py') || families.includes('swift'))) {
+    const last = text.split(/[.:]/).pop() ?? text;
+    if (EXCEPTION_RESPONSE.test(text) && !NOT_A_RESPONSE.test(last)) return { category: 'response' };
+  }
+  return null;
+}
+
+const READ_OPS =
+  /^(?:find\w*|findone|get|getone|getmany|getall\w*|getbyid\w*|getasync|list\w*|count\w*|aggregate|select\w*|query\w*|queryrow\w*|scalar\w*|first\w*|single\w*|last\w*|all|any\w*|exists\w*|tolist\w*|toarray\w*|filter|where|fetch\w*|load\w*|read\w*|scan|search|paginate|pluck|distinct|group\w*|order\w*|include\w*|join\w*|objects|values|values_list|iterator|stream|max|min|sum|avg|average|exists\?|one|one_or_none|scalars?|find_by|find_each|firstordefault\w*|singleordefault\w*|countdocuments|rows|row|preload|take|limit|offset|skip|raw|fromsql\w*|\$queryraw|get_or_404|get_object_or_404|describe\w*|head\w*|retrieve|show|index|lookup|peek|contains|has|check|isempty|is_empty|size|length|fetchone|fetchall|fetchmany|refresh)$/i;
+const WRITE_OPS =
+  /^(?:create\w*|update\w*|upsert\w*|delete\w*|destroy\w*|save\w*|insert\w*|remove\w*|add\w*|persist\w*|merge|flush|commit|bulk\w*|batch\w*|put\w*|set\w*|exec|execute\w*|savechanges\w*|increment\w*|decrement\w*|truncate|drop|sqlmodel_update|find_or_create\w*|update_or_create|firstorcreate|updateorcreate|get_or_create|delete_all|update_all|insert_all|upsert_all|refresh_from_db|patch|write\w*|push|pull|inc|attach|detach|sync|replace\w*|touch|purge|restore|forcedelete|store|migrate|automigrate|createmany|updatemany|deletemany|\$executeraw|\$transaction|transaction|begin|rollback|expunge|lock|unlock|import|reindex|rebuild|clear|reset|empty|fill|assign|append|prepend|move|copy|rename|link|unlink|register|unregister|enable|disable|activate|deactivate|approve|reject|publish|unpublish|archive|unarchive|complete|cancel|confirm|revoke|grant|deny|ban|unban|verify|invalidate|expire|evict|generate|seed)$/i;
+
+/** The category plus what the text says about the model and the access. */
+function withShape(category: EffectCategory, text: string, args: string | null, language: Language | null, receiverType: string | null): Effect {
+  if (category !== 'database') return { category };
+  const out: Effect = { category };
+  const segments = text.replace(/\([^()]*\)/g, '').split(/[.:]+/).filter(Boolean);
+  const last = segments[segments.length - 1] ?? '';
+  const model = modelOf(segments, args, receiverType);
+  if (model) out.model = model;
+  const op = last.replace(/[!?]$/, '');
+  if (op === 'exec' && language === 'python') out.access = 'read';
+  else if (WRITE_OPS.test(op)) out.access = 'write';
+  else if (READ_OPS.test(op)) out.access = 'read';
+  return out;
+}
+
+const MODEL_SUFFIX = /(?:Repository|Repo|Dao|DAO|Model|Mapper|Store|Collection|Table|Service|Entity|Manager)$/;
+
+function modelOf(segments: string[], args: string | null, receiverType: string | null): string | null {
+  const noThis = segments[0] === 'this' || segments[0] === 'self' ? segments.slice(1) : segments;
+  const first = noThis[0] ?? '';
+  const secondLast = noThis.length >= 3 ? noThis[noThis.length - 2]! : '';
+  // `prisma.user.create`, `db.users.insert`, `_context.TodoItems.Add`, `this.prisma.user.findMany`.
+  if (noThis.length >= 3 && /^[A-Za-z_$][\w$]*$/.test(secondLast) && !/^(?:objects|query|session|db|\$|from|table|collection|opsForValue|Items)$/.test(secondLast) && !MODEL_SUFFIX.test(secondLast) && secondLast !== '$transaction') {
+    if (/^(?:prisma|db|database|orm|em|drizzle|sql|_context|context|dbContext|_db|ctx|this)$/i.test(first) || /(?:Context|Client|Prisma|Db|DB)$/.test(first)) return secondLast;
+  }
+  // `usersRepository.save`, `_orderRepository.AddAsync`, `userModel.find`, `ownerDao.get`.
+  const receiver = noThis.length >= 2 ? noThis[0]! : '';
+  if (receiver && MODEL_SUFFIX.test(receiver) && !/^(?:this|self)$/.test(receiver)) {
+    const stem = receiver.replace(/^_+/, '').replace(MODEL_SUFFIX, '');
+    if (stem) return stem;
+  }
+  // `User.objects.filter`, `User.query.get`, `User.find`, `Todo.query(on:)`, `User::find`.
+  if (noThis.length >= 2 && /^[A-Z][A-Za-z0-9]*$/.test(first) && !/^(?:DB|ActiveRecord|Files|File|Path|Base)$/.test(first)) return first;
+  // Spring: `owners.save` where `owners` is an `OwnerRepository`.
+  if (receiverType) {
+    const stem = receiverType.replace(/<[^>]*>/g, '').replace(/^I(?=[A-Z])/, '').replace(MODEL_SUFFIX, '');
+    if (stem && stem !== receiverType && /^[A-Z]/.test(stem) && !/^(?:Entity|Db|Data|Jdbc|Mongo|Session|Async)$/.test(stem)) return stem;
+  }
+  // `knex('users')`, `db.from('users')`, `collection('todos')`, `sql.table('x')`.
+  if (args && /^(?:knex|db|collection|table|from|into|selectFrom|insertInto|updateTable|deleteFrom|sql\.table|query|getRepository|getCollection|model)$/i.test(noThis[noThis.length - 1] ?? '')) {
+    const m = /^['"`]([\w.-]+)['"`]/.exec(args);
+    if (m) return m[1]!;
+  }
+  return null;
+}
+
+// =============================================================================
+// Response status
+// =============================================================================
+
+const STATUS_BY_NAME: Record<string, number> = {
+  ok: 200, success: 200, created: 201, accepted: 202, nocontent: 204, resetcontent: 205, partialcontent: 206,
+  movedpermanently: 301, found: 302, redirect: 302, seeother: 303, notmodified: 304, temporaryredirect: 307, permanentredirect: 308,
+  badrequest: 400, unauthorized: 401, unauthenticated: 401, paymentrequired: 402, forbidden: 403, notfound: 404, methodnotallowed: 405,
+  notacceptable: 406, proxyauthenticationrequired: 407, requesttimeout: 408, conflict: 409, gone: 410, lengthrequired: 411,
+  preconditionfailed: 412, payloadtoolarge: 413, requestentitytoolarge: 413, uritoolong: 414, unsupportedmediatype: 415,
+  rangenotsatisfiable: 416, expectationfailed: 417, imateapot: 418, misdirectedrequest: 421, unprocessableentity: 422, unprocessable: 422,
+  locked: 423, faileddependency: 424, tooearly: 425, upgraderequired: 426, preconditionrequired: 428, toomanyrequests: 429, throttled: 429,
+  requestheaderfieldstoolarge: 431, unavailableforlegalreasons: 451, internalservererror: 500, internalerror: 500, servererror: 500, internalserver: 500,
+  notimplemented: 501, badgateway: 502, serviceunavailable: 503, gatewaytimeout: 504, httpversionnotsupported: 505, insufficientstorage: 507,
+  loopdetected: 508, networkauthenticationrequired: 511, validationproblem: 400, problem: 500, forbid: 403, challenge: 401, entitynotfound: 404,
+};
+
+function statusOfName(raw: string): number | null {
+  let name = raw.replace(/^(?:HttpStatus|HTTPStatus|StatusCodes|StatusCode|http|status|HttpStatusCode|Status|HTTP_|HTTP)[._]?/, '');
+  name = name.replace(/(?:Exception|Error|Response|Result|Async|Http|Status|Code)$/g, '').replace(/^Http(?=[A-Z])/, '');
+  const key = name.replace(/[^A-Za-z0-9]/g, '').toLowerCase();
+  if (key === '') return null;
+  if (/^\d{3}$/.test(key)) return Number(key);
+  const m = /^(?:http)?(\d{3})$/.exec(key) ?? /(?:^|[a-z_])([1-5]\d{2})(?:[a-z_]|$)/.exec(key);
+  if (m) return Number(m[1]);
+  if (STATUS_BY_NAME[key] !== undefined) return STATUS_BY_NAME[key]!;
+  // `UserNotFound`, `InvalidTokenUnauthorized`: the status name ends the class name.
+  for (const [known, code] of Object.entries(STATUS_BY_NAME)) {
+    if (known.length >= 6 && key.endsWith(known)) return code;
+  }
+  return null;
+}
+
+/**
+ * The status code a response site sends, when it is literal: the number in
+ * `res.status(404)`, the name in `ResponseEntity.notFound()`,
+ * `throw new NotFoundException()`, `c.JSON(http.StatusCreated, …)`,
+ * `HTTPException(status_code=404)`, `Abort(.notFound)`. Null when the code
+ * is a variable or the site sets none.
+ */
+export function responseStatus(text: string, args: string | null | undefined, _kind: 'calls' | 'instantiates' = 'calls'): number | null {
+  const call = normaliseCall(text);
+  // `res.status(404).json`, `reply.code(201).send`, `ResponseEntity.status(HttpStatus.NOT_FOUND).body`.
+  const inChain = /(?:^|\.)(?:status|sendStatus|code|Status|StatusCode|SendStatus|withStatus|with_status)\(([^()]+)\)/.exec(call);
+  if (inChain) {
+    const s = literalStatus(inChain[1]!);
+    if (s !== null) return s;
+  }
+  const last = (call.replace(/\([^()]*\)/g, '').split(/[.:]/).pop() ?? call).replace(/^new/, '');
+  const a = args ?? '';
+  // The status is the argument: `res.status(404)`, `res.sendStatus(204)`, `abort(404)`, `StatusCode(500)`, `c.String(200, …)`, `http.Error(w, m, 500)`.
+  if (/^(?:status|sendStatus|code|abort|StatusCode|Status|SendStatus|WriteHeader|String|Data|JSON|IndentedJSON|XML|YAML|HTML|AbortWithStatus|AbortWithStatusJSON|Error|Redirect|head|Problem|error)$/.test(last)) {
+    const fromArgs = literalStatus(firstStatusToken(a, last === 'Error' || last === 'Redirect' ? 'last' : 'first'));
+    if (fromArgs !== null) return fromArgs;
+    if (last === 'Redirect') return 302;
+    if (last === 'Problem' || last === 'error') return 500;
+  }
+  // `status_code=404`, `status=400`, `statusCode: 404`, `HttpStatus.CREATED` anywhere in the arguments.
+  const kw = /(?:status_code|statusCode|status|code)\s*[=:]\s*([\w.]+)/.exec(a);
+  if (kw) {
+    const s = literalStatus(kw[1]!);
+    if (s !== null) return s;
+  }
+  const named = /\b(?:HttpStatus|HTTPStatus|StatusCodes|HttpStatusCode|http)\.(\w+)/.exec(a) ?? /(?:^|[\s,(])\.(\w+)/.exec(a);
+  if (named) {
+    const s = statusOfName(named[1]!);
+    if (s !== null) return s;
+  }
+  // `new HttpException(message, 403)`, `new HttpException(422, { errors })`,
+  // `abort(404, …)`: an exception or a reply takes its status wherever the
+  // argument list carries one literal.
+  if (/Exception$|Error$|^Abort$|^abort$|^HTTPError$/.test(last)) {
+    for (const part of splitArgs(a)) {
+      const s = literalStatus(part);
+      if (s !== null) return s;
+    }
+  }
+  // The name says it: `NotFoundException`, `ResponseEntity.notFound().build`, `TypedResults.NoContent`, `Ok`, `Http404`.
+  const segments = call.replace(/\([^()]*\)/g, '').split(/[.:]/).filter(Boolean);
+  for (let i = segments.length - 1; i >= 0; i--) {
+    const seg = segments[i]!;
+    if (/^(?:json|send|end|render|view|View|Json|Content|File|jsonify|JSONResponse|Response|HttpResponse|JsonResponse|render_template|body|text|html|build|status|type|header|headers|set|res|response|reply|rep|ctx|c|context|this|self|http|w|rw|ResponseEntity|Results|TypedResults|HttpStatus|NextResponse)$/.test(seg)) continue;
+    const s = statusOfName(seg);
+    if (s !== null) return s;
+    break;
+  }
+  if (/^(?:redirect|redirect_to|RedirectResponse|HttpResponseRedirect|RedirectToAction|RedirectToPage|RedirectToRoute|LocalRedirect|permanentRedirect|Redirect)$/.test(last)) return /permanent/i.test(last) ? 308 : 302;
+  if (/^(?:Created|CreatedAtAction|CreatedAtRoute|created)$/.test(last)) return 201;
+  return null;
+}
+
+/** The abbreviated argument list split on its top-level commas. */
+function splitArgs(args: string): string[] {
+  const out: string[] = [];
+  let depth = 0;
+  let current = '';
+  for (const ch of args) {
+    if (ch === '(' || ch === '[' || ch === '{') depth++;
+    else if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1);
+    if (ch === ',' && depth === 0) {
+      out.push(current.trim());
+      current = '';
+      continue;
+    }
+    current += ch;
+  }
+  if (current.trim()) out.push(current.trim());
+  return out;
+}
+
+function firstStatusToken(args: string, which: 'first' | 'last'): string {
+  const parts = splitArgs(args);
+  if (parts.length === 0) return '';
+  return which === 'first' ? parts[0]! : parts[parts.length - 1]!;
+}
+
+function literalStatus(token: string): number | null {
+  const t = token.trim();
+  if (/^\d{3}$/.test(t)) return Number(t);
+  const named = /^(?:HttpStatus|HTTPStatus|StatusCodes|HttpStatusCode|http|status|Status|HttpStatusCodes|StatusCode|HTTPResponseStatus)\.(\w+)$/.exec(t) ?? /^\.(\w+)$/.exec(t) ?? /^(?:HTTP_|StatusCodes\.)(\w+)$/.exec(t);
+  if (named) return statusOfName(named[1]!);
+  if (/^[A-Z][A-Z_]+$/.test(t)) return statusOfName(t);
+  return null;
+}
+
+/** The word the legend and a box's sub line use for a category. */
+export function categoryWord(category: string): string {
+  switch (category) {
+    case 'database':
+      return 'database';
+    case 'response':
+      return 'response';
+    case 'queue':
+      return 'queue';
+    case 'email':
+      return 'email';
+    case 'payments':
+      return 'payments';
+    case 'cache':
+      return 'cache';
+    case 'auth':
+      return 'auth';
+    case 'process':
+      return 'process';
+    default:
+      return category;
+  }
+}

+ 92 - 0
src/ui-server/api/route-roots.ts

@@ -0,0 +1,92 @@
+/**
+ * Where a route's code starts — the symbol that runs when a request arrives
+ * at `POST /users`, or when a navigation lands on `/capture/review`.
+ *
+ * Every framework resolver binds a route node to what serves it, but not with
+ * the same edge: Expo Router and the React page routers draw a `calls` edge
+ * to the component the screen file exports; Express (named handler), NestJS,
+ * Spring, FastAPI / Flask / Django, ASP.NET, Vapor, Gin and the React Router
+ * draw a `references` edge to the handler; an Express route whose handler is
+ * an inline arrow has no handler node at all — the resolver attributes the
+ * body's calls to the route itself. The Steps and Screens pictures need ONE
+ * answer per route, so this is it, in order of evidence:
+ *
+ * 1. the target of a `references` edge that is a function, a method, or a
+ *    class (a DRF ViewSet, a class-based view) — the handler the resolver
+ *    named at the registration site;
+ * 2. the target of a `calls` / `instantiates` edge that is a component — the
+ *    page a file-routed screen exports;
+ * 3. the route itself, when it carries `calls` edges and nothing else — the
+ *    inline handler, walked as if the route were the function;
+ * 4. nothing: the route is drawn alone, and the picture says so.
+ *
+ * A route whose handler is a DIFFERENT symbol from what the routing manifest
+ * names would be a resolver bug, not a case to arbitrate here — both read the
+ * same edges.
+ */
+
+import type CodeGraph from '../../index';
+import type { Node } from '../../types';
+
+export interface RouteRoot {
+  /** The symbol a walk from the route starts at; the route itself for an inline handler. */
+  node: Node;
+  /** The route's handler is an anonymous function at the registration site — the route node stands in for it. */
+  inline: boolean;
+}
+
+const HANDLER_KINDS: ReadonlySet<Node['kind']> = new Set(['function', 'method', 'class', 'component']);
+const JS_FAMILY: ReadonlySet<string> = new Set(['javascript', 'typescript', 'tsx', 'jsx']);
+
+/** A React component, by the convention that names one: a PascalCase function in a JS-family file. */
+export function looksLikeComponent(node: Node): boolean {
+  if (node.kind === 'component') return true;
+  if (node.kind !== 'function') return false;
+  return JS_FAMILY.has(node.language) && /^[A-Z]/.test(node.name);
+}
+
+/** Route id → where its code starts, for every route that has an answer. */
+export function routeRoots(cg: CodeGraph, routes: readonly Node[]): Map<string, RouteRoot> {
+  const out = new Map<string, RouteRoot>();
+  if (routes.length === 0) return out;
+  const ids = routes.map((r) => r.id);
+  const edges = cg.getOutgoingEdgesFrom(ids, ['references', 'calls', 'instantiates']);
+  if (edges.length === 0) return out;
+  const targets = cg.getNodesByIds(edges.map((e) => e.target));
+  const byRoute = new Map<string, typeof edges>();
+  for (const e of edges) {
+    const list = byRoute.get(e.source) ?? [];
+    list.push(e);
+    byRoute.set(e.source, list);
+  }
+  const rank = (n: Node): number => (n.kind === 'function' || n.kind === 'method' ? 0 : n.kind === 'component' ? 1 : 2);
+  for (const route of routes) {
+    const list = byRoute.get(route.id);
+    if (!list || list.length === 0) continue;
+    // 1. The handler the resolver named.
+    const named = list
+      .filter((e) => e.kind === 'references')
+      .map((e) => targets.get(e.target))
+      .filter((n): n is Node => !!n && HANDLER_KINDS.has(n.kind) && n.id !== route.id)
+      .sort((a, b) => rank(a) - rank(b) || a.startLine - b.startLine);
+    if (named[0]) {
+      out.set(route.id, { node: named[0], inline: false });
+      continue;
+    }
+    // 2. The component a screen file exports.
+    const rendered = list
+      .filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
+      .map((e) => targets.get(e.target))
+      .filter((n): n is Node => !!n && looksLikeComponent(n) && n.id !== route.id)
+      .sort((a, b) => a.startLine - b.startLine);
+    if (rendered[0]) {
+      out.set(route.id, { node: rendered[0], inline: false });
+      continue;
+    }
+    // 3. The inline handler: the route's own calls are the body's.
+    if (list.some((e) => e.kind === 'calls' && targets.get(e.target)?.kind !== 'file')) {
+      out.set(route.id, { node: route, inline: true });
+    }
+  }
+  return out;
+}

+ 11 - 9
src/ui-server/api/screens.ts

@@ -29,6 +29,7 @@
 
 import type CodeGraph from '../../index';
 import type { Edge, Node } from '../../types';
+import { routeRoots } from './route-roots';
 import { createWhenReader } from './when';
 import { toNodeRef, type WireNodeRef } from './wire';
 
@@ -170,20 +171,21 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
     };
   }
 
-  // Route → the component it renders; component → its route.
+  // Route → the symbol that serves it (`route-roots.ts`: the component a
+  // screen file exports, or the handler a resolver named); symbol → its route.
+  // A route standing in for its own inline handler binds to nothing here — a
+  // walk back from a navigation cannot land on a registration site.
   const routeById = new Map(routes.map((r) => [r.id, r]));
   const routeByFile = new Map(routes.map((r) => [r.filePath, r.id]));
-  const renders = cg.getOutgoingEdgesFrom(routeIds, ['calls', 'instantiates']);
-  const componentIds = new Set(renders.map((e) => e.target));
-  const nodesById = cg.getNodesByIds([...componentIds, ...navEdges.map((e) => e.source)]);
+  const roots = routeRoots(cg, routes);
   const componentOf = new Map<string, Node>();
   const screenOfComponent = new Map<string, string>();
-  for (const edge of renders) {
-    const component = nodesById.get(edge.target);
-    if (!component || componentOf.has(edge.source)) continue;
-    componentOf.set(edge.source, component);
-    screenOfComponent.set(component.id, edge.source);
+  for (const [routeId, root] of roots) {
+    if (root.inline) continue;
+    componentOf.set(routeId, root.node);
+    if (!screenOfComponent.has(root.node.id)) screenOfComponent.set(root.node.id, routeId);
   }
+  const nodesById = cg.getNodesByIds([...componentOf.values()].map((n) => n.id).concat(navEdges.map((e) => e.source)));
 
   const readWhen = createWhenReader(cg, projectRoot, MAX_WHEN_SITES);
   const whenAt = (caller: Node, edge: Edge): Promise<string> => readWhen(caller, { line: edge.line, column: edge.column });

+ 427 - 81
src/ui-server/api/steps.ts

@@ -42,7 +42,11 @@ import type { Edge, Language, Node, UnresolvedReference } from '../../types';
 import { badRequest, intParam, notFound } from './respond';
 import { createSiteReader } from './when';
 import type { SiteTrigger } from '../../graph/branch-guards';
+import { classifyEffect, responseStatus, type Effect } from './effects';
+import { looksLikeComponent, routeRoots } from './route-roots';
+import { splitRouteName } from './routes';
 import { HUB_THRESHOLD, UNCERTAIN_BELOW, toNodeRef, type WireNodeRef } from './wire';
+import { isTestPath } from '../../search/query-utils';
 
 // =============================================================================
 // Wire shapes
@@ -73,6 +77,8 @@ export interface WireStepSite {
   when: string;
   /** What fires THIS site, when it differs from the link's first. */
   trigger?: WireStepTrigger;
+  /** For a response site: the status code it sends, when literal (`res.status(404)`, `throw new NotFoundException`). */
+  status?: number;
 }
 
 /** What fires a step or a link: the event it is written under, and the function that writes it there. */
@@ -107,13 +113,31 @@ export interface WireStep {
   events?: string[];
   /** For a handler: what fires it — the first binding the walk met. */
   trigger?: WireStepTrigger;
-  /** For a screen: its path and the component that renders it. */
-  screen?: { path: string; component: WireNodeRef | null };
+  /**
+   * For a screen or an endpoint: its path and the symbol that serves it — the
+   * component a screen renders, the handler an endpoint runs. `endpoint` when
+   * the route leads with an HTTP verb (`POST /users`); `inline` when the
+   * handler is an anonymous function at the registration site, so the route
+   * itself stands in for it and `component` is null.
+   */
+  screen?: { path: string; component: WireNodeRef | null; endpoint: boolean; inline: boolean };
   /**
    * For an effect: the calls one function makes into one category — `api` is
-   * the first, `apis` all of them — and the function that makes them.
+   * the first, `apis` all of them — and the function that makes them. A
+   * database call also says the model / table it touches when the call
+   * names one, and whether it reads or writes; a response box lists the
+   * status codes its sites send.
    */
-  effect?: { api: string; apis: string[]; category: string; by: WireNodeRef; line: number };
+  effect?: {
+    api: string;
+    apis: string[];
+    category: string;
+    by: WireNodeRef;
+    line: number;
+    model?: string;
+    access?: 'read' | 'write';
+    statuses?: number[];
+  };
 }
 
 export interface WireStepLink {
@@ -138,6 +162,12 @@ export interface WireStepsPayload {
   anchor: WireNodeRef;
   /** Other symbols that share the anchor's name, when it was given by name. */
   ambiguous: WireNodeRef[];
+  /**
+   * What the index is a picture of, decided from its routes: an `app` of
+   * screens, an `api` of endpoints, or a `web` app with both. The viewer's
+   * words (screen / endpoint, store action / data) follow it.
+   */
+  project: 'app' | 'api' | 'web';
   steps: WireStep[];
   links: WireStepLink[];
   depth: number;
@@ -174,6 +204,8 @@ const MAX_FANOUT = 80;
 const MAX_EFFECT_SCANS = 800;
 /** Call sites read for conditions and arguments per request. */
 const MAX_WHEN_SITES = 1600;
+/** Call sites read for the callee as written (effect classification) per request — lookups on trees the guards parsed anyway. */
+const MAX_CALL_SITES = 4000;
 /** Longest effect-box label before its argument list is cut. */
 const MAX_EFFECT_LABEL = 56;
 /**
@@ -214,33 +246,50 @@ export function isStoreFile(file: string): boolean {
 }
 
 /**
- * Calls that leave the index and change something outside the process. A
- * curated table, deliberately: "any call into a package" is every `Date` and
- * `Math.max`, and a box for each would bury the ones that matter. Matched on
- * the reference text as written at the call.
+ * What a call is when it leaves the index, by the reference text alone — the
+ * mobile app's table, kept for callers that have no language in hand. The
+ * Steps walk itself classifies on the call AS WRITTEN with the language and
+ * the project kind (`effects.ts`).
  */
-export const EFFECTS: ReadonlyArray<{ category: string; test: RegExp }> = [
-  {
-    category: 'network',
-    test: /^(?:fetch|axios|ky|got|superagent|XMLHttpRequest|WebSocket)$|^(?:axios|api|client|http|https|httpClient|apiClient|instance|request|agent|graphql|apollo|supabase)\.(?:get|post|put|patch|delete|head|request|query|mutate|rpc|invoke)$|^URLSession(?:\.|$)|^(?:Alamofire|AF)\.|\.(?:dataTask|uploadTask|downloadTask)$/,
-  },
-  {
-    category: 'storage',
-    test: /^(?:AsyncStorage|SecureStore|MMKV|localStorage|sessionStorage|indexedDB|UserDefaults|Keychain|KeychainAccess|FileSystem|RNFS|FileManager|fs|fsp)\b/,
-  },
-  {
-    category: 'device',
-    test: /^(?:Linking|Share|Clipboard|Notifications|Camera|ImagePicker|MediaLibrary|Haptics|Alert|Vibration|Location|Geolocation|Permissions|UIApplication|AVCaptureSession|AVAudioSession|CLLocationManager|UNUserNotificationCenter)\b/,
-  },
-  {
-    category: 'telemetry',
-    test: /^(?:DdRum|DdLogs|DdTrace|DdSdkReactNative|CustomerIO|Sentry|Bugsnag|analytics|Analytics|crashlytics|Crashlytics|mixpanel|Mixpanel|amplitude|Amplitude|posthog|PostHog|LDClient|ldClient)\b/,
-  },
-];
-
 export function effectCategory(referenceName: string): string | null {
-  for (const e of EFFECTS) if (e.test.test(referenceName)) return e.category;
-  return null;
+  return classifyEffect({ text: referenceName, kind: 'calls' })?.category ?? null;
+}
+
+/** A method of a repository / DAO / mapper, by the container's name — the ORM boundary in a project that types it. */
+const REPOSITORY_CONTAINER = /(?:Repository|Repositories|Repo|Dao|DAO|Mapper|Store|Datastore)$/;
+
+/** Decorators that gate a handler: guards, interceptors, pipes, roles, auth, validation, transactions, throttles. */
+const GUARD_DECORATOR =
+  /^(?:UseGuards|UseInterceptors|UsePipes|UseFilters|Roles|Auth|Public|Permissions|Throttle|SkipThrottle|Authorize|AllowAnonymous|PreAuthorize|PostAuthorize|Secured|RolesAllowed|PermitAll|DenyAll|Transactional|Validated|login_required|permission_required|user_passes_test|staff_member_required|require_http_methods|require_POST|require_GET|csrf_exempt|csrf_protect|ratelimit|throttle_classes|permission_classes|authentication_classes|cache_page|ValidateAntiForgeryToken|RequireAuthorization|RequireRole|RequireHttps|EnableCors|CrossOrigin|Cacheable|CacheEvict|CachePut|RateLimiter|CircuitBreaker|Retry|Timeout|Bulkhead|jwt_required|Security|ApiBearerAuth|ApiKeyAuth|BearerAuth|OAuth|Scopes|Roles|HasRole|HasPermission|Idempotent|Lock|Locked|Retryable|Recover)$|Guard|Interceptor|Pipe$|Filter$|Auth|Role|Permission|Throttle|Valid|Transaction|Csrf|Limit/i;
+/** Decorators that ARE the route, the DI wiring, or documentation — never a guard. */
+const NOT_A_GUARD =
+  /^(?:Get|Post|Put|Patch|Delete|Head|Options|All|Controller|RestController|Resolver|Query|Mutation|Subscription|Injectable|Module|Api\w*|Http(?:Get|Post|Put|Patch|Delete|Head|Options)|Route|RequestMapping|\w+Mapping|Component|Service|Repository|Bean|Autowired|Override|Inject|Param|Body|Res|Req|Headers|Ip|HostParam|Session|UploadedFiles?|HttpCode|Header|Redirect|Render|Version|SerializeOptions|ResponseBody|ResponseStatus|Produces|Consumes|FromBody|FromRoute|FromQuery|FromForm|FromHeader|FromServices|Path|PathVariable|RequestParam|RequestBody|RequestHeader|ModelAttribute|Valid|Args|Context|Parent|Info|Field|ObjectType|InputType|ArgsType|Entity|Column|PrimaryGeneratedColumn|OneToMany|ManyToOne|Prop|Schema|Type|Expose|Exclude|Transform|IsString|IsNumber|IsOptional|Length|Min|Max|Deprecated|SuppressWarnings|FunctionalInterface|Slf4j|Data|Builder|Getter|Setter|NoArgsConstructor|AllArgsConstructor|RequiredArgsConstructor|Value|ConfigurationProperties|Configuration|EnableScheduling|SpringBootApplication|Profile|Order|Primary|Qualifier|Lazy|Scope|JsonProperty|JsonIgnore|Nullable|NonNull|NotNull|Size|Pattern|Email|Positive|router\.\w+|app\.\w+|api\.\w+|bp\.\w+|blueprint\.\w+|\w+\.(?:route|get|post|put|patch|delete))$/;
+/** Decorators that fire a function from outside a request: a job, an event, a message, a schedule. */
+const CONSUMER_DECORATOR =
+  /^(?:Process|Processor|OnEvent|OnQueueEvent|OnWorkerEvent|OnGlobalQueueEvent|Cron|Interval|Timeout|MessagePattern|EventPattern|SubscribeMessage|Scheduled|Schedules?|KafkaListener|RabbitListener|RabbitSubscribe|RabbitRPC|JmsListener|SqsListener|SqsMessageHandler|EventListener|TransactionalEventListener|StreamListener|ServiceActivator|receiver|shared_task|task|periodic_task|app\.task|celery\.task|on|hears|command|event|listen|listener|Consume|Consumer|Subscribe|Subscriber|CapSubscribe|Function|FunctionName|TimerTrigger|QueueTrigger|ServiceBusTrigger|EventGridTrigger|BlobTrigger|CosmosDBTrigger|Job|job|Worker|worker|EventHandler|CommandHandler|QueryHandler|OnMessage|MessageHandler|GrpcMethod|GrpcStreamMethod|WebSocketGateway|dramatiq\.actor|actor|huey\.task|db_task|Signal|signal|hook|Hook|OnModuleInit|OnApplicationBootstrap|PostConstruct|PreDestroy|Bean|Startup|Shutdown)$/;
+
+/** The name of a decorator, before its arguments. */
+function decoratorName(text: string): string {
+  return text.replace(/\(.*$/s, '').trim();
+}
+
+/** The first string literal in a decorator's arguments — `'email'` of `@Process('email')`. */
+function decoratorLiteral(text: string): string | null {
+  const m = /\(\s*(['"`])((?:(?!\1).)*)\1/.exec(text);
+  return m ? `'${m[2]}'` : null;
+}
+
+function isGuardDecorator(text: string): boolean {
+  const name = decoratorName(text);
+  if (NOT_A_GUARD.test(name)) return false;
+  return GUARD_DECORATOR.test(name);
+}
+
+/** FastAPI: `dependencies=[Depends(auth), Depends(rate_limit)]` inside the route decorator. */
+function dependenciesIn(text: string): string[] {
+  const m = /dependencies\s*=\s*\[([^\]]*)\]/.exec(text);
+  if (!m) return [];
+  return m[1]!.split(/,(?![^()]*\))/).map((x) => x.trim()).filter(Boolean);
 }
 
 // =============================================================================
@@ -269,25 +318,150 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
 
   const { anchor, ambiguous } = resolveAnchor(cg, query);
 
-  // Route → the component it renders, and the routes by id.
+  // Route → where its code starts: the handler a resolver named, the page a
+  // screen file exports, or the route itself standing in for an inline
+  // handler (`route-roots.ts`) — and what kind of project this is, for the
+  // words the viewer uses.
   const routes = cg.getNodesByKind('route');
-  const renders = routes.length === 0 ? [] : cg.getOutgoingEdgesFrom(routes.map((r) => r.id), ['calls', 'instantiates']);
-  const componentOf = new Map<string, Node>();
-  if (renders.length > 0) {
-    const components = cg.getNodesByIds(renders.map((e) => e.target));
-    for (const edge of renders) {
-      const c = components.get(edge.target);
-      if (c && !componentOf.has(edge.source)) componentOf.set(edge.source, c);
-    }
-  }
+  const roots = routeRoots(cg, routes);
+  const project = projectKind(routes, stats.edgesByKind?.navigates ?? 0);
 
   const reader = createSiteReader(cg, projectRoot, MAX_WHEN_SITES);
+  const calls = createSiteReader(cg, projectRoot, MAX_CALL_SITES);
   const whenAt = (caller: Node, site: { line?: number; column?: number }) => reader.when(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);
     return args === null ? site : { ...site, args };
   };
+  /** The call as written at a site, and what it passes — one read for both. */
+  const callAt = (caller: Node, site: { line?: number; column?: number; callee?: string }) => calls.callSite(caller, site);
+
+  // The declared type of a receiver: `OwnerRepository owners` in a Spring
+  // controller makes `owners.save` the database; `private readonly
+  // usersService: UsersService` in a Nest controller says where
+  // `this.usersService.findByEmail` goes. The index keeps the first in a
+  // field's signature and nothing of the second, so the class body is read
+  // from the tree at request time, once per class.
+  const fileTypes = new Map<string, Map<string, string>>();
+  const classTypes = new Map<string, Map<string, string>>();
+  const receiverTypeFor = async (caller: Node, callee: string): Promise<string | null> => {
+    const first = callee.replace(/^(?:this|self)\./, '').split(/[.:(]/)[0] ?? '';
+    if (!first || /^[A-Z]/.test(first)) return null;
+    const declared = await memberTypesOf(caller);
+    const own = declared.get(first) ?? declared.get(first.replace(/^_/, '')) ?? null;
+    if (own) return own;
+    let types = fileTypes.get(caller.filePath);
+    if (!types) {
+      types = new Map();
+      for (const n of cg.getNodesInFile(caller.filePath)) {
+        if ((n.kind !== 'field' && n.kind !== 'property' && n.kind !== 'variable' && n.kind !== 'parameter') || !n.signature) continue;
+        const sig = n.signature.replace(/\s+/g, ' ').trim();
+        // `OwnerRepository owners`, `private final OwnerRepository owners`, `owners: OwnerRepository`, `val owners: OwnerRepository`.
+        const typed = new RegExp(`(?:^|\\s)([A-Z][\\w<>,?. ]*?)\\s+${n.name}\\b`).exec(sig) ?? new RegExp(`\\b${n.name}\\s*:\\s*([A-Z][\\w<>,?. ]*)`).exec(sig);
+        if (typed && !types.has(n.name)) types.set(n.name, typed[1]!.trim());
+      }
+      fileTypes.set(caller.filePath, types);
+    }
+    return types.get(first) ?? null;
+  };
+  const memberTypesOf = async (node: Node): Promise<Map<string, string>> => {
+    const key = `${node.filePath}:${node.startLine}`;
+    let types = classTypes.get(key);
+    if (!types) {
+      types = await calls.memberTypes(node);
+      classTypes.set(key, types);
+    }
+    return types;
+  };
+
+  // Where a member call really goes, by the receiver's declared type: the
+  // class named by the type, and its method of the call's name. Null when the
+  // type names nothing in the index (an ORM's `Repository<Cat>`) — then the
+  // call leaves the index, and the effect table says as what.
+  const classByName = new Map<string, Node | null>();
+  /** The class / interface / struct a declared type names in the index, or null for a library's. */
+  const classOfType = async (type: string): Promise<Node | null> => {
+    const typeName = type.replace(/<.*$/, '').replace(/^[*&]+/, '').replace(/[?!]$/, '').split(/[.:]/).pop()?.trim() ?? '';
+    if (!typeName || /^(?:string|number|boolean|any|unknown|object|void|String|Integer|Long|Boolean|int|long|bool|var|dynamic|Object|List|Map|Set|Array|Promise|Optional|Task|IEnumerable|Iterable)$/.test(typeName)) return null;
+    let cls = classByName.get(typeName);
+    if (cls === undefined) {
+      const found = cg.getNodesByName(typeName).filter((n) => n.kind === 'class' || n.kind === 'interface' || n.kind === 'struct');
+      cls = found.find((n) => !isTestPath(n.filePath)) ?? found[0] ?? null;
+      classByName.set(typeName, cls);
+    }
+    return cls;
+  };
+  const resolveByReceiver = async (caller: Node, callee: string): Promise<Node | null> => {
+    const segments = callee.replace(/\([^()]*\)/g, '').split(/[.:]+/).filter(Boolean);
+    if (segments.length < 2) return null;
+    const type = await receiverTypeFor(caller, callee);
+    if (!type) return null;
+    const cls = await classOfType(type);
+    if (!cls) return null;
+    const method = segments[segments.length - 1]!;
+    const members = cg.getNodesInFile(cls.filePath).filter((n) => (n.kind === 'method' || n.kind === 'function') && n.name === method && n.startLine >= cls!.startLine && n.endLine <= cls!.endLine);
+    return members[0] ?? null;
+  };
+
+  /** A method the walk cannot enter (an interface's, an ORM's) on a repository-shaped container. */
+  const repositoryMethod = (target: Node): boolean => {
+    if (target.kind !== 'method' && target.kind !== 'function') return false;
+    if (isTestPath(target.filePath)) return false;
+    const container = target.qualifiedName.replace(/[.:]+[^.:]*$/, '').split(/[.:]+/).pop() ?? '';
+    if (!REPOSITORY_CONTAINER.test(container) && !/(?:^|\/)(?:repositories|repository|dao|daos|mappers)\//i.test(posix(target.filePath))) return false;
+    return cg.getOutgoingEdgesFrom([target.id], WALK_KINDS).length === 0;
+  };
+
+  /** What runs before a route's handler: the middleware arguments at the registration, or the guard decorators on it. */
+  const chainFor = async (route: Node, root: Node | null): Promise<string[]> => {
+    const after: string[] = [];
+    if (JS_FAMILY.has(route.language)) {
+      const site = await calls.callSite(route, { line: route.startLine, column: 0 });
+      if (site && /\.(?:get|post|put|patch|delete|all|use|head|options|route)$/i.test(site.callee)) {
+        const args = site.argList.slice(1);
+        if (args.length > 0 && !/^\{/.test(args[args.length - 1]!)) args.pop();
+        for (const a of args) if (a && !/^\{ ?…? ?\}$/.test(a)) after.push(a);
+      }
+    }
+    if (root && root.id !== route.id) {
+      const decs = await calls.decorators(root);
+      if (decs) {
+        for (const d of [...decs.class, ...decs.own]) {
+          if (!JS_FAMILY.has(root.language) && !/^(?:python)$/.test(root.language)) {
+            if (isGuardDecorator(d)) after.push(d);
+            continue;
+          }
+          for (const dep of dependenciesIn(d)) after.push(dep);
+          if (isGuardDecorator(d)) after.push(d);
+        }
+      }
+    }
+    return [...new Set(after)];
+  };
+
+  /** The request a route's handler serves, as its trigger. */
+  const requestTrigger = async (route: Node, root: Node | null): Promise<WireStepTrigger | null> => {
+    const { method, path } = splitRouteName(route.name);
+    if (method === null) return null;
+    const after = await chainFor(route, root);
+    return { kind: 'request', name: method, of: path, in: basename(route.filePath), ...(after.length > 0 ? { after } : {}) };
+  };
+
+  /** A job, an event, a message or a schedule that fires a function, from its decorators. */
+  const consumerTrigger = async (node: Node): Promise<WireStepTrigger | null> => {
+    if (node.kind !== 'function' && node.kind !== 'method') return null;
+    const decs = await calls.decorators(node);
+    if (!decs) return null;
+    for (const d of decs.own) {
+      const name = decoratorName(d);
+      const last = name.split('.').pop() ?? name;
+      if (!CONSUMER_DECORATOR.test(name) && !CONSUMER_DECORATOR.test(last)) continue;
+      const guards = [...decs.class, ...decs.own].filter((x) => x !== d && isGuardDecorator(x));
+      return { kind: 'decorator', name, of: decoratorLiteral(d), in: basename(node.filePath), ...(guards.length > 0 ? { after: guards } : {}) };
+    }
+    return null;
+  };
 
   const steps = new Map<string, StepRecord>();
   const links = new Map<string, WireStepLink>();
@@ -318,20 +492,36 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
       return null;
     }
     const isRoute = node.kind === 'route';
+    const routeRoot = isRoute ? (roots.get(node.id) ?? null) : null;
     const record: StepRecord = {
       id: node.id,
       kind: isRoute ? 'screen' : kind,
       anchor: false,
       node: toNodeRef(node),
-      label: isRoute ? node.name : node.name,
-      sub: isRoute ? (componentOf.get(node.id)?.name ?? posix(node.filePath)) : posix(node.filePath),
+      label: node.name,
+      // A screen says its component, an endpoint its handler; a route the
+      // graph bound to nothing says only where it is registered.
+      sub: isRoute
+        ? routeRoot === null
+          ? basename(node.filePath)
+          : routeRoot.inline
+            ? `inline handler · ${basename(node.filePath)}`
+            : routeRoot.node.name
+        : posix(node.filePath),
       depth,
       cut: null,
       ...extra,
-      root: isRoute ? (componentOf.get(node.id) ?? null) : node,
+      root: isRoute ? (routeRoot?.node ?? null) : node,
     };
     if (kind === 'event' && extra.event) record.events = [extra.event];
-    if (isRoute) record.screen = { path: node.name, component: componentOf.has(node.id) ? toNodeRef(componentOf.get(node.id)!) : null };
+    if (isRoute) {
+      record.screen = {
+        path: node.name,
+        component: routeRoot !== null && !routeRoot.inline ? toNodeRef(routeRoot.node) : null,
+        endpoint: splitRouteName(node.name).method !== null,
+        inline: routeRoot?.inline ?? false,
+      };
+    }
     steps.set(node.id, record);
     return record;
   };
@@ -339,37 +529,102 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
   // One box per (function, category): `uploadARCapture` makes one network
   // call, three storage calls and three telemetry calls — three boxes, each
   // listing its calls, not seven.
-  const effectStep = (by: Node, ref: { referenceName: string; line: number }, category: string, depth: number): StepRecord | null => {
+  const effectSub = (e: NonNullable<WireStep['effect']>, by: Node): string =>
+    [e.category, e.model, e.access, by.name].filter((x): x is string => !!x).join(' · ');
+  const effectStep = (by: Node, ref: { referenceName: string; line: number }, effect: Effect, depth: number): StepRecord | null => {
+    const category = effect.category;
     const id = `effect:${by.id}:${category}`;
     const existing = steps.get(id);
     if (existing) {
-      const apis = existing.effect!.apis;
-      if (!apis.includes(ref.referenceName)) {
-        apis.push(ref.referenceName);
-        existing.label = `${apis[0]} +${apis.length - 1}`;
+      const e = existing.effect!;
+      if (!e.apis.includes(ref.referenceName)) {
+        e.apis.push(ref.referenceName);
+        existing.label = `${e.apis[0]} +${e.apis.length - 1}`;
+      }
+      // Several models behind one box: list them; several accesses: say both.
+      if (effect.model && e.model !== effect.model) {
+        const models = new Set((e.model ?? '').split(', ').filter(Boolean));
+        models.add(effect.model);
+        e.model = [...models].slice(0, 3).join(', ') + (models.size > 3 ? ', …' : '');
       }
+      if (effect.access && e.access && e.access !== effect.access) e.access = undefined;
+      existing.sub = effectSub(e, by);
       return existing;
     }
     if (steps.size >= limit) {
       truncated.steps++;
       return null;
     }
+    const e: NonNullable<WireStep['effect']> = {
+      api: ref.referenceName,
+      apis: [ref.referenceName],
+      category,
+      by: toNodeRef(by),
+      line: ref.line,
+      ...(effect.model ? { model: effect.model } : {}),
+      ...(effect.access ? { access: effect.access } : {}),
+    };
     const record: StepRecord = {
       id,
       kind: 'effect',
       anchor: false,
       node: null,
       label: ref.referenceName,
-      sub: `${category} · ${by.name}`,
+      sub: effectSub(e, by),
       depth,
       cut: null,
-      effect: { api: ref.referenceName, apis: [ref.referenceName], category, by: toNodeRef(by), line: ref.line },
+      effect: e,
       root: null,
     };
     steps.set(id, record);
     return record;
   };
 
+  /** One effect site: the call as written, what it passes, when, what fires it, and — for a response — the status. */
+  const effectLink = async (
+    step: StepRecord,
+    fold: Fold,
+    ref: { referenceName: string; referenceKind: 'calls' | 'instantiates'; line: number; column?: number },
+    trigger: WireStepTrigger | null,
+    fallbackArgs: string | null = null,
+    requireReceiver = false
+  ): Promise<boolean> => {
+    const at = { line: ref.line, column: ref.column };
+    const site = await callAt(fold.node, { ...at, callee: ref.referenceName });
+    // The site read must be THIS call: its last segment is the reference's.
+    const last = (n: string) => n.replace(/\([^()]*\)/g, '').split(/[.:]/).pop() ?? n;
+    const usable = !!site && site.callee !== '' && last(site.callee) === last(ref.referenceName);
+    const text = usable ? site.callee : ref.referenceName;
+    if (requireReceiver && !/[.:>]/.test(text)) return false;
+    const args = usable ? site.args : fallbackArgs;
+    // The receiver's declared type counts only when the call leaves the
+    // index through it: a library's `Repository<Cat>`, or the project's own
+    // `OwnerRepository` interface whose `save` comes from Spring Data — never
+    // a project class that declares the method, which is a place to walk into.
+    const declared = await receiverTypeFor(fold.node, text);
+    const receiverType = declared && (await resolveByReceiver(fold.node, text)) === null ? declared : null;
+    const effect = classifyEffect({
+      text,
+      kind: ref.referenceKind,
+      language: fold.node.language,
+      project,
+      receiverType,
+      args,
+    });
+    if (effect === null) return false;
+    const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, step.depth + 1);
+    if (target === null) return true;
+    const when = await whenAt(fold.node, at);
+    const wireSite: WireStepSite = { file: posix(fold.node.filePath), line: ref.line, text, when: '' };
+    if (args !== null) wireSite.args = args;
+    if (effect.category === 'response') {
+      const status = responseStatus(text, args, ref.referenceKind);
+      if (status !== null) wireSite.status = status;
+    }
+    link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, trigger ?? (await triggerAt(fold.node, at)));
+    return true;
+  };
+
   const link = (
     from: StepRecord,
     to: StepRecord,
@@ -395,7 +650,13 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     if (existing) {
       if (structural(stamped) && existing.sites.some((s) => !structural(s))) return;
       if (!structural(stamped) && existing.sites.every(structural)) existing.sites.length = 0;
-      if (!existing.sites.some((s) => s.file === site.file && s.line === site.line)) existing.sites.push(stamped);
+      // One statement, two references (`res.status(201)` and its `.json(…)`):
+      // the outer call is the site, the inner one folds into it.
+      const sameLine = existing.sites.findIndex((s) => s.file === site.file && s.line === site.line);
+      if (sameLine < 0) existing.sites.push(stamped);
+      else if (stamped.text.startsWith(existing.sites[sameLine]!.text) && stamped.text.length > existing.sites[sameLine]!.text.length) {
+        existing.sites[sameLine] = stamped;
+      }
       if (!existing.trigger && trigger) existing.trigger = trigger;
       if (when !== existing.when) {
         if (!when || !existing.when) existing.when = '';
@@ -425,9 +686,18 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     return t ? { ...t, in: caller.name } : null;
   };
 
-  // The anchor: a screen keeps its kind and explores from its component.
+  // The anchor: a screen keeps its kind and explores from its component; an
+  // endpoint says the request that fires it and what runs before its handler;
+  // a function says the job, event or schedule written on it.
   const first = stepFor(anchor, 'anchor', 0)!;
   first.anchor = true;
+  if (anchor.kind === 'route') {
+    const t = await requestTrigger(anchor, first.root);
+    if (t) first.trigger = t;
+  } else {
+    const t = await consumerTrigger(anchor);
+    if (t) first.trigger = t;
+  }
   const queue: StepRecord[] = [first];
   /** Steps whose exploration has been queued — each is explored once, from the first row it appears on. */
   const explored = new Set<string>([first.id]);
@@ -500,14 +770,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           }
           for (const ref of [...refs].sort((a, b) => a.line - b.line || a.column - b.column)) {
             if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'instantiates') continue;
-            const category = effectCategory(ref.referenceName);
-            if (category === null) continue;
-            const target = effectStep(fold.node, ref, category, step.depth + 1);
-            if (target === null) continue;
-            const at = { line: ref.line, column: ref.column };
-            const when = await whenAt(fold.node, at);
-            const site = await withArgs({ file: posix(fold.node.filePath), line: ref.line, text: ref.referenceName, when: '' }, fold.node, at);
-            link(step, target, 'effect', fold.chain, [...fold.whens, when], site, null, await triggerAt(fold.node, at));
+            await effectLink(step, fold, { referenceName: ref.referenceName, referenceKind: ref.referenceKind, line: ref.line, column: ref.column }, null);
           }
         }
 
@@ -516,8 +779,14 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           const meta = (e.metadata ?? {}) as Record<string, unknown>;
           if (e.kind === 'references') return meta.fnRef === true;
           if (e.kind === 'contains') {
+            // A function's nested handlers; and, when the walk STARTS at a
+            // class (a ViewSet, a class-based view bound to a route), its
+            // methods — never a class met on the way, whose methods are not
+            // what the caller reached.
             const t = targets.get(e.target);
-            return (fold.node.kind === 'function' || fold.node.kind === 'method') && !!t && (t.kind === 'function' || t.kind === 'method');
+            const fromFunction = fold.node.kind === 'function' || fold.node.kind === 'method';
+            const fromRootClass = fold.node.kind === 'class' && fold.chain.length === 0 && fold.node.id === step.root?.id;
+            return (fromFunction || fromRootClass) && !!t && (t.kind === 'function' || t.kind === 'method');
           }
           return true;
         });
@@ -541,10 +810,55 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           trigger: WireStepTrigger | null;
         }
         const arrivals: Arrival[] = [];
+        const fromTest = isTestPath(fold.node.filePath);
         for (const e of edges) {
-          const target = targets.get(e.target);
-          if (!target || target.kind === 'file' || target.id === fold.node.id) continue;
+          const found = targets.get(e.target);
+          if (!found || found.kind === 'file') continue;
           const meta = (e.metadata ?? {}) as Record<string, unknown>;
+
+          // A member call the index kept only the last segment of (`create`
+          // for `prisma.user.create`) resolves by name alone — a guess, and
+          // often the wrong one. The call AS WRITTEN decides first: an effect
+          // is drawn as one and the guessed edge is not walked. A call through
+          // a project-made value (`client.post` on the axios instance) is the
+          // same case with the constant as the target.
+          let target = targets.get(e.target)!;
+          let retargeted = false;
+          if (e.kind === 'calls' && typeof meta.synthesizedBy !== 'string' && e.provenance !== 'heuristic') {
+            const refName = typeof meta.refName === 'string' ? meta.refName : target.name;
+            const bare = !refName.includes('.');
+            // A member call whose receiver is declared as a type the index
+            // holds no class for (`DataStore<UserPreferences>`) leaves the
+            // index too, whatever name-matched — an `updateData` on a test
+            // double, say.
+            let external = false;
+            if (!bare && target.kind !== 'constant' && target.kind !== 'variable') {
+              const declared = await receiverTypeFor(fold.node, refName);
+              external = !!declared && (await classOfType(declared)) === null;
+            }
+            if (bare || external || target.kind === 'constant' || target.kind === 'variable') {
+              const drawn = await effectLink(step, fold, { referenceName: refName, referenceKind: 'calls', line: e.line ?? fold.node.startLine, column: e.column }, null, null, true);
+              if (drawn) continue;
+              // Not an effect: does the receiver's declared type say where the
+              // call goes? A class in the index wins over the name-only guess.
+              if (bare) {
+                const written = await callAt(fold.node, { line: e.line, column: e.column, callee: refName });
+                if (written && /[.:]/.test(written.callee) && (written.callee.split(/[.:]/).pop() ?? '') === refName) {
+                  const real = await resolveByReceiver(fold.node, written.callee);
+                  if (real && real.id !== target.id) {
+                    target = real;
+                    retargeted = true;
+                  }
+                }
+              }
+            }
+          }
+          if (target.id === fold.node.id) continue;
+          // A production walk never enters a test double: an interface's
+          // dispatch into `TestUserDataRepository`, or a `DataStore` name-matched
+          // to the in-memory one, is the test suite's story. Judged after the
+          // call as written had its chance to be an effect.
+          if (!fromTest && isTestPath(target.filePath)) continue;
           const site: WireStepSite = {
             file: posix(fold.node.filePath),
             line: e.line ?? fold.node.startLine,
@@ -607,13 +921,19 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
               if (trigger) extra.trigger = trigger;
             }
           }
+          if (retargeted) meta.resolvedBy = 'receiver-type';
           arrivals.push({ e, target, meta, site, kind, linkKind, extra, trigger });
         }
 
         for (const a of arrivals) {
           if (a.kind === null) continue;
+          const fresh = !steps.has(a.target.id);
           const to = stepFor(a.target, a.kind, step.depth + 1, a.extra);
           if (to === null) continue;
+          if (fresh && !to.trigger) {
+            const t = a.target.kind === 'route' ? await requestTrigger(a.target, to.root) : await consumerTrigger(a.target);
+            if (t) to.trigger = t;
+          }
           const at = { line: a.e.line, column: a.e.column };
           const when = await whenAt(fold.node, at);
           // A call-shaped hop says what it passes; a navigation already says
@@ -635,18 +955,17 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           // `client` constant, not to anything outside the index. The call
           // text is the evidence: the call is the effect, the constant is not
           // a place to walk into.
-          if (e.kind === 'calls' && (target.kind === 'constant' || target.kind === 'variable')) {
-            const api = typeof meta.refName === 'string' ? meta.refName : null;
-            const category = api === null ? null : effectCategory(api);
-            if (api !== null && category !== null) {
-              const to = effectStep(fold.node, { referenceName: api, line: e.line ?? fold.node.startLine }, category, step.depth + 1);
-              if (to === null) continue;
-              const at = { line: e.line, column: e.column };
-              const when = await whenAt(fold.node, at);
-              const site = await withArgs({ file: posix(fold.node.filePath), line: e.line ?? fold.node.startLine, text: api, when: '' }, fold.node, at);
-              link(step, to, 'effect', fold.chain, [...fold.whens, when], site, null, a.trigger);
-              continue;
-            }
+          // A thrown exception the framework answers with (`throw new
+          // NotFoundException(…)` on a class the project defines) is a
+          // response, not a place to walk into; a repository's method the
+          // walk cannot enter (an interface's, the ORM's) is the database.
+          if (e.kind === 'instantiates' && target.kind === 'class') {
+            if (await effectLink(step, fold, { referenceName: target.name, referenceKind: 'instantiates', line: e.line ?? fold.node.startLine, column: e.column }, a.trigger)) continue;
+          }
+          if (e.kind === 'calls' && repositoryMethod(target)) {
+            const container = target.qualifiedName.replace(/[.:]+[^.:]*$/, '').split(/[.:]+/).pop() ?? '';
+            const api = typeof meta.refName === 'string' && meta.refName.includes('.') ? meta.refName : `${container}.${target.name}`;
+            if (await effectLink(step, fold, { referenceName: api, referenceKind: 'calls', line: e.line ?? fold.node.startLine, column: e.column }, a.trigger)) continue;
           }
 
           // Already a step, reached here by a plain call: a link, not a fold.
@@ -693,8 +1012,19 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     sitesByStep.set(l.to, list);
   }
   for (const step of steps.values()) {
-    if (step.kind !== 'effect' || !step.effect || step.effect.apis.length !== 1) continue;
+    if (step.kind !== 'effect' || !step.effect) continue;
     const sites = sitesByStep.get(step.id) ?? [];
+    // A response box is the endpoint's contract: the status codes it can
+    // send, when they are literal, are its label; the rows say when.
+    if (step.effect.category === 'response') {
+      const statuses = [...new Set(sites.map((s) => s.status).filter((x): x is number => typeof x === 'number'))].sort((a, b) => a - b);
+      if (statuses.length > 0) {
+        step.effect.statuses = statuses;
+        step.label = statuses.join(' · ');
+        continue;
+      }
+    }
+    if (step.effect.apis.length !== 1) continue;
     if (sites.length !== 1 || sites[0]!.args === undefined) continue;
     const label = `${step.effect.api}(${sites[0]!.args})`;
     step.label = label.length > MAX_EFFECT_LABEL ? `${label.slice(0, MAX_EFFECT_LABEL - 2)}…)` : label;
@@ -704,6 +1034,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
   return {
     anchor: toNodeRef(anchor),
     ambiguous,
+    project,
     steps: ordered.map(({ root: _root, ...step }) => step),
     links: [...links.values()].sort((a, b) => a.id.localeCompare(b.id)),
     depth: depthCap,
@@ -763,11 +1094,25 @@ function isSharedChrome(cg: CodeGraph, component: Node, memo: Map<string, number
   return renderParents(cg, component, memo) >= SHARED_CHROME_MIN;
 }
 
-/** A React component, by the convention that names one: a PascalCase function in a JS-family file. */
-function looksLikeComponent(node: Node): boolean {
-  if (node.kind === 'component') return true;
-  if (node.kind !== 'function') return false;
-  return JS_FAMILY.has(node.language) && /^[A-Z]/.test(node.name);
+/**
+ * What kind of project the picture is of, by what its routes are: endpoints
+ * (`POST /users`) make an API; screens with navigation between them make an
+ * app; both — pages and the endpoints behind them — make a web app.
+ */
+export function projectKind(routes: readonly Node[], navigates: number): 'app' | 'api' | 'web' {
+  let endpoints = 0;
+  let pages = 0;
+  for (const r of routes) {
+    if (splitRouteName(r.name).method !== null) endpoints++;
+    else if (r.name.startsWith('/')) pages++;
+  }
+  if (endpoints === 0) return 'app';
+  return navigates > 0 || pages > 0 ? 'web' : 'api';
+}
+
+function basename(p: string): string {
+  const s = posix(p);
+  return s.slice(s.lastIndexOf('/') + 1);
 }
 
 /**
@@ -807,6 +1152,7 @@ function hopLabel(meta: Record<string, unknown>, synthesized: boolean): string {
   const parts: string[] = [];
   if (typeof meta.synthesizedBy === 'string') parts.push(`via ${meta.synthesizedBy}`);
   else if (synthesized) parts.push('inferred');
+  if (meta.resolvedBy === 'receiver-type') parts.push('by the receiver’s declared type');
   if (typeof meta.event === 'string') parts.push(`event ${meta.event}`);
   if (meta.bridge === 'react-native') parts.push(`React Native bridge${typeof meta.module === 'string' ? ` · ${meta.module}` : ''}`);
   if (typeof meta.registeredAt === 'string') parts.push(`registered at ${meta.registeredAt}`);

+ 32 - 0
src/ui-server/api/when.ts

@@ -14,11 +14,16 @@ import type CodeGraph from '../../index';
 import type { Language } from '../../types';
 import {
   callArgumentsForFile,
+  callSitesForFile,
+  decoratorsForFile,
   guardLabel,
   guardsForFile,
+  memberTypesForFile,
   siteKey,
   supportsBranchGuards,
   triggersForFile,
+  type CallSiteText,
+  type DefinitionDecorators,
   type SiteTrigger,
 } from '../../graph/branch-guards';
 import { resolveProjectFile } from '../security';
@@ -96,6 +101,12 @@ export interface SiteReader {
   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. */
   trigger(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<SiteTrigger | null>;
+  /** The call as written (the whole member chain) and what it passes; null when unreadable. `callee` names the call when a position is shared. */
+  callSite(caller: { filePath: string; language: Language }, site: { line?: number; column?: number; callee?: string }): Promise<CallSiteText | null>;
+  /** The decorators / annotations / attributes on a definition, and on its class; null when unreadable. */
+  decorators(definition: { filePath: string; language: Language; startLine: number }): Promise<DefinitionDecorators | null>;
+  /** The declared types of the members of the class a definition belongs to, by member name; empty when unreadable. */
+  memberTypes(definition: { filePath: string; language: Language; startLine: number }): Promise<Map<string, string>>;
 }
 
 /**
@@ -151,6 +162,27 @@ export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites =
       const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
       return (await triggersForFile(file.abs, file.language, [key])).get(siteKey(key)) ?? null;
     },
+    async callSite(caller, site) {
+      if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return null;
+      const file = resolve(caller);
+      if (!file) return null;
+      sites++;
+      const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null, ...(site.callee ? { callee: site.callee } : {}) };
+      return (await callSitesForFile(file.abs, file.language, [key])).get(siteKey(key)) ?? null;
+    },
+    async decorators(definition) {
+      // Not counted: one lookup per step, on a tree the walk has parsed anyway.
+      if (!definition.startLine || !supportsBranchGuards(definition.language)) return null;
+      const file = resolve(definition);
+      if (!file) return null;
+      return (await decoratorsForFile(file.abs, file.language, [definition.startLine])).get(definition.startLine) ?? null;
+    },
+    async memberTypes(definition) {
+      if (!definition.startLine || !supportsBranchGuards(definition.language)) return new Map();
+      const file = resolve(definition);
+      if (!file) return new Map();
+      return memberTypesForFile(file.abs, file.language, definition.startLine);
+    },
   };
 }
 

+ 3 - 2
ui/src/components/steps/StepNode.svelte

@@ -14,7 +14,7 @@
    */
   import { Handle, Position, type NodeProps } from '@xyflow/svelte';
   import type { MapNodeLayout } from '../../lib/map-model';
-  import { kindWord, type StepNodeInfo } from '../../lib/steps-model';
+  import { kindWord, type ProjectKind, type StepNodeInfo } from '../../lib/steps-model';
 
   let { data }: NodeProps = $props();
 
@@ -22,6 +22,7 @@
     data as unknown as {
       layout: MapNodeLayout;
       info: StepNodeInfo;
+      project: ProjectKind;
       selected: boolean;
       dimmed: boolean;
       onSelect: (id: string) => void;
@@ -73,7 +74,7 @@
   style={`width:${layout.width}px;height:${layout.height}px`}
   onclick={() => node.onSelect(info.id)}
   aria-pressed={node.selected}
-  title={`${info.label} — ${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind)}. ${info.sub}.${cutNote}`}
+  title={`${info.label} — ${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind, node.project, step)}. ${info.sub}.${cutNote}`}
 >
   <span class="name"
     >{#if step.anchor}<span class="mark" aria-hidden="true">●</span>{/if}{info.label}{#if step.cut !== null}<span

+ 46 - 15
ui/src/lib/steps-model.ts

@@ -63,36 +63,66 @@ const HIT_SAMPLES = 24;
 
 /* ---------------------------------------------------------------- words -- */
 
-/** A short word for a step's kind, as the panel and the legend say it. */
-export function kindWord(kind: WireStep['kind']): string {
+/** What the index is a picture of; the server decides it from the routes (`WireStepsPayload.project`). */
+export type ProjectKind = WireStepsPayload['project'];
+
+/**
+ * A short word for a step's kind, as the panel and the legend say it — in the
+ * project's own vocabulary. The same box is a screen in an app, a page in a
+ * web app and an endpoint in an API; a route that leads with an HTTP verb is
+ * an endpoint wherever it is. One place decides, so the legend, the panel
+ * and the tooltip never disagree.
+ */
+export function kindWord(kind: WireStep['kind'], project: ProjectKind = 'app', step?: WireStep): string {
+  return kindWords(kind, project, step)[0];
+}
+
+/** The singular and the plural, for counts: `1 endpoint`, `3 outside the index`. */
+export function kindWords(kind: WireStep['kind'], project: ProjectKind = 'app', step?: WireStep): [string, string] {
   switch (kind) {
     case 'screen':
-      return 'screen';
+      if (step?.screen?.endpoint) return ['endpoint', 'endpoints'];
+      return project === 'api' ? ['endpoint', 'endpoints'] : project === 'web' ? ['page', 'pages'] : ['screen', 'screens'];
     case 'trigger':
-      return 'handler';
+      return ['handler', 'handlers'];
     case 'bridge':
-      return 'native call';
+      return project === 'app' ? ['native call', 'native calls'] : project === 'web' ? ['call to the server', 'calls to the server'] : ['call to another tier', 'calls to another tier'];
     case 'event':
-      return 'native event';
+      return project === 'app' ? ['native event', 'native events'] : project === 'web' ? ['arrives from the server', 'arrive from the server'] : ['arrives from a queue or bus', 'arrive from a queue or bus'];
     case 'store':
-      return 'store action';
+      return project === 'api' ? ['data call', 'data calls'] : ['store action', 'store actions'];
     case 'effect':
-      return 'outside the index';
+      return ['outside the index', 'outside the index'];
     default:
-      return 'start';
+      return ['start', 'start'];
   }
 }
 
+/** `3 handlers`, `1 endpoint`, `11 outside the index`. */
+export function countWords(n: number, kind: WireStep['kind'], project: ProjectKind = 'app'): string {
+  const [one, many] = kindWords(kind, project);
+  return `${n} ${n === 1 ? one : many}`;
+}
+
 /**
  * What fires something, in a few characters: `onPress · <Button>`,
- * `onSubmit · useFormik(…)`, `addListener('onZipComplete')`, `useEffect`.
+ * `onSubmit · useFormik(…)`, `addListener('onZipComplete')`, `useEffect`;
+ * for a server, `POST /users · after authenticate, validate(…)`,
+ * `@Process('email')`, `page load · /blog/[slug]`.
  */
 export function triggerWords(t: WireStepTrigger): string {
+  const after = t.after && t.after.length > 0 ? ` · after ${t.after.join(', ')}` : '';
   switch (t.kind) {
     case 'prop':
       return t.of ? `${t.name} · <${t.of}>` : t.name;
     case 'option':
       return t.of ? `${t.name} · ${t.of}(…)` : t.name;
+    case 'request':
+      return `${t.name} ${t.of ?? ''}`.trim() + after;
+    case 'decorator':
+      return `@${t.name}(${t.of ?? ''})` + after;
+    case 'load':
+      return `page load · ${t.of ?? t.name}` + after;
     default:
       return t.of ? `${t.name}(${t.of})` : t.name;
   }
@@ -114,7 +144,7 @@ export function stepLabel(step: WireStep): string {
 }
 
 /** The second line: what the step is, then where it is. */
-export function stepSub(step: WireStep): string {
+export function stepSub(step: WireStep, project: ProjectKind = 'app'): string {
   const file = step.node ? step.node.file.slice(step.node.file.lastIndexOf('/') + 1) : '';
   switch (step.kind) {
     case 'screen':
@@ -123,15 +153,16 @@ export function stepSub(step: WireStep): string {
       // The event before the file: `onPress · <Button> · index.tsx`.
       return step.trigger ? `${triggerWords(step.trigger)} · ${file}` : `handler · ${file}`;
     case 'bridge':
-      return `native · ${file}`;
+      return `${project === 'app' ? 'native' : project === 'web' ? 'server' : 'another tier'} · ${file}`;
     case 'event':
       return `${step.label} · ${file}`;
     case 'store':
-      return `store · ${file}`;
+      return `${project === 'api' ? 'data' : 'store'} · ${file}`;
     case 'effect':
       return step.sub;
     default:
-      return step.sub;
+      // The anchor: its file, at the size of a box; the panel prints the whole path.
+      return step.node && step.sub === step.node.file ? file : step.sub;
   }
 }
 
@@ -156,7 +187,7 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
   }
   for (const step of payload.steps) {
     counts[step.kind]++;
-    const info: StepNodeInfo = { id: step.id, step, label: stepLabel(step), sub: stepSub(step) };
+    const info: StepNodeInfo = { id: step.id, step, label: stepLabel(step), sub: stepSub(step, payload.project) };
     nodes.set(step.id, info);
     modules.push({
       id: step.id,

+ 36 - 5
ui/src/lib/wire.ts

@@ -712,17 +712,28 @@ export interface WireStepSite {
   when: string;
   /** What fires THIS site, when it differs from the link's first. */
   trigger?: WireStepTrigger;
+  /** For a response site: the status code it sends, when literal. */
+  status?: number;
 }
 
 /** What fires a step or a link: the event it is written under, and the function that writes it there. */
 export interface WireStepTrigger {
-  kind: 'prop' | 'option' | 'callback';
-  /** `onPress`, `onSubmit`, `useEffect`, `addListener`. */
+  /**
+   * `prop` / `option` / `callback`: a binding at the call site (JSX attribute,
+   * `on*` key, runs-later argument). `request`: the route a handler serves —
+   * `name` the verb, `of` the path. `decorator`: a decorator on the handler —
+   * `name` its name, `of` its literal argument (`@Process('email')`). `load`:
+   * a page's own load-time work — `of` the page path.
+   */
+  kind: 'prop' | 'option' | 'callback' | 'request' | 'decorator' | 'load';
+  /** `onPress`, `onSubmit`, `useEffect`, `addListener`, `POST`, `Process`. */
   name: string;
   /** `Button` for a prop, `useFormik` for an option, the first string argument for a callback; null when unknown. */
   of: string | null;
   /** The function the binding is written in. */
   in: string;
+  /** What runs before it fires: the middleware / guard chain, in order (`authenticate`, `validate(…)`). */
+  after?: string[];
 }
 
 export interface WireStep {
@@ -748,9 +759,27 @@ export interface WireStep {
   events?: string[];
   /** For a handler: what fires it. */
   trigger?: WireStepTrigger;
-  screen?: { path: string; component: WireNodeRef | null };
-  /** The calls one function makes into one category, and the function. */
-  effect?: { api: string; apis: string[]; category: string; by: WireNodeRef; line: number };
+  /**
+   * For a screen or an endpoint: its path and the symbol that serves it.
+   * `endpoint` when the route leads with an HTTP verb; `inline` when the
+   * handler is anonymous at the registration site (component is null).
+   */
+  screen?: { path: string; component: WireNodeRef | null; endpoint: boolean; inline: boolean };
+  /**
+   * The calls one function makes into one category, and the function. A
+   * database call names its model / table and read vs write when the call
+   * says; a response box lists the status codes its sites send.
+   */
+  effect?: {
+    api: string;
+    apis: string[];
+    category: string;
+    by: WireNodeRef;
+    line: number;
+    model?: string;
+    access?: 'read' | 'write';
+    statuses?: number[];
+  };
 }
 
 export interface WireStepLink {
@@ -775,6 +804,8 @@ export interface WireStepsPayload {
   anchor: WireNodeRef;
   /** Other symbols that share the anchor's name, when it was given by name. */
   ambiguous: WireNodeRef[];
+  /** An `app` of screens, an `api` of endpoints, or a `web` app with both — the viewer's words follow it. */
+  project: 'app' | 'api' | 'web';
   steps: WireStep[];
   links: WireStepLink[];
   depth: number;

+ 164 - 43
ui/src/views/StepsView.svelte

@@ -21,8 +21,10 @@
   import KindGlyph from '../components/KindGlyph.svelte';
   import {
     canDrawSteps,
+    fetchRoutes,
     fetchScreens,
     fetchSteps,
+    type WireRoute,
     type WireScreen,
     type WireStepLink,
     type WireStepsPayload,
@@ -35,6 +37,7 @@
   import {
     buildStepsModel,
     kindWord,
+    kindWords,
     stepNeighbourhood,
     stepPairId,
     stepViaText,
@@ -62,8 +65,30 @@
   let viewport = $state<Viewport | undefined>(undefined);
   const HOVER_REACH = 10;
 
-  /** The chooser's list, when the view opens without an anchor. */
+  /** The chooser's lists, when the view opens without an anchor: the screens of an app, else the endpoints of an API. */
   let screens = $state<WireScreen[] | null>(null);
+  let routes = $state<WireRoute[] | null>(null);
+
+  /** What the chooser offers: null while reading. */
+  const chooser = $derived.by<'screens' | 'routes' | 'none' | null>(() => {
+    if (screens === null) return null;
+    if (screens.length > 0) return 'screens';
+    if (routes === null) return null;
+    return routes.length > 0 ? 'routes' : 'none';
+  });
+
+  /** Endpoints by the file they are registered in — the router file is how a reader groups them — biggest first, in registration order within. */
+  function routeGroups(list: WireRoute[]): Array<{ file: string; entries: WireRoute[] }> {
+    const byFile = new Map<string, WireRoute[]>();
+    for (const r of list) {
+      const group = byFile.get(r.routeFile) ?? [];
+      group.push(r);
+      byFile.set(r.routeFile, group);
+    }
+    return [...byFile]
+      .map(([file, entries]) => ({ file, entries: [...entries].sort((a, b) => a.routeLine - b.routeLine) }))
+      .sort((a, b) => b.entries.length - a.entries.length || a.file.localeCompare(b.file));
+  }
 
   const LEGEND_KEY = 'codegraph-ui:steps-legend';
   let legendOpen = $state(readLegendOpen());
@@ -82,7 +107,16 @@
     }
   });
 
-  const FIT = { fitViewOptions: { padding: 0.1, maxZoom: 1, minZoom: 0.4 } };
+  /**
+   * The fit. A picture of a few boxes is centred — and the key, bottom left,
+   * would sit on its second row; it is fitted to the right of the key instead.
+   * A picture of many boxes is fitted to the whole stage, as the Screens view's.
+   */
+  const fitOptions = $derived(
+    model !== null && model.layout.nodes.length <= 24 && legendOpen
+      ? { padding: { left: '440px', top: '32px', right: '32px', bottom: '32px' }, maxZoom: 1, minZoom: 0.4 }
+      : { padding: 0.1, maxZoom: 1, minZoom: 0.4 }
+  );
   const nodeTypes = { step: StepNode };
   const edgeTypes = { screen: ScreenEdge };
   const DEPTHS = [4, 6, 8, 10, 12];
@@ -107,11 +141,19 @@
       loading = false;
       error = null;
       fetchScreens(controller.signal)
-        .then((next) => {
+        .then(async (next) => {
           screens = next.routed ? next.screens : [];
+          // No screens: an API's endpoints are its places to start from.
+          if (next.routed) {
+            routes = [];
+            return;
+          }
+          const found = await fetchRoutes({ limit: 300 }, controller.signal);
+          routes = found.routed ? found.entries : [];
         })
         .catch(() => {
-          screens = [];
+          screens = screens ?? [];
+          routes = routes ?? [];
         });
       return () => controller.abort();
     }
@@ -162,6 +204,7 @@
       data: {
         layout: node,
         info: model.nodes.get(node.id)!,
+        project: payload?.project ?? 'app',
         selected: selected === node.id,
         dimmed: neighbours !== null && !neighbours.has(node.id),
         onSelect: (id: string) => {
@@ -333,20 +376,29 @@
     {:else if !asked}
       <div class="state chooser">
         <h2>What happens from where?</h2>
-        <p>
-          Pick a screen and this view draws everything it sets in motion — its handlers, the calls that
-          cross into native code, the events that come back, the state it writes, the requests that leave
-          the app — one box per step, an arrow for every way one leads to the next, and on each arrow the
-          condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
-        </p>
-        {#if screens === null}
-          <p class="dim">Reading screens…</p>
-        {:else if screens.length === 0}
+        {#if chooser === 'routes'}
+          <p>
+            Pick an endpoint and this view draws everything it sets in motion — its handler and what runs
+            before it, the calls into the database, a queue, another service, and every response it can
+            send — one box per step, an arrow for every way one leads to the next, and on each arrow the
+            condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
+          </p>
+        {:else}
+          <p>
+            Pick a screen and this view draws everything it sets in motion — its handlers, the calls that
+            cross into native code, the events that come back, the state it writes, the requests that leave
+            the app — one box per step, an arrow for every way one leads to the next, and on each arrow the
+            condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
+          </p>
+        {/if}
+        {#if chooser === null}
+          <p class="dim">Reading {screens === null ? 'screens' : 'endpoints'}…</p>
+        {:else if chooser === 'none'}
           <p class="dim">
-            No screens in this graph. Open a symbol from the search box and follow <i>What happens from here</i>,
+            No screens or endpoints in this graph. Open a symbol from the search box and follow <i>What happens from here</i>,
             or link here directly with <span class="mono">#/steps?symbol=&lt;name&gt;</span>.
           </p>
-        {:else}
+        {:else if chooser === 'screens' && screens !== null}
           <div class="chooser-list">
             {#each [...screens].sort((a, b) => b.outgoing + b.incoming - (a.outgoing + a.incoming) || a.path.localeCompare(b.path)) as screen (screen.id)}
               <a class="pick mono" href={stepsHref({ anchor: screen.id })}
@@ -354,6 +406,17 @@
               >
             {/each}
           </div>
+        {:else if routes !== null}
+          {#each routeGroups(routes) as group (group.file)}
+            <div class="group-h"><span class="mono">{group.file}</span><span class="dim">{group.entries.length}</span></div>
+            <div class="chooser-list">
+              {#each group.entries as route (route.routeId)}
+                <a class="pick mono" href={stepsHref({ anchor: route.routeId })}
+                  >{route.url} <span class="dim sans">{route.handler}</span></a
+                >
+              {/each}
+            </div>
+          {/each}
         {/if}
       </div>
     {:else if error !== null}
@@ -370,7 +433,7 @@
         {nodeTypes}
         {edgeTypes}
         fitView
-        {...FIT}
+        fitViewOptions={fitOptions}
         bind:viewport
         minZoom={0.2}
         maxZoom={3}
@@ -398,22 +461,58 @@
               <span class="k-box k-anchor mono"><span class="mark">●</span>start</span>
               <span>Where the picture starts; each row down is one more step away</span>
             </div>
-            <div class="lrow">
-              <span class="k-box mono">/path</span>
-              <span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
-            </div>
-            <div class="lrow">
-              <span class="k-box k-cross mono">⇢ fn</span>
-              <span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
-            </div>
-            <div class="lrow">
-              <span class="k-box k-store mono">set</span>
-              <span>A store action — a function in a store file</span>
-            </div>
-            <div class="lrow">
-              <span class="k-box k-effect mono">api</span>
-              <span>A call that leaves the index: the network, storage, the device, telemetry</span>
-            </div>
+            {#if payload.project === 'api'}
+              <div class="lrow">
+                <span class="k-box mono">POST /x</span>
+                <span>An endpoint — its verb and path — or a handler: a function a request, a job, an event or a schedule fires; its line says which</span>
+              </div>
+              <div class="lrow">
+                <span class="k-box k-cross mono">⇢ fn</span>
+                <span>The code crosses a tier: a call into another service or a job put on a queue (⇢), or a job, an event, a message arriving (⇠)</span>
+              </div>
+              <div class="lrow">
+                <span class="k-box k-store mono">set</span>
+                <span>A data call — a function in a store or state file</span>
+              </div>
+              <div class="lrow">
+                <span class="k-box k-effect mono">db</span>
+                <span>A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network</span>
+              </div>
+            {:else if payload.project === 'web'}
+              <div class="lrow">
+                <span class="k-box mono">/path</span>
+                <span>A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which</span>
+              </div>
+              <div class="lrow">
+                <span class="k-box k-cross mono">⇢ fn</span>
+                <span>The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream)</span>
+              </div>
+              <div class="lrow">
+                <span class="k-box k-store mono">set</span>
+                <span>A store action — a function in a store file</span>
+              </div>
+              <div class="lrow">
+                <span class="k-box k-effect mono">api</span>
+                <span>A call that leaves the index: the network, the database, the response, storage, a queue, email</span>
+              </div>
+            {:else}
+              <div class="lrow">
+                <span class="k-box mono">/path</span>
+                <span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
+              </div>
+              <div class="lrow">
+                <span class="k-box k-cross mono">⇢ fn</span>
+                <span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
+              </div>
+              <div class="lrow">
+                <span class="k-box k-store mono">set</span>
+                <span>A store action — a function in a store file</span>
+              </div>
+              <div class="lrow">
+                <span class="k-box k-effect mono">api</span>
+                <span>A call that leaves the index: the network, storage, the device, telemetry</span>
+              </div>
+            {/if}
             <div class="lrow">
               <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
               <span>Leads to — the plumbing between the two is folded into the line</span>
@@ -448,7 +547,7 @@
               {#if link.sites.length > 1}<span class="dim">{link.sites.length} ways</span>{/if}
               <span class="when">{@render words(conditionTokens(link.when))}</span>
               {#if link.label}<span class="dim">{link.label}</span>{/if}
-              {#if link.sites[0]}<span class="mono">{siteWords(link.sites[0])}</span>{/if}
+              {#if link.sites[0]}<span class="mono">{#if link.sites[0].status}<b class="status">{link.sites[0].status}</b> · {/if}{siteWords(link.sites[0])}</span>{/if}
             </div>
           {/each}
           {#if hoveredInfo.links.length > 5}<div class="dim">+{hoveredInfo.links.length - 5} more</div>{/if}
@@ -463,7 +562,7 @@
         <div class="head">
           <div>
             <div class="mono big">{selectedInfo.label}</div>
-            <div class="sub dim">{kindWord(selectedInfo.step.kind)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
+            <div class="sub dim">{kindWord(selectedInfo.step.kind, payload.project, selectedInfo.step)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
             {#if selectedInfo.step.trigger}
               <div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(selectedInfo.step.trigger)} <span class="dim">in {selectedInfo.step.trigger.in}</span></div>
             {/if}
@@ -494,7 +593,7 @@
           <button class="clear" onclick={() => (selected = null)}>clear</button>
         </div>
         {#if selectedInfo.step.cut === 'screen'}
-          <p class="dim note">Another screen — a chapter of its own. Start here to see what happens on it, or continue through screens from the summary.</p>
+          <p class="dim note">Another {kindWord('screen', payload.project, selectedInfo.step)} — a chapter of its own. Start here to see what happens on it, or continue through {kindWords('screen', payload.project)[1]} from the summary.</p>
         {:else if selectedInfo.step.cut === 'component'}
           <p class="dim note">The event lands in a component of another screen — a picture of its own. Start here to see it, or continue through screens from the summary.</p>
         {:else if selectedInfo.step.cut !== null}
@@ -511,6 +610,9 @@
         {#if selectedInfo.step.effect && selectedInfo.step.effect.apis.length > 1}
           <p class="dim note mono">{selectedInfo.step.effect.apis.join(' · ')}</p>
         {/if}
+        {#if selectedInfo.step.effect?.category === 'response'}
+          <p class="dim note">The endpoint’s contract as the code has it: each row below is one way it answers, with the condition it answers under.</p>
+        {/if}
         {#if selectedInfo.step.events && selectedInfo.step.events.length > 1}
           <p class="dim note mono">⇠ {selectedInfo.step.events.join(' · ')}</p>
         {/if}
@@ -551,9 +653,9 @@
                 {/if}
                 {#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
                 {#if href}
-                  <a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
+                  <a class="site" {href}>{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
                 {:else}
-                  <span class="site">{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
+                  <span class="site">{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
                 {/if}
               </div>
             {/each}
@@ -593,9 +695,9 @@
                 {/if}
                 {#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
                 {#if href}
-                  <a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
+                  <a class="site" {href}>{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
                 {:else}
-                  <span class="site">{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
+                  <span class="site">{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
                 {/if}
               </div>
             {/each}
@@ -638,14 +740,15 @@
         <p>
           <label class="opt">
             <input type="checkbox" checked={payload.through} onchange={(e) => navigate(rewrite({ through: (e.currentTarget as HTMLInputElement).checked }))} />
-            Continue through screens
+            Continue through {kindWords('screen', payload.project)[1]}
           </label>
-          <span class="dim">— otherwise another screen is drawn as a boundary, and is a click from being the next anchor.</span>
+          <span class="dim">— otherwise another {kindWord('screen', payload.project)} is drawn as a boundary, and is a click from being the next anchor.</span>
         </p>
         <p class="counts">
           {#each ['screen', 'trigger', 'bridge', 'event', 'store', 'effect'] as const as kind (kind)}
             {#if model.counts[kind] > 0}
-              <span><b>{model.counts[kind]}</b> {kindWord(kind)}{model.counts[kind] === 1 ? '' : 's'}</span>
+              {@const words = kindWords(kind, payload.project)}
+              <span><b>{model.counts[kind]}</b> {model.counts[kind] === 1 ? words[0] : words[1]}</span>
             {/if}
           {/each}
         </p>
@@ -665,7 +768,7 @@
         {/if}
         <h4>Most connected</h4>
         {#each [...payload.steps].sort((a, b) => (model.layout.nodes.find((n) => n.id === b.id)?.ports.top.length ?? 0) + (model.layout.nodes.find((n) => n.id === b.id)?.ports.bottom.length ?? 0) - ((model.layout.nodes.find((n) => n.id === a.id)?.ports.top.length ?? 0) + (model.layout.nodes.find((n) => n.id === a.id)?.ports.bottom.length ?? 0))).slice(0, 8) as step (step.id)}
-          <button class="peer mono" onclick={() => (selected = step.id)}>{model.nodes.get(step.id)?.label ?? step.label} <span class="dim sans">{kindWord(step.kind)}</span></button>
+          <button class="peer mono" onclick={() => (selected = step.id)}>{model.nodes.get(step.id)?.label ?? step.label} <span class="dim sans">{kindWord(step.kind, payload.project, step)}</span></button>
         {/each}
       {/if}
     </aside>
@@ -730,6 +833,19 @@
     margin-top: 12px;
     border-top: 1px solid var(--rule-soft);
   }
+  /* A router file heading over its endpoints; the list under it keeps its own top rule. */
+  .group-h {
+    display: flex;
+    justify-content: space-between;
+    align-items: baseline;
+    gap: 12px;
+    margin-top: 18px;
+    font-size: 11.5px;
+    color: var(--ink-2);
+  }
+  .group-h + .chooser-list {
+    margin-top: 6px;
+  }
   .pick {
     display: block;
     padding: 7px 8px;
@@ -984,6 +1100,11 @@
     text-decoration: none;
     overflow-wrap: anywhere;
   }
+  /* A response's status code leads its row: the number is the fact. */
+  .status {
+    color: var(--ink);
+    font-weight: 600;
+  }
   a.site:hover {
     text-decoration: underline;
   }