GitNexus/gitnexus/test/unit/spring-config-bindings.test.ts
dependabot[bot] c35403b59d
chore(deps)(deps): bump js-yaml from 4.3.0 to 5.0.0 in /gitnexus (#2618)
* chore(deps)(deps): bump js-yaml from 4.3.0 to 5.0.0 in /gitnexus

Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 5.0.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...5.0.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(spring-config): migrate YAML parsing to js-yaml 5 event API

js-yaml 5 removed the loadAll `listener` callback, the EventType/State
types, and DEFAULT_SCHEMA that spring-config relied on, breaking the build.

Rebuild the per-key line tree from parseEvents()/constructFromEvents()
(positions are source offsets → mapped to lines), apply the `<<` merge tag
via CORE_SCHEMA.withTags(mergeTag) (CORE alone leaves merge keys unexpanded),
and resolve aliases by anchor name, which lets the object-identity WeakMap go.

Behavior preserved: 9 unit + 8 integration spring-config tests pass, including
merged-key declaration-line, cyclic-alias termination, and the depth budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(spring-config): restore v4 tag coverage, cover the v5 rewrite with tests

Review follow-up for #2618.

CORE_SCHEMA.withTags(mergeTag) was a narrowing, not a port: js-yaml 5
throws "unknown tag" on !!timestamp/!!binary/!!set/!!omap/!!pairs, and an
unknown tag aborts the whole parse, which readConfigKeys swallows — so an
application.yml using any of them would have gone from its full key set to
zero keys, silently. Carry the rest of what DEFAULT_SCHEMA was; none of
these tags can execute code.

Add tests for every path the review flagged as uncovered: multi-document
files, empty/comment-only/bare-`---`/bare-scalar documents, sequence-form
merge keys, and explicitly tagged values (which fail against the one-tag
schema, so they target the changed line).

Clear the anchor map per document. It cannot change output today —
constructFromEvents rejects a cross-document alias before the event tree is
built, now asserted — but it keeps both layers on YAML's scoping rule.

Drop the stale @types/js-yaml devDependency; js-yaml 5 ships its own types
and tsc --noEmit is clean without it. Lockfile hand-edited because npm
uninstall also strips every libc field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(spring-config): flatten !!set members, walk YAML iteratively

Review follow-up for #2618.

js-yaml 5 constructs `!!set` as a native Set; v4 built a plain
`{member: null}` object. Object.entries of a Set is empty, so a tagged set
collapsed to a bare leaf key and lost every member. Enumerate the Set
instead. Sets arrive as mapping events with key/value scalar pairs, so
member lines resolve through the usual lookup. !!binary and !!timestamp are
unaffected — both are scalar events and take the leaf path, which is why a
Uint8Array never explodes into one key per byte.

Convert findYamlMappingLocation and flattenYamlValue from recursion to an
explicit stack. Children are pushed in reverse so pops happen in
declaration order, preserving "first match" and `out` insertion order;
`leave` frames release the cycle guard where the old `finally` did. The
depth budget still throws at the same boundary with the same message.

Cover the gaps the review named: !!pairs (both duplicate entries survive),
anchor-name reuse resolving to the nearest preceding declaration, and
marker-only leading documents staying index-aligned across the two streams.

buildYamlEventTree keeps no node budget by design — one node per event over
an already-materialized array, bounded by MAX_CONFIG_FILE_BYTES. The
docstring now says so rather than implying MAX_YAML_TRAVERSAL_NODES covers
it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-22 07:50:51 +01:00

297 lines
10 KiB
TypeScript

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('flattens every document of a multi-document file and ignores empty ones', () => {
expect(
parseSpringYaml(
'server:\n port: 8080\n---\nservice:\n name: demo\n',
'application.yml',
).map((entry) => [entry.key, entry.line]),
).toEqual([
['server.port', 2],
['service.name', 5],
]);
expect(parseSpringYaml('', 'application.yml')).toEqual([]);
expect(parseSpringYaml('# only a comment\n\n', 'application.yml')).toEqual([]);
expect(parseSpringYaml('---\n', 'application.yml')).toEqual([]);
// A bare top-level scalar has no key to attribute, so it contributes nothing.
expect(parseSpringYaml('just-a-scalar\n', 'application.yml')).toEqual([]);
// Anchors are document-scoped: an alias may not reach into a previous document.
expect(() =>
parseSpringYaml(
'base: &base\n timeout: 30\n---\nservice:\n <<: *base\n',
'application.yml',
),
).toThrow('unidentified alias');
});
it('resolves sequence-form merge keys and explicitly tagged values', () => {
expect(
parseSpringYaml('a: &a\n x: 1\nb: &b\n y: 2\nc:\n <<: [*a, *b]\n', 'application.yml').map(
(entry) => [entry.key, entry.line],
),
).toEqual([
['a.x', 2],
['b.y', 4],
['c.x', 2],
['c.y', 4],
]);
// js-yaml 5's CORE schema alone rejects these tags; the file-level catch would
// then drop every key in the file, so the schema must keep carrying them.
// `!!set` constructs a native Set in v5 (a plain object in v4), so its members
// are only reachable by enumerating the Set itself.
const tagged = parseSpringYaml(
[
'when: !!timestamp 2001-12-14',
'blob: !!binary "R0lGODlh"',
'flags: !!set\n ? a\n ? b',
'ordered: !!omap\n - first: 1',
'listed: !!pairs\n - dup: 1\n - dup: 2',
].join('\n'),
'application.yml',
);
expect(tagged.map((entry) => [entry.key, entry.line])).toEqual([
['blob', 2],
['flags.a', 4],
['flags.b', 5],
// `!!pairs` keeps both `dup` entries instead of collapsing them, which is the
// point of the tag. Nested sequence items inherit their parent's line here,
// as they did under v4 — the mapping lookup that refines a line has no
// equivalent for a bare array index.
['listed[0][0]', 8],
['listed[0][1]', 8],
['listed[1][0]', 8],
['listed[1][1]', 8],
['ordered[0].first', 7],
['when', 1],
]);
expect(JSON.stringify(tagged)).not.toContain('R0lGODlh');
});
it('resolves an alias to the nearest preceding anchor when a name is reused', () => {
// v4 keyed aliases on constructed-object identity; v5 keys them by anchor
// name, so redeclaring a name is a case the old scheme could not express.
expect(
parseSpringYaml(
'first: &shared\n a: 1\nsecond: &shared\n b: 2\nthird: *shared\n',
'application.yml',
).map((entry) => [entry.key, entry.line]),
).toEqual([
['first.a', 2],
['second.b', 4],
['third.b', 4],
]);
});
it('keeps document and event streams aligned across marker-only documents', () => {
// The value tree and the line tree are built from the same DOCUMENT events but
// zipped by index, so a leading empty document must consume a slot in both.
expect(
parseSpringYaml('---\n---\nfoo: 1\n', 'application.yml').map((entry) => [
entry.key,
entry.line,
]),
).toEqual([['foo', 3]]);
expect(
parseSpringYaml('a: 1\n---\n---\nb: 2\n', 'application.yml').map((entry) => [
entry.key,
entry.line,
]),
).toEqual([
['a', 1],
['b', 4],
]);
});
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);
});
});