GitNexus/gitnexus/test/unit/python-decorator-handler-name.test.ts
Carter LaSalle f34daea86a
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>
2026-09-01 14:03:30 +00:00

96 lines
4 KiB
TypeScript

/**
* 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]);
});
});