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
+58
View File
@@ -624,6 +624,64 @@ describe('Java end-to-end — field-injected bean trace (issue #389)', () => {
cg.close();
});
it('binds a config key only for `references` refs, never a same-named method call (#1180)', async () => {
// `service.process` is BOTH a yaml key and a `service.process()` method call.
// canonicalConfigKey collapses them to the same token, so before #1180 the
// method call (kind `calls`) fell into the Spring config-key branch and
// mis-resolved to the YAML constant at 0.9 confidence — a wrong edge, and the
// uncached constant scan that made large Java/Kotlin indexes take ~1h. The
// branch is now gated to `references` (only @Value/@ConfigurationProperties).
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-spring-kindgate-'));
const javaDir = path.join(tmpDir, 'src/main/java/com/example');
const resDir = path.join(tmpDir, 'src/main/resources');
fs.mkdirSync(javaDir, { recursive: true });
fs.mkdirSync(resDir, { recursive: true });
fs.writeFileSync(
path.join(tmpDir, 'pom.xml'),
'<project><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency></dependencies></project>\n'
);
fs.writeFileSync(path.join(resDir, 'application.yml'), 'service:\n process: "enabled"\n');
fs.writeFileSync(
path.join(javaDir, 'Worker.java'),
'package com.example;\n' +
'import org.springframework.beans.factory.annotation.Value;\n' +
'class Processor { void process() {} }\n' +
'public class Worker {\n' +
' private Processor service;\n' +
' @Value("${service.process}") private String sp;\n' +
' void run() { service.process(); }\n' +
'}\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const yamlKey = cg
.getNodesByKind('constant')
.find((n) => n.language === 'yaml' && n.qualifiedName === 'service.process');
expect(yamlKey, 'yaml key service.process should be indexed').toBeDefined();
// `references` ref (@Value) DOES bind to the config key.
const valueBind = cg
.getNodesByKind('constant')
.find((n) => n.id.startsWith('spring-value:') && n.name === 'service.process');
expect(valueBind).toBeDefined();
expect(
cg.getOutgoingEdges(valueBind!.id).some((e) => e.target === yamlKey!.id),
'@Value should still bind to the yaml key',
).toBe(true);
// `calls` ref (service.process()) must NOT bind to the config key.
const run = cg.getNodesByKind('method').find((n) => n.name === 'run');
expect(run).toBeDefined();
expect(
cg.getOutgoingEdges(run!.id).some((e) => e.target === yamlKey!.id),
'a method call must never resolve to a config-key constant',
).toBe(false);
cg.close();
});
it('emits only a file node for non-MyBatis XML (pom.xml, beans.xml, log4j.xml)', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-xml-non-mybatis-'));
fs.writeFileSync(