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:
henry 2026-06-17 19:42:30 +08:00
parent 72876ab69a
commit 4e0070612a
3 changed files with 251 additions and 2 deletions

View file

@ -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

View file

@ -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 } : {}),

View 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');
});
});