mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +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>
291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
|
import type { PipelineResult } from '../../types/pipeline.js';
|
|
import { DATA_ROUTE_TABLE_SOURCE } from '../../src/core/ingestion/route-extractors/data-route-table.js';
|
|
import {
|
|
loadParseCache,
|
|
PARSE_CACHE_VERSION,
|
|
pruneCache,
|
|
saveParseCache,
|
|
type ParseCache,
|
|
} from '../../src/storage/parse-cache.js';
|
|
import {
|
|
getDurableParsedFileDir,
|
|
pruneAndSaveDurableParsedFileStore,
|
|
} from '../../src/storage/parsedfile-store.js';
|
|
|
|
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'data-route-table-app');
|
|
|
|
describe('data-driven route table ingestion', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(FIXTURE, () => {}, {});
|
|
}, 60_000);
|
|
|
|
const routes = (pipeline: PipelineResult = result) => {
|
|
const found: Array<{
|
|
path: string;
|
|
method?: string;
|
|
handler?: string;
|
|
filePath: string;
|
|
responseKeys?: string[];
|
|
}> = [];
|
|
pipeline.graph.forEachNode((node) => {
|
|
if (node.label !== 'Route') return;
|
|
found.push({
|
|
path: String(node.properties.name),
|
|
method: node.properties.method as string | undefined,
|
|
handler: node.properties.handlerSymbolId as string | undefined,
|
|
filePath: String(node.properties.filePath),
|
|
responseKeys: node.properties.responseKeys as string[] | undefined,
|
|
});
|
|
});
|
|
return found;
|
|
};
|
|
|
|
it('emits distinct GET and POST Route nodes for the same URL', () => {
|
|
expect(
|
|
routes()
|
|
.map((route) => `${route.method} ${route.path}`)
|
|
.sort(),
|
|
).toEqual(['GET /auth/me', 'GET /users', 'POST /users']);
|
|
});
|
|
|
|
it('resolves free and member handlers across files', () => {
|
|
expect(
|
|
routes().find((route) => route.path === '/users' && route.method === 'GET')?.handler,
|
|
).toMatch(/listUsers/);
|
|
expect(
|
|
routes().find((route) => route.path === '/users' && route.method === 'GET')?.handler,
|
|
).not.toMatch(/handleUsers/);
|
|
expect(
|
|
routes().find((route) => route.path === '/users' && route.method === 'POST')?.handler,
|
|
).toMatch(/createUser/);
|
|
expect(routes().find((route) => route.path === '/auth/me')?.handler).toMatch(/getCurrentUser/);
|
|
});
|
|
|
|
it('links every resolved route from the real cross-file handler file', () => {
|
|
const handled: Array<{ filePath: string; method?: string; path: string; reason: string }> = [];
|
|
result.graph.forEachRelationship((rel) => {
|
|
if (rel.type !== 'HANDLES_ROUTE' || rel.reason !== DATA_ROUTE_TABLE_SOURCE) return;
|
|
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,
|
|
path: String(target.properties.name),
|
|
reason: String(rel.reason),
|
|
});
|
|
});
|
|
expect(
|
|
handled.sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`)),
|
|
).toEqual([
|
|
{
|
|
filePath: 'handlers.js',
|
|
method: 'GET',
|
|
path: '/auth/me',
|
|
reason: DATA_ROUTE_TABLE_SOURCE,
|
|
},
|
|
{
|
|
filePath: 'handlers.js',
|
|
method: 'GET',
|
|
path: '/users',
|
|
reason: DATA_ROUTE_TABLE_SOURCE,
|
|
},
|
|
{
|
|
filePath: 'handlers.js',
|
|
method: 'POST',
|
|
path: '/users',
|
|
reason: DATA_ROUTE_TABLE_SOURCE,
|
|
},
|
|
]);
|
|
});
|
|
|
|
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');
|
|
const currentUser = routes().find((route) => route.path === '/auth/me');
|
|
|
|
expect(getUsers).toMatchObject({ filePath: 'handlers.js', responseKeys: ['users'] });
|
|
expect(postUsers).toMatchObject({ filePath: 'handlers.js', responseKeys: ['createdId'] });
|
|
expect(currentUser).toMatchObject({ filePath: 'handlers.js', responseKeys: ['accountId'] });
|
|
});
|
|
|
|
it('does not emit dynamic, spread, computed, or inline-handler entries', () => {
|
|
const paths = routes().map((route) => route.path);
|
|
expect(paths).not.toContain('/dynamic');
|
|
expect(paths).not.toContain('/spread');
|
|
expect(paths).not.toContain('/computed');
|
|
expect(paths).not.toContain('/inline');
|
|
expect(paths).not.toContain('/unresolved');
|
|
});
|
|
|
|
it('resolves declared jsconfig path aliases for handlers', async () => {
|
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-table-alias-'));
|
|
try {
|
|
fs.mkdirSync(path.join(repoDir, 'src', 'handlers'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'jsconfig.json'),
|
|
JSON.stringify({
|
|
compilerOptions: {
|
|
baseUrl: '.',
|
|
paths: { '@handler': ['src/handlers/user'] },
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'src', 'routes.js'),
|
|
`import { aliasHandler } from '@handler';
|
|
const routes = [{ path: '/alias', method: 'GET', handler: aliasHandler }];
|
|
for (const route of routes) {
|
|
if (route.path === request.path && route.method === request.method) route.handler();
|
|
}`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'src', 'handlers', 'user.js'),
|
|
`export function aliasHandler(_req, res) { return res.json({ id: 'alias' }); }`,
|
|
);
|
|
const aliasResult = await runPipelineFromRepo(repoDir, () => {}, {});
|
|
expect(routes(aliasResult)).toContainEqual(
|
|
expect.objectContaining({
|
|
path: '/alias',
|
|
method: 'GET',
|
|
filePath: 'src/handlers/user.js',
|
|
responseKeys: ['id'],
|
|
}),
|
|
);
|
|
} finally {
|
|
fs.rmSync(repoDir, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
|
|
it('does not infer a default-export handler without explicit provenance', async () => {
|
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-table-default-'));
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'routes.js'),
|
|
`import handleDefault from './handler.js';
|
|
const routes = [{ path: '/default', method: 'GET', handler: handleDefault }];
|
|
for (const route of routes) {
|
|
if (route.path === request.path && route.method === request.method) route.handler();
|
|
}`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'handler.js'),
|
|
`export default function defaultHandler(_req, res) {
|
|
return res.json({ source: 'default' });
|
|
}`,
|
|
);
|
|
|
|
const defaultResult = await runPipelineFromRepo(repoDir, () => {}, {});
|
|
expect(routes(defaultResult)).toEqual([]);
|
|
} finally {
|
|
fs.rmSync(repoDir, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
|
|
it('replays data-table routes from a serialized warm parse cache', async () => {
|
|
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-table-warm-'));
|
|
try {
|
|
const cold: ParseCache = {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map(),
|
|
usedKeys: new Set<string>(),
|
|
storagePath: storageDir,
|
|
onDiskKeys: new Set<string>(),
|
|
};
|
|
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);
|
|
expect(warm).not.toBeNull();
|
|
|
|
const replay = await runPipelineFromRepo(FIXTURE, () => {}, {
|
|
parseCache: warm ?? undefined,
|
|
workerPoolSize: 1,
|
|
});
|
|
expect(replay.usedWorkerPool).toBe(false);
|
|
|
|
const project = (pipeline: PipelineResult) =>
|
|
routes(pipeline)
|
|
.map((route) => ({
|
|
method: route.method,
|
|
path: route.path,
|
|
handler: route.handler,
|
|
filePath: route.filePath,
|
|
responseKeys: route.responseKeys,
|
|
}))
|
|
.sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`));
|
|
const coldProjection = project(coldResult);
|
|
expect(coldProjection).toEqual([
|
|
{
|
|
method: 'GET',
|
|
path: '/auth/me',
|
|
handler: expect.stringMatching(/getCurrentUser/),
|
|
filePath: 'handlers.js',
|
|
responseKeys: ['accountId'],
|
|
},
|
|
{
|
|
method: 'GET',
|
|
path: '/users',
|
|
handler: expect.stringMatching(/listUsers/),
|
|
filePath: 'handlers.js',
|
|
responseKeys: ['users'],
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/users',
|
|
handler: expect.stringMatching(/createUser/),
|
|
filePath: 'handlers.js',
|
|
responseKeys: ['createdId'],
|
|
},
|
|
]);
|
|
expect(project(replay)).toEqual(coldProjection);
|
|
} finally {
|
|
fs.rmSync(storageDir, { recursive: true, force: true });
|
|
}
|
|
}, 120_000);
|
|
});
|