GitNexus/gitnexus/bench/spring-config-bindings/measure.mjs
Gergő Magyar e1e2464960
feat(kotlin): bind Spring config consumers on Kotlin sources (#3126)
* 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>
2026-09-01 10:32:09 +00:00

164 lines
5.1 KiB
JavaScript

/**
* Build-free throughput + identity bench for Kotlin Spring config-consumer
* capture (#2412).
*
* Arms (identical corpora except the import style):
* - exact: explicit `import ...annotation.Value` control, which resolves the
* annotation from `imports.exact` before any shadow check runs
* - wildcard: `import ...annotation.*` feature path, where every simple-name
* annotation pays the lexical local-type shadow walk. Each file also
* declares a sibling nested type named `Value` that must NOT suppress the
* Spring annotation — the file-wide-shadow regression fixed on this branch.
*
* Parsing is prepared outside the timer; the measured path is the capture
* function the Kotlin worker calls on its own AST.
*
* Usage:
* node --import tsx bench/spring-config-bindings/measure.mjs
* node --import tsx bench/spring-config-bindings/measure.mjs --check
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import { SupportedLanguages } from 'gitnexus-shared';
import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts';
import { captureKotlinSpringConfigConsumerFacts } from '../../src/core/ingestion/languages/kotlin/spring-config-bindings.ts';
import { fingerprintIds, minSample, runBaselineCheck } from '../lib/identity-guard.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const SMALL = 250;
const LARGE = 800;
const REPS = 15;
const WARMUP = 5;
/** Two @Value properties plus one @ConfigurationProperties class per file. */
const FACTS_PER_FILE = 3;
function consumerSource(i, mode) {
const imports =
mode === 'wildcard'
? `import org.springframework.beans.factory.annotation.*
import org.springframework.boot.context.properties.*`
: `import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.properties.ConfigurationProperties`;
return `package bench.config
${imports}
class Shadowing${i} {
class Value
}
@ConfigurationProperties(prefix = "svc.${i}")
class Props${i} {
var endpoint: String? = null
}
class Consumer${i} {
@Value("\\\${app.key${i}}")
var timeout: Int = 0
@Value("\\\${app.other${i}:5}")
var other: String? = null
fun decoy() {}
}
`;
}
/** Position-free fact identity, so both arms are directly comparable. */
function factId(fact) {
const consumer = fact.consumer;
return consumer.kind === 'value'
? `value|${consumer.fieldName}|${[...consumer.keys].sort().join(',')}`
: `configuration-properties|${consumer.className}|${consumer.prefix}`;
}
function prepare(mode, fileCount) {
const files = [];
const lang = getLanguageGrammar(SupportedLanguages.Kotlin);
for (let i = 0; i < fileCount; i++) {
const parser = new Parser();
parser.setLanguage(lang);
const filePath = `bench/${mode}/Consumer${i}.kt`;
files.push({ tree: parser.parse(consumerSource(i, mode)), filePath, parser });
}
return files;
}
function runAll(files) {
const ids = [];
for (const f of files) {
for (const fact of captureKotlinSpringConfigConsumerFacts(f.tree.rootNode, f.filePath)) {
ids.push(factId(fact));
}
}
return ids;
}
function measure(mode, fileCount) {
const files = prepare(mode, fileCount);
const { last, ms } = minSample(() => runAll(files), WARMUP, REPS);
return {
files: fileCount,
ms,
facts: last.length,
fingerprint: fingerprintIds(last),
};
}
function failIfNeeded(current, errors) {
if (errors.length === 0) return;
console.error(JSON.stringify({ report: current, errors }, null, 2));
process.exit(1);
}
function runFactCountCheck(current, expectedCounts) {
const errors = [];
for (const [arm, expected] of Object.entries(expectedCounts)) {
const actual = current[arm]?.facts;
if (actual !== expected) errors.push(`${arm}.facts ${String(actual)} != ${expected}`);
}
failIfNeeded(current, errors);
}
/**
* A wildcard import plus a sibling `Value` declaration must capture exactly the
* facts the explicit-import control captures.
*/
function runFingerprintParityCheck(current, leftArm, rightArm) {
const left = current[leftArm]?.fingerprint;
const right = current[rightArm]?.fingerprint;
failIfNeeded(
current,
left === right ? [] : [`${leftArm}.fingerprint ${left} != ${rightArm}.fingerprint ${right}`],
);
}
const report = {
exact_small: measure('exact', SMALL),
exact_large: measure('exact', LARGE),
wildcard_small: measure('wildcard', SMALL),
wildcard_large: measure('wildcard', LARGE),
};
report.scaling_ratio = Number(
(report.wildcard_large.ms / report.wildcard_small.ms / (LARGE / SMALL)).toFixed(3),
);
report.widening_overhead = Number(
(report.wildcard_large.ms / Math.max(report.exact_large.ms, 0.001)).toFixed(3),
);
report.fingerprint = report.wildcard_large.fingerprint;
runFactCountCheck(report, {
exact_large: LARGE * FACTS_PER_FILE,
wildcard_large: LARGE * FACTS_PER_FILE,
});
runFingerprintParityCheck(report, 'exact_large', 'wildcard_large');
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
runBaselineCheck(report, BASELINE_PATH);