mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
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:
parent
8848f16060
commit
3d105e00ce
13 changed files with 836 additions and 32 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
154
gitnexus/src/core/ingestion/route-extractors/spring.ts
Normal file
154
gitnexus/src/core/ingestion/route-extractors/spring.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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 "{}";
|
||||
}
|
||||
}
|
||||
25
gitnexus/test/fixtures/spring-route-app/src/main/java/com/example/controller/UserController.java
vendored
Normal file
25
gitnexus/test/fixtures/spring-route-app/src/main/java/com/example/controller/UserController.java
vendored
Normal 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() {}
|
||||
}
|
||||
107
gitnexus/test/integration/spring-route-pipeline.test.ts
Normal file
107
gitnexus/test/integration/spring-route-pipeline.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
242
gitnexus/test/unit/java-spring-route-ingestion.test.ts
Normal file
242
gitnexus/test/unit/java-spring-route-ingestion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
112
gitnexus/test/unit/spring-route-extractor-parity.test.ts
Normal file
112
gitnexus/test/unit/spring-route-extractor-parity.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue