GitNexus/gitnexus/test/unit/group/http-route-graph-method.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

362 lines
12 KiB
TypeScript

/**
* Step A coverage for issue #2138 groundwork:
* `HttpRouteExtractor.extractProvidersGraph` should read the HTTP verb
* persisted on the Route node (`route.method`, surfaced as `routeMethod`
* by HANDLES_ROUTE_QUERY) as the authoritative method, falling back to
* the edge `reason` only for older indexes / filesystem routes that never
* stored a method.
*
* Why this matters: framework routes (Java Spring, Laravel) are emitted
* with `routeSource = 'framework-route'`, which `methodFromRouteReason`
* cannot decode (returns null). Before the Route node carried `method`,
* the graph path had to re-parse the handler source to recover the verb.
* Persisting the verb on the node removes that dependency for the method
* piece (the handler-name piece is addressed separately in Step B).
*
* Harness mirrors http-route-multi-verb.test.ts: the plugin registry,
* fs-utils, and tree-sitter are mocked so we drive the graph rows
* directly without real grammars.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type Parser from 'tree-sitter';
import type { HttpDetection } from '../../../src/core/group/extractors/http-patterns/types.js';
const FILE_DETECTIONS = new Map<string, HttpDetection[]>();
vi.mock('../../../src/core/group/extractors/fs-utils.js', () => ({
readSafe: (_repo: string, _rel: string) => 'stub content',
}));
vi.mock('../../../src/core/group/extractors/http-patterns/index.js', () => {
return {
HTTP_SCAN_GLOB: '**/*.fake',
getPluginForFile: (rel: string) => ({
name: 'fake',
language: {},
scan: (_tree: Parser.Tree) => FILE_DETECTIONS.get(rel) ?? [],
}),
};
});
vi.mock('tree-sitter', () => {
class FakeParser {
setLanguage(_lang: unknown) {}
parse(_src: string) {
return {} as Parser.Tree;
}
}
return { default: FakeParser };
});
import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js';
function detection(
role: 'provider' | 'consumer',
method: string,
p: string,
name: string | null,
): HttpDetection {
return { role, framework: 'test', method, path: p, name, confidence: 0.8 };
}
const containsFor = (names: string[]) =>
names.map((name) => ({
uid: `uid-${name}`,
name,
filePath: 'OrderController.java',
labels: ['Method'],
0: `uid-${name}`,
1: name,
2: 'OrderController.java',
3: ['Method'],
}));
describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () => {
beforeEach(() => {
FILE_DETECTIONS.clear();
});
it('framework-route: uses Route.method when the edge reason cannot decode the verb', async () => {
// Spring controller: reason is the generic 'framework-route', so
// methodFromRouteReason() returns null. The verb must come from the
// Route node's persisted `method` (routeMethod).
FILE_DETECTIONS.set('OrderController.java', [
detection('provider', 'POST', '/api/orders', 'createOrder'),
]);
const db = vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return [
{
fileId: 'f1',
filePath: 'OrderController.java',
routePath: '/api/orders',
routeId: 'r1',
routeMethod: 'POST',
routeSource: 'framework-route',
},
];
}
if (query.includes('UNION ALL')) return containsFor(['createOrder']);
return [];
});
const out = await new HttpRouteExtractor().extract(db, '/repo', {
name: 'r',
url: 'r',
} as never);
expect(out).toHaveLength(1);
expect(out[0].meta.method).toBe('POST');
expect(out[0].contractId).toBe('http::POST::/api/orders');
});
it('framework-route: Route.method disambiguates the handler among multi-verb candidates', async () => {
// Two verbs at the same path in one controller; reason is generic.
// Route.method = PUT must both set the verb AND pick replaceOrder.
FILE_DETECTIONS.set('OrderController.java', [
detection('provider', 'GET', '/api/orders', 'listOrders'),
detection('provider', 'PUT', '/api/orders', 'replaceOrder'),
]);
const db = vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return [
{
fileId: 'f1',
filePath: 'OrderController.java',
routePath: '/api/orders',
routeMethod: 'PUT',
routeSource: 'framework-route',
},
];
}
if (query.includes('UNION ALL')) return containsFor(['listOrders', 'replaceOrder']);
return [];
});
const out = await new HttpRouteExtractor().extract(db, '/repo', {
name: 'r',
url: 'r',
} as never);
expect(out).toHaveLength(1);
expect(out[0].meta.method).toBe('PUT');
expect(out[0].symbolName).toBe('replaceOrder');
});
it('case-insensitive: lower-case Route.method is normalized to an upper-case verb', async () => {
FILE_DETECTIONS.set('OrderController.java', [
detection('provider', 'DELETE', '/api/orders/{param}', 'deleteOrder'),
]);
const db = vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return [
{
fileId: 'f1',
filePath: 'OrderController.java',
routePath: '/api/orders/{id}',
routeMethod: 'delete',
routeSource: 'framework-route',
},
];
}
if (query.includes('UNION ALL')) return containsFor(['deleteOrder']);
return [];
});
const out = await new HttpRouteExtractor().extract(db, '/repo', {
name: 'r',
url: 'r',
} as never);
expect(out[0].meta.method).toBe('DELETE');
});
it('backward-compat: missing Route.method falls back to the edge reason (old indexes)', async () => {
// Old index has no `method` on the Route node → routeMethod undefined.
// The decorator reason still decodes the verb as before.
FILE_DETECTIONS.set('routes.ts', [detection('provider', 'GET', '/api/orders', 'listOrders')]);
const db = vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return [
{
fileId: 'f1',
filePath: 'routes.ts',
routePath: '/api/orders',
// no routeMethod field at all
routeSource: 'decorator-Get',
},
];
}
if (query.includes('UNION ALL')) return containsFor(['listOrders']);
return [];
});
const out = await new HttpRouteExtractor().extract(db, '/repo', {
name: 'r',
url: 'r',
} as never);
expect(out[0].meta.method).toBe('GET');
expect(out[0].symbolName).toBe('listOrders');
});
it('fast path: Route.handlerSymbolId resolves the handler without any source scan', async () => {
// Deliberately leave FILE_DETECTIONS empty: if the extractor still resolves
// the handler, it MUST have used the persisted handlerSymbolId (the graph
// fast path), not a plugin scan of the source.
const HID = 'Method:OrderController.java:OrderController.createOrder#0';
const db = vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return [
{
fileId: 'f1',
filePath: 'OrderController.java',
routePath: '/api/orders',
routeMethod: 'POST',
handlerSymbolId: HID,
routeSource: 'framework-route',
},
];
}
if (query.includes('UNION ALL')) {
return [
{
uid: HID,
name: 'createOrder',
filePath: 'OrderController.java',
startLine: 10,
endLine: 12,
labels: ['Method'],
0: HID,
1: 'createOrder',
2: 'OrderController.java',
},
];
}
return [];
});
const out = await new HttpRouteExtractor().extract(db, '/repo', {
name: 'r',
url: 'r',
} as never);
expect(out).toHaveLength(1);
expect(out[0].meta.method).toBe('POST');
// The persisted symbol id is authoritative; name/path come from the cheap
// CONTAINING_QUERY graph lookup by filePath (no source parse).
expect(out[0].symbolUid).toBe(HID);
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')]);
const db = vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return [
{
fileId: 'f1',
filePath: 'routes.ts',
routePath: '/api/orders',
routeSource: 'framework-route', // undecodable, and no routeMethod
},
];
}
if (query.includes('UNION ALL')) return containsFor(['createOrder']);
return [];
});
const out = await new HttpRouteExtractor().extract(db, '/repo', {
name: 'r',
url: 'r',
} as never);
// Single candidate, so its method is adopted (existing behavior); the
// point is that absence of routeMethod does not throw and still works.
expect(out[0].meta.method).toBe('POST');
});
});