mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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>
242 lines
7 KiB
TypeScript
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);
|
|
});
|
|
});
|