mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-13 08:51:28 +00:00
Merge branch 'main' into dependabot/npm_and_yarn/gitnexus/ladybugdb/core-0.18.2
This commit is contained in:
commit
70164df43e
24 changed files with 1511 additions and 34 deletions
|
|
@ -59,6 +59,7 @@ export interface SpringBeanCandidateAdapter {
|
|||
}
|
||||
|
||||
type OwnedTypeNamesByOwner = ReadonlyMap<string, ReadonlySet<string>>;
|
||||
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);
|
||||
|
|
|
|||
166
gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts
Normal file
166
gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts
Normal file
|
|
@ -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<string, GraphNode[]>();
|
||||
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<string, GraphNode[]>();
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
};
|
||||
|
|
@ -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<string, readonly JavaSpringConfigConsumerFact[]>();
|
||||
|
||||
/** 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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
readonly wildcard: ReadonlySet<string>;
|
||||
readonly localTypes: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface JavaSpringConfigConsumerFact {
|
||||
readonly consumer: SpringConfigConsumer;
|
||||
readonly annotationName: string;
|
||||
readonly classScopeId: ScopeId;
|
||||
}
|
||||
|
||||
function collectJavaImports(root: SyntaxNode): JavaImports {
|
||||
const exact = new Set<string>();
|
||||
const wildcard = new Set<string>();
|
||||
const localTypes = new Set<string>();
|
||||
|
||||
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<string, string> = {
|
||||
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<string>();
|
||||
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, '<memory>').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<string, string> },
|
||||
): 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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
426
gitnexus/src/core/ingestion/pipeline-phases/spring-config.ts
Normal file
426
gitnexus/src/core/ingestion/pipeline-phases/spring-config.ts
Normal file
|
|
@ -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<string>();
|
||||
|
||||
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<object>;
|
||||
}
|
||||
|
||||
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<object, YamlParseEvent>,
|
||||
): 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<object, YamlParseEvent>,
|
||||
traversal: YamlTraversalState,
|
||||
visited = new Set<YamlParseEvent>(),
|
||||
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<string, number>,
|
||||
objectEvents: WeakMap<object, YamlParseEvent>,
|
||||
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<string, unknown>);
|
||||
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<string, number>();
|
||||
const eventStack: YamlParseEvent[] = [];
|
||||
const documentEvents: YamlParseEvent[] = [];
|
||||
const objectEvents = new WeakMap<object, YamlParseEvent>();
|
||||
const documents: unknown[] = [];
|
||||
const traversal: YamlTraversalState = {
|
||||
remainingNodes: MAX_YAML_TRAVERSAL_NODES,
|
||||
activeObjects: new Set<object>(),
|
||||
};
|
||||
|
||||
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<SpringConfigKey[]> {
|
||||
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<SpringConfigOutput> = {
|
||||
name: 'springConfig',
|
||||
deps: ['structure'],
|
||||
|
||||
async execute(
|
||||
ctx: PipelineContext,
|
||||
deps: ReadonlyMap<string, PhaseResult<unknown>>,
|
||||
): Promise<SpringConfigOutput> {
|
||||
const { scannedFiles } = getPhaseOutput<StructureOutput>(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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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<PipelineOptions>()
|
||||
.register(scanPhase)
|
||||
.register(structurePhase)
|
||||
.register(springConfigPhase)
|
||||
.register(markdownPhase)
|
||||
.register(cobolPhase)
|
||||
.register(parsePhase)
|
||||
|
|
|
|||
|
|
@ -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\`,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
25
gitnexus/test/fixtures/spring-config-app/src/main/java/com/example/ConfigConsumers.java
vendored
Normal file
25
gitnexus/test/fixtures/spring-config-app/src/main/java/com/example/ConfigConsumers.java
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package com.example;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
class DirectValues {
|
||||
@Value("${payment.timeout:30}")
|
||||
private 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 {}
|
||||
6
gitnexus/test/fixtures/spring-config-app/src/main/resources/application-dev.yml
vendored
Normal file
6
gitnexus/test/fixtures/spring-config-app/src/main/resources/application-dev.yml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
defaults: &defaults
|
||||
retry:
|
||||
max-attempts: 3
|
||||
service:
|
||||
<<: *defaults
|
||||
endpoint: https://service.example.test
|
||||
2
gitnexus/test/fixtures/spring-config-app/src/main/resources/application.properties
vendored
Normal file
2
gitnexus/test/fixtures/spring-config-app/src/main/resources/application.properties
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
payment.timeout=30
|
||||
service.endpoint=https://base.example.test
|
||||
8
gitnexus/test/fixtures/spring-config-shadow-app/src/main/java/com/example/Shadowed.java
vendored
Normal file
8
gitnexus/test/fixtures/spring-config-shadow-app/src/main/java/com/example/Shadowed.java
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package com.example;
|
||||
|
||||
import org.springframework.beans.factory.annotation.*;
|
||||
|
||||
class Shadowed {
|
||||
@Value("${fake.key}")
|
||||
private String fake;
|
||||
}
|
||||
5
gitnexus/test/fixtures/spring-config-shadow-app/src/main/java/com/example/Value.java
vendored
Normal file
5
gitnexus/test/fixtures/spring-config-shadow-app/src/main/java/com/example/Value.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package com.example;
|
||||
|
||||
public @interface Value {
|
||||
String value();
|
||||
}
|
||||
1
gitnexus/test/fixtures/spring-config-shadow-app/src/main/resources/application.properties
vendored
Normal file
1
gitnexus/test/fixtures/spring-config-shadow-app/src/main/resources/application.properties
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
fake.key=must-not-bind
|
||||
77
gitnexus/test/integration/spring-config-mcp.test.ts
Normal file
77
gitnexus/test/integration/spring-config-mcp.test.ts
Normal file
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
);
|
||||
156
gitnexus/test/integration/spring-config-pipeline.test.ts
Normal file
156
gitnexus/test/integration/spring-config-pipeline.test.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const { lbugPath } = getStoragePaths(repoPath);
|
||||
|
|
@ -120,6 +131,21 @@ async function readWildcardServiceAnnotations(repoPath: string): Promise<string[
|
|||
}
|
||||
}
|
||||
|
||||
async function readSpringConfigPropertyNames(repoPath: string): Promise<string[]> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ describe('PhaseRegistry', () => {
|
|||
const FULL_ORDER = [
|
||||
'scan',
|
||||
'structure',
|
||||
'springConfig',
|
||||
'markdown',
|
||||
'cobol',
|
||||
'parse',
|
||||
|
|
|
|||
193
gitnexus/test/unit/spring-config-bindings.test.ts
Normal file
193
gitnexus/test/unit/spring-config-bindings.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue