diff --git a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts index 55dee7c4b..b85616602 100644 --- a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts +++ b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts @@ -1,12 +1,19 @@ import type { AnalysisFeatureDescriptor } from '../../../analysis-features.js'; +function isSpringApplicationConfig(filePath: string): boolean { + const base = filePath.replaceAll('\\', '/').split('/').pop() ?? ''; + return /^application(?:-[^.]+)?\.(?:properties|ya?ml)$/i.test(base); +} + /** Durable completeness contract for Java Spring configuration bindings. */ export const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor = { id: 'spring.config-bindings', version: 1, - // Annotation presence is content-derived, so the conservative upgrade gate - // is every Java repository. This guarantees the first analyzer version that - // ships bindings performs a full rebuild even when config files are absent - // (missing @Value placeholders still need their unresolved marker). - appliesTo: (filePaths) => filePaths.some((filePath) => filePath.toLowerCase().endsWith('.java')), + // 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. + appliesTo: (filePaths) => + filePaths.some( + (filePath) => filePath.toLowerCase().endsWith('.java') || isSpringApplicationConfig(filePath), + ), }; diff --git a/gitnexus/src/core/ingestion/languages/java/spring-config-bindings.ts b/gitnexus/src/core/ingestion/languages/java/spring-config-bindings.ts index 5f2eb07c3..59e98fdd0 100644 --- a/gitnexus/src/core/ingestion/languages/java/spring-config-bindings.ts +++ b/gitnexus/src/core/ingestion/languages/java/spring-config-bindings.ts @@ -19,7 +19,7 @@ const CONFIGURATION_PROPERTIES_ANNOTATION = interface JavaAnnotation { readonly name: string; - readonly text: string; + readonly node: SyntaxNode; } interface JavaImports { @@ -70,7 +70,7 @@ function annotationsOn(node: SyntaxNode): JavaAnnotation[] { for (const child of modifiers.namedChildren) { if (child.type !== 'annotation' && child.type !== 'marker_annotation') continue; const name = child.childForFieldName('name')?.text ?? child.firstNamedChild?.text; - if (name) annotations.push({ name, text: child.text }); + if (name) annotations.push({ name, node: child }); } return annotations; } @@ -88,8 +88,9 @@ function resolvesToAnnotation( } function decodeJavaStringLiteral(literal: string): string { + const delimiterLength = literal.startsWith('"""') && literal.endsWith('"""') ? 3 : 1; return literal - .slice(1, -1) + .slice(delimiterLength, -delimiterLength) .replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)), ) @@ -105,14 +106,16 @@ function decodeJavaStringLiteral(literal: string): string { }); } -function javaStringLiterals(text: string): string[] { - return [...text.matchAll(/"(?:\\.|[^"\\])*"/g)].map((match) => decodeJavaStringLiteral(match[0])); +function javaStringLiterals(annotation: SyntaxNode): string[] { + return annotation + .descendantsOfType('string_literal') + .map((literal) => decodeJavaStringLiteral(literal.text)); } /** Extract statically readable Spring placeholder keys from a Java annotation. */ -export function parseValuePlaceholderKeys(annotationText: string): string[] { +export function parseValuePlaceholderKeys(annotation: SyntaxNode): string[] { const keys = new Set(); - for (const literal of javaStringLiterals(annotationText)) { + for (const literal of javaStringLiterals(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); @@ -122,11 +125,22 @@ export function parseValuePlaceholderKeys(annotationText: string): string[] { } /** Extract `prefix`/`value` (or the positional value) from the annotation. */ -export function parseConfigurationPropertiesPrefix(annotationText: string): string | null { - const named = /\b(?:prefix|value)\s*=\s*("(?:\\.|[^"\\])*")/.exec(annotationText); - const literal = named?.[1] ?? annotationText.match(/"(?:\\.|[^"\\])*"/)?.[0]; - if (literal === undefined) return null; - const prefix = decodeJavaStringLiteral(literal) +export function parseConfigurationPropertiesPrefix(annotation: SyntaxNode): string | null { + const named = annotation.descendantsOfType('element_value_pair').find((pair) => { + const key = pair.childForFieldName('key')?.text; + return key === 'prefix' || key === 'value'; + }); + const namedValue = named?.childForFieldName('value'); + const argumentsNode = annotation.childForFieldName('arguments'); + const literalNode = + (namedValue?.type === 'string_literal' + ? namedValue + : namedValue?.descendantsOfType('string_literal')[0]) ?? + (named === undefined + ? argumentsNode?.namedChildren.find((child) => child.type === 'string_literal') + : undefined); + if (literalNode === undefined) return null; + const prefix = decodeJavaStringLiteral(literalNode.text) .trim() .replace(/^\.+|\.+$/g, ''); return /^[A-Za-z0-9_.-]+$/.test(prefix) ? prefix : null; @@ -172,7 +186,7 @@ export function captureJavaSpringConfigConsumerFacts( const fieldName = declarator.childForFieldName('name')?.text; if (!fieldName) continue; for (const annotation of annotations) { - const keys = parseValuePlaceholderKeys(annotation.text); + const keys = parseValuePlaceholderKeys(annotation.node); if (keys.length > 0) { facts.push({ consumer: { kind: 'value', fieldName, line: field.startPosition.row + 1, keys }, @@ -192,7 +206,7 @@ export function captureJavaSpringConfigConsumerFacts( if (!resolvesToAnnotation(annotation.name, CONFIGURATION_PROPERTIES_ANNOTATION, imports)) { continue; } - const prefix = parseConfigurationPropertiesPrefix(annotation.text); + const prefix = parseConfigurationPropertiesPrefix(annotation.node); if (prefix !== null) { facts.push({ consumer: { diff --git a/gitnexus/src/core/ingestion/pipeline-phases/spring-config.ts b/gitnexus/src/core/ingestion/pipeline-phases/spring-config.ts index 4b222ff93..90fa040c6 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/spring-config.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/spring-config.ts @@ -25,6 +25,8 @@ import type { StructureOutput } from './structure.js'; const require = createRequire(import.meta.url); const yaml = require('js-yaml') as typeof import('js-yaml'); const MAX_CONFIG_FILE_BYTES = 2 * 1024 * 1024; +const MAX_YAML_TRAVERSAL_DEPTH = 128; +const MAX_YAML_TRAVERSAL_NODES = 100_000; export interface SpringConfigKey { readonly key: string; @@ -143,6 +145,21 @@ interface YamlMappingLocation { readonly line: number; } +interface YamlTraversalState { + remainingNodes: number; + readonly activeObjects: Set; +} + +function consumeYamlTraversalBudget(state: YamlTraversalState, depth: number): void { + if (depth > MAX_YAML_TRAVERSAL_DEPTH) { + throw new Error(`Spring YAML traversal depth exceeds ${MAX_YAML_TRAVERSAL_DEPTH}`); + } + state.remainingNodes--; + if (state.remainingNodes < 0) { + throw new Error(`Spring YAML traversal exceeds ${MAX_YAML_TRAVERSAL_NODES} nodes`); + } +} + function isObjectValue(value: unknown): value is object { return value !== null && typeof value === 'object'; } @@ -174,15 +191,25 @@ function findYamlMappingLocation( event: YamlParseEvent | undefined, key: string, objectEvents: WeakMap, + traversal: YamlTraversalState, visited = new Set(), + depth = 0, ): YamlMappingLocation | undefined { + consumeYamlTraversalBudget(traversal, depth); const resolved = resolveYamlAliasEvent(event, objectEvents); if (resolved === undefined || visited.has(resolved)) return undefined; visited.add(resolved); if (resolved.kind === 'sequence') { for (const child of resolved.children) { - const found = findYamlMappingLocation(child, key, objectEvents, visited); + const found = findYamlMappingLocation( + child, + key, + objectEvents, + traversal, + visited, + depth + 1, + ); if (found !== undefined) return found; } return undefined; @@ -195,7 +222,14 @@ function findYamlMappingLocation( return { valueEvent: direct.valueEvent, line: direct.keyEvent.startLine }; } for (const merge of pairs.filter((pair) => pair.key === '<<')) { - const found = findYamlMappingLocation(merge.valueEvent, key, objectEvents, visited); + const found = findYamlMappingLocation( + merge.valueEvent, + key, + objectEvents, + traversal, + visited, + depth + 1, + ); if (found !== undefined) return found; } return undefined; @@ -207,45 +241,60 @@ function flattenYamlValue( prefix: string, out: Map, objectEvents: WeakMap, + traversal: YamlTraversalState, sourceLine = event?.startLine ?? 1, + depth = 0, ): void { + consumeYamlTraversalBudget(traversal, depth); const resolvedEvent = resolveYamlAliasEvent(event, objectEvents); - if (Array.isArray(value)) { - if (value.length === 0 && prefix.length > 0 && !out.has(prefix)) out.set(prefix, sourceLine); - value.forEach((item, index) => - flattenYamlValue( - item, - resolvedEvent?.children[index], - `${prefix}[${index}]`, - out, - objectEvents, - sourceLine, - ), - ); - return; - } - if ( - value !== null && - typeof value === 'object' && - (resolvedEvent?.kind === 'mapping' || resolvedEvent === undefined) - ) { - const entries = Object.entries(value as Record); - if (entries.length === 0 && prefix.length > 0 && !out.has(prefix)) out.set(prefix, sourceLine); - for (const [key, nested] of entries) { - const next = prefix.length === 0 ? key : `${prefix}.${key}`; - const location = findYamlMappingLocation(resolvedEvent, key, objectEvents); - flattenYamlValue( - nested, - location?.valueEvent, - next, - out, - objectEvents, - location?.line ?? sourceLine, + const trackedObject = isObjectValue(value) ? value : undefined; + if (trackedObject !== undefined && traversal.activeObjects.has(trackedObject)) return; + if (trackedObject !== undefined) traversal.activeObjects.add(trackedObject); + try { + if (Array.isArray(value)) { + if (value.length === 0 && prefix.length > 0 && !out.has(prefix)) out.set(prefix, sourceLine); + value.forEach((item, index) => + flattenYamlValue( + item, + resolvedEvent?.children[index], + `${prefix}[${index}]`, + out, + objectEvents, + traversal, + sourceLine, + depth + 1, + ), ); + return; } - return; + if ( + value !== null && + typeof value === 'object' && + (resolvedEvent?.kind === 'mapping' || resolvedEvent === undefined) + ) { + const entries = Object.entries(value as Record); + if (entries.length === 0 && prefix.length > 0 && !out.has(prefix)) + out.set(prefix, sourceLine); + for (const [key, nested] of entries) { + const next = prefix.length === 0 ? key : `${prefix}.${key}`; + const location = findYamlMappingLocation(resolvedEvent, key, objectEvents, traversal); + flattenYamlValue( + nested, + location?.valueEvent, + next, + out, + objectEvents, + traversal, + location?.line ?? sourceLine, + depth + 1, + ); + } + return; + } + if (prefix.length > 0 && !out.has(prefix)) out.set(prefix, sourceLine); + } finally { + if (trackedObject !== undefined) traversal.activeObjects.delete(trackedObject); } - if (prefix.length > 0 && !out.has(prefix)) out.set(prefix, sourceLine); } /** Parse and flatten YAML leaves without retaining their values. */ @@ -259,6 +308,10 @@ export function parseSpringYaml( const documentEvents: YamlParseEvent[] = []; const objectEvents = new WeakMap(); const documents: unknown[] = []; + const traversal: YamlTraversalState = { + remainingNodes: MAX_YAML_TRAVERSAL_NODES, + activeObjects: new Set(), + }; yaml.loadAll(content, (document) => documents.push(document), { schema: yaml.DEFAULT_SCHEMA, @@ -290,7 +343,7 @@ export function parseSpringYaml( }); documents.forEach((document, index) => - flattenYamlValue(document, documentEvents[index], '', flattened, objectEvents), + flattenYamlValue(document, documentEvents[index], '', flattened, objectEvents, traversal), ); return [...flattened.entries()] .sort(([left], [right]) => left.localeCompare(right)) @@ -350,7 +403,6 @@ export const springConfigPhase: PipelinePhase = { filePath: entry.filePath, startLine: entry.line, endLine: entry.line, - language: entry.format, description: entry.profile ? `${SPRING_CONFIG_DESCRIPTION} (profile: ${entry.profile})` : SPRING_CONFIG_DESCRIPTION, diff --git a/gitnexus/test/fixtures/spring-config-app/src/main/java/com/example/ConfigConsumers.java b/gitnexus/test/fixtures/spring-config-app/src/main/java/com/example/ConfigConsumers.java index 49e6847dd..0d9803d06 100644 --- a/gitnexus/test/fixtures/spring-config-app/src/main/java/com/example/ConfigConsumers.java +++ b/gitnexus/test/fixtures/spring-config-app/src/main/java/com/example/ConfigConsumers.java @@ -17,4 +17,9 @@ class ServiceProperties { private Retry retry; } +@ConfigurationProperties("service") +class UnmatchedServiceProperties { + private String unrelated; +} + class Retry {} diff --git a/gitnexus/test/fixtures/spring-config-app/src/main/resources/application.properties b/gitnexus/test/fixtures/spring-config-app/src/main/resources/application.properties index b763cc27c..fff1b7e68 100644 --- a/gitnexus/test/fixtures/spring-config-app/src/main/resources/application.properties +++ b/gitnexus/test/fixtures/spring-config-app/src/main/resources/application.properties @@ -1 +1,2 @@ payment.timeout=30 +service.endpoint=https://base.example.test diff --git a/gitnexus/test/integration/spring-config-pipeline.test.ts b/gitnexus/test/integration/spring-config-pipeline.test.ts index 797262816..002014f28 100644 --- a/gitnexus/test/integration/spring-config-pipeline.test.ts +++ b/gitnexus/test/integration/spring-config-pipeline.test.ts @@ -1,8 +1,11 @@ import path from 'node:path'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { SPRING_CONFIG_DESCRIPTION } from '../../src/core/ingestion/frameworks/spring/config-bindings.js'; import type { PipelineResult } from '../../types/pipeline.js'; +import { createTempDir } from '../helpers/test-db.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-config-app'); const SHADOW_FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-config-shadow-app'); @@ -33,8 +36,18 @@ describe('Spring configuration binding pipeline', () => { .map((edge) => String(result.graph.getNode(edge.targetId)?.properties.name)) .sort(); + const targetFilesFrom = (source: GraphNode, targetName: string): string[] => + uses + .filter((edge) => edge.sourceId === source.id) + .map((edge) => result.graph.getNode(edge.targetId)) + .filter((node) => node?.properties.name === targetName) + .map((node) => String(node?.properties.filePath)) + .sort(); + it('creates key-only Property nodes for properties and profile YAML files', () => { - expect(nodeNamed('payment.timeout', 'application.properties')).toBeDefined(); + const propertiesKey = nodeNamed('payment.timeout', 'application.properties'); + expect(propertiesKey).toBeDefined(); + expect(propertiesKey?.properties).not.toHaveProperty('language'); expect(nodeNamed('service.endpoint', 'application-dev.yml')?.properties.description).toContain( 'profile: dev', ); @@ -65,10 +78,32 @@ describe('Spring configuration binding pipeline', () => { if (owner === undefined || endpoint === undefined || retry === undefined) { throw new Error('fixture ConfigurationProperties symbols missing'); } - expect(targetsFrom(owner)).toEqual(['service.endpoint', 'service.retry.max-attempts']); - expect(targetsFrom(endpoint)).toEqual(['service.endpoint']); + expect(targetsFrom(owner)).toEqual([ + 'service.endpoint', + 'service.endpoint', + 'service.retry.max-attempts', + ]); + expect(targetsFrom(endpoint)).toEqual(['service.endpoint', 'service.endpoint']); + expect(targetFilesFrom(endpoint, 'service.endpoint')).toEqual([ + 'src/main/resources/application-dev.yml', + 'src/main/resources/application.properties', + ]); expect(targetsFrom(retry)).toEqual(['service.retry.max-attempts']); }); + + it('keeps the class-level binding when no field relaxed-name matches', () => { + const owner = nodeNamed('UnmatchedServiceProperties', 'ConfigConsumers.java'); + const unrelated = nodeNamed('unrelated', 'ConfigConsumers.java'); + if (owner === undefined || unrelated === undefined) { + throw new Error('unmatched ConfigurationProperties symbols missing'); + } + expect(targetsFrom(owner)).toEqual([ + 'service.endpoint', + 'service.endpoint', + 'service.retry.max-attempts', + ]); + expect(targetsFrom(unrelated)).toEqual([]); + }); }); describe('Spring configuration annotation attribution', () => { @@ -91,3 +126,31 @@ describe('Spring configuration annotation attribution', () => { expect(String(fake.properties.description ?? '')).not.toContain('Spring config unresolved:'); }); }); + +describe('Spring configuration file safety bounds', () => { + it('fails closed for malformed and oversized configuration files', async () => { + const repo = await createTempDir(); + try { + const resources = path.join(repo.dbPath, 'src', 'main', 'resources'); + await mkdir(resources, { recursive: true }); + await writeFile(path.join(resources, 'application-broken.yml'), 'broken: [\n', 'utf8'); + await writeFile( + path.join(resources, 'application-oversized.properties'), + `oversized.key=${'x'.repeat(2 * 1024 * 1024)}\n`, + 'utf8', + ); + + // Let the scanner admit the file so this exercises springConfig's + // stricter 2 MiB cap rather than the scanner's default 512 KiB cap. + vi.stubEnv('GITNEXUS_MAX_FILE_SIZE', '4096'); + const result = await runPipelineFromRepo(repo.dbPath, () => {}, { skipGraphPhases: true }); + const configNodes = [...result.graph.iterNodes()].filter((node) => + String(node.properties.description ?? '').startsWith(SPRING_CONFIG_DESCRIPTION), + ); + expect(configNodes).toEqual([]); + } finally { + vi.unstubAllEnvs(); + await repo.cleanup(); + } + }); +}); diff --git a/gitnexus/test/unit/analysis-features.test.ts b/gitnexus/test/unit/analysis-features.test.ts index c2592b494..146965798 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -28,6 +28,15 @@ describe('analysis feature versions', () => { 'graph.class-framework-annotations': 1, 'spring.bean-inventory': 1, }); + expect( + resolveAnalysisFeatureVersions(FEATURES, [ + 'src/main/resources/application-local.yml', + 'README.md', + ]), + ).toEqual({ + 'graph.class-framework-annotations': 1, + 'spring.config-bindings': 1, + }); }); it('requires an exact, well-formed feature set', () => { diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index 12726a607..b74efcb47 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -44,6 +44,7 @@ import { } from '../helpers/embedding-seed.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js'; import { SPRING_BEAN_INVENTORY_FEATURE } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; +import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js'; const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-'); @@ -102,6 +103,16 @@ async function setupKotlinSpringBeanIncrementalRepo() { return repo; } +async function setupSpringConfigIncrementalRepo() { + const repo = await createTempDir('gitnexus-incr-spring-config-'); + const resources = path.join(repo.dbPath, 'src', 'main', 'resources'); + await mkdir(resources, { recursive: true }); + await writeFile(path.join(resources, 'application.properties'), 'service.timeout=30\n', 'utf-8'); + execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); + gitCommitAll(repo.dbPath, 'initial spring configuration'); + return repo; +} + async function readWildcardServiceAnnotations(repoPath: string): Promise { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); @@ -120,6 +131,21 @@ async function readWildcardServiceAnnotations(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.filePath = 'src/main/resources/application.properties' " + + 'RETURN p.name AS name ORDER BY p.name', + )) as Array<{ name?: unknown }>; + return rows.map((row) => String(row.name)); + } finally { + await adapter.closeLbug(); + } +} + /** * Direct count over INJECTS CodeRelation rows — mirrors pdg-mode-flip's * countBasicBlocks: reopen the repo DB, count, close (runFullAnalysis closes @@ -271,6 +297,38 @@ describe('runFullAnalysis — incremental orchestration', () => { } }, 300_000); + it('a config-only index missing Spring config evidence rebuilds and restores the scoped stamp', async () => { + const repo = await setupSpringConfigIncrementalRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + const { storagePath } = getStoragePaths(repo.dbPath); + const meta = await loadMeta(storagePath); + expect(meta!.analysisFeatures).toEqual({ + [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, + [SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version, + }); + + await saveMeta(storagePath, withoutAnalysisFeature(meta!, SPRING_CONFIG_BINDINGS_FEATURE.id)); + const logs: string[] = []; + const reanalyzed = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(reanalyzed.alreadyUpToDate).toBeUndefined(); + expect(logs.join('\n')).toContain(`missing:${SPRING_CONFIG_BINDINGS_FEATURE.id}`); + expect(await readSpringConfigPropertyNames(repo.dbPath)).toEqual(['service.timeout']); + expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ + [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, + [SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version, + }); + } 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 { diff --git a/gitnexus/test/unit/spring-config-bindings.test.ts b/gitnexus/test/unit/spring-config-bindings.test.ts index 321d3647a..a935039fc 100644 --- a/gitnexus/test/unit/spring-config-bindings.test.ts +++ b/gitnexus/test/unit/spring-config-bindings.test.ts @@ -6,11 +6,7 @@ import { parseSpringProperties, parseSpringYaml, } from '../../src/core/ingestion/pipeline-phases/spring-config.js'; -import { - extractJavaSpringConfigConsumers, - parseConfigurationPropertiesPrefix, - parseValuePlaceholderKeys, -} from '../../src/core/ingestion/languages/java/spring-config-bindings.js'; +import { extractJavaSpringConfigConsumers } from '../../src/core/ingestion/languages/java/spring-config-bindings.js'; describe('Spring configuration parsing', () => { it('recognizes base and profile-specific application config files', () => { @@ -83,13 +79,17 @@ describe('Spring configuration parsing', () => { expect(keys.some((entry) => entry.key.includes('<<'))).toBe(false); }); - it('parses Value defaults and ConfigurationProperties aliases', () => { - expect(parseValuePlaceholderKeys('@Value("${server.port:8080}")')).toEqual(['server.port']); + it('terminates cyclic YAML aliases and bounds deeply nested expansion', () => { expect( - parseConfigurationPropertiesPrefix('@ConfigurationProperties(prefix = "acme.api")'), - ).toBe('acme.api'); - expect(parseConfigurationPropertiesPrefix('@ConfigurationProperties("acme.api")')).toBe( - 'acme.api', + parseSpringYaml('cycle: &cycle { self: *cycle }\nhealthy: true\n', 'application.yml'), + ).toEqual([expect.objectContaining({ key: 'healthy', line: 2 })]); + + const aliasChain = ['level0: &level0 { leaf: true }']; + for (let index = 1; index <= 130; index++) { + aliasChain.push(`level${index}: &level${index} { next: *level${index - 1} }`); + } + expect(() => parseSpringYaml(aliasChain.join('\n'), 'application.yml')).toThrow( + 'Spring YAML traversal depth', ); }); }); @@ -144,6 +144,30 @@ describe('Java Spring configuration consumers', () => { }), ]); }); + + it('reads only string-literal AST nodes and ignores placeholders inside comments', () => { + const consumers = extractJavaSpringConfigConsumers(` + import org.springframework.beans.factory.annotation.Value; + import org.springframework.boot.context.properties.ConfigurationProperties; + + @ConfigurationProperties( + // legacy prefix: "old.unsafe" + value = "service" + ) + class ServiceProperties { + @Value( + /* legacy: "\${old.unsafe.key}" */ + "\${service.timeout:30}" + ) + private int timeout; + } + `); + + expect(consumers).toEqual([ + expect.objectContaining({ kind: 'value', keys: ['service.timeout'] }), + expect.objectContaining({ kind: 'configuration-properties', prefix: 'service' }), + ]); + }); }); describe('Spring configuration graph binding', () => {