mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* feat(kotlin): bind Spring @Value and ConfigurationProperties consumers Kotlin sources were skipping the Java-only config-binding attach path, so mixed JVM apps under-reported blast radius for Kotlin placeholders. Capture from the live AST, serialize on the existing side channel, and reuse the shared binder. (U1-U4) Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): require exact Spring annotation FQNs and skip raw-string escapes Reject similarly named third-party imports and leave Kotlin triple-quoted bodies undecoded so capture stays fail-closed. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(kotlin): use only grammar-valid Kotlin string and class-name nodes The coverage shard failed the #1920 literal gate on invented string node types and a Java-style name field. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(kotlin): fail closed on non-literal prefixes and persist unresolved config markers Reject constant and boolean @ConfigurationProperties arguments, scope nested Value shadows to their owner, and rewrite drifted consumer files when a config key is deleted. Co-authored-by: Cursor <cursoragent@cursor.com> * test(kotlin): add config-consumer capture benchmark and fix JVM feature stamps The Kotlin capture path had no performance or behavior guard: a file-wide lexical shadow regression silently dropped two of every three facts. The new bench arm fingerprints @Value / @ConfigurationProperties facts from an explicit-import control against a wildcard-import corpus whose files each declare a sibling nested `Value` type, so parity between the arms is the regression gate, and CI runs it with scaling + widening budgets. Broadening spring.config-bindings to .kt also means Kotlin-only JVM repos now stamp the feature, which the two exact-map orchestration expectations still denied. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(kotlin): let an explicit import shadow the Spring wildcard import importedAs accepted a star import of the Spring package even when the same simple name was explicitly bound to another type, so a file importing com.example.Value alongside the Spring annotation package emitted a false @Value consumer fact. Kotlin resolves the explicit import first, so the wildcard branch now only applies when the name is otherwise unbound. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
511 lines
17 KiB
TypeScript
511 lines
17 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('Kotlin Spring configuration consumers', () => {
|
|
it('resolves official imports and ignores shadowed annotation names', async () => {
|
|
const { extractKotlinSpringConfigConsumers } =
|
|
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
|
|
|
|
const consumers = extractKotlinSpringConfigConsumers(`
|
|
import org.springframework.beans.factory.annotation.Value
|
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
|
|
|
@ConfigurationProperties(prefix = "service")
|
|
class ServiceProperties {
|
|
@Value("\\\${service.timeout:30}")
|
|
var timeout: Int = 0
|
|
}
|
|
`);
|
|
expect(consumers).toEqual([
|
|
expect.objectContaining({ kind: 'value', fieldName: 'timeout', keys: ['service.timeout'] }),
|
|
expect.objectContaining({
|
|
kind: 'configuration-properties',
|
|
className: 'ServiceProperties',
|
|
prefix: 'service',
|
|
}),
|
|
]);
|
|
|
|
expect(
|
|
extractKotlinSpringConfigConsumers(`
|
|
annotation class Value(val value: String)
|
|
class Local {
|
|
@Value("\\\${fake.key}")
|
|
var field: String = ""
|
|
}
|
|
`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('limits nested annotation shadows to their lexical owner', async () => {
|
|
const { extractKotlinSpringConfigConsumers } =
|
|
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
|
|
|
|
const consumers = extractKotlinSpringConfigConsumers(`
|
|
import org.springframework.beans.factory.annotation.*
|
|
|
|
class ShadowOwner {
|
|
annotation class Value(val value: String)
|
|
|
|
@Value("\\\${ignored.local}")
|
|
var local: String = ""
|
|
}
|
|
|
|
class SpringConsumer {
|
|
@Value("\\\${service.timeout}")
|
|
var timeout: Int = 0
|
|
}
|
|
`);
|
|
|
|
expect(consumers).toEqual([
|
|
expect.objectContaining({ kind: 'value', fieldName: 'timeout', keys: ['service.timeout'] }),
|
|
]);
|
|
});
|
|
|
|
it('rejects non-literal ConfigurationProperties prefixes', async () => {
|
|
const { extractKotlinSpringConfigConsumers } =
|
|
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
|
|
|
|
expect(
|
|
extractKotlinSpringConfigConsumers(`
|
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
|
|
|
const val SERVICE_PREFIX = "service"
|
|
|
|
@ConfigurationProperties(prefix = SERVICE_PREFIX)
|
|
class ConstantPrefix
|
|
|
|
@ConfigurationProperties(ignoreUnknownFields = true)
|
|
class BooleanFirstArgument
|
|
`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('binds constructor properties, setters, and import aliases, not getters or unbound params', async () => {
|
|
const { extractKotlinSpringConfigConsumers } =
|
|
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
|
|
|
|
const consumers = extractKotlinSpringConfigConsumers(`
|
|
import org.springframework.beans.factory.annotation.Value as Bound
|
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
|
|
|
@ConfigurationProperties("service")
|
|
class BoundProps(
|
|
@param:Bound("\\\${service.timeout}")
|
|
val timeout: Int,
|
|
@Bound("\\\${ignored.plain}")
|
|
timeoutPlain: Int,
|
|
) {
|
|
@get:Bound("\\\${ignored.getter}")
|
|
@set:Bound("\\\${service.endpoint}")
|
|
var endpoint: String = ""
|
|
|
|
@field:Bound("\\\${service.retry.max-attempts}")
|
|
var attempts: Int = 0
|
|
}
|
|
`);
|
|
|
|
expect(consumers).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ kind: 'value', fieldName: 'timeout', keys: ['service.timeout'] }),
|
|
expect.objectContaining({
|
|
kind: 'value',
|
|
fieldName: 'endpoint',
|
|
keys: ['service.endpoint'],
|
|
}),
|
|
expect.objectContaining({
|
|
kind: 'value',
|
|
fieldName: 'attempts',
|
|
keys: ['service.retry.max-attempts'],
|
|
}),
|
|
expect.objectContaining({
|
|
kind: 'configuration-properties',
|
|
className: 'BoundProps',
|
|
prefix: 'service',
|
|
}),
|
|
]),
|
|
);
|
|
expect(
|
|
consumers.some(
|
|
(consumer) => consumer.kind === 'value' && consumer.keys.includes('ignored.getter'),
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
consumers.some(
|
|
(consumer) => consumer.kind === 'value' && consumer.keys.includes('ignored.plain'),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('fails closed on Kotlin string interpolation and decodes escaped placeholders', async () => {
|
|
const { extractKotlinSpringConfigConsumers } =
|
|
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
|
|
|
|
expect(
|
|
extractKotlinSpringConfigConsumers(`
|
|
import org.springframework.beans.factory.annotation.Value
|
|
class Interpolated {
|
|
val prefix = "service"
|
|
@Value("\${prefix}.timeout")
|
|
var timeout: Int = 0
|
|
}
|
|
`),
|
|
).toEqual([]);
|
|
|
|
expect(
|
|
extractKotlinSpringConfigConsumers(`
|
|
import org.springframework.beans.factory.annotation.Value
|
|
class Escaped {
|
|
@Value("\\\${payment.timeout}")
|
|
var timeout: Int = 0
|
|
}
|
|
`),
|
|
).toEqual([
|
|
expect.objectContaining({ kind: 'value', fieldName: 'timeout', keys: ['payment.timeout'] }),
|
|
]);
|
|
});
|
|
|
|
it('does not treat similarly named third-party imports as Spring annotations', async () => {
|
|
const { extractKotlinSpringConfigConsumers } =
|
|
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
|
|
|
|
expect(
|
|
extractKotlinSpringConfigConsumers(`
|
|
import com.example.Value
|
|
class Local {
|
|
@Value("\\\${fake.key}")
|
|
var field: String = ""
|
|
}
|
|
`),
|
|
).toEqual([]);
|
|
|
|
// The explicit import still wins when Spring's package is star-imported
|
|
// alongside it, so only the unshadowed annotation resolves.
|
|
expect(
|
|
extractKotlinSpringConfigConsumers(`
|
|
import com.example.Value
|
|
import org.springframework.beans.factory.annotation.*
|
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
|
|
|
@ConfigurationProperties("service")
|
|
class Local {
|
|
@Value("\\\${fake.key}")
|
|
var field: String = ""
|
|
}
|
|
`),
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
kind: 'configuration-properties',
|
|
className: 'Local',
|
|
prefix: 'service',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('does not decode escapes inside Kotlin raw string prefixes', async () => {
|
|
const { extractKotlinSpringConfigConsumers } =
|
|
await import('../../src/core/ingestion/languages/kotlin/spring-config-bindings.js');
|
|
|
|
expect(
|
|
extractKotlinSpringConfigConsumers(`
|
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
|
@ConfigurationProperties("""service\\u002eendpoint""")
|
|
class RawProps
|
|
`),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|