From 3d105e00cecc525eadd74a3702ecd28216053d0a Mon Sep 17 00:00:00 2001 From: henry201605 <31428013+henry201605@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:44:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(ingestion):=20Java=20Spring=20route=20anno?= =?UTF-8?q?tation=20=E2=86=92=20Route=20node=20extraction=20(#2078)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- .../group/extractors/http-patterns/java.ts | 39 +-- .../src/core/ingestion/language-provider.ts | 18 ++ gitnexus/src/core/ingestion/languages/java.ts | 4 + .../route-extractors/spring-shared.ts | 88 +++++++ .../core/ingestion/route-extractors/spring.ts | 154 +++++++++++ .../core/ingestion/workers/parse-worker.ts | 10 + .../example/controller/HealthController.java | 20 ++ .../example/controller/MultiController.java | 31 +++ .../example/controller/OrderController.java | 18 ++ .../example/controller/UserController.java | 25 ++ .../integration/spring-route-pipeline.test.ts | 107 ++++++++ .../unit/java-spring-route-ingestion.test.ts | 242 ++++++++++++++++++ .../spring-route-extractor-parity.test.ts | 112 ++++++++ 13 files changed, 836 insertions(+), 32 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/spring-shared.ts create mode 100644 gitnexus/src/core/ingestion/route-extractors/spring.ts create mode 100644 gitnexus/test/fixtures/spring-route-app/src/main/java/com/example/controller/HealthController.java create mode 100644 gitnexus/test/fixtures/spring-route-app/src/main/java/com/example/controller/MultiController.java create mode 100644 gitnexus/test/fixtures/spring-route-app/src/main/java/com/example/controller/OrderController.java create mode 100644 gitnexus/test/fixtures/spring-route-app/src/main/java/com/example/controller/UserController.java create mode 100644 gitnexus/test/integration/spring-route-pipeline.test.ts create mode 100644 gitnexus/test/unit/java-spring-route-ingestion.test.ts create mode 100644 gitnexus/test/unit/spring-route-extractor-parity.test.ts diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 5d452fd1fa..3ad66543ef 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -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 = { - 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>); /** - * 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; diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index b375050fa0..8edde3dca9 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -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). */ diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index b44464ddc2..f7ca23de3d 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -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, }); diff --git a/gitnexus/src/core/ingestion/route-extractors/spring-shared.ts b/gitnexus/src/core/ingestion/route-extractors/spring-shared.ts new file mode 100644 index 0000000000..6d3ea1e5d2 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/spring-shared.ts @@ -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 = { + 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; +} diff --git a/gitnexus/src/core/ingestion/route-extractors/spring.ts b/gitnexus/src/core/ingestion/route-extractors/spring.ts new file mode 100644 index 0000000000..4476e55cc9 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/spring.ts @@ -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(); + + for (const match of matches) { + const caps: Record = {}; + 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 = {}; + 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; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 681804ee50..aacc41e79e 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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