Files
codegraph/src/resolution/frameworks/java.ts
T
Colby Mchenry 625b4fe921 fix(resolution): stop per-call config-key scan that made large Java/Kotlin (Spring) indexes take ~1h (#1180) (#1210)
On a large Java/Kotlin Spring monorepo, reference resolution — not extraction —
dominated a full index (Spring Boot's ~9,650-file tree: extraction 62s,
resolution ~26min). The Spring framework resolver ran an uncached
getNodesByKind('constant') full scan + canonicalConfigKey() filter for EVERY
dotted `calls` ref (every list.add(), builder.build(), receiver.method()),
because the config-key branch gated only on "dotted java/kotlin", not on ref
kind. With ~1,100 constant nodes × ~200k dotted calls that is ~200M wasted
row-fetches/allocations.

Fixes, one theme — config-key constants bind config `references`, never `calls`:
- frameworks/java.ts: gate the Spring config-key branch on
  referenceKind === 'references' (what @Value/@ConfigurationProperties emit) so
  the `calls` flood skips the scan.
- name-matcher.ts: a `calls` ref no longer resolves to a yaml/properties config
  node via matchByQualifiedName (service.process() vs the yaml key
  service.process) — a wrong edge that also hid the real callee; it now falls
  through to method resolution.
- resolution/index.ts: cache getNodesByKind in the resolver context (same
  lifetime as nameCache). Fixes the same uncached-per-ref scan in the Drupal
  hook_ resolver and is defense-in-depth for the Spring :prefix branch.

Measured (Spring Boot): resolution 269s→16.5s on a 4.3k-file module (16×) and
~26min→44.7s on the full 9.6k-file tree (~35×); graph byte-identical, full suite
passes. Adds a regression test (same key, two ref kinds, opposite outcomes).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:17:16 -05:00

552 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Java Framework Resolver
*
* Handles Spring Boot and general Java patterns.
*/
import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
export const springResolver: FrameworkResolver = {
name: 'spring',
languages: ['java', 'kotlin', 'yaml', 'properties'],
claimsReference(name: string): boolean {
// `@ConfigurationProperties(prefix="app.cache")` emits a reference whose
// name carries the `:prefix` sentinel — there's no declared symbol with
// that exact spelling, so the resolver's name-existence pre-filter would
// drop it. Opt those through.
return name.endsWith(':prefix');
},
detect(context: ResolutionContext): boolean {
// Check for pom.xml with Spring
const pomXml = context.readFile('pom.xml');
if (pomXml && (pomXml.includes('spring-boot') || pomXml.includes('springframework'))) {
return true;
}
// Check for build.gradle with Spring
const buildGradle = context.readFile('build.gradle');
if (buildGradle && (buildGradle.includes('spring-boot') || buildGradle.includes('springframework'))) {
return true;
}
const buildGradleKts = context.readFile('build.gradle.kts');
if (buildGradleKts && (buildGradleKts.includes('spring-boot') || buildGradleKts.includes('springframework'))) {
return true;
}
// Check for Spring annotations in Java files
const allFiles = context.getAllFiles();
for (const file of allFiles) {
if (file.endsWith('.java')) {
const content = context.readFile(file);
if (content && (
content.includes('@SpringBootApplication') ||
content.includes('@RestController') ||
content.includes('@Service') ||
content.includes('@Repository')
)) {
return true;
}
}
}
return false;
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Spring config-key references — `@Value("${key}")` (single leaf) and
// `@ConfigurationProperties(prefix="X")` (entire subtree, marked with the
// `:prefix` suffix in extractSpringValueBindings). Lookup goes through
// Spring's relaxed binding (kebab/camel/snake → canonical lowercase).
if (ref.referenceName.endsWith(':prefix')) {
const prefix = ref.referenceName.slice(0, -':prefix'.length);
const canonPrefix = canonicalConfigKey(prefix);
// Prefer an exact prefix match (one node = the prefix subtree). Without
// node-level subtree representation we map to the closest matching key.
const candidates = context.getNodesByKind('constant').filter(
(n) => (n.language === 'yaml' || n.language === 'properties')
&& canonicalConfigKey(n.qualifiedName).startsWith(canonPrefix),
);
if (candidates.length === 0) return null;
// Pick the SHORTEST canonical name — it's the closest binding point
// (`app.cache` over `app.cache.name.user-token` for prefix=`app.cache`).
const best = candidates.reduce((a, b) =>
canonicalConfigKey(a.qualifiedName).length <= canonicalConfigKey(b.qualifiedName).length ? a : b,
);
return { original: ref, targetNodeId: best.id, confidence: 0.85, resolvedBy: 'framework' };
}
if (ref.referenceName.includes('.') && ref.language !== 'java' && ref.language !== 'kotlin') {
// Spring config dotted key — only when the source language is Java/Kotlin
// (the bindings come from `@Value`). Skip non-Spring refs that happen to
// have dots in them.
}
// Spring config-key resolution: `@Value("${a.b.c}")` and
// `@ConfigurationProperties`. Gate on the `references` kind — those bindings
// are emitted as `references` by extractSpringValueBindings, whereas the far
// more numerous method-call refs (`list.add()`, `builder.build()`, every
// `receiver.method()`) are `calls`. A config key is NEVER a `calls` ref, so
// this loses no resolution. Without the gate, every dotted `calls` ref fell
// into the uncached getNodesByKind('constant') scan below — an
// O(dotted-calls × constant-nodes) cost that dominated resolution and made a
// full index take ~1h on large Java/Kotlin (Spring) monorepos (#1180). The
// old `split('.').length >= 2` heuristic couldn't separate keys from calls;
// the kind check does it exactly.
if (
ref.referenceKind === 'references' &&
(ref.language === 'java' || ref.language === 'kotlin') &&
ref.referenceName.includes('.') &&
!ref.referenceName.includes('::')
) {
const canonRef = canonicalConfigKey(ref.referenceName);
const candidates = context.getNodesByKind('constant').filter(
(n) => n.kind === 'constant'
&& (n.language === 'yaml' || n.language === 'properties')
&& canonicalConfigKey(n.qualifiedName) === canonRef,
);
if (candidates.length === 1) {
return { original: ref, targetNodeId: candidates[0]!.id, confidence: 0.9, resolvedBy: 'framework' };
}
if (candidates.length > 1) {
// Multiple profile-specific files (application-dev.yml +
// application-prod.yml) can define the same key. Prefer the one with
// the shortest profile suffix (the base `application.yml` wins over
// profile variants when both exist), then by alphabetical path so the
// pick is deterministic across reindexes.
const score = (n: Node) => {
const base = n.filePath.split('/').pop() ?? '';
const isBase = /^(application|bootstrap)\.(yml|yaml|properties)$/i.test(base);
return (isBase ? 0 : 1) * 1000 + base.length;
};
const best = candidates.reduce((a, b) => (score(a) <= score(b) ? a : b));
return { original: ref, targetNodeId: best.id, confidence: 0.75, resolvedBy: 'framework' };
}
}
// Pattern 1: Service references (dependency injection)
if (ref.referenceName.endsWith('Service')) {
const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.85,
resolvedBy: 'framework',
};
}
}
// Pattern 2: Repository references
if (ref.referenceName.endsWith('Repository')) {
const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, REPO_DIRS, context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.85,
resolvedBy: 'framework',
};
}
}
// Pattern 3: Controller references
if (ref.referenceName.endsWith('Controller')) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CONTROLLER_DIRS, context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.85,
resolvedBy: 'framework',
};
}
}
// Pattern 4: Entity/Model references
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, ENTITY_DIRS, context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.7,
resolvedBy: 'framework',
};
}
}
// Pattern 5: Component references
if (ref.referenceName.endsWith('Component') || ref.referenceName.endsWith('Config')) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, COMPONENT_DIRS, context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.8,
resolvedBy: 'framework',
};
}
}
return null;
},
extract(filePath, content) {
// Spring config files (application.yml / application.properties /
// bootstrap.yml + per-profile variants) are extracted on the framework
// path, not in the language extractor, so the keys become first-class
// nodes a `@Value("${k}")` reference can resolve to.
if (isSpringConfigFile(filePath)) {
return extractSpringConfig(filePath, content);
}
// 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
// `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+|open\s+|data\s+|sealed\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;
while ((match = mappingRegex.exec(safe)) !== null) {
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 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;
// 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(/\bfun\s+(\w+)\s*\(|\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
if (methodMatch) {
references.push({
fromNodeId: routeNode.id,
referenceName: (methodMatch[1] ?? methodMatch[2])!,
referenceKind: 'references',
line,
column: 0,
filePath,
language: lang,
});
}
}
// 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+|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';
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: lang, updatedAt: now,
};
nodes.push(routeNode);
references.push({
fromNodeId: routeNode.id,
referenceName: (methodMatch[1] ?? methodMatch[2])!,
referenceKind: 'references',
line, column: 0, filePath, language: lang,
});
}
// @Value("${key}") and @ConfigurationProperties(prefix="...") — bind
// Spring config-key references in Java/Kotlin source. The reference target
// is the corresponding YAML/properties leaf-key node emitted by
// extractSpringConfig; springResolver.resolve looks it up with relaxed
// binding (kebab/camel/snake collapse).
extractSpringValueBindings(filePath, safe, lang, now, nodes, references);
return { nodes, references };
},
};
/** Spring config file patterns: application(-profile)?.{yml,yaml,properties} +
* bootstrap variants. Matches the basename, not the path, so a project that
* vendors `application.yml` under `src/main/resources` and one under `src/test/
* resources` are both picked up. */
function isSpringConfigFile(filePath: string): boolean {
const base = filePath.split('/').pop() ?? '';
return /^(application|bootstrap)(-[\w.-]+)?\.(yml|yaml|properties)$/i.test(base);
}
/**
* Parse a Spring config file (YAML or .properties) and emit one `constant`
* node per LEAF key, with `qualifiedName` = the dotted path. Leaf keys are
* what `@Value("${k}")` references hit; intermediate keys aren't bound by
* Spring's `@Value` (a `@ConfigurationProperties` class binds a SUBTREE, and
* those references are resolved at lookup time by prefix-suffix matching).
*/
function extractSpringConfig(
filePath: string,
content: string,
): { nodes: Node[]; references: UnresolvedRef[] } {
const nodes: Node[] = [];
const isProperties = /\.properties$/i.test(filePath);
const lang = isProperties ? 'properties' : 'yaml';
const now = Date.now();
const emitLeaf = (dottedKey: string, line: number, valueText: string) => {
if (!dottedKey) return;
nodes.push({
id: `spring-config:${filePath}:${line}:${dottedKey}`,
kind: 'constant',
name: dottedKey.split('.').pop() ?? dottedKey,
qualifiedName: dottedKey,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: valueText.length,
language: lang,
signature: dottedKey,
// SECURITY (#383): store the KEY only, never the value. Config files
// routinely hold secrets (DB passwords, API keys, JDBC URLs with embedded
// credentials), and surfacing the value here pushes it into agent context
// unbidden (it lands in codegraph_node/explore output via the docstring).
// The key is all `@Value`/`@ConfigurationProperties` resolution needs; an
// agent that genuinely needs a value can read the file directly.
updatedAt: now,
});
};
if (isProperties) {
// Properties format: `k1.k2.k3 = value` (or `:` separator, or no value).
// Lines starting with `#`/`!` are comments. Backslash continuations are
// valid but rare; we don't try to join them (a continued value is still
// a value of the same key).
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? '';
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) continue;
const sep = (() => {
for (let j = 0; j < raw.length; j++) {
const ch = raw[j];
if (ch === '=' || ch === ':') return j;
if (ch === '\\' && raw[j + 1]) { j++; continue; }
}
return -1;
})();
if (sep < 0) continue;
const key = raw.slice(0, sep).trim();
const val = raw.slice(sep + 1).trim();
emitLeaf(key, i + 1, val);
}
return { nodes, references: [] };
}
// YAML: indent-based. We track a stack of (indent, key) so the dotted path
// is built by joining ancestor keys with `.`. A leaf is a line with a value
// on the same line (after `:`). List items, flow-style scalars, and `---`
// separators are ignored — they don't bind to `@Value` anyway.
const stack: Array<{ indent: number; key: string }> = [];
const yamlLines = content.split(/\r?\n/);
for (let i = 0; i < yamlLines.length; i++) {
const raw = yamlLines[i] ?? '';
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed === '---' || trimmed.startsWith('- ')) continue;
const indent = raw.length - raw.replace(/^[\t ]+/, '').length;
const colonIdx = (() => {
let inStr: string | null = null;
for (let j = 0; j < raw.length; j++) {
const ch = raw[j];
if (inStr) { if (ch === inStr && raw[j - 1] !== '\\') inStr = null; continue; }
if (ch === '"' || ch === "'") { inStr = ch; continue; }
if (ch === ':') return j;
}
return -1;
})();
if (colonIdx < 0) continue;
const key = raw.slice(indent, colonIdx).trim();
if (!key) continue;
const after = raw.slice(colonIdx + 1).trim();
while (stack.length > 0 && stack[stack.length - 1]!.indent >= indent) stack.pop();
const dotted = [...stack.map((s) => s.key), key].join('.');
if (after === '' || after.startsWith('#')) {
stack.push({ indent, key });
} else {
// A leaf with an inline value (or a flow-mapping like `{ a: 1 }` — we
// emit it as a leaf, not as a subtree; precision is fine for `@Value`).
const valStripped = after.replace(/^["']|["']$/g, '');
emitLeaf(dotted, i + 1, valStripped);
}
}
return { nodes, references: [] };
}
/** Append `@Value("${k}")` and `@ConfigurationProperties(prefix=...)`
* references discovered in `safe` (comments stripped) into the caller's
* `nodes`/`references` arrays. */
function extractSpringValueBindings(
filePath: string,
safe: string,
lang: 'java' | 'kotlin',
now: number,
nodes: Node[],
references: UnresolvedRef[],
): void {
const valueRe = /@Value\s*\(\s*["']\$\{([^}:]+)(?::[^}]*)?\}["']\s*\)/g;
let m: RegExpExecArray | null;
while ((m = valueRe.exec(safe)) !== null) {
const key = m[1]!.trim();
if (!key) continue;
const line = safe.slice(0, m.index).split('\n').length;
const bindNode: Node = {
id: `spring-value:${filePath}:${line}:${key}`,
kind: 'constant',
name: key,
qualifiedName: `${filePath}::@Value:${key}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: m[0].length,
language: lang,
signature: `@Value("${key}")`,
updatedAt: now,
};
nodes.push(bindNode);
references.push({
fromNodeId: bindNode.id,
referenceName: key,
referenceKind: 'references',
line,
column: 0,
filePath,
language: lang,
});
}
const cpRe = /@ConfigurationProperties\s*\(\s*(?:prefix\s*=\s*)?["']([^"']+)["']/g;
while ((m = cpRe.exec(safe)) !== null) {
const prefix = m[1]!.trim();
if (!prefix) continue;
const line = safe.slice(0, m.index).split('\n').length;
const bindNode: Node = {
id: `spring-cp:${filePath}:${line}:${prefix}`,
kind: 'constant',
name: prefix,
qualifiedName: `${filePath}::@ConfigurationProperties:${prefix}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: m[0].length,
language: lang,
signature: `@ConfigurationProperties("${prefix}")`,
updatedAt: now,
};
nodes.push(bindNode);
references.push({
fromNodeId: bindNode.id,
// Mark the reference with a `:prefix` suffix so springResolver.resolve
// knows to expand it into the SUBTREE rather than a single key.
referenceName: `${prefix}:prefix`,
referenceKind: 'references',
line,
column: 0,
filePath,
language: lang,
});
}
}
/** Spring's relaxed binding (`cache-list` ↔ `cacheList` ↔ `cache_list` ↔
* `CACHE_LIST`) collapses on lowercase + dash/underscore removal. We compare
* candidate keys to a reference in this canonical form. */
function canonicalConfigKey(key: string): string {
return key.toLowerCase().replace(/[-_]/g, '');
}
// Directory patterns
const SERVICE_DIRS = ['/service/', '/services/'];
const REPO_DIRS = ['/repository/', '/repositories/'];
const CONTROLLER_DIRS = ['/controller/', '/controllers/'];
const ENTITY_DIRS = ['/entity/', '/entities/', '/model/', '/models/', '/domain/'];
const COMPONENT_DIRS = ['/component/', '/components/', '/config/'];
const CLASS_KINDS = new Set(['class']);
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.
*/
function resolveByNameAndKind(
name: string,
kinds: Set<string>,
preferredDirPatterns: string[],
context: ResolutionContext,
): string | null {
const candidates = context.getNodesByName(name);
if (candidates.length === 0) return null;
const kindFiltered = candidates.filter((n) => kinds.has(n.kind));
if (kindFiltered.length === 0) return null;
// Prefer candidates in framework-conventional directories
const preferred = kindFiltered.filter((n) =>
preferredDirPatterns.some((d) => n.filePath.includes(d))
);
if (preferred.length > 0) return preferred[0]!.id;
// Fall back to any match
return kindFiltered[0]!.id;
}