mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* 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>
362 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|