feat(ingestion): Java Spring route annotation → Route node extraction (#2078)

* feat(ingestion): add Java Spring route annotation → Route node extraction

Previously, GitNexus only supported Route node generation for JS/TS
ecosystems (Express, Next.js, Fastify, etc.) and Python (FastAPI, Flask).
Java Spring's annotation-based routing (@RequestMapping, @GetMapping,
@PostMapping, etc.) was only supported at the group contract layer
(http-patterns/java.ts) for cross-repo matching, but NOT at the
ingestion layer for generating graph Route nodes.

This commit adds ingestion-layer support:

1. JAVA_QUERIES (tree-sitter-queries.ts):
   - Added method-level annotation captures (@GetMapping, @PostMapping,
     @PutMapping, @DeleteMapping, @PatchMapping) → @decorator captures
   - Added class-level @RequestMapping → @decorator capture (prefix)
   - Supports both positional ("/path") and named (path="/path",
     value="/path") annotation argument forms

2. parse-worker.ts:
   - Java class-level @RequestMapping is detected and stored as a prefix
     (not pushed as a standalone Route)
   - After per-file capture processing, the prefix is applied to all
     method-level routes in the same file via the existing
     ExtractedDecoratorRoute.prefix field
   - The routes phase (normalizeExtractedRoutePath) handles the prefix
     joining, producing final URLs like /api/users/list

3. Tests:
   - Unit test (worker-backed): 4 cases covering prefix joining,
     bare routes, class-level exclusion, multi-file isolation
   - Integration test (full pipeline): 6 cases covering end-to-end
     Route node + HANDLES_ROUTE edge generation

Closes the feature gap where `route_map`, `shape_check`, and
`api_impact` MCP tools returned empty results for Java Spring projects.

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: address review findings — extract spring.ts module, fix PatchMapping, multi-class support

Addresses all P2 findings from tri-review:

1. **Architecture**: Extracted Spring route logic from parse-worker.ts into
   a dedicated `route-extractors/spring.ts` module (matching the pattern
   of `laravel.ts` and `fastapi-router-bindings.ts`). parse-worker now
   has a single dispatch line — no language-specific logic inline.

2. **PatchMapping bug**: Added `'PatchMapping'` to `ROUTE_DECORATOR_NAMES`
   (was silently dropped before).

3. **Multi-class bug**: The new `extractSpringRoutes` walks each class
   declaration independently with its own prefix — no more single-scalar
   `javaClassPrefix` last-wins issue.

4. **Test hygiene**: Unit tests now import `extractSpringRoutes` directly
   (no dist build / worker pool dependency). Tests run in all tiers.

5. **Removed JAVA_QUERIES decorator patterns**: The Spring extractor does
   its own AST walk, so the tree-sitter query captures for Java annotations
   are no longer needed (avoids duplicate route emission).

Additional test coverage:
- Multi-class in one file with independent prefixes
- @PatchMapping support
- Named annotation args (path= and value=) on class-level @RequestMapping

* refactor: move Spring route extraction to LanguageProvider hook

Addresses the second review comment: instead of an inline
`if (language === SupportedLanguages.Java)` dispatch in parse-worker,
the Spring route extraction is now wired through a new optional
`extractDecoratorRoutes` hook on LanguageProviderConfig.

- Added `extractDecoratorRoutes` to LanguageProviderConfig interface
- Java provider registers `extractSpringRoutes` as its implementation
- parse-worker calls `provider.extractDecoratorRoutes?.()` generically
- Removed direct import of spring.ts from parse-worker

This keeps parse-worker fully language-agnostic — no language names
appear in the dispatch path for route extraction.

* refactor: rewrite spring.ts with tree-sitter captures, fix inline imports

Addresses all 4 inline review comments:

1. Rewrote spring.ts to use a single predicate-free Parser.Query
   (same pattern as group-layer JAVA_ROUTE_ANNOTATION_PATTERNS).
   Two-phase loop: first pass collects class prefixes by node.id,
   second pass resolves method routes via findEnclosingClass.
   No more manual DFS / recursion.

2-3. Moved inline import(...) type references in language-provider.ts
     to proper top-level imports (Parser, ExtractedDecoratorRoute).

4. Covered by #1 — recursive helpers removed entirely.

Added 3 extra test cases: non-route named args filtering,
prefix isolation across mixed classes, line number accuracy.

* refactor: extract shared Spring route primitives + add parity test

Addresses review follow-up on #2078:

- Extract the primitives shared by the ingestion (route-extractors/spring.ts)
  and group (http-patterns/java.ts) Spring extractors into a new
  route-extractors/spring-shared.ts: METHOD_ANNOTATION_TO_HTTP,
  findEnclosingClass, isRouteMemberKey, and a safe unquoteSpringLiteral.
  Both extractors now import from it (group -> ingestion, the layer-correct
  direction) so the shared semantics can't drift apart.

- Replace spring.ts's local unquote() with the safer unquoteSpringLiteral
  (returns null for non-string nodes instead of assuming a quoted string).

- Add test/unit/spring-route-extractor-parity.test.ts: runs one shared Spring
  fixture through both extractors and asserts they surface the same provider
  method/path combinations.

The broader HttpRouteExtractor source-scan optimization is tracked in #2138.

---------

Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
henry201605 2026-06-10 16:44:56 +08:00 • committed by GitHub
parent 8848f16060
commit 3d105e00ce
13 changed files with 836 additions and 32 deletions

View file

@ -6,6 +6,11 @@ import {
unquoteLiteral,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import {
METHOD_ANNOTATION_TO_HTTP,
isRouteMemberKey,
findEnclosingClass,
} from '../../../ingestion/route-extractors/spring-shared.js';
import type {
HttpDetection,
HttpFileDetections,
@ -33,14 +38,6 @@ import type {
* OkHttp, Java/Apache HttpClient) keep their own focused queries.
*/
const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
GetMapping: 'GET',
PostMapping: 'POST',
PutMapping: 'PUT',
DeleteMapping: 'DELETE',
PatchMapping: 'PATCH',
};
// Each route-defining annotation has two AST shapes — a positional argument
// and a named one — that must both be matched:
// @RequestMapping("/api") → (annotation_argument_list (string_literal))
@ -361,19 +358,9 @@ const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({
} satisfies LanguagePatterns<Record<string, never>>);
/**
* Find the nearest enclosing class/interface declaration ancestor for
* a node, or null if the node is top-level. Tree-sitter's
* SyntaxNode.parent walks one level at a time.
* Find the nearest enclosing interface declaration ancestor for a node, or
* null if the node is top-level.
*/
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
while (cur) {
if (cur.type === 'class_declaration') return cur;
cur = cur.parent;
}
return null;
}
function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
while (cur) {
@ -439,18 +426,6 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[
return false;
}
/**
* A named annotation argument contributes a route only when its member key is
* `path` or `value`; a positional argument (no key node) always qualifies.
* This is the JS-side replacement for the in-query `^(path|value)$` filter and
* drops Spring's non-route string attributes (`produces`, `consumes`,
* `headers`, `name`, `params`) that would otherwise be mis-read as routes.
*/
function isRouteMemberKey(keyNode: Parser.SyntaxNode | undefined): boolean {
if (!keyNode) return true;
return keyNode.text === 'path' || keyNode.text === 'value';
}
interface MethodRouteAnnotation {
methodNode: Parser.SyntaxNode;
methodName: string | null;

View file

@ -36,6 +36,8 @@ import type { VariableExtractor } from './variable-types.js';
import type { ImportResolverFn } from './import-resolvers/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import type { NodeLabel } from 'gitnexus-shared';
import type Parser from 'tree-sitter';
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
// ── Shared type aliases ────────────────────────────────────────────────────
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
@ -236,6 +238,22 @@ interface LanguageProviderConfig {
* Default: undefined (no route files). */
readonly isRouteFile?: (filePath: string) => boolean;
/**
* Extract decorator-style route annotations from a parsed file.
*
* When defined, the parse worker calls this after per-file capture processing
* to extract framework route definitions that require AST-level analysis beyond
* generic `@decorator` captures (e.g., Java Spring class-level prefix joining,
* multi-class handling). The returned routes are appended to `decoratorRoutes`.
*
* Default: undefined (no language-specific decorator route extraction).
*/
readonly extractDecoratorRoutes?: (
tree: Parser.Tree,
filePath: string,
lineOffset: number,
) => ExtractedDecoratorRoute[];
// ── Noise filtering ────────────────────────────────────────────────
/** Built-in/stdlib names that should be filtered from the call graph for this language.
* Default: undefined (no language-specific filtering). */

View file

@ -13,6 +13,7 @@ import { javaClassConfig } from '../class-extractors/configs/jvm.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { javaTypeConfig } from '../type-extractors/jvm.js';
import { extractSpringRoutes } from '../route-extractors/spring.js';
import { javaExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { javaImportConfig } from '../import-resolvers/configs/jvm.js';
@ -126,4 +127,7 @@ export const javaProvider = defineLanguage({
arityCompatibility: javaArityCompatibility,
resolveImportTarget: resolveJavaImportTarget,
orderSameNameTypeCandidates: orderJavaSameNameTypeCandidates,
// ── Route extraction ──
extractDecoratorRoutes: extractSpringRoutes,
});

View file

@ -0,0 +1,88 @@
/**
* Shared Spring route-annotation primitives.
*
* These are the low-level building blocks the two Spring route extractors —
* the ingestion-layer `route-extractors/spring.ts` (produces graph `Route`
* nodes) and the group-layer `group/extractors/http-patterns/java.ts`
* (produces cross-repo HTTP contracts) — would otherwise each maintain
* independently. Centralising the annotation→method map, the enclosing-class
* lookup, and the route-key filter keeps those semantics in one place so the
* two extractors can't drift apart.
*
* This module lives in `ingestion/` (the lower layer); the group layer imports
* from it, matching the existing `group → ingestion` dependency direction
* (e.g. `group/extractors/include-extractor.ts` already imports
* `ingestion/import-resolvers/utils.ts`). It MUST NOT import anything from
* `group/` to avoid a dependency cycle.
*/
import type Parser from 'tree-sitter';
/**
* Spring shortcut method-annotation → HTTP verb.
*
* `@RequestMapping` is intentionally absent: on a method it carries no implicit
* verb (the verb lives in its `method = RequestMethod.X` attribute), and on a
* class it is a URL prefix rather than a route. Callers handle `@RequestMapping`
* separately.
*/
export const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
GetMapping: 'GET',
PostMapping: 'POST',
PutMapping: 'PUT',
DeleteMapping: 'DELETE',
PatchMapping: 'PATCH',
};
/**
* A named annotation argument contributes a route only when its member key is
* `path` or `value`; a positional argument (no key node) always qualifies.
* Drops Spring's non-route string attributes (`produces`, `consumes`,
* `headers`, `name`, `params`) that would otherwise be mis-read as routes.
*/
export function isRouteMemberKey(keyNode: Parser.SyntaxNode | undefined): boolean {
if (!keyNode) return true;
return keyNode.text === 'path' || keyNode.text === 'value';
}
/**
* Find the nearest enclosing `class_declaration` ancestor for a node, or null
* if the node is top-level. Tree-sitter's `SyntaxNode.parent` walks one level
* at a time.
*/
export function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
while (cur) {
if (cur.type === 'class_declaration') return cur;
cur = cur.parent;
}
return null;
}
/**
* Strip enclosing quotes from a tree-sitter string-literal node's text.
* Handles single / double / template (backtick) quotes and triple-quoted
* strings. Mirrors the safer semantics of the group layer's `unquoteLiteral`:
* returns `null` for empty / nullish input so callers can uniformly skip
* captures whose value is missing, and returns the text unchanged when it
* carries no recognisable surrounding quotes (some grammars expose string
* content without quotes already).
*/
export function unquoteSpringLiteral(raw: string): string | null {
if (!raw) return null;
if (
(raw.startsWith('"""') && raw.endsWith('"""')) ||
(raw.startsWith("'''") && raw.endsWith("'''"))
) {
return raw.slice(3, -3);
}
const first = raw[0];
const last = raw[raw.length - 1];
if ((first === '"' || first === "'" || first === '`') && last === first && raw.length >= 2) {
return raw.slice(1, -1);
}
return raw;
}

View file

@ -0,0 +1,154 @@
/**
* Spring route annotation extractor for the ingestion pipeline.
*
* Extracts `@GetMapping`, `@PostMapping`, `@PutMapping`, `@DeleteMapping`,
* `@PatchMapping`, and `@RequestMapping` annotations from Java source files
* and returns `ExtractedDecoratorRoute[]` with class-level `@RequestMapping`
* prefixes already resolved per-class.
*
* This module is the ingestion-layer counterpart of
* `group/extractors/http-patterns/java.ts` (which extracts HTTP contracts
* for cross-repo matching). It uses the same tree-sitter capture approach:
* a single predicate-free query matches all route annotations generically,
* then a for-loop discriminates class-level prefixes from method-level routes
* by reading `@node.type` and the annotation name.
*
* The query is predicate-free to avoid the tree-sitter 0.21.x hazard where
* `#match?` / `#eq?` predicates in a top-level `[...]` alternation silently
* drop sibling-branch matches (see group-layer `JAVA_ROUTE_ANNOTATION_PATTERNS`
* header comment for details).
*/
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
import {
METHOD_ANNOTATION_TO_HTTP,
isRouteMemberKey,
findEnclosingClass,
unquoteSpringLiteral,
} from './spring-shared.js';
/**
* Single predicate-free tree-sitter query that captures all route annotations
* on classes and methods. Discrimination by annotation name and node type
* happens in the loop below.
*
* Captures:
* @ann → annotation name identifier (RequestMapping, GetMapping, etc.)
* @node → enclosing declaration (class_declaration | method_declaration)
* @value → the string-literal argument
* @key → the named-argument member key (absent for positional form)
*/
const ROUTE_ANNOTATION_QUERY = new Parser.Query(
Java,
`
[
(class_declaration
(modifiers
(annotation
name: (identifier) @ann
arguments: (annotation_argument_list (string_literal) @value)))) @node
(class_declaration
(modifiers
(annotation
name: (identifier) @ann
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key
value: (string_literal) @value))))) @node
(method_declaration
(modifiers
(annotation
name: (identifier) @ann
arguments: (annotation_argument_list (string_literal) @value)))) @node
(method_declaration
(modifiers
(annotation
name: (identifier) @ann
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key
value: (string_literal) @value))))) @node
]
`,
);
/**
* Extract Spring route annotations from a parsed Java file.
*
* Uses a single tree-sitter query pass to capture all annotations, then
* discriminates class-level prefixes from method-level routes in a loop.
* Handles multiple classes per file, each with its own prefix.
*
* @param tree - tree-sitter parse tree
* @param filePath - relative file path (for `ExtractedDecoratorRoute.filePath`)
* @param lineOffset - line offset for pre-processing (usually 0)
* @returns Decorator routes with prefix already set per-class
*/
export function extractSpringRoutes(
tree: Parser.Tree,
filePath: string,
lineOffset = 0,
): ExtractedDecoratorRoute[] {
const matches = ROUTE_ANNOTATION_QUERY.matches(tree.rootNode);
// Phase 1: collect class-level @RequestMapping prefixes keyed by node id
const prefixByClassId = new Map<number, string>();
for (const match of matches) {
const caps: Record<string, Parser.SyntaxNode> = {};
for (const { name, node } of match.captures) {
caps[name] = node;
}
const annNode = caps['ann'];
const node = caps['node'];
const valueNode = caps['value'];
const keyNode = caps['key'];
if (!annNode || !node || !valueNode) continue;
if (node.type === 'class_declaration' && annNode.text === 'RequestMapping') {
if (!isRouteMemberKey(keyNode)) continue;
const prefix = unquoteSpringLiteral(valueNode.text);
if (prefix !== null) prefixByClassId.set(node.id, prefix);
}
}
// Phase 2: collect method-level routes and resolve their class prefix
const routes: ExtractedDecoratorRoute[] = [];
for (const match of matches) {
const caps: Record<string, Parser.SyntaxNode> = {};
for (const { name, node } of match.captures) {
caps[name] = node;
}
const annNode = caps['ann'];
const node = caps['node'];
const valueNode = caps['value'];
const keyNode = caps['key'];
if (!annNode || !node || !valueNode) continue;
if (node.type !== 'method_declaration') continue;
const ann = annNode.text;
const httpMethod = METHOD_ANNOTATION_TO_HTTP[ann];
if (!httpMethod) continue; // skip @RequestMapping on methods (ambiguous verb)
if (!isRouteMemberKey(keyNode)) continue;
const routePath = unquoteSpringLiteral(valueNode.text);
if (routePath === null) continue;
const enclosingClass = findEnclosingClass(node);
const classPrefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
routes.push({
filePath,
routePath,
httpMethod,
decoratorName: ann,
lineNumber: annNode.startPosition.row + lineOffset,
...(classPrefix ? { prefix: classPrefix } : {}),
});
}
return routes;
}

View file

@ -1035,6 +1035,7 @@ const ROUTE_DECORATOR_NAMES = new Set([
'PostMapping',
'PutMapping',
'DeleteMapping',
'PatchMapping',
]);
// ============================================================================
@ -2281,6 +2282,15 @@ const processFileGroup = (
);
}
// Language-specific decorator route extraction via provider hook.
// The provider's extractDecoratorRoutes walks the AST for framework-specific
// route patterns (e.g., Java Spring class-level prefix joining). Routes are
// appended to decoratorRoutes for the routes phase to emit as Route nodes.
if (provider.extractDecoratorRoutes) {
const frameworkRoutes = provider.extractDecoratorRoutes(tree, file.path, lineOffset);
for (const r of frameworkRoutes) result.decoratorRoutes.push(r);
}
// Vue: emit CALLS edges for components used in <template>
if (language === SupportedLanguages.Vue) {
const templateComponents = extractTemplateComponents(file.content);

View file

@ -0,0 +1,20 @@
package com.example.controller;
import org.springframework.web.bind.annotation.*;
/**
* Controller without class-level @RequestMapping — routes should be bare paths.
*/
@RestController
public class HealthController {
@GetMapping("/health")
public String health() {
return "OK";
}
@GetMapping("/ready")
public String ready() {
return "OK";
}
}

View file

@ -0,0 +1,31 @@
package com.example.controller;
import org.springframework.web.bind.annotation.*;
/**
* Two controllers in one file — tests that class prefixes don't bleed.
*/
@RestController
@RequestMapping("/api/admin")
class AdminController {
@GetMapping("/dashboard")
public String dashboard() {
return "admin";
}
@PatchMapping("/settings")
public String updateSettings() {
return "{}";
}
}
@RestController
@RequestMapping("/api/public")
class PublicController {
@GetMapping("/info")
public String info() {
return "public";
}
}

View file

@ -0,0 +1,18 @@
package com.example.controller;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/list")
public String listOrders() {
return "[]";
}
@PostMapping("/submit")
public String submitOrder() {
return "{}";
}
}

View file

@ -0,0 +1,25 @@
package com.example.controller;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/list")
public List<User> listUsers() {
return null;
}
@PostMapping("/create")
public User createUser() {
return null;
}
@DeleteMapping(path = "/delete")
public void deleteUser() {}
@PutMapping(value = "/update")
public void updateUser() {}
}

View file

@ -0,0 +1,107 @@
/**
* End-to-end coverage of Spring @RequestMapping / @GetMapping route ingestion.
*
* This test verifies that the ingestion pipeline correctly:
* 1. Extracts method-level route annotations (@GetMapping, @PostMapping, etc.)
* 2. Joins class-level @RequestMapping prefix with method-level paths
* 3. Handles both positional and named annotation arguments (path = "...", value = "...")
* 4. Generates correct Route nodes without a class prefix when none exists
*
* The fixture lives at `test/fixtures/spring-route-app/`.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-route-app');
describe('Spring @RequestMapping route ingestion pipeline', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(FIXTURE, () => {}, {});
}, 60_000);
function routeNames(): string[] {
const out: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Route') out.push(String(n.properties.name));
});
return out.sort();
}
it('joins class-level @RequestMapping prefix with method-level @GetMapping/@PostMapping', () => {
const names = routeNames();
// UserController: @RequestMapping("/api/users") + @GetMapping("/list")
expect(names).toContain('/api/users/list');
// UserController: @RequestMapping("/api/users") + @PostMapping("/create")
expect(names).toContain('/api/users/create');
});
it('handles named annotation arguments (path = "..." and value = "...")', () => {
const names = routeNames();
// UserController: @DeleteMapping(path = "/delete")
expect(names).toContain('/api/users/delete');
// UserController: @PutMapping(value = "/update")
expect(names).toContain('/api/users/update');
});
it('joins prefix for OrderController routes', () => {
const names = routeNames();
// OrderController: @RequestMapping("/api/orders") + @GetMapping("/list")
expect(names).toContain('/api/orders/list');
// OrderController: @RequestMapping("/api/orders") + @PostMapping("/submit")
expect(names).toContain('/api/orders/submit');
});
it('emits bare paths when no class-level @RequestMapping exists', () => {
const names = routeNames();
// HealthController: no class prefix, @GetMapping("/health") and @GetMapping("/ready")
expect(names).toContain('/health');
expect(names).toContain('/ready');
});
it('does NOT emit class-level @RequestMapping as a standalone Route', () => {
const names = routeNames();
// The prefix "/api/users" alone must not become a Route node
expect(names).not.toContain('/api/users');
expect(names).not.toContain('/api/orders');
});
it('handles multiple classes in one file with independent prefixes', () => {
const names = routeNames();
// MultiController.java: AdminController @RequestMapping("/api/admin") + @GetMapping("/dashboard")
expect(names).toContain('/api/admin/dashboard');
// MultiController.java: PublicController @RequestMapping("/api/public") + @GetMapping("/info")
expect(names).toContain('/api/public/info');
// Prefixes should not bleed between classes
expect(names).not.toContain('/api/public/dashboard');
expect(names).not.toContain('/api/admin/info');
});
it('supports @PatchMapping', () => {
const names = routeNames();
// MultiController.java: AdminController @PatchMapping("/settings")
expect(names).toContain('/api/admin/settings');
});
it('emits HANDLES_ROUTE edges linking Route nodes to their handler files', () => {
const handlesRouteEdges: Array<{ routeName: string; filePath: string }> = [];
result.graph.forEachRelationship((r) => {
if (r.type !== 'HANDLES_ROUTE') return;
const targetNode = result.graph.getNode(r.targetId);
const sourceNode = result.graph.getNode(r.sourceId);
if (targetNode?.label === 'Route' && sourceNode?.label === 'File') {
handlesRouteEdges.push({
routeName: String(targetNode.properties.name),
filePath: String(sourceNode.properties.name),
});
}
});
// At least one route should be linked to the UserController file
const userRoutes = handlesRouteEdges.filter((e) => e.filePath.includes('UserController.java'));
expect(userRoutes.length).toBeGreaterThanOrEqual(1);
});
});

View file

@ -0,0 +1,242 @@
/**
* Unit test: Java Spring @RequestMapping / @GetMapping route extraction
* via the dedicated `route-extractors/spring.ts` module.
*
* Tests the extractSpringRoutes function directly (no worker pool needed)
* and validates per-class prefix resolution, multi-class handling, and
* all HTTP method annotations.
*/
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import { extractSpringRoutes } from '../../src/core/ingestion/route-extractors/spring.js';
function parse(code: string): Parser.Tree {
const parser = new Parser();
parser.setLanguage(Java);
return parser.parse(code);
}
describe('extractSpringRoutes', () => {
it('extracts method-level routes with class-level @RequestMapping prefix', () => {
const tree = parse(`
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/list")
public List<User> listUsers() { return null; }
@PostMapping("/create")
public User createUser() { return null; }
@DeleteMapping(path = "/delete")
public void deleteUser() {}
@PutMapping(value = "/update")
public void updateUser() {}
@PatchMapping("/patch")
public void patchUser() {}
}
`);
const routes = extractSpringRoutes(tree, 'UserController.java');
expect(routes).toHaveLength(5);
const byMethod = new Map(routes.map((r) => [r.httpMethod, r]));
expect(byMethod.get('GET')!.routePath).toBe('/list');
expect(byMethod.get('GET')!.prefix).toBe('/api/users');
expect(byMethod.get('POST')!.routePath).toBe('/create');
expect(byMethod.get('POST')!.prefix).toBe('/api/users');
expect(byMethod.get('DELETE')!.routePath).toBe('/delete');
expect(byMethod.get('DELETE')!.prefix).toBe('/api/users');
expect(byMethod.get('PUT')!.routePath).toBe('/update');
expect(byMethod.get('PUT')!.prefix).toBe('/api/users');
expect(byMethod.get('PATCH')!.routePath).toBe('/patch');
expect(byMethod.get('PATCH')!.prefix).toBe('/api/users');
});
it('emits bare routes when no class-level @RequestMapping exists', () => {
const tree = parse(`
@RestController
public class HealthController {
@GetMapping("/health")
public String health() { return "OK"; }
@GetMapping("/ready")
public String ready() { return "OK"; }
}
`);
const routes = extractSpringRoutes(tree, 'HealthController.java');
expect(routes).toHaveLength(2);
for (const route of routes) {
expect(route.prefix).toBeUndefined();
}
const paths = routes.map((r) => r.routePath).sort();
expect(paths).toEqual(['/health', '/ready']);
});
it('does NOT emit class-level @RequestMapping as a standalone Route', () => {
const tree = parse(`
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/list")
public List<User> listUsers() { return null; }
}
`);
const routes = extractSpringRoutes(tree, 'UserController.java');
// Only the method-level route, not the class-level prefix
expect(routes).toHaveLength(1);
expect(routes[0].decoratorName).toBe('GetMapping');
});
it('handles multiple classes in one file with independent prefixes', () => {
const tree = parse(`
@RestController
@RequestMapping("/api/admin")
class AdminController {
@GetMapping("/dashboard")
public String dashboard() { return "admin"; }
}
@RestController
@RequestMapping("/api/public")
class PublicController {
@GetMapping("/info")
public String info() { return "public"; }
}
`);
const routes = extractSpringRoutes(tree, 'MultiController.java');
expect(routes).toHaveLength(2);
const adminRoute = routes.find((r) => r.routePath === '/dashboard');
expect(adminRoute).toBeDefined();
expect(adminRoute!.prefix).toBe('/api/admin');
const publicRoute = routes.find((r) => r.routePath === '/info');
expect(publicRoute).toBeDefined();
expect(publicRoute!.prefix).toBe('/api/public');
});
it('supports @PatchMapping (previously missing)', () => {
const tree = parse(`
@RestController
@RequestMapping("/api")
public class PatchController {
@PatchMapping("/update")
public void patch() {}
}
`);
const routes = extractSpringRoutes(tree, 'PatchController.java');
expect(routes).toHaveLength(1);
expect(routes[0].httpMethod).toBe('PATCH');
expect(routes[0].routePath).toBe('/update');
expect(routes[0].prefix).toBe('/api');
});
it('handles named annotation arguments with path= and value=', () => {
const tree = parse(`
@RestController
@RequestMapping(value = "/api/v2")
public class V2Controller {
@GetMapping(path = "/items")
public String items() { return "[]"; }
@PostMapping(value = "/items")
public String createItem() { return "{}"; }
}
`);
const routes = extractSpringRoutes(tree, 'V2Controller.java');
expect(routes).toHaveLength(2);
for (const route of routes) {
expect(route.prefix).toBe('/api/v2');
expect(route.routePath).toBe('/items');
}
});
it('ignores non-route named args like produces/consumes', () => {
const tree = parse(`
@RestController
@RequestMapping("/api")
public class MediaController {
@GetMapping(value = "/json", produces = "application/json")
public String json() { return "{}"; }
}
`);
const routes = extractSpringRoutes(tree, 'MediaController.java');
// Should only extract the route path, not the produces value
expect(routes).toHaveLength(1);
expect(routes[0].routePath).toBe('/json');
expect(routes[0].prefix).toBe('/api');
});
it('does not bleed prefix across unrelated classes', () => {
const tree = parse(`
@RestController
@RequestMapping("/api/v1")
class V1Controller {
@GetMapping("/old")
public String old() { return "v1"; }
}
@RestController
class NoPrefix {
@GetMapping("/bare")
public String bare() { return "no prefix"; }
}
@RestController
@RequestMapping("/api/v3")
class V3Controller {
@GetMapping("/new")
public String newer() { return "v3"; }
}
`);
const routes = extractSpringRoutes(tree, 'Multi.java');
expect(routes).toHaveLength(3);
const v1 = routes.find((r) => r.routePath === '/old');
expect(v1!.prefix).toBe('/api/v1');
const bare = routes.find((r) => r.routePath === '/bare');
expect(bare!.prefix).toBeUndefined();
const v3 = routes.find((r) => r.routePath === '/new');
expect(v3!.prefix).toBe('/api/v3');
});
it('reports correct line numbers', () => {
const tree = parse(`@RestController
@RequestMapping("/api")
public class LineTest {
@GetMapping("/first")
public String first() { return "1"; }
@PostMapping("/second")
public String second() { return "2"; }
}
`);
const routes = extractSpringRoutes(tree, 'LineTest.java');
expect(routes).toHaveLength(2);
// @GetMapping is on line index 3 (0-based)
const first = routes.find((r) => r.routePath === '/first');
expect(first!.lineNumber).toBe(3);
// @PostMapping is on line index 6
const second = routes.find((r) => r.routePath === '/second');
expect(second!.lineNumber).toBe(6);
});
});

View file

@ -0,0 +1,112 @@
/**
* Parity test: the two Spring route extractors must agree.
*
* The ingestion-layer extractor (`route-extractors/spring.ts`, producing
* graph `Route` nodes) and the group-layer extractor
* (`group/extractors/http-patterns/java.ts`, producing cross-repo HTTP
* contracts) share their low-level primitives via `route-extractors/
* spring-shared.ts`. They keep separate output shapes and the group layer
* carries extra consumer/inheritance handling the ingestion side doesn't
* need — but for plain Spring `@RestController` providers they MUST surface
* the same set of (HTTP method, full URL) pairs.
*
* This test parses one shared fixture through both extractors and asserts
* the provider method/path combinations match, so the two implementations
* can't drift apart on the semantics they share.
*/
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import { extractSpringRoutes } from '../../src/core/ingestion/route-extractors/spring.js';
import { JAVA_HTTP_PLUGIN } from '../../src/core/group/extractors/http-patterns/java.js';
import { normalizeExtractedRoutePath } from '../../src/core/ingestion/pipeline-phases/routes.js';
function parse(code: string): Parser.Tree {
const parser = new Parser();
parser.setLanguage(Java);
return parser.parse(code);
}
/** Shared fixture: a Spring controller exercising every supported verb,
* positional + named args, and a class-level @RequestMapping prefix. */
const SPRING_FIXTURE = `
package com.example.controller;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/list")
public String list() { return "[]"; }
@PostMapping("/create")
public String create() { return "{}"; }
@PutMapping(value = "/update")
public void update() {}
@DeleteMapping(path = "/delete")
public void delete() {}
@PatchMapping("/patch")
public void patch() {}
}
`;
/** Normalize an ingestion route (separate routePath + prefix) to a full URL. */
function ingestionPairs(tree: Parser.Tree): Set<string> {
return new Set(
extractSpringRoutes(tree, 'UserController.java').map(
(r) => `${r.httpMethod} ${normalizeExtractedRoutePath(r.routePath, r.prefix ?? null)}`,
),
);
}
/** Normalize a group provider detection (path already prefix-joined) to a full URL. */
function groupPairs(tree: Parser.Tree): Set<string> {
return new Set(
JAVA_HTTP_PLUGIN.scan(tree)
.filter((d) => d.role === 'provider')
.map((d) => `${d.method} ${normalizeExtractedRoutePath(d.path, null)}`),
);
}
describe('Spring route extractor parity (ingestion vs group)', () => {
it('both extractors surface the same provider method/path combinations', () => {
const tree = parse(SPRING_FIXTURE);
const ingestion = ingestionPairs(tree);
const group = groupPairs(tree);
// Both must agree on the full set — no route only one side sees.
expect([...ingestion].sort()).toEqual([...group].sort());
// Sanity: the expected combinations are actually present (guards against
// both extractors silently agreeing on an empty set).
expect(ingestion).toEqual(
new Set([
'GET /api/users/list',
'POST /api/users/create',
'PUT /api/users/update',
'DELETE /api/users/delete',
'PATCH /api/users/patch',
]),
);
});
it('agree when there is no class-level prefix', () => {
const tree = parse(`
@RestController
public class HealthController {
@GetMapping("/health")
public String health() { return "OK"; }
@PostMapping("/ping")
public String ping() { return "OK"; }
}
`);
expect([...ingestionPairs(tree)].sort()).toEqual([...groupPairs(tree)].sort());
});
});