Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
co-authored by
Colby McHenry
parent
0fd259b554
commit
4453310eef
@@ -135,6 +135,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461)
|
||||||
- `codegraph callers`, `codegraph callees` and `codegraph impact` now resolve qualified names, group results and JSON edges by definition, and accept `--file` to narrow ambiguous names; thanks @ferrine. (#1512, #1656)
|
- `codegraph callers`, `codegraph callees` and `codegraph impact` now resolve qualified names, group results and JSON edges by definition, and accept `--file` to narrow ambiguous names; thanks @ferrine. (#1512, #1656)
|
||||||
- `codegraph callers`, `codegraph callees` and `codegraph impact` (CLI and MCP) now report missing names with did-you-mean suggestions instead of another symbol's results, and exact matches with no callers stay empty; thanks @uvmplus. (#1473, #1481)
|
- `codegraph callers`, `codegraph callees` and `codegraph impact` (CLI and MCP) now report missing names with did-you-mean suggestions instead of another symbol's results, and exact matches with no callers stay empty; thanks @uvmplus. (#1473, #1481)
|
||||||
|
|
||||||
|
|||||||
@@ -837,6 +837,118 @@ describe('railsResolver.extract', () => {
|
|||||||
import { springResolver } from '../src/resolution/frameworks/java';
|
import { springResolver } from '../src/resolution/frameworks/java';
|
||||||
|
|
||||||
describe('springResolver.extract', () => {
|
describe('springResolver.extract', () => {
|
||||||
|
it.each([
|
||||||
|
['UserController.java', '{"/a", "/b"}', '@GetMapping({"/x", "/y"})', 'public String handle() { return "ok"; }'],
|
||||||
|
['UserController.java', 'path = {"/a", "/b"}', '@RequestMapping(value = {"/x", "/y"}, method = RequestMethod.GET)', 'public String handle() { return "ok"; }'],
|
||||||
|
['UserController.kt', 'value = ["/a", "/b"]', '@GetMapping(path = ["/x", "/y"])', 'fun handle(): String = "ok"'],
|
||||||
|
])('indexes every class/method path pair in %s with %s and %s (#1461)', (filePath, base, mapping, handler) => {
|
||||||
|
const src = `@RestController
|
||||||
|
@RequestMapping(${base})
|
||||||
|
public class UserController {
|
||||||
|
${mapping}
|
||||||
|
${handler}
|
||||||
|
}`;
|
||||||
|
const { nodes, references } = springResolver.extract!(filePath, src);
|
||||||
|
expect(nodes.map(n => n.name)).toEqual(['GET /a/x', 'GET /a/y', 'GET /b/x', 'GET /b/y']);
|
||||||
|
expect(new Set(nodes.map(n => n.id)).size).toBe(4);
|
||||||
|
expect(references.map(r => [r.fromNodeId, r.referenceName])).toEqual(nodes.map(n => [n.id, 'handle']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['ErrorHandler.PATH', 'PATH', 'value = ErrorHandler.PATH', 'path = PATH'])(
|
||||||
|
'resolves a same-file constant prefix in @RequestMapping(%s) (#1461)', (args) => {
|
||||||
|
const src = `@Controller
|
||||||
|
@RequestMapping(${args})
|
||||||
|
public class ErrorHandler {
|
||||||
|
public static final String PATH = "/error";
|
||||||
|
@RequestMapping(method = {RequestMethod.GET})
|
||||||
|
public String handle() { return "err"; }
|
||||||
|
}`;
|
||||||
|
const { nodes, references } = springResolver.extract!('ErrorHandler.java', src);
|
||||||
|
expect(nodes.map(n => n.name)).toEqual(['GET /error']);
|
||||||
|
expect(references.map(r => [r.fromNodeId, r.referenceName])).toEqual([[nodes[0].id, 'handle']]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('keeps literals and resolved constants in path arrays, including URI variables (#1461)', () => {
|
||||||
|
const src = `@RequestMapping({"/api", "/{tenant}/api"})
|
||||||
|
public class ItemController {
|
||||||
|
public static final String ITEMS = "/items";
|
||||||
|
@GetMapping(path = {ITEMS, "/items/{id}", External.MISSING}, produces = "application/json")
|
||||||
|
public String get() { return "ok"; }
|
||||||
|
}`;
|
||||||
|
const { nodes, references } = springResolver.extract!('ItemController.java', src);
|
||||||
|
expect(nodes.map(n => n.name)).toEqual([
|
||||||
|
'GET /api/items', 'GET /api/items/{id}', 'GET /{tenant}/api/items', 'GET /{tenant}/api/items/{id}',
|
||||||
|
]);
|
||||||
|
expect(references.map(r => r.referenceName)).toEqual(['get', 'get', 'get', 'get']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['value = "/ok", produces = "application/json"', '/base/ok'],
|
||||||
|
['consumes = {"application/json", "text/plain"}, path = "/ok", produces = "application/json"', '/base/ok'],
|
||||||
|
['produces = "application/json", consumes = "text/plain"', '/base'],
|
||||||
|
])('only treats path arguments as paths: %s (#1461)', (args, expected) => {
|
||||||
|
const src = `@RequestMapping("/base")
|
||||||
|
public class UserController {
|
||||||
|
@GetMapping(${args})
|
||||||
|
public String handle() { return "ok"; }
|
||||||
|
}`;
|
||||||
|
const { nodes } = springResolver.extract!('UserController.java', src);
|
||||||
|
expect(nodes.map(n => n.name)).toEqual([`GET ${expected}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['External.MISSING', '@GetMapping'],
|
||||||
|
['value = MISSING, produces = "application/json"', '@GetMapping("/ok")'],
|
||||||
|
['"/base"', '@GetMapping(External.MISSING)'],
|
||||||
|
['"/base"', '@GetMapping(path = MISSING, produces = "application/json")'],
|
||||||
|
['"/base"', '@RequestMapping(value = MISSING, method = RequestMethod.GET)'],
|
||||||
|
])('omits unresolved paths: class %s, method %s (#1461)', (base, mapping) => {
|
||||||
|
const src = `@RequestMapping(${base})
|
||||||
|
public class UserController {
|
||||||
|
// public static final String MISSING = "/comment";
|
||||||
|
${mapping}
|
||||||
|
public String handle() { return "ok"; }
|
||||||
|
}`;
|
||||||
|
expect(springResolver.extract!('UserController.java', src)).toEqual({ nodes: [], references: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['@GetMapping', 'GET'],
|
||||||
|
['@GetMapping()', 'GET'],
|
||||||
|
['@RequestMapping(method = RequestMethod.GET)', 'GET'],
|
||||||
|
['@RequestMapping(method = {RequestMethod.GET})', 'GET'],
|
||||||
|
['@RequestMapping', 'ANY'],
|
||||||
|
])('inherits the class prefix for %s without emitting a class route (#1461)', (mapping, verb) => {
|
||||||
|
const src = `@RequestMapping("/base")
|
||||||
|
public class UserController {
|
||||||
|
${mapping}
|
||||||
|
public String handle() { return "ok"; }
|
||||||
|
}`;
|
||||||
|
const { nodes, references } = springResolver.extract!('UserController.java', src);
|
||||||
|
expect(nodes.map(n => n.name)).toEqual([`${verb} /base`]);
|
||||||
|
expect(references.map(r => r.referenceName)).toEqual(['handle']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves annotation and reference line numbers after multiline Javadocs (#1461)', () => {
|
||||||
|
const src = `/**
|
||||||
|
* Controller documentation.
|
||||||
|
*/
|
||||||
|
@RequestMapping("/base")
|
||||||
|
public class UserController {
|
||||||
|
/**
|
||||||
|
* Handler documentation with @GetMapping("/fake").
|
||||||
|
*/
|
||||||
|
@GetMapping({"/x", "/y"})
|
||||||
|
public String handle() { return "ok"; }
|
||||||
|
}`;
|
||||||
|
const { nodes, references } = springResolver.extract!('UserController.java', src);
|
||||||
|
expect(nodes.map(n => [n.name, n.startLine, n.endLine])).toEqual([
|
||||||
|
['GET /base/x', 9, 9], ['GET /base/y', 9, 9],
|
||||||
|
]);
|
||||||
|
expect(references.map(r => [r.referenceName, r.line])).toEqual([['handle', 9], ['handle', 9]]);
|
||||||
|
});
|
||||||
|
|
||||||
it('extracts route with @GetMapping and next method', () => {
|
it('extracts route with @GetMapping and next method', () => {
|
||||||
const src = `
|
const src = `
|
||||||
@GetMapping("/users")
|
@GetMapping("/users")
|
||||||
|
|||||||
@@ -211,14 +211,17 @@ export const springResolver: FrameworkResolver = {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lang: 'java' | 'kotlin' = filePath.endsWith('.kt') ? 'kotlin' : 'java';
|
const lang: 'java' | 'kotlin' = filePath.endsWith('.kt') ? 'kotlin' : 'java';
|
||||||
const safe = stripCommentsForRegex(content, 'java');
|
const safe = stripCommentsForRegex(content, 'java');
|
||||||
|
const consts = new Map<string, string>();
|
||||||
|
for (const m of safe.matchAll(/\bstatic\s+final\s+String\s+(\w+)\s*=\s*"([^"]*)"\s*;/g)) {
|
||||||
|
consts.set(m[1]!, m[2]!);
|
||||||
|
}
|
||||||
|
|
||||||
// Class-level @RequestMapping prefix (an @RequestMapping whose tail leads to a
|
// Class-level @RequestMapping prefix (an @RequestMapping whose tail leads to a
|
||||||
// `class`). Joined onto each method's path — and, crucially, NOT treated as 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
|
// 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).
|
// 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+|open\s+|data\s+|sealed\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 classPrefixes = cls ? parseMappingPaths(cls[1]!, consts) : [''];
|
||||||
|
|
||||||
const VERB: Record<string, string> = {
|
const VERB: Record<string, string> = {
|
||||||
GetMapping: 'GET', PostMapping: 'POST', PutMapping: 'PUT', PatchMapping: 'PATCH', DeleteMapping: 'DELETE',
|
GetMapping: 'GET', PostMapping: 'POST', PutMapping: 'PUT', PatchMapping: 'PATCH', DeleteMapping: 'DELETE',
|
||||||
@@ -228,38 +231,39 @@ export const springResolver: FrameworkResolver = {
|
|||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = mappingRegex.exec(safe)) !== null) {
|
while ((match = mappingRegex.exec(safe)) !== null) {
|
||||||
const method = VERB[match[1]!]!;
|
const method = VERB[match[1]!]!;
|
||||||
const sub = parseMappingPath((match[2] || '').replace(/^\(|\)$/g, ''));
|
const paths = parseMappingPaths((match[2] || '').replace(/^\(|\)$/g, ''), consts);
|
||||||
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 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: lang,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
nodes.push(routeNode);
|
|
||||||
|
|
||||||
// Method it decorates: first declared method after (skip stacked annotations;
|
// 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.
|
// 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 tail = safe.slice(match.index + match[0].length, match.index + match[0].length + 600);
|
||||||
const methodMatch = tail.match(/\bfun\s+(\w+)\s*\(|\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) {
|
for (const routePath of classPrefixes.flatMap(prefix => paths.map(sub => joinPath(prefix, sub)))) {
|
||||||
references.push({
|
const routeNode: Node = {
|
||||||
fromNodeId: routeNode.id,
|
id: `route:${filePath}:${line}:${method}:${routePath}`,
|
||||||
referenceName: (methodMatch[1] ?? methodMatch[2])!,
|
kind: 'route',
|
||||||
referenceKind: 'references',
|
name: `${method} ${routePath}`,
|
||||||
line,
|
qualifiedName: `${filePath}::route:${routePath}`,
|
||||||
column: 0,
|
|
||||||
filePath,
|
filePath,
|
||||||
|
startLine: line,
|
||||||
|
endLine: line,
|
||||||
|
startColumn: 0,
|
||||||
|
endColumn: match[0].length,
|
||||||
language: lang,
|
language: lang,
|
||||||
});
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
nodes.push(routeNode);
|
||||||
|
|
||||||
|
if (methodMatch) {
|
||||||
|
references.push({
|
||||||
|
fromNodeId: routeNode.id,
|
||||||
|
referenceName: (methodMatch[1] ?? methodMatch[2])!,
|
||||||
|
referenceKind: 'references',
|
||||||
|
line,
|
||||||
|
column: 0,
|
||||||
|
filePath,
|
||||||
|
language: lang,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,24 +277,26 @@ export const springResolver: FrameworkResolver = {
|
|||||||
if (/^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+|open\s+|data\s+|sealed\s+)*class\b/.test(after)) continue; // class-level prefix
|
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*\(/);
|
const methodMatch = after.match(/\bfun\s+(\w+)\s*\(|\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
|
||||||
if (!methodMatch) continue;
|
if (!methodMatch) continue;
|
||||||
const verbM = args.match(/method\s*=\s*(?:RequestMethod\.)?(\w+)/);
|
const verbM = args.match(/method\s*=\s*[{\[]?\s*(?:RequestMethod\.)?(\w+)/);
|
||||||
const method = verbM ? verbM[1]!.toUpperCase() : 'ANY';
|
const method = verbM ? verbM[1]!.toUpperCase() : 'ANY';
|
||||||
const routePath = joinPath(classPrefix, parseMappingPath(args));
|
const paths = parseMappingPaths(args, consts);
|
||||||
const line = safe.slice(0, match.index).split('\n').length;
|
const line = safe.slice(0, match.index).split('\n').length;
|
||||||
const routeNode: Node = {
|
for (const routePath of classPrefixes.flatMap(prefix => paths.map(sub => joinPath(prefix, sub)))) {
|
||||||
id: `route:${filePath}:${line}:${method}:${routePath}`,
|
const routeNode: Node = {
|
||||||
kind: 'route',
|
id: `route:${filePath}:${line}:${method}:${routePath}`,
|
||||||
name: `${method} ${routePath}`,
|
kind: 'route',
|
||||||
qualifiedName: `${filePath}::route:${routePath}`,
|
name: `${method} ${routePath}`,
|
||||||
filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: lang, updatedAt: now,
|
qualifiedName: `${filePath}::route:${routePath}`,
|
||||||
};
|
filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: lang, updatedAt: now,
|
||||||
nodes.push(routeNode);
|
};
|
||||||
references.push({
|
nodes.push(routeNode);
|
||||||
fromNodeId: routeNode.id,
|
references.push({
|
||||||
referenceName: (methodMatch[1] ?? methodMatch[2])!,
|
fromNodeId: routeNode.id,
|
||||||
referenceKind: 'references',
|
referenceName: (methodMatch[1] ?? methodMatch[2])!,
|
||||||
line, column: 0, filePath, language: lang,
|
referenceKind: 'references',
|
||||||
});
|
line, column: 0, filePath, language: lang,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Value("${key}") and @ConfigurationProperties(prefix="...") — bind
|
// @Value("${key}") and @ConfigurationProperties(prefix="...") — bind
|
||||||
@@ -512,10 +518,24 @@ 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. */
|
/** All declared paths; [''] for an omitted path, [] for an unresolved one. */
|
||||||
function parseMappingPath(args: string): string {
|
function parseMappingPaths(args: string, consts: Map<string, string>): string[] {
|
||||||
const m = args.match(/["']([^"']*)["']/);
|
const paths: string[] = [];
|
||||||
return m ? m[1]! : '';
|
let hasPaths = false;
|
||||||
|
// Keep Java/Kotlin arrays and quoted URI variables together when separating
|
||||||
|
// attributes. Strings in produces/consumes/etc. are never mapping paths.
|
||||||
|
const argRe = /(?:^|,)\s*(?:(\w+)\s*=\s*)?(\{(?:[^"'}]|"[^"]*"|'[^']*')*\}|\[(?:[^"'\]]|"[^"]*"|'[^']*')*\]|"[^"]*"|'[^']*'|[^,]+)/g;
|
||||||
|
for (const arg of args.matchAll(argRe)) {
|
||||||
|
if (arg[1] && arg[1] !== 'value' && arg[1] !== 'path') continue;
|
||||||
|
hasPaths = true;
|
||||||
|
const scope = arg[2]!.trim().replace(/^[{\[]|[}\]]$/g, '').trim();
|
||||||
|
if (!scope) paths.push(''); // An explicitly empty path array also inherits the prefix.
|
||||||
|
for (const value of scope.matchAll(/(?:^|,)\s*(?:"([^"]*)"|'([^']*)'|([\w.]+))\s*(?=,|$)/g)) {
|
||||||
|
const path = value[1] ?? value[2] ?? consts.get(value[3]!.split('.').pop()!);
|
||||||
|
if (path !== undefined) paths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasPaths ? [...new Set(paths)] : [''];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Join a class-level prefix and a method sub-path into one normalized `/path`. */
|
/** Join a class-level prefix and a method sub-path into one normalized `/path`. */
|
||||||
|
|||||||
Reference in New Issue
Block a user