Prechádzať zdrojové kódy

feat(routes): FastAPI prefixes, ASP.NET endpoint groups, wrapped server actions on the Screens tab

- python.ts postExtract: APIRouter(prefix=) and literal include_router(prefix=) composed down the include tree (module import, alias, local); a computed prefix leaves that mount alone; full-stack-fastapi-template 23 routes named by path
- csharp.ts: handler-first MapPost(Handler[, "path"]) under the endpoint-group class, the app's $"/api/{groupName}" head read in postExtract, RoutePrefix honoured; detection covers Endpoints/ files; CleanArchitecture 10 routes
- tier-synthesizer: a type argument between a client call and its parentheses (useSWR<T>(…), ky.get<T>(…)); recorded callee without it
- screens.ts: a file-scope navigation attributed to the value spanning it; a value nothing calls attributed to the functions mentioning it in importing files (request-time source read, bounded); steps.ts lends navigates edges to a value root
- tests: servers fixture (FastAPI prefixed routers, ASP.NET endpoint group end to end), frameworks.test.ts (group form, RoutePrefix), cross-tier (generic useSWR)
- docs: CHANGELOG, plan, playbook rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
Colby McHenry 1 týždeň pred
rodič
commit
bc45e9071d

+ 8 - 0
CHANGELOG.md

@@ -36,6 +36,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - **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.
 
+- **FastAPI routes are named by the path a request takes.** `APIRouter(prefix="/items")` and a literal `app.include_router(router, prefix="/api/v1")` — nested through an aggregate router, through a module import or an alias — now compose onto the route names (`GET /api/v1/items/{id}` instead of `GET /{id}`); a prefix that is a setting rather than a string leaves that mount alone rather than guessing.
+
+- **A client call with a type argument binds too.** `useSWR<Team>('/api/team', fetcher)` and `ky.get<User>('/api/users/1')` reach their routes like the untyped forms.
+
+- **ASP.NET Minimal API endpoint groups are routes.** The handler-first form — `groupBuilder.MapPost(CreateTodoItem)`, `MapPut(UpdateTodoItem, "{id}")` inside an `IEndpointGroup` / `EndpointGroupBase` class (the Clean Architecture template and its descendants) — now registers `POST /api/TodoItems` and `PUT /api/TodoItems/{id}`, with the `/api/` head read from the app's own `MapGroup($"/api/{groupName}")` and a class's `RoutePrefix` honoured, each bound to its handler so the Steps tab starts there and lists its `TypedResults` replies by status code.
+
+- **A server action written through a wrapper starts its transition on the right page.** `export const signIn = validatedAction(schema, async (data) => { … redirect('/dashboard') })` — the arrow inside is no symbol of its own — now belongs to `signIn` on the Screens tab, and `signIn` is attributed to the page whose component hands it to `useActionState(signIn, …)`, read from the source when the graph holds no such edge.
+
 - **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.

+ 36 - 0
__tests__/frameworks.test.ts

@@ -1424,6 +1424,42 @@ public IActionResult ListUsers()
     expect(nodes[0].name).toBe('GET /users');
     expect(references[0].referenceName).toBe('ListUsers');
   });
+
+  it('extracts the handler-first endpoint-group form under the class, with the optional path', () => {
+    const src = `
+public class TodoItems : IEndpointGroup
+{
+    public static void Map(RouteGroupBuilder groupBuilder)
+    {
+        groupBuilder.RequireAuthorization();
+        groupBuilder.MapPost(CreateTodoItem);
+        groupBuilder.MapPut(UpdateTodoItem, "{id}");
+        groupBuilder.MapDelete(DeleteTodoItem, "{id}");
+    }
+    public static async Task<Created<int>> CreateTodoItem(ISender sender, CreateTodoItemCommand command) { }
+}
+`;
+    const { nodes, references } = aspnetResolver.extract!('Web/Endpoints/TodoItems.cs', src);
+    expect(nodes.map((n) => n.name)).toEqual(['POST /TodoItems', 'PUT /TodoItems/{id}', 'DELETE /TodoItems/{id}']);
+    expect(nodes.map((n) => n.startLine)).toEqual([7, 8, 9]);
+    expect(references.map((r) => r.referenceName)).toEqual(['CreateTodoItem', 'UpdateTodoItem', 'DeleteTodoItem']);
+    expect(nodes[0]!.qualifiedName).toBe('Web/Endpoints/TodoItems.cs::group:TodoItems:POST:');
+  });
+
+  it('a class with its own RoutePrefix literal names its routes under it', () => {
+    const src = `
+public class TodoLists : IEndpointGroup
+{
+    public static string RoutePrefix => "/api/todo-lists";
+    public static void Map(RouteGroupBuilder group)
+    {
+        group.MapGet(GetTodoLists);
+    }
+}
+`;
+    const { nodes } = aspnetResolver.extract!('TodoLists.cs', src);
+    expect(nodes.map((n) => n.name)).toEqual(['GET /api/todo-lists']);
+  });
 });
 
 import { vaporResolver } from '../src/resolution/frameworks/swift';

+ 90 - 1
__tests__/ui-steps-api-servers.test.ts

@@ -159,7 +159,27 @@ beforeAll(async () => {
   write('api/deps.py', 'def get_current_user():\n    return None\n\nSessionDep = None\n');
   write('api/models.py', 'class Item:\n    pass\n\nclass ItemCreate:\n    pass\n');
   write('api/tasks.py', 'from celery import shared_task\n\n@shared_task\ndef send_welcome(item_id):\n    return item_id\n');
-  write('api/main.py', 'from fastapi import FastAPI\nfrom .items import router\napp = FastAPI()\napp.include_router(router)\n');
+  // A router with its own prefix, included by an aggregate router, mounted at a literal prefix — and one at a computed one.
+  write(
+    'api/orders.py',
+    'from fastapi import APIRouter\n' +
+      '\n' +
+      'router = APIRouter(prefix="/orders", tags=["orders"])\n' +
+      '\n' +
+      '@router.get("/")\n' +
+      'def list_orders():\n' +
+      '    return []\n' +
+      '\n' +
+      '@router.get("/{order_id}")\n' +
+      'def get_order(order_id: int):\n' +
+      '    return order_id\n'
+  );
+  write('api/v1.py', 'from fastapi import APIRouter\nfrom .orders import router as orders_router\napi_router = APIRouter()\napi_router.include_router(orders_router)\n');
+  write(
+    'api/main.py',
+    'from fastapi import FastAPI\nfrom .items import router\nfrom .v1 import api_router\nfrom .config import settings\napp = FastAPI()\napp.include_router(router)\napp.include_router(api_router, prefix="/api/v1")\napp.include_router(api_router, prefix=settings.LEGACY)\n'
+  );
+  write('api/config.py', 'settings = None\n');
   write('requirements.txt', 'fastapi\nsqlmodel\ncelery\n');
   // ---- Spring: a repository typed on a field, ResponseEntity replies, a guard annotation.
   write(
@@ -188,6 +208,48 @@ beforeAll(async () => {
     'package demo;\nimport org.springframework.data.jpa.repository.JpaRepository;\npublic interface OwnerRepository extends JpaRepository<Owner, Integer> {\n}\n'
   );
   write('src/main/java/demo/Owner.java', 'package demo;\npublic class Owner {\n  private String name;\n  public String getName() { return name; }\n}\n');
+  // ---- ASP.NET Minimal API, endpoint-group style: the class is the group,
+  // the handler is the first argument, the app's extension supplies `/api/`.
+  write(
+    'src/Web/Endpoints/TodoItems.cs',
+    'using Microsoft.AspNetCore.Http.HttpResults;\n' +
+      'namespace Demo.Web.Endpoints;\n' +
+      'public class TodoItems : IEndpointGroup\n' +
+      '{\n' +
+      '    public static void Map(RouteGroupBuilder groupBuilder)\n' +
+      '    {\n' +
+      '        groupBuilder.RequireAuthorization();\n' +
+      '        groupBuilder.MapPost(CreateTodoItem);\n' +
+      '        groupBuilder.MapPut(UpdateTodoItem, "{id}");\n' +
+      '    }\n' +
+      '    public static async Task<Created<int>> CreateTodoItem(ISender sender, CreateTodoItemCommand command)\n' +
+      '    {\n' +
+      '        var id = await sender.Send(command);\n' +
+      '        return TypedResults.Created($"/{nameof(TodoItems)}/{id}", id);\n' +
+      '    }\n' +
+      '    public static async Task<Results<NoContent, BadRequest>> UpdateTodoItem(ISender sender, int id, UpdateTodoItemCommand command)\n' +
+      '    {\n' +
+      '        if (id != command.Id)\n' +
+      '            return TypedResults.BadRequest();\n' +
+      '        await sender.Send(command);\n' +
+      '        return TypedResults.NoContent();\n' +
+      '    }\n' +
+      '}\n'
+  );
+  write(
+    'src/Web/Infrastructure/WebApplicationExtensions.cs',
+    'using Microsoft.AspNetCore.Builder;\n' +
+      'namespace Demo.Web.Infrastructure;\n' +
+      'public static class WebApplicationExtensions\n' +
+      '{\n' +
+      '    public static WebApplication MapEndpoints(this WebApplication app)\n' +
+      '    {\n' +
+      '        var groupName = "x";\n' +
+      '        var group = app.MapGroup($"/api/{groupName}").WithTags(groupName);\n' +
+      '        return app;\n' +
+      '    }\n' +
+      '}\n'
+  );
   cg = CodeGraph.initSync(tmpDir);
   await cg.indexAll();
 });
@@ -303,6 +365,14 @@ describe('NestJS', () => {
 });
 
 describe('FastAPI', () => {
+  it('names a mounted router’s routes by the path a request takes — the include prefix, then the router’s own', () => {
+    const names = cg.getNodesByKind('route').map((r) => r.name);
+    expect(names).toContain('GET /api/v1/orders');
+    expect(names).toContain('GET /api/v1/orders/{order_id}');
+    expect(names).toContain('POST /items');
+    expect(names).not.toContain('GET /');
+  });
+
   it('reads the dependency on the route, the session writes, the 422 and the Celery task', async () => {
     const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /items').id }));
     const anchor = p.steps.find((s) => s.anchor)!;
@@ -320,6 +390,25 @@ describe('FastAPI', () => {
   });
 });
 
+describe('ASP.NET endpoint groups', () => {
+  it('names the group’s routes under the app’s /api/ head and starts the walk at the handler, with its replies', async () => {
+    const names = cg.getNodesByKind('route').map((r) => r.name);
+    expect(names).toContain('POST /api/TodoItems');
+    expect(names).toContain('PUT /api/TodoItems/{id}');
+    const p = await buildSteps(cg, tmpDir, q({ anchor: route('PUT /api/TodoItems/{id}').id }));
+    const anchor = p.steps.find((s) => s.anchor)!;
+    expect(anchor.sub).toBe('UpdateTodoItem');
+    expect(anchor.trigger).toMatchObject({ kind: 'request', name: 'PUT', of: '/api/TodoItems/{id}' });
+    const res = effect(p, 'response')!;
+    expect(res.label).toBe('204 · 400');
+    const rows = p.links.find((l) => l.to === res.id)!.sites.map((s) => [s.status, s.when]);
+    expect(rows).toEqual([
+      [400, 'id != command.Id'],
+      [204, 'id == command.Id'],
+    ]);
+  });
+});
+
 describe('Spring', () => {
   it('types the repository off the field, reads the annotation guard, and both replies with their codes', async () => {
     const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /owners/new').id }));

+ 8 - 1
__tests__/ui-steps-cross-tier.test.ts

@@ -167,12 +167,14 @@ beforeAll(async () => {
   write(
     'apps/web/components/orders.tsx',
     "'use client'\n" +
+      "import useSWR from 'swr'\n" +
       'export function Orders() {\n' +
+      "  const { data } = useSWR<Order[]>('/api/v1/orders', fetcher)\n" +
       '  async function loadOrders() {\n' +
       "    const res = await fetch('/api/v1/orders')\n" +
       '    return res.json()\n' +
       '  }\n' +
-      '  return null\n' +
+      '  return data\n' +
       '}\n'
   );
   write(
@@ -345,6 +347,11 @@ describe('express mounts: a mounted router’s routes are named by the path a re
     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');
+    // `useSWR<Order[]>('/api/v1/orders')` — a type argument between the name and the call.
+    const hook = synthesized(sym('Orders'), 'http-client');
+    expect(hook).toHaveLength(1);
+    expect(hook[0]!.target).toBe(route('GET /api/v1/orders').id);
+    expect((hook[0]!.metadata as Record<string, unknown>).callee).toBe('useSWR');
   });
 });
 

+ 2 - 0
docs/design/dynamic-dispatch-coverage-playbook.md

@@ -280,6 +280,8 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
 | TypeScript/JS | Next.js (App Router + Pages Router) | page → `<Link>` / `router.push` / `redirect` → page; page load → data; client component → `'use server'` action → DB → `redirect`; `route.ts` handler → DB → response | R + S | ✅ 2026-08-28 (`frameworks/nextjs.ts`, `next-router-synthesizer.ts`, Steps `load` trigger + server-action crossing): fixture `__tests__/nextjs.test.ts` end to end (Screens `routed`, the push attributed back under its condition, the action's redirect); `leerob/next-saas-starter` indexes its pages and links. 🔬 agent A/B (`--model sonnet`, ≥2 runs/arm) not run yet |
 | TypeScript/JS | Express + React (MERN monorepo) | client `axios` / `fetch` literal path → own route → handler → Mongoose → response rows | S + R | ✅ 2026-08-28 (`tier-synthesizer.ts` `http-client` + Express mounts / chained `router.route()` / wrapped `const h = asyncHandler(…)` handlers / nested `package.json` detection): `bradtraversy/proshop_mern` 49 routes (19 pages + 30 endpoints), 23 client→route edges, every one spot-checked correct; `login → ⇢ POST /api/users/login → User.findOne → 401 rows → jwt.sign`. Node count stable across re-index. 🔬 A/B not run |
 | TypeScript/JS | NestJS queues / events / sockets | `queue.add('job')` → `@Process('job')`; `emit('x')` → `@OnEvent('x')`; `socket.emit` → `@SubscribeMessage` and `server.emit` → `socket.on` | S | ✅ 2026-08-28 (`tier-synthesizer.ts` `queue-job`, `event-bus`): `nestjs/nest` `sample/26-queues` (`transcode` → `handleTranscode`) and `sample/30-event-emitter` (`order.created` → its listener) exact, 0 wrong edges after the `e2e/` and generic-event guards; `immich-app/immich` 0 edges — a generated SDK client and a wrapped queue API carry no literal, so silence (correct). 🔬 A/B not run |
+| Python | FastAPI (prefixed routers) | request → `Depends` → handler → session → `HTTPException` rows | R | ✅ 2026-08-28 `python.ts` `postExtract` composes `APIRouter(prefix=)` + literal `include_router(prefix=)`: `fastapi/full-stack-fastapi-template` 23 routes named `GET /items/{id}`, `POST /login/access-token` (were `GET /`); a computed mount prefix is skipped. 🔬 A/B not run |
+| C# | ASP.NET Minimal API endpoint groups | request → group handler → `ISender.Send` → `TypedResults` rows | R | ✅ 2026-08-28 `csharp.ts` handler-first `MapPost(Handler[, path])` under the class + `$"/api/{groupName}"` head (`jasontaylordev/CleanArchitecture` shape); fixture end to end in `ui-steps-api-servers.test.ts` (`PUT /api/TodoItems/{id}` → `204 · 400` rows); `jasontaylordev/CleanArchitecture` 10 routes, all the app's endpoints (were 0). 🔬 A/B not run |
 
 ### Retrieval A/Bs that are not coverage work
 

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

@@ -126,7 +126,7 @@ All three are **JS-family only** (`supportsBranchGuards`), Swift for guards. Pyt
 | **Next.js** (`frameworks/react.ts`) | `pages/**` and `app/**` files with `export default` → a route named by path (`/blog/:slug`) | none (the page component is the default export in the same file, not linked from the route) | none — no `navigates` for `<Link href>`, `router.push`, `redirect()` | `app/api/**/route.ts` handlers (`export async function GET`) are **not** routes; server actions (`'use server'`) unknown; `middleware.ts` unknown |
 | **React Router** | `<Route path component={C}/>` / `element={<C/>}`, object data-router (literal form) | `references` to the component | none | |
 | **SvelteKit / Vue / Nuxt / Astro** | file routes | `svelteKitLoadEdges`, `vueTemplateEdges`, Pinia/Vuex channels | none | |
-| **FastAPI / Django / Flask / Spring / Laravel / Rails / Gin / Axum** | routes + handler edges (resolvers) | yes | — | **no WHEN / arguments / triggers** (language rules missing) |
+| **FastAPI / Django / Flask / Spring / Laravel / Rails / Gin / Axum** | routes + handler edges (resolvers); FastAPI `APIRouter(prefix=)` + literal `include_router(prefix=)` composed since 2026-08-28 (`python.ts` `postExtract`) | yes | — | WHEN / arguments / triggers built for Python, Java, Kotlin, C#, Go, C (P5) |
 
 What the viewer does with that today: **Entry points** lists every route with its handler (this is the
 "Screens" of an API today); **Screens** answers "no screen navigation" for all of them; **Steps**
@@ -286,7 +286,11 @@ reading `tier` / `channel`, an endpoint reached across a tier drawn as a bridge
 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
+Later the same day: FastAPI `APIRouter(prefix=)` + literal `include_router(prefix=)` composed (`python.ts` `postExtract`;
+`fastapi/full-stack-fastapi-template` 23 routes now read `GET /items/{id}` instead of `GET /`; its `settings.API_V1_STR`
+mount is skipped, not guessed) and the ASP.NET endpoint-group form (`csharp.ts`: `groupBuilder.MapPost(Handler[, "path"])`
+under the class, the app's `$"/api/{groupName}"` head read in `postExtract`, `RoutePrefix` honoured — the
+`jasontaylordev/CleanArchitecture` shape). 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
@@ -343,6 +347,13 @@ dashed `navigates` from the component), `scoreMatch` accepting `:param` / `:all*
 Next page in `steps.ts`, `{ status: 201 }` read off the call site for response rows. Test `__tests__/nextjs.test.ts`.
 Route-handler references resolve by name with the same-file preference (`GET` / `POST` are common names). **Not built:** `revalidatePath`
 as a refresh, `middleware.ts` `config.matcher` as a global guard, a helper's return value as a destination (Expo has it).
+**Verified on `leerob/next-saas-starter`:** 8 pages + 4 endpoints, Screens routed (12 screens, 15 links), `<Link>`s in the
+layout, `redirect()` in the actions, `NextResponse.redirect` in the middleware and the checkout handler all bound. Two
+gaps it showed, both the wrapped-arrow idiom: `export const signIn = validatedAction(schema, async (data) => { … })` holds
+its `redirect` on the FILE node (Screens now re-attributes a file-scope navigation to the value spanning it), and
+`useActionState(signIn, …)` leaves no function-as-value edge (a plain call argument — Screens now falls back to the
+functions that MENTION the value in the files importing it, read from the source; the principled fix is an extractor
+fnRef rule for `useActionState` / `useFormState` / `startTransition` arguments, with the Rust kernel twin).
 
 *Where:* `resolution/frameworks/react.ts` (split a `nextjs.ts` out of it — the pages/app routing is
 already there), a `next-router-synthesizer.ts` modelled on `expo-router-synthesizer.ts`.

+ 89 - 2
src/resolution/frameworks/csharp.ts

@@ -48,14 +48,17 @@ export const aspnetResolver: FrameworkResolver = {
     // root-only checks above miss (e.g. realworld: Features/*/FooController.cs).
     // `.csproj` often isn't in the indexed source set, so source-scan is the
     // reliable signal.
+    // Endpoint-group apps (`Endpoints/TodoItems.cs : IEndpointGroup`) have
+    // none of those names: their files, and the extension that maps them, count.
     for (const file of allFiles) {
-      if (!/(?:Controller|Program|Startup)\.cs$/.test(file)) continue;
+      if (!/(?:Controller|Program|Startup|Endpoints?|Extensions)\.cs$/.test(file) && !/(?:^|\/)Endpoints\/[^/]+\.cs$/.test(file)) continue;
       const c = context.readFile(file);
       if (c && (
         /\[(?:ApiController|Route|Http(?:Get|Post|Put|Patch|Delete))\b/.test(c) ||
         c.includes('ControllerBase') || c.includes(': Controller') ||
         c.includes('MapControllers') || c.includes('WebApplication') ||
-        c.includes('Microsoft.AspNetCore')
+        c.includes('Microsoft.AspNetCore') || c.includes('IEndpointGroup') ||
+        c.includes('EndpointGroupBase') || c.includes('RouteGroupBuilder')
       )) return true;
     }
     return false;
@@ -221,8 +224,92 @@ export const aspnetResolver: FrameworkResolver = {
       }
     }
 
+    // Minimal APIs, handler first — the endpoint-group idiom (Jason Taylor's
+    // Clean Architecture template and its descendants):
+    //
+    //   public class TodoItems : IEndpointGroup {
+    //     public static void Map(RouteGroupBuilder group) {
+    //       group.MapPost(CreateTodoItem);
+    //       group.MapPut(UpdateTodoItem, "{id}");
+    //
+    // The class is the group, the handler is the first argument, the path the
+    // optional second. The group's prefix is the app's convention
+    // (`$"/api/{groupName}"`, read repo-wide in postExtract) or the class's own
+    // `RoutePrefix` literal; here the route is named under the class.
+    const groupRegex = /\.Map(Get|Post|Put|Patch|Delete)\s*\(\s*([A-Za-z_]\w*)\s*(?:,\s*"([^"]*)")?\s*\)/g;
+    const routePrefixLiteral = /\bRoutePrefix\s*(?:=>|=)\s*"([^"]+)"/.exec(safe);
+    while ((match = groupRegex.exec(safe)) !== null) {
+      const [, verb, handlerName, sub] = match;
+      const method = verb!.toUpperCase();
+      const line = safe.slice(0, match.index).split('\n').length;
+      const before = safe.slice(0, match.index);
+      const classMatch = [...before.matchAll(/\bclass\s+([A-Za-z_]\w*)/g)].pop();
+      if (!classMatch) continue;
+      const group = classMatch[1]!;
+      const routePath = joinCsPath(routePrefixLiteral ? routePrefixLiteral[1]! : `/${group}`, sub ?? '');
+      const routeNode: Node = {
+        id: `route:${filePath}:${line}:${method}:${routePath}`,
+        kind: 'route',
+        name: `${method} ${routePath}`,
+        qualifiedName: `${filePath}::group:${group}:${method}:${sub ?? ''}`,
+        filePath,
+        startLine: line,
+        endLine: line,
+        startColumn: 0,
+        endColumn: match[0].length,
+        language: 'csharp',
+        updatedAt: now,
+      };
+      nodes.push(routeNode);
+      references.push({
+        fromNodeId: routeNode.id,
+        referenceName: handlerName!,
+        referenceKind: 'references',
+        line,
+        column: 0,
+        filePath,
+        language: 'csharp',
+      });
+    }
+
     return { nodes, references };
   },
+
+  /**
+   * The endpoint-group prefix convention, read once from the app: the
+   * `MapGroup($"/api/{groupName}")` that registers every `IEndpointGroup`
+   * (or `EndpointGroupBase`) under a head — `/api/` — before the class name.
+   * A group route extracted as `POST /TodoItems` becomes `POST /api/TodoItems`;
+   * a class with its own `RoutePrefix` literal already has its path. Idempotent:
+   * `qualifiedName` keeps the group and the sub-path.
+   */
+  postExtract(context: ResolutionContext): Node[] {
+    let head: string | null = null;
+    let looked = 0;
+    for (const file of context.getAllFiles()) {
+      if (!file.endsWith('.cs')) continue;
+      const content = context.readFile(file);
+      if (!content || !content.includes('MapGroup')) continue;
+      if (++looked > 400) break;
+      const m = /\$"([^"{]*)\{\s*(?:groupName|type\.Name|name|prefix)\s*\}"/.exec(content) ?? /MapGroup\(\s*\$"([^"{]*)\{/.exec(content);
+      if (m) {
+        head = m[1]!;
+        break;
+      }
+    }
+    if (!head || head === '/' || head === '') return [];
+    const updates: Node[] = [];
+    for (const route of context.getNodesByKind('route')) {
+      if (route.language !== 'csharp') continue;
+      const q = /::group:([A-Za-z_]\w*):([A-Z]+):(.*)$/.exec(route.qualifiedName);
+      if (!q) continue;
+      const content = context.readFile(route.filePath);
+      if (content && /\bRoutePrefix\s*(?:=>|=)\s*"/.test(content)) continue;
+      const name = `${q[2]} ${joinCsPath(head.replace(/\/+$/, '') + '/' + q[1], q[3]!)}`;
+      if (name !== route.name) updates.push({ ...route, name });
+    }
+    return updates;
+  },
 };
 
 /** Join a class-level [Route] prefix and an action's path into one normalized `/path`. */

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

@@ -7,6 +7,7 @@
 import { Node } from '../../types';
 import { FrameworkResolver, UnresolvedRef, ResolutionContext, FrameworkExtractionResult } from '../types';
 import { stripCommentsForRegex } from '../strip-comments';
+import { resolveImportPath } from '../import-resolver';
 
 export const djangoResolver: FrameworkResolver = {
   name: 'django',
@@ -286,8 +287,145 @@ export const fastapiResolver: FrameworkResolver = {
       language: 'python',
     });
   },
+
+  /**
+   * Cross-file finalization for prefixes. A router's routes are written
+   * relative to where it is mounted —
+   *
+   *   router = APIRouter(prefix="/items")                 # items.py
+   *   api_router.include_router(items.router)              # api.py
+   *   app.include_router(api_router, prefix="/api/v1")     # main.py
+   *   @router.get("/{id}")                                 # → GET /api/v1/items/{id}
+   *
+   * — and per-file `extract()` can only see `GET /{id}`. This pass reads every
+   * `APIRouter(prefix=…)` and every `X.include_router(router, prefix=…)` whose
+   * prefix is a literal (a `settings.API_V1_STR` is unknown and that mount is
+   * left alone), resolves the included router to the file and variable it is
+   * (`items.router` through the module import, `items_router` through the
+   * alias, a local name), composes the prefixes down the tree, and renames the
+   * routes decorated with each router to the path a request takes. `id` and
+   * `qualifiedName` are preserved, so the pass is idempotent on every sync.
+   */
+  postExtract(context) {
+    interface Mount {
+      fromVar: string;
+      prefix: string;
+      target: { file: string; variable: string };
+    }
+    const own = new Map<string, Map<string, string>>(); // file → router variable → APIRouter(prefix=)
+    const mounts = new Map<string, Mount[]>(); // mounting file → mounts
+    const receivers = new Map<string, Map<number, string>>(); // file → decorator line → router variable
+    for (const file of context.getAllFiles()) {
+      if (!file.endsWith('.py')) continue;
+      const content = context.readFile(file);
+      if (!content || (!content.includes('APIRouter') && !content.includes('include_router'))) continue;
+      const safe = stripCommentsForRegex(content, 'python');
+      const vars = new Map<string, string>();
+      const decl = /\b([A-Za-z_]\w*)\s*=\s*APIRouter\s*\(([^)]*)\)/g;
+      let m: RegExpExecArray | null;
+      while ((m = decl.exec(safe)) !== null) {
+        const p = /\bprefix\s*=\s*['"]([^'"]*)['"]/.exec(m[2]!);
+        vars.set(m[1]!, p ? p[1]! : '');
+      }
+      own.set(file, vars);
+      const byLine = new Map<number, string>();
+      const deco = /@([A-Za-z_]\w*)\.(?:get|post|put|patch|delete|options|head)\s*\(/g;
+      while ((m = deco.exec(safe)) !== null) byLine.set(safe.slice(0, m.index).split('\n').length, m[1]!);
+      receivers.set(file, byLine);
+      const inc = /\b([A-Za-z_]\w*)\.include_router\s*\(\s*([A-Za-z_][\w.]*)\s*((?:,[^)]*)?)\)/g;
+      while ((m = inc.exec(safe)) !== null) {
+        const rest = m[3] ?? '';
+        const literal = /\bprefix\s*=\s*['"]([^'"]*)['"]/.exec(rest);
+        if (!literal && /\bprefix\s*=/.test(rest)) continue; // a computed prefix: unknown, not guessed
+        const target = includedRouter(m[2]!, file, vars, context);
+        if (!target) continue;
+        const list = mounts.get(file) ?? [];
+        list.push({ fromVar: m[1]!, prefix: literal ? literal[1]! : '', target });
+        mounts.set(file, list);
+      }
+    }
+    if (mounts.size === 0 && ![...own.values()].some((vars) => [...vars.values()].some((p) => p !== ''))) return [];
+
+    // The include-derived base of each (file, variable): the mounting router's
+    // base, plus its own prefix, plus the mount's — to a fixed point.
+    const key = (file: string, variable: string): string => `${file}\0${variable}`;
+    let base = new Map<string, string>();
+    for (let round = 0; round < 8; round++) {
+      const next = new Map<string, string>();
+      for (const [file, list] of mounts) {
+        for (const mount of list) {
+          const fromBase = base.get(key(file, mount.fromVar)) ?? '';
+          const fromOwn = own.get(file)?.get(mount.fromVar) ?? '';
+          const full = joinPyPaths(joinPyPaths(fromBase, fromOwn), mount.prefix);
+          const k = key(mount.target.file, mount.target.variable);
+          const seen = next.get(k);
+          if (seen !== undefined && seen !== full) next.set(k, '\0'); // two mounts, two paths: ambiguous
+          else next.set(k, full);
+        }
+      }
+      for (const [k, v] of [...next]) if (v === '\0') next.delete(k);
+      let changed = next.size !== base.size;
+      if (!changed) for (const [k, v] of next) if (base.get(k) !== v) changed = true;
+      base = next;
+      if (!changed) break;
+    }
+
+    const updates: Node[] = [];
+    for (const [file, byLine] of receivers) {
+      const vars = own.get(file) ?? new Map<string, string>();
+      for (const route of context.getNodesInFile(file)) {
+        if (route.kind !== 'route') continue;
+        const variable = byLine.get(route.startLine);
+        if (!variable) continue;
+        const prefix = joinPyPaths(base.get(key(file, variable)) ?? '', vars.get(variable) ?? '');
+        if (prefix === '' || prefix === '/') continue;
+        const sep = route.qualifiedName.indexOf('::');
+        const colon = sep < 0 ? -1 : route.qualifiedName.indexOf(':', sep + 2);
+        if (colon < 0) continue;
+        const method = route.qualifiedName.slice(sep + 2, colon);
+        const original = route.qualifiedName.slice(colon + 1);
+        const name = `${method} ${joinPyPaths(prefix, original)}`.trim();
+        if (name !== route.name) updates.push({ ...route, name });
+      }
+    }
+    return updates;
+  },
 };
 
+/** `/api/v1` + `/items` → `/api/v1/items`; `/items` + `` → `/items`; `` + `` → ``. */
+function joinPyPaths(prefix: string, path: string): string {
+  const a = prefix.replace(/\/+$/, '');
+  const b = path.replace(/^\/+/, '');
+  if (!a) return b ? `/${b}` : '';
+  return b ? `${a}/${b}` : a;
+}
+
+/**
+ * The file and variable an `include_router` argument names: `items.router`
+ * through the module's import, `items_router` through an alias import, or a
+ * router defined in the same file.
+ */
+function includedRouter(
+  expr: string,
+  file: string,
+  local: Map<string, string>,
+  context: ResolutionContext
+): { file: string; variable: string } | null {
+  const segs = expr.split('.');
+  const head = segs[0]!;
+  if (segs.length === 1 && local.has(head)) return { file, variable: head };
+  const mapping = context.getImportMappings(file, 'python').find((im) => im.localName === head);
+  if (!mapping) return null;
+  if (segs.length > 1) {
+    // `items.router`: `items` is a module — `from app.api.routes import items`.
+    const moduleFile = resolveImportPath(`${mapping.source}.${mapping.exportedName}`, file, 'python', context) ?? resolveImportPath(mapping.source, file, 'python', context);
+    return moduleFile ? { file: moduleFile, variable: segs[segs.length - 1]! } : null;
+  }
+  // `items_router`: `from .items import router as items_router`.
+  const moduleFile = resolveImportPath(mapping.source, file, 'python', context);
+  return moduleFile ? { file: moduleFile, variable: mapping.exportedName } : null;
+}
+
 interface DecoratorRouteOpts {
   decoratorRegex: RegExp;
   defaultMethod: string;

+ 9 - 4
src/resolution/tier-synthesizer.ts

@@ -345,8 +345,13 @@ 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;
+/** A type argument between the callee and its `(` — `useSWR<TeamData>('/api/team')`, `ky.get<User>('/x')`. */
+const GENERIC = String.raw`(?:<[^()<>]*(?:<[^()<>]*>[^()<>]*)*>)?`;
+const BARE_CLIENT_CALL = new RegExp(String.raw`(?:(?:window|globalThis|global)\s*\.\s*)?\b(fetch|\$fetch|ofetch|axios|ky|got|useFetch|useSWR)\s*${GENERIC}\s*\(`, 'g');
+const MEMBER_CLIENT_CALL = new RegExp(
+  String.raw`((?: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*${GENERIC}\s*\(`,
+  'g'
+);
 
 interface HttpRoute {
   node: Node;
@@ -506,7 +511,7 @@ function collectHttpSites(ctx: ResolutionContext, facts: FileFacts, sites: HttpS
   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, '');
+    const callee = safe.slice(index, open).replace(/\s+/g, '').replace(/<.*>$/, '');
     if (facts.routeLines.has(line)) return; // a registration the resolver already read
     const fn = enclosingFn(nodes, line);
     if (!fn) return;
@@ -871,7 +876,7 @@ function pairEvents(dispatches: readonly Dispatch[], handlers: readonly Handler[
 // 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 HTTP_GATE = /\b(?:fetch|\$fetch|ofetch|axios|ky|got|useFetch|useSWR)\b|\.\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*\(/;
 

+ 92 - 2
src/ui-server/api/screens.ts

@@ -27,9 +27,12 @@
  * hundred guarded call sites resolve in tens of milliseconds.
  */
 
+import * as fs from 'fs';
 import type CodeGraph from '../../index';
 import type { Edge, Node } from '../../types';
 import { routeRoots } from './route-roots';
+import { resolveProjectFile } from '../security';
+import { findIndexedFile, hasDriftedOnDisk } from './source';
 import { createWhenReader } from './when';
 import { toNodeRef, type WireNodeRef } from './wire';
 
@@ -133,6 +136,10 @@ const MAX_CALLERS_PER_NODE = 30;
 const MAX_VISITED = 800;
 /** Call sites labelled with conditions per request. */
 const MAX_WHEN_SITES = 600;
+/** Importing files read for mentions of a value nothing calls, and mentions taken, per navigation. */
+const MAX_MENTION_FILES = 6;
+const MAX_MENTIONS = 8;
+const MAX_MENTION_FILE_BYTES = 256 * 1024;
 
 /**
  * Edges walked backwards from a navigation call. `contains` because a handler
@@ -199,10 +206,21 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
   };
   let dropped = 0;
 
+  const valuesByFile = new Map<string, Node[]>();
   for (const nav of navEdges) {
-    const holder = nodesById.get(nav.source);
+    let holder = nodesById.get(nav.source);
     const target = routeById.get(nav.target);
     if (!holder || !target) continue;
+    // A navigation the file scope holds — `redirect('/dashboard')` inside
+    // `export const signIn = validatedAction(schema, async (data) => { … })`,
+    // whose arrow is no node of its own — belongs to the value that spans it:
+    // the action every form passes, and the way back to its page.
+    if (holder.kind === 'file' && typeof nav.line === 'number') {
+      const value = valueSpanning(cg, holder.filePath, nav.line, valuesByFile);
+      if (!value) continue;
+      holder = value;
+      nodesById.set(value.id, value);
+    }
     const meta = (nav.metadata ?? {}) as Record<string, unknown>;
     const site: WireScreenSite = {
       file: toPosix(holder.filePath),
@@ -212,7 +230,7 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
       when: nav.provenance === 'heuristic' ? '' : await whenAt(holder, nav),
     };
 
-    let starts = await attribute(cg, holder, screenOfComponent, routeByFile, nodesById);
+    let starts = await attribute(cg, projectRoot, holder, screenOfComponent, routeByFile, nodesById);
     if (starts === null) {
       dropped++;
       continue;
@@ -332,6 +350,7 @@ interface Attribution {
  */
 async function attribute(
   cg: CodeGraph,
+  projectRoot: string,
   holder: Node,
   screenOfComponent: Map<string, string>,
   routeByFile: Map<string, string>,
@@ -357,6 +376,20 @@ async function attribute(
       list.push(e);
       byTarget.set(e.target, list);
     }
+    // A value nothing calls — `export const signIn = validatedAction(schema,
+    // async (data) => { … redirect('/dashboard') })`, handed to
+    // `useActionState(signIn, …)` as a plain argument the graph keeps no
+    // function-as-value edge for — is used wherever a function in a file that
+    // imports it names it. Those mentions are its callers, read from the source.
+    for (const id of frontier) {
+      const value = nodes.get(id);
+      if (!value || (value.kind !== 'constant' && value.kind !== 'variable')) continue;
+      // The file that declares the value `contains` it; that is not a caller.
+      const callers = (byTarget.get(id) ?? []).filter((e) => e.kind !== 'contains');
+      if (callers.length > 0) continue;
+      const mentions = mentionsOf(cg, projectRoot, value);
+      if (mentions.length > 0) byTarget.set(id, [...callers, ...mentions]);
+    }
     const nextIds: string[] = [];
     const wanted = new Set<string>();
     for (const [, edges] of byTarget) {
@@ -436,6 +469,63 @@ function collapseSharedChrome(starts: Attribution[], origins: Map<string, WireSc
   return out;
 }
 
+/**
+ * Synthetic `references` edges from the functions that mention `value` by
+ * name in the files importing it (the import line itself excepted), read from
+ * the source at request time. Bounded: a handful of files, a handful of hits.
+ */
+function mentionsOf(cg: CodeGraph, projectRoot: string, value: Node): Edge[] {
+  const out: Edge[] = [];
+  const importers = cg
+    .getIncomingEdgesTo([value.id], ['imports'])
+    .map((e) => e.source)
+    .filter((id, i, all) => all.indexOf(id) === i)
+    .slice(0, MAX_MENTION_FILES);
+  if (importers.length === 0) return out;
+  const files = cg.getNodesByIds(importers);
+  const word = new RegExp(`(?<![\\w$.])${value.name.replace(/\$/g, '\\$')}(?![\\w$])`);
+  for (const file of files.values()) {
+    if (file.kind !== 'file') continue;
+    const found = findIndexedFile(cg, file.filePath.replace(/\\/g, '/'));
+    if (!found || hasDriftedOnDisk(projectRoot, found.storedPath, found.record)) continue;
+    let text: string;
+    try {
+      const abs = resolveProjectFile(projectRoot, found.storedPath);
+      if (fs.statSync(abs).size > MAX_MENTION_FILE_BYTES) continue;
+      text = fs.readFileSync(abs, 'utf8');
+    } catch {
+      continue;
+    }
+    const functions = cg.getNodesInFile(file.filePath).filter((n) => n.kind === 'function' || n.kind === 'method' || n.kind === 'component');
+    const lines = text.split('\n');
+    for (let i = 0; i < lines.length && out.length < MAX_MENTIONS; i++) {
+      const line = lines[i]!;
+      if (!word.test(line) || /^\s*import\b|^\s*export\s*\{/.test(line)) continue;
+      let best: Node | null = null;
+      for (const fn of functions) {
+        if (fn.startLine <= i + 1 && fn.endLine >= i + 1 && (!best || fn.startLine >= best.startLine)) best = fn;
+      }
+      if (!best || best.id === value.id) continue;
+      out.push({ source: best.id, target: value.id, kind: 'references', line: i + 1, provenance: 'heuristic', metadata: { fnRef: true, mention: true } });
+    }
+  }
+  return out;
+}
+
+/** The smallest constant / variable of a file whose lines contain `line`, or null. */
+function valueSpanning(cg: CodeGraph, filePath: string, line: number, memo: Map<string, Node[]>): Node | null {
+  let values = memo.get(filePath);
+  if (!values) {
+    values = cg.getNodesInFile(filePath).filter((n) => n.kind === 'constant' || n.kind === 'variable');
+    memo.set(filePath, values);
+  }
+  let best: Node | null = null;
+  for (const v of values) {
+    if (v.startLine <= line && v.endLine >= line && (!best || v.startLine >= best.startLine)) best = v;
+  }
+  return best;
+}
+
 /** The chain from `start` down to the holder, following `prev` links. */
 function pathFrom(
   start: string,

+ 2 - 2
src/ui-server/api/steps.ts

@@ -1215,8 +1215,8 @@ function fileScopeEdgesWithin(cg: CodeGraph, node: Node, memo: Map<string, Edge[
     const file = cg.getNodesInFile(node.filePath).find((n) => n.kind === 'file');
     refs = file
       ? cg
-          .getOutgoingEdgesFrom([file.id], ['references', 'calls'])
-          .filter((e) => e.kind === 'calls' || (e.metadata as Record<string, unknown> | undefined)?.fnRef === true)
+          .getOutgoingEdgesFrom([file.id], ['references', 'calls', 'navigates'])
+          .filter((e) => e.kind !== 'references' || (e.metadata as Record<string, unknown> | undefined)?.fnRef === true)
       : [];
     memo.set(node.filePath, refs);
   }