mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +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>
200 lines
8.5 KiB
TypeScript
200 lines
8.5 KiB
TypeScript
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');
|
|
|
|
const ktTimeout = nodeNamed('timeout', 'ConfigConsumers.kt');
|
|
const ktMissing = nodeNamed('missing', 'ConfigConsumers.kt');
|
|
expect(ktTimeout).toBeDefined();
|
|
expect(ktMissing).toBeDefined();
|
|
if (ktTimeout === undefined || ktMissing === undefined) {
|
|
throw new Error('kotlin fixture fields missing');
|
|
}
|
|
expect(targetsFrom(ktTimeout)).toEqual(['payment.timeout']);
|
|
expect(targetsFrom(ktMissing)).toEqual([]);
|
|
expect(ktMissing?.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']);
|
|
|
|
const ktOwner = nodeNamed('ServiceProperties', 'ConfigConsumers.kt');
|
|
const ktEndpoint = nodeNamed('endpoint', 'ConfigConsumers.kt');
|
|
const ktRetry = nodeNamed('retry', 'ConfigConsumers.kt');
|
|
expect(ktOwner).toBeDefined();
|
|
if (ktOwner === undefined || ktEndpoint === undefined || ktRetry === undefined) {
|
|
throw new Error('kotlin fixture ConfigurationProperties symbols missing');
|
|
}
|
|
expect(targetsFrom(ktOwner)).toEqual([
|
|
'service.endpoint',
|
|
'service.endpoint',
|
|
'service.retry.max-attempts',
|
|
]);
|
|
expect(targetsFrom(ktEndpoint)).toEqual(['service.endpoint', 'service.endpoint']);
|
|
expect(targetFilesFrom(ktEndpoint, 'service.endpoint')).toEqual([
|
|
'src/main/resources/application-dev.yml',
|
|
'src/main/resources/application.properties',
|
|
]);
|
|
expect(targetsFrom(ktRetry)).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([]);
|
|
|
|
const ktOwner = nodeNamed('UnmatchedServiceProperties', 'ConfigConsumers.kt');
|
|
const ktUnrelated = nodeNamed('unrelated', 'ConfigConsumers.kt');
|
|
if (ktOwner === undefined || ktUnrelated === undefined) {
|
|
throw new Error('kotlin unmatched ConfigurationProperties symbols missing');
|
|
}
|
|
expect(targetsFrom(ktOwner)).toEqual([
|
|
'service.endpoint',
|
|
'service.endpoint',
|
|
'service.retry.max-attempts',
|
|
]);
|
|
expect(targetsFrom(ktUnrelated)).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();
|
|
}
|
|
});
|
|
});
|