fix(spring): harden configuration bindings

This commit is contained in:
Shining
2026-07-21 13:44:43 +08:00
parent ce6cefe696
commit 41e590fed7
9 changed files with 304 additions and 71 deletions
@@ -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,
@@ -17,4 +17,9 @@ class ServiceProperties {
private Retry retry;
}
@ConfigurationProperties("service")
class UnmatchedServiceProperties {
private String unrelated;
}
class Retry {}
@@ -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', () => {