mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-14 23:22:54 +00:00
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:
parent
a82362765c
commit
e1e2464960
17 changed files with 1274 additions and 11 deletions
11
.github/workflows/ci-tests.yml
vendored
11
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
gitnexus/bench/spring-config-bindings/baselines.json
Normal file
8
gitnexus/bench/spring-config-bindings/baselines.json
Normal 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."
|
||||
}
|
||||
164
gitnexus/bench/spring-config-bindings/measure.mjs
Normal file
164
gitnexus/bench/spring-config-bindings/measure.mjs
Normal 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);
|
||||
57
gitnexus/src/core/incremental/spring-config-drift.ts
Normal file
57
gitnexus/src/core/incremental/spring-config-drift.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
25
gitnexus/test/fixtures/spring-config-app/src/main/kotlin/com/example/ConfigConsumers.kt
vendored
Normal file
25
gitnexus/test/fixtures/spring-config-app/src/main/kotlin/com/example/ConfigConsumers.kt
vendored
Normal 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
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
101
gitnexus/test/unit/spring-config-drift.test.ts
Normal file
101
gitnexus/test/unit/spring-config-drift.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue