Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 7 additions & 32 deletions gitnexus/src/core/group/extractors/http-patterns/java.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions gitnexus/src/core/ingestion/language-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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). */
Expand Down
4 changes: 4 additions & 0 deletions gitnexus/src/core/ingestion/languages/java.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -126,4 +127,7 @@ export const javaProvider = defineLanguage({
arityCompatibility: javaArityCompatibility,
resolveImportTarget: resolveJavaImportTarget,
orderSameNameTypeCandidates: orderJavaSameNameTypeCandidates,

// ── Route extraction ──
extractDecoratorRoutes: extractSpringRoutes,
});
88 changes: 88 additions & 0 deletions gitnexus/src/core/ingestion/route-extractors/spring-shared.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 154 additions & 0 deletions gitnexus/src/core/ingestion/route-extractors/spring.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions gitnexus/src/core/ingestion/workers/parse-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,7 @@ const ROUTE_DECORATOR_NAMES = new Set([
'PostMapping',
'PutMapping',
'DeleteMapping',
'PatchMapping',
]);

// ============================================================================
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading