mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
feat(routes): persist HTTP method on Route nodes
Part 1 of 2 for issue #2138 (skip redundant HTTP provider source-scan). The ingestion routes phase already knows each route's HTTP verb — `ExtractedRoute.httpMethod` (Spring/Laravel framework routes) and `ExtractedDecoratorRoute.httpMethod` (decorator routes) — but dropped it when creating the Route graph node. As a result `HttpRouteExtractor`'s graph-assisted path could not recover the verb for `framework-route` sources (whose edge `reason` is undecodable by `methodFromRouteReason`) and had to fall back to re-scanning the handler source. Changes: - routes phase: carry `httpMethod` into `RouteEntry` and persist it as `Route.method` (filesystem-derived Next.js/Expo/PHP routes have no structural verb, so they stay method-less). - HttpRouteExtractor: HANDLES_ROUTE query now returns `route.method`; `extractProvidersGraph` prefers it and falls back to the edge reason for older indexes / method-less routes (fail-open, fully backward compatible). - tests: graph-method precedence, multi-verb handler disambiguation via the persisted verb, case normalization, and old-index fallback. This change is intentionally NOT a performance optimization on its own: the graph path still parses handler files to recover the handler *name*. Eliminating that parse (and thus the redundant source-scan #2138 targets) requires linking HANDLES_ROUTE to the handler symbol, which lands in Part 2. This PR is the data-completeness groundwork for that. Refs #2138
This commit is contained in:
parent
72876ab69a
commit
4e0070612a
3 changed files with 251 additions and 2 deletions
|
|
@ -44,6 +44,7 @@ const HANDLES_ROUTE_QUERY = `
|
|||
MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
|
||||
RETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath,
|
||||
route.name AS routePath, route.id AS routeId,
|
||||
route.method AS routeMethod,
|
||||
route.responseKeys AS responseKeys,
|
||||
r.reason AS routeSource`;
|
||||
|
||||
|
|
@ -332,7 +333,14 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
const filePath = String(row.filePath ?? '');
|
||||
const routePath = String(row.routePath ?? '');
|
||||
const routeSource = String(row.routeSource ?? row.routeReason ?? '');
|
||||
let method = methodFromRouteReason(routeSource);
|
||||
// Prefer the HTTP verb persisted on the Route node by the ingestion
|
||||
// routes phase (Spring/Laravel framework routes and decorator routes
|
||||
// carry it). Fall back to parsing it out of the edge reason for
|
||||
// older indexes or filesystem routes that never stored a method.
|
||||
const graphMethod = String(row.routeMethod ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
let method = (graphMethod || null) ?? methodFromRouteReason(routeSource);
|
||||
|
||||
// Look up handler name (and backfill method if missing) from the
|
||||
// plugin's scan of the handler file. This replaces the old
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ const EXPO_NAV_PATTERNS = [
|
|||
export interface RouteEntry {
|
||||
filePath: string;
|
||||
source: string;
|
||||
/**
|
||||
* HTTP verb for this route when ingestion knows it structurally
|
||||
* (Spring/Laravel framework routes and decorator routes carry
|
||||
* `httpMethod`; filesystem-derived routes — Next.js/Expo/PHP file
|
||||
* routes — do not, so this stays undefined for them). Persisted onto
|
||||
* the Route node so downstream contract extraction can read the verb
|
||||
* from the graph instead of re-parsing the handler source.
|
||||
*/
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export interface RoutesOutput {
|
||||
|
|
@ -213,6 +222,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
addRoute(routeUrl, {
|
||||
filePath: route.filePath,
|
||||
source: 'framework-route',
|
||||
method: route.httpMethod,
|
||||
});
|
||||
if (route.routeName && !namedRouteRegistry.has(route.routeName)) {
|
||||
namedRouteRegistry.set(route.routeName, routeUrl);
|
||||
|
|
@ -223,6 +233,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
addRoute(url, {
|
||||
filePath: dr.filePath,
|
||||
source: `decorator-${dr.decoratorName}`,
|
||||
method: dr.httpMethod,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +243,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
handlerContents = await readFileContents(ctx.repoPath, handlerPaths);
|
||||
|
||||
for (const [routeURL, entry] of routeRegistry) {
|
||||
const { filePath: handlerPath, source: routeSource } = entry;
|
||||
const { filePath: handlerPath, source: routeSource, method: routeMethod } = entry;
|
||||
const content = handlerContents.get(handlerPath);
|
||||
|
||||
const { responseKeys, errorKeys } = content
|
||||
|
|
@ -251,6 +262,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
properties: {
|
||||
name: routeURL,
|
||||
filePath: handlerPath,
|
||||
...(routeMethod ? { method: routeMethod } : {}),
|
||||
...(responseKeys ? { responseKeys } : {}),
|
||||
...(errorKeys ? { errorKeys } : {}),
|
||||
...(middleware && middleware.length > 0 ? { middleware } : {}),
|
||||
|
|
|
|||
229
gitnexus/test/unit/group/http-route-graph-method.test.ts
Normal file
229
gitnexus/test/unit/group/http-route-graph-method.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* 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('CONTAINS')) 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('CONTAINS')) 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('CONTAINS')) 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('CONTAINS')) 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('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('CONTAINS')) 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue