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
This commit is contained in:
Colby McHenry
2026-08-28 14:52:40 -05:00
co-authored by Claude Fable 5
parent 9c6bc23b21
commit bc45e9071d
11 changed files with 487 additions and 14 deletions
+89 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}