fix(routes): connect decorator routes to their handler function (#2865)

* fix(routes): connect decorator routes to their handler function

A Route node's only relationship was HANDLES_ROUTE from its FILE. The graph knew
a route existed and which file declared it, but not which function implemented
it. Two consequences on a 12.4k-file repository with 162 FastAPI routes:

  - Every decorated handler was indistinguishable from dead code. Its sole edge
    was DEFINES, so a reachability query reported it unreferenced even though the
    framework invokes it on every request.
  - `route_map` / `api_impact` could only answer at file granularity, and
    `processes.ts` routed every route through its `routesWithoutHandlerByFile`
    fallback instead of keying by handler.

Two halves of one gap, both already designed for and neither wired:

1. `ExtractedDecoratorRoute.handlerName` is documented as "captured at extraction
   where the decorated definition node is in hand", and `resolveRouteHandlerSymbols`
   already consumes it to stamp `handlerSymbolId`. Only the Spring extractor ever
   set it, so for every decorator-routed framework — FastAPI, Flask, NestJS — it
   arrived undefined and 0 of 162 routes carried a handler. A route decorator's
   parent IS the decorated definition, so the name is in hand: add
   `decoratedDefinitionName` and thread it through. It climbs consecutive
   decorators so stacked forms (`@router.get(...)` over `@requires_auth`) resolve,
   caps the climb so a malformed tree cannot loop, and returns undefined rather
   than guessing — the routes phase already treats a missing name as
   "fall back to file-level".

2. With a handler symbol resolved there is finally something to point an edge at.
   Emit a definition-level HANDLES_ROUTE alongside the file-level one. The sibling
   decorator overlay already does exactly this: `pipeline-phases/tools.ts` anchors
   HANDLES_TOOL on the definition the decorator sat on, not its file. Routes were
   the outlier.

Kept as one change because the edge is inert without the symbol — emitted from a
branch lacking part 1 it produces zero edges, since `handlerSymbolId` is empty.

Additive, and both existing consumers are unaffected:
`group/extractors/http-route-extractor.ts` types its query `(handlerFile:File)`;
`manifest-extractor.ts` matches an untyped `(handler)` but takes `LIMIT 1` ordered
by `handler.id`, and `File:…` sorts before `Function:…`, so its selected row is
unchanged.

Direction is Function → Route, matching how every other overlay attaches
(MEMBER_OF → Community, STEP_IN_PROCESS → Process, HANDLES_TOOL → Tool: the symbol
is the source). That also keeps it free of schema risk — `Function|Route` is
already declared by the ATTACHMENT rule in `lbug/schema.ts`
(`DEFINITION_ANCHOR_LABELS × ATTACHMENT_TARGET_LABELS`), which that file documents
as deliberate headroom for this case. Route → Function would have needed a new
hand-listed pair, and an undeclared pair aborts `analyze` outright — a failure
that file records having hit four separate times.

Verified on a FastAPI fixture (edges 9 → 11):
  api.py        (File)    -> GET /widgets, POST /widgets   [unchanged]
  list_widgets  (line 10) -> GET  /widgets                 [new]
  create_widget (line 15) -> POST /widgets                 [new]

On the 12.4k-file repository: 161 of 162 routes now resolve to their handler
function, up from 0. The single abstention is `uniqueSymbolId` correctly refusing
to guess where the name is not uniquely resolvable in its file.

`npx tsc --noEmit` clean; schema-pair coverage and route suites pass (196 tests).

* fix(routes): harden decorator handler attribution (#2865)

Keep definition-level route links correct across warm caches and malformed symbol lookups, and avoid per-route group-sync scans. Move Python AST ownership behind the language provider and add end-to-end regression coverage.

Note: full npm test could not complete in this container due unrelated worker startup failures and a stalled retry; targeted route suites, typecheck, format, and lint passed.
Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(routes): reuse per-file symbol lookup and drop duplicate warm-cache test

Share extract()'s CONTAINING_QUERY memo with the graph provider path, resolve each route handler once, and fold the decorator-edge warm-cache assertions into the existing FastAPI composed-route round-trip.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Carter LaSalle <carterlasalle@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Carter LaSalle 2026-09-01 07:03:30 -07:00 committed by GitHub
parent f7a58cf188
commit f34daea86a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 588 additions and 64 deletions

View file

@ -1137,13 +1137,17 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'python-http',
language: Python,
// routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2).
// It would be a no-op even if set to 'complete': FastAPI decorator routes set
// no handlerName (generic worker path) and Django sets methodName: null, so no
// Python file ever resolves a handlerSymbolId and none would be parse-skipped.
// Declaring 'complete' now is only a latent trap for the moment a follow-up
// gives FastAPI routes a handlerName. `hasConsumerSignals` is kept (and is a
// true superset of scan()'s consumer shapes) so the precondition already holds
// when Python is later flipped to 'complete'.
// 'complete' is now an active data-loss risk rather than a no-op: FastAPI and
// Flask decorator routes do carry a handlerName (Python's
// `decoratorRouteHandlerName` hook reads the `decorated_definition`), so their
// files can resolve every handlerSymbolId and become parse-skip candidates.
// The flag asserts more than that — it asserts ingestion emits a Route node
// for EVERY provider route this scan() finds, and it does not: Flask's
// imperative `add_url_rule('/p', view_func=handler)` registration below has no
// ingestion counterpart, so skipping a file that mixes it with resolved
// decorator routes would drop those providers. `hasConsumerSignals` is kept
// (and is a true superset of scan()'s consumer shapes) so the consumer half of
// the precondition already holds once provider parity is closed.
// Consumer signals scan() can detect: `requests.<verb>`/`requests.request`,
// `httpx` (sync/async client), the `uri=`/`url=` keyword/variable wrapper
// calls, plus aiohttp/urllib. Conservative — over-matching only costs a parse.

View file

@ -621,6 +621,7 @@ export class HttpRouteExtractor implements ContractExtractor {
dbExecutor,
getDetections,
resolveDetectionSymbol,
loadFileSymbols,
coveredFiles,
)
: [];
@ -690,6 +691,7 @@ export class HttpRouteExtractor implements ContractExtractor {
db: CypherExecutor,
getDetections: (rel: string) => Promise<HttpDetection[]>,
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
loadFileSymbols: (filePath: string) => Promise<Record<string, unknown>[]>,
coveredFiles?: Set<string>,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
@ -749,15 +751,11 @@ export class HttpRouteExtractor implements ContractExtractor {
if (!method) method = 'GET';
symbolUid = handlerSymbolId;
if (filePath) {
try {
const syms = await db(CONTAINING_QUERY, { filePath });
const hit = syms.find((s) => String(s.uid ?? s[0]) === handlerSymbolId);
if (hit) {
symbolName = String(hit.name ?? hit[1]) || symbolName;
symPath = String(hit.filePath ?? hit[2]) || filePath;
}
} catch {
/* keep the authoritative uid + basename fallback */
const syms = await loadFileSymbols(filePath);
const hit = syms.find((s) => String(s.uid ?? s[0]) === handlerSymbolId);
if (hit) {
symbolName = String(hit.name ?? hit[1]) || symbolName;
symPath = String(hit.filePath ?? hit[2]) || filePath;
}
}
} else {

View file

@ -413,6 +413,28 @@ interface LanguageProviderConfig {
lineOffset: number,
) => ExtractedDecoratorRoute[];
/**
* Name of the function a route decorator captured by the worker's generic
* `@decorator` query applies to, given the decorator's own AST node.
*
* The worker knows a decorator is a route decorator but not how this
* language's grammar attaches it to a definition, so it hands the node over
* unchanged and takes whatever the language returns. Only languages that
* declare route handlers through the generic decorator captures need this;
* languages with a dedicated {@link extractDecoratorRoutes} extractor
* (JS/TS via `nest.ts`, Java via `spring.ts`) already set
* `ExtractedDecoratorRoute.handlerName` there and should leave this undefined.
*
* Implementations must read their own decorated-definition shape directly and
* return undefined for anything else never climb ancestors to find a name,
* since a decorator that is not attached to a function has no handler and a
* borrowed enclosing name resolves `handlerSymbolId` to the wrong symbol. The
* routes phase treats undefined as "fall back to the file-level edge".
*
* Default: undefined (no handler name from generic decorator captures).
*/
readonly decoratorRouteHandlerName?: (decoratorNode: SyntaxNode) => string | undefined;
/**
* Collect a project-wide, language-agnostic view of route-defining
* class/interface declarations (`SharedSpringType`) from a parsed file.

View file

@ -45,6 +45,7 @@ import {
import { extractDjangoRoutes } from '../route-extractors/django.js';
import { discoverDjangoRootUrls } from '../route-extractors/django-root-discovery.js';
import { extractPythonModuleConstants } from '../route-extractors/python-const-resolver.js';
import { pythonDecoratorRouteHandlerName } from '../route-extractors/python-decorator-handler.js';
const BUILT_INS: ReadonlySet<string> = new Set([
'print',
@ -143,6 +144,7 @@ export const pythonProvider = defineLanguage({
discoverDjangoRootUrls(files, contentMap, reader),
extractRoutes: (tree, filePath, reader, parser) =>
parser ? extractDjangoRoutes(tree, filePath, parser, reader) : [],
decoratorRouteHandlerName: pythonDecoratorRouteHandlerName,
labelOverride: pythonFunctionDefinitionLabel,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────

View file

@ -336,32 +336,31 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
let handlerContents: Map<string, string> | undefined;
if (routeRegistry.size > 0) {
const handlerPathFor = (routeKey: string, entry: RouteEntry): string => {
if (entry.source !== DATA_ROUTE_TABLE_SOURCE) return entry.filePath;
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
const resolvedPath = handlerSymbolId
? ctx.graph.getNode(handlerSymbolId)?.properties.filePath
: undefined;
return typeof resolvedPath === 'string' ? resolvedPath : entry.filePath;
};
const handlerPaths = [...routeRegistry].map(([key, entry]) => handlerPathFor(key, entry));
handlerContents = await readFileContents(ctx.repoPath, handlerPaths);
// Resolve once so content attribution, the route stamp, and the edge use
// the same live graph node. Pre-seeded routes never own handler symbols.
const routes = [...routeRegistry].map(([routeKey, entry]) => {
const id = preSeededKeys.has(routeKey) ? undefined : routeHandlerSymbols.get(routeKey);
const node = id ? ctx.graph.getNode(id) : undefined;
const handlerSymbol = id && node ? { id, node } : undefined;
const resolvedPath =
entry.source === DATA_ROUTE_TABLE_SOURCE
? handlerSymbol?.node.properties.filePath
: undefined;
const handlerPath = typeof resolvedPath === 'string' ? resolvedPath : entry.filePath;
return { routeKey, entry, handlerSymbol, handlerPath };
});
handlerContents = await readFileContents(
ctx.repoPath,
routes.map(({ handlerPath }) => handlerPath),
);
for (const [routeKey, entry] of routeRegistry) {
for (const { routeKey, entry, handlerSymbol, handlerPath } of routes) {
const { source: routeSource, method: routeMethod, url } = entry;
const handlerPath = handlerPathFor(routeKey, entry);
const content = handlerContents.get(handlerPath);
// A pre-seeded route can never legitimately appear in
// `routeHandlerSymbols`, so a key that does is a route that LOST (#3049).
const handlerSymbolId = preSeededKeys.has(routeKey)
? undefined
: routeHandlerSymbols.get(routeKey);
const handlerSymbolId = handlerSymbol?.id;
const analysisContent =
entry.source === DATA_ROUTE_TABLE_SOURCE && content
? handlerSymbolContent(
content,
handlerSymbolId ? ctx.graph.getNode(handlerSymbolId) : undefined,
)
? handlerSymbolContent(content, handlerSymbol?.node)
: content;
const { responseKeys, errorKeys } = analysisContent
@ -397,6 +396,19 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
confidence: 1.0,
reason: routeSource,
});
// Keep the file edge for existing extractor queries; add the live
// definition edge for explicit handler-level traversal.
if (handlerSymbolId) {
ctx.graph.addRelationship({
id: generateId('HANDLES_ROUTE', `${handlerSymbolId}->${routeNodeId}`),
sourceId: handlerSymbolId,
targetId: routeNodeId,
type: 'HANDLES_ROUTE',
confidence: 1.0,
reason: routeSource,
});
}
}
if (isDev) {

View file

@ -0,0 +1,19 @@
/**
* Return the function name attached to a Python decorator's immediate
* `decorated_definition`; reject every other shape rather than climbing.
*/
import type { SyntaxNode } from '../utils/ast-helpers.js';
export function pythonDecoratorRouteHandlerName(decoratorNode: SyntaxNode): string | undefined {
const decorated = decoratorNode.parent;
if (decorated === null || decorated.type !== 'decorated_definition') return undefined;
// `async def` is still a `function_definition` in tree-sitter-python (the
// `async` keyword is an anonymous child), so async handlers need no branch.
const definition = decorated.childForFieldName('definition');
if (!definition || definition.type !== 'function_definition') return undefined;
const name = definition.childForFieldName('name')?.text;
return name !== undefined && name.length > 0 ? name : undefined;
}

View file

@ -1795,12 +1795,14 @@ const processFileGroup = (
const httpMethod = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(method)
? method
: 'GET';
const handlerName = provider.decoratorRouteHandlerName?.(decoratorNode);
const base = {
filePath: file.path,
httpMethod,
decoratorName,
lineNumber: decoratorNode.startPosition.row + lineOffset,
...(decoratorReceiver ? { decoratorReceiver } : {}),
...(handlerName ? { handlerName } : {}),
};
if (decoratorArgStr) {
// String-literal path (the fast path, unchanged). Empty-string

View file

@ -371,7 +371,7 @@ const DEFINITION_ANCHOR_LABELS: readonly NodeTableName[] = NODE_TABLES.filter(
* | `Annotation` | `frameworks/spring/conditionals.ts` CONDITIONAL_ON | `resolveDefGraphId` / `resolveCallerGraphId` |
* | `Community` | `pipeline-phases/communities.ts` MEMBER_OF | Leiden membership, `isCommunitySymbol`-gated |
* | `Process` | `pipeline-phases/processes.ts` STEP_IN_PROCESS | trace step node |
* | `Route` | `pipeline-phases/routes.ts` HANDLES_ROUTE | `generateId('File', handlerPath)` a literal |
* | `Route` | `pipeline-phases/routes.ts` HANDLES_ROUTE | `generateId('File', handlerPath)` a literal plus, when the route's handler resolves, that definition |
* | `Tool` | `pipeline-phases/tools.ts` HANDLES_TOOL | `handlerNodeId` whatever definition the decorator sat on |
* | `File` | `languages/vue/scope-resolver.ts` BINDS_EVENT_HANDLER | handler node |
* | `Record` | `cobol-processor.ts` × 8 external-resource sites | `scopedCallerLookup` |
@ -383,25 +383,35 @@ const DEFINITION_ANCHOR_LABELS: readonly NodeTableName[] = NODE_TABLES.filter(
* {@link DEFINITION_ANCHOR_LABELS} × this set covers all four plus every
* sibling the same emitters can reach.
*
* TWO TARGETS ARE LABEL-GATED TODAY, and the cross product over-declares for
* them ON PURPOSE (~47 of the 182 attachment pairs are unreachable right now):
* TWO TARGETS USE ONLY PART OF THEIR CROSS PRODUCT TODAY, and it over-declares
* for them ON PURPOSE (~45 of the 182 attachment pairs have no emitter that can
* reach them right now):
* - `Community` `isCommunitySymbol` (`community-processor.ts`) admits only
* `Function` / `Class` / `Method` / `Interface` as members, so the other 22
* anchors cannot source a MEMBER_OF edge until that predicate widens.
* - `Route` HANDLES_ROUTE sources `generateId('File', handlerPath)`, a
* literal `File`, so every non-`File` anchor is headroom.
* - `Route` HANDLES_ROUTE always sources the literal
* `generateId('File', handlerPath)`, and additionally the route's HANDLER
* DEFINITION whenever `routeHandlerSymbols` produced an id that
* `ctx.graph.getNode` confirms is in the graph. That second anchor is a
* lookup result the emitter never label-checks; handler resolution returns
* `Function` / `Method` definitions today, so `Route` is no longer a
* `File`-only target and the remaining anchors are headroom.
*
* Those pairs stay declared because the two sides of the error are not
* symmetric: an UNDECLARED pair makes LadybugDB reject the edge and aborts
* `analyze` outright on a user's repo, while an unused DECLARED pair costs
* almost nothing `bench/schema-pairs` measured the pre-#2801 332450 growth
* (118 pairs, of which these ~47 are a part) at 0.931.05×, i.e. inside
* (118 pairs, of which these ~45 are a part) at 0.931.05×, i.e. inside
* run-to-run noise. #2801's 11 generated `Record` pairs bring the total to 461.
* Its Windows measurements and noise caveats live in the benchmark README; the
* operational production ceiling is the checked 1.5× budget.
* Every one of the four aborts above came from re-narrowing a set to what one
* predicate looked like it allowed so a reading of `isCommunitySymbol` is not
* grounds to shrink this. Widening either predicate is then a no-op here.
* grounds to shrink this. `Route` is the worked example in the other direction:
* the definition-level HANDLES_ROUTE edge started emitting `Function|Route` /
* `Method|Route` with no DDL change, because those pairs were already declared.
* Widening `isCommunitySymbol`, or handler resolution returning a further
* label, is likewise a no-op here.
*
* `Route` / `Tool` being excluded as ANCHORS (see {@link NON_DEFINITION_LABELS})
* is likewise a SIZE choice, not something derived from a rule: they do source

View file

@ -705,7 +705,18 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// pre-change cache would have served as zero. origin/main at allocation is 88.
// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING — this
// entry was allocated 83 first, and five bumps landed upstream before it merged.
const SCHEMA_BUMP = 89;
// 89 -> 90 (#2865): route decorator captures now carry `handlerName` in
// `decoratorRoutes`, which is what lets `resolveRouteHandlerSymbols` stamp
// `handlerSymbolId` and the routes phase emit the definition-level
// HANDLES_ROUTE edge. The field is minted in the parse WORKER and persisted
// verbatim, so a warm v89 cache replays handler-less decorator routes: every
// route loses its definition-level association on incremental analyze while
// every cold-run test passes — the inert-feature trap the entries above record.
// 89 is now taken by merged #3128. 90 is the next free value above origin/main
// (89) and above every in-flight claim found by scanning open PRs'
// parse-cache.ts at their exact head SHAs (highest other open claim was still
// ≤88). RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 90;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -75,6 +75,11 @@ describe('data-driven route table ingestion', () => {
const source = result.graph.getNode(rel.sourceId);
const target = result.graph.getNode(rel.targetId);
if (source === undefined || target === undefined) return;
// A resolved route also carries a definition-level edge from the handler
// symbol itself; this assertion is about the file-level edge, which is the
// one `http-route-extractor.ts` queries. The definition edges are pinned
// by the next test.
if (source.label !== 'File') return;
handled.push({
filePath: String(source.properties.filePath),
method: target.properties.method as string | undefined,
@ -106,6 +111,29 @@ describe('data-driven route table ingestion', () => {
]);
});
it('also links every resolved route from the handler definition it stamped', () => {
// The definition-level edge must agree with `Route.handlerSymbolId` — both
// come from the same graph-resolved symbol — and its source must be a real
// definition node, not a File.
const definitionEdges = new Map<string, string>();
result.graph.forEachRelationship((rel) => {
if (rel.type !== 'HANDLES_ROUTE' || rel.reason !== DATA_ROUTE_TABLE_SOURCE) return;
const source = result.graph.getNode(rel.sourceId);
if (source === undefined || source.label === 'File') return;
definitionEdges.set(rel.targetId, rel.sourceId);
});
const resolved = routes().filter((route) => route.handler !== undefined);
expect(resolved).toHaveLength(3);
result.graph.forEachNode((node) => {
if (node.label !== 'Route') return;
const handlerSymbolId = node.properties.handlerSymbolId as string | undefined;
if (handlerSymbolId === undefined) return;
expect(definitionEdges.get(node.id), String(node.properties.name)).toBe(handlerSymbolId);
expect(result.graph.getNode(handlerSymbolId)?.label).toMatch(/^(Function|Method)$/);
});
});
it('extracts response shapes from each resolved handler only', () => {
const getUsers = routes().find((route) => route.path === '/users' && route.method === 'GET');
const postUsers = routes().find((route) => route.path === '/users' && route.method === 'POST');

View file

@ -27,7 +27,13 @@ import {
loadParseCache,
saveParseCache,
PARSE_CACHE_VERSION,
pruneCache,
type ParseCache,
} from '../../src/storage/parse-cache.js';
import {
getDurableParsedFileDir,
pruneAndSaveDurableParsedFileStore,
} from '../../src/storage/parsedfile-store.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'fastapi-composed-app');
@ -164,35 +170,67 @@ describe('FastAPI composed route constants — ingestion↔group parity (#2391 R
// ─── Warm parse-cache: composed routes survive the cache serialization ────────
describe('FastAPI composed route constants — warm parse-cache (#2391 SCHEMA_BUMP)', () => {
it('re-resolves the composed route on an all-hit warm run after a save/load round-trip', async () => {
describe('FastAPI composed routes — warm parse-cache (#2391, #2865)', () => {
it('replays composed paths and handler metadata on an all-hit warm run', async () => {
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-composed-warm-'));
try {
// Run #1 populates the parse cache.
const cold = {
const cold: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set<string>(),
storagePath: storageDir,
onDiskKeys: new Set<string>(),
};
await runPipelineFromRepo(FIXTURE, () => {}, { parseCache: cold });
// Force the JSON round-trip (mapReplacer/mapReviver) the real warm path uses
// — this is where the new `moduleConstants` Maps and `routePathExpr` fields
// must survive, or a warm re-analyze silently drops the composed route.
await saveParseCache(storageDir, cold);
const warm = await loadParseCache(storageDir);
expect(warm).not.toBeNull();
const result = await runPipelineFromRepo(FIXTURE, () => {}, {
parseCache: warm ?? undefined,
const coldResult = await runPipelineFromRepo(FIXTURE, () => {}, {
parseCache: cold,
workerPoolSize: 1,
});
expect(coldResult.usedWorkerPool).toBe(true);
pruneCache(cold, cold.usedKeys);
const savedKeys = await saveParseCache(storageDir, cold);
await pruneAndSaveDurableParsedFileStore(
getDurableParsedFileDir(storageDir),
PARSE_CACHE_VERSION,
new Set(savedKeys),
);
const warm = await loadParseCache(storageDir);
const replay = await runPipelineFromRepo(FIXTURE, () => {}, {
parseCache: warm,
workerPoolSize: 1,
});
expect(replay.usedWorkerPool).toBe(false);
const urls = new Set<string>();
result.graph.forEachNode((n) => {
replay.graph.forEachNode((n) => {
if (n.label === 'Route') urls.add(String(n.properties.name));
});
expect(urls.has('/api/v1/widgets/get')).toBe(true);
expect(urls.has('/root/mid/leaf')).toBe(true);
expect(urls.has('/')).toBe(false);
const handlers = (pipeline: PipelineResult): Map<string, string> => {
const out = new Map<string, string>();
pipeline.graph.forEachNode((node) => {
if (node.label !== 'Route') return;
const id = node.properties.handlerSymbolId;
if (typeof id === 'string') out.set(String(node.properties.name), id);
});
return out;
};
const coldHandlers = handlers(coldResult);
expect(coldHandlers.get('/api/v1/widgets/get')).toMatch(/create_widget/);
expect(handlers(replay)).toEqual(coldHandlers);
const handlerId = coldHandlers.get('/api/v1/widgets/get');
const routeId = [...replay.graph.iterNodes()].find(
(node) => node.label === 'Route' && node.properties.name === '/api/v1/widgets/get',
)?.id;
expect(
[...replay.graph.iterRelationshipsByType('HANDLES_ROUTE')].some(
(edge) => edge.sourceId === handlerId && edge.targetId === routeId,
),
).toBe(true);
} finally {
fs.rmSync(storageDir, { recursive: true, force: true });
}

View file

@ -248,6 +248,91 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
expect(out[0].symbolName).toBe('createOrder');
});
it('fast path: CONTAINING_QUERY runs once per file, not once per resolved route', async () => {
// The fast path resolves a handler per ROUTE but CONTAINING_QUERY is a
// per-FILE lookup. Three resolved routes in one controller must therefore
// execute it exactly once — otherwise a group sync re-scans the same file
// for every route it registers.
const ids = ['listOrders', 'createOrder', 'deleteOrder'].map(
(name) => [name, `Method:OrderController.java:OrderController.${name}#0`] as const,
);
const symbols = ids.map(([name, uid]) => ({
uid,
name,
filePath: 'OrderController.java',
startLine: 10,
endLine: 12,
labels: ['Method'],
0: uid,
1: name,
2: 'OrderController.java',
}));
const db = vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return ids.map(([name, uid], i) => ({
fileId: 'f1',
filePath: 'OrderController.java',
routePath: `/api/orders/${name}`,
routeId: `r${i}`,
routeMethod: 'GET',
handlerSymbolId: uid,
routeSource: 'framework-route',
}));
}
if (query.includes('UNION ALL')) return symbols;
return [];
});
const out = await new HttpRouteExtractor().extract(db, '/repo', {
name: 'r',
url: 'r',
} as never);
const containingCalls = db.mock.calls.filter(([query]) => query.includes('UNION ALL'));
expect(containingCalls).toHaveLength(1);
// Memoization must not cost resolution: every route still names its handler.
expect(out.map((c) => c.symbolName).sort()).toEqual([
'createOrder',
'deleteOrder',
'listOrders',
]);
});
it('fast path: a failed CONTAINING_QUERY is cached as empty and not retried per route', async () => {
// Failures previously fell through to the uid + basename fallback per route;
// memoizing must keep that fallback while collapsing the retries.
const ids = ['listOrders', 'createOrder'].map(
(name) => [name, `Method:OrderController.java:OrderController.${name}#0`] as const,
);
const db = vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return ids.map(([name, uid], i) => ({
fileId: 'f1',
filePath: 'OrderController.java',
routePath: `/api/orders/${name}`,
routeId: `r${i}`,
routeMethod: 'GET',
handlerSymbolId: uid,
routeSource: 'framework-route',
}));
}
if (query.includes('UNION ALL')) throw new Error('boom');
return [];
});
const out = await new HttpRouteExtractor().extract(db, '/repo', {
name: 'r',
url: 'r',
} as never);
expect(db.mock.calls.filter(([query]) => query.includes('UNION ALL'))).toHaveLength(1);
expect(out.map((c) => c.symbolUid).sort()).toEqual(ids.map(([, uid]) => uid).sort());
// The authoritative uid survives; only the display name falls back.
for (const contract of out) expect(contract.symbolName).toBe('OrderController.java');
});
it('backward-compat: no Route.method and undecodable reason stays at conservative GET', async () => {
FILE_DETECTIONS.set('routes.ts', [detection('provider', 'POST', '/api/orders', 'createOrder')]);

View file

@ -244,12 +244,17 @@ describe('PARSE_CACHE_VERSION', () => {
// collided, because each re-checked once and neither re-checked after the
// other moved — which is why the rule is re-applied AT MERGE, not when the
// number is picked.
it('pins SCHEMA_BUMP to 89 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(89);
// Moved 89 -> 90 for #2865's decorator-route `handlerName` after #3128
// merged and took 89. origin/main is 89; 90 is the next free value and
// still unused by other open PRs' parse-cache.ts heads — the same
// collision the paragraph above describes, caught this time by re-checking
// at merge.
it('pins SCHEMA_BUMP to 90 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(90);
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
for (const taken of [
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
82, 83, 84, 85, 86, 87, 88,
82, 83, 84, 85, 86, 87, 88, 89,
]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}

View file

@ -0,0 +1,96 @@
/**
* Pins Python's `decoratorRouteHandlerName` provider hook against real
* tree-sitter-python trees.
*
* The hook feeds `ExtractedDecoratorRoute.handlerName`, which the routes phase
* turns into `handlerSymbolId` and a definition-level `HANDLES_ROUTE` edge. Two
* failure directions matter and both are asserted here:
*
* too little a plain module function, a method, a stacked-decorator run,
* or an `async def` must all yield the decorated function's name, or every
* Flask/FastAPI handler silently degrades to a file-level edge;
* too much a class-attached route decorator must yield nothing. A class
* does not handle a request, and returning its name would resolve
* `handlerSymbolId` to the wrong symbol.
*/
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import Python from 'tree-sitter-python';
import { pythonDecoratorRouteHandlerName } from '../../src/core/ingestion/route-extractors/python-decorator-handler.js';
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
const parser = new Parser();
parser.setLanguage(Python);
/** Every `decorator` node in `src`, in source order. */
function decorators(src: string): SyntaxNode[] {
const found: SyntaxNode[] = [];
const walk = (node: SyntaxNode): void => {
if (node.type === 'decorator') found.push(node);
for (const child of node.children) walk(child);
};
walk(parser.parse(src).rootNode);
return found;
}
/** Handler names the hook reports for each decorator in `src`. */
const handlerNames = (src: string): Array<string | undefined> =>
decorators(src).map((node) => pythonDecoratorRouteHandlerName(node));
describe('pythonDecoratorRouteHandlerName', () => {
it('names the module-level function a route decorator sits on', () => {
expect(handlerNames('@router.get("/widgets")\ndef list_widgets(): pass\n')).toEqual([
'list_widgets',
]);
});
it('names a method inside a class', () => {
expect(
handlerNames('class WidgetView:\n @router.post("/widgets")\n def create(self): pass\n'),
).toEqual(['create']);
});
it('names the same function for every decorator in a stacked run', () => {
// tree-sitter-python puts all decorators of a run under one
// `decorated_definition`, so no ancestor walk is needed to reach the
// definition past the sibling decorator.
expect(handlerNames('@router.get("/me")\n@requires_auth\ndef whoami(): pass\n')).toEqual([
'whoami',
'whoami',
]);
});
it('names an async handler (`async def` is still a function_definition)', () => {
expect(handlerNames('@app.get("/health")\nasync def health(): pass\n')).toEqual(['health']);
});
it('returns undefined for a class-attached route decorator', () => {
expect(handlerNames('@router.get("/widgets")\nclass WidgetResource: pass\n')).toEqual([
undefined,
]);
});
it('does not climb out of a class body to borrow the enclosing class name', () => {
// The decorator's parent here is the class body's `decorated_definition`
// holding a class, not a function. An ancestor walk would have found
// `Outer`; direct-shape ownership reports nothing.
expect(handlerNames('class Outer:\n @router.get("/x")\n class Inner: pass\n')).toEqual([
undefined,
]);
});
it('names the real def when a commented-out def precedes it', () => {
// Python applies the decorator to the next real definition; the comment is
// not a definition, so `real_handler` is the correct answer.
expect(
handlerNames('@router.get("/x")\n# def old_handler(): pass\ndef real_handler(): pass\n'),
).toEqual(['real_handler']);
});
it('returns undefined for a non-route decorator context with no decorated definition', () => {
// A bare decorator with no following definition is an ERROR/partial parse;
// the hook must not invent a name from whatever the parent happens to be.
expect(handlerNames('@router.get("/x")\n')).toEqual([undefined]);
});
});

View file

@ -0,0 +1,192 @@
/**
* Routes phase: the definition-level `HANDLES_ROUTE` edge and the
* `Route.handlerSymbolId` stamp, both gated on the handler symbol EXISTING in
* the graph.
*
* `routeHandlerSymbols` carries `SemanticModel` node ids, which are a
* resolution claim rather than proof that the definition node reached
* `ctx.graph`. Two directions are pinned here:
*
* resolved a live `Function` / `Method` node gets BOTH the file-level edge
* (what `http-route-extractor.ts` queries) and the definition-level edge, so
* a HANDLES_ROUTE traversal reaches the handler and not just its file;
* dangling an id with no node in the graph falls back to exactly the
* file-level behavior a route with no handler resolution has: file-level
* edge only, no `handlerSymbolId` stamp, and no edge sourced on a node that
* does not exist.
*/
import { describe, expect, it } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import type { NodeLabel } from 'gitnexus-shared';
import { routesPhase } from '../../src/core/ingestion/pipeline-phases/routes.js';
import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js';
import { DATA_ROUTE_TABLE_SOURCE } from '../../src/core/ingestion/route-extractors/data-route-table.js';
import type { ParseOutput } from '../../src/core/ingestion/pipeline-phases/parse.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
import { generateId } from '../../src/lib/utils.js';
const CONTROLLER = 'src/orders/controller.ts';
const TABLE_FILE = 'src/routes/table.ts';
const HANDLER_FILE = 'src/handlers/orders.ts';
const HANDLER_SOURCE = `export function createOrder() {\n return { id: 1 };\n}\n`;
interface RunOptions {
/** Files to materialize in the temp repo, path → contents. */
readonly files?: Readonly<Record<string, string>>;
/** Handler definition node to seed into the graph before the phase runs. */
readonly handlerNode?: { id: string; label: NodeLabel; filePath: string };
readonly routeHandlerSymbols: ReadonlyMap<string, string>;
readonly extractedRoutes?: readonly unknown[];
readonly decoratorRoutes?: readonly unknown[];
}
async function runRoutesPhase(
options: RunOptions,
): Promise<{ graph: KnowledgeGraph; repoPath: string }> {
const repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-route-handler-edge-'));
const files = options.files ?? { [CONTROLLER]: HANDLER_SOURCE };
for (const [filePath, contents] of Object.entries(files)) {
await fs.mkdir(path.join(repoPath, path.dirname(filePath)), { recursive: true });
await fs.writeFile(path.join(repoPath, filePath), contents);
}
const graph = createKnowledgeGraph();
if (options.handlerNode) {
graph.addNode({
id: options.handlerNode.id,
label: options.handlerNode.label,
properties: {
name: 'createOrder',
filePath: options.handlerNode.filePath,
startLine: 0,
endLine: 2,
},
});
}
const parseOutput = {
allPaths: Object.keys(files),
allFetchCalls: [],
allFetchWrapperDefs: [],
allExtractedRoutes: options.extractedRoutes ?? [],
allDecoratorRoutes: options.decoratorRoutes ?? [],
routeHandlerSymbols: options.routeHandlerSymbols,
} as unknown as ParseOutput;
try {
await routesPhase.execute(
{ repoPath, graph, onProgress: () => {}, pipelineStart: Date.now() },
new Map([['parse', { phaseName: 'parse', output: parseOutput, durationMs: 0 }]]),
);
return { graph, repoPath };
} finally {
await fs.rm(repoPath, { recursive: true, force: true });
}
}
/** A single framework route: `POST /orders`, declared in `CONTROLLER`. */
const frameworkRoute = (filePath = CONTROLLER) => ({
filePath,
httpMethod: 'post',
routePath: '/orders',
routeName: null,
controllerName: null,
methodName: null,
middleware: [],
prefix: null,
lineNumber: 1,
});
const POST_ORDERS = routeNodeKey('POST', '/orders');
const ROUTE_ID = generateId('Route', POST_ORDERS);
/** `HANDLES_ROUTE` source ids pointing at the route node, de-duplicated. */
const handlesRouteSources = (graph: KnowledgeGraph, routeNodeId = ROUTE_ID): string[] => [
...new Set(
graph.relationships
.filter((rel) => rel.type === 'HANDLES_ROUTE' && rel.targetId === routeNodeId)
.map((rel) => rel.sourceId),
),
];
describe('routes phase — definition-level HANDLES_ROUTE', () => {
it.each<NodeLabel>(['Function', 'Method'])(
'emits both the file-level and the definition-level edge for a live %s handler',
async (label) => {
const handlerId = `${label}:${CONTROLLER}:createOrder`;
const { graph } = await runRoutesPhase({
handlerNode: { id: handlerId, label, filePath: CONTROLLER },
routeHandlerSymbols: new Map([[POST_ORDERS, handlerId]]),
extractedRoutes: [frameworkRoute()],
});
expect(graph.getNode(ROUTE_ID)?.properties.handlerSymbolId).toBe(handlerId);
expect(handlesRouteSources(graph).sort()).toEqual(
[generateId('File', CONTROLLER), handlerId].sort(),
);
},
);
it('falls back to file-level attribution when the handler id has no node in the graph', async () => {
// Resolution claimed a symbol the graph does not hold. The stamp is the
// extractor's fast path and the edge would source on an absent node, so
// neither may be emitted.
const { graph } = await runRoutesPhase({
routeHandlerSymbols: new Map([[POST_ORDERS, `Method:${CONTROLLER}:ghost`]]),
extractedRoutes: [frameworkRoute()],
});
const route = graph.getNode(ROUTE_ID);
expect(route, 'the route itself must survive a dangling handler id').toBeTruthy();
expect(route?.properties).not.toHaveProperty('handlerSymbolId');
expect(handlesRouteSources(graph)).toEqual([generateId('File', CONTROLLER)]);
});
it('resolves the data-route-table handler path from the graph, and falls back when it dangles', async () => {
const routeKey = routeNodeKey('GET', '/orders');
const routeNodeId = generateId('Route', routeKey);
const handlerId = `Function:${HANDLER_FILE}:createOrder`;
const dataRoute = {
filePath: TABLE_FILE,
routePath: '/orders',
httpMethod: 'GET',
decoratorName: 'data-route-table',
source: DATA_ROUTE_TABLE_SOURCE,
lineNumber: 1,
handlerName: 'createOrder',
};
const files = {
[TABLE_FILE]: `export const routes = [{ path: '/orders', method: 'GET', handler: createOrder }];\n`,
[HANDLER_FILE]: HANDLER_SOURCE,
};
const resolved = await runRoutesPhase({
files,
handlerNode: { id: handlerId, label: 'Function', filePath: HANDLER_FILE },
routeHandlerSymbols: new Map([[routeKey, handlerId]]),
decoratorRoutes: [dataRoute],
});
const resolvedRoute = resolved.graph.getNode(routeNodeId);
expect(resolvedRoute?.properties.filePath).toBe(HANDLER_FILE);
expect(resolvedRoute?.properties.handlerSymbolId).toBe(handlerId);
expect(handlesRouteSources(resolved.graph, routeNodeId).sort()).toEqual(
[generateId('File', HANDLER_FILE), handlerId].sort(),
);
const dangling = await runRoutesPhase({
files,
routeHandlerSymbols: new Map([[routeKey, handlerId]]),
decoratorRoutes: [dataRoute],
});
const danglingRoute = dangling.graph.getNode(routeNodeId);
// No handler node to read a path from, so the declaring file stands in.
expect(danglingRoute?.properties.filePath).toBe(TABLE_FILE);
expect(danglingRoute?.properties).not.toHaveProperty('handlerSymbolId');
expect(handlesRouteSources(dangling.graph, routeNodeId)).toEqual([
generateId('File', TABLE_FILE),
]);
});
});