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>
This commit is contained in:
Colby Mchenry
2026-07-07 14:17:16 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent e65a39746c
commit 625b4fe921
5 changed files with 103 additions and 9 deletions
+13 -5
View File
@@ -84,14 +84,22 @@ export const springResolver: FrameworkResolver = {
// (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('::') &&
// Exclude method-call style (single-dot, both sides lower-camel). Spring
// config keys are typically 3+ segments and contain kebabs/dashes; we
// can't filter perfectly but skipping single-dot keeps the lookup tight.
ref.referenceName.split('.').length >= 2
!ref.referenceName.includes('::')
) {
const canonRef = canonicalConfigKey(ref.referenceName);
const candidates = context.getNodesByKind('constant').filter(
+15 -1
View File
@@ -229,6 +229,15 @@ export class ReferenceResolver {
private qualifiedNameCache: LRUCache<string, Node[]>; // qualified_name → nodes cache
private fileLinesCache: LRUCache<string, string[] | null>; // file → split lines cache
private methodMatchCache: LRUCache<string, Node[]>; // lang\0Type::method → matching method nodes
// Node kinds are a small fixed set (~24), so this is a plain Map, not an LRU.
// getNodesByKind returns the FULL node list for a kind; it was previously
// uncached — a per-ref `SELECT * FROM nodes WHERE kind=?` + row-mapping. Called
// for every dotted call ref by the Spring resolver (constants) and every
// `hook_` ref by the Drupal resolver (functions), that scan dominated
// resolution on large repos (#1180). The node set is stable within a
// resolution pass (same lifetime assumption as nameCache); clearCaches() resets
// it between passes. Callers must treat the returned array as read-only.
private nodesByKindCache = new Map<Node['kind'], Node[]>();
private knownNames: Set<string> | null = null; // all known symbol names for fast pre-filtering
private knownFiles: Set<string> | null = null;
private cachesWarmed = false;
@@ -332,6 +341,7 @@ export class ReferenceResolver {
this.qualifiedNameCache.clear();
this.fileLinesCache.clear();
this.methodMatchCache.clear();
this.nodesByKindCache.clear();
this.knownNames = null;
this.knownFiles = null;
this.cachesWarmed = false;
@@ -404,7 +414,11 @@ export class ReferenceResolver {
},
getNodesByKind: (kind: Node['kind']) => {
return this.queries.getNodesByKind(kind);
const cached = this.nodesByKindCache.get(kind);
if (cached !== undefined) return cached;
const result = this.queries.getNodesByKind(kind);
this.nodesByKindCache.set(kind, result);
return result;
},
fileExists: (filePath: string) => {
+16 -3
View File
@@ -412,7 +412,21 @@ export function matchByQualifiedName(
return null;
}
const candidates = context.getNodesByQualifiedName(ref.referenceName);
// A method call `receiver.method()` can share an exact qualified name with a
// config-file key: `service.process()` (a `calls` ref named `service.process`)
// vs the yaml key `service.process`. Config keys are bound to their code refs
// upstream by the framework resolvers (`@Value` → `references`); a `calls` ref
// must never resolve to a yaml/properties config node — that's a wrong edge
// AND it hides the real callee. Drop those from both the exact and the partial
// candidate sets so resolution falls through to method resolution below (#1180).
const keepForRef = (nodes: Node[]): Node[] =>
ref.referenceKind === 'calls'
? nodes.filter(
(n) => !(n.kind === 'constant' && (n.language === 'yaml' || n.language === 'properties')),
)
: nodes;
const candidates = keepForRef(context.getNodesByQualifiedName(ref.referenceName));
if (candidates.length === 1) {
return {
@@ -444,8 +458,7 @@ export function matchByQualifiedName(
const parts = ref.referenceName.split(/[:.]/);
const lastName = parts[parts.length - 1];
if (lastName) {
const partialCandidates = context
.getNodesByName(lastName)
const partialCandidates = keepForRef(context.getNodesByName(lastName))
.filter((candidate) => candidate.qualifiedName.endsWith(ref.referenceName));
const chosen = preferCallSiteFile(partialCandidates, ref.filePath)[0];
if (chosen) {