fix(spring): harden configuration bindings
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import type { AnalysisFeatureDescriptor } from '../../../analysis-features.js';
|
||||
|
||||
function isSpringApplicationConfig(filePath: string): boolean {
|
||||
const base = filePath.replaceAll('\\', '/').split('/').pop() ?? '';
|
||||
return /^application(?:-[^.]+)?\.(?:properties|ya?ml)$/i.test(base);
|
||||
}
|
||||
|
||||
/** Durable completeness contract for Java Spring configuration bindings. */
|
||||
export const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor = {
|
||||
id: 'spring.config-bindings',
|
||||
version: 1,
|
||||
// Annotation presence is content-derived, so the conservative upgrade gate
|
||||
// is every Java repository. This guarantees the first analyzer version that
|
||||
// ships bindings performs a full rebuild even when config files are absent
|
||||
// (missing @Value placeholders still need their unresolved marker).
|
||||
appliesTo: (filePaths) => filePaths.some((filePath) => filePath.toLowerCase().endsWith('.java')),
|
||||
// Java sources need consumer extraction even without config files (missing
|
||||
// placeholders still get unresolved markers). Config-only repositories also
|
||||
// need a one-time rebuild to backfill language-agnostic Property nodes.
|
||||
appliesTo: (filePaths) =>
|
||||
filePaths.some(
|
||||
(filePath) => filePath.toLowerCase().endsWith('.java') || isSpringApplicationConfig(filePath),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ const CONFIGURATION_PROPERTIES_ANNOTATION =
|
||||
|
||||
interface JavaAnnotation {
|
||||
readonly name: string;
|
||||
readonly text: string;
|
||||
readonly node: SyntaxNode;
|
||||
}
|
||||
|
||||
interface JavaImports {
|
||||
@@ -70,7 +70,7 @@ function annotationsOn(node: SyntaxNode): JavaAnnotation[] {
|
||||
for (const child of modifiers.namedChildren) {
|
||||
if (child.type !== 'annotation' && child.type !== 'marker_annotation') continue;
|
||||
const name = child.childForFieldName('name')?.text ?? child.firstNamedChild?.text;
|
||||
if (name) annotations.push({ name, text: child.text });
|
||||
if (name) annotations.push({ name, node: child });
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
@@ -88,8 +88,9 @@ function resolvesToAnnotation(
|
||||
}
|
||||
|
||||
function decodeJavaStringLiteral(literal: string): string {
|
||||
const delimiterLength = literal.startsWith('"""') && literal.endsWith('"""') ? 3 : 1;
|
||||
return literal
|
||||
.slice(1, -1)
|
||||
.slice(delimiterLength, -delimiterLength)
|
||||
.replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
)
|
||||
@@ -105,14 +106,16 @@ function decodeJavaStringLiteral(literal: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function javaStringLiterals(text: string): string[] {
|
||||
return [...text.matchAll(/"(?:\\.|[^"\\])*"/g)].map((match) => decodeJavaStringLiteral(match[0]));
|
||||
function javaStringLiterals(annotation: SyntaxNode): string[] {
|
||||
return annotation
|
||||
.descendantsOfType('string_literal')
|
||||
.map((literal) => decodeJavaStringLiteral(literal.text));
|
||||
}
|
||||
|
||||
/** Extract statically readable Spring placeholder keys from a Java annotation. */
|
||||
export function parseValuePlaceholderKeys(annotationText: string): string[] {
|
||||
export function parseValuePlaceholderKeys(annotation: SyntaxNode): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const literal of javaStringLiterals(annotationText)) {
|
||||
for (const literal of javaStringLiterals(annotation)) {
|
||||
for (const match of literal.matchAll(/\$\{([^{}]+)\}/g)) {
|
||||
const key = match[1].split(':', 1)[0].trim();
|
||||
if (/^[A-Za-z0-9_.-]+$/.test(key)) keys.add(key);
|
||||
@@ -122,11 +125,22 @@ export function parseValuePlaceholderKeys(annotationText: string): string[] {
|
||||
}
|
||||
|
||||
/** Extract `prefix`/`value` (or the positional value) from the annotation. */
|
||||
export function parseConfigurationPropertiesPrefix(annotationText: string): string | null {
|
||||
const named = /\b(?:prefix|value)\s*=\s*("(?:\\.|[^"\\])*")/.exec(annotationText);
|
||||
const literal = named?.[1] ?? annotationText.match(/"(?:\\.|[^"\\])*"/)?.[0];
|
||||
if (literal === undefined) return null;
|
||||
const prefix = decodeJavaStringLiteral(literal)
|
||||
export function parseConfigurationPropertiesPrefix(annotation: SyntaxNode): string | null {
|
||||
const named = annotation.descendantsOfType('element_value_pair').find((pair) => {
|
||||
const key = pair.childForFieldName('key')?.text;
|
||||
return key === 'prefix' || key === 'value';
|
||||
});
|
||||
const namedValue = named?.childForFieldName('value');
|
||||
const argumentsNode = annotation.childForFieldName('arguments');
|
||||
const literalNode =
|
||||
(namedValue?.type === 'string_literal'
|
||||
? namedValue
|
||||
: namedValue?.descendantsOfType('string_literal')[0]) ??
|
||||
(named === undefined
|
||||
? argumentsNode?.namedChildren.find((child) => child.type === 'string_literal')
|
||||
: undefined);
|
||||
if (literalNode === undefined) return null;
|
||||
const prefix = decodeJavaStringLiteral(literalNode.text)
|
||||
.trim()
|
||||
.replace(/^\.+|\.+$/g, '');
|
||||
return /^[A-Za-z0-9_.-]+$/.test(prefix) ? prefix : null;
|
||||
@@ -172,7 +186,7 @@ export function captureJavaSpringConfigConsumerFacts(
|
||||
const fieldName = declarator.childForFieldName('name')?.text;
|
||||
if (!fieldName) continue;
|
||||
for (const annotation of annotations) {
|
||||
const keys = parseValuePlaceholderKeys(annotation.text);
|
||||
const keys = parseValuePlaceholderKeys(annotation.node);
|
||||
if (keys.length > 0) {
|
||||
facts.push({
|
||||
consumer: { kind: 'value', fieldName, line: field.startPosition.row + 1, keys },
|
||||
@@ -192,7 +206,7 @@ export function captureJavaSpringConfigConsumerFacts(
|
||||
if (!resolvesToAnnotation(annotation.name, CONFIGURATION_PROPERTIES_ANNOTATION, imports)) {
|
||||
continue;
|
||||
}
|
||||
const prefix = parseConfigurationPropertiesPrefix(annotation.text);
|
||||
const prefix = parseConfigurationPropertiesPrefix(annotation.node);
|
||||
if (prefix !== null) {
|
||||
facts.push({
|
||||
consumer: {
|
||||
|
||||
@@ -25,6 +25,8 @@ import type { StructureOutput } from './structure.js';
|
||||
const require = createRequire(import.meta.url);
|
||||
const yaml = require('js-yaml') as typeof import('js-yaml');
|
||||
const MAX_CONFIG_FILE_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_YAML_TRAVERSAL_DEPTH = 128;
|
||||
const MAX_YAML_TRAVERSAL_NODES = 100_000;
|
||||
|
||||
export interface SpringConfigKey {
|
||||
readonly key: string;
|
||||
@@ -143,6 +145,21 @@ interface YamlMappingLocation {
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
interface YamlTraversalState {
|
||||
remainingNodes: number;
|
||||
readonly activeObjects: Set<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';
|
||||
}
|
||||
@@ -174,15 +191,25 @@ 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, visited);
|
||||
const found = findYamlMappingLocation(
|
||||
child,
|
||||
key,
|
||||
objectEvents,
|
||||
traversal,
|
||||
visited,
|
||||
depth + 1,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
return undefined;
|
||||
@@ -195,7 +222,14 @@ function findYamlMappingLocation(
|
||||
return { valueEvent: direct.valueEvent, line: direct.keyEvent.startLine };
|
||||
}
|
||||
for (const merge of pairs.filter((pair) => pair.key === '<<')) {
|
||||
const found = findYamlMappingLocation(merge.valueEvent, key, objectEvents, visited);
|
||||
const found = findYamlMappingLocation(
|
||||
merge.valueEvent,
|
||||
key,
|
||||
objectEvents,
|
||||
traversal,
|
||||
visited,
|
||||
depth + 1,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
return undefined;
|
||||
@@ -207,45 +241,60 @@ function flattenYamlValue(
|
||||
prefix: string,
|
||||
out: Map<string, number>,
|
||||
objectEvents: WeakMap<object, YamlParseEvent>,
|
||||
traversal: YamlTraversalState,
|
||||
sourceLine = event?.startLine ?? 1,
|
||||
depth = 0,
|
||||
): void {
|
||||
consumeYamlTraversalBudget(traversal, depth);
|
||||
const resolvedEvent = resolveYamlAliasEvent(event, objectEvents);
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0 && prefix.length > 0 && !out.has(prefix)) out.set(prefix, sourceLine);
|
||||
value.forEach((item, index) =>
|
||||
flattenYamlValue(
|
||||
item,
|
||||
resolvedEvent?.children[index],
|
||||
`${prefix}[${index}]`,
|
||||
out,
|
||||
objectEvents,
|
||||
sourceLine,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
(resolvedEvent?.kind === 'mapping' || resolvedEvent === undefined)
|
||||
) {
|
||||
const entries = Object.entries(value as Record<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);
|
||||
flattenYamlValue(
|
||||
nested,
|
||||
location?.valueEvent,
|
||||
next,
|
||||
out,
|
||||
objectEvents,
|
||||
location?.line ?? sourceLine,
|
||||
const trackedObject = isObjectValue(value) ? value : undefined;
|
||||
if (trackedObject !== undefined && traversal.activeObjects.has(trackedObject)) return;
|
||||
if (trackedObject !== undefined) traversal.activeObjects.add(trackedObject);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0 && prefix.length > 0 && !out.has(prefix)) out.set(prefix, sourceLine);
|
||||
value.forEach((item, index) =>
|
||||
flattenYamlValue(
|
||||
item,
|
||||
resolvedEvent?.children[index],
|
||||
`${prefix}[${index}]`,
|
||||
out,
|
||||
objectEvents,
|
||||
traversal,
|
||||
sourceLine,
|
||||
depth + 1,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
(resolvedEvent?.kind === 'mapping' || resolvedEvent === undefined)
|
||||
) {
|
||||
const entries = Object.entries(value as Record<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);
|
||||
}
|
||||
if (prefix.length > 0 && !out.has(prefix)) out.set(prefix, sourceLine);
|
||||
}
|
||||
|
||||
/** Parse and flatten YAML leaves without retaining their values. */
|
||||
@@ -259,6 +308,10 @@ export function parseSpringYaml(
|
||||
const documentEvents: YamlParseEvent[] = [];
|
||||
const objectEvents = new WeakMap<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,
|
||||
@@ -290,7 +343,7 @@ export function parseSpringYaml(
|
||||
});
|
||||
|
||||
documents.forEach((document, index) =>
|
||||
flattenYamlValue(document, documentEvents[index], '', flattened, objectEvents),
|
||||
flattenYamlValue(document, documentEvents[index], '', flattened, objectEvents, traversal),
|
||||
);
|
||||
return [...flattened.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
@@ -350,7 +403,6 @@ export const springConfigPhase: PipelinePhase<SpringConfigOutput> = {
|
||||
filePath: entry.filePath,
|
||||
startLine: entry.line,
|
||||
endLine: entry.line,
|
||||
language: entry.format,
|
||||
description: entry.profile
|
||||
? `${SPRING_CONFIG_DESCRIPTION} (profile: ${entry.profile})`
|
||||
: SPRING_CONFIG_DESCRIPTION,
|
||||
|
||||
+5
@@ -17,4 +17,9 @@ class ServiceProperties {
|
||||
private Retry retry;
|
||||
}
|
||||
|
||||
@ConfigurationProperties("service")
|
||||
class UnmatchedServiceProperties {
|
||||
private String unrelated;
|
||||
}
|
||||
|
||||
class Retry {}
|
||||
|
||||
+1
@@ -1 +1,2 @@
|
||||
payment.timeout=30
|
||||
service.endpoint=https://base.example.test
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import path from 'node:path';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import { SPRING_CONFIG_DESCRIPTION } from '../../src/core/ingestion/frameworks/spring/config-bindings.js';
|
||||
import type { PipelineResult } from '../../types/pipeline.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-config-app');
|
||||
const SHADOW_FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-config-shadow-app');
|
||||
@@ -33,8 +36,18 @@ describe('Spring configuration binding pipeline', () => {
|
||||
.map((edge) => String(result.graph.getNode(edge.targetId)?.properties.name))
|
||||
.sort();
|
||||
|
||||
const targetFilesFrom = (source: GraphNode, targetName: string): string[] =>
|
||||
uses
|
||||
.filter((edge) => edge.sourceId === source.id)
|
||||
.map((edge) => result.graph.getNode(edge.targetId))
|
||||
.filter((node) => node?.properties.name === targetName)
|
||||
.map((node) => String(node?.properties.filePath))
|
||||
.sort();
|
||||
|
||||
it('creates key-only Property nodes for properties and profile YAML files', () => {
|
||||
expect(nodeNamed('payment.timeout', 'application.properties')).toBeDefined();
|
||||
const propertiesKey = nodeNamed('payment.timeout', 'application.properties');
|
||||
expect(propertiesKey).toBeDefined();
|
||||
expect(propertiesKey?.properties).not.toHaveProperty('language');
|
||||
expect(nodeNamed('service.endpoint', 'application-dev.yml')?.properties.description).toContain(
|
||||
'profile: dev',
|
||||
);
|
||||
@@ -65,10 +78,32 @@ describe('Spring configuration binding pipeline', () => {
|
||||
if (owner === undefined || endpoint === undefined || retry === undefined) {
|
||||
throw new Error('fixture ConfigurationProperties symbols missing');
|
||||
}
|
||||
expect(targetsFrom(owner)).toEqual(['service.endpoint', 'service.retry.max-attempts']);
|
||||
expect(targetsFrom(endpoint)).toEqual(['service.endpoint']);
|
||||
expect(targetsFrom(owner)).toEqual([
|
||||
'service.endpoint',
|
||||
'service.endpoint',
|
||||
'service.retry.max-attempts',
|
||||
]);
|
||||
expect(targetsFrom(endpoint)).toEqual(['service.endpoint', 'service.endpoint']);
|
||||
expect(targetFilesFrom(endpoint, 'service.endpoint')).toEqual([
|
||||
'src/main/resources/application-dev.yml',
|
||||
'src/main/resources/application.properties',
|
||||
]);
|
||||
expect(targetsFrom(retry)).toEqual(['service.retry.max-attempts']);
|
||||
});
|
||||
|
||||
it('keeps the class-level binding when no field relaxed-name matches', () => {
|
||||
const owner = nodeNamed('UnmatchedServiceProperties', 'ConfigConsumers.java');
|
||||
const unrelated = nodeNamed('unrelated', 'ConfigConsumers.java');
|
||||
if (owner === undefined || unrelated === undefined) {
|
||||
throw new Error('unmatched ConfigurationProperties symbols missing');
|
||||
}
|
||||
expect(targetsFrom(owner)).toEqual([
|
||||
'service.endpoint',
|
||||
'service.endpoint',
|
||||
'service.retry.max-attempts',
|
||||
]);
|
||||
expect(targetsFrom(unrelated)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spring configuration annotation attribution', () => {
|
||||
@@ -91,3 +126,31 @@ describe('Spring configuration annotation attribution', () => {
|
||||
expect(String(fake.properties.description ?? '')).not.toContain('Spring config unresolved:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spring configuration file safety bounds', () => {
|
||||
it('fails closed for malformed and oversized configuration files', async () => {
|
||||
const repo = await createTempDir();
|
||||
try {
|
||||
const resources = path.join(repo.dbPath, 'src', 'main', 'resources');
|
||||
await mkdir(resources, { recursive: true });
|
||||
await writeFile(path.join(resources, 'application-broken.yml'), 'broken: [\n', 'utf8');
|
||||
await writeFile(
|
||||
path.join(resources, 'application-oversized.properties'),
|
||||
`oversized.key=${'x'.repeat(2 * 1024 * 1024)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Let the scanner admit the file so this exercises springConfig's
|
||||
// stricter 2 MiB cap rather than the scanner's default 512 KiB cap.
|
||||
vi.stubEnv('GITNEXUS_MAX_FILE_SIZE', '4096');
|
||||
const result = await runPipelineFromRepo(repo.dbPath, () => {}, { skipGraphPhases: true });
|
||||
const configNodes = [...result.graph.iterNodes()].filter((node) =>
|
||||
String(node.properties.description ?? '').startsWith(SPRING_CONFIG_DESCRIPTION),
|
||||
);
|
||||
expect(configNodes).toEqual([]);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,15 @@ describe('analysis feature versions', () => {
|
||||
'graph.class-framework-annotations': 1,
|
||||
'spring.bean-inventory': 1,
|
||||
});
|
||||
expect(
|
||||
resolveAnalysisFeatureVersions(FEATURES, [
|
||||
'src/main/resources/application-local.yml',
|
||||
'README.md',
|
||||
]),
|
||||
).toEqual({
|
||||
'graph.class-framework-annotations': 1,
|
||||
'spring.config-bindings': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('requires an exact, well-formed feature set', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,11 +6,7 @@ import {
|
||||
parseSpringProperties,
|
||||
parseSpringYaml,
|
||||
} from '../../src/core/ingestion/pipeline-phases/spring-config.js';
|
||||
import {
|
||||
extractJavaSpringConfigConsumers,
|
||||
parseConfigurationPropertiesPrefix,
|
||||
parseValuePlaceholderKeys,
|
||||
} from '../../src/core/ingestion/languages/java/spring-config-bindings.js';
|
||||
import { extractJavaSpringConfigConsumers } from '../../src/core/ingestion/languages/java/spring-config-bindings.js';
|
||||
|
||||
describe('Spring configuration parsing', () => {
|
||||
it('recognizes base and profile-specific application config files', () => {
|
||||
@@ -83,13 +79,17 @@ describe('Spring configuration parsing', () => {
|
||||
expect(keys.some((entry) => entry.key.includes('<<'))).toBe(false);
|
||||
});
|
||||
|
||||
it('parses Value defaults and ConfigurationProperties aliases', () => {
|
||||
expect(parseValuePlaceholderKeys('@Value("${server.port:8080}")')).toEqual(['server.port']);
|
||||
it('terminates cyclic YAML aliases and bounds deeply nested expansion', () => {
|
||||
expect(
|
||||
parseConfigurationPropertiesPrefix('@ConfigurationProperties(prefix = "acme.api")'),
|
||||
).toBe('acme.api');
|
||||
expect(parseConfigurationPropertiesPrefix('@ConfigurationProperties("acme.api")')).toBe(
|
||||
'acme.api',
|
||||
parseSpringYaml('cycle: &cycle { self: *cycle }\nhealthy: true\n', 'application.yml'),
|
||||
).toEqual([expect.objectContaining({ key: 'healthy', line: 2 })]);
|
||||
|
||||
const aliasChain = ['level0: &level0 { leaf: true }'];
|
||||
for (let index = 1; index <= 130; index++) {
|
||||
aliasChain.push(`level${index}: &level${index} { next: *level${index - 1} }`);
|
||||
}
|
||||
expect(() => parseSpringYaml(aliasChain.join('\n'), 'application.yml')).toThrow(
|
||||
'Spring YAML traversal depth',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -144,6 +144,30 @@ describe('Java Spring configuration consumers', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads only string-literal AST nodes and ignores placeholders inside comments', () => {
|
||||
const consumers = extractJavaSpringConfigConsumers(`
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(
|
||||
// legacy prefix: "old.unsafe"
|
||||
value = "service"
|
||||
)
|
||||
class ServiceProperties {
|
||||
@Value(
|
||||
/* legacy: "\${old.unsafe.key}" */
|
||||
"\${service.timeout:30}"
|
||||
)
|
||||
private int timeout;
|
||||
}
|
||||
`);
|
||||
|
||||
expect(consumers).toEqual([
|
||||
expect.objectContaining({ kind: 'value', keys: ['service.timeout'] }),
|
||||
expect.objectContaining({ kind: 'configuration-properties', prefix: 'service' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spring configuration graph binding', () => {
|
||||
|
||||
Reference in New Issue
Block a user