Przeglądaj źródła

feat(resolution): Spring bare + class-prefixed route mappings → controller method

The Spring resolver required a string path in the mapping regex, so BARE method
mappings (`@PostMapping` with the path on the class-level `@RequestMapping`) were
missed — the dominant multi-method-controller pattern. realworld's two-action
ArticleFavoriteApi only linked one method; halo had 28 routes for 2444 files.

Fix (frameworks/java.ts):
- Treat class-level `@RequestMapping` as a PREFIX (not a bogus route) and join it
  onto each method's path.
- Match verb-specific mappings (@GetMapping/@PostMapping/...) BARE or with a path.
- Also handle method-level `@RequestMapping(value=..., method=RequestMethod.X)`
  (older style) — restored after an initial cut dropped it (mall regressed 292→1;
  caught by the regression check).

Validated: realworld 13→19, mall 246 (all precise, class prefix joined:
GET /subject/listAll→listAll, POST /articles/{slug}/favorite→favoriteArticle +
DELETE→unfavoriteArticle), no node explosion. DI controller→service resolves
(article→findBySlug, updateArticle→canWriteArticle). Agent A/B (mall cart flow):
with codegraph 0 reads/0 grep vs without 2/2. Residuals: halo's complex custom
patterns (9/29 resolve); Spring Data JPA derived queries (metaprogramming frontier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 miesiąc temu
rodzic
commit
b340731108
1 zmienionych plików z 62 dodań i 9 usunięć
  1. 62 9
      src/resolution/frameworks/java.ts

+ 62 - 9
src/resolution/frameworks/java.ts

@@ -125,15 +125,25 @@ export const springResolver: FrameworkResolver = {
     const now = Date.now();
     const now = Date.now();
     const safe = stripCommentsForRegex(content, 'java');
     const safe = stripCommentsForRegex(content, 'java');
 
 
-    // @GetMapping("/path"), @PostMapping(value = "/path"), @RequestMapping("/path")
-    const mappingRegex = /@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)\s*\(\s*(?:value\s*=\s*|path\s*=\s*)?["']([^"']+)["'][^)]*\)/g;
+    // Class-level @RequestMapping prefix (an @RequestMapping whose tail leads to a
+    // `class`). Joined onto each method's path — and, crucially, NOT treated as a
+    // 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);
+    if (cls) classPrefix = parseMappingPath(cls[1]!);
+
+    const VERB: Record<string, string> = {
+      GetMapping: 'GET', PostMapping: 'POST', PutMapping: 'PUT', PatchMapping: 'PATCH', DeleteMapping: 'DELETE',
+    };
+    // Verb-specific method mappings — always method-level, BARE or with a path.
+    const mappingRegex = /@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\b\s*(\([^)]*\))?/g;
     let match: RegExpExecArray | null;
     let match: RegExpExecArray | null;
     while ((match = mappingRegex.exec(safe)) !== null) {
     while ((match = mappingRegex.exec(safe)) !== null) {
-      const [, mappingName, routePath] = match;
+      const method = VERB[match[1]!]!;
+      const sub = parseMappingPath((match[2] || '').replace(/^\(|\)$/g, ''));
+      const routePath = joinPath(classPrefix, sub);
       const line = safe.slice(0, match.index).split('\n').length;
       const line = safe.slice(0, match.index).split('\n').length;
-      const method =
-        mappingName === 'RequestMapping' ? 'ANY' : mappingName!.replace(/Mapping$/, '').toUpperCase();
-
       const routeNode: Node = {
       const routeNode: Node = {
         id: `route:${filePath}:${line}:${method}:${routePath}`,
         id: `route:${filePath}:${line}:${method}:${routePath}`,
         kind: 'route',
         kind: 'route',
@@ -149,9 +159,10 @@ export const springResolver: FrameworkResolver = {
       };
       };
       nodes.push(routeNode);
       nodes.push(routeNode);
 
 
-      // Look for the next public/private/protected method after the annotation
-      const tail = safe.slice(match.index + match[0].length);
-      const methodMatch = tail.match(/\b(?:public|private|protected)\s+[^;{]*?\s+(\w+)\s*\(/);
+      // 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*\(/);
       if (methodMatch) {
       if (methodMatch) {
         references.push({
         references.push({
           fromNodeId: routeNode.id,
           fromNodeId: routeNode.id,
@@ -165,6 +176,36 @@ export const springResolver: FrameworkResolver = {
       }
       }
     }
     }
 
 
+    // Method-level @RequestMapping (older style: `@RequestMapping(value="/x",
+    // method=RequestMethod.GET)` on a method). The class-level @RequestMapping is
+    // the prefix (handled above) — skip it here so it isn't double-counted.
+    const reqRe = /@RequestMapping\b\s*(\([^)]*\))?/g;
+    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 (!methodMatch) continue;
+      const verbM = args.match(/method\s*=\s*(?:RequestMethod\.)?(\w+)/);
+      const method = verbM ? verbM[1]!.toUpperCase() : 'ANY';
+      const routePath = joinPath(classPrefix, parseMappingPath(args));
+      const line = safe.slice(0, match.index).split('\n').length;
+      const routeNode: Node = {
+        id: `route:${filePath}:${line}:${method}:${routePath}`,
+        kind: 'route',
+        name: `${method} ${routePath}`,
+        qualifiedName: `${filePath}::route:${routePath}`,
+        filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: 'java', updatedAt: now,
+      };
+      nodes.push(routeNode);
+      references.push({
+        fromNodeId: routeNode.id,
+        referenceName: methodMatch[1]!,
+        referenceKind: 'references',
+        line, column: 0, filePath, language: 'java',
+      });
+    }
+
     return { nodes, references };
     return { nodes, references };
   },
   },
 };
 };
@@ -179,6 +220,18 @@ const COMPONENT_DIRS = ['/component/', '/components/', '/config/'];
 const CLASS_KINDS = new Set(['class']);
 const CLASS_KINDS = new Set(['class']);
 const SERVICE_KINDS = new Set(['class', 'interface']);
 const SERVICE_KINDS = new Set(['class', 'interface']);
 
 
+/** Path string from a mapping's args (`"/x"`, `value = "/x"`, `path = "/x"`); '' if bare. */
+function parseMappingPath(args: string): string {
+  const m = args.match(/["']([^"']*)["']/);
+  return m ? m[1]! : '';
+}
+
+/** Join a class-level prefix and a method sub-path into one normalized `/path`. */
+function joinPath(prefix: string, sub: string): string {
+  const parts = [prefix, sub].map((p) => p.replace(/^\/+|\/+$/g, '')).filter(Boolean);
+  return '/' + parts.join('/');
+}
+
 /**
 /**
  * Resolve a symbol by name using indexed queries instead of scanning all files.
  * Resolve a symbol by name using indexed queries instead of scanning all files.
  */
  */