diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ec7262c3b..41b6844cc 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1701,7 +1701,7 @@ export const processAssignmentsFromExtracted = ( }; /** - * Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods. + * Resolve pre-extracted framework routes to CALLS edges from handler files to controller methods. */ export const processRoutesFromExtracted = async ( graph: KnowledgeGraph, @@ -1739,7 +1739,7 @@ export const processRoutesFromExtracted = async ( targetId: guessedId, type: 'CALLS', confidence: confidence * 0.8, - reason: 'laravel-route', + reason: 'framework-route', }); continue; } @@ -1751,7 +1751,7 @@ export const processRoutesFromExtracted = async ( targetId: methodId, type: 'CALLS', confidence, - reason: 'laravel-route', + reason: 'framework-route', }); } diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 8f18056d3..9f339f689 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -19,6 +19,15 @@ import { javaConfig } from '../field-extractors/configs/jvm.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; +function isSpringRouteFile(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/'); + return ( + normalized.endsWith('Controller.java') || + normalized.includes('/controller/') || + normalized.includes('/controllers/') + ); +} + export const javaProvider = defineLanguage({ id: SupportedLanguages.Java, extensions: ['.java'], @@ -31,4 +40,5 @@ export const javaProvider = defineLanguage({ mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(javaConfig), methodExtractor: createMethodExtractor(javaMethodConfig), + isRouteFile: isSpringRouteFile, }); diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 5f7d21fe1..376a3126a 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -27,6 +27,7 @@ import { import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js'; import { expoFileToRouteURL } from './route-extractors/expo.js'; import { phpFileToRouteURL } from './route-extractors/php.js'; +import { extractSpringJavaRoutes } from './route-extractors/spring-java.js'; import { extractResponseShapes, extractPHPResponseShapes, @@ -1014,6 +1015,19 @@ async function runChunkedParseAndResolve( if (rubyHeritage.length > 0) { await processHeritageFromExtracted(graph, rubyHeritage, ctx); } + const chunkSpringRoutes: ExtractedRoute[] = []; + for (const file of chunkFiles) { + const provider = getProviderForFile(file.path); + const language = getLanguageFromFilename(file.path); + if (language !== SupportedLanguages.Java || !provider?.isRouteFile?.(file.path)) continue; + const tree = astCache.get(file.path); + if (!tree) continue; + chunkSpringRoutes.push(...extractSpringJavaRoutes(tree, file.path)); + } + if (chunkSpringRoutes.length > 0) { + await processRoutesFromExtracted(graph, chunkSpringRoutes, ctx); + allExtractedRoutes.push(...chunkSpringRoutes); + } // Extract fetch() calls for Next.js route matching (sequential path) const chunkFetchCalls = await extractFetchCallsFromFiles(chunkFiles, astCache); if (chunkFetchCalls.length > 0) { diff --git a/gitnexus/src/core/ingestion/route-extractors/spring-java.ts b/gitnexus/src/core/ingestion/route-extractors/spring-java.ts new file mode 100644 index 000000000..e509ced64 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/spring-java.ts @@ -0,0 +1,197 @@ +import type Parser from 'tree-sitter'; +import type { ExtractedRoute } from '../workers/parse-worker.js'; +import { findChild, type SyntaxNode } from '../utils/ast-helpers.js'; + +const CONTROLLER_ANNOTATIONS = new Set(['Controller', 'RestController']); +const SHORTCUT_HTTP_METHODS = new Map([ + ['GetMapping', 'GET'], + ['PostMapping', 'POST'], + ['PutMapping', 'PUT'], + ['DeleteMapping', 'DELETE'], + ['PatchMapping', 'PATCH'], +]); +const REQUEST_MAPPING_ANNOTATIONS = new Set([ + 'RequestMapping', + ...SHORTCUT_HTTP_METHODS.keys(), +]); + +function getAnnotationName(node: SyntaxNode): string | null { + const nameNode = node.childForFieldName('name') ?? node.firstNamedChild; + return nameNode?.text ?? null; +} + +function extractJavaString(node: SyntaxNode | null | undefined): string | null { + if (!node) return null; + if (node.type === 'string_fragment') return node.text; + if (node.type === 'string_literal') { + const fragment = node.namedChildren.find((child) => child.type === 'string_fragment'); + if (fragment) return fragment.text; + return node.text.replace(/^"/, '').replace(/"$/, ''); + } + return null; +} + +function getElementValuePairParts(node: SyntaxNode): { key: string | null; value: SyntaxNode | null } { + const keyNode = node.childForFieldName('key') ?? node.namedChild(0); + const valueNode = node.childForFieldName('value') ?? node.namedChild(1); + return { + key: keyNode?.text ?? null, + value: valueNode ?? null, + }; +} + +function extractRequestMappingPath(annotation: SyntaxNode): string | null { + const argsNode = findChild(annotation, 'annotation_argument_list'); + if (!argsNode) return null; + + for (let i = 0; i < argsNode.namedChildCount; i++) { + const child = argsNode.namedChild(i); + if (!child) continue; + if (child.type === 'string_literal') { + return extractJavaString(child); + } + if (child.type === 'element_value_pair') { + const { key, value } = getElementValuePairParts(child); + if ((key === 'value' || key === 'path') && value) { + return extractJavaString(value); + } + } + } + + return null; +} + +function extractRequestMethodName(annotation: SyntaxNode, annotationName: string): string { + const shortcutMethod = SHORTCUT_HTTP_METHODS.get(annotationName); + if (shortcutMethod) return shortcutMethod; + if (annotationName !== 'RequestMapping') return 'GET'; + + const argsNode = findChild(annotation, 'annotation_argument_list'); + if (!argsNode) return 'GET'; + + for (let i = 0; i < argsNode.namedChildCount; i++) { + const child = argsNode.namedChild(i); + if (!child || child.type !== 'element_value_pair') continue; + const { key, value } = getElementValuePairParts(child); + if (key !== 'method' || !value) continue; + + if (value.type === 'field_access') { + return value.text.split('.').pop() ?? 'GET'; + } + + if (value.type === 'array_initializer') { + for (let j = 0; j < value.namedChildCount; j++) { + const candidate = value.namedChild(j); + if (candidate?.type === 'field_access') { + return candidate.text.split('.').pop() ?? 'GET'; + } + } + } + } + + return 'GET'; +} + +function normalizePath(path: string | null): string | null { + if (path == null) return null; + const trimmed = path.trim(); + if (!trimmed) return null; + const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + return withLeadingSlash.replace(/\/+/g, '/'); +} + +function joinRoutePaths(prefix: string | null, routePath: string | null): string { + const normalizedPrefix = normalizePath(prefix); + const normalizedRoutePath = normalizePath(routePath); + + if (normalizedPrefix && normalizedRoutePath) { + return `${normalizedPrefix}/${normalizedRoutePath.replace(/^\/+/, '')}`.replace(/\/+/g, '/'); + } + if (normalizedPrefix) return normalizedPrefix; + if (normalizedRoutePath) return normalizedRoutePath; + return '/'; +} + +function findAnnotation(modifiersNode: SyntaxNode | null, names: ReadonlySet): SyntaxNode | null { + if (!modifiersNode) return null; + + for (let i = 0; i < modifiersNode.namedChildCount; i++) { + const child = modifiersNode.namedChild(i); + if (!child || (child.type !== 'annotation' && child.type !== 'marker_annotation')) continue; + const name = getAnnotationName(child); + if (name && names.has(name)) return child; + } + + return null; +} + +function isSpringController(modifiersNode: SyntaxNode | null): boolean { + return findAnnotation(modifiersNode, CONTROLLER_ANNOTATIONS) !== null; +} + +function extractClassLevelPrefix(classNode: SyntaxNode): string | null { + const modifiersNode = findChild(classNode, 'modifiers'); + const requestMapping = findAnnotation(modifiersNode, new Set(['RequestMapping'])); + return requestMapping ? extractRequestMappingPath(requestMapping) : null; +} + +function extractMethodRoute( + filePath: string, + className: string, + classPrefix: string | null, + methodNode: SyntaxNode, +): ExtractedRoute | null { + const modifiersNode = findChild(methodNode, 'modifiers'); + if (!modifiersNode) return null; + + const mappingAnnotation = findAnnotation(modifiersNode, REQUEST_MAPPING_ANNOTATIONS); + if (!mappingAnnotation) return null; + + const annotationName = getAnnotationName(mappingAnnotation); + const methodName = methodNode.childForFieldName('name')?.text ?? null; + if (!annotationName || !methodName) return null; + + const methodPath = extractRequestMappingPath(mappingAnnotation); + + return { + filePath, + httpMethod: extractRequestMethodName(mappingAnnotation, annotationName), + routePath: joinRoutePaths(classPrefix, methodPath), + controllerName: className, + methodName, + middleware: [], + prefix: normalizePath(classPrefix), + lineNumber: mappingAnnotation.startPosition.row, + }; +} + +function walkClasses(node: SyntaxNode, visit: (classNode: SyntaxNode) => void): void { + if (node.type === 'class_declaration') visit(node); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) walkClasses(child, visit); + } +} + +export function extractSpringJavaRoutes(tree: Parser.Tree, filePath: string): ExtractedRoute[] { + const routes: ExtractedRoute[] = []; + + walkClasses(tree.rootNode, (classNode) => { + const modifiersNode = findChild(classNode, 'modifiers'); + if (!isSpringController(modifiersNode)) return; + + const className = classNode.childForFieldName('name')?.text; + const classBody = classNode.childForFieldName('body'); + if (!className || !classBody) return; + + const classPrefix = extractClassLevelPrefix(classNode); + for (let i = 0; i < classBody.namedChildCount; i++) { + const child = classBody.namedChild(i); + if (!child || child.type !== 'method_declaration') continue; + const route = extractMethodRoute(filePath, className, classPrefix, child); + if (route) routes.push(route); + } + }); + + return routes; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 20909db11..be4c10714 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -63,6 +63,7 @@ import { extractParsedCallSite } from '../call-sites/extract-language-call-site. import { buildTypeEnv } from '../type-env.js'; import type { ConstructorBinding } from '../type-env.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; +import { extractSpringJavaRoutes } from '../route-extractors/spring-java.js'; import { generateId } from '../../../lib/utils.js'; import { preprocessImportPath } from '../import-processor.js'; import type { NamedBinding } from '../named-bindings/types.js'; @@ -1887,9 +1888,14 @@ const processFileGroup = ( } } - // Extract framework routes via provider detection (e.g., Laravel routes.php) + // Extract framework routes via provider detection (e.g., Laravel routes.php, Spring controllers) if (provider.isRouteFile?.(file.path)) { - const extractedRoutes = extractLaravelRoutes(tree, file.path); + let extractedRoutes: ExtractedRoute[] = []; + if (language === SupportedLanguages.PHP) { + extractedRoutes = extractLaravelRoutes(tree, file.path); + } else if (language === SupportedLanguages.Java) { + extractedRoutes = extractSpringJavaRoutes(tree, file.path); + } result.routes.push(...extractedRoutes); } diff --git a/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/HealthController.java b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/HealthController.java new file mode 100644 index 000000000..7896e8bc8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/HealthController.java @@ -0,0 +1,13 @@ +package com.example.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthController { + @RequestMapping(path = "/health", method = RequestMethod.GET) + public String health() { + return "ok"; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/UserController.java b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/UserController.java new file mode 100644 index 000000000..7fae149f3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/UserController.java @@ -0,0 +1,26 @@ +package com.example.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api") +public class UserController { + @GetMapping("/users") + public String listUsers() { + return "users"; + } + + @PostMapping(path = "/users/create") + public String createUser() { + return "created"; + } + + @PatchMapping("/users/profile") + public String updateProfile() { + return "updated"; + } +} diff --git a/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts b/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts new file mode 100644 index 000000000..ad529a5cb --- /dev/null +++ b/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +describe('Spring route mapping', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'spring-route-mapping'), () => {}); + }, 60000); + + it('creates Route nodes for Spring request mappings', () => { + const routes = getNodesByLabel(result, 'Route'); + expect(routes).toContain('/api/users'); + expect(routes).toContain('/api/users/create'); + expect(routes).toContain('/api/users/profile'); + expect(routes).toContain('/health'); + }); + + it('creates HANDLES_ROUTE edges from controller files', () => { + const edges = getRelationships(result, 'HANDLES_ROUTE'); + const usersRoute = edges.find((edge) => edge.target === '/api/users'); + const healthRoute = edges.find((edge) => edge.target === '/health'); + + expect(usersRoute).toBeDefined(); + expect(usersRoute!.sourceFilePath).toContain('UserController.java'); + expect(healthRoute).toBeDefined(); + expect(healthRoute!.sourceFilePath).toContain('HealthController.java'); + }); + + it('creates framework CALLS edges from controller files to handler methods', () => { + const edges = getRelationships(result, 'CALLS'); + expect( + edges.some((edge) => edge.source === 'UserController.java' && edge.target === 'listUsers'), + ).toBe(true); + expect( + edges.some((edge) => edge.source === 'HealthController.java' && edge.target === 'health'), + ).toBe(true); + }); +});