mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
fix: detect Spring Boot request mappings as routes
Add a Spring-specific framework route extractor so RequestMapping-style annotations produce Route nodes and handler links in both worker and sequential parsing paths. Made-with: Cursor
This commit is contained in:
parent
dcf40da3a8
commit
a86ed50bfd
8 changed files with 317 additions and 5 deletions
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
197
gitnexus/src/core/ingestion/route-extractors/spring-java.ts
Normal file
197
gitnexus/src/core/ingestion/route-extractors/spring-java.ts
Normal file
|
|
@ -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<string>): 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue