GitNexus/gitnexus/test/unit/route-handler-definition-edge.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

192 lines
7.6 KiB
TypeScript

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