GitNexus/gitnexus/test/unit/java-spring-route-ingestion.test.ts
henry201605 36ca096e75
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>
2026-06-10 09:44:56 +01:00

242 lines
7 KiB
TypeScript

/**
* 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);
});
});