diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 718718c43..be7b4d813 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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 diff --git a/gitnexus/bench/spring-config-bindings/baselines.json b/gitnexus/bench/spring-config-bindings/baselines.json new file mode 100644 index 000000000..97f881dd4 --- /dev/null +++ b/gitnexus/bench/spring-config-bindings/baselines.json @@ -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." +} diff --git a/gitnexus/bench/spring-config-bindings/measure.mjs b/gitnexus/bench/spring-config-bindings/measure.mjs new file mode 100644 index 000000000..94ac94bf2 --- /dev/null +++ b/gitnexus/bench/spring-config-bindings/measure.mjs @@ -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); diff --git a/gitnexus/src/core/incremental/spring-config-drift.ts b/gitnexus/src/core/incremental/spring-config-drift.ts new file mode 100644 index 000000000..958f33924 --- /dev/null +++ b/gitnexus/src/core/incremental/spring-config-drift.ts @@ -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 { + const persistedById = new Map(); + for (const row of persistedRows) { + if (typeof row.id !== 'string') continue; + persistedById.set(row.id, unresolvedKeys(row.description)); + } + + const driftFiles = new Set(); + 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; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts b/gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts index 0baba4e88..e08273167 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts index 73969670d..64ff16294 100644 --- a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts +++ b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts @@ -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. */ diff --git a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts index 8c8ed35a7..cda62fb88 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts @@ -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(); @@ -61,6 +62,7 @@ const springConditionalFacts = new Map(); const springDynamicLookupFacts = new Map(); const springNonHttpHandlerFacts = new Map(); +const springConfigConsumerFacts = new Map(); /** * 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, diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 6c8d6f6c3..7688a527f 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 514d65d26..983f181b8 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -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); }, }; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-config-bindings.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-config-bindings.ts new file mode 100644 index 000000000..91aa3f9c0 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-config-bindings.ts @@ -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; + readonly wildcard: ReadonlySet; + readonly localTypes: ReadonlyMap; +} + +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(); + const wildcard = new Set(); + const localTypes = new Map(); + + 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 && /(? + String.fromCharCode(Number.parseInt(hex, 16)), + ) + .replace(/\\(["'\\$btnfr])/g, (_match, escaped: string) => { + const controls: Record = { + 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(); + 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, '').map( + (fact) => fact.consumer, + ); +} + +export function extractKotlinSpringConfigConsumerFacts( + source: string, +): KotlinSpringConfigConsumerFact[] { + const tree = parseSourceSafe(getKotlinParser(), source); + return captureKotlinSpringConfigConsumerFacts(tree.rootNode, ''); +} + +/** 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); +} diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index af03a9651..453979a5b 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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 diff --git a/gitnexus/test/fixtures/spring-config-app/src/main/kotlin/com/example/ConfigConsumers.kt b/gitnexus/test/fixtures/spring-config-app/src/main/kotlin/com/example/ConfigConsumers.kt new file mode 100644 index 000000000..9295bdf13 --- /dev/null +++ b/gitnexus/test/fixtures/spring-config-app/src/main/kotlin/com/example/ConfigConsumers.kt @@ -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 diff --git a/gitnexus/test/integration/spring-config-pipeline.test.ts b/gitnexus/test/integration/spring-config-pipeline.test.ts index 002014f28..65adf802a 100644 --- a/gitnexus/test/integration/spring-config-pipeline.test.ts +++ b/gitnexus/test/integration/spring-config-pipeline.test.ts @@ -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([]); }); }); diff --git a/gitnexus/test/unit/analysis-features.test.ts b/gitnexus/test/unit/analysis-features.test.ts index 5bc70eb0f..415026be1 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -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, [ diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index 6b3290ba3..3e7b89c07 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -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 { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); @@ -246,6 +264,32 @@ async function readSpringConfigPropertyNames(repoPath: string): Promise { + 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 { diff --git a/gitnexus/test/unit/spring-config-bindings.test.ts b/gitnexus/test/unit/spring-config-bindings.test.ts index 1e64fd7df..80f0e4641 100644 --- a/gitnexus/test/unit/spring-config-bindings.test.ts +++ b/gitnexus/test/unit/spring-config-bindings.test.ts @@ -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(); diff --git a/gitnexus/test/unit/spring-config-drift.test.ts b/gitnexus/test/unit/spring-config-drift.test.ts new file mode 100644 index 000000000..dde7db89a --- /dev/null +++ b/gitnexus/test/unit/spring-config-drift.test.ts @@ -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()); + }); +});