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:
许恩宁 2026-04-01 21:40:21 +08:00
parent dcf40da3a8
commit a86ed50bfd
8 changed files with 317 additions and 5 deletions

View file

@ -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',
});
}

View file

@ -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,
});

View file

@ -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) {

View 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;
}

View file

@ -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);
}

View file

@ -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";
}
}

View file

@ -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";
}
}

View file

@ -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);
});
});