瀏覽代碼

feat(resolution): Spring Boot Kotlin routing (.kt + fun handlers)

Kotlin had zero framework coverage — no resolver listed kotlin, and the
Spring resolver was languages:['java'] with a .java-only extract gate and a
Java-syntax handler regex (public X name()). Spring Boot Kotlin apps (same
@GetMapping/@RestController annotations, .kt files) extracted 0 routes.

Extend the Spring resolver: languages ['java','kotlin'], accept .kt, and add
a Kotlin `fun name(` alternative to the handler-method regex (Kotlin has no
access modifier; the return type follows the name). Also allow Kotlin class
modifiers (open/data/sealed) in the class @RequestMapping-prefix detection,
and tag route/ref language per file.

spring-petclinic-kotlin 0→18 routes, 18/18 resolved; class @RequestMapping
prefixes join, stacked annotations skipped, DI controller→repo resolves
(showOwner ← GET /owners/{ownerId} → OwnerRepository.findById). Java Spring
unchanged (realworld 19/19 — the Kotlin fun and Java public-X alternatives
are disjoint per language). Jetpack Compose composition already works
(@Composable→child are plain function calls). Tests: Kotlin @GetMapping+fun,
class-prefix + stacked annotation. Suite green (800).

Frontier: Ktor inline-lambda routing, Compose recomposition, coroutines/Flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 月之前
父節點
當前提交
a8cd9d8fee
共有 2 個文件被更改,包括 46 次插入12 次删除
  1. 30 0
      __tests__/frameworks.test.ts
  2. 16 12
      src/resolution/frameworks/java.ts

+ 30 - 0
__tests__/frameworks.test.ts

@@ -562,6 +562,36 @@ public List<User> listUsers() {
     expect(nodes[0].name).toBe('GET /users');
     expect(references[0].referenceName).toBe('listUsers');
   });
+
+  it('extracts a Kotlin @GetMapping with a fun handler', () => {
+    const src = `
+@GetMapping("/vets")
+fun showVetList(model: MutableMap<String, Any>): String {
+  return "vets"
+}
+`;
+    const { nodes, references } = springResolver.extract!('VetController.kt', src);
+    expect(nodes[0].name).toBe('GET /vets');
+    expect(references[0].referenceName).toBe('showVetList');
+    expect(nodes[0].language).toBe('kotlin');
+  });
+
+  it('joins a Kotlin class @RequestMapping prefix and skips a stacked annotation', () => {
+    const src = `
+@RestController
+@RequestMapping("/owners")
+class OwnerController {
+  @GetMapping("/{ownerId}")
+  @ResponseBody
+  fun showOwner(@PathVariable ownerId: Int): String {
+    return "owner"
+  }
+}
+`;
+    const { nodes, references } = springResolver.extract!('OwnerController.kt', src);
+    expect(nodes[0].name).toBe('GET /owners/{ownerId}');
+    expect(references[0].referenceName).toBe('showOwner');
+  });
 });
 
 import { goResolver } from '../src/resolution/frameworks/go';

+ 16 - 12
src/resolution/frameworks/java.ts

@@ -10,7 +10,7 @@ import { stripCommentsForRegex } from '../strip-comments';
 
 export const springResolver: FrameworkResolver = {
   name: 'spring',
-  languages: ['java'],
+  languages: ['java', 'kotlin'],
 
   detect(context: ResolutionContext): boolean {
     // Check for pom.xml with Spring
@@ -119,10 +119,14 @@ export const springResolver: FrameworkResolver = {
   },
 
   extract(filePath, content) {
-    if (!filePath.endsWith('.java')) return { nodes: [], references: [] };
+    // Spring Boot is used from both Java and Kotlin (identical @GetMapping etc.
+    // annotations); the difference is method syntax — Kotlin `fun name(...)` vs
+    // Java `public X name(...)` — handled in the method regex below.
+    if (!filePath.endsWith('.java') && !filePath.endsWith('.kt')) return { nodes: [], references: [] };
     const nodes: Node[] = [];
     const references: UnresolvedRef[] = [];
     const now = Date.now();
+    const lang: 'java' | 'kotlin' = filePath.endsWith('.kt') ? 'kotlin' : 'java';
     const safe = stripCommentsForRegex(content, 'java');
 
     // Class-level @RequestMapping prefix (an @RequestMapping whose tail leads to a
@@ -130,7 +134,7 @@ export const springResolver: FrameworkResolver = {
     // route itself (the old regex did, creating one bogus class route and missing
     // every BARE method mapping like `@PostMapping` with the path on the class).
     let classPrefix = '';
-    const cls = /@RequestMapping\s*\(([^)]*)\)\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+)*class\b/.exec(safe);
+    const cls = /@RequestMapping\s*\(([^)]*)\)\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+|open\s+|data\s+|sealed\s+)*class\b/.exec(safe);
     if (cls) classPrefix = parseMappingPath(cls[1]!);
 
     const VERB: Record<string, string> = {
@@ -154,7 +158,7 @@ export const springResolver: FrameworkResolver = {
         endLine: line,
         startColumn: 0,
         endColumn: match[0].length,
-        language: 'java',
+        language: lang,
         updatedAt: now,
       };
       nodes.push(routeNode);
@@ -162,16 +166,16 @@ export const springResolver: FrameworkResolver = {
       // Method it decorates: first declared method after (skip stacked annotations;
       // Java puts the return type before the name). Bounded so we don't grab a far one.
       const tail = safe.slice(match.index + match[0].length, match.index + match[0].length + 600);
-      const methodMatch = tail.match(/\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
+      const methodMatch = tail.match(/\bfun\s+(\w+)\s*\(|\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
       if (methodMatch) {
         references.push({
           fromNodeId: routeNode.id,
-          referenceName: methodMatch[1]!,
+          referenceName: (methodMatch[1] ?? methodMatch[2])!,
           referenceKind: 'references',
           line,
           column: 0,
           filePath,
-          language: 'java',
+          language: lang,
         });
       }
     }
@@ -183,8 +187,8 @@ export const springResolver: FrameworkResolver = {
     while ((match = reqRe.exec(safe)) !== null) {
       const args = (match[1] || '').replace(/^\(|\)$/g, '');
       const after = safe.slice(match.index + match[0].length, match.index + match[0].length + 600);
-      if (/^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+)*class\b/.test(after)) continue; // class-level prefix
-      const methodMatch = after.match(/\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
+      if (/^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+|open\s+|data\s+|sealed\s+)*class\b/.test(after)) continue; // class-level prefix
+      const methodMatch = after.match(/\bfun\s+(\w+)\s*\(|\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
       if (!methodMatch) continue;
       const verbM = args.match(/method\s*=\s*(?:RequestMethod\.)?(\w+)/);
       const method = verbM ? verbM[1]!.toUpperCase() : 'ANY';
@@ -195,14 +199,14 @@ export const springResolver: FrameworkResolver = {
         kind: 'route',
         name: `${method} ${routePath}`,
         qualifiedName: `${filePath}::route:${routePath}`,
-        filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: 'java', updatedAt: now,
+        filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: lang, updatedAt: now,
       };
       nodes.push(routeNode);
       references.push({
         fromNodeId: routeNode.id,
-        referenceName: methodMatch[1]!,
+        referenceName: (methodMatch[1] ?? methodMatch[2])!,
         referenceKind: 'references',
-        line, column: 0, filePath, language: 'java',
+        line, column: 0, filePath, language: lang,
       });
     }