diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index e14b9b701..c8ca5fda0 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index 561590e05..42cbe4f35 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -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 = { 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 = { addRoute(url, { filePath: dr.filePath, source: `decorator-${dr.decoratorName}`, + method: dr.httpMethod, }); } @@ -232,7 +243,7 @@ export const routesPhase: PipelinePhase = { 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 = { properties: { name: routeURL, filePath: handlerPath, + ...(routeMethod ? { method: routeMethod } : {}), ...(responseKeys ? { responseKeys } : {}), ...(errorKeys ? { errorKeys } : {}), ...(middleware && middleware.length > 0 ? { middleware } : {}), diff --git a/gitnexus/test/unit/group/http-route-graph-method.test.ts b/gitnexus/test/unit/group/http-route-graph-method.test.ts new file mode 100644 index 000000000..0162a3678 --- /dev/null +++ b/gitnexus/test/unit/group/http-route-graph-method.test.ts @@ -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(); + +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'); + }); +});