diff --git a/gitnexus/src/core/ingestion/frameworks/spring/bean-candidates.ts b/gitnexus/src/core/ingestion/frameworks/spring/bean-candidates.ts index f18424598..5981a9e5a 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/bean-candidates.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/bean-candidates.ts @@ -59,6 +59,7 @@ export interface SpringBeanCandidateAdapter { } type OwnedTypeNamesByOwner = ReadonlyMap>; +type RecognizedAnnotationNames = { readonly has: (value: string) => boolean }; function simpleNameOf(def: SymbolDefinition): string | undefined { const qualifiedName = def.qualifiedName; @@ -152,7 +153,11 @@ function hasVisibleTypeBinding( return false; } -function wildcardImportTarget(parsed: ParsedFile, simpleName: string): string | undefined { +function wildcardImportTarget( + parsed: ParsedFile, + simpleName: string, + recognizedAnnotations: RecognizedAnnotationNames, +): string | undefined { const wildcardPackages = new Set( parsed.parsedImports .filter((entry) => entry.kind === 'wildcard') @@ -161,37 +166,40 @@ function wildcardImportTarget(parsed: ParsedFile, simpleName: string): string | if (wildcardPackages.size !== 1) return undefined; const [packageName] = wildcardPackages; const target = `${packageName}.${simpleName}`; - return SPRING_BEAN_STEREOTYPES.has(target) ? target : undefined; + return recognizedAnnotations.has(target) ? target : undefined; } -function resolveSpringAnnotation( - rawName: string, - parsed: ParsedFile, - enclosingScope: ScopeId | null, - indexes: ScopeResolutionIndexes, - ownedTypeNamesByOwner: OwnedTypeNamesByOwner, - isPackageVisibilityIncomplete: boolean, -): string | undefined { - if (rawName.includes('.')) { - return SPRING_BEAN_STEREOTYPES.has(rawName) ? rawName : undefined; - } +/** Build a scope-aware Spring annotation resolver shared by framework hooks. */ +export function createSpringAnnotationNameResolver(indexes: ScopeResolutionIndexes) { + const ownedTypeNamesByOwner = buildOwnedTypeNamesByOwner(indexes); + return ( + rawName: string, + parsed: ParsedFile, + enclosingScope: ScopeId | null, + recognizedAnnotations: RecognizedAnnotationNames, + isPackageVisibilityIncomplete: boolean, + ): string | undefined => { + if (rawName.includes('.')) { + return recognizedAnnotations.has(rawName) ? rawName : undefined; + } - if (hasLexicalTypeDeclaration(enclosingScope, rawName, indexes)) return undefined; - if (hasInheritedTypeDeclaration(enclosingScope, rawName, indexes, ownedTypeNamesByOwner)) { - return undefined; - } + if (hasLexicalTypeDeclaration(enclosingScope, rawName, indexes)) return undefined; + if (hasInheritedTypeDeclaration(enclosingScope, rawName, indexes, ownedTypeNamesByOwner)) { + return undefined; + } - const explicitImports = explicitImportTargets(parsed, rawName); - if (explicitImports.size > 0) { - if (explicitImports.size !== 1) return undefined; - const [imported] = explicitImports; - return SPRING_BEAN_STEREOTYPES.has(imported) ? imported : undefined; - } + const explicitImports = explicitImportTargets(parsed, rawName); + if (explicitImports.size > 0) { + if (explicitImports.size !== 1) return undefined; + const [imported] = explicitImports; + return recognizedAnnotations.has(imported) ? imported : undefined; + } - const wildcardTarget = wildcardImportTarget(parsed, rawName); - if (wildcardTarget === undefined || isPackageVisibilityIncomplete) return undefined; + const wildcardTarget = wildcardImportTarget(parsed, rawName, recognizedAnnotations); + if (wildcardTarget === undefined || isPackageVisibilityIncomplete) return undefined; - return hasVisibleTypeBinding(enclosingScope, rawName, indexes) ? undefined : wildcardTarget; + return hasVisibleTypeBinding(enclosingScope, rawName, indexes) ? undefined : wildcardTarget; + }; } /** Build a language hook that enriches Class nodes after scope resolution. */ @@ -202,7 +210,7 @@ export function createSpringBeanCandidateAttacher(adapter: SpringBeanCandidateAd nodeLookup: GraphNodeLookup, indexes: ScopeResolutionIndexes, ): void => { - const ownedTypeNamesByOwner = buildOwnedTypeNamesByOwner(indexes); + const resolveSpringAnnotation = createSpringAnnotationNameResolver(indexes); for (const parsed of parsedFiles) { for (const fact of adapter.getClassAnnotationFacts(parsed.filePath)) { const classScope = indexes.scopeTree.getScope(fact.classScopeId); @@ -221,8 +229,7 @@ export function createSpringBeanCandidateAttacher(adapter: SpringBeanCandidateAd rawName, parsed, classScope.parent, - indexes, - ownedTypeNamesByOwner, + SPRING_BEAN_STEREOTYPES, adapter.isPackageVisibilityIncomplete(parsed.filePath), ); if (annotation !== undefined) recognized.add(annotation); diff --git a/gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts b/gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts new file mode 100644 index 000000000..0baba4e88 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts @@ -0,0 +1,166 @@ +import type { GraphNode } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import { generateId } from '../../../../lib/utils.js'; + +export const SPRING_CONFIG_DESCRIPTION = 'Spring configuration property'; + +export interface SpringValueConsumer { + readonly kind: 'value'; + readonly fieldName: string; + readonly line: number; + readonly keys: readonly string[]; +} + +export interface SpringConfigurationPropertiesConsumer { + readonly kind: 'configuration-properties'; + readonly className: string; + readonly line: number; + readonly prefix: string; +} + +export type SpringConfigConsumer = SpringValueConsumer | SpringConfigurationPropertiesConsumer; + +export interface SpringConfigConsumerBatch { + readonly filePath: string; + readonly consumers: readonly SpringConfigConsumer[]; +} + +function closestNode( + candidates: readonly GraphNode[], + filePath: string, + name: string, + line: number, +): GraphNode | undefined { + return candidates + .filter((node) => node.properties.filePath === filePath && node.properties.name === name) + .sort( + (left, right) => + Math.abs(Number(left.properties.startLine ?? 0) - line) - + Math.abs(Number(right.properties.startLine ?? 0) - line), + )[0]; +} + +function markUnresolved(node: GraphNode, key: string): void { + const marker = `Spring config unresolved: ${key}`; + const existing = + typeof node.properties.description === 'string' ? node.properties.description : ''; + if (existing.includes(marker)) return; + node.properties.description = existing.length > 0 ? `${existing}; ${marker}` : marker; +} + +function relaxedName(value: string): string { + return value.toLowerCase().replace(/[-_.]/g, ''); +} + +function isSpringConfigNode(node: GraphNode): boolean { + return ( + node.label === 'Property' && + typeof node.properties.description === 'string' && + node.properties.description.startsWith(SPRING_CONFIG_DESCRIPTION) + ); +} + +/** + * Attach normalized, language-provider-produced Spring consumers to config + * keys already present in the shared graph. + */ +export function bindSpringConfigConsumers( + graph: KnowledgeGraph, + batches: readonly SpringConfigConsumerBatch[], +): void { + if (batches.length === 0) return; + + const configNodes: GraphNode[] = []; + const propertyNodes: GraphNode[] = []; + const classNodes: GraphNode[] = []; + for (const node of graph.iterNodes()) { + if (isSpringConfigNode(node)) configNodes.push(node); + else if (node.label === 'Property') propertyNodes.push(node); + else if (node.label === 'Class' || node.label === 'Record') classNodes.push(node); + } + + const keyNodes = new Map(); + for (const node of configNodes) { + const key = String(node.properties.name); + const bucket = keyNodes.get(key) ?? []; + bucket.push(node); + keyNodes.set(key, bucket); + } + + const propertiesByOwner = new Map(); + for (const rel of graph.iterRelationshipsByType('HAS_PROPERTY')) { + const property = graph.getNode(rel.targetId); + if (property?.label !== 'Property' || isSpringConfigNode(property)) continue; + const members = propertiesByOwner.get(rel.sourceId) ?? []; + members.push(property); + propertiesByOwner.set(rel.sourceId, members); + } + + const addBinding = ( + source: GraphNode, + target: GraphNode, + reason: string, + confidence: number, + ): void => { + const edgeId = generateId('USES', `${source.id}->${target.id}:${reason}`); + graph.addRelationship({ + id: edgeId, + sourceId: source.id, + targetId: target.id, + type: 'USES', + confidence, + reason, + }); + }; + + for (const { filePath, consumers } of batches) { + for (const consumer of consumers) { + if (consumer.kind === 'value') { + const field = closestNode(propertyNodes, filePath, consumer.fieldName, consumer.line); + if (field === undefined) continue; + for (const key of consumer.keys) { + const matches = keyNodes.get(key) ?? []; + if (matches.length === 0) { + markUnresolved(field, key); + continue; + } + for (const match of matches) { + addBinding(field, match, `spring-config:@Value ${key}`, 1); + } + } + continue; + } + + const owner = closestNode(classNodes, filePath, consumer.className, consumer.line); + if (owner === undefined) continue; + const prefix = `${consumer.prefix}.`; + const matches = configNodes.filter((node) => { + const key = String(node.properties.name); + return key === consumer.prefix || key.startsWith(prefix); + }); + if (matches.length === 0) { + markUnresolved(owner, consumer.prefix); + continue; + } + for (const match of matches) { + addBinding(owner, match, `spring-config:@ConfigurationProperties ${consumer.prefix}`, 0.95); + } + + for (const field of propertiesByOwner.get(owner.id) ?? []) { + const fieldName = relaxedName(String(field.properties.name)); + for (const match of matches) { + const key = String(match.properties.name); + const suffix = key === consumer.prefix ? '' : key.slice(prefix.length); + const firstSegment = suffix.split(/[.\[]/, 1)[0]; + if (firstSegment.length === 0 || relaxedName(firstSegment) !== fieldName) continue; + addBinding( + field, + match, + `spring-config:@ConfigurationProperties field ${consumer.prefix}`, + 0.95, + ); + } + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts new file mode 100644 index 000000000..b85616602 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts @@ -0,0 +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, + // 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/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts index 8cc55a382..348c91653 100644 --- a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts @@ -9,6 +9,7 @@ import { type JvmPackageFact, } from '../jvm/package-facts.js'; import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js'; +import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js'; export type JavaClassAnnotationFact = ClassAnnotationFact; @@ -16,13 +17,16 @@ export interface JavaCaptureSideChannel { readonly kind: 'java'; readonly packageFact: JvmPackageFact; readonly classAnnotations: readonly JavaClassAnnotationFact[]; + readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[]; } const classAnnotations = createClassAnnotationFactStore(); +const springConfigConsumers = new Map(); /** Clear facts retained by a prior workspace pass in a long-lived process. */ export function clearJavaClassAnnotationFacts(): void { classAnnotations.clear(); + springConfigConsumers.clear(); } /** Store the annotation syntax collected by Java's existing scope-query traversal. */ @@ -33,17 +37,35 @@ export function setJavaClassAnnotationFacts( classAnnotations.set(filePath, facts); } +export function setJavaSpringConfigConsumerFacts( + filePath: string, + facts: readonly JavaSpringConfigConsumerFact[], +): void { + if (facts.length === 0) springConfigConsumers.delete(filePath); + else springConfigConsumers.set(filePath, facts); +} + +export function getJavaSpringConfigConsumerFacts( + filePath: string, +): readonly JavaSpringConfigConsumerFact[] { + return springConfigConsumers.get(filePath) ?? []; +} + /** Snapshot worker-local Java annotation facts for ParsedFile serialization. */ export function collectJavaCaptureSideChannel( filePath: string, ): JavaCaptureSideChannel | undefined { const facts = classAnnotations.get(filePath); + const configConsumers = springConfigConsumers.get(filePath) ?? []; const packageFact = getJavaPackageFact(filePath); - if (facts.length === 0 && packageFact === undefined) return undefined; + if (facts.length === 0 && configConsumers.length === 0 && packageFact === undefined) { + return undefined; + } return { kind: 'java', packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT, classAnnotations: facts, + ...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}), }; } @@ -62,10 +84,15 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { !Array.isArray(data.classAnnotations) ) { setJavaClassAnnotationFacts(parsed.filePath, []); + setJavaSpringConfigConsumerFacts(parsed.filePath, []); setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; } setJavaClassAnnotationFacts(parsed.filePath, data.classAnnotations); + setJavaSpringConfigConsumerFacts( + parsed.filePath, + Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [], + ); setJavaPackageFact( parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT, diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 9864d0763..3adb036c8 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -32,9 +32,13 @@ import { getJavaParser, getJavaScopeQuery } from './query.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; -import { setJavaClassAnnotationFacts } from './capture-side-channel.js'; +import { + setJavaClassAnnotationFacts, + setJavaSpringConfigConsumerFacts, +} from './capture-side-channel.js'; import { captureJavaPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; +import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js'; /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; @@ -280,6 +284,10 @@ export function emitJavaScopeCaptures( } setJavaClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations)); + setJavaSpringConfigConsumerFacts( + filePath, + captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath), + ); return [ ...resolveVarTypeBindings(out), diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index fd47fb69e..52266456b 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -30,6 +30,7 @@ import { } from './index.js'; import { populateJavaPackageSiblings } from './package-siblings.js'; import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; +import { attachJavaSpringConfigBindings } from './spring-config-bindings.js'; import { applyJavaCaptureSideChannel, clearJavaClassAnnotationFacts, @@ -83,7 +84,10 @@ const javaScopeResolver: ScopeResolver = { populateNamespaceSiblings: populateJavaPackageSiblings, populateRangeBindings: populateJavaCrossFileReturnTypes, - emitPostResolutionEdges: attachSpringBeanCandidateMetadata, + emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => { + attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); + attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx); + }, }; export { javaScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/java/spring-config-bindings.ts b/gitnexus/src/core/ingestion/languages/java/spring-config-bindings.ts new file mode 100644 index 000000000..59e98fdd0 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-config-bindings.ts @@ -0,0 +1,267 @@ +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 { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getJavaParser } from './query.js'; +import { getJavaSpringConfigConsumerFacts } from './capture-side-channel.js'; +import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; + +const VALUE_ANNOTATION = 'org.springframework.beans.factory.annotation.Value'; +const CONFIGURATION_PROPERTIES_ANNOTATION = + 'org.springframework.boot.context.properties.ConfigurationProperties'; + +interface JavaAnnotation { + readonly name: string; + readonly node: SyntaxNode; +} + +interface JavaImports { + readonly exact: ReadonlySet; + readonly wildcard: ReadonlySet; + readonly localTypes: ReadonlySet; +} + +export interface JavaSpringConfigConsumerFact { + readonly consumer: SpringConfigConsumer; + readonly annotationName: string; + readonly classScopeId: ScopeId; +} + +function collectJavaImports(root: SyntaxNode): JavaImports { + const exact = new Set(); + const wildcard = new Set(); + const localTypes = new Set(); + + for (const node of root.descendantsOfType('import_declaration')) { + const imported = node.text + .replace(/^\s*import\s+(?:static\s+)?/, '') + .replace(/;\s*$/, '') + .trim(); + if (imported.endsWith('.*')) wildcard.add(imported.slice(0, -2)); + else exact.add(imported); + } + + for (const type of [ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', + 'annotation_type_declaration', + ]) { + for (const node of root.descendantsOfType(type)) { + const name = node.childForFieldName('name')?.text; + if (name) localTypes.add(name); + } + } + return { exact, wildcard, localTypes }; +} + +function annotationsOn(node: SyntaxNode): JavaAnnotation[] { + const modifiers = node.namedChildren.find((child) => child.type === 'modifiers'); + if (modifiers === undefined) return []; + const annotations: 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, node: child }); + } + return annotations; +} + +function resolvesToAnnotation( + rawName: string, + canonicalName: string, + imports: JavaImports, +): boolean { + if (rawName.includes('.')) return rawName === canonicalName; + if (imports.localTypes.has(rawName)) return false; + if (imports.exact.has(canonicalName)) return true; + const packageName = canonicalName.slice(0, canonicalName.lastIndexOf('.')); + return imports.wildcard.has(packageName); +} + +function decodeJavaStringLiteral(literal: string): string { + const delimiterLength = literal.startsWith('"""') && literal.endsWith('"""') ? 3 : 1; + return literal + .slice(delimiterLength, -delimiterLength) + .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 = { + b: '\b', + t: '\t', + n: '\n', + f: '\f', + r: '\r', + }; + return controls[escaped] ?? escaped; + }); +} + +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(annotation: SyntaxNode): string[] { + const keys = new Set(); + 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); + } + } + return [...keys]; +} + +/** Extract `prefix`/`value` (or the positional value) from the annotation. */ +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; +} + +function classScopeId(filePath: string, declaration: SyntaxNode): ScopeId { + return makeScopeId({ + filePath, + range: nodeToCapture('@scope.class', declaration).range, + kind: 'Class', + }); +} + +function enclosingClass(node: SyntaxNode): SyntaxNode | undefined { + let current = node.parent; + while (current !== null) { + if (current.type === 'class_declaration' || current.type === 'record_declaration') { + return current; + } + current = current.parent; + } + return undefined; +} + +/** Collect config facts from the Java parser's existing AST (no reparse). */ +export function captureJavaSpringConfigConsumerFacts( + root: SyntaxNode, + filePath: string, +): JavaSpringConfigConsumerFact[] { + const imports = collectJavaImports(root); + const facts: JavaSpringConfigConsumerFact[] = []; + + for (const field of root.descendantsOfType('field_declaration')) { + const annotations = annotationsOn(field).filter((annotation) => + resolvesToAnnotation(annotation.name, VALUE_ANNOTATION, imports), + ); + if (annotations.length === 0) continue; + const owner = enclosingClass(field); + if (owner === undefined) continue; + for (const declarator of field.namedChildren.filter( + (child) => child.type === 'variable_declarator', + )) { + const fieldName = declarator.childForFieldName('name')?.text; + if (!fieldName) continue; + for (const annotation of annotations) { + const keys = parseValuePlaceholderKeys(annotation.node); + if (keys.length > 0) { + facts.push({ + consumer: { kind: 'value', fieldName, line: field.startPosition.row + 1, keys }, + annotationName: annotation.name, + classScopeId: classScopeId(filePath, owner), + }); + } + } + } + } + + for (const type of ['class_declaration', 'record_declaration']) { + for (const declaration of root.descendantsOfType(type)) { + const className = declaration.childForFieldName('name')?.text; + if (!className) continue; + for (const annotation of annotationsOn(declaration)) { + if (!resolvesToAnnotation(annotation.name, CONFIGURATION_PROPERTIES_ANNOTATION, imports)) { + continue; + } + const prefix = parseConfigurationPropertiesPrefix(annotation.node); + if (prefix !== null) { + facts.push({ + consumer: { + kind: 'configuration-properties', + className, + line: declaration.startPosition.row + 1, + prefix, + }, + annotationName: annotation.name, + classScopeId: classScopeId(filePath, declaration), + }); + } + } + } + } + return facts; +} + +/** Parse Java consumers for focused unit tests; production reuses the worker AST. */ +export function extractJavaSpringConfigConsumers(source: string): SpringConfigConsumer[] { + const tree = parseSourceSafe(getJavaParser(), source); + return captureJavaSpringConfigConsumerFacts(tree.rootNode, '').map( + (fact) => fact.consumer, + ); +} + +/** Java ScopeResolver post-resolution hook for Spring configuration consumers. */ +export function attachJavaSpringConfigBindings( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + _nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, + _ctx: { readonly fileContents: ReadonlyMap }, +): 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 getJavaSpringConfigConsumerFacts(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, + isJavaPackageSiblingVisibilityIncomplete(parsed.filePath), + ); + if (resolved === expectedAnnotation) consumers.push(fact.consumer); + } + if (consumers.length > 0) batches.push({ filePath: parsed.filePath, consumers }); + } + bindSpringConfigConsumers(graph, batches); +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts index f4996a8ac..a3fb83aeb 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/index.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts @@ -20,6 +20,7 @@ export { scopeResolutionPhase, type ScopeResolutionOutput, } from '../scope-resolution/pipeline/phase.js'; +export { springConfigPhase, type SpringConfigOutput } from './spring-config.js'; export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js'; export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js'; export { callSummariesPhase, type CallSummariesOutput } from './call-summaries.js'; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/spring-config.ts b/gitnexus/src/core/ingestion/pipeline-phases/spring-config.ts new file mode 100644 index 000000000..90fa040c6 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/spring-config.ts @@ -0,0 +1,426 @@ +/** + * Phase: springConfig + * + * Adds key-only nodes for statically readable Spring + * `application*.properties` / `application*.yml` / `application*.yaml` files. + * Language-specific ScopeResolver hooks attach consumers later. Configuration + * values are deliberately never copied into the graph because they may contain + * credentials and key identity is sufficient for impact analysis. + * + * @deps structure + * @reads Spring application configuration files + * @writes Property nodes and DEFINES edges + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import type { EventType as YamlEventType, State as YamlState } from 'js-yaml'; +import { SPRING_CONFIG_DESCRIPTION } from '../frameworks/spring/config-bindings.js'; +import { generateId } from '../../../lib/utils.js'; +import type { PipelineContext, PipelinePhase, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +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; + readonly filePath: string; + readonly line: number; + readonly profile?: string; + readonly format: 'properties' | 'yaml'; +} + +interface SpringConfigFile { + readonly filePath: string; + readonly profile?: string; + readonly format: SpringConfigKey['format']; +} + +export interface SpringConfigOutput { + readonly configKeys: number; +} + +/** Match only Spring Boot's conventional application config file names. */ +export function classifySpringConfigFile(filePath: string): SpringConfigFile | null { + const base = path.posix.basename(filePath.replaceAll('\\', '/')); + const match = /^application(?:-([^.]+))?\.(properties|ya?ml)$/i.exec(base); + if (match === null) return null; + return { + filePath, + ...(match[1] ? { profile: match[1] } : {}), + format: match[2].toLowerCase() === 'properties' ? 'properties' : 'yaml', + }; +} + +function unescapePropertyKey(raw: string): string { + return raw + .replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)), + ) + .replace(/\\([:=#!\\ ])/g, '$1'); +} + +function logicalPropertiesLines(content: string): Array<{ text: string; line: number }> { + const physical = content.split(/\r?\n/); + const logical: Array<{ text: string; line: number }> = []; + let current = ''; + let startLine = 1; + + for (let index = 0; index < physical.length; index++) { + const line = physical[index]; + if (current.length === 0) startLine = index + 1; + current += current.length === 0 ? line : line.trimStart(); + + let trailingBackslashes = 0; + for (let cursor = current.length - 1; cursor >= 0 && current[cursor] === '\\'; cursor--) { + trailingBackslashes++; + } + if (trailingBackslashes % 2 === 1) { + current = current.slice(0, -1); + continue; + } + logical.push({ text: current, line: startLine }); + current = ''; + } + if (current.length > 0) logical.push({ text: current, line: startLine }); + return logical; +} + +/** Parse `.properties` keys without retaining their values. */ +export function parseSpringProperties( + content: string, + filePath: string, + profile?: string, +): SpringConfigKey[] { + const keys: SpringConfigKey[] = []; + const seen = new Set(); + + for (const logical of logicalPropertiesLines(content)) { + const trimmed = logical.text.trimStart(); + if (trimmed.length === 0 || trimmed.startsWith('#') || trimmed.startsWith('!')) continue; + + let separator = -1; + let escaped = false; + for (let index = 0; index < trimmed.length; index++) { + const char = trimmed[index]; + if (!escaped && (char === '=' || char === ':' || /\s/.test(char))) { + separator = index; + break; + } + escaped = !escaped && char === '\\'; + if (char !== '\\') escaped = false; + } + const rawKey = (separator === -1 ? trimmed : trimmed.slice(0, separator)).trim(); + const key = unescapePropertyKey(rawKey); + if (key.length === 0 || seen.has(key)) continue; + seen.add(key); + keys.push({ + key, + filePath, + line: logical.line, + ...(profile ? { profile } : {}), + format: 'properties', + }); + } + + return keys; +} + +interface YamlParseEvent { + readonly startLine: number; + kind: string | null; + result: unknown; + tag: string | null; + readonly children: YamlParseEvent[]; +} + +interface YamlMappingLocation { + readonly valueEvent: YamlParseEvent; + 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'; +} + +function resolveYamlAliasEvent( + event: YamlParseEvent | undefined, + objectEvents: WeakMap, +): YamlParseEvent | undefined { + if (event?.kind !== null || !isObjectValue(event.result)) return event; + return objectEvents.get(event.result) ?? event; +} + +function yamlMappingPairs(event: YamlParseEvent): Array<{ + key: string; + keyEvent: YamlParseEvent; + valueEvent: YamlParseEvent; +}> { + const pairs: Array<{ key: string; keyEvent: YamlParseEvent; valueEvent: YamlParseEvent }> = []; + for (let index = 0; index + 1 < event.children.length; index += 2) { + const keyEvent = event.children[index]; + const valueEvent = event.children[index + 1]; + if (keyEvent.kind !== 'scalar') continue; + pairs.push({ key: String(keyEvent.result), keyEvent, valueEvent }); + } + return pairs; +} + +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, + traversal, + visited, + depth + 1, + ); + if (found !== undefined) return found; + } + return undefined; + } + if (resolved.kind !== 'mapping') return undefined; + + const pairs = yamlMappingPairs(resolved); + const direct = pairs.find((pair) => pair.key === key); + if (direct !== undefined) { + return { valueEvent: direct.valueEvent, line: direct.keyEvent.startLine }; + } + for (const merge of pairs.filter((pair) => pair.key === '<<')) { + const found = findYamlMappingLocation( + merge.valueEvent, + key, + objectEvents, + traversal, + visited, + depth + 1, + ); + if (found !== undefined) return found; + } + return undefined; +} + +function flattenYamlValue( + value: unknown, + event: YamlParseEvent | undefined, + prefix: string, + out: Map, + objectEvents: WeakMap, + traversal: YamlTraversalState, + sourceLine = event?.startLine ?? 1, + depth = 0, +): void { + consumeYamlTraversalBudget(traversal, depth); + const resolvedEvent = resolveYamlAliasEvent(event, objectEvents); + 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; + } + 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); + } +} + +/** Parse and flatten YAML leaves without retaining their values. */ +export function parseSpringYaml( + content: string, + filePath: string, + profile?: string, +): SpringConfigKey[] { + const flattened = new Map(); + const eventStack: YamlParseEvent[] = []; + 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, + json: true, + listener: (eventType: YamlEventType, state: YamlState) => { + if (eventType === 'open') { + eventStack.push({ + startLine: state.line + 1, + kind: null, + result: undefined, + tag: null, + children: [], + }); + return; + } + + const event = eventStack.pop(); + if (event === undefined) return; + event.kind = state.kind ?? null; + event.result = state.result; + event.tag = (state as YamlState & { tag?: string | null }).tag ?? null; + if (isObjectValue(event.result) && event.kind !== null) { + objectEvents.set(event.result, event); + } + const parent = eventStack[eventStack.length - 1]; + if (parent === undefined) documentEvents.push(event); + else parent.children.push(event); + }, + }); + + documents.forEach((document, index) => + flattenYamlValue(document, documentEvents[index], '', flattened, objectEvents, traversal), + ); + return [...flattened.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, line]) => ({ + key, + filePath, + line, + ...(profile ? { profile } : {}), + format: 'yaml' as const, + })); +} + +function configKeyNodeId(entry: SpringConfigKey): string { + return generateId('Property', `spring-config:${entry.filePath}:${entry.key}`); +} + +async function readConfigKeys( + repoPath: string, + scannedFiles: StructureOutput['scannedFiles'], +): Promise { + const keys: SpringConfigKey[] = []; + for (const scanned of scannedFiles) { + const classified = classifySpringConfigFile(scanned.path); + if (classified === null || scanned.size > MAX_CONFIG_FILE_BYTES) continue; + try { + const content = await fs.readFile(path.join(repoPath, scanned.path), 'utf8'); + keys.push( + ...(classified.format === 'properties' + ? parseSpringProperties(content, classified.filePath, classified.profile) + : parseSpringYaml(content, classified.filePath, classified.profile)), + ); + } catch { + // Malformed configuration is not a reason to fail the entire code index. + // Fail closed: no keys and therefore no misleading bindings for this file. + } + } + return keys; +} + +export const springConfigPhase: PipelinePhase = { + name: 'springConfig', + deps: ['structure'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { scannedFiles } = getPhaseOutput(deps, 'structure'); + const configKeys = await readConfigKeys(ctx.repoPath, scannedFiles); + for (const entry of configKeys) { + const nodeId = configKeyNodeId(entry); + ctx.graph.addNode({ + id: nodeId, + label: 'Property', + properties: { + name: entry.key, + filePath: entry.filePath, + startLine: entry.line, + endLine: entry.line, + description: entry.profile + ? `${SPRING_CONFIG_DESCRIPTION} (profile: ${entry.profile})` + : SPRING_CONFIG_DESCRIPTION, + }, + }); + const fileId = generateId('File', entry.filePath); + if (ctx.graph.getNode(fileId) !== undefined) { + ctx.graph.addRelationship({ + id: generateId('DEFINES', `${fileId}->${nodeId}`), + sourceId: fileId, + targetId: nodeId, + type: 'DEFINES', + confidence: 1, + reason: 'spring-config:key', + }); + } + } + + return { configKeys: configKeys.length }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 58ec391c0..ebf290112 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -31,6 +31,7 @@ import { ormPhase, crossFilePhase, scopeResolutionPhase, + springConfigPhase, pruneLocalSymbolsPhase, taintSummariesPhase, callSummariesPhase, @@ -242,7 +243,7 @@ export interface PipelineOptions { * * Phase dependency graph: * - * scan → structure → [markdown, cobol] → parse → [routes, tools, orm] + * scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm] * → crossFile → scopeResolution → pruneLocalSymbols * → mro → di → communities → processes * @@ -261,6 +262,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { new PhaseRegistry() .register(scanPhase) .register(structurePhase) + .register(springConfigPhase) .register(markdownPhase) .register(cobolPhase) .register(parsePhase) diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 22aed098d..c8943d719 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -402,6 +402,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Static\` TO Community, FROM \`Variable\` TO Community, FROM \`Property\` TO Community, + FROM \`Property\` TO \`Property\`, FROM \`Record\` TO Method, FROM \`Record\` TO \`Constructor\`, FROM \`Record\` TO \`Property\`, diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 721d0ce17..9d5e7a811 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -125,6 +125,7 @@ import { EMBEDDING_TABLE_NAME } from './lbug/schema.js'; import { STALE_HASH_SENTINEL } from './lbug/schema.js'; import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.js'; import { SPRING_BEAN_INVENTORY_FEATURE } from './ingestion/frameworks/spring/analysis-features.js'; +import { SPRING_CONFIG_BINDINGS_FEATURE } from './ingestion/languages/java/analysis-features.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, findAnalysisFeatureMismatches, @@ -139,6 +140,7 @@ import { const ANALYSIS_FEATURES = [ CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, ] as const; interface PersistedFrameworkAnnotationRow { 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 new file mode 100644 index 000000000..0d9803d06 --- /dev/null +++ b/gitnexus/test/fixtures/spring-config-app/src/main/java/com/example/ConfigConsumers.java @@ -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 int timeout; + + @Value("${payment.missing}") + private String missing; +} + +@ConfigurationProperties(prefix = "service") +class ServiceProperties { + private String endpoint; + 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-dev.yml b/gitnexus/test/fixtures/spring-config-app/src/main/resources/application-dev.yml new file mode 100644 index 000000000..28cf5ab6a --- /dev/null +++ b/gitnexus/test/fixtures/spring-config-app/src/main/resources/application-dev.yml @@ -0,0 +1,6 @@ +defaults: &defaults + retry: + max-attempts: 3 +service: + <<: *defaults + endpoint: https://service.example.test 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 new file mode 100644 index 000000000..fff1b7e68 --- /dev/null +++ b/gitnexus/test/fixtures/spring-config-app/src/main/resources/application.properties @@ -0,0 +1,2 @@ +payment.timeout=30 +service.endpoint=https://base.example.test diff --git a/gitnexus/test/fixtures/spring-config-shadow-app/src/main/java/com/example/Shadowed.java b/gitnexus/test/fixtures/spring-config-shadow-app/src/main/java/com/example/Shadowed.java new file mode 100644 index 000000000..833184fc8 --- /dev/null +++ b/gitnexus/test/fixtures/spring-config-shadow-app/src/main/java/com/example/Shadowed.java @@ -0,0 +1,8 @@ +package com.example; + +import org.springframework.beans.factory.annotation.*; + +class Shadowed { + @Value("${fake.key}") + private String fake; +} diff --git a/gitnexus/test/fixtures/spring-config-shadow-app/src/main/java/com/example/Value.java b/gitnexus/test/fixtures/spring-config-shadow-app/src/main/java/com/example/Value.java new file mode 100644 index 000000000..85156a4cf --- /dev/null +++ b/gitnexus/test/fixtures/spring-config-shadow-app/src/main/java/com/example/Value.java @@ -0,0 +1,5 @@ +package com.example; + +public @interface Value { + String value(); +} diff --git a/gitnexus/test/fixtures/spring-config-shadow-app/src/main/resources/application.properties b/gitnexus/test/fixtures/spring-config-shadow-app/src/main/resources/application.properties new file mode 100644 index 000000000..4fded8755 --- /dev/null +++ b/gitnexus/test/fixtures/spring-config-shadow-app/src/main/resources/application.properties @@ -0,0 +1 @@ +fake.key=must-not-bind diff --git a/gitnexus/test/integration/spring-config-mcp.test.ts b/gitnexus/test/integration/spring-config-mcp.test.ts new file mode 100644 index 000000000..570c15b6b --- /dev/null +++ b/gitnexus/test/integration/spring-config-mcp.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', () => ({ + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), +})); + +const CONSUMER_ID = 'Property:src/Config.java:DirectValues.timeout'; +const CONFIG_ID = 'Property:spring-config:application.properties:payment.timeout'; +const SEED = [ + `CREATE (p:\`Property\` {id:'${CONSUMER_ID}', name:'timeout', filePath:'src/Config.java', startLine:4, endLine:4, content:'', description:'', declaredType:'int'})`, + `CREATE (p:\`Property\` {id:'${CONFIG_ID}', name:'payment.timeout', filePath:'application.properties', startLine:1, endLine:1, content:'', description:'Spring configuration property', declaredType:''})`, + `MATCH (consumer:\`Property\` {id:'${CONSUMER_ID}'}), (config:\`Property\` {id:'${CONFIG_ID}'}) CREATE (consumer)-[:CodeRelation {type:'USES', confidence:1.0, reason:'spring-config:@Value payment.timeout'}]->(config)`, +]; + +withTestLbugDB( + 'spring-config-mcp', + (handle) => { + let backend: LocalBackend; + + beforeAll(() => { + backend = (handle as typeof handle & { _backend: LocalBackend })._backend; + }); + + describe('Spring configuration context and impact visibility', () => { + it('shows configuration dependencies in context without special query flags', async () => { + const context = await backend.callTool('context', { uid: CONSUMER_ID }); + expect(context.outgoing.uses).toEqual([ + expect.objectContaining({ + uid: CONFIG_ID, + name: 'payment.timeout', + filePath: 'application.properties', + }), + ]); + }); + + it('shows consumers in upstream impact from a configuration key', async () => { + const impact = await backend.callTool('impact', { + target_uid: CONFIG_ID, + target: 'payment.timeout', + direction: 'upstream', + }); + expect(impact.risk).not.toBe('UNKNOWN'); + expect(impact.byDepth[1]).toEqual([ + expect.objectContaining({ + id: CONSUMER_ID, + name: 'timeout', + relationType: 'USES', + }), + ]); + }); + }); + }, + { + seed: SEED, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 2, nodes: 2, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as typeof handle & { _backend?: LocalBackend })._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/spring-config-pipeline.test.ts b/gitnexus/test/integration/spring-config-pipeline.test.ts new file mode 100644 index 000000000..002014f28 --- /dev/null +++ b/gitnexus/test/integration/spring-config-pipeline.test.ts @@ -0,0 +1,156 @@ +import path from 'node:path'; +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'); + +describe('Spring configuration binding pipeline', () => { + let result: PipelineResult; + let nodes: GraphNode[]; + let uses: GraphRelationship[]; + + beforeAll(async () => { + result = await runPipelineFromRepo(FIXTURE, () => {}, { skipGraphPhases: true }); + nodes = [...result.graph.iterNodes()]; + uses = [...result.graph.iterRelationshipsByType('USES')].filter((edge) => + edge.reason.startsWith('spring-config:'), + ); + }, 60_000); + + const nodeNamed = (name: string, fileSuffix?: string): GraphNode | undefined => + nodes.find( + (node) => + node.properties.name === name && + (fileSuffix === undefined || String(node.properties.filePath).endsWith(fileSuffix)), + ); + + const targetsFrom = (source: GraphNode): string[] => + uses + .filter((edge) => edge.sourceId === source.id) + .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', () => { + 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', + ); + expect( + nodeNamed('service.retry.max-attempts', 'application-dev.yml')?.properties.startLine, + ).toBe(3); + expect( + nodes.some((node) => JSON.stringify(node.properties).includes('service.example.test')), + ).toBe(false); + }); + + it('links Value fields to exact keys and leaves missing placeholders unresolved', () => { + const timeout = nodeNamed('timeout', 'ConfigConsumers.java'); + const missing = nodeNamed('missing', 'ConfigConsumers.java'); + expect(timeout).toBeDefined(); + expect(missing).toBeDefined(); + if (timeout === undefined || missing === undefined) throw new Error('fixture fields missing'); + expect(targetsFrom(timeout)).toEqual(['payment.timeout']); + expect(targetsFrom(missing)).toEqual([]); + expect(missing?.properties.description).toContain('Spring config unresolved: payment.missing'); + }); + + it('links ConfigurationProperties classes and relaxed field names to their prefix', () => { + const owner = nodeNamed('ServiceProperties', 'ConfigConsumers.java'); + const endpoint = nodeNamed('endpoint', 'ConfigConsumers.java'); + const retry = nodeNamed('retry', 'ConfigConsumers.java'); + expect(owner).toBeDefined(); + if (owner === undefined || endpoint === undefined || retry === undefined) { + throw new Error('fixture ConfigurationProperties symbols missing'); + } + 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', () => { + it('fails closed when a same-package annotation shadows a Spring wildcard import', async () => { + const result = await runPipelineFromRepo(SHADOW_FIXTURE, () => {}, { + skipGraphPhases: true, + }); + const fake = [...result.graph.iterNodes()].find( + (node) => + node.properties.name === 'fake' && + String(node.properties.filePath).endsWith('Shadowed.java'), + ); + expect(fake).toBeDefined(); + if (fake === undefined) throw new Error('shadow fixture field missing'); + expect( + [...result.graph.iterRelationshipsByType('USES')].filter( + (edge) => edge.sourceId === fake.id && edge.reason.startsWith('spring-config:'), + ), + ).toEqual([]); + 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 6965c99d5..146965798 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -6,8 +6,13 @@ import { type AnalysisFeatureDescriptor, } 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 FEATURES = [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, SPRING_BEAN_INVENTORY_FEATURE] as const; +const FEATURES = [ + CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, + SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, +] as const; describe('analysis feature versions', () => { it('separates the global Class schema capability from JVM-only Bean evidence', () => { @@ -17,11 +22,21 @@ describe('analysis feature versions', () => { expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({ 'graph.class-framework-annotations': 1, 'spring.bean-inventory': 1, + 'spring.config-bindings': 1, }); expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({ '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/ingestion/pipeline-phase-registry.test.ts b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts index 51efd6d1f..7aeb8b431 100644 --- a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts +++ b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts @@ -66,6 +66,7 @@ describe('PhaseRegistry', () => { const FULL_ORDER = [ 'scan', 'structure', + 'springConfig', 'markdown', 'cobol', 'parse', diff --git a/gitnexus/test/unit/spring-config-bindings.test.ts b/gitnexus/test/unit/spring-config-bindings.test.ts new file mode 100644 index 000000000..a935039fc --- /dev/null +++ b/gitnexus/test/unit/spring-config-bindings.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { bindSpringConfigConsumers } from '../../src/core/ingestion/frameworks/spring/config-bindings.js'; +import { + classifySpringConfigFile, + parseSpringProperties, + parseSpringYaml, +} from '../../src/core/ingestion/pipeline-phases/spring-config.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', () => { + const base = classifySpringConfigFile('src/main/resources/application.properties'); + expect(base).toMatchObject({ format: 'properties' }); + expect(base).not.toHaveProperty('profile'); + expect(classifySpringConfigFile('src/main/resources/application-local.yml')).toMatchObject({ + format: 'yaml', + profile: 'local', + }); + expect(classifySpringConfigFile('src/main/resources/bootstrap.yml')).toBeNull(); + }); + + it('extracts properties keys, continuations, and escaped separators without values', () => { + const keys = parseSpringProperties( + '# comment\nserver.port=8080\nservice\\:name: demo\nlong.\\\n key = secret\n', + 'application.properties', + ); + expect(keys.map((entry) => [entry.key, entry.line])).toEqual([ + ['server.port', 2], + ['service:name', 3], + ['long.key', 4], + ]); + expect(JSON.stringify(keys)).not.toContain('8080'); + expect(JSON.stringify(keys)).not.toContain('secret'); + }); + + it('flattens YAML maps and arrays while retaining profile identity', () => { + const keys = parseSpringYaml( + 'service:\n endpoint: https://example.test\n retries:\n - delay: 10\n', + 'application-dev.yml', + 'dev', + ); + expect(keys).toEqual([ + expect.objectContaining({ + key: 'service.endpoint', + line: 2, + profile: 'dev', + format: 'yaml', + }), + expect.objectContaining({ + key: 'service.retries[0].delay', + line: 4, + profile: 'dev', + format: 'yaml', + }), + ]); + expect(JSON.stringify(keys)).not.toContain('example.test'); + }); + + it('expands YAML merge keys and retains the declaration line for merged values', () => { + const keys = parseSpringYaml( + [ + 'defaults: &defaults', + ' endpoint: https://base.example.test', + ' timeout: 30', + 'service:', + ' <<: *defaults', + ' endpoint: https://override.example.test', + ].join('\n'), + 'application.yml', + ); + + expect(keys).toEqual([ + expect.objectContaining({ key: 'defaults.endpoint', line: 2 }), + expect.objectContaining({ key: 'defaults.timeout', line: 3 }), + expect.objectContaining({ key: 'service.endpoint', line: 6 }), + expect.objectContaining({ key: 'service.timeout', line: 3 }), + ]); + expect(keys.some((entry) => entry.key.includes('<<'))).toBe(false); + }); + + it('terminates cyclic YAML aliases and bounds deeply nested expansion', () => { + expect( + 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', + ); + }); +}); + +describe('Java Spring configuration consumers', () => { + it('resolves official imports and ignores shadowed annotation names', () => { + const consumers = extractJavaSpringConfigConsumers(` + import org.springframework.beans.factory.annotation.Value; + import org.springframework.boot.context.properties.ConfigurationProperties; + + @ConfigurationProperties(prefix = "service") + class ServiceProperties { + @Value("\${service.timeout:30}") private int timeout; + } + `); + expect(consumers).toEqual([ + expect.objectContaining({ kind: 'value', fieldName: 'timeout', keys: ['service.timeout'] }), + expect.objectContaining({ + kind: 'configuration-properties', + className: 'ServiceProperties', + prefix: 'service', + }), + ]); + + expect( + extractJavaSpringConfigConsumers(` + @interface Value { String value(); } + class Local { @Value("\${fake.key}") String field; } + `), + ).toEqual([]); + }); + + it('supports wildcard/FQN annotations and every declarator in a field', () => { + const consumers = extractJavaSpringConfigConsumers(` + import org.springframework.beans.factory.annotation.*; + + class DirectValues { + @Value("\${shared.key}") String first, second; + } + + @org.springframework.boot.context.properties.ConfigurationProperties("service") + record ServiceProperties(String endpoint) {} + `); + + expect(consumers).toEqual([ + expect.objectContaining({ kind: 'value', fieldName: 'first', keys: ['shared.key'] }), + expect.objectContaining({ kind: 'value', fieldName: 'second', keys: ['shared.key'] }), + expect.objectContaining({ + kind: 'configuration-properties', + className: 'ServiceProperties', + prefix: 'service', + }), + ]); + }); + + 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', () => { + it('indexes the graph once for all consumer files and skips empty work', () => { + const graph = createKnowledgeGraph(); + const iterNodes = vi.spyOn(graph, 'iterNodes'); + + bindSpringConfigConsumers(graph, []); + expect(iterNodes).not.toHaveBeenCalled(); + + bindSpringConfigConsumers(graph, [ + { + filePath: 'First.java', + consumers: [{ kind: 'value', fieldName: 'first', line: 1, keys: ['first.key'] }], + }, + { + filePath: 'Second.java', + consumers: [{ kind: 'value', fieldName: 'second', line: 1, keys: ['second.key'] }], + }, + ]); + expect(iterNodes).toHaveBeenCalledTimes(1); + }); +});