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