feat(kotlin): bind Spring config consumers on Kotlin sources (#3126)

* feat(kotlin): bind Spring @Value and ConfigurationProperties consumers

Kotlin sources were skipping the Java-only config-binding attach path, so mixed JVM apps under-reported blast radius for Kotlin placeholders. Capture from the live AST, serialize on the existing side channel, and reuse the shared binder. (U1-U4)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): require exact Spring annotation FQNs and skip raw-string escapes

Reject similarly named third-party imports and leave Kotlin triple-quoted bodies undecoded so capture stays fail-closed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(kotlin): use only grammar-valid Kotlin string and class-name nodes

The coverage shard failed the #1920 literal gate on invented string node types and a Java-style name field.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(kotlin): fail closed on non-literal prefixes and persist unresolved config markers

Reject constant and boolean @ConfigurationProperties arguments, scope nested Value shadows to their owner, and rewrite drifted consumer files when a config key is deleted.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(kotlin): add config-consumer capture benchmark and fix JVM feature stamps

The Kotlin capture path had no performance or behavior guard: a file-wide
lexical shadow regression silently dropped two of every three facts. The new
bench arm fingerprints @Value / @ConfigurationProperties facts from an
explicit-import control against a wildcard-import corpus whose files each
declare a sibling nested `Value` type, so parity between the arms is the
regression gate, and CI runs it with scaling + widening budgets.

Broadening spring.config-bindings to .kt also means Kotlin-only JVM repos now
stamp the feature, which the two exact-map orchestration expectations still
denied.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(kotlin): let an explicit import shadow the Spring wildcard import

importedAs accepted a star import of the Spring package even when the same
simple name was explicitly bound to another type, so a file importing
com.example.Value alongside the Spring annotation package emitted a false
@Value consumer fact. Kotlin resolves the explicit import first, so the
wildcard branch now only applies when the name is otherwise unbound.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergő Magyar 2026-09-01 11:32:09 +01:00 committed by GitHub
parent a82362765c
commit e1e2464960
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1274 additions and 11 deletions

View file

@ -537,6 +537,17 @@ jobs:
run: node --import tsx bench/kotlin-jvm-accessors/measure.mjs --check
working-directory: gitnexus
- name: Kotlin Spring config-consumer capture guards (#2412)
if: ${{ !cancelled() }}
# Build-free: explicit-import control vs wildcard-import feature path;
# fingerprints @Value / @ConfigurationProperties facts and guards scaling
# + widening overhead. The parity check is the regression gate: each file
# declares a sibling nested type named `Value`, which must not suppress
# the imported Spring annotation (file-wide shadowing dropped 2 of every
# 3 facts on this corpus).
run: node --import tsx bench/spring-config-bindings/measure.mjs --check
working-directory: gitnexus
- name: Re-export closure scaling guards (#2864)
# Build-free: asserts buildReexportClosures stays linear in chain depth
# and within an absolute ceiling on a wide package corpus. #2864 changed

View file

@ -0,0 +1,8 @@
{
"_comment": "Baselines for bench/spring-config-bindings/measure.mjs --check (#2412). fingerprint is sha256 over position-free Kotlin config-consumer fact ids on the wildcard_large corpus (800 files × 2 @Value properties + 1 @ConfigurationProperties class = 2400 facts). Both arms must fingerprint identically: the wildcard arm adds a sibling nested type named `Value`, which must not suppress the imported Spring annotation. Budgets are timing gates with CI headroom.",
"fingerprint": "34776f883427479befbeb3c09eaae2260ba778e769bff195044d3cb8f5ad9889",
"scaling_budget": 1.6,
"_scaling_note": "(t_large/t_small)/(800/250) on the wildcard arm. Measured ~0.99.",
"widening_overhead_budget": 1.8,
"_widening_overhead_note": "wildcard_large_ms / exact_large_ms. The exact-import control resolves each annotation from imports.exact before any shadow check, so this isolates the wildcard path's per-annotation lexical shadow walk. Measured ~1.09; budget guards against a per-annotation rescan of the file's declarations."
}

View file

@ -0,0 +1,164 @@
/**
* Build-free throughput + identity bench for Kotlin Spring config-consumer
* capture (#2412).
*
* Arms (identical corpora except the import style):
* - exact: explicit `import ...annotation.Value` control, which resolves the
* annotation from `imports.exact` before any shadow check runs
* - wildcard: `import ...annotation.*` feature path, where every simple-name
* annotation pays the lexical local-type shadow walk. Each file also
* declares a sibling nested type named `Value` that must NOT suppress the
* Spring annotation the file-wide-shadow regression fixed on this branch.
*
* Parsing is prepared outside the timer; the measured path is the capture
* function the Kotlin worker calls on its own AST.
*
* Usage:
* node --import tsx bench/spring-config-bindings/measure.mjs
* node --import tsx bench/spring-config-bindings/measure.mjs --check
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import { SupportedLanguages } from 'gitnexus-shared';
import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts';
import { captureKotlinSpringConfigConsumerFacts } from '../../src/core/ingestion/languages/kotlin/spring-config-bindings.ts';
import { fingerprintIds, minSample, runBaselineCheck } from '../lib/identity-guard.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const SMALL = 250;
const LARGE = 800;
const REPS = 15;
const WARMUP = 5;
/** Two @Value properties plus one @ConfigurationProperties class per file. */
const FACTS_PER_FILE = 3;
function consumerSource(i, mode) {
const imports =
mode === 'wildcard'
? `import org.springframework.beans.factory.annotation.*
import org.springframework.boot.context.properties.*`
: `import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.properties.ConfigurationProperties`;
return `package bench.config
${imports}
class Shadowing${i} {
class Value
}
@ConfigurationProperties(prefix = "svc.${i}")
class Props${i} {
var endpoint: String? = null
}
class Consumer${i} {
@Value("\\\${app.key${i}}")
var timeout: Int = 0
@Value("\\\${app.other${i}:5}")
var other: String? = null
fun decoy() {}
}
`;
}
/** Position-free fact identity, so both arms are directly comparable. */
function factId(fact) {
const consumer = fact.consumer;
return consumer.kind === 'value'
? `value|${consumer.fieldName}|${[...consumer.keys].sort().join(',')}`
: `configuration-properties|${consumer.className}|${consumer.prefix}`;
}
function prepare(mode, fileCount) {
const files = [];
const lang = getLanguageGrammar(SupportedLanguages.Kotlin);
for (let i = 0; i < fileCount; i++) {
const parser = new Parser();
parser.setLanguage(lang);
const filePath = `bench/${mode}/Consumer${i}.kt`;
files.push({ tree: parser.parse(consumerSource(i, mode)), filePath, parser });
}
return files;
}
function runAll(files) {
const ids = [];
for (const f of files) {
for (const fact of captureKotlinSpringConfigConsumerFacts(f.tree.rootNode, f.filePath)) {
ids.push(factId(fact));
}
}
return ids;
}
function measure(mode, fileCount) {
const files = prepare(mode, fileCount);
const { last, ms } = minSample(() => runAll(files), WARMUP, REPS);
return {
files: fileCount,
ms,
facts: last.length,
fingerprint: fingerprintIds(last),
};
}
function failIfNeeded(current, errors) {
if (errors.length === 0) return;
console.error(JSON.stringify({ report: current, errors }, null, 2));
process.exit(1);
}
function runFactCountCheck(current, expectedCounts) {
const errors = [];
for (const [arm, expected] of Object.entries(expectedCounts)) {
const actual = current[arm]?.facts;
if (actual !== expected) errors.push(`${arm}.facts ${String(actual)} != ${expected}`);
}
failIfNeeded(current, errors);
}
/**
* A wildcard import plus a sibling `Value` declaration must capture exactly the
* facts the explicit-import control captures.
*/
function runFingerprintParityCheck(current, leftArm, rightArm) {
const left = current[leftArm]?.fingerprint;
const right = current[rightArm]?.fingerprint;
failIfNeeded(
current,
left === right ? [] : [`${leftArm}.fingerprint ${left} != ${rightArm}.fingerprint ${right}`],
);
}
const report = {
exact_small: measure('exact', SMALL),
exact_large: measure('exact', LARGE),
wildcard_small: measure('wildcard', SMALL),
wildcard_large: measure('wildcard', LARGE),
};
report.scaling_ratio = Number(
(report.wildcard_large.ms / report.wildcard_small.ms / (LARGE / SMALL)).toFixed(3),
);
report.widening_overhead = Number(
(report.wildcard_large.ms / Math.max(report.exact_large.ms, 0.001)).toFixed(3),
);
report.fingerprint = report.wildcard_large.fingerprint;
runFactCountCheck(report, {
exact_large: LARGE * FACTS_PER_FILE,
wildcard_large: LARGE * FACTS_PER_FILE,
});
runFingerprintParityCheck(report, 'exact_large', 'wildcard_large');
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
runBaselineCheck(report, BASELINE_PATH);

View file

@ -0,0 +1,57 @@
import type { KnowledgeGraph } from '../graph/types.js';
import { SPRING_CONFIG_UNRESOLVED_PREFIX } from '../ingestion/frameworks/spring/config-bindings.js';
export interface PersistedSpringConfigConsumerRow {
readonly id?: unknown;
readonly description?: unknown;
}
const CONSUMER_LABELS = new Set(['Property', 'Class', 'Record']);
function unresolvedKeys(description: unknown): readonly string[] {
if (typeof description !== 'string') return [];
return description
.split(';')
.map((part) => part.trim())
.filter((part) => part.startsWith(SPRING_CONFIG_UNRESOLVED_PREFIX))
.map((part) => part.slice(SPRING_CONFIG_UNRESOLVED_PREFIX.length))
.sort();
}
/**
* Find unchanged Spring consumer files whose unresolved markers changed.
*
* A removed config key also removes the old USES edge from the fresh graph, so
* ordinary new-graph boundary expansion cannot discover the consumer file.
*/
export function collectSpringConfigConsumerDriftFiles(
graph: KnowledgeGraph,
persistedRows: readonly PersistedSpringConfigConsumerRow[],
): Set<string> {
const persistedById = new Map<string, readonly string[]>();
for (const row of persistedRows) {
if (typeof row.id !== 'string') continue;
persistedById.set(row.id, unresolvedKeys(row.description));
}
const driftFiles = new Set<string>();
graph.forEachNode((node) => {
if (!CONSUMER_LABELS.has(node.label)) return;
const filePath = node.properties.filePath;
if (typeof filePath !== 'string') return;
const description = node.properties.description;
const persisted = persistedById.get(node.id);
if (
persisted === undefined &&
(typeof description !== 'string' || !description.includes(SPRING_CONFIG_UNRESOLVED_PREFIX))
) {
return;
}
const current = unresolvedKeys(description);
const prior = persisted ?? [];
if (current.length !== prior.length || current.some((key, index) => key !== prior[index])) {
driftFiles.add(filePath);
}
});
return driftFiles;
}

View file

@ -3,6 +3,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
export const SPRING_CONFIG_DESCRIPTION = 'Spring configuration property';
export const SPRING_CONFIG_UNRESOLVED_PREFIX = 'Spring config unresolved: ';
export interface SpringValueConsumer {
readonly kind: 'value';
@ -41,7 +42,7 @@ function closestNode(
}
function markUnresolved(node: GraphNode, key: string): void {
const marker = `Spring config unresolved: ${key}`;
const marker = `${SPRING_CONFIG_UNRESOLVED_PREFIX}${key}`;
const existing =
typeof node.properties.description === 'string' ? node.properties.description : '';
if (existing.includes(marker)) return;

View file

@ -5,17 +5,24 @@ function isSpringApplicationConfig(filePath: string): boolean {
return /^application(?:-[^.]+)?\.(?:properties|ya?ml)$/i.test(base);
}
/** Durable completeness contract for Java Spring configuration bindings. */
/** Durable completeness contract for Java and Kotlin Spring configuration bindings. */
export const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor = {
id: 'spring.config-bindings',
version: 1,
// Java sources need consumer extraction even without config files (missing
// placeholders still get unresolved markers). Config-only repositories also
// need a one-time rebuild to backfill language-agnostic Property nodes.
version: 2,
// Java and Kotlin sources need consumer extraction even without config files
// (missing placeholders still get unresolved markers). Config-only
// repositories also need a one-time rebuild to backfill language-agnostic
// Property nodes. Gradle Kotlin DSL is not a consumer source.
appliesTo: (filePaths) =>
filePaths.some(
(filePath) => filePath.toLowerCase().endsWith('.java') || isSpringApplicationConfig(filePath),
),
filePaths.some((filePath) => {
const normalized = filePath.replaceAll('\\', '/').toLowerCase();
if (normalized.endsWith('.gradle.kts')) return false;
return (
normalized.endsWith('.java') ||
normalized.endsWith('.kt') ||
isSpringApplicationConfig(filePath)
);
}),
};
/** Durable completeness contract for implicit Java record-component accessors. */

View file

@ -54,6 +54,7 @@ import type { KotlinSpringAopFact } from './spring-aop.js';
import type { KotlinSpringConditionalFact } from './spring-conditionals.js';
import type { KotlinSpringDiClassFact } from './spring-di.js';
import type { KotlinSpringNonHttpHandlerFact } from './spring-non-http-handlers.js';
import type { KotlinSpringConfigConsumerFact } from './spring-config-bindings.js';
const classAnnotations = createClassAnnotationFactStore();
const springAopFacts = new Map<string, readonly KotlinSpringAopFact[]>();
@ -61,6 +62,7 @@ const springConditionalFacts = new Map<string, readonly KotlinSpringConditionalF
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
const springDynamicLookupFacts = new Map<string, readonly SpringDynamicLookupFact[]>();
const springNonHttpHandlerFacts = new Map<string, readonly KotlinSpringNonHttpHandlerFact[]>();
const springConfigConsumerFacts = new Map<string, readonly KotlinSpringConfigConsumerFact[]>();
/**
* Plain JSON-serializable snapshot of the per-file Kotlin capture-time
@ -86,6 +88,8 @@ export interface KotlinCaptureSideChannel {
readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[];
/** Scheduled, event, messaging, and managed-job handler syntax captured per callable. */
readonly springNonHttpHandlerFacts?: readonly KotlinSpringNonHttpHandlerFact[];
/** `@Value` / `@ConfigurationProperties` syntax captured per owner. */
readonly springConfigConsumerFacts?: readonly KotlinSpringConfigConsumerFact[];
}
export function clearKotlinClassAnnotationFacts(): void {
@ -95,6 +99,7 @@ export function clearKotlinClassAnnotationFacts(): void {
springDiFacts.clear();
springDynamicLookupFacts.clear();
springNonHttpHandlerFacts.clear();
springConfigConsumerFacts.clear();
}
export function setKotlinSpringAopFacts(
@ -174,6 +179,20 @@ export function getKotlinSpringNonHttpHandlerFacts(
return springNonHttpHandlerFacts.get(filePath) ?? [];
}
export function setKotlinSpringConfigConsumerFacts(
filePath: string,
facts: readonly KotlinSpringConfigConsumerFact[],
): void {
if (facts.length === 0) springConfigConsumerFacts.delete(filePath);
else springConfigConsumerFacts.set(filePath, facts);
}
export function getKotlinSpringConfigConsumerFacts(
filePath: string,
): readonly KotlinSpringConfigConsumerFact[] {
return springConfigConsumerFacts.get(filePath) ?? [];
}
/**
* `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin.
* Returns `undefined` when this file recorded no side-channel state at all, so
@ -189,6 +208,7 @@ export function collectKotlinCaptureSideChannel(
const diFacts = springDiFacts.get(filePath) ?? [];
const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? [];
const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? [];
const configConsumerFacts = springConfigConsumerFacts.get(filePath) ?? [];
const packageFact = getKotlinPackageFact(filePath);
if (
companionScopes.length === 0 &&
@ -198,6 +218,7 @@ export function collectKotlinCaptureSideChannel(
diFacts.length === 0 &&
dynamicLookupFacts.length === 0 &&
nonHttpHandlerFacts.length === 0 &&
configConsumerFacts.length === 0 &&
packageFact === undefined
) {
return undefined;
@ -212,6 +233,7 @@ export function collectKotlinCaptureSideChannel(
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}),
...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}),
...(configConsumerFacts.length > 0 ? { springConfigConsumerFacts: configConsumerFacts } : {}),
};
}
@ -239,6 +261,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
setKotlinSpringDiFacts(parsed.filePath, []);
setKotlinSpringDynamicLookupFacts(parsed.filePath, []);
setKotlinSpringNonHttpHandlerFacts(parsed.filePath, []);
setKotlinSpringConfigConsumerFacts(parsed.filePath, []);
setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
return;
}
@ -266,6 +289,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
parsed.filePath,
Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [],
);
setKotlinSpringConfigConsumerFacts(
parsed.filePath,
Array.isArray(data.springConfigConsumerFacts) ? data.springConfigConsumerFacts : [],
);
setKotlinPackageFact(
parsed.filePath,
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,

View file

@ -25,11 +25,13 @@ import {
setKotlinSpringDiFacts,
setKotlinSpringDynamicLookupFacts,
setKotlinSpringNonHttpHandlerFacts,
setKotlinSpringConfigConsumerFacts,
} from './capture-side-channel.js';
import { captureKotlinPackageFact } from './package-facts.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeLombokAccessorCaptures } from './lombok-synthesizer.js';
import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js';
import { captureKotlinSpringConfigConsumerFacts } from './spring-config-bindings.js';
import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js';
import { captureKotlinSpringDynamicLookupFact } from './spring-dynamic-lookup.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
@ -372,6 +374,10 @@ export function emitKotlinScopeCaptures(
setKotlinSpringDiFacts(filePath, springDiFacts);
setKotlinSpringDynamicLookupFacts(filePath, springDynamicLookupFacts);
setKotlinSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts);
setKotlinSpringConfigConsumerFacts(
filePath,
captureKotlinSpringConfigConsumerFacts(tree.rootNode, filePath),
);
out.push(...synthesizeLombokAccessorCaptures(tree.rootNode));
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
return out;

View file

@ -27,6 +27,7 @@ import { clearKotlinPackageFacts } from './package-facts.js';
import { attachKotlinSpringDiMetadata } from './spring-di.js';
import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js';
import { attachKotlinSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js';
import { attachKotlinSpringConfigBindings } from './spring-config-bindings.js';
import { attachKotlinSpringDynamicLookup } from './spring-dynamic-lookup.js';
/**
@ -150,6 +151,7 @@ export const kotlinScopeResolver: ScopeResolver = {
attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
attachKotlinSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes);
attachKotlinSpringDynamicLookup(graph, parsedFiles, nodeLookup, indexes);
attachKotlinSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes);
},
};

View file

@ -0,0 +1,467 @@
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { makeScopeId, type ParsedFile, type ScopeId } from 'gitnexus-shared';
import {
bindSpringConfigConsumers,
type SpringConfigConsumer,
} from '../../frameworks/spring/config-bindings.js';
import { createSpringAnnotationNameResolver } from '../../frameworks/spring/bean-candidates.js';
import {
parseSpringAnnotationArguments,
parseStaticStringLiteral,
} from '../../frameworks/spring/annotation-arguments.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { getKotlinParser } from './query.js';
import { getKotlinSpringConfigConsumerFacts } from './capture-side-channel.js';
import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
const VALUE_ANNOTATION = 'org.springframework.beans.factory.annotation.Value';
const CONFIGURATION_PROPERTIES_ANNOTATION =
'org.springframework.boot.context.properties.ConfigurationProperties';
const VALUE_SIMPLE = 'Value';
const CONFIGURATION_PROPERTIES_SIMPLE = 'ConfigurationProperties';
const SKIP_USE_SITES = new Set(['get', 'property', 'file']);
const BIND_USE_SITES = new Set(['field', 'set', 'param']);
const OWNER_TYPES = new Set(['class_declaration', 'object_declaration', 'companion_object']);
const INTERPOLATION_TYPES = new Set([
'interpolated_identifier',
'interpolated_expression',
'interpolation_expression_start',
]);
const STRING_LITERAL_TYPES = new Set(['string_literal', 'character_literal']);
export interface KotlinSpringConfigConsumerFact {
readonly consumer: SpringConfigConsumer;
readonly annotationName: string;
readonly classScopeId: ScopeId;
}
interface KotlinAnnotation {
readonly name: string;
readonly node: SyntaxNode;
readonly useSiteTarget?: string;
}
interface KotlinImports {
readonly exact: ReadonlyMap<string, string>;
readonly wildcard: ReadonlySet<string>;
readonly localTypes: ReadonlyMap<string, readonly SyntaxNode[]>;
}
function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | undefined {
const stack = [...node.namedChildren].reverse();
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) continue;
if (current.type === type) return current;
for (let index = current.namedChildren.length - 1; index >= 0; index--) {
const child = current.namedChildren[index];
if (child !== undefined) stack.push(child);
}
}
return undefined;
}
function ownerName(declaration: SyntaxNode): string | undefined {
if (declaration.type === 'companion_object') {
const named = declaration.namedChildren.find((child) => child.type === 'type_identifier');
return named?.text.trim() || 'Companion';
}
return (
declaration.namedChildren.find((child) => child.type === 'type_identifier')?.text.trim() ??
declaration.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim()
);
}
function enclosingOwner(node: SyntaxNode): SyntaxNode | undefined {
let current = node.parent;
while (current !== null) {
if (OWNER_TYPES.has(current.type)) return current;
current = current.parent;
}
return undefined;
}
function classScopeId(filePath: string, declaration: SyntaxNode): ScopeId {
return makeScopeId({
filePath,
range: nodeToCapture('@scope.class', declaration).range,
kind: 'Class',
});
}
function collectKotlinImports(root: SyntaxNode): KotlinImports {
const exact = new Map<string, string>();
const wildcard = new Set<string>();
const localTypes = new Map<string, SyntaxNode[]>();
for (const header of root.descendantsOfType('import_header')) {
const text = header.text.replace(/^import\s+/, '').trim();
const aliasMatch = text.match(/^([\w.]+)\s+as\s+(\w+)\s*$/);
if (aliasMatch !== null) {
exact.set(aliasMatch[2], aliasMatch[1]);
continue;
}
if (text.endsWith('.*')) wildcard.add(text.slice(0, -2));
else {
const simple = text.slice(text.lastIndexOf('.') + 1);
if (simple.length > 0) exact.set(simple, text);
}
}
for (const type of ['class_declaration', 'object_declaration']) {
for (const declaration of root.descendantsOfType(type)) {
const name = ownerName(declaration);
if (name) {
const declarations = localTypes.get(name) ?? [];
declarations.push(declaration);
localTypes.set(name, declarations);
}
}
}
return { exact, wildcard, localTypes };
}
function annotationFromNode(annotation: SyntaxNode): KotlinAnnotation | null {
const nameNode =
firstDescendantOfType(annotation, 'user_type') ??
firstDescendantOfType(annotation, 'type_identifier') ??
firstDescendantOfType(annotation, 'simple_identifier');
if (nameNode === undefined) return null;
const useSiteTarget = annotation.namedChildren
.find((child) => child.type === 'use_site_target')
?.text.replace(/:\s*$/, '')
.trim();
return {
name: nameNode.text.trim(),
node: annotation,
...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }),
};
}
function annotationsOn(node: SyntaxNode): KotlinAnnotation[] {
const annotations: KotlinAnnotation[] = [];
for (const child of node.namedChildren) {
if (child.type === 'annotation') {
const fact = annotationFromNode(child);
if (fact !== null) annotations.push(fact);
continue;
}
if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue;
for (const nested of child.namedChildren) {
if (nested.type !== 'annotation') continue;
const fact = annotationFromNode(nested);
if (fact !== null) annotations.push(fact);
}
}
return annotations;
}
function simpleName(rawName: string): string {
const parts = rawName.split('.');
return parts[parts.length - 1] ?? rawName;
}
function importedAs(
imports: KotlinImports,
simple: string,
fqn: string,
wildcardPackage: string,
): boolean {
if (imports.exact.get(simple) === fqn) return true;
// An explicit import wins over a star import in Kotlin, so a conflicting
// binding for the same simple name rules the Spring annotation out even when
// its package is wildcard-imported.
return imports.exact.get(simple) === undefined && imports.wildcard.has(wildcardPackage);
}
function hasVisibleLocalType(
imports: KotlinImports,
simple: string,
annotation: SyntaxNode,
): boolean {
for (const declaration of imports.localTypes.get(simple) ?? []) {
const declarationOwner = enclosingOwner(declaration);
if (declarationOwner === undefined) return true;
let current: SyntaxNode | null = annotation;
while (current !== null) {
if (current.id === declarationOwner.id) return true;
current = current.parent;
}
}
return false;
}
const SIMPLE_CONFIG_ANNOTATIONS = [
{
simple: VALUE_SIMPLE,
kind: 'value',
fqn: VALUE_ANNOTATION,
wildcardPackage: 'org.springframework.beans.factory.annotation',
},
{
simple: CONFIGURATION_PROPERTIES_SIMPLE,
kind: 'configuration-properties',
fqn: CONFIGURATION_PROPERTIES_ANNOTATION,
wildcardPackage: 'org.springframework.boot.context.properties',
},
] as const;
function configAnnotationKind(
annotation: KotlinAnnotation,
imports: KotlinImports,
): 'value' | 'configuration-properties' | null {
const rawName = annotation.name;
if (rawName === VALUE_ANNOTATION) return 'value';
if (rawName === CONFIGURATION_PROPERTIES_ANNOTATION) return 'configuration-properties';
const simple = simpleName(rawName);
const aliased = imports.exact.get(simple);
if (aliased === VALUE_ANNOTATION) return 'value';
if (aliased === CONFIGURATION_PROPERTIES_ANNOTATION) return 'configuration-properties';
for (const candidate of SIMPLE_CONFIG_ANNOTATIONS) {
if (simple !== candidate.simple) continue;
if (hasVisibleLocalType(imports, simple, annotation.node) && !imports.exact.has(simple)) {
return null;
}
return importedAs(imports, simple, candidate.fqn, candidate.wildcardPackage)
? candidate.kind
: null;
}
return null;
}
function hasInterpolation(annotation: SyntaxNode): boolean {
const stack: SyntaxNode[] = [...annotation.namedChildren];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) continue;
if (INTERPOLATION_TYPES.has(current.type)) return true;
stack.push(...current.namedChildren);
}
return false;
}
function decodeKotlinStringLiteral(literal: string): string | null {
const raw = literal.startsWith('"""') && literal.endsWith('"""');
const delimiterLength = raw ? 3 : 1;
if (literal.length < delimiterLength * 2) return null;
const body = literal.slice(delimiterLength, -delimiterLength);
if (!raw && /(?<!\\)\$\{/.test(body)) return null;
if (raw) return body;
return body
.replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
)
.replace(/\\(["'\\$btnfr])/g, (_match, escaped: string) => {
const controls: Record<string, string> = {
b: '\b',
t: '\t',
n: '\n',
f: '\f',
r: '\r',
$: '$',
};
return controls[escaped] ?? escaped;
});
}
function kotlinStringLiterals(annotation: SyntaxNode): string[] {
const literals: string[] = [];
const stack: SyntaxNode[] = [...annotation.namedChildren];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) continue;
if (STRING_LITERAL_TYPES.has(current.type)) {
const decoded = decodeKotlinStringLiteral(current.text);
if (decoded !== null) literals.push(decoded);
continue;
}
stack.push(...current.namedChildren);
}
return literals;
}
function parseValuePlaceholderKeys(annotation: SyntaxNode): string[] {
if (hasInterpolation(annotation)) return [];
const keys = new Set<string>();
for (const literal of kotlinStringLiterals(annotation)) {
for (const match of literal.matchAll(/\$\{([^{}]+)\}/g)) {
const key = match[1].split(':', 1)[0].trim();
if (/^[A-Za-z0-9_.-]+$/.test(key)) keys.add(key);
}
}
return [...keys];
}
function parseConfigurationPropertiesPrefix(annotation: SyntaxNode): string | null {
if (hasInterpolation(annotation)) return null;
const argumentsList = parseSpringAnnotationArguments(annotation.text);
if (argumentsList !== null) {
const named = argumentsList.filter(
(argument) => argument.name === 'prefix' || argument.name === 'value',
);
const positional = argumentsList.filter((argument) => argument.name === undefined);
const chosen = named.length === 1 ? named[0] : named.length === 0 ? positional[0] : undefined;
if (chosen !== undefined) {
const decoded = parseStaticStringLiteral(chosen.value);
if (decoded === null) return null;
const prefix = decoded.replace(/^\.+|\.+$/g, '');
if (/^[A-Za-z0-9_.-]+$/.test(prefix)) return prefix;
return null;
}
if (argumentsList.length > 0) return null;
}
const literals = kotlinStringLiterals(annotation);
if (literals.length !== 1) return null;
const prefix = literals[0].trim().replace(/^\.+|\.+$/g, '');
return /^[A-Za-z0-9_.-]+$/.test(prefix) ? prefix : null;
}
function allowedUseSite(useSiteTarget: string | undefined): boolean {
if (useSiteTarget === undefined) return true;
if (SKIP_USE_SITES.has(useSiteTarget)) return false;
return BIND_USE_SITES.has(useSiteTarget);
}
function hasBindingPattern(parameter: SyntaxNode): boolean {
return parameter.namedChildren.some((child) => child.type === 'binding_pattern_kind');
}
function propertyName(node: SyntaxNode): string | undefined {
if (node.type === 'class_parameter') {
return node.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim();
}
const variable = node.namedChildren.find((child) => child.type === 'variable_declaration');
return variable?.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim();
}
function underFileAnnotation(node: SyntaxNode): boolean {
let current: SyntaxNode | null = node;
while (current !== null) {
if (current.type === 'file_annotation') return true;
current = current.parent;
}
return false;
}
function pushValueFacts(
facts: KotlinSpringConfigConsumerFact[],
member: SyntaxNode,
filePath: string,
imports: KotlinImports,
): void {
if (underFileAnnotation(member)) return;
const owner = enclosingOwner(member);
if (owner === undefined) return;
const fieldName = propertyName(member);
if (fieldName === undefined) return;
for (const annotation of annotationsOn(member)) {
if (!allowedUseSite(annotation.useSiteTarget)) continue;
if (configAnnotationKind(annotation, imports) !== 'value') continue;
const keys = parseValuePlaceholderKeys(annotation.node);
if (keys.length === 0) continue;
facts.push({
consumer: {
kind: 'value',
fieldName,
line: member.startPosition.row + 1,
keys,
},
annotationName: annotation.name,
classScopeId: classScopeId(filePath, owner),
});
}
}
/** Collect config facts from the Kotlin parser's existing AST (no reparse). */
export function captureKotlinSpringConfigConsumerFacts(
root: SyntaxNode,
filePath: string,
): KotlinSpringConfigConsumerFact[] {
const imports = collectKotlinImports(root);
const facts: KotlinSpringConfigConsumerFact[] = [];
for (const property of root.descendantsOfType('property_declaration')) {
pushValueFacts(facts, property, filePath, imports);
}
for (const parameter of root.descendantsOfType('class_parameter')) {
if (!hasBindingPattern(parameter)) continue;
pushValueFacts(facts, parameter, filePath, imports);
}
for (const type of ['class_declaration', 'object_declaration']) {
for (const declaration of root.descendantsOfType(type)) {
const className = ownerName(declaration);
if (className === undefined) continue;
for (const annotation of annotationsOn(declaration)) {
if (configAnnotationKind(annotation, imports) !== 'configuration-properties') {
continue;
}
const prefix = parseConfigurationPropertiesPrefix(annotation.node);
if (prefix === null) continue;
facts.push({
consumer: {
kind: 'configuration-properties',
className,
line: declaration.startPosition.row + 1,
prefix,
},
annotationName: annotation.name,
classScopeId: classScopeId(filePath, declaration),
});
}
}
}
return facts;
}
/** Parse Kotlin consumers for focused unit tests; production reuses the worker AST. */
export function extractKotlinSpringConfigConsumers(source: string): SpringConfigConsumer[] {
const tree = parseSourceSafe(getKotlinParser(), source);
return captureKotlinSpringConfigConsumerFacts(tree.rootNode, '<memory>').map(
(fact) => fact.consumer,
);
}
export function extractKotlinSpringConfigConsumerFacts(
source: string,
): KotlinSpringConfigConsumerFact[] {
const tree = parseSourceSafe(getKotlinParser(), source);
return captureKotlinSpringConfigConsumerFacts(tree.rootNode, '<memory>');
}
/** Kotlin ScopeResolver post-resolution hook for Spring configuration consumers. */
export function attachKotlinSpringConfigBindings(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
_nodeLookup: GraphNodeLookup,
indexes: ScopeResolutionIndexes,
): void {
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
const recognizedAnnotations = new Set([VALUE_ANNOTATION, CONFIGURATION_PROPERTIES_ANNOTATION]);
const batches: Array<{ filePath: string; consumers: SpringConfigConsumer[] }> = [];
for (const parsed of parsedFiles) {
const consumers: SpringConfigConsumer[] = [];
for (const fact of getKotlinSpringConfigConsumerFacts(parsed.filePath)) {
const classScope = indexes.scopeTree.getScope(fact.classScopeId);
if (classScope === undefined || classScope.kind !== 'Class') continue;
const expectedAnnotation =
fact.consumer.kind === 'value' ? VALUE_ANNOTATION : CONFIGURATION_PROPERTIES_ANNOTATION;
const enclosingScope = fact.consumer.kind === 'value' ? classScope.id : classScope.parent;
const resolved = resolveAnnotation(
fact.annotationName,
parsed,
enclosingScope,
recognizedAnnotations,
isKotlinPackageSiblingVisibilityIncomplete(parsed.filePath),
);
if (resolved === expectedAnnotation) consumers.push(fact.consumer);
}
if (consumers.length > 0) batches.push({ filePath: parsed.filePath, consumers });
}
bindSpringConfigConsumers(graph, batches);
}

View file

@ -138,6 +138,10 @@ import {
extractChangedSubgraph,
computeEffectiveWriteSet,
} from './incremental/subgraph-extract.js';
import {
collectSpringConfigConsumerDriftFiles,
type PersistedSpringConfigConsumerRow,
} from './incremental/spring-config-drift.js';
import { shadowCandidatesFor } from './incremental/shadow-candidates.js';
import { shouldEscalateIncrementalWrite } from './incremental/escalation-gate.js';
import {
@ -183,6 +187,8 @@ import {
} from './lbug/schema.js';
import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.js';
import { isSpringBeanFactoryDeclaration } from './ingestion/frameworks/spring/bean-factories.js';
import { SPRING_CONFIG_UNRESOLVED_PREFIX } from './ingestion/frameworks/spring/config-bindings.js';
import { classifySpringConfigFile } from './ingestion/pipeline-phases/spring-config.js';
import {
SPRING_AOP_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE,
@ -2453,6 +2459,37 @@ async function runFullAnalysisInner(
// leave stale rows or PK-conflict at COPY time.
const effectiveWriteSet = computeEffectiveWriteSet(pipelineResult.graph, writableFiles);
const springConfigChanged =
hashDiff.toWrite.some((filePath) => classifySpringConfigFile(filePath) !== null) ||
hashDiff.deleted.some((filePath) => classifySpringConfigFile(filePath) !== null);
if (springConfigChanged) {
const unresolvedPrefix = escapeCypherString(SPRING_CONFIG_UNRESOLVED_PREFIX);
const persistedSpringConfigConsumers = (await executeQuery(
'MATCH (n:Property) ' +
`WHERE n.description CONTAINS '${unresolvedPrefix}' ` +
'RETURN n.id AS id, n.description AS description ' +
'UNION ALL ' +
'MATCH (n:Class) ' +
`WHERE n.description CONTAINS '${unresolvedPrefix}' ` +
'RETURN n.id AS id, n.description AS description ' +
'UNION ALL ' +
'MATCH (n:Record) ' +
`WHERE n.description CONTAINS '${unresolvedPrefix}' ` +
'RETURN n.id AS id, n.description AS description',
)) as PersistedSpringConfigConsumerRow[];
const springConfigConsumerDriftFiles = collectSpringConfigConsumerDriftFiles(
pipelineResult.graph,
persistedSpringConfigConsumers,
);
for (const filePath of springConfigConsumerDriftFiles) effectiveWriteSet.add(filePath);
if (springConfigConsumerDriftFiles.size > 0) {
log(
`Incremental: +${springConfigConsumerDriftFiles.size} file(s) added for ` +
'Spring config consumer property drift',
);
}
}
// `frameworkAnnotations` is derived from cross-file JVM visibility, so
// an unchanged Class row can change when a same-package declaration is
// added or removed without producing an IMPORTS edge. Compare the fresh

View file

@ -0,0 +1,25 @@
package com.example
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.properties.ConfigurationProperties
class DirectValues {
@Value("\${payment.timeout:30}")
private var timeout: Int = 0
@Value("\${payment.missing}")
private var missing: String? = null
}
@ConfigurationProperties(prefix = "service")
class ServiceProperties {
var endpoint: String? = null
var retry: Retry? = null
}
@ConfigurationProperties("service")
class UnmatchedServiceProperties {
var unrelated: String? = null
}
class Retry

View file

@ -68,6 +68,19 @@ describe('Spring configuration binding pipeline', () => {
expect(targetsFrom(timeout)).toEqual(['payment.timeout']);
expect(targetsFrom(missing)).toEqual([]);
expect(missing?.properties.description).toContain('Spring config unresolved: payment.missing');
const ktTimeout = nodeNamed('timeout', 'ConfigConsumers.kt');
const ktMissing = nodeNamed('missing', 'ConfigConsumers.kt');
expect(ktTimeout).toBeDefined();
expect(ktMissing).toBeDefined();
if (ktTimeout === undefined || ktMissing === undefined) {
throw new Error('kotlin fixture fields missing');
}
expect(targetsFrom(ktTimeout)).toEqual(['payment.timeout']);
expect(targetsFrom(ktMissing)).toEqual([]);
expect(ktMissing?.properties.description).toContain(
'Spring config unresolved: payment.missing',
);
});
it('links ConfigurationProperties classes and relaxed field names to their prefix', () => {
@ -89,6 +102,25 @@ describe('Spring configuration binding pipeline', () => {
'src/main/resources/application.properties',
]);
expect(targetsFrom(retry)).toEqual(['service.retry.max-attempts']);
const ktOwner = nodeNamed('ServiceProperties', 'ConfigConsumers.kt');
const ktEndpoint = nodeNamed('endpoint', 'ConfigConsumers.kt');
const ktRetry = nodeNamed('retry', 'ConfigConsumers.kt');
expect(ktOwner).toBeDefined();
if (ktOwner === undefined || ktEndpoint === undefined || ktRetry === undefined) {
throw new Error('kotlin fixture ConfigurationProperties symbols missing');
}
expect(targetsFrom(ktOwner)).toEqual([
'service.endpoint',
'service.endpoint',
'service.retry.max-attempts',
]);
expect(targetsFrom(ktEndpoint)).toEqual(['service.endpoint', 'service.endpoint']);
expect(targetFilesFrom(ktEndpoint, 'service.endpoint')).toEqual([
'src/main/resources/application-dev.yml',
'src/main/resources/application.properties',
]);
expect(targetsFrom(ktRetry)).toEqual(['service.retry.max-attempts']);
});
it('keeps the class-level binding when no field relaxed-name matches', () => {
@ -103,6 +135,18 @@ describe('Spring configuration binding pipeline', () => {
'service.retry.max-attempts',
]);
expect(targetsFrom(unrelated)).toEqual([]);
const ktOwner = nodeNamed('UnmatchedServiceProperties', 'ConfigConsumers.kt');
const ktUnrelated = nodeNamed('unrelated', 'ConfigConsumers.kt');
if (ktOwner === undefined || ktUnrelated === undefined) {
throw new Error('kotlin unmatched ConfigurationProperties symbols missing');
}
expect(targetsFrom(ktOwner)).toEqual([
'service.endpoint',
'service.endpoint',
'service.retry.max-attempts',
]);
expect(targetsFrom(ktUnrelated)).toEqual([]);
});
});

View file

@ -40,7 +40,15 @@ describe('analysis feature versions', () => {
'spring.aop-advice': 1,
'spring.bean-inventory': 2,
'spring.conditionals-auto-configuration': 1,
'spring.config-bindings': 1,
'spring.config-bindings': 2,
'spring.non-http-handlers': 1,
});
expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.kt'])).toEqual({
'graph.class-framework-annotations': 1,
'spring.aop-advice': 1,
'spring.bean-inventory': 2,
'spring.conditionals-auto-configuration': 1,
'spring.config-bindings': 2,
'spring.non-http-handlers': 1,
});
expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({
@ -57,7 +65,7 @@ describe('analysis feature versions', () => {
]),
).toEqual({
'graph.class-framework-annotations': 1,
'spring.config-bindings': 1,
'spring.config-bindings': 2,
});
expect(
resolveAnalysisFeatureVersions(FEATURES, [

View file

@ -213,6 +213,24 @@ async function setupSpringConfigIncrementalRepo() {
return repo;
}
async function setupKotlinSpringConfigConsumerIncrementalRepo() {
const repo = await setupSpringConfigIncrementalRepo();
const kotlin = path.join(repo.dbPath, 'src', 'main', 'kotlin', 'com', 'example');
await mkdir(kotlin, { recursive: true });
await writeFile(
path.join(kotlin, 'ConfigConsumer.kt'),
'package com.example\n' +
'import org.springframework.beans.factory.annotation.Value\n\n' +
'class ConfigConsumer {\n' +
' @Value("\\${service.timeout}")\n' +
' var timeout: Int = 0\n' +
'}\n',
'utf-8',
);
gitCommitAll(repo.dbPath, 'add Kotlin Spring config consumer');
return repo;
}
async function readWildcardServiceAnnotations(repoPath: string): Promise<string[]> {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { lbugPath } = getStoragePaths(repoPath);
@ -246,6 +264,32 @@ async function readSpringConfigPropertyNames(repoPath: string): Promise<string[]
}
}
async function readKotlinConfigConsumerState(repoPath: string): Promise<{
description: string;
bindingCount: number;
}> {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { lbugPath } = getStoragePaths(repoPath);
await adapter.initLbug(lbugPath);
try {
const rows = (await adapter.executeQuery(
"MATCH (p:Property) WHERE p.name = 'timeout' " +
'RETURN p.description AS description LIMIT 1',
)) as Array<{ description?: unknown }>;
const bindings = (await adapter.executeQuery(
"MATCH (p:Property {name: 'timeout'})-[r:CodeRelation]->(c:Property) " +
"WHERE r.type = 'USES' AND r.reason STARTS WITH 'spring-config:' " +
'RETURN count(r) AS count',
)) as Array<{ count?: number | bigint }>;
return {
description: String(rows[0]?.description ?? ''),
bindingCount: Number(bindings[0]?.count ?? 0),
};
} finally {
await adapter.closeLbug();
}
}
async function readActuatorSnapshotLeakRows(
repoPath: string,
snapshotPath: string,
@ -753,6 +797,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
[SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version,
[SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version,
});
@ -772,6 +817,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
[SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version,
[SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version,
});
} finally {
@ -811,6 +857,43 @@ describe('runFullAnalysis — incremental orchestration', () => {
}
}, 300_000);
it('persists Kotlin unresolved markers when a Spring config key is deleted incrementally', async () => {
const repo = await setupKotlinSpringConfigConsumerIncrementalRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
expect(await readKotlinConfigConsumerState(repo.dbPath)).toEqual({
description: '',
bindingCount: 1,
});
const configPath = path.join(
repo.dbPath,
'src',
'main',
'resources',
'application.properties',
);
await writeFile(configPath, '', 'utf-8');
gitCommitAll(repo.dbPath, 'delete Spring config key');
const logs: string[] = [];
await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true },
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
);
expect(logs.join('\n')).toContain('Spring config consumer property drift');
expect(await readKotlinConfigConsumerState(repo.dbPath)).toEqual({
description: 'Spring config unresolved: service.timeout',
bindingCount: 0,
});
} finally {
await repo.cleanup();
}
}, 300_000);
it('adding the first JVM file re-evaluates capabilities after the pipeline and avoids a top-up', async () => {
const repo = await setupMiniRepo();
try {
@ -842,6 +925,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
[SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version,
[SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version,
});
} finally {

View file

@ -274,6 +274,220 @@ describe('Java Spring configuration consumers', () => {
});
});
describe('Kotlin Spring configuration consumers', () => {
it('resolves official imports and ignores shadowed annotation names', async () => {
const { extractKotlinSpringConfigConsumers } =
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
const consumers = extractKotlinSpringConfigConsumers(`
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "service")
class ServiceProperties {
@Value("\\\${service.timeout:30}")
var timeout: Int = 0
}
`);
expect(consumers).toEqual([
expect.objectContaining({ kind: 'value', fieldName: 'timeout', keys: ['service.timeout'] }),
expect.objectContaining({
kind: 'configuration-properties',
className: 'ServiceProperties',
prefix: 'service',
}),
]);
expect(
extractKotlinSpringConfigConsumers(`
annotation class Value(val value: String)
class Local {
@Value("\\\${fake.key}")
var field: String = ""
}
`),
).toEqual([]);
});
it('limits nested annotation shadows to their lexical owner', async () => {
const { extractKotlinSpringConfigConsumers } =
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
const consumers = extractKotlinSpringConfigConsumers(`
import org.springframework.beans.factory.annotation.*
class ShadowOwner {
annotation class Value(val value: String)
@Value("\\\${ignored.local}")
var local: String = ""
}
class SpringConsumer {
@Value("\\\${service.timeout}")
var timeout: Int = 0
}
`);
expect(consumers).toEqual([
expect.objectContaining({ kind: 'value', fieldName: 'timeout', keys: ['service.timeout'] }),
]);
});
it('rejects non-literal ConfigurationProperties prefixes', async () => {
const { extractKotlinSpringConfigConsumers } =
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
expect(
extractKotlinSpringConfigConsumers(`
import org.springframework.boot.context.properties.ConfigurationProperties
const val SERVICE_PREFIX = "service"
@ConfigurationProperties(prefix = SERVICE_PREFIX)
class ConstantPrefix
@ConfigurationProperties(ignoreUnknownFields = true)
class BooleanFirstArgument
`),
).toEqual([]);
});
it('binds constructor properties, setters, and import aliases, not getters or unbound params', async () => {
const { extractKotlinSpringConfigConsumers } =
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
const consumers = extractKotlinSpringConfigConsumers(`
import org.springframework.beans.factory.annotation.Value as Bound
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties("service")
class BoundProps(
@param:Bound("\\\${service.timeout}")
val timeout: Int,
@Bound("\\\${ignored.plain}")
timeoutPlain: Int,
) {
@get:Bound("\\\${ignored.getter}")
@set:Bound("\\\${service.endpoint}")
var endpoint: String = ""
@field:Bound("\\\${service.retry.max-attempts}")
var attempts: Int = 0
}
`);
expect(consumers).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: 'value', fieldName: 'timeout', keys: ['service.timeout'] }),
expect.objectContaining({
kind: 'value',
fieldName: 'endpoint',
keys: ['service.endpoint'],
}),
expect.objectContaining({
kind: 'value',
fieldName: 'attempts',
keys: ['service.retry.max-attempts'],
}),
expect.objectContaining({
kind: 'configuration-properties',
className: 'BoundProps',
prefix: 'service',
}),
]),
);
expect(
consumers.some(
(consumer) => consumer.kind === 'value' && consumer.keys.includes('ignored.getter'),
),
).toBe(false);
expect(
consumers.some(
(consumer) => consumer.kind === 'value' && consumer.keys.includes('ignored.plain'),
),
).toBe(false);
});
it('fails closed on Kotlin string interpolation and decodes escaped placeholders', async () => {
const { extractKotlinSpringConfigConsumers } =
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
expect(
extractKotlinSpringConfigConsumers(`
import org.springframework.beans.factory.annotation.Value
class Interpolated {
val prefix = "service"
@Value("\${prefix}.timeout")
var timeout: Int = 0
}
`),
).toEqual([]);
expect(
extractKotlinSpringConfigConsumers(`
import org.springframework.beans.factory.annotation.Value
class Escaped {
@Value("\\\${payment.timeout}")
var timeout: Int = 0
}
`),
).toEqual([
expect.objectContaining({ kind: 'value', fieldName: 'timeout', keys: ['payment.timeout'] }),
]);
});
it('does not treat similarly named third-party imports as Spring annotations', async () => {
const { extractKotlinSpringConfigConsumers } =
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
expect(
extractKotlinSpringConfigConsumers(`
import com.example.Value
class Local {
@Value("\\\${fake.key}")
var field: String = ""
}
`),
).toEqual([]);
// The explicit import still wins when Spring's package is star-imported
// alongside it, so only the unshadowed annotation resolves.
expect(
extractKotlinSpringConfigConsumers(`
import com.example.Value
import org.springframework.beans.factory.annotation.*
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties("service")
class Local {
@Value("\\\${fake.key}")
var field: String = ""
}
`),
).toEqual([
expect.objectContaining({
kind: 'configuration-properties',
className: 'Local',
prefix: 'service',
}),
]);
});
it('does not decode escapes inside Kotlin raw string prefixes', async () => {
const { extractKotlinSpringConfigConsumers } =
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
expect(
extractKotlinSpringConfigConsumers(`
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties("""service\\u002eendpoint""")
class RawProps
`),
).toEqual([]);
});
});
describe('Spring configuration graph binding', () => {
it('indexes the graph once for all consumer files and skips empty work', () => {
const graph = createKnowledgeGraph();

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import type { GraphNode } from 'gitnexus-shared';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { collectSpringConfigConsumerDriftFiles } from '../../src/core/incremental/spring-config-drift.js';
function consumerNode(
id: string,
filePath: string,
description?: string,
label = 'Property',
): GraphNode {
return {
id,
label,
properties: {
name: id,
filePath,
...(description === undefined ? {} : { description }),
},
} as GraphNode;
}
describe('collectSpringConfigConsumerDriftFiles', () => {
it('finds consumers that become unresolved after a config key is deleted', () => {
const graph = createKnowledgeGraph();
graph.addNode(
consumerNode(
'property:timeout',
'src/main/kotlin/Service.kt',
'Property; Spring config unresolved: service.timeout',
),
);
expect(
collectSpringConfigConsumerDriftFiles(graph, [
{
id: 'property:timeout',
description: 'Property',
},
]),
).toEqual(new Set(['src/main/kotlin/Service.kt']));
});
it('finds newly unresolved consumers even when the persisted row is absent', () => {
const graph = createKnowledgeGraph();
graph.addNode(
consumerNode(
'property:timeout',
'src/main/kotlin/Service.kt',
'Spring config unresolved: service.timeout',
),
);
expect(collectSpringConfigConsumerDriftFiles(graph, [])).toEqual(
new Set(['src/main/kotlin/Service.kt']),
);
});
it('finds consumers that become resolved and ignores unrelated description changes', () => {
const graph = createKnowledgeGraph();
graph.addNode(
consumerNode('class:service', 'src/main/kotlin/Service.kt', 'Updated docs', 'Class'),
);
graph.addNode(
consumerNode('function:helper', 'src/main/kotlin/Helper.kt', undefined, 'Function'),
);
expect(
collectSpringConfigConsumerDriftFiles(graph, [
{
id: 'class:service',
description: 'Old docs; Spring config unresolved: service',
},
{
id: 'function:helper',
description: 'Spring config unresolved: ignored',
},
]),
).toEqual(new Set(['src/main/kotlin/Service.kt']));
});
it('does not rewrite consumers whose unresolved keys are unchanged', () => {
const graph = createKnowledgeGraph();
graph.addNode(
consumerNode(
'property:timeout',
'src/main/kotlin/Service.kt',
'Spring config unresolved: service.timeout; Existing docs',
),
);
expect(
collectSpringConfigConsumerDriftFiles(graph, [
{
id: 'property:timeout',
description: 'Existing docs; Spring config unresolved: service.timeout',
},
]),
).toEqual(new Set());
});
});