Explorar el Código

feat(steps): cross-tier channels — a client's fetch onto its own route, queue jobs onto consumers, bus and socket events onto handlers

- resolution/tier-synthesizer.ts: http-client (literal fetch/axios/ky/got/$fetch paths, axios.create baseURL instances, template holes as :params, base-URL holes by a two-segment tail; unique match only), queue-job (BullMQ/Bull add ↔ @Process/@Processor, WorkerHost process, new Worker, queue.process), event-bus (EventEmitter2 emit ↔ @OnEvent with globs; socket emit ↔ @SubscribeMessage / socket.on both ways with tier); channel, tier, callee, registeredAt on every edge; generic transport events never pair; test and generated files never sources; registered before the emitter pass
- steps.ts: crossing() reads tier/channel before languages; an endpoint reached across a tier is a bridge box and a boundary like a screen (through=1 enters it); a channel's call is not also an effect; sites read as written; a Next 'use server' action is a crossing by its directive (when.ts directive); a function-valued constant handler (asyncHandler(...)) is a route root and borrows the file-scope calls and refs within its lines
- express.ts: app.use('/prefix', router) mounts composed onto route names in postExtract (nested, by import or require); chained router.route('/x').get(h).put(h2) extracted, across lines
- frameworks/package-deps.ts: dependencies read from workspace package.json files too (Express, React, Expo Router, NestJS detect)
- routing manifest names constant handlers; e2e/ is a test directory; explore's Flow section labels the new channels
- tests: ui-steps-cross-tier (monorepo fixture: Next client + Express/Nest API), servers test updated for the queue landing
- docs: CHANGELOG, spec §3.13 cross-tier paragraph, CLAUDE.md, callback-edge-synthesis.md, plan P3 built

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
Colby McHenry hace 1 semana
padre
commit
b1f40c57dd

+ 10 - 0
CHANGELOG.md

@@ -14,6 +14,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- **The Steps tab follows a web app across its tiers.** A page's `fetch('/api/users', { method: 'POST' })` (or an `axios` / `ky` / `got` / `$fetch` call, including one through a project instance made with `axios.create({ baseURL })`) now reaches the route that serves it in the same index — drawn as a crossing to the server (`⇢ POST /api/users`) with the handler named on the box and the registration site in the panel, a boundary by default and entered with *Continue through*, so the picture reads page → handler → the endpoint → its database write → its response. A job put on a BullMQ / Bull queue lands on the `@Process` method, `Worker` or `queue.process` handler that consumes it; a NestJS `EventEmitter2` event lands on its `@OnEvent` listeners (globs included); a socket message crosses from a client's `socket.emit` to the gateway's `@SubscribeMessage` and back from the server's `emit` to the component that registered `socket.on`; and a Next.js server action called from a client file is a crossing to the server by its `'use server'` directive. Each of these is a synthesized hop — dashed, with where it was wired up — and `codegraph_explore`'s Flow section names them too. Only a literal path or event name pairs: a variable url, a path no route serves, or one two routes serve alike produce nothing. Re-index to pick the new edges up.
+
 - **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.
@@ -26,6 +28,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- **Express routes behind `app.use('/api', router)` are named by the path a request takes.** A router mounted at a prefix — through as many `router.use('/users', usersRouter)` levels as the app nests, by import or `require` — now names its routes `POST /api/users` instead of `POST /`, and the chained form `router.route('/:id').get(getProduct).put(protect, updateProduct)` (split across lines or not) registers one route per method. Entry points, the Steps tab and the client-to-route pairing all read the real paths.
+
+- **An Express handler written through a wrapper is the endpoint's handler.** `const authUser = asyncHandler(async (req, res) => { … })` — the `express-async-handler` idiom — now names the handler in Entry points and starts the Steps walk at it, with the database reads, the token check and the `401` row read from the arrow's body.
+
+- **A framework declared in a workspace's `package.json` is detected.** React, Next.js, Expo Router, Express and NestJS are found when their dependency lives in `frontend/`, `backend/`, `apps/web/` or `packages/api/` rather than at the repository root, so a monorepo's pages and endpoints exist in the index.
+
+- **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.
+
 - **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.

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 2
CLAUDE.md


+ 12 - 2
__tests__/ui-steps-api-servers.test.ts

@@ -273,8 +273,18 @@ describe('NestJS', () => {
     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 })");
+    // The job put on the `cats` queue lands on the processor that consumes it
+    // — an arrival, not a call outside the index; the site is the `add` as written.
+    expect(effect(p, 'queue')).toBeUndefined();
+    const landing = p.steps.find((s) => s.kind === 'event' && s.node?.name === 'handleIndex')!;
+    expect(landing).toBeDefined();
+    expect(landing.event).toBe('index');
+    expect(landing.trigger).toEqual({ kind: 'decorator', name: 'Process', of: "'index'", in: 'cats.processor.ts' });
+    const toLanding = p.links.find((l) => l.to === landing.id)!;
+    expect(toLanding.kind).toBe('event');
+    expect(toLanding.synthesized).toBe(true);
+    expect(toLanding.sites[0]).toMatchObject({ text: 'this.catsQueue.add', args: "'index', { id }" });
+    expect(toLanding.label).toBe('via queue-job · job index · queue cats · registered at src/nest/cats.processor.ts:4');
   });
 
   it('a thrown exception is the 404 the request gets', async () => {

+ 467 - 0
__tests__/ui-steps-cross-tier.test.ts

@@ -0,0 +1,467 @@
+/**
+ * Cross-tier channels (`src/resolution/tier-synthesizer.ts`) and the Steps
+ * picture they make: a monorepo with a Next.js client (`apps/web`) and an
+ * Express + NestJS API (`apps/api`) in one indexed fixture. The page's form
+ * posts to its own route, a service puts a job on a queue that a processor
+ * consumes, a service emits an event a listener handles, and a chat component
+ * talks to a gateway over a socket in both directions. Mirrors
+ * `ui-steps-api-servers.test.ts` (the servers) and `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 } from '../src/ui-server/api/steps';
+import type { Edge, Node } from '../src/types';
+
+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-tier-'));
+  write(
+    'package.json',
+    JSON.stringify({
+      name: 'mono',
+      workspaces: ['apps/*'],
+      dependencies: {
+        next: '15',
+        react: '19',
+        express: '4',
+        axios: '1',
+        bullmq: '5',
+        '@nestjs/common': '10',
+        '@nestjs/core': '10',
+        '@nestjs/bull': '10',
+        '@nestjs/event-emitter': '2',
+        '@nestjs/websockets': '10',
+        'socket.io': '4',
+        'socket.io-client': '4',
+        '@prisma/client': '5',
+      },
+    })
+  );
+
+  // ---- The web app: a page, a client form that posts to the API and calls a
+  // server action, a card that reads through an axios instance, a chat.
+  write(
+    'apps/web/app/users/page.tsx',
+    "import { NewUserForm } from '../../components/new-user-form'\n" +
+      'export default function UsersPage() {\n' +
+      '  return <NewUserForm />\n' +
+      '}\n'
+  );
+  write(
+    'apps/web/components/new-user-form.tsx',
+    "'use client'\n" +
+      "import { useCallback, useState } from 'react'\n" +
+      "import { createUserAction } from '../app/actions'\n" +
+      'export function NewUserForm() {\n' +
+      "  const [email, setEmail] = useState('')\n" +
+      '  const handleSubmit = useCallback(async (e) => {\n' +
+      '    e.preventDefault()\n' +
+      '    if (!email) return\n' +
+      "    const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify({ email }) })\n" +
+      '    if (res.ok) await createUserAction({ email })\n' +
+      '  }, [email])\n' +
+      '  return <form onSubmit={handleSubmit}><input value={email} onChange={(e) => setEmail(e.target.value)} /></form>\n' +
+      '}\n'
+  );
+  write(
+    'apps/web/app/actions.ts',
+    "'use server'\n" +
+      "import { prisma } from '../lib/db'\n" +
+      "import { redirect } from 'next/navigation'\n" +
+      'export async function createUserAction(data) {\n' +
+      '  await prisma.user.create({ data })\n' +
+      "  redirect('/users')\n" +
+      '}\n'
+  );
+  write('apps/web/lib/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
+  write('apps/web/lib/api.ts', "import axios from 'axios'\nexport const api = axios.create({ baseURL: '/api' })\n");
+  write(
+    'apps/web/components/user-card.tsx',
+    "'use client'\n" +
+      "import { api } from '../lib/api'\n" +
+      'export function UserCard({ id, url }) {\n' +
+      '  async function load() {\n' +
+      '    const { data } = await api.get(`/users/${id}`)\n' +
+      "    const external = await fetch('https://api.stripe.com/v1/charges')\n" +
+      '    const dynamic = await fetch(url)\n' +
+      '    const orders = await fetch(`${process.env.API_URL}/api/users/${id}/orders`)\n' +
+      '    return [data, external, dynamic, orders]\n' +
+      '  }\n' +
+      '  return null\n' +
+      '}\n'
+  );
+  write(
+    'apps/web/components/chat.tsx',
+    "'use client'\n" +
+      "import { useEffect, useState } from 'react'\n" +
+      "import { io } from 'socket.io-client'\n" +
+      'const socket = io()\n' +
+      'export function Chat() {\n' +
+      '  const [messages, setMessages] = useState([])\n' +
+      '  useEffect(() => {\n' +
+      "    socket.on('message', (m) => {\n" +
+      '      setMessages((prev) => [...prev, m])\n' +
+      '    })\n' +
+      '  }, [])\n' +
+      '  function send(text) {\n' +
+      "    socket.emit('message', text)\n" +
+      '  }\n' +
+      '  return null\n' +
+      '}\n'
+  );
+
+  // ---- The API: Express routes, a queue and its Nest processor, a Nest
+  // service emitting an event and its listener, a gateway, a BullMQ worker.
+  write(
+    'apps/api/src/app.ts',
+    "import express from 'express'\n" +
+      "import { createUser, getUser, listOrders } from './users'\n" +
+      'const app = express()\n' +
+      "app.post('/api/users', createUser)\n" +
+      "app.get('/api/users/:id', getUser)\n" +
+      "app.get('/api/users/:id/orders', listOrders)\n" +
+      "const v1 = require('./v1')\n" +
+      "app.use('/api/v1', authenticate, v1)\n" +
+      'export default app\n'
+  );
+  // A mounted router, two levels deep: its routes are written relative to the mount.
+  write(
+    'apps/api/src/v1/index.ts',
+    "import { Router } from 'express'\n" +
+      "import ordersRouter from '../orders.routes'\n" +
+      'const router = Router()\n' +
+      "router.use('/orders', ordersRouter)\n" +
+      'export default router\n'
+  );
+  write(
+    'apps/api/src/orders.routes.ts',
+    "import { Router } from 'express'\n" +
+      "import { prisma } from './db'\n" +
+      'const router = Router()\n' +
+      "router.get('/', listAllOrders)\n" +
+      "router.post('/:id/refund', refund)\n" +
+      'export async function listAllOrders(req, res) {\n' +
+      '  res.json(await prisma.order.findMany())\n' +
+      '}\n' +
+      'export async function refund(req, res) {\n' +
+      '  res.status(202).end()\n' +
+      '}\n' +
+      'export default router\n'
+  );
+  write(
+    'apps/web/components/orders.tsx',
+    "'use client'\n" +
+      'export function Orders() {\n' +
+      '  async function loadOrders() {\n' +
+      "    const res = await fetch('/api/v1/orders')\n" +
+      '    return res.json()\n' +
+      '  }\n' +
+      '  return null\n' +
+      '}\n'
+  );
+  write(
+    'apps/api/src/users.ts',
+    "import { prisma } from './db'\n" +
+      "import { emailQueue, reportQueue } from './queue'\n" +
+      'export async function createUser(req, res) {\n' +
+      '  const user = await prisma.user.create({ data: req.body })\n' +
+      "  await emailQueue.add('welcome', { userId: user.id })\n" +
+      '  if (req.body.plan) {\n' +
+      "    await reportQueue.add('monthly', { userId: user.id })\n" +
+      '  }\n' +
+      '  res.status(201).json(user)\n' +
+      '}\n' +
+      'export async function getUser(req, res) {\n' +
+      '  const user = await prisma.user.findUnique({ where: { id: req.params.id } })\n' +
+      '  res.json(user)\n' +
+      '}\n' +
+      'export async function listOrders(req, res) {\n' +
+      '  res.json(await prisma.order.findMany({ where: { userId: req.params.id } }))\n' +
+      '}\n'
+  );
+  write('apps/api/src/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
+  write('apps/api/src/queue.ts', "import { Queue } from 'bullmq'\nexport const emailQueue = new Queue('email')\nexport const reportQueue = new Queue('reports')\n");
+  write(
+    'apps/api/src/email.processor.ts',
+    "import { Processor, Process } from '@nestjs/bull'\n" +
+      "@Processor('email')\n" +
+      'export class EmailProcessor {\n' +
+      '  constructor(private readonly mailer: MailerService) {}\n' +
+      "  @Process('welcome')\n" +
+      '  async sendWelcome(job) {\n' +
+      '    await this.mailer.sendMail({ to: job.data.email })\n' +
+      '  }\n' +
+      '}\n'
+  );
+  write(
+    'apps/api/src/reports.worker.ts',
+    "import { Worker } from 'bullmq'\n" +
+      "export const reportWorker = new Worker('reports', async (job) => {\n" +
+      '  await buildReport(job.data)\n' +
+      '})\n' +
+      'export async function buildReport(data) {\n' +
+      '  return data\n' +
+      '}\n'
+  );
+  write(
+    'apps/api/src/users.service.ts',
+    "import { Injectable } from '@nestjs/common'\n" +
+      "import { EventEmitter2 } from '@nestjs/event-emitter'\n" +
+      '@Injectable()\n' +
+      'export class UsersService {\n' +
+      '  constructor(private readonly eventEmitter: EventEmitter2) {}\n' +
+      '  async create(dto) {\n' +
+      '    const user = { id: 1, ...dto }\n' +
+      "    this.eventEmitter.emit('user.created', user)\n" +
+      '    return user\n' +
+      '  }\n' +
+      '}\n'
+  );
+  write(
+    'apps/api/src/notifications.listener.ts',
+    "import { Injectable } from '@nestjs/common'\n" +
+      "import { OnEvent } from '@nestjs/event-emitter'\n" +
+      '@Injectable()\n' +
+      'export class NotificationsListener {\n' +
+      "  @OnEvent('user.created')\n" +
+      '  handleUserCreated(user) {\n' +
+      '    return notify(user)\n' +
+      '  }\n' +
+      "  @OnEvent('user.*')\n" +
+      '  audit(payload) {\n' +
+      '    return log(payload)\n' +
+      '  }\n' +
+      "  @OnEvent('order.paid')\n" +
+      '  handleOrderPaid(order) {\n' +
+      '    return order\n' +
+      '  }\n' +
+      '}\n'
+  );
+  write(
+    'apps/api/src/chat.gateway.ts',
+    "import { WebSocketGateway, SubscribeMessage, WebSocketServer } from '@nestjs/websockets'\n" +
+      '@WebSocketGateway()\n' +
+      'export class ChatGateway {\n' +
+      '  @WebSocketServer() server\n' +
+      "  @SubscribeMessage('message')\n" +
+      '  handleMessage(client, payload) {\n' +
+      "    this.server.emit('message', payload)\n" +
+      '    return payload\n' +
+      '  }\n' +
+      '}\n'
+  );
+  // A test suite calling the API is the test's story: never a source.
+  write(
+    'apps/api/src/__tests__/users.test.ts',
+    "import { it } from 'vitest'\n" +
+      "it('creates a user', async () => {\n" +
+      "  await fetch('/api/users', { method: 'POST' })\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 sym = (name: string, file?: string): Node => {
+  const found = cg.getNodesByName(name).filter((n) => n.kind !== 'route' && n.kind !== 'file' && (!file || n.filePath.endsWith(file)));
+  if (!found[0]) throw new Error(`no symbol ${name}`);
+  return found[0];
+};
+const route = (name: string): Node => {
+  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 synthesized = (from: Node, by: string): Edge[] =>
+  cg.getOutgoingEdges(from.id).filter((e) => e.provenance === 'heuristic' && (e.metadata as Record<string, unknown>)?.synthesizedBy === by);
+const effect = (p: Awaited<ReturnType<typeof buildSteps>>, category: string) => p.steps.find((s) => s.kind === 'effect' && s.effect?.category === category);
+
+describe('http-client: a literal path in a client call reaches its own route', () => {
+  it('binds fetch("/api/users", { method: "POST" }) to POST /api/users, remembering the registration', () => {
+    const edges = synthesized(sym('handleSubmit'), 'http-client');
+    expect(edges).toHaveLength(1);
+    expect(edges[0]!.target).toBe(route('POST /api/users').id);
+    expect(edges[0]!.kind).toBe('calls');
+    expect(edges[0]!.line).toBe(9);
+    expect(edges[0]!.metadata).toEqual({
+      synthesizedBy: 'http-client',
+      channel: 'http',
+      callee: 'fetch',
+      tier: 'client→server',
+      method: 'POST',
+      href: '/api/users',
+      registeredAt: 'apps/api/src/app.ts:4',
+    });
+  });
+
+  it('joins an axios instance’s literal baseURL, matches a template hole to a :param, and a base-URL hole by the tail', () => {
+    const edges = synthesized(sym('load'), 'http-client');
+    const byHref = new Map(edges.map((e) => [(e.metadata as Record<string, unknown>).href, e]));
+    expect([...byHref.keys()].sort()).toEqual(['/api/users/${…}', '/api/users/${…}/orders']);
+    expect(byHref.get('/api/users/${…}')!.target).toBe(route('GET /api/users/:id').id);
+    expect((byHref.get('/api/users/${…}')!.metadata as Record<string, unknown>).method).toBe('GET');
+    expect(byHref.get('/api/users/${…}/orders')!.target).toBe(route('GET /api/users/:id/orders').id);
+  });
+
+  it('produces nothing for an external URL, a variable url, or a call in a test suite', () => {
+    // `load` makes four calls; only two name a route (asserted above).
+    expect(synthesized(sym('load'), 'http-client')).toHaveLength(2);
+    const testFns = cg.getNodesInFile('apps/api/src/__tests__/users.test.ts');
+    for (const n of testFns) expect(synthesized(n, 'http-client')).toHaveLength(0);
+    const incoming = cg.getIncomingEdgesTo([route('POST /api/users').id], ['calls']).filter((e) => e.provenance === 'heuristic');
+    expect(incoming.map((e) => e.source)).toEqual([sym('handleSubmit').id]);
+  });
+});
+
+describe('express mounts: a mounted router’s routes are named by the path a request takes', () => {
+  it('composes app.use("/api/v1") and router.use("/orders") onto the routes, and a client path binds to the composed name', () => {
+    const names = cg.getNodesByKind('route').map((r) => r.name);
+    expect(names).toContain('GET /api/v1/orders');
+    expect(names).toContain('POST /api/v1/orders/:id/refund');
+    expect(names).not.toContain('GET /');
+    const edges = synthesized(sym('loadOrders'), 'http-client');
+    expect(edges).toHaveLength(1);
+    expect(edges[0]!.target).toBe(route('GET /api/v1/orders').id);
+    expect((edges[0]!.metadata as Record<string, unknown>).registeredAt).toBe('apps/api/src/orders.routes.ts:4');
+  });
+});
+
+describe('queue-job: a job put on a named queue reaches its consumer', () => {
+  it('pairs emailQueue.add("welcome") with the @Process("welcome") method of the @Processor("email") class', () => {
+    const edges = synthesized(sym('createUser'), 'queue-job');
+    const welcome = edges.find((e) => (e.metadata as Record<string, unknown>).event === 'welcome')!;
+    expect(welcome).toBeDefined();
+    expect(welcome.target).toBe(sym('sendWelcome').id);
+    expect(welcome.line).toBe(5);
+    expect(welcome.metadata).toEqual({ synthesizedBy: 'queue-job', channel: 'queue', callee: 'emailQueue.add', event: 'welcome', queue: 'email', registeredAt: 'apps/api/src/email.processor.ts:5' });
+  });
+
+  it('pairs reportQueue.add("monthly") with the BullMQ Worker on that queue', () => {
+    const edges = synthesized(sym('createUser'), 'queue-job');
+    const monthly = edges.find((e) => (e.metadata as Record<string, unknown>).event === 'monthly')!;
+    expect(monthly).toBeDefined();
+    const target = cg.getNode(monthly.target)!;
+    expect(target.filePath).toBe('apps/api/src/reports.worker.ts');
+    expect((monthly.metadata as Record<string, unknown>).queue).toBe('reports');
+    expect((monthly.metadata as Record<string, unknown>).registeredAt).toBe('apps/api/src/reports.worker.ts:2');
+  });
+});
+
+describe('event-bus: an emitted event reaches its listeners; a socket message crosses tiers both ways', () => {
+  it('pairs eventEmitter.emit("user.created") with @OnEvent("user.created") and the "user.*" glob, not "order.paid"', () => {
+    const edges = synthesized(sym('create', 'users.service.ts'), 'event-bus');
+    const targets = edges.map((e) => cg.getNode(e.target)!.name).sort();
+    expect(targets).toEqual(['audit', 'handleUserCreated']);
+    const direct = edges.find((e) => e.target === sym('handleUserCreated').id)!;
+    expect(direct.metadata).toEqual({ synthesizedBy: 'event-bus', channel: 'event', callee: 'this.eventEmitter.emit', event: 'user.created', registeredAt: 'apps/api/src/notifications.listener.ts:5' });
+  });
+
+  it('a client’s socket.emit lands on the gateway’s @SubscribeMessage, client → server', () => {
+    const edges = synthesized(sym('send'), 'event-bus');
+    expect(edges).toHaveLength(1);
+    expect(edges[0]!.target).toBe(sym('handleMessage').id);
+    expect(edges[0]!.metadata).toEqual({ synthesizedBy: 'event-bus', channel: 'socket', callee: 'socket.emit', event: 'message', tier: 'client→server', registeredAt: 'apps/api/src/chat.gateway.ts:5' });
+  });
+
+  it('the gateway’s server.emit lands in the component that registered socket.on inline, server → client', () => {
+    const edges = synthesized(sym('handleMessage'), 'event-bus');
+    expect(edges).toHaveLength(1);
+    expect(edges[0]!.target).toBe(sym('Chat').id);
+    expect(edges[0]!.metadata).toEqual({ synthesizedBy: 'event-bus', channel: 'socket', callee: 'this.server.emit', event: 'message', tier: 'server→client', registeredAt: 'apps/web/components/chat.tsx:8' });
+  });
+});
+
+describe('the Steps picture across the tiers', () => {
+  it('draws the route as a boundary the form crosses to (⇢), and enters it on request', async () => {
+    const boundary = await buildSteps(cg, tmpDir, q({ symbol: 'UsersPage' }));
+    expect(boundary.project).toBe('web');
+    const handler = boundary.steps.find((s) => s.kind === 'trigger' && s.node?.name === 'handleSubmit')!;
+    expect(handler).toBeDefined();
+    expect(handler.trigger).toMatchObject({ kind: 'prop', name: 'onSubmit', of: 'form' });
+    const bridge = boundary.steps.find((s) => s.kind === 'bridge' && s.screen?.path === 'POST /api/users')!;
+    expect(bridge).toBeDefined();
+    expect(bridge.cut).toBe('screen');
+    expect(bridge.sub).toBe('createUser');
+    expect(bridge.trigger).toEqual({ kind: 'request', name: 'POST', of: '/api/users', in: 'app.ts' });
+    const link = boundary.links.find((l) => l.from === handler.id && l.to === bridge.id)!;
+    expect(link.kind).toBe('bridge');
+    expect(link.synthesized).toBe(true);
+    expect(link.when).toBe('email');
+    expect(link.sites[0]).toMatchObject({ text: 'fetch', args: "'/api/users', { method, body }", line: 9 });
+    expect(link.label).toContain('POST /api/users');
+    expect(link.label).toContain('to the server');
+    expect(link.label).toContain('registered at apps/api/src/app.ts:4');
+    // The fetch is the crossing, not also a network call outside the index;
+    // the route is not entered, so the handler's write is not drawn — the
+    // server action's is, since a function the code crosses to is walked.
+    expect(effect(boundary, 'network')).toBeUndefined();
+    const writes = boundary.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'database');
+    expect(writes.map((s) => s.effect!.by.name)).toEqual(['createUserAction']);
+
+    const through = await buildSteps(cg, tmpDir, q({ symbol: 'UsersPage', through: '1' }));
+    const entered = through.steps.find((s) => s.kind === 'bridge' && s.screen?.path === 'POST /api/users')!;
+    expect(entered.cut).toBeNull();
+    const db = through.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'database');
+    expect(db.map((s) => s.effect!.by.name).sort()).toEqual(['createUser', 'createUserAction']);
+    const res = effect(through, 'response')!;
+    expect(res.label).toBe('201');
+    expect(res.effect!.by.name).toBe('createUser');
+    const welcome = through.steps.find((s) => s.kind === 'event' && s.event === 'welcome')!;
+    expect(welcome).toBeDefined();
+    expect(welcome.node!.name).toBe('sendWelcome');
+    expect(welcome.trigger).toEqual({ kind: 'decorator', name: 'Process', of: "'welcome'", in: 'email.processor.ts' });
+    const toWelcome = through.links.find((l) => l.to === welcome.id)!;
+    expect(toWelcome.kind).toBe('event');
+    expect(toWelcome.sites[0]).toMatchObject({ text: 'emailQueue.add', args: "'welcome', { userId }", line: 5 });
+    expect(toWelcome.label).toBe('via queue-job · job welcome · queue email · registered at apps/api/src/email.processor.ts:5');
+    expect(effect(through, 'queue')?.effect?.apis ?? []).not.toContain('emailQueue.add');
+    const mail = effect(through, 'email')!;
+    expect(mail.effect!.by.name).toBe('sendWelcome');
+  });
+
+  it('a server action called from a client component is a crossing to the server, by its directive', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ symbol: 'NewUserForm' }));
+    const action = p.steps.find((s) => s.node?.name === 'createUserAction')!;
+    expect(action).toBeDefined();
+    expect(action.kind).toBe('bridge');
+    const link = p.links.find((l) => l.to === action.id)!;
+    expect(link.kind).toBe('bridge');
+    expect(link.when).toBe('email && res.ok');
+    expect(link.label).toContain('server action');
+    expect(link.sites[0]).toMatchObject({ text: 'calls createUserAction', args: '{ email }' });
+    expect(effect(p, 'database')?.effect?.by.name).toBe('createUserAction');
+  });
+
+  it('a socket message arriving in a component is an event landing, drawn as a boundary', async () => {
+    const p = await buildSteps(cg, tmpDir, q({ symbol: 'handleMessage' }));
+    const chat = p.steps.find((s) => s.kind === 'event' && s.node?.name === 'Chat')!;
+    expect(chat).toBeDefined();
+    expect(chat.event).toBe('message');
+    expect(chat.cut).toBe('component');
+    const link = p.links.find((l) => l.to === chat.id)!;
+    expect(link.kind).toBe('event');
+    expect(link.label).toContain('from the server');
+  });
+});

+ 35 - 1
docs/design/callback-edge-synthesis.md

@@ -38,7 +38,7 @@ We synthesize `dispatcher → callback` edges that static parsing misses. It wor
 npm run build
 rm -rf /tmp/codegraph-corpus/excalidraw/.codegraph
 ( cd /tmp/codegraph-corpus/excalidraw && codegraph init -i )
-# synthesized edges (provenance='heuristic', metadata.synthesizedBy in {callback,event-emitter}):
+# synthesized edges (provenance='heuristic', metadata.synthesizedBy in {callback,event-emitter,…,http-client,queue-job,event-bus}):
 sqlite3 /tmp/codegraph-corpus/excalidraw/.codegraph/codegraph.db \
   "select s.name||' → '||t.name||'  '||coalesce(e.metadata,'') from edges e \
    join nodes s on e.source=s.id join nodes t on e.target=t.id where e.provenance='heuristic';"
@@ -51,6 +51,40 @@ fixture lives at `/tmp/cb-fixture/bus.js` (ephemeral — recreate or move into `
 
 ---
 
+## Cross-tier channels (`src/resolution/tier-synthesizer.ts`, 2026-08-28)
+
+The web's RN bridge: one pass, registered before the in-process emitter pass (the more specific edge wins a duplicate
+`source>target` pair in the merge), gated on JS-family files, never sourced from a test suite or a generated file.
+Three channels, each keyed on a literal on both sides, each edge `kind:'calls'`, `provenance:'heuristic'`, with
+`synthesizedBy`, `channel` (`http` | `queue` | `event` | `socket`), `tier` (`client→server` / `server→client`) when the
+direction is known, the `event` / `queue` / `method` / `href` it paired on, `line` + `column` of the call, and
+`registeredAt` = the other side (route registration, decorator, `.on`):
+
+- **`http-client`** — `fetch` / `$fetch` / `ofetch` / `axios` / `ky` / `got` / `useFetch` / `useSWR`, `<client>.get|post|…(`
+  where the receiver is a known client name or a binding made by `axios.create(…)` / `ky.extend(…)` (same file or the file it
+  is imported from — `resolveImportPath`, since import mappings carry no resolved path), with a literal / template first
+  argument (`new URL('/x', base)` and `{ url, method }` configs read too) → the ONE route `METHOD path` in the index it
+  denotes. A hole fills a `:param` / `{id}` / `[id]` / catch-all segment and never a literal one; a hole in front of the path
+  (`${API_URL}/users`) matches by the route's tail; a line a framework resolver made a route node on is a registration, not a
+  client call; a tie between routes is nothing. No fan-out cap — the match is exact.
+- **`queue-job`** — `<queue>.add('job', …)` where the queue is named (`new Queue('email')`, `@InjectQueue('email') x`, in the
+  file or its import) or queue-shaped → the `@Process('job')` method of the `@Processor('email')` class (a WorkerHost's
+  `process` when there is none), `new Worker('email', handler)` (an inline handler → the enclosing function, else the
+  enclosing constant), Bull's `queue.process('job', handler)`. Most specific pairing wins (queue+job > job > the queue's
+  default); an unnamed queue pairs only on a unique job name. Fan-out cap 6.
+- **`event-bus`** — `.emit|emitAsync('x')` on a bus-shaped receiver (`eventEmitter`, `bus`, `pubsub`, …) → `@OnEvent`
+  handlers, `*` / `**` globs honoured; on a socket-shaped receiver (`socket`, `io`, `server`, `client`, `.to(room)`, …) from a
+  file without a socket server → `@SubscribeMessage('x')` and server-side `socket.on('x')` (`client→server`); from a file
+  with one (`@WebSocketGateway`, `io.on('connection')`, `new Server`) → client-side `socket.on('x', …)`, named or inline
+  (→ the enclosing component) (`server→client`). Plain `.on` ↔ `.emit` stays the emitter pass's. Fan-out cap 6.
+
+The Steps view (`ui-server/api/steps.ts`) reads `tier` / `channel` before the languages in `crossing()`, so a hop between
+two TS files draws as a bridge (`⇢ POST /api/users`, a boundary like another screen) or an event (`⇠ welcome`); explore's
+Flow section labels them (`context/index.ts`, `mcp/tools.ts`). A Next `'use server'` action needs no edge: `steps.ts` marks
+the call at request time from the directive. Validated on `bradtraversy/proshop_mern` (30 routes, 23 client→route edges,
+all correct on inspection, after Express mounts + chained `router.route()` landed) and `nestjs/nest` (`sample/26-queues`,
+`sample/30-event-emitter`); test `__tests__/ui-steps-cross-tier.test.ts`.
+
 ## The hole
 
 ```ts

+ 23 - 0
docs/design/codegraph-ui-design-spec.md

@@ -513,6 +513,29 @@ endpoints grouped by router file when there are no screens. A production walk ne
 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.
 
+**Across the tiers (a web app, a monorepo).** A web app is two programs that talk over a wire the graph cannot see, and the
+same picture wants the same evidence the RN bridge gives it: a string on both sides. `resolution/tier-synthesizer.ts` pairs them at
+index time (`provenance: 'heuristic'`, `synthesizedBy`, `channel`, `tier`, `registeredAt`): a client call with a literal path —
+`fetch('/api/users', { method: 'POST' })`, `axios.post`, `ky`, `got`, `$fetch`, `useFetch`, `useSWR`, or a project instance made by
+`axios.create({ baseURL })` — onto the one route `METHOD path` it names (`http-client`, `tier: 'client→server'`; a template hole fills
+a `:param` and never a literal segment, a hole in front of the path matches a route by its tail, a variable url or a path two routes
+serve alike is nothing); `queue.add('welcome')` on a named queue onto the `@Process('welcome')` method of the `@Processor` class, a
+WorkerHost's `process`, a `new Worker('email', handler)` or Bull's `queue.process` (`queue-job`, `channel: 'queue'`);
+`eventEmitter.emit('user.created')` onto `@OnEvent` listeners, globs honoured (`event-bus`, `channel: 'event'`); a client's
+`socket.emit('x')` onto the gateway's `@SubscribeMessage('x')` and the server's `server.emit('x')` back onto the component that
+registered `socket.on('x', …)` inline (`channel: 'socket'`, the `tier` each way). A Next server action needs no edge: a call from a file
+without the directive into a function whose file (or body) opens with `'use server'` is marked `client→server` at request time.
+`crossing()` reads the marker before the languages, so a hop between two TypeScript files can be a **bridge** or an **event**: an
+endpoint reached across a tier draws as a bridge box that keeps its endpoint face (`⇢ POST /api/users` over its handler's name, `FIRES
+FROM POST /api/users · after …`) and is a boundary exactly as another screen is — `cut: 'screen'`, entered with `&through=1`, the walk
+going on into the handler; a job, an event or a message arriving draws as `⇠ welcome` on its consumer, whose trigger already says
+`@Process('welcome')`. The site of such a hop is the call as written (`fetch('/api/users', { method, body })`, `emailQueue.add('welcome',
+{ userId })`) with its conditions, and the link's label says the channel, the way it crosses and where it was registered (`via http-client
+· POST /api/users · to the server · registered at app.ts:4`). A call a channel follows is not also drawn as a call outside the index
+— the crossing is the story — and a top-level `const worker = new Worker('q', async (job) => …)` lends its constant the file-scope calls
+within its lines, so the landing walks on into what the handler does. Test suites and generated files are never sources: forty supertest
+calls would make a route a hub. A mounted Express router (`app.use('/api', routes)`, nested) names its routes by the path a request takes.
+
 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

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

@@ -2,8 +2,8 @@
 
 **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
+sessions: P0, P1, P2, P3, P5 and P6 are built** (see the per-item notes marked *Built*); 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
@@ -280,6 +280,24 @@ consumer reads `FIRES FROM @Process('email')`.
 
 ### P3 — Cross-tier channels (the RN bridge, for the web)
 
+*Built* (2026-08-28, later session) — `src/resolution/tier-synthesizer.ts` (one pass, three channels: `http-client` with
+`tier: 'client→server'`, `queue-job`, `event-bus` for a bus and for sockets both ways; registered before the emitter pass), Next
+server actions marked at request time from the `'use server'` directive (`api/when.ts` `directive`), `crossing()` in `steps.ts`
+reading `tier` / `channel`, an endpoint reached across a tier drawn as a bridge box that is a boundary like a screen, a channel's
+call never also an effect, a top-level `new Worker` landing on its constant with the file-scope calls lent to it; Express mounts
+(`app.use('/api', router)`, nested, by import or `require`) composed onto route names in `postExtract`, and the chained
+`router.route('/x').get(h).put(h2)` form extracted; `e2e/` counts as a test directory. Test: `__tests__/ui-steps-cross-tier.test.ts`.
+Verified on `bradtraversy/proshop_mern` (30 routes, 23 client→route edges, every one correct on inspection; `login` reads
+`login → ⇢ POST /api/users/login (authUser) → User.findOne({ email }) → 401 rows → jwt.sign via generateToken`, which needed
+`routeRoots` to accept a function-valued constant — `const authUser = asyncHandler(async (req, res) => …)` — and the walk to lend
+such a value the file-scope calls and unresolved refs within its lines; and framework detection to read a workspace's
+`package.json` (`frameworks/package-deps.ts`), proshop keeping `react` in `frontend/`) and `nestjs/nest`
+(`sample/26-queues` `transcode` → `@Process('transcode')`, `sample/30-event-emitter` → `@OnEvent`; the `integration/*/e2e`
+helpers no longer count). **Not built:** tRPC (a procedure's inline handler is not a node — extractor work with the kernel twin).
+**Gap found:** a nested `const handleSubmit = async (e) => …` inside a component is not a node (only `function` declarations and
+`useCallback`-bound arrows are — `tree-sitter.ts` `reactHookBoundName`), so such a handler's `fetch` attributes to the component and
+the link carries no FIRES FROM; the fix is an extractor rule for a nested arrow bound by a declarator, in TS and in the Rust kernel.
+
 *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).

+ 6 - 0
src/context/index.ts

@@ -412,6 +412,12 @@ export class ContextBuilder {
         ? `renders <${String(m.via || 'child')}>`
         : m.synthesizedBy === 'vue-handler'
         ? `Vue @${String(m.event || 'event')} handler`
+        : m.synthesizedBy === 'http-client'
+        ? `HTTP ${String(m.method || 'GET')} ${String(m.href || '')} — the client's call onto its own route${at}`
+        : m.synthesizedBy === 'queue-job'
+        ? `queue job ${m.event ? `\`${String(m.event)}\`` : ''}${m.queue ? ` on \`${String(m.queue)}\`` : ''}${at}`
+        : m.synthesizedBy === 'event-bus' && m.channel === 'socket'
+        ? `socket message ${m.event ? `\`${String(m.event)}\`` : ''}${m.tier === 'client→server' ? ' → server' : m.tier === 'server→client' ? ' → client' : ''}${at}`
         : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
       synthByPair.set(`${e.source}>${e.target}`, label);
     }

+ 1 - 1
src/db/queries.ts

@@ -1058,7 +1058,7 @@ export class QueryBuilder {
         JOIN nodes h ON e.target = h.id
         WHERE r.kind = 'route'
           AND e.kind IN ('references', 'calls')
-          AND h.kind IN ('function', 'method', 'class')
+          AND h.kind IN ('function', 'method', 'class', 'constant', 'variable')
         ORDER BY r.file_path, r.start_line
         LIMIT ?
       `);

+ 27 - 0
src/mcp/tools.ts

@@ -2478,6 +2478,33 @@ export class ToolHandler {
         registeredAt,
       };
     }
+    if (m?.synthesizedBy === 'http-client') {
+      const req = `${String(m.method ?? 'GET')} ${String(m.href ?? '')}`.trim();
+      return {
+        label: `HTTP request \`${req}\` — the client's call onto its own route (cross-tier)`,
+        compact: `dynamic: HTTP ${req}${at}`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'queue-job') {
+      const job = m.event ? `\`${String(m.event)}\`` : 'a job';
+      const queue = m.queue ? ` on queue \`${String(m.queue)}\`` : '';
+      return {
+        label: `queue job ${job}${queue} — producer → consumer (cross-tier)`,
+        compact: `dynamic: queue job ${job}${at}`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'event-bus') {
+      const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
+      const what = m.channel === 'socket' ? 'socket message' : 'bus event';
+      const dir = m.tier === 'client→server' ? ', client → server' : m.tier === 'server→client' ? ', server → client' : '';
+      return {
+        label: `${what} ${ev} — emit → handler${dir} (dynamic dispatch)`,
+        compact: `dynamic: ${what} ${ev}${at}`,
+        registeredAt,
+      };
+    }
     if (m?.synthesizedBy === 'event-emitter') {
       const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
       return {

+ 7 - 42
src/resolution/callback-synthesizer.ts

@@ -30,6 +30,8 @@ import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
 import { goframeRouteEdges } from './goframe-synthesizer';
 import { expoRouterReturnEdges } from './expo-router-synthesizer';
 import { createYielder, type MaybeYield } from './cooperative-yield';
+import { crossTierEdges } from './tier-synthesizer';
+import { enclosingFn, makeLineAt } from './synth-utils';
 
 const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
 const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
@@ -110,33 +112,6 @@ function sliceLines(content: string, startLine?: number, endLine?: number): stri
   return content.split('\n').slice(startLine - 1, endLine).join('\n');
 }
 
-/**
- * Per-match line resolver over `src`, 1-based at `baseLine`. The inline
- * `src.slice(0, idx).split('\n').length` idiom is O(source-length) PER MATCH,
- * which goes quadratic on a match-dense source (a generated function full of
- * `.push(` calls re-scanned tens of thousands of times was most of the #1235
- * indexing wedge). Builds the newline index once — lazily, since most sources
- * never produce a match — then answers each call with a binary search.
- */
-function makeLineAt(src: string, baseLine: number): (idx: number) => number {
-  let nl: number[] | null = null;
-  return (idx: number) => {
-    if (!nl) {
-      nl = [];
-      for (let i = src.indexOf('\n'); i !== -1; i = src.indexOf('\n', i + 1)) nl.push(i);
-    }
-    // Count newlines strictly before idx.
-    let lo = 0;
-    let hi = nl.length;
-    while (lo < hi) {
-      const mid = (lo + hi) >> 1;
-      if (nl[mid]! < idx) lo = mid + 1;
-      else hi = mid;
-    }
-    return baseLine + lo;
-  };
-}
-
 function registrarField(src: string): string | null {
   const m = src.match(/this\.(\w+)\.(?:add|push|set)\(/);
   return m ? m[1]! : null;
@@ -150,21 +125,6 @@ function dispatcherField(src: string): string | null {
   return null;
 }
 
-const FN_KINDS = new Set(['method', 'function', 'component']);
-
-/** Innermost function/method node whose line range contains `line`. */
-function enclosingFn(nodesInFile: Node[], line: number): Node | null {
-  let best: Node | null = null;
-  for (const n of nodesInFile) {
-    if (!FN_KINDS.has(n.kind)) continue;
-    const end = n.endLine ?? n.startLine;
-    if (n.startLine <= line && end >= line) {
-      if (!best || n.startLine >= best.startLine) best = n; // prefer the tightest (latest-starting) encloser
-    }
-  }
-  return best;
-}
-
 /**
  * Stream method + function nodes lazily. The synthesizers only scan-and-filter
  * down to a tiny matched subset, so materializing every function/method (which
@@ -3578,6 +3538,11 @@ const ALWAYS = (): boolean => true;
 export const SYNTH_PASSES: SynthPassDef[] = [
   { name: 'fieldEdges', gate: ALWAYS, run: (q, c, y) => fieldChannelEdges(q, c, y) },
   { name: 'closureCollEdges', gate: ALWAYS, run: (q, c, y) => closureCollectionEdges(q, c, y) },
+  // Cross-tier channels — a client's `fetch('/api/x')` onto its own route,
+  // a queue job onto its consumer, a bus / socket event onto its handler.
+  // Before the in-process emitter pass: the same (source, target) pair
+  // keeps the more specific edge — the one that says which tier it crosses.
+  { name: 'tierEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => crossTierEdges(c, y) },
   { name: 'emitterEdges', gate: ALWAYS, run: (_q, c, y) => eventEmitterEdges(c, y) },
   { name: 'renderEdges', gate: ALWAYS, run: (q, c, y) => reactRenderEdges(q, c, y) },
   { name: 'jsxEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => reactJsxChildEdges(c, y) },

+ 4 - 12
src/resolution/frameworks/expo-router.ts

@@ -44,6 +44,7 @@ import type {
   UnresolvedRef,
 } from '../types';
 import { stripCommentsForRegex } from '../strip-comments';
+import { dependsOn } from './package-deps';
 
 // =============================================================================
 // Route files
@@ -143,8 +144,8 @@ export function navVerb(name: string): string | null {
 /** Lines a single navigation call is allowed to span. */
 const MAX_CALL_LINES = 12;
 
-/** Placeholder for an interpolated `${…}` inside a template-literal href. */
-const HOLE = '\u0000';
+/** Placeholder for an interpolated `${…}` inside a template-literal href (shared with the cross-tier synthesizer). */
+export const HOLE = '\u0000';
 
 /** Index of the `)` matching the `(` at `open`, skipping string bodies; -1 if unbalanced. */
 function matchParen(s: string, open: number): number {
@@ -573,16 +574,7 @@ export const expoRouterResolver: FrameworkResolver = {
   languages: [...ROUTE_LANGUAGES],
 
   detect(context: ResolutionContext): boolean {
-    const packageJson = context.readFile('package.json');
-    if (packageJson) {
-      try {
-        const pkg = JSON.parse(packageJson);
-        const deps = { ...pkg.dependencies, ...pkg.devDependencies };
-        if (deps['expo-router']) return true;
-      } catch {
-        // Not JSON — fall through to the layout check.
-      }
-    }
+    if (dependsOn(context, 'expo-router')) return true;
     const files = context.getAllFiles();
     const hasLayout = files.some((f) => /(?:^|\/)(?:src\/)?app\/_layout\.(?:tsx|jsx|ts|js)$/.test(f));
     const hasExpoConfig = files.some((f) => /^app\.(?:json|config\.(?:js|ts))$/.test(f));

+ 193 - 14
src/resolution/frameworks/express.ts

@@ -7,6 +7,8 @@
 import { Node } from '../../types';
 import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
 import { stripCommentsForRegex } from '../strip-comments';
+import { resolveImportPath } from '../import-resolver';
+import { dependsOn } from './package-deps';
 
 function extractTailIdent(expr: string): string | null {
   const cleaned = expr.replace(/\s+/g, '').replace(/\(\)$/, '');
@@ -52,19 +54,8 @@ export const expressResolver: FrameworkResolver = {
   languages: ['javascript', 'typescript'],
 
   detect(context: ResolutionContext): boolean {
-    // Check for Express in package.json
-    const packageJson = context.readFile('package.json');
-    if (packageJson) {
-      try {
-        const pkg = JSON.parse(packageJson);
-        const deps = { ...pkg.dependencies, ...pkg.devDependencies };
-        if (deps.express || deps.fastify || deps.koa || deps.hapi) {
-          return true;
-        }
-      } catch {
-        // Invalid JSON
-      }
-    }
+    // Express in a package.json — the root's, or a workspace's (`backend/`, `apps/api/`).
+    if (dependsOn(context, 'express', 'fastify', 'koa', 'hapi', '@hapi/hapi')) return true;
 
     // Check for common Express patterns
     const allFiles = context.getAllFiles();
@@ -141,7 +132,7 @@ export const expressResolver: FrameworkResolver = {
     // Match the route head up to the first arg: (app|router).METHOD('/path',
     // (NOT the whole call — handlers are often inline arrows whose `)`/`{}` the
     // old single-regex couldn't span, so inline-handler routes connected to nothing.)
-    const head = /\b(app|router)\.(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,/g;
+    const head = /\b(app|router)\s*\.\s*(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,/g;
     let match: RegExpExecArray | null;
     while ((match = head.exec(safe)) !== null) {
       const method = match[2]!;
@@ -217,10 +208,198 @@ export const expressResolver: FrameworkResolver = {
         }
       }
     }
+    // The chained form: `router.route('/:id').get(getProduct).put(protect, updateProduct)`
+    // — one path, several methods, each with its own handler. One route node
+    // per method, at the line of its `.method(`, bound like the plain form.
+    const chainHead = /\b(?:app|router)\s*\.\s*route\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
+    while ((match = chainHead.exec(safe)) !== null) {
+      const routePath = match[1]!;
+      let at = match.index + match[0].length;
+      for (;;) {
+        const link = /^\s*\.\s*(get|post|put|patch|delete|all)\s*\(/.exec(safe.slice(at, at + 64));
+        if (!link) break;
+        const openParen = at + link[0].length - 1;
+        const closeParen = matchDelim(safe, openParen, '(', ')');
+        if (closeParen < 0) break;
+        const method = link[1]!;
+        const line = safe.slice(0, openParen).split('\n').length;
+        const args = safe.slice(openParen + 1, closeParen);
+        const routeNode: Node = {
+          id: `route:${filePath}:${line}:${method.toUpperCase()}:${routePath}`,
+          kind: 'route',
+          name: `${method.toUpperCase()} ${routePath}`,
+          qualifiedName: `${filePath}::${method.toUpperCase()}:${routePath}`,
+          filePath,
+          startLine: line,
+          endLine: line,
+          startColumn: 0,
+          endColumn: link[0].length,
+          language: lang,
+          updatedAt: now,
+        };
+        nodes.push(routeNode);
+        if (args.includes('=>')) {
+          const callRe = /\b([A-Za-z_$][\w$]*)\s*\(/g;
+          const seen = new Set<string>();
+          let cm: RegExpExecArray | null;
+          while ((cm = callRe.exec(args)) !== null) {
+            const name = cm[1]!;
+            if (seen.has(name) || RESERVED_CALLS.has(name)) continue;
+            seen.add(name);
+            references.push({ fromNodeId: routeNode.id, referenceName: name, referenceKind: 'calls', line, column: 0, filePath, language: lang });
+          }
+        } else {
+          const parts = splitTopLevel(args).map((s) => s.trim()).filter(Boolean);
+          const last = parts[parts.length - 1];
+          const handlerName = last ? extractTailIdent(last) : null;
+          if (handlerName) {
+            references.push({ fromNodeId: routeNode.id, referenceName: handlerName, referenceKind: 'references', line, column: 0, filePath, language: lang });
+          }
+        }
+        at = closeParen + 1;
+      }
+    }
     return { nodes, references };
   },
+
+  /**
+   * Cross-file finalization for mounts. A router's routes are written
+   * relative to where it is mounted —
+   *
+   *   app.use('/api', routes)            // app.js
+   *   router.use('/users', usersRouter)  // routes/index.js
+   *   router.post('/', createUser)       // routes/users.js  → POST /api/users
+   *
+   * — and per-file `extract()` can only see `POST /`. This pass reads every
+   * `X.use('/prefix', …, router)` whose last argument names a router file
+   * (an import, or an inline `require('./x')`), composes the prefixes down
+   * the mount tree, and renames the routes of each mounted file to the path
+   * a request actually takes. A file mounted at two different prefixes is
+   * left alone: one name cannot be two paths.
+   *
+   * The route node's `id` and `qualifiedName` are preserved (`qualifiedName`
+   * still encodes the in-file `METHOD:path`), so the pass is idempotent on
+   * every sync, exactly as the NestJS `RouterModule` pass is.
+   */
+  postExtract(context: ResolutionContext): Node[] {
+    const files = context.getAllFiles().filter((f) => /\.(m?js|tsx?|cjs)$/.test(f));
+    const mounts = new Map<string, Array<{ prefix: string; target: string }>>();
+    for (const file of files) {
+      const content = context.readFile(file);
+      if (!content || !content.includes('.use(')) continue;
+      const lang = detectLanguage(file);
+      const safe = stripCommentsForRegex(content, lang);
+      const mount = /\b[A-Za-z_$][\w$]*\.use\s*\(\s*(['"`])(\/[^'"`]*)\1\s*,/g;
+      let m: RegExpExecArray | null;
+      while ((m = mount.exec(safe)) !== null) {
+        const open = safe.indexOf('(', m.index);
+        const close = open >= 0 ? matchDelim(safe, open, '(', ')') : -1;
+        if (close < 0) continue;
+        const args = splitTopLevel(safe.slice(open + 1, close));
+        const last = args[args.length - 1]?.trim() ?? '';
+        const target = mountTarget(last, safe, file, lang, context);
+        if (!target || target === file) continue;
+        const list = mounts.get(file) ?? [];
+        list.push({ prefix: m[2]!, target });
+        mounts.set(file, list);
+      }
+    }
+    if (mounts.size === 0) return [];
+
+    // Compose prefixes down the mount tree until nothing changes; a file
+    // reached at two different paths is ambiguous and dropped.
+    let prefixOf = new Map<string, string>();
+    for (let round = 0; round < 8; round++) {
+      const next = new Map<string, string>();
+      const ambiguous = new Set<string>();
+      for (const [file, list] of mounts) {
+        const base = prefixOf.get(file) ?? '';
+        for (const { prefix, target } of list) {
+          const full = joinPaths(base, prefix);
+          const seen = next.get(target);
+          if (seen !== undefined && seen !== full) ambiguous.add(target);
+          else next.set(target, full);
+        }
+      }
+      for (const a of ambiguous) next.delete(a);
+      let changed = next.size !== prefixOf.size;
+      if (!changed) for (const [k, v] of next) if (prefixOf.get(k) !== v) changed = true;
+      prefixOf = next;
+      if (!changed) break;
+    }
+
+    const updates: Node[] = [];
+    for (const [file, prefix] of prefixOf) {
+      if (prefix === '' || prefix === '/') continue;
+      for (const route of context.getNodesInFile(file)) {
+        if (route.kind !== 'route') continue;
+        const sep = route.qualifiedName.indexOf('::');
+        if (sep < 0) continue;
+        const colon = route.qualifiedName.indexOf(':', sep + 2);
+        if (colon < 0) continue;
+        const method = route.qualifiedName.slice(sep + 2, colon);
+        const original = route.qualifiedName.slice(colon + 1);
+        if (!original.startsWith('/')) continue;
+        const name = `${method} ${joinPaths(prefix, original)}`;
+        if (name !== route.name) updates.push({ ...route, name });
+      }
+    }
+    return updates;
+  },
 };
 
+/** Top-level comma split of an argument list, strings and brackets respected. */
+function splitTopLevel(args: string): string[] {
+  const out: string[] = [];
+  let depth = 0;
+  let start = 0;
+  for (let i = 0; i < args.length; i++) {
+    const ch = args[i];
+    if (ch === '"' || ch === "'" || ch === '`') {
+      const q = ch;
+      i++;
+      while (i < args.length && args[i] !== q) {
+        if (args[i] === '\\') i++;
+        i++;
+      }
+      continue;
+    }
+    if (ch === '(' || ch === '[' || ch === '{') depth++;
+    else if (ch === ')' || ch === ']' || ch === '}') depth--;
+    else if (ch === ',' && depth === 0) {
+      out.push(args.slice(start, i));
+      start = i + 1;
+    }
+  }
+  out.push(args.slice(start));
+  return out;
+}
+
+/** `/api` + `/users` → `/api/users`; `/api/` + `/` → `/api`. */
+function joinPaths(prefix: string, path: string): string {
+  const a = prefix.replace(/\/+$/, '');
+  const b = path.replace(/^\/+/, '');
+  const joined = b ? `${a}/${b}` : a;
+  return joined === '' ? '/' : joined;
+}
+
+/**
+ * The file a mount's last argument names: an inline `require('./x')`, an
+ * identifier imported from a project file, or one bound to `require('./x')`
+ * in the same file. `x.default` / `x.router` count as `x`.
+ */
+function mountTarget(expr: string, safe: string, file: string, lang: 'typescript' | 'javascript', context: ResolutionContext): string | null {
+  const inline = /^require\s*\(\s*(['"])([^'"]+)\1\s*\)(?:\.\w+)?$/.exec(expr);
+  if (inline) return resolveImportPath(inline[2]!, file, lang, context);
+  const ident = /^([A-Za-z_$][\w$]*)(?:\.(?:default|router|routes))?$/.exec(expr);
+  if (!ident) return null;
+  const name = ident[1]!;
+  const mapping = context.getImportMappings(file, lang).find((im) => im.localName === name);
+  if (mapping) return resolveImportPath(mapping.source, file, lang, context);
+  const required = new RegExp(`\\b(?:const|let|var)\\s+${name.replace(/\$/g, '\\$')}\\s*=\\s*require\\s*\\(\\s*(['"])([^'"]+)\\1\\s*\\)`).exec(safe);
+  return required ? resolveImportPath(required[2]!, file, lang, context) : null;
+}
+
 /**
  * Check if a name looks like middleware
  */

+ 4 - 13
src/resolution/frameworks/nestjs.ts

@@ -30,6 +30,7 @@ import {
   ResolutionContext,
 } from '../types';
 import { stripCommentsForRegex } from '../strip-comments';
+import { declaredDependencies } from './package-deps';
 
 // ---------------------------------------------------------------------------
 // Public surface — see comment at top of file. This file owns four NestJS
@@ -47,19 +48,9 @@ export const nestjsResolver: FrameworkResolver = {
   languages: ['typescript', 'javascript'],
 
   detect(context: ResolutionContext): boolean {
-    // Primary, fast path: any @nestjs/* dependency in package.json.
-    const packageJson = context.readFile('package.json');
-    if (packageJson) {
-      try {
-        const pkg = JSON.parse(packageJson);
-        const deps = { ...pkg.dependencies, ...pkg.devDependencies };
-        if (Object.keys(deps).some((k) => k.startsWith('@nestjs/'))) {
-          return true;
-        }
-      } catch {
-        // Invalid JSON — fall through to the source scan.
-      }
-    }
+    // Primary, fast path: any @nestjs/* dependency in a package.json — the
+    // root's, or a workspace's (`apps/api/`, `server/`).
+    for (const name of declaredDependencies(context)) if (name.startsWith('@nestjs/')) return true;
 
     // Fallback: NestJS-specific decorators in conventionally named files.
     const allFiles = context.getAllFiles();

+ 47 - 0
src/resolution/frameworks/package-deps.ts

@@ -0,0 +1,47 @@
+/**
+ * The dependencies a project declares — in its root `package.json` and in
+ * the ones one or two directories down (`apps/web/package.json`,
+ * `frontend/package.json`, `packages/api/package.json`). A framework detector
+ * that reads only the root misses every monorepo: proshop keeps `react` in
+ * `frontend/`, a Turborepo keeps `next` in `apps/web/`, and the resolver for
+ * that framework then never runs, so its routes never exist.
+ */
+
+import type { ResolutionContext } from '../types';
+
+/** Nested manifests read per project, at most — a monorepo with hundreds of packages is sampled, not scanned. */
+const MAX_MANIFESTS = 24;
+
+const cache = new WeakMap<ResolutionContext, Set<string>>();
+
+/** Every dependency name declared at the root or up to two directories down, de-duplicated. */
+export function declaredDependencies(context: ResolutionContext): Set<string> {
+  const cached = cache.get(context);
+  if (cached) return cached;
+  const names = new Set<string>();
+  const manifests = ['package.json'];
+  for (const file of context.getAllFiles()) {
+    if (manifests.length > MAX_MANIFESTS) break;
+    if (/^(?:[^/]+\/){1,2}package\.json$/.test(file) && !file.includes('node_modules/')) manifests.push(file);
+  }
+  for (const manifest of manifests) {
+    const content = context.readFile(manifest);
+    if (!content) continue;
+    try {
+      const pkg = JSON.parse(content) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
+      for (const group of [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies]) {
+        if (group && typeof group === 'object') for (const name of Object.keys(group)) names.add(name);
+      }
+    } catch {
+      // Not JSON — a template, a broken manifest; nothing to read.
+    }
+  }
+  cache.set(context, names);
+  return names;
+}
+
+/** True when any of `deps` is declared anywhere the project's manifests are read. */
+export function dependsOn(context: ResolutionContext, ...deps: string[]): boolean {
+  const names = declaredDependencies(context);
+  return deps.some((d) => names.has(d));
+}

+ 3 - 13
src/resolution/frameworks/react.ts

@@ -6,6 +6,7 @@
 
 import { Node } from '../../types';
 import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
+import { dependsOn } from './package-deps';
 
 export const reactResolver: FrameworkResolver = {
   name: 'react',
@@ -17,19 +18,8 @@ export const reactResolver: FrameworkResolver = {
   languages: ['javascript', 'typescript', 'tsx', 'jsx'],
 
   detect(context: ResolutionContext): boolean {
-    // Check for React in package.json
-    const packageJson = context.readFile('package.json');
-    if (packageJson) {
-      try {
-        const pkg = JSON.parse(packageJson);
-        const deps = { ...pkg.dependencies, ...pkg.devDependencies };
-        if (deps.react || deps.next || deps['react-native']) {
-          return true;
-        }
-      } catch {
-        // Invalid JSON
-      }
-    }
+    // React in a package.json — the root's, or a workspace's (`frontend/`, `apps/web/`).
+    if (dependsOn(context, 'react', 'next', 'react-native')) return true;
 
     // Check for .jsx/.tsx files
     const allFiles = context.getAllFiles();

+ 67 - 0
src/resolution/synth-utils.ts

@@ -0,0 +1,67 @@
+/**
+ * Small helpers the dynamic-edge synthesizers share: a per-match line
+ * resolver over one source string, and "the function this line is in".
+ */
+
+import type { Node } from '../types';
+
+/** The kinds a call site can be attributed to. */
+export const FN_KINDS: ReadonlySet<string> = new Set(['method', 'function', 'component']);
+
+/**
+ * Per-match line resolver over `src`, 1-based at `baseLine`. The inline
+ * `src.slice(0, idx).split('\n').length` idiom is O(source-length) PER MATCH,
+ * which goes quadratic on a match-dense source (a generated function full of
+ * `.push(` calls re-scanned tens of thousands of times was most of the #1235
+ * indexing wedge). Builds the newline index once — lazily, since most sources
+ * never produce a match — then answers each call with a binary search.
+ */
+export function makeLineAt(src: string, baseLine: number): (idx: number) => number {
+  let nl: number[] | null = null;
+  return (idx: number) => {
+    if (!nl) {
+      nl = [];
+      for (let i = src.indexOf('\n'); i !== -1; i = src.indexOf('\n', i + 1)) nl.push(i);
+    }
+    // Count newlines strictly before idx.
+    let lo = 0;
+    let hi = nl.length;
+    while (lo < hi) {
+      const mid = (lo + hi) >> 1;
+      if (nl[mid]! < idx) lo = mid + 1;
+      else hi = mid;
+    }
+    return baseLine + lo;
+  };
+}
+
+/** Innermost function/method node whose line range contains `line`. */
+export function enclosingFn(nodesInFile: readonly Node[], line: number): Node | null {
+  let best: Node | null = null;
+  for (const n of nodesInFile) {
+    if (!FN_KINDS.has(n.kind)) continue;
+    const end = n.endLine ?? n.startLine;
+    if (n.startLine <= line && end >= line) {
+      if (!best || n.startLine >= best.startLine) best = n; // prefer the tightest (latest-starting) encloser
+    }
+  }
+  return best;
+}
+
+/**
+ * The smallest `constant` / `variable` node whose lines contain `line` — the
+ * value a top-level registration is written inside (`const worker = new
+ * Worker('q', async (job) => …)`): what a caller imports and a walk can start
+ * from when no function encloses the site.
+ */
+export function enclosingValue(nodesInFile: readonly Node[], line: number): Node | null {
+  let best: Node | null = null;
+  for (const n of nodesInFile) {
+    if (n.kind !== 'constant' && n.kind !== 'variable') continue;
+    const end = n.endLine ?? n.startLine;
+    if (n.startLine <= line && end >= line) {
+      if (!best || n.startLine >= best.startLine) best = n;
+    }
+  }
+  return best;
+}

+ 938 - 0
src/resolution/tier-synthesizer.ts

@@ -0,0 +1,938 @@
+/**
+ * Cross-tier channels — the web's equivalent of the React Native bridge.
+ *
+ * A web app is two programs that talk over a wire the graph cannot see: the
+ * page calls `fetch('/api/users', { method: 'POST' })` and the API's
+ * `app.post('/api/users', createUser)` answers; a service puts `'welcome'` on
+ * the `email` queue and a `@Process('welcome')` method picks it up; a
+ * gateway's `this.server.emit('message')` lands in the component that wrote
+ * `socket.on('message', …)`. Each hop is a string on both sides, which is the
+ * evidence that lets a synthesizer close it — exactly as the RN event channel
+ * pairs `sendEvent(withName: "x")` with `addListener('x')`.
+ *
+ * Three channels, one scan:
+ *
+ *  1. **`http-client`** — a literal path in a client call (`fetch`, `axios.post`,
+ *     `ky`, `got`, `$fetch`, `useFetch`, `useSWR`, or a project instance made by
+ *     `axios.create(…)` / `ky.extend(…)`) → the ONE route node `METHOD path` it
+ *     denotes. Template holes match a `:param`; a hole in front of the path
+ *     (`${API_URL}/users`) matches a route by its tail; a variable url, a path
+ *     no route serves, or a path two routes serve alike produce nothing.
+ *     Edge: enclosing function → route, `tier: 'client→server'`.
+ *  2. **`queue-job`** — `queue.add('job', …)` where the queue is named (`new
+ *     Queue('email')`, `@InjectQueue('email')`) → the `@Process('job')` method
+ *     of the `@Processor('email')` class, a WorkerHost's `process`, a
+ *     `new Worker('email', handler)`, or Bull's `queue.process('job', handler)`.
+ *  3. **`event-bus`** — `eventEmitter.emit('user.created')` → `@OnEvent('user.created')`
+ *     (globs honoured); and sockets in both directions: a client's
+ *     `socket.emit('x')` → the server's `@SubscribeMessage('x')` / `socket.on('x')`
+ *     (`tier: 'client→server'`), the server's `server.emit('x')` → the client's
+ *     `socket.on('x', …)` (`tier: 'server→client'`). The in-process
+ *     `.on('x', fn)` ↔ `.emit('x')` pairing stays the emitter pass's.
+ *
+ * Every edge is `kind: 'calls'`, `provenance: 'heuristic'`, and carries
+ * `synthesizedBy`, `channel` (`http` | `queue` | `event` | `socket`), the
+ * `tier` when the direction is known, the `event` / `queue` / `method` /
+ * `href` it was paired on, and `registeredAt` — the route registration, the
+ * decorator, the `.on` — so a reader can check the pairing. Fan-out is capped
+ * per event as the emitter pass caps it; an HTTP pairing needs no cap because
+ * it is exact. Test suites and generated files are never sources: a supertest
+ * call is the test's story, and forty of them would make the route a hub.
+ */
+
+import type { Edge, Language, Node } from '../types';
+import type { ResolutionContext } from './types';
+import type { MaybeYield } from './cooperative-yield';
+import { stripCommentsForRegex } from './strip-comments';
+import { resolveImportPath } from './import-resolver';
+import { isGeneratedFile } from '../extraction/generated-detection';
+import { isTestPath } from '../search/query-utils';
+import { HOLE, readStringAt } from './frameworks/expo-router';
+import { enclosingFn, enclosingValue, makeLineAt } from './synth-utils';
+
+const JS_FILE = /\.(?:[cm]?[jt]sx?)$/;
+
+/** Events with more handlers or dispatchers than this are too generic to pair without type information. */
+const EVENT_FANOUT_CAP = 6;
+
+const HTTP_VERBS: ReadonlySet<string> = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'ALL', 'ANY']);
+
+export const TIER_CLIENT_TO_SERVER = 'client→server';
+export const TIER_SERVER_TO_CLIENT = 'server→client';
+
+// =============================================================================
+// Source reading
+// =============================================================================
+
+/** Index just past the `)` that closes the `(` at `open`, skipping strings; -1 if unbalanced. */
+function closeParen(s: string, open: number): number {
+  let depth = 0;
+  for (let i = open; i < s.length; i++) {
+    const ch = s[i];
+    if (ch === '"' || ch === "'") {
+      const q = ch;
+      i++;
+      while (i < s.length && s[i] !== q) {
+        if (s[i] === '\\') i++;
+        i++;
+      }
+      continue;
+    }
+    if (ch === '`') {
+      i = templateEnd(s, i);
+      continue;
+    }
+    if (ch === '(') depth++;
+    else if (ch === ')') {
+      depth--;
+      if (depth === 0) return i;
+    }
+  }
+  return -1;
+}
+
+/** Index of the backtick closing the template opening at `open`. */
+function templateEnd(s: string, open: number): number {
+  let i = open + 1;
+  while (i < s.length) {
+    const ch = s[i];
+    if (ch === '\\') {
+      i += 2;
+      continue;
+    }
+    if (ch === '`') return i;
+    if (ch === '$' && s[i + 1] === '{') {
+      let depth = 0;
+      for (i = i + 1; i < s.length; i++) {
+        if (s[i] === '{') depth++;
+        else if (s[i] === '}') {
+          depth--;
+          if (depth === 0) break;
+        } else if (s[i] === '`') i = templateEnd(s, i);
+      }
+    }
+    i++;
+  }
+  return s.length;
+}
+
+/** The arguments of the call whose `(` is at `open`, split at depth-0 commas. */
+function argumentsAt(s: string, open: number): string[] | null {
+  const close = closeParen(s, open);
+  if (close < 0) return null;
+  const inner = s.slice(open + 1, close);
+  const out: string[] = [];
+  let depth = 0;
+  let start = 0;
+  for (let i = 0; i < inner.length; i++) {
+    const c = inner[i];
+    if (c === '"' || c === "'") {
+      const q = c;
+      i++;
+      while (i < inner.length && inner[i] !== q) {
+        if (inner[i] === '\\') i++;
+        i++;
+      }
+      continue;
+    }
+    if (c === '`') {
+      i = templateEnd(inner, i);
+      continue;
+    }
+    if (c === '(' || c === '[' || c === '{') depth++;
+    else if (c === ')' || c === ']' || c === '}') depth--;
+    else if (c === ',' && depth === 0) {
+      out.push(inner.slice(start, i));
+      start = i + 1;
+    }
+  }
+  out.push(inner.slice(start));
+  return out.map((a) => a.trim()).filter((a, i) => a.length > 0 || i === 0);
+}
+
+/** The first string literal in `text`, holes kept; null when it opens with anything else. */
+function leadingString(text: string): string | null {
+  const t = text.trim().replace(/^\(\s*/, '');
+  // `new URL('/x', base)` — the path is the first argument of the URL.
+  const url = /^new\s+URL\s*\(/.exec(t);
+  if (url) {
+    const args = argumentsAt(t, url[0].length - 1);
+    return args && args[0] ? leadingString(args[0]) : null;
+  }
+  if (t[0] === '"' || t[0] === "'" || t[0] === '`') return readStringAt(t, 0);
+  return null;
+}
+
+/** `method: 'POST'` inside an options object / config, upper-cased; null when absent or computed. */
+function methodIn(text: string): string | null {
+  const m = /\bmethod\s*:\s*(['"`])([A-Za-z]+)\1/.exec(text);
+  return m ? m[2]!.toUpperCase() : null;
+}
+
+/** The first string literal anywhere in a decorator's arguments (`'x'`, `{ name: 'x' }`). */
+function firstLiteral(args: string): string | null {
+  const m = /(['"`])([^'"`]+)\1/.exec(args);
+  return m ? m[2]! : null;
+}
+
+/** Every string literal in a decorator's arguments, for `@OnEvent(['a', 'b'])`. */
+function allLiterals(args: string): string[] {
+  const out: string[] = [];
+  const re = /(['"`])([^'"`]+)\1/g;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(args)) !== null) out.push(m[2]!);
+  return out;
+}
+
+/**
+ * The name of the method a decorator sits on: skip further stacked decorators
+ * and modifiers after the decorator's `)`, then take the identifier before `(`.
+ */
+function methodNameAfter(safe: string, from: number): { name: string; index: number } | null {
+  let i = from;
+  const ws = /\s*/y;
+  const deco = /@[\w.]+/y;
+  const modifier = /(?:public|private|protected|async|static|readonly|override)\b/y;
+  const ident = /([A-Za-z_$][\w$]*)\s*[<(]/y;
+  const eat = (): void => {
+    ws.lastIndex = i;
+    if (ws.exec(safe)) i = ws.lastIndex;
+  };
+  for (;;) {
+    eat();
+    if (safe[i] !== '@') break;
+    deco.lastIndex = i;
+    if (!deco.exec(safe)) break;
+    i = deco.lastIndex;
+    eat();
+    if (safe[i] === '(') {
+      const close = closeParen(safe, i);
+      if (close < 0) return null;
+      i = close + 1;
+    }
+  }
+  for (;;) {
+    eat();
+    modifier.lastIndex = i;
+    if (modifier.exec(safe) && modifier.lastIndex > i) {
+      i = modifier.lastIndex;
+      continue;
+    }
+    break;
+  }
+  eat();
+  ident.lastIndex = i;
+  const m = ident.exec(safe);
+  return m ? { name: m[1]!, index: i } : null;
+}
+
+/** Every `@Name(` decorator in `safe` with its arguments and where it ends. */
+function decorators(safe: string, name: string): Array<{ args: string; index: number; end: number }> {
+  const out: Array<{ args: string; index: number; end: number }> = [];
+  const re = new RegExp(`@${name}\\s*\\(`, 'g');
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(safe)) !== null) {
+    const open = m.index + m[0].length - 1;
+    const close = closeParen(safe, open);
+    if (close < 0) continue;
+    out.push({ args: safe.slice(open + 1, close), index: m.index, end: close + 1 });
+    re.lastIndex = close + 1;
+  }
+  return out;
+}
+
+// =============================================================================
+// Per-file facts, read once
+// =============================================================================
+
+interface FileFacts {
+  file: string;
+  safe: string;
+  nodes: Node[];
+  lineOf: (idx: number) => number;
+  /** The 0-based column of an index on its line — where the site reader looks for the call. */
+  columnOf: (idx: number) => number;
+  /** Lines a framework resolver made a route node on — registrations, never client calls. */
+  routeLines: Set<number>;
+  /** Local names bound to an HTTP client instance, with their literal base URL when written. */
+  clients: Map<string, { baseURL: string | null }>;
+  /** The module's default export is a client instance. */
+  defaultClient: { baseURL: string | null } | null;
+  /** Local names bound to a named queue (`new Queue('email')`, `@InjectQueue('email') x`). */
+  queues: Map<string, string>;
+  /** The file holds a socket server (a gateway, `io.on('connection')`, `new Server(…)`). */
+  socketServer: boolean;
+}
+
+const CLIENT_FACTORY =
+  /\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*?)?=\s*(?:await\s+)?(?:(?:axios|ky|got|ofetch|\$fetch|wretch|redaxios)\s*\.\s*(?:create|extend)|new\s+Axios|wretch)\s*\(/g;
+const DEFAULT_CLIENT_FACTORY = /\bexport\s+default\s+(?:(?:axios|ky|got|ofetch|\$fetch|wretch|redaxios)\s*\.\s*(?:create|extend)|new\s+Axios|wretch)\s*\(/;
+const QUEUE_BINDING =
+  /\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*?)?=\s*(?:new\s+)?(?:Queue|Bull|BullQueue)\s*(?:<[^>]*>)?\s*\(\s*(['"`])([^'"`]+)\2/g;
+const INJECT_QUEUE = /@InjectQueue\s*\(\s*(['"`])([^'"`]+)\1\s*\)\s*(?:(?:private|public|protected|readonly)\s+)*([A-Za-z_$][\w$]*)/g;
+const SOCKET_SERVER_FILE = /@WebSocketGateway\s*\(|@SubscribeMessage\s*\(|@WebSocketServer\s*\(|\bnew\s+(?:Server|SocketIOServer|WebSocketServer|WebSocket\.Server|WSServer)\b|\.on\s*\(\s*['"]connection['"]/;
+
+function baseUrlIn(safe: string, open: number): string | null {
+  const args = argumentsAt(safe, open);
+  const config = args?.[0] ?? '';
+  const m = /\b(?:baseURL|baseUrl|prefixUrl|baseURI)\s*:\s*/.exec(config);
+  if (!m) return null;
+  return readStringAt(config, m.index + m[0].length);
+}
+
+function readFacts(ctx: ResolutionContext, file: string): FileFacts | null {
+  const content = ctx.readFile(file);
+  if (!content) return null;
+  const safe = stripCommentsForRegex(content, 'typescript');
+  const nodes = ctx.getNodesInFile(file);
+  const routeLines = new Set<number>();
+  for (const n of nodes) if (n.kind === 'route') routeLines.add(n.startLine);
+  const clients = new Map<string, { baseURL: string | null }>();
+  CLIENT_FACTORY.lastIndex = 0;
+  let m: RegExpExecArray | null;
+  while ((m = CLIENT_FACTORY.exec(safe)) !== null) {
+    clients.set(m[1]!, { baseURL: baseUrlIn(safe, m.index + m[0].length - 1) });
+  }
+  const dm = DEFAULT_CLIENT_FACTORY.exec(safe);
+  const defaultClient = dm ? { baseURL: baseUrlIn(safe, dm.index + dm[0].length - 1) } : null;
+  const queues = new Map<string, string>();
+  QUEUE_BINDING.lastIndex = 0;
+  while ((m = QUEUE_BINDING.exec(safe)) !== null) queues.set(m[1]!, m[3]!);
+  INJECT_QUEUE.lastIndex = 0;
+  while ((m = INJECT_QUEUE.exec(safe)) !== null) queues.set(m[3]!, m[2]!);
+  return {
+    file,
+    safe,
+    nodes,
+    lineOf: makeLineAt(safe, 1),
+    columnOf: (idx: number) => idx - (safe.lastIndexOf('\n', idx - 1) + 1),
+    routeLines,
+    clients,
+    defaultClient,
+    queues,
+    socketServer: SOCKET_SERVER_FILE.test(safe),
+  };
+}
+
+/** Facts for the file a local name is imported from, when the import resolves to a project file. */
+function importedFacts(
+  ctx: ResolutionContext,
+  facts: FileFacts,
+  localName: string,
+  cache: Map<string, FileFacts | null>
+): { facts: FileFacts; exportedName: string; isDefault: boolean } | null {
+  const lang: Language = facts.file.endsWith('x') ? 'tsx' : 'typescript';
+  const im = ctx.getImportMappings(facts.file, lang).find((i) => i.localName === localName);
+  if (!im) return null;
+  // The mappings name the module as written; the file it is comes from the
+  // same resolution the import resolver uses (aliases, extensions, index files).
+  const resolved = im.resolvedPath ?? resolveImportPath(im.source, facts.file, lang, ctx);
+  if (!resolved) return null;
+  let target = cache.get(resolved);
+  if (target === undefined) {
+    target = JS_FILE.test(resolved) ? readFacts(ctx, resolved) : null;
+    cache.set(resolved, target);
+  }
+  return target ? { facts: target, exportedName: im.exportedName, isDefault: im.isDefault } : null;
+}
+
+// =============================================================================
+// 1. HTTP client → route
+// =============================================================================
+
+/** A receiver that is an HTTP client by name alone. */
+const CLIENT_NAMES =
+  /^(?:axios|ky|got|superagent|http|https|httpClient|httpService|api|apiClient|client|restClient|request|agent|fetcher|instance|\$api|\$http|\$axios|axiosInstance|Axios|HttpClient|backend|server)$/;
+/** A receiver that registers routes, never a client — unless it was made by a client factory. */
+const SERVER_NAMES = /^(?:app|router|route|routes|express|fastify|koa|hono|elysia|apiRouter|v1|v2|r)$/;
+const BARE_CLIENT_CALL = /(?:(?:window|globalThis|global)\s*\.\s*)?\b(fetch|\$fetch|ofetch|axios|ky|got|useFetch|useSWR)\s*\(/g;
+const MEMBER_CLIENT_CALL = /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*(get|post|put|patch|delete|head|options|request|\$get|\$post|\$put|\$patch|\$delete)\s*\(/g;
+
+interface HttpRoute {
+  node: Node;
+  method: string;
+  segs: string[];
+}
+
+interface HttpSite {
+  fn: Node;
+  file: string;
+  line: number;
+  column: number;
+  /** The call as written, `fetch` / `api.get` — what the Steps walk must not also draw as an effect. */
+  callee: string;
+  method: string;
+  segs: string[];
+  /** The path began with a hole — a base URL — and matches a route by its tail. */
+  suffix: boolean;
+  display: string;
+}
+
+function httpRoutes(ctx: ResolutionContext): HttpRoute[] {
+  const out: HttpRoute[] = [];
+  for (const node of ctx.getNodesByKind('route')) {
+    const space = node.name.indexOf(' ');
+    if (space <= 0) continue;
+    const method = node.name.slice(0, space).toUpperCase();
+    if (!HTTP_VERBS.has(method)) continue;
+    const path = node.name.slice(space + 1).trim();
+    if (!path.startsWith('/')) continue;
+    out.push({ node, method, segs: path.split('/').filter((s) => s.length > 0) });
+  }
+  return out;
+}
+
+const PARAM_SEG = /^(?::|\{|\[|<|\*)|\?$/;
+const CATCH_ALL = /^(?:\*|\[\.\.\.|\{\*|:[\w$]+\*$|\{[\w$]+:\*\}|\*[\w$]*$)/;
+
+/** How well the client's segments match a route's; null when they do not. A literal match beats a parameter's. */
+function scorePath(client: readonly string[], route: readonly string[]): number | null {
+  let score = 0;
+  let i = 0;
+  for (let r = 0; r < route.length; r++) {
+    const seg = route[r]!;
+    if (CATCH_ALL.test(seg)) {
+      if (i >= client.length) return null;
+      score += client.length - i;
+      i = client.length;
+      continue;
+    }
+    if (i >= client.length) return null;
+    const c = client[i]!;
+    // A hole (`${id}`) fills a route's parameter; it never stands in for a
+    // literal segment — `/api/products/${id}` is not `/api/products/top`.
+    if (PARAM_SEG.test(seg)) score += 2;
+    else if (c === seg) score += 3;
+    else return null;
+    i++;
+  }
+  return i === client.length ? score : null;
+}
+
+function matchHttp(site: HttpSite, routes: readonly HttpRoute[]): HttpRoute | null {
+  let best: HttpRoute | null = null;
+  let bestScore = -1;
+  let tied = false;
+  for (const r of routes) {
+    if (r.method !== 'ALL' && r.method !== 'ANY' && r.method !== site.method) continue;
+    let score: number | null;
+    if (site.suffix) {
+      // A base URL hides a prefix the route may spell out (`${API}/users` for
+      // `GET /api/users`) — but a one-segment tail names half the routes in
+      // an index, and a long hidden prefix is a different API. Two segments
+      // of tail at least, two of prefix at most.
+      if (site.segs.length < 2 || r.segs.length < site.segs.length || r.segs.length - site.segs.length > 2) continue;
+      score = scorePath(site.segs, r.segs.slice(r.segs.length - site.segs.length));
+    } else score = scorePath(site.segs, r.segs);
+    if (score === null) continue;
+    if (score > bestScore) {
+      best = r;
+      bestScore = score;
+      tied = false;
+    } else if (score === bestScore) tied = true;
+  }
+  return tied ? null : best;
+}
+
+/**
+ * The path a client call names, as segments, with `${…}` as `*`; `suffix`
+ * when a base URL came first. Null when the path is not literal enough: a
+ * relative path with no base, or nothing but holes.
+ */
+function clientPath(raw: string, baseURL: string | null): { segs: string[]; suffix: boolean; display: string } | null {
+  let p = raw;
+  const cut = p.search(/[?#]/);
+  if (cut >= 0) p = p.slice(0, cut);
+  let suffix = false;
+  const absolute = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*(\/.*)?$/i.exec(p);
+  if (absolute) p = absolute[1] ?? '/';
+  else if (!p.startsWith('/')) {
+    if (p.startsWith(HOLE)) {
+      const rest = p.slice(1);
+      if (!rest.startsWith('/')) return null;
+      p = rest;
+      suffix = true;
+    } else if (baseURL !== null) {
+      const base = clientPath(baseURL, null);
+      if (!base) {
+        // A base that is itself a hole: match by the tail.
+        if (!baseURL.includes(HOLE)) return null;
+        suffix = true;
+        p = '/' + p;
+      } else {
+        suffix = base.suffix;
+        p = '/' + [...base.segs, ...p.split('/')].filter(Boolean).join('/');
+      }
+    } else return null;
+  } else if (baseURL !== null) {
+    // An instance with a literal path base: axios joins `baseURL + url`.
+    const base = clientPath(baseURL, null);
+    if (base && base.segs.length > 0) {
+      suffix = base.suffix;
+      p = '/' + [...base.segs, ...p.split('/')].filter(Boolean).join('/');
+    } else if (!base && baseURL.includes(HOLE)) suffix = true;
+  }
+  const segs = p
+    .split('/')
+    .filter((s) => s.length > 0)
+    .map((s) => (s.includes(HOLE) ? '*' : s));
+  if (segs.length > 0 && segs.every((s) => s === '*')) return null;
+  return { segs, suffix, display: '/' + segs.map((s) => (s === '*' ? '${…}' : s)).join('/') };
+}
+
+/** What a member-call receiver is: a client (with its base URL), or nothing. */
+function clientFor(
+  ctx: ResolutionContext,
+  facts: FileFacts,
+  receiver: string,
+  cache: Map<string, FileFacts | null>
+): { baseURL: string | null } | null {
+  const chain = receiver.replace(/\s+/g, '').replace(/^this\./, '').split('.');
+  const head = chain[0]!;
+  const last = chain[chain.length - 1]!;
+  const local = facts.clients.get(head);
+  if (local) return local;
+  const imported = importedFacts(ctx, facts, head, cache);
+  if (imported) {
+    const bound = imported.isDefault ? imported.facts.defaultClient : imported.facts.clients.get(imported.exportedName) ?? null;
+    if (bound) return bound;
+  }
+  if (SERVER_NAMES.test(last) || SERVER_NAMES.test(head)) return null;
+  if (CLIENT_NAMES.test(last)) return { baseURL: null };
+  return null;
+}
+
+function collectHttpSites(ctx: ResolutionContext, facts: FileFacts, sites: HttpSite[], cache: Map<string, FileFacts | null>): void {
+  const { safe, nodes, lineOf } = facts;
+  const add = (index: number, open: number, verb: string | null, baseURL: string | null): void => {
+    const line = lineOf(index);
+    const callee = safe.slice(index, open).replace(/\s+/g, '');
+    if (facts.routeLines.has(line)) return; // a registration the resolver already read
+    const fn = enclosingFn(nodes, line);
+    if (!fn) return;
+    const args = argumentsAt(safe, open);
+    if (!args || !args[0]) return;
+    let first = args[0];
+    let method = verb;
+    // `axios({ url, method })`, `.request({ url, method })`, `ky(url, { method })`.
+    if (first.trimStart().startsWith('{')) {
+      const url = /\burl\s*:\s*/.exec(first);
+      if (!url) return;
+      method = method ?? methodIn(first) ?? 'GET';
+      first = first.slice(url.index + url[0].length);
+    } else if (method === null) {
+      method = methodIn(args.slice(1).join(',')) ?? 'GET';
+    }
+    const literal = leadingString(first);
+    if (literal === null) return;
+    const path = clientPath(literal, baseURL);
+    if (!path) return;
+    sites.push({ fn, file: facts.file, line, column: facts.columnOf(index), callee, method, segs: path.segs, suffix: path.suffix, display: path.display });
+  };
+
+  BARE_CLIENT_CALL.lastIndex = 0;
+  let m: RegExpExecArray | null;
+  while ((m = BARE_CLIENT_CALL.exec(safe)) !== null) {
+    // `this.fetch(…)` / `repo.fetch(…)` is a project method, not the platform's.
+    const before = safe[m.index - 1];
+    if (before === '.' && !/^(?:window|globalThis|global)\s*\./.test(m[0])) continue;
+    add(m.index, m.index + m[0].length - 1, null, null);
+  }
+  MEMBER_CLIENT_CALL.lastIndex = 0;
+  while ((m = MEMBER_CLIENT_CALL.exec(safe)) !== null) {
+    const client = clientFor(ctx, facts, m[1]!, cache);
+    if (!client) continue;
+    const verb = m[2]!.replace(/^\$/, '').toUpperCase();
+    add(m.index, m.index + m[0].length - 1, verb === 'REQUEST' ? null : verb, client.baseURL);
+  }
+}
+
+// =============================================================================
+// 2. Queue job → consumer
+// =============================================================================
+
+const QUEUE_ADD = /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*add\s*\(\s*(['"`])([^'"`]+)\2/g;
+const QUEUE_SHAPED = /queue|jobs?$|worker|bull|flow|producer/i;
+const NEW_WORKER = /\bnew\s+Worker\s*(?:<[^>]*>)?\s*\(\s*(['"`])([^'"`]+)\1\s*,\s*/g;
+const QUEUE_PROCESS = /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*process\s*\(\s*(?:(['"`])([^'"`]+)\2\s*,\s*)?(?:\d+\s*,\s*)?/g;
+/** A handler argument: a named function (group 1), or an inline function. */
+const HANDLER_ARG = /^(?:(?:async\s+)?([A-Za-z_$][\w$.]*)\s*(?:[,)]|$)|(?:async\s*)?(?:\(|function\b|[A-Za-z_$][\w$]*\s*=>))/;
+
+interface QueueProducer {
+  fn: Node;
+  file: string;
+  line: number;
+  column: number;
+  callee: string;
+  queue: string | null;
+  job: string;
+}
+
+interface QueueConsumer {
+  node: Node;
+  file: string;
+  line: number;
+  queue: string | null;
+  /** Null: every job on the queue (`@Process()` with no name, a WorkerHost's `process`, `new Worker`). */
+  job: string | null;
+}
+
+/** The queue a receiver is bound to, by its binding in this file or the file it is imported from. */
+function queueFor(ctx: ResolutionContext, facts: FileFacts, receiver: string, cache: Map<string, FileFacts | null>): string | null {
+  const chain = receiver.replace(/\s+/g, '').replace(/^this\./, '').split('.');
+  const head = chain[0]!;
+  const own = facts.queues.get(head);
+  if (own) return own;
+  const imported = importedFacts(ctx, facts, head, cache);
+  if (imported) {
+    const bound = imported.facts.queues.get(imported.exportedName);
+    if (bound) return bound;
+  }
+  return null;
+}
+
+/** The function a handler argument names or encloses; null when it is neither. */
+function handlerNode(ctx: ResolutionContext, facts: FileFacts, text: string, line: number, cache: Map<string, FileFacts | null>): Node | null {
+  const m = HANDLER_ARG.exec(text.trimStart());
+  if (!m) return null;
+  if (m[1]) {
+    const name = m[1].split('.').pop()!;
+    const candidates = ctx.getNodesByName(name).filter((n) => n.kind === 'function' || n.kind === 'method');
+    const local = candidates.filter((n) => n.filePath === facts.file);
+    if (local.length === 1) return local[0]!;
+    if (local.length > 1) return null;
+    const imported = importedFacts(ctx, facts, m[1].split('.')[0]!, cache);
+    if (imported) {
+      const viaImport = candidates.filter((n) => n.filePath === imported.facts.file);
+      if (viaImport.length === 1) return viaImport[0]!;
+    }
+    return candidates.length === 1 ? candidates[0]! : null;
+  }
+  return enclosingFn(facts.nodes, line) ?? enclosingValue(facts.nodes, line);
+}
+
+/** The nearest class declared at or after `index`, as its node. */
+function classAfter(facts: FileFacts, index: number): Node | null {
+  const m = /\bclass\s+([A-Za-z_$][\w$]*)/g;
+  m.lastIndex = index;
+  const hit = m.exec(facts.safe);
+  if (!hit) return null;
+  const line = facts.lineOf(hit.index);
+  return facts.nodes.find((n) => n.kind === 'class' && n.name === hit[1] && n.startLine >= line - 2) ?? null;
+}
+
+/** The method node a decorator at `end` sits on, inside `cls` when given. */
+function decoratedMethod(facts: FileFacts, end: number, cls: Node | null): Node | null {
+  const named = methodNameAfter(facts.safe, end);
+  if (!named) return null;
+  const line = facts.lineOf(named.index);
+  return (
+    facts.nodes.find(
+      (n) =>
+        (n.kind === 'method' || n.kind === 'function') &&
+        n.name === named.name &&
+        n.startLine >= line - 1 &&
+        n.startLine <= line + 1 &&
+        (!cls || (n.startLine >= cls.startLine && n.endLine <= cls.endLine))
+    ) ?? null
+  );
+}
+
+function collectQueue(ctx: ResolutionContext, facts: FileFacts, producers: QueueProducer[], consumers: QueueConsumer[], cache: Map<string, FileFacts | null>): void {
+  const { safe, nodes, lineOf } = facts;
+  let m: RegExpExecArray | null;
+  QUEUE_ADD.lastIndex = 0;
+  while ((m = QUEUE_ADD.exec(safe)) !== null) {
+    const receiver = m[1]!;
+    const queue = queueFor(ctx, facts, receiver, cache);
+    const last = receiver.replace(/\s+/g, '').split('.').pop()!;
+    if (queue === null && !QUEUE_SHAPED.test(last)) continue;
+    const line = lineOf(m.index);
+    const fn = enclosingFn(nodes, line);
+    if (!fn) continue;
+    producers.push({ fn, file: facts.file, line, column: facts.columnOf(m.index), callee: `${receiver.replace(/\s+/g, '')}.add`, queue, job: m[3]! });
+  }
+
+  // Nest: `@Processor('email')` on a class; `@Process('welcome')` on its methods,
+  // or a WorkerHost's `process(job)`.
+  for (const proc of decorators(safe, 'Processor')) {
+    const queue = firstLiteral(proc.args);
+    const cls = classAfter(facts, proc.end);
+    if (!cls) continue;
+    let any = false;
+    for (const job of decorators(safe, 'Process')) {
+      const line = lineOf(job.index);
+      if (line < cls.startLine || line > cls.endLine) continue;
+      const method = decoratedMethod(facts, job.end, cls);
+      if (!method) continue;
+      any = true;
+      consumers.push({ node: method, file: facts.file, line, queue, job: firstLiteral(job.args) });
+    }
+    if (!any) {
+      const process = nodes.find((n) => n.kind === 'method' && n.name === 'process' && n.startLine >= cls.startLine && n.endLine <= cls.endLine);
+      if (process) consumers.push({ node: process, file: facts.file, line: process.startLine, queue, job: null });
+    }
+  }
+
+  // BullMQ: `new Worker('email', handler)`.
+  NEW_WORKER.lastIndex = 0;
+  while ((m = NEW_WORKER.exec(safe)) !== null) {
+    const line = lineOf(m.index);
+    const node = handlerNode(ctx, facts, safe.slice(m.index + m[0].length, m.index + m[0].length + 200), line, cache);
+    if (!node) continue;
+    consumers.push({ node, file: facts.file, line, queue: m[2]!, job: null });
+  }
+
+  // Bull: `queue.process('welcome', handler)` / `queue.process(handler)`.
+  QUEUE_PROCESS.lastIndex = 0;
+  while ((m = QUEUE_PROCESS.exec(safe)) !== null) {
+    const receiver = m[1]!;
+    const queue = queueFor(ctx, facts, receiver, cache);
+    const last = receiver.replace(/\s+/g, '').split('.').pop()!;
+    if (queue === null && !QUEUE_SHAPED.test(last)) continue;
+    const line = lineOf(m.index);
+    const node = handlerNode(ctx, facts, safe.slice(m.index + m[0].length, m.index + m[0].length + 200), line, cache);
+    if (!node) continue;
+    consumers.push({ node, file: facts.file, line, queue, job: m[3] ?? null });
+  }
+}
+
+function pairQueue(producers: readonly QueueProducer[], consumers: readonly QueueConsumer[], edges: Edge[], seen: Set<string>): void {
+  for (const p of producers) {
+    let candidates = consumers.filter((c) => (p.queue === null || c.queue === null || c.queue === p.queue) && (c.job === null || c.job === p.job));
+    // The most specific pairing wins: the job by name on the named queue,
+    // then the job by name, then the queue's default consumer.
+    const exact = candidates.filter((c) => c.job === p.job && c.queue === p.queue && p.queue !== null);
+    if (exact.length > 0) candidates = exact;
+    else {
+      const byJob = candidates.filter((c) => c.job === p.job);
+      if (byJob.length > 0) candidates = byJob;
+      else if (p.queue === null) continue; // an unnamed queue and no consumer naming the job: a guess
+      else candidates = candidates.filter((c) => c.queue === p.queue);
+    }
+    if (candidates.length === 0 || candidates.length > EVENT_FANOUT_CAP) continue;
+    for (const c of candidates) {
+      if (c.node.id === p.fn.id) continue;
+      const key = `${p.fn.id}>${c.node.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: p.fn.id,
+        target: c.node.id,
+        kind: 'calls',
+        line: p.line,
+        column: p.column,
+        provenance: 'heuristic',
+        metadata: {
+          synthesizedBy: 'queue-job',
+          channel: 'queue',
+          callee: p.callee,
+          event: p.job,
+          ...(p.queue ?? c.queue ? { queue: p.queue ?? c.queue } : {}),
+          registeredAt: `${c.file}:${c.line}`,
+        },
+      });
+    }
+  }
+}
+
+// =============================================================================
+// 3. Events: a bus, and sockets both ways
+// =============================================================================
+
+const EMIT = /((?:[\w$]+(?:\([^()]*\))?\s*\.\s*)*[\w$]+)\s*\.\s*(emit|emitAsync)\s*\(\s*(['"`])([^'"`\n]+)\3/g;
+const SOCKET_ON = /((?:[\w$]+(?:\([^()]*\))?\s*\.\s*)*[\w$]+)\s*\.\s*(?:on|once)\s*\(\s*(['"`])([^'"`\n]+)\2\s*,\s*/g;
+const SOCKET_WORDS = /^(?:socket|io|ws|wss|client|server|namespace|nsp|conn|connection|gateway|broadcast|to|in|of|except|volatile|local|sockets|socketServer|wsServer|room|channel|pusher|ably|ioClient|socketClient|sock)$/;
+const BUS_WORDS = /^(?:eventEmitter|emitter|events|eventBus|bus|dispatcher|pubsub|publisher|eventPublisher|ee|hub|mediator|broker|messageBus|appEvents|domainEvents|eventsService|eventService)$/;
+/** The transport's own events — every socket emits and handles them; pairing them says nothing. */
+const GENERIC_EVENT =
+  /^(?:error|connect|connect_error|connect_failed|connection|disconnect|disconnecting|reconnect|reconnect_attempt|reconnecting|reconnect_error|reconnect_failed|close|open|end|data|ready|drain|finish|pipe|unpipe|listening|timeout|ping|pong|upgrade|newListener|removeListener)$/;
+
+interface Dispatch {
+  fn: Node;
+  file: string;
+  line: number;
+  column: number;
+  callee: string;
+  event: string;
+  shape: 'bus' | 'socket';
+  side: 'server' | 'client';
+}
+
+interface Handler {
+  node: Node;
+  file: string;
+  line: number;
+  /** An event name, or an `@OnEvent` glob. */
+  pattern: string;
+  kind: 'bus' | 'socket';
+  side: 'server' | 'client';
+}
+
+function shapeOf(receiver: string): 'bus' | 'socket' | null {
+  const segs = receiver.replace(/\([^()]*\)/g, '').replace(/\s+/g, '').split('.').filter((s) => s !== 'this');
+  if (segs.some((s) => SOCKET_WORDS.test(s))) return 'socket';
+  if (segs.some((s) => BUS_WORDS.test(s))) return 'bus';
+  return null;
+}
+
+function collectEvents(ctx: ResolutionContext, facts: FileFacts, dispatches: Dispatch[], handlers: Handler[], cache: Map<string, FileFacts | null>): void {
+  const { safe, nodes, lineOf } = facts;
+  const side: 'server' | 'client' = facts.socketServer ? 'server' : 'client';
+  let m: RegExpExecArray | null;
+  EMIT.lastIndex = 0;
+  while ((m = EMIT.exec(safe)) !== null) {
+    const shape = shapeOf(m[1]!);
+    if (!shape || GENERIC_EVENT.test(m[4]!)) continue;
+    const line = lineOf(m.index);
+    const fn = enclosingFn(nodes, line);
+    if (!fn) continue;
+    dispatches.push({ fn, file: facts.file, line, column: facts.columnOf(m.index), callee: `${m[1]!.replace(/\s+/g, '')}.${m[2]!}`, event: m[4]!, shape, side });
+  }
+  for (const d of decorators(safe, 'OnEvent')) {
+    const method = decoratedMethod(facts, d.end, null);
+    if (!method) continue;
+    const patterns = d.args.trimStart().startsWith('[') ? allLiterals(d.args) : [firstLiteral(d.args)].filter((x): x is string => x !== null);
+    for (const pattern of patterns) handlers.push({ node: method, file: facts.file, line: lineOf(d.index), pattern, kind: 'bus', side });
+  }
+  for (const d of decorators(safe, 'SubscribeMessage')) {
+    const method = decoratedMethod(facts, d.end, null);
+    const event = firstLiteral(d.args);
+    if (!method || event === null) continue;
+    handlers.push({ node: method, file: facts.file, line: lineOf(d.index), pattern: event, kind: 'socket', side: 'server' });
+  }
+  SOCKET_ON.lastIndex = 0;
+  while ((m = SOCKET_ON.exec(safe)) !== null) {
+    if (shapeOf(m[1]!) !== 'socket' || GENERIC_EVENT.test(m[3]!)) continue;
+    const line = lineOf(m.index);
+    const node = handlerNode(ctx, facts, safe.slice(m.index + m[0].length, m.index + m[0].length + 200), line, cache);
+    if (!node) continue;
+    handlers.push({ node, file: facts.file, line, pattern: m[3]!, kind: 'socket', side });
+  }
+}
+
+/** `user.*` matches one segment, `**` any; anything else is exact. */
+function eventMatches(pattern: string, event: string): boolean {
+  if (pattern === event) return true;
+  if (!pattern.includes('*')) return false;
+  const re = new RegExp('^' + pattern.split('**').map((part) => part.split('*').map(escapeRe).join('[^.]+')).join('.*') + '$');
+  return re.test(event);
+}
+
+function escapeRe(s: string): string {
+  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function pairEvents(dispatches: readonly Dispatch[], handlers: readonly Handler[], edges: Edge[], seen: Set<string>): void {
+  // Fan-out is judged per event name on each side, as the emitter pass does.
+  const dispatchesByEvent = new Map<string, Dispatch[]>();
+  for (const d of dispatches) dispatchesByEvent.set(`${d.shape}:${d.event}`, [...(dispatchesByEvent.get(`${d.shape}:${d.event}`) ?? []), d]);
+  for (const [, group] of dispatchesByEvent) {
+    if (group.length > EVENT_FANOUT_CAP) continue;
+    for (const d of group) {
+      let matched: Handler[];
+      let tier: string | null = null;
+      if (d.shape === 'bus') matched = handlers.filter((h) => h.kind === 'bus' && eventMatches(h.pattern, d.event));
+      else if (d.side === 'client') {
+        matched = handlers.filter((h) => h.kind === 'socket' && h.side === 'server' && h.pattern === d.event);
+        tier = TIER_CLIENT_TO_SERVER;
+      } else {
+        matched = handlers.filter((h) => h.kind === 'socket' && h.side === 'client' && h.pattern === d.event);
+        tier = TIER_SERVER_TO_CLIENT;
+      }
+      if (matched.length === 0 || matched.length > EVENT_FANOUT_CAP) continue;
+      for (const h of matched) {
+        if (h.node.id === d.fn.id) continue;
+        const key = `${d.fn.id}>${h.node.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: d.fn.id,
+          target: h.node.id,
+          kind: 'calls',
+          line: d.line,
+          column: d.column,
+          provenance: 'heuristic',
+          metadata: {
+            synthesizedBy: 'event-bus',
+            channel: d.shape === 'bus' ? 'event' : 'socket',
+            callee: d.callee,
+            event: d.event,
+            ...(tier ? { tier } : {}),
+            registeredAt: `${h.file}:${h.line}`,
+          },
+        });
+      }
+    }
+  }
+}
+
+// =============================================================================
+// The pass
+// =============================================================================
+
+const HTTP_GATE = /\b(?:fetch|\$fetch|ofetch|axios|ky|got|useFetch|useSWR)\s*\(|\.\s*(?:get|post|put|patch|delete|head|options|request|\$get|\$post)\s*\(/;
+const QUEUE_GATE = /\.\s*add\s*\(|@Processor\s*\(|\bnew\s+Worker\s*[<(]|\.\s*process\s*\(/;
+const EVENT_GATE = /\.\s*(?:emit|emitAsync|on|once)\s*\(|@OnEvent\s*\(|@SubscribeMessage\s*\(/;
+
+export async function crossTierEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
+  const routes = httpRoutes(ctx);
+  const httpSites: HttpSite[] = [];
+  const producers: QueueProducer[] = [];
+  const consumers: QueueConsumer[] = [];
+  const dispatches: Dispatch[] = [];
+  const handlers: Handler[] = [];
+  const cache = new Map<string, FileFacts | null>();
+
+  let scanned = 0;
+  for (const file of ctx.getAllFiles()) {
+    if (!JS_FILE.test(file) || isTestPath(file) || isGeneratedFile(file)) continue;
+    if ((++scanned & 63) === 0) await onYield();
+    const content = ctx.readFile(file);
+    if (!content) continue;
+    const wantsHttp = routes.length > 0 && HTTP_GATE.test(content);
+    const wantsQueue = QUEUE_GATE.test(content);
+    const wantsEvents = EVENT_GATE.test(content);
+    if (!wantsHttp && !wantsQueue && !wantsEvents) continue;
+    let facts = cache.get(file);
+    if (facts === undefined) {
+      facts = readFacts(ctx, file);
+      cache.set(file, facts);
+    }
+    if (!facts) continue;
+    if (wantsHttp) collectHttpSites(ctx, facts, httpSites, cache);
+    if (wantsQueue) collectQueue(ctx, facts, producers, consumers, cache);
+    if (wantsEvents) collectEvents(ctx, facts, dispatches, handlers, cache);
+  }
+
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const site of httpSites) {
+    const route = matchHttp(site, routes);
+    if (!route || route.node.id === site.fn.id) continue;
+    const key = `${site.fn.id}>${route.node.id}`;
+    if (seen.has(key)) continue;
+    seen.add(key);
+    edges.push({
+      source: site.fn.id,
+      target: route.node.id,
+      kind: 'calls',
+      line: site.line,
+      column: site.column,
+      provenance: 'heuristic',
+      metadata: {
+        synthesizedBy: 'http-client',
+        channel: 'http',
+        callee: site.callee,
+        tier: TIER_CLIENT_TO_SERVER,
+        method: site.method,
+        href: site.display,
+        registeredAt: `${route.node.filePath}:${route.node.startLine}`,
+      },
+    });
+  }
+  await onYield();
+  pairQueue(producers, consumers, edges, seen);
+  pairEvents(dispatches, handlers, edges, seen);
+  return edges;
+}

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

@@ -332,9 +332,10 @@ export function isTestPath(filePath: string): boolean {
     lower.includes('/tests/') || lower.includes('/test/') ||
     lower.includes('/__tests__/') || lower.includes('/spec/') ||
     lower.includes('/specs/') || lower.includes('/testlib/') ||
-    lower.includes('/testing/') ||
+    lower.includes('/testing/') || lower.includes('/e2e/') ||
     lower.startsWith('test/') || lower.startsWith('tests/') ||
     lower.startsWith('spec/') || lower.startsWith('specs/') ||
+    lower.startsWith('e2e/') ||
     // CamelCase test source-set dirs (Kotlin Multiplatform / Gradle / Xcode):
     // jvmTest/, commonTest/, androidTest/, iosTest/, integrationTest/. Capital-led
     // so "latest/" / "manifest/" are not matched.

+ 9 - 2
src/ui-server/api/route-roots.ts

@@ -35,7 +35,14 @@ export interface RouteRoot {
   inline: boolean;
 }
 
-const HANDLER_KINDS: ReadonlySet<Node['kind']> = new Set(['function', 'method', 'class', 'component']);
+/**
+ * What a resolver's `references` edge may name as the handler. A constant or
+ * a variable counts: `const authUser = asyncHandler(async (req, res) => …)` is
+ * how an Express handler is written with a wrapper, and the registration site
+ * named it — the arrow inside has no node of its own, so the binding is the
+ * handler, and the walk lends it the file-scope calls within its lines.
+ */
+const HANDLER_KINDS: ReadonlySet<Node['kind']> = new Set(['function', 'method', 'class', 'component', 'constant', 'variable']);
 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. */
@@ -59,7 +66,7 @@ export function routeRoots(cg: CodeGraph, routes: readonly Node[]): Map<string,
     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);
+  const rank = (n: Node): number => (n.kind === 'function' || n.kind === 'method' ? 0 : n.kind === 'component' ? 1 : n.kind === 'class' ? 2 : 3);
   for (const route of routes) {
     const list = byRoute.get(route.id);
     if (!list || list.length === 0) continue;

+ 123 - 21
src/ui-server/api/steps.ts

@@ -103,8 +103,9 @@ export interface WireStep {
   depth: number;
   /**
    * Why the walk did not go on from this step, when it did not: a cap it hit
-   * (`depth`, `fan-out`, `folded`, `steps`), or `screen` — another screen is
-   * a chapter of its own, drawn but not entered unless `through` asks.
+   * (`depth`, `fan-out`, `folded`, `steps`), or `screen` — another screen, or
+   * an endpoint reached across a tier, is a chapter of its own, drawn but not
+   * entered unless `through` asks.
    */
   cut: 'depth' | 'fan-out' | 'folded' | 'steps' | 'screen' | 'component' | null;
   /** The event name a native event step arrived on (`onZipComplete`) — the first, when several land here. */
@@ -227,8 +228,19 @@ const WALK_KINDS: Edge['kind'][] = ['calls', 'instantiates', 'navigates', 'refer
 const JS_FAMILY: ReadonlySet<Language> = new Set<Language>(['javascript', 'typescript', 'tsx', 'jsx']);
 const NATIVE_FAMILY: ReadonlySet<Language> = new Set<Language>(['swift', 'objc', 'java', 'kotlin']);
 
-/** JS → native is a bridge call; native → JS is an event. Anything else is one family. */
-export function crossing(from: Language, to: Language): 'bridge' | 'event' | null {
+/**
+ * JS → native is a bridge call; native → JS is an event. Anything else is one
+ * family — unless the edge itself says which way it crosses: a synthesized
+ * channel (`resolution/tier-synthesizer.ts`) marks a client's request onto its
+ * own route `client→server`, a socket message back `server→client`, and a
+ * queue job or a bus event as a `channel` whose landing is an arrival; a
+ * server action called from a client file is marked `client→server` at
+ * request time, by its directive.
+ */
+export function crossing(from: Language, to: Language, meta: Record<string, unknown> = {}): 'bridge' | 'event' | null {
+  if (meta.tier === 'client→server') return 'bridge';
+  if (meta.tier === 'server→client') return 'event';
+  if (meta.channel === 'queue' || meta.channel === 'event' || meta.channel === 'socket') return 'event';
   if (JS_FAMILY.has(from) && NATIVE_FAMILY.has(to)) return 'bridge';
   if (NATIVE_FAMILY.has(from) && JS_FAMILY.has(to)) return 'event';
   return null;
@@ -470,6 +482,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
   const fanIn = new Map<string, number>();
   const chromeParents = new Map<string, number>();
   const fileScopeRefs = new Map<string, Edge[]>();
+  const fileScopeUnresolved = new Map<string, UnresolvedReference[]>();
 
   const stepFor = (node: Node, kind: WireStepKind, depth: number, extra: Partial<WireStep> = {}): StepRecord | null => {
     const existing = steps.get(node.id);
@@ -495,7 +508,9 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     const routeRoot = isRoute ? (roots.get(node.id) ?? null) : null;
     const record: StepRecord = {
       id: node.id,
-      kind: isRoute ? 'screen' : kind,
+      // A route is a screen or an endpoint — except one reached across a
+      // tier (`fetch('/api/users')` onto its own route), which is the crossing.
+      kind: isRoute && kind !== 'bridge' ? 'screen' : kind,
       anchor: false,
       node: toNodeRef(node),
       label: node.name,
@@ -708,7 +723,9 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     // Another screen is a chapter of its own: the Screens view draws the way
     // between screens, and a picture that walked on through Home would be the
     // whole app. Drawn as a boundary, entered on request.
-    if (step.kind === 'screen' && !step.anchor && !through) {
+    // An endpoint reached across a tier is the same kind of boundary: the
+    // request's own picture starts at its handler, entered on request.
+    if ((step.kind === 'screen' || (step.kind === 'bridge' && step.node?.kind === 'route')) && !step.anchor && !through) {
       step.cut = 'screen';
       continue;
     }
@@ -743,9 +760,14 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
       // from the FILE scope, at the wrapper's line. Lend the wrapper those
       // references, so the screen that renders `<Memoized/>` walks on into
       // what the component does.
+      // The same for a value a registration is written inside — `const
+      // worker = new Worker('q', async (job) => { … })`: the arrow's calls
+      // belong to the file scope and the constant spans them; a queue job
+      // lands on the constant, and the walk goes on into what the handler does.
       for (const fold of frontier) {
-        if (fold.node.kind !== 'component' || (bySource.get(fold.node.id)?.length ?? 0) > 0) continue;
-        for (const e of fileScopeFnRefsWithin(cg, fold.node, fileScopeRefs)) {
+        const value = fold.node.kind === 'constant' || fold.node.kind === 'variable';
+        if ((fold.node.kind !== 'component' && !value) || (bySource.get(fold.node.id)?.length ?? 0) > 0) continue;
+        for (const e of fileScopeEdgesWithin(cg, fold.node, fileScopeRefs, value)) {
           const list = bySource.get(fold.node.id) ?? [];
           list.push({ ...e, source: fold.node.id });
           bySource.set(fold.node.id, list);
@@ -759,17 +781,38 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
       if (unknownFanIn.length > 0) for (const [id, n] of cg.getFanIn(unknownFanIn)) fanIn.set(id, n);
 
       for (const fold of frontier) {
-        // Effects made by this node, folded or not.
+        // A call a synthesized channel already follows — the `fetch` that
+        // reaches its own route, the `queue.add` its consumer picks up — is
+        // the crossing, not also a call outside the index.
+        const channelLines = new Set<number>();
+        /** Per line, the last segment of each call a channel follows there (`add` of `emailQueue.add`). */
+        const channelCalls = new Map<number, Set<string>>();
+        for (const e of bySource.get(fold.node.id) ?? []) {
+          const m = e.metadata as Record<string, unknown> | undefined;
+          if (typeof m?.channel !== 'string' || typeof e.line !== 'number') continue;
+          channelLines.add(e.line);
+          if (typeof m.callee === 'string') {
+            const set = channelCalls.get(e.line) ?? new Set<string>();
+            set.add(m.callee.split(/[.:]/).pop() ?? m.callee);
+            channelCalls.set(e.line, set);
+          }
+        }
+        // Effects made by this node, folded or not. A value a handler is
+        // written inside (`const authUser = asyncHandler(async (req, res) =>
+        // …)`) made none itself — the arrow's calls belong to the file scope —
+        // so it is lent the file's, within its lines, as its call edges are.
         if (effectScans < MAX_EFFECT_SCANS) {
           effectScans++;
           let refs: UnresolvedReference[] = [];
           try {
             refs = cg.getUnresolvedReferencesFrom(fold.node.id);
+            if (refs.length === 0 && (fold.node.kind === 'constant' || fold.node.kind === 'variable')) refs = fileScopeRefsWithin(cg, fold.node, fileScopeUnresolved);
           } catch {
             refs = [];
           }
           for (const ref of [...refs].sort((a, b) => a.line - b.line || a.column - b.column)) {
             if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'instantiates') continue;
+            if (channelLines.has(ref.line)) continue;
             await effectLink(step, fold, { referenceName: ref.referenceName, referenceKind: ref.referenceKind, line: ref.line, column: ref.column }, null);
           }
         }
@@ -824,6 +867,14 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           // same case with the constant as the target.
           let target = targets.get(e.target)!;
           let retargeted = false;
+          // `api.get('/users')` resolves to the `api` constant — and
+          // `this.audioQueue.add('transcode')` to some `add` by name — AND,
+          // on the same line, a channel follows the call: the channel is the story.
+          if (typeof meta.channel !== 'string' && e.kind === 'calls' && channelLines.has(e.line ?? -1)) {
+            const written = typeof meta.refName === 'string' ? meta.refName : target.name;
+            const last = written.split(/[.:]/).pop() ?? written;
+            if (target.kind === 'constant' || target.kind === 'variable' || channelCalls.get(e.line!)?.has(last)) continue;
+          }
           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('.');
@@ -873,11 +924,28 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           const isCall = e.kind === 'calls' || e.kind === 'instantiates' || (e.kind === 'references' && meta.fnRef === true);
           const trigger = isCall ? await triggerAt(fold.node, { line: e.line, column: e.column }) : null;
 
+          // A server action, by its directive: a function in a `'use server'`
+          // file (or opening with the directive) called from a file that is
+          // not — the call crosses to the server, whatever the import says.
+          if (
+            e.provenance !== 'heuristic' &&
+            (e.kind === 'calls' || (e.kind === 'references' && meta.fnRef === true)) &&
+            (target.kind === 'function' || target.kind === 'method') &&
+            JS_FAMILY.has(target.language) &&
+            JS_FAMILY.has(fold.node.language)
+          ) {
+            const callee = await calls.directive(target);
+            if ((callee.file === 'server' || callee.own) && (await calls.directive(fold.node)).file !== 'server') {
+              meta.tier = 'client→server';
+              meta.channel = 'server-action';
+            }
+          }
+
           // What kind of step, if any, this edge arrives at.
           let kind: WireStepKind | null = null;
           let linkKind: WireStepLinkKind = 'calls';
           const extra: Partial<WireStep> = {};
-          if (target.kind === 'route') {
+          if (target.kind === 'route' && meta.tier !== 'client→server') {
             kind = 'screen';
             linkKind = 'navigates';
           } else {
@@ -886,8 +954,9 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
             // a synthesized channel's. A plain name-matched call across the
             // families (`arr.flat()` landing on a Swift `flat`) is noise, and
             // is neither drawn nor walked.
-            const cross = crossing(fold.node.language, target.language);
-            const evidenced = e.provenance === 'heuristic' || meta.bridge === 'react-native' || meta.resolvedBy === 'framework';
+            const cross = crossing(fold.node.language, target.language, meta);
+            const evidenced =
+              e.provenance === 'heuristic' || meta.bridge === 'react-native' || meta.resolvedBy === 'framework' || meta.channel === 'server-action';
             if (cross !== null && !evidenced) continue;
             if (cross === 'event') {
               kind = 'event';
@@ -937,8 +1006,15 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           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
-          // its href, a handler binding and an event channel pass nothing.
-          const site = a.linkKind === 'bridge' || a.linkKind === 'store' || a.linkKind === 'calls' ? await withArgs(a.site, fold.node, at) : a.site;
+          // its href, a handler binding and a native event channel pass
+          // nothing. A hop over a synthesized channel is a call in the source
+          // — `fetch('/api/users', {…})`, `emailQueue.add('welcome', {…})` —
+          // and its site reads as written.
+          let site = a.site;
+          if (typeof a.meta.channel === 'string' && a.meta.channel !== 'server-action') {
+            const written = await callAt(fold.node, at);
+            site = written && written.callee ? { ...a.site, text: written.callee, args: written.args } : await withArgs(a.site, fold.node, at);
+          } else if (a.linkKind === 'bridge' || a.linkKind === 'store' || a.linkKind === 'calls') site = await withArgs(a.site, fold.node, at);
           link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger);
           if (to.root !== null && !explored.has(to.id)) {
             explored.add(to.id);
@@ -1116,20 +1192,40 @@ function basename(p: string): string {
 }
 
 /**
- * Function-as-value references made at a file's top level within a node's
- * lines — what `const Memoized = memo(CaptureComponent)` leaves behind: the
- * reference belongs to the file scope, the wrapper node spans the line.
+ * Function-as-value references — and, for a value, calls — made at a file's
+ * top level within a node's lines: what `const Memoized = memo(CaptureComponent)`
+ * leaves behind (the reference belongs to the file scope, the wrapper node
+ * spans the line), and what `const worker = new Worker('q', async (job) =>
+ * { … })` leaves behind (the handler's calls belong to the file scope, the
+ * constant spans them).
  */
-function fileScopeFnRefsWithin(cg: CodeGraph, node: Node, memo: Map<string, Edge[]>): Edge[] {
+function fileScopeEdgesWithin(cg: CodeGraph, node: Node, memo: Map<string, Edge[]>, calls: boolean): Edge[] {
   let refs = memo.get(node.filePath);
   if (refs === undefined) {
     const file = cg.getNodesInFile(node.filePath).find((n) => n.kind === 'file');
     refs = file
-      ? cg.getOutgoingEdgesFrom([file.id], ['references']).filter((e) => (e.metadata as Record<string, unknown> | undefined)?.fnRef === true)
+      ? cg
+          .getOutgoingEdgesFrom([file.id], ['references', 'calls'])
+          .filter((e) => e.kind === 'calls' || (e.metadata as Record<string, unknown> | undefined)?.fnRef === true)
       : [];
     memo.set(node.filePath, refs);
   }
-  return refs.filter((e) => typeof e.line === 'number' && e.line >= node.startLine && e.line <= node.endLine);
+  return refs.filter((e) => (calls || e.kind === 'references') && typeof e.line === 'number' && e.line >= node.startLine && e.line <= node.endLine);
+}
+
+/** The file scope's unresolved calls within a value's lines — what a wrapped handler's arrow body leaves on the file node. */
+function fileScopeRefsWithin(cg: CodeGraph, node: Node, memo: Map<string, UnresolvedReference[]>): UnresolvedReference[] {
+  let refs = memo.get(node.filePath);
+  if (refs === undefined) {
+    const file = cg.getNodesInFile(node.filePath).find((n) => n.kind === 'file');
+    try {
+      refs = file ? cg.getUnresolvedReferencesFrom(file.id) : [];
+    } catch {
+      refs = [];
+    }
+    memo.set(node.filePath, refs);
+  }
+  return refs.filter((r) => r.line >= node.startLine && r.line <= node.endLine);
 }
 
 /** `push /capture`, `renders <Button>`, `via rn-event-channel`, `calls`. */
@@ -1143,6 +1239,7 @@ function siteText(edge: Edge, meta: Record<string, unknown>, target: Node): stri
   if (edge.kind === 'contains') return `defines ${target.name}`;
   if (edge.kind === 'instantiates') return `new ${target.name}`;
   if (meta.bridge === 'react-native') return `bridge ${typeof meta.module === 'string' ? meta.module + '.' : ''}${target.name}`;
+  if (meta.channel === 'http') return `${typeof meta.method === 'string' ? meta.method : 'GET'} ${typeof meta.href === 'string' ? meta.href : target.name}`;
   if (typeof meta.synthesizedBy === 'string') return `via ${meta.synthesizedBy}`;
   return `calls ${target.name}`;
 }
@@ -1152,8 +1249,13 @@ 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.channel === 'server-action') parts.push('server action');
+  if (meta.channel === 'http' && typeof meta.method === 'string') parts.push(`${meta.method} ${typeof meta.href === 'string' ? meta.href : ''}`.trim());
+  if (meta.tier === 'client→server') parts.push('to the server');
+  else if (meta.tier === 'server→client') parts.push('from the server');
   if (meta.resolvedBy === 'receiver-type') parts.push('by the receiver’s declared type');
-  if (typeof meta.event === 'string') parts.push(`event ${meta.event}`);
+  if (typeof meta.event === 'string') parts.push(`${meta.channel === 'queue' ? 'job' : meta.channel === 'socket' ? 'message' : 'event'} ${meta.event}`);
+  if (typeof meta.queue === 'string') parts.push(`queue ${meta.queue}`);
   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}`);
   return parts.join(' · ');

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

@@ -10,6 +10,7 @@
  * Symbol view into a parse of the repository.
  */
 
+import * as fs from 'fs';
 import type CodeGraph from '../../index';
 import type { Language } from '../../types';
 import {
@@ -107,8 +108,20 @@ export interface SiteReader {
   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>>;
+  /**
+   * The `'use server'` / `'use client'` directive a JS-family file opens with,
+   * and whether the definition itself opens with `'use server'` (a server
+   * action declared inline). Nothing for other languages or unreadable files.
+   */
+  directive(definition: { filePath: string; language: Language; startLine: number }): Promise<{ file: 'server' | 'client' | null; own: boolean }>;
 }
 
+const JS_FAMILY: ReadonlySet<string> = new Set(['javascript', 'typescript', 'tsx', 'jsx']);
+/** A file read for its directives, at most. */
+const MAX_DIRECTIVE_FILE = 512 * 1024;
+const FILE_DIRECTIVE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*(['"])use (server|client)\1/;
+const OWN_DIRECTIVE = /^\s*(['"])use server\1\s*;?\s*$/m;
+
 /**
  * Both readings of one call site — WHEN it runs and WITH WHAT — for the
  * endpoints that walk chains (Screens, Steps). One file resolution and one
@@ -117,6 +130,7 @@ export interface SiteReader {
  */
 export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites = 600): SiteReader {
   const files = new Map<string, { abs: string; language: Language } | null>();
+  const texts = new Map<string, string | null>();
   let sites = 0;
   const resolve = (caller: { filePath: string; language: Language }): { abs: string; language: Language } | null => {
     const posix = caller.filePath.replace(/\\/g, '/');
@@ -183,6 +197,31 @@ export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites =
       if (!file) return new Map();
       return memberTypesForFile(file.abs, file.language, definition.startLine);
     },
+    async directive(definition) {
+      // Not counted: a text read, cached per file, no tree.
+      const none = { file: null, own: false } as const;
+      if (!JS_FAMILY.has(definition.language)) return none;
+      const file = resolve(definition);
+      if (!file) return none;
+      let text = texts.get(file.abs);
+      if (text === undefined) {
+        try {
+          text = fs.statSync(file.abs).size <= MAX_DIRECTIVE_FILE ? fs.readFileSync(file.abs, 'utf8') : null;
+        } catch {
+          text = null;
+        }
+        texts.set(file.abs, text);
+      }
+      if (text === null) return none;
+      const head = FILE_DIRECTIVE.exec(text);
+      const fileDirective = head ? (head[2] as 'server' | 'client') : null;
+      let own = false;
+      if (definition.startLine > 0) {
+        const lines = text.split('\n');
+        own = OWN_DIRECTIVE.test(lines.slice(definition.startLine - 1, definition.startLine + 3).join('\n'));
+      }
+      return { file: fileDirective, own };
+    },
   };
 }
 

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

@@ -3,7 +3,9 @@
    * One step on the Steps view. The box is the Screens view's screen box with
    * a kind: a screen is drawn exactly as there; a handler is a plain box; a
    * native call or a native event carries an accent rule on its left, where
-   * the language changes under the code; a store action sits on `--paper-2`;
+   * the language changes under the code — and so does an endpoint the code
+   * crosses to (`⇢ POST /api/users`) or a job, an event, a message arriving;
+   * a store action sits on `--paper-2`;
    * a call that leaves the index is dashed, like a trigger no screen reaches
    * on the Screens view — a place the graph cannot follow into. The anchor
    * carries the entry mark. A step the walk was cut at ends its name with an
@@ -43,7 +45,7 @@
       case 'steps':
         return ' The picture reached its size limit here.';
       case 'screen':
-        return ' Another screen — a chapter of its own. Start here to see what happens on it.';
+        return ` Another ${kindWord('screen', node.project, step)} — a chapter of its own. Start here to see what happens on it.`;
       case 'component':
         return ' The event lands in a component of another screen — a picture of its own. Start here to see it.';
       default:

+ 4 - 0
ui/src/lib/steps-model.ts

@@ -86,6 +86,8 @@ export function kindWords(kind: WireStep['kind'], project: ProjectKind = 'app',
     case 'trigger':
       return ['handler', 'handlers'];
     case 'bridge':
+      // An endpoint reached across a tier is a call to the server wherever it is.
+      if (step?.screen?.endpoint) return ['call to the server', 'calls to the server'];
       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 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'];
@@ -153,6 +155,8 @@ export function stepSub(step: WireStep, project: ProjectKind = 'app'): string {
       // The event before the file: `onPress · <Button> · index.tsx`.
       return step.trigger ? `${triggerWords(step.trigger)} · ${file}` : `handler · ${file}`;
     case 'bridge':
+      // An endpoint the code crosses to says its handler, as an endpoint box does.
+      if (step.screen) return step.sub;
       return `${project === 'app' ? 'native' : project === 'web' ? 'server' : 'another tier'} · ${file}`;
     case 'event':
       return `${step.label} · ${file}`;

+ 4 - 2
ui/src/lib/wire.ts

@@ -750,7 +750,8 @@ export interface WireStep {
   depth: number;
   /**
    * Why the walk did not go on from this step: a cap (`depth`, `fan-out`,
-   * `folded`, `steps`), or `screen` — another screen, drawn as a boundary.
+   * `folded`, `steps`), or `screen` — another screen, or an endpoint reached
+   * across a tier, drawn as a boundary.
    */
   cut: 'depth' | 'fan-out' | 'folded' | 'steps' | 'screen' | 'component' | null;
   /** The event name a native event step arrived on — the first, when several land here. */
@@ -760,7 +761,8 @@ export interface WireStep {
   /** For a handler: what fires it. */
   trigger?: WireStepTrigger;
   /**
-   * For a screen or an endpoint: its path and the symbol that serves it.
+   * For a screen or an endpoint — also a `bridge` step that is an endpoint
+   * reached across a tier: 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).
    */

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio