mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +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>
192 lines
7.6 KiB
TypeScript
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),
|
|
]);
|
|
});
|
|
});
|