diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 9739f9028..718718c43 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -481,6 +481,20 @@ jobs: node --import tsx bench/python-scope/import-target-fingerprint.mjs --check working-directory: gitnexus + - name: Java wildcard-static route constant guards (#3110) + if: ${{ !cancelled() }} + # Build-free: named-import control vs wildcard materialization; + # fingerprints bindings and guards scaling + absolute wall time. + run: node --import tsx bench/java-wildcard-route-constants/measure.mjs --check + working-directory: gitnexus + + - name: Kotlin package-star route constant guards (#3110) + if: ${{ !cancelled() }} + # Build-free: explicit-import control vs package-star folding; + # fingerprints route facts and guards scaling + widening overhead. + run: node --import tsx bench/kotlin-star-route-constants/measure.mjs --check + working-directory: gitnexus + - name: Cross-language scope-capture fingerprint + scaling guards # Runs even after an earlier guard fails (#2895). Every step here was # fail-fast, so the FIRST failing --check aborted the job and every guard diff --git a/gitnexus/bench/java-wildcard-route-constants/baselines.json b/gitnexus/bench/java-wildcard-route-constants/baselines.json new file mode 100644 index 000000000..961d9a883 --- /dev/null +++ b/gitnexus/bench/java-wildcard-route-constants/baselines.json @@ -0,0 +1,8 @@ +{ + "_comment": "Baselines for bench/java-wildcard-route-constants/measure.mjs --check (#3110). fingerprint is sha256 over 800 materialized route bindings from 800 constant files and must match the named-import control. The benchmark builds the constant import index once per repo pass, matching ingestion and group wiring.", + "fingerprint": "8114e613e93ce0ef6220b810850888592e0e822fe04bf2eb5d8fc4ec3dbba5ef", + "scaling_budget": 1.6, + "_scaling_note": "(t_large/t_small)/(800/250) while both constant files and wildcard importers scale. Measured about 1.14 with the suffix index; repeated candidate scans are quadratic.", + "absolute_ms_budget": 10, + "_absolute_ms_note": "Wildcard materialization for 800 controllers. Measured about 1.1 ms; the generous ceiling catches gross regressions without treating the near-zero named-import control as a stable ratio denominator." +} diff --git a/gitnexus/bench/java-wildcard-route-constants/measure.mjs b/gitnexus/bench/java-wildcard-route-constants/measure.mjs new file mode 100644 index 000000000..50eda340c --- /dev/null +++ b/gitnexus/bench/java-wildcard-route-constants/measure.mjs @@ -0,0 +1,158 @@ +/** + * Build-free throughput + identity benchmark for Java wildcard-static route constants. + * + * Arms: + * - named: explicit `import static ...ApiPaths.ROUTE_n` control + * - wildcard: `import static ...ApiPaths.*` feature path + * + * Parsing is prepared outside the timer. The measured path mirrors ingestion: + * build the constant-key index once, materialize pending wildcard imports, then + * read the resulting binding. Route folding itself has separate integration + * coverage and an older per-fold index cost shared by both arms. + * + * Usage: + * node --import tsx bench/java-wildcard-route-constants/measure.mjs + * node --import tsx bench/java-wildcard-route-constants/measure.mjs --check + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; +import { + extractJavaModuleConstants, + prepareJavaRouteConstants, +} from '../../src/core/ingestion/route-extractors/java-const-resolver.ts'; +import { + fingerprintIds, + minSampleFresh, + runBaselineCheck, + runCountCheck, + runFingerprintParityCheck, +} from '../lib/route-constant-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; + +const parser = new Parser(); +parser.setLanguage(Java); + +function constantsSource(i) { + return `package bench.constants; +public final class ApiPaths${i} { + public static final String ROUTE = "/api/routes/${i}"; +} +`; +} + +function controllerSource(i, mode) { + const fqn = `bench.constants.ApiPaths${i}`; + const imported = mode === 'wildcard' ? `import static ${fqn}.*;` : `import static ${fqn}.ROUTE;`; + return `package bench.web; +${imported} +class Controller${i} {} +`; +} + +function cloneConstants(mc) { + return { + literals: new Map(mc.literals), + exprs: new Map(mc.exprs), + imports: new Map(mc.imports), + wildcardImports: mc.wildcardImports ? [...mc.wildcardImports] : undefined, + unfoldableDeclarations: new Set(mc.unfoldableDeclarations ?? []), + }; +} + +function prepare(mode, fileCount) { + const constants = []; + const controllers = []; + for (let i = 0; i < fileCount; i++) { + constants.push({ + key: `bench/constants/ApiPaths${i}.java`, + constants: extractJavaModuleConstants(parser.parse(constantsSource(i))), + }); + controllers.push({ + key: `bench/web/Controller${i}.java`, + route: 'ROUTE', + constants: extractJavaModuleConstants(parser.parse(controllerSource(i, mode))), + }); + } + return { constants, controllers }; +} + +function instantiate(prepared) { + const repo = new Map(); + for (const constant of prepared.constants) { + repo.set(constant.key, cloneConstants(constant.constants)); + } + const controllers = []; + for (const controller of prepared.controllers) { + repo.set(controller.key, cloneConstants(controller.constants)); + controllers.push({ key: controller.key, route: controller.route }); + } + return { repo, controllers }; +} + +function runAll(instance) { + const { repo, controllers } = instance; + prepareJavaRouteConstants(repo); + const bindings = []; + for (const controller of controllers) { + const mc = repo.get(controller.key); + const binding = mc.imports.get(controller.route); + if (binding) { + bindings.push( + `${controller.key}:${controller.route}:${binding.module}:${binding.originalName}`, + ); + } + } + return bindings; +} + +function measure(mode, fileCount) { + const prepared = prepare(mode, fileCount); + // Expansion mutates each importing file's `imports` map. Give every timed + // sample a fresh repo, but build those clones outside the timer. + const { last, ms } = minSampleFresh(() => instantiate(prepared), runAll, WARMUP, REPS); + return { + files: fileCount, + ms, + bindings: last.length, + fingerprint: fingerprintIds(last), + }; +} + +const report = { + named_small: measure('named', SMALL), + named_large: measure('named', 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.overhead_us_per_binding = Number( + ( + ((report.wildcard_large.ms - report.named_large.ms) * 1000) / + report.wildcard_large.bindings + ).toFixed(3), +); +report.absolute_ms = report.wildcard_large.ms; +report.fingerprint = report.wildcard_large.fingerprint; + +runCountCheck(report, 'bindings', { + named_large: LARGE, + wildcard_large: LARGE, +}); +runFingerprintParityCheck(report, 'named_large', 'wildcard_large'); + +if (!process.argv.includes('--check')) { + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +runBaselineCheck(report, BASELINE_PATH); diff --git a/gitnexus/bench/kotlin-star-route-constants/baselines.json b/gitnexus/bench/kotlin-star-route-constants/baselines.json new file mode 100644 index 000000000..a68d3ba9f --- /dev/null +++ b/gitnexus/bench/kotlin-star-route-constants/baselines.json @@ -0,0 +1,10 @@ +{ + "_comment": "Baselines for bench/kotlin-star-route-constants/measure.mjs --check (#3110). fingerprint is sha256 over 800 folded route facts from 800 constant files and must match the explicit-import control. The feature arm resolves package-star names through one prepared KotlinConstantIndex.", + "fingerprint": "881101236c511d73d3894d3c9bd2e4a166e3437329149dd99fd16c256435482e", + "scaling_budget": 1.6, + "_scaling_note": "(t_large/t_small)/(800/250) while both constant files and importing controllers scale. Measured about 1.07-1.14.", + "widening_overhead_budget": 2.5, + "_widening_overhead_note": "star_large_ms / named_large_ms. Measured below 1.0; budget guards against a pathological star-lookup regression.", + "absolute_ms_budget": 5, + "_absolute_ms_note": "Package-star folding for 800 controllers. Measured below 0.6 ms; budget includes substantial CI headroom." +} diff --git a/gitnexus/bench/kotlin-star-route-constants/measure.mjs b/gitnexus/bench/kotlin-star-route-constants/measure.mjs new file mode 100644 index 000000000..299accdb8 --- /dev/null +++ b/gitnexus/bench/kotlin-star-route-constants/measure.mjs @@ -0,0 +1,156 @@ +/** + * Build-free throughput + identity benchmark for Kotlin package-star route constants. + * + * Arms: + * - named: explicit `import bench.constants.ROUTE_n` control + * - star: `import bench.constants.*` feature path + * + * Parsing is prepared outside the timer. The measured path mirrors the Kotlin + * group plugin: overlay one importing controller on the prepared constant + * index, then fold its route. + * + * Usage: + * node --import tsx bench/kotlin-star-route-constants/measure.mjs + * node --import tsx bench/kotlin-star-route-constants/measure.mjs --check + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Parser from 'tree-sitter'; +import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.ts'; +import { + buildKotlinConstantIndex, + extractKotlinModuleConstants, + foldKotlinOperands, + overlayKotlinConstantIndex, +} from '../../src/core/ingestion/route-extractors/kotlin-const-resolver.ts'; +import { + fingerprintIds, + minSampleFresh, + runBaselineCheck, + runCountCheck, + runFingerprintParityCheck, +} from '../lib/route-constant-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; + +const parser = new Parser(); +parser.setLanguage(requireVendoredGrammar('tree-sitter-kotlin')); + +function constantsSource(i) { + return `package bench.constants +const val ROUTE_${i} = "/api/routes/${i}" +`; +} + +function controllerSource(i, mode) { + const route = `ROUTE_${i}`; + const imported = mode === 'star' ? 'import bench.constants.*' : `import bench.constants.${route}`; + return `package bench.web +${imported} +class Controller${i} +`; +} + +function cloneConstants(mc) { + return { + literals: new Map(mc.literals), + exprs: new Map(mc.exprs), + imports: new Map(mc.imports), + wildcardImports: mc.wildcardImports ? [...mc.wildcardImports] : undefined, + packageName: mc.packageName, + unfoldableDeclarations: new Set(mc.unfoldableDeclarations), + topLevelDeclarations: new Set(mc.topLevelDeclarations), + }; +} + +function prepare(mode, fileCount) { + const constants = []; + const controllers = []; + for (let i = 0; i < fileCount; i++) { + constants.push({ + key: `bench/constants/ApiPaths${i}.kt`, + constants: extractKotlinModuleConstants(parser.parse(constantsSource(i))), + }); + controllers.push({ + key: `bench/web/Controller${i}.kt`, + route: `ROUTE_${i}`, + constants: extractKotlinModuleConstants(parser.parse(controllerSource(i, mode))), + }); + } + return { constants, controllers }; +} + +function instantiate(prepared) { + const baseRepo = new Map(); + for (const constant of prepared.constants) { + baseRepo.set(constant.key, cloneConstants(constant.constants)); + } + const controllers = prepared.controllers.map((controller) => ({ + key: controller.key, + route: controller.route, + constants: cloneConstants(controller.constants), + })); + return { baseRepo, controllers }; +} + +function runAll(instance) { + const { baseRepo, controllers } = instance; + const baseIndex = buildKotlinConstantIndex(baseRepo); + const routes = []; + for (const controller of controllers) { + const index = overlayKotlinConstantIndex(baseIndex, controller.key, controller.constants); + const route = foldKotlinOperands( + controller.key, + [{ kind: 'ref', name: controller.route }], + index.repo, + [], + index, + ); + if (route !== null) routes.push(`${controller.key}:${route}`); + } + return routes; +} + +function measure(mode, fileCount) { + const prepared = prepare(mode, fileCount); + const { last, ms } = minSampleFresh(() => instantiate(prepared), runAll, WARMUP, REPS); + return { + files: fileCount, + ms, + routes: last.length, + fingerprint: fingerprintIds(last), + }; +} + +const report = { + named_small: measure('named', SMALL), + named_large: measure('named', LARGE), + star_small: measure('star', SMALL), + star_large: measure('star', LARGE), +}; +report.scaling_ratio = Number( + (report.star_large.ms / report.star_small.ms / (LARGE / SMALL)).toFixed(3), +); +report.widening_overhead = Number( + (report.star_large.ms / Math.max(report.named_large.ms, 0.001)).toFixed(3), +); +report.absolute_ms = report.star_large.ms; +report.fingerprint = report.star_large.fingerprint; + +runCountCheck(report, 'routes', { + named_large: LARGE, + star_large: LARGE, +}); +runFingerprintParityCheck(report, 'named_large', 'star_large'); + +if (!process.argv.includes('--check')) { + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +runBaselineCheck(report, BASELINE_PATH); diff --git a/gitnexus/bench/lib/route-constant-guard.mjs b/gitnexus/bench/lib/route-constant-guard.mjs new file mode 100644 index 000000000..cae8b95fc --- /dev/null +++ b/gitnexus/bench/lib/route-constant-guard.mjs @@ -0,0 +1,77 @@ +/** Shared fingerprint + --check helpers for route-constant benchmarks. */ +import fs from 'node:fs'; +import crypto from 'node:crypto'; + +export function fingerprintIds(ids) { + return crypto + .createHash('sha256') + .update([...ids].sort().join('\n')) + .digest('hex'); +} + +/** Min sample for mutating benchmarks that need fresh state per repetition. */ +export function minSampleFresh(create, run, warmup, reps) { + for (let w = 0; w < warmup; w++) run(create()); + const samples = []; + let last; + for (let r = 0; r < reps; r++) { + const state = create(); + const t0 = performance.now(); + last = run(state); + samples.push(performance.now() - t0); + } + return { last, ms: Math.min(...samples) }; +} + +export function runCountCheck(report, field, expectedCounts) { + const errors = []; + for (const [arm, expected] of Object.entries(expectedCounts)) { + const actual = report[arm]?.[field]; + if (actual !== expected) { + errors.push(`${arm}.${field} ${String(actual)} != ${expected}`); + } + } + failIfNeeded(report, errors); +} + +export function runFingerprintParityCheck(report, leftArm, rightArm) { + const left = report[leftArm]?.fingerprint; + const right = report[rightArm]?.fingerprint; + failIfNeeded( + report, + left === right ? [] : [`${leftArm}.fingerprint ${left} != ${rightArm}.fingerprint ${right}`], + ); +} + +export function runBaselineCheck(report, baselinePath) { + const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8')); + const errors = []; + if (report.fingerprint !== baseline.fingerprint) { + errors.push(`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint}`); + } + if (report.scaling_ratio > baseline.scaling_budget) { + errors.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`); + } + if ( + baseline.absolute_ms_budget !== undefined && + report.absolute_ms > baseline.absolute_ms_budget + ) { + errors.push(`absolute_ms ${report.absolute_ms} > ${baseline.absolute_ms_budget}`); + } + if ( + baseline.widening_overhead_budget !== undefined && + report.widening_overhead > baseline.widening_overhead_budget + ) { + errors.push( + `widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`, + ); + } + failIfNeeded(report, errors); + console.log(JSON.stringify({ ok: true, report }, null, 2)); +} + +function failIfNeeded(report, errors) { + if (errors.length === 0) return; + console.error(JSON.stringify({ report, errors }, null, 2)); + process.exit(1); +} diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 081b71805..8d216c50a 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -29,10 +29,13 @@ import { EXCHANGE_CONFIDENCE, } from './spring-consumer-shared.js'; import { + expandJavaWildcardStaticImports, extractJavaModuleConstants, foldJavaOperands, isJavaConstantFile, parseJavaConstOperands, + prepareJavaRouteConstants, + type JavaConstantIndex, type RepoConstants, } from '../../../ingestion/route-extractors/java-const-resolver.js'; import { @@ -917,7 +920,12 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { const tree = args.parseSource(args.parser, src); if (!tree) continue; const mc = extractJavaModuleConstants(tree); - if (mc.literals.size > 0 || mc.exprs.size > 0 || mc.imports.size > 0) { + if ( + mc.literals.size > 0 || + mc.exprs.size > 0 || + mc.imports.size > 0 || + (mc.wildcardImports?.length ?? 0) > 0 + ) { constants.set(rel, mc); } } catch { @@ -927,11 +935,20 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { continue; } } - return { constants }; + // On-demand static imports (`import static a.b.C.*`) were recorded as + // pending class FQNs during extraction; materialize their bare-name + // bindings now that the whole map exists. A wildcard's target is itself + // a constants file, so it is necessarily a map entry — anything else + // degrades to the fold's skip floor. In-place: each entry is owned by + // this map, and every file is expanded exactly once. + const constantIndex = prepareJavaRouteConstants(constants); + return { constants, constantIndex }; }, scan(tree, repoContext, fileRel) { const out: HttpDetection[] = []; - const javaCtx = repoContext as { constants: RepoConstants } | undefined; + const javaCtx = repoContext as + | { constants: RepoConstants; constantIndex: JavaConstantIndex } + | undefined; // ─── Spring providers + OpenFeign consumers (one query pass) ──── // `scanRouteAnnotations` resolves every route-defining annotation — @@ -966,8 +983,12 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { if (javaCtx.constants.has(fileRel)) return foldConstants; try { const mc = extractJavaModuleConstants(tree); - if (mc.imports.size > 0) { + // A file carrying ONLY wildcard static imports has an empty import + // table pre-expansion — overlay it too, then materialize the promised + // bindings against the repo map before it becomes a fold target. + if (mc.imports.size > 0 || (mc.wildcardImports?.length ?? 0) > 0) { const merged = new Map(javaCtx.constants); + expandJavaWildcardStaticImports(mc, fileRel, merged, javaCtx.constantIndex); merged.set(fileRel, mc); foldConstants = merged; } diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index d9a7c1705..ec695a9b6 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -1328,6 +1328,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { mc.literals.size > 0 || mc.exprs.size > 0 || mc.imports.size > 0 || + (mc.wildcardImports?.length ?? 0) > 0 || unfoldableDeclarationsOf(mc).size > 0 ) { // POSIX key (see `normalizeRel`); `readFile` above got the raw `rel`. @@ -1373,6 +1374,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { mc.literals.size > 0 || mc.exprs.size > 0 || mc.imports.size > 0 || + (mc.wildcardImports?.length ?? 0) > 0 || unfoldableDeclarationsOf(mc).size > 0 ) { foldIndex = overlayKotlinConstantIndex(kotlinCtx.index, fileKey, mc); diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index a835c2105..7a9142e70 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -484,6 +484,16 @@ interface LanguageProviderConfig { */ readonly moduleConstantHeuristic?: (content: string) => boolean; + /** + * Prepare this language's harvested constants once the complete repo map is + * available and before route operands are folded. The parse phase passes only + * entries owned by this provider, so implementations can build one reusable + * language-specific index and may materialize deferred bindings in place. + * + * Default: undefined (the harvested constants are already fold-ready). + */ + readonly prepareRouteConstants?: (repo: RepoConstants) => void; + /** * Fold one file's non-literal route-path operand list * (`routePathExpr`/`routePathOperands` of an `ExtractedDecoratorRoute`) @@ -902,6 +912,34 @@ export interface LanguageProvider extends Omit boolean; } +/** + * Run each provider's repo-constant preparation hook once over only the files + * that provider owns. Values are shared with `repo`, so in-place preparation + * is visible to the subsequent fold without copying the complete map. + */ +export function prepareRouteConstantsByProvider( + repo: RepoConstants, + providerForFile: (filePath: string) => Pick | null, +): void { + const slices = new Map< + Pick, + Map + >(); + for (const [filePath, constants] of repo) { + const provider = providerForFile(filePath); + if (!provider?.prepareRouteConstants) continue; + let slice = slices.get(provider); + if (!slice) { + slice = new Map(); + slices.set(provider, slice); + } + slice.set(filePath, constants); + } + for (const [provider, slice] of slices) { + provider.prepareRouteConstants?.(slice); + } +} + const DEFAULTS: Pick = { mroStrategy: 'first-wins', }; diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 25b1daa18..dafe3f843 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -19,6 +19,7 @@ import { extractJavaModuleConstants, foldJavaOperands, isJavaConstantFile, + prepareJavaRouteConstants, } from '../route-extractors/java-const-resolver.js'; import { javaExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; @@ -239,11 +240,10 @@ export const javaProvider = defineLanguage({ // the group still published the contract). moduleConstantHeuristic: (content) => isJavaConstantFile(content) || - // `import com.winning.opt.common.ApiPaths;` — ANY class import can bind a - // constant ref (`ApiPaths.X` at an annotation site), so gate on the - // general import shape, not on the imported name. Ingestion-only: this - // side needs the importing controller's own import table, which the group - // side instead derives lazily from the tree it already holds. - /\bimport\s+(?:static\s+)?[\w.]+\s*;/.test(content), + // Class imports and static (including on-demand) imports can bind a + // constant ref. Ordinary `import a.b.*;` is not a Java type import and is + // not expanded by extractJavaModuleConstants, so it must not harvest. + /\bimport\s+(?:static\s+[\w.]+(?:\.\*)?|[\w.]+)\s*;/.test(content), + prepareRouteConstants: prepareJavaRouteConstants, foldRoutePathOperands: foldJavaOperands, }); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 352fd0f2b..beaf58184 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -89,10 +89,9 @@ import type { ExtractedRouterModuleAlias, } from '../route-extractors/fastapi-router-bindings.js'; import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js'; -import { - resolveOperands, - type ModuleConstants, -} from '../route-extractors/python-const-resolver.js'; +import { resolveOperands } from '../route-extractors/python-const-resolver.js'; +import type { ModuleConstants } from '../route-extractors/constant-resolver.js'; +import { prepareRouteConstantsByProvider } from '../language-provider.js'; import { resolveInheritedSpringRoutes, type SharedSpringType, @@ -1273,6 +1272,10 @@ export async function runChunkedParseAndResolve( for (const { filePath, constants } of allModuleConstants) { repoConstants.set(filePath, constants); } + // Let each language prepare only its own constants slice before folding. + // This is where deferred wildcard bindings can be materialized once per + // provider without naming a language in the shared parse phase. + prepareRouteConstantsByProvider(repoConstants, getProviderForFile); const resolvedRoutes: ExtractedDecoratorRoute[] = []; let skipped = 0; for (const dr of allDecoratorRoutes) { diff --git a/gitnexus/src/core/ingestion/route-extractors/constant-resolver.ts b/gitnexus/src/core/ingestion/route-extractors/constant-resolver.ts index 9b70b458c..8605f5a02 100644 --- a/gitnexus/src/core/ingestion/route-extractors/constant-resolver.ts +++ b/gitnexus/src/core/ingestion/route-extractors/constant-resolver.ts @@ -66,6 +66,30 @@ export interface ModuleConstants { readonly literals: Map; readonly exprs: Map; readonly imports: Map; + /** + * On-demand (wildcard) import specifiers whose bound member names could not + * be enumerated at extract time — Java `import static a.b.C.*;`, Python + * `from m import *`. The agnostic fold never reads this (it has no way to + * enumerate a target module's exports); a language binding materializes the + * promised bindings from a repo-wide map after extraction — see + * `expandJavaWildcardStaticImports` in the Java binding — so they resolve + * through the plain `imports` path with no special cases in the fold. + */ + readonly wildcardImports?: readonly string[]; +} + +const NO_UNFOLDABLE_DECLARATIONS: ReadonlySet = new Set(); + +/** + * Declaration keys a language extractor found but could not fold. Java and + * Kotlin both use this metadata to keep lower-priority imports from replacing + * a real local declaration; other producers simply return the empty set. + */ +export function unfoldableDeclarationsOf(mc: ModuleConstants | undefined): ReadonlySet { + const declarations = ( + mc as (ModuleConstants & { readonly unfoldableDeclarations?: unknown }) | undefined + )?.unfoldableDeclarations; + return declarations instanceof Set ? declarations : NO_UNFOLDABLE_DECLARATIONS; } /** Repo-wide map: unique file key (e.g. `app/constants.py`) → that file's diff --git a/gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts b/gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts index 0ca5e71e2..95f9f4710 100644 --- a/gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts +++ b/gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts @@ -49,6 +49,7 @@ import { type ModuleConstants, type Operand, type RepoConstants, + unfoldableDeclarationsOf, } from './constant-resolver.js'; export type { @@ -58,6 +59,11 @@ export type { RepoConstants, } from './constant-resolver.js'; +export interface JavaModuleConstants extends ModuleConstants { + /** Declaration keys whose initializer exists but cannot be folded. */ + readonly unfoldableDeclarations: ReadonlySet; +} + /** * Cheap content gate: can this Java file DEFINE a string constant that a route * annotation might reference? @@ -140,10 +146,16 @@ export const resolveJavaImport: ImportResolver = (_importingFileKey, moduleSpec, const asPath = moduleSpec.replace(/\./g, '/'); const classFile = `${asPath}.java`; + // Compare in POSIX space: on Windows the repo keys can carry backslash + // separators, which would otherwise never match a '/'-joined class file + // (observed as 675 calls with zero hits on a backslash-keyed repo). + const toPosix = (p: string): string => p.replace(/\\/g, '/'); + // Exact package-path suffix match, unique or nothing. let hit: string | null = null; for (const key of repoKeys) { - if (key === classFile || key.endsWith(`/${classFile}`)) { + const posixKey = toPosix(key); + if (posixKey === classFile || posixKey.endsWith(`/${classFile}`)) { if (hit !== null) return null; // 2+ modules carry this FQN — unresolvable hit = key; } @@ -264,29 +276,53 @@ export function parseJavaConstOperands( * Last-wins in source order; a non-foldable rebind (`X = compute()`) drops X * to unresolvable rather than keeping a stale literal. */ -export function extractJavaModuleConstants(tree: Parser.Tree): ModuleConstants { +export function extractJavaModuleConstants(tree: Parser.Tree): JavaModuleConstants { const literals = new Map(); const exprs = new Map(); const imports = new Map(); + const unfoldableDeclarations = new Set(); + + // On-demand static imports (`import static a.b.C.*`) — expanded post-map + // by expandJavaWildcardStaticImports below. + const wildcardImports: string[] = []; // Pass 1: imports (both shapes). const walkImports = (node: Parser.SyntaxNode): void => { if (node.type === 'import_declaration') { // import a.b.C; | import static a.b.C; | import static a.b.C.F; + // import static a.b.C.*; — asterisk is a sibling of scoped_identifier + // (tree-sitter-java), not the last path segment. Same detection as + // import-decomposer.ts (`static-wildcard`). const isStatic = node.children.some((c) => c.type === 'static' && c.text === 'static'); - const scoped = node.children.find((c) => c.type === 'scoped_identifier'); + const isWildcard = node.children.some((c) => c.type === 'asterisk'); + const scoped = + node.children.find((c) => c.type === 'scoped_identifier') ?? + node.children.find((c) => c.type === 'identifier'); if (scoped) { const text = scoped.text; - const lastDot = text.lastIndexOf('.'); - const fqn = text.slice(0, lastDot); - const name = text.slice(lastDot + 1); - if (isStatic) { - // import static a.b.C.F → local F from module a.b.C, original F. - imports.set(name, { module: fqn, originalName: name }); + if (isStatic && isWildcard) { + // Class FQN only — members are materialized post-map. + if (text.length > 0 && !wildcardImports.includes(text)) { + wildcardImports.push(text); + } } else { - // import a.b.C → module IS the class FQN; originalName is the class - // simple name. resolveJavaImport maps `a.b.C` → `a/b/C.java`. - imports.set(name, { module: text, originalName: name }); + const lastDot = text.lastIndexOf('.'); + const fqn = text.slice(0, lastDot); + const name = text.slice(lastDot + 1); + if (isStatic) { + // Preserve the declaring type in the target lookup. Constants from + // multiple types share one file-level map, so a bare `F` could + // otherwise resolve to a sibling type's flattened field. + const declaringClass = fqn.slice(fqn.lastIndexOf('.') + 1); + imports.set(name, { + module: fqn, + originalName: `${declaringClass}.${name}`, + }); + } else { + // import a.b.C → module IS the class FQN; originalName is the class + // simple name. resolveJavaImport maps `a.b.C` → `a/b/C.java`. + imports.set(name, { module: text, originalName: name }); + } } } } @@ -343,6 +379,7 @@ export function extractJavaModuleConstants(tree: Parser.Tree): ModuleConstants { if (operands === null) { literals.delete(name); exprs.delete(name); + unfoldableDeclarations.add(name); // …and the static IMPORT of the same simple name. A local // `static final String` shadows `import static a.b.C.PATH` inside // that class (JLS 6.4.1), so the correct answer for a non-foldable @@ -363,9 +400,12 @@ export function extractJavaModuleConstants(tree: Parser.Tree): ModuleConstants { if (qname) { literals.delete(qname); exprs.delete(qname); + unfoldableDeclarations.add(qname); } continue; } + unfoldableDeclarations.delete(name); + if (qname) unfoldableDeclarations.delete(qname); const literalValue = operands.length === 1 && operands[0].kind === 'literal' ? (operands[0] as { value: string }).value @@ -436,7 +476,13 @@ export function extractJavaModuleConstants(tree: Parser.Tree): ModuleConstants { }; walkTypes(tree.rootNode, false); - return { literals, exprs, imports: imports as Map }; + return { + literals, + exprs, + imports: imports as Map, + wildcardImports, + unfoldableDeclarations, + }; } /** @@ -578,6 +624,7 @@ function computeJavaFold( if (literal !== undefined) return literal; const expr = mc.exprs.get(name); if (expr !== undefined) return foldOperands(fileKey, expr, state, depth + 1); + if (unfoldableDeclarationsOf(mc).has(name)) return null; const imp = mc.imports.get(name); if (imp !== undefined) { const targetFile = resolveJavaImport(fileKey, imp.module, constantKeys); @@ -628,3 +675,117 @@ export function foldJavaOperands( const out = foldOperands(fileKey, operands, newFoldState(repo), 0); return out === '' ? null : out; } + +/** + * Constant-defining file keys used by Java import resolution. + * + * Build once per repo pass. Recomputing this set for every wildcard-importing + * controller makes expansion quadratic in controller count. + */ +export function buildJavaConstantKeys(repo: RepoConstants): ReadonlySet { + const repoKeys = new Set(); + for (const [key, target] of repo) { + if (target.literals.size > 0 || target.exprs.size > 0) repoKeys.add(key); + } + return repoKeys; +} + +/** Direct static members owned by `classSimple`, excluding nested-type members. */ +function directJavaMembers(target: ModuleConstants, classSimple: string): Set { + const members = new Set(); + const prefix = `${classSimple}.`; + for (const map of [target.literals, target.exprs]) { + for (const key of map.keys()) { + if (!key.startsWith(prefix)) continue; + const member = key.slice(prefix.length); + if (member.length > 0 && !member.includes('.')) members.add(member); + } + } + return members; +} + +export interface JavaConstantIndex { + readonly keys: ReadonlySet; + /** Every resolvable path suffix as a dotted module name; null means ambiguous. */ + readonly byModule: ReadonlyMap; + /** Direct members by constant-defining file, built once for all importers. */ + readonly membersByFile: ReadonlyMap>; +} + +/** + * Build all Java import suffixes once, turning repeated wildcard target lookup + * from O(importers × constant files) into O(path segments + importers). + */ +export function buildJavaConstantIndex(repo: RepoConstants): JavaConstantIndex { + const keys = buildJavaConstantKeys(repo); + const byModule = new Map(); + const membersByFile = new Map>(); + for (const key of keys) { + const normalized = key.replace(/\\/g, '/').replace(/^\.\//, ''); + if (!normalized.endsWith('.java')) continue; + const segments = normalized.slice(0, -'.java'.length).split('/'); + const classSimple = segments[segments.length - 1]; + const constants = repo.get(key); + if (constants) membersByFile.set(key, directJavaMembers(constants, classSimple)); + for (let start = 0; start < segments.length; start++) { + const moduleName = segments.slice(start).join('.'); + const existing = byModule.get(moduleName); + if (existing === undefined) byModule.set(moduleName, key); + else if (existing !== key) byModule.set(moduleName, null); + } + } + return { keys, byModule, membersByFile }; +} + +export function expandJavaWildcardStaticImports( + mc: ModuleConstants, + _fileKey: string, + repo: RepoConstants, + index: JavaConstantIndex = buildJavaConstantIndex(repo), +): ModuleConstants { + const wildcards = mc.wildcardImports; + if (!wildcards || wildcards.length === 0) return mc; + // Resolve targets against constant-DEFINING files only. Ingestion's harvest + // also admits import-only files; measuring uniqueness over every key made + // a duplicate empty FQN floor ingestion to skip while group still folded + // (#2980 R4). + const explicitImports = new Set(mc.imports.keys()); + const pending = new Map(); + for (const fqn of wildcards) { + const targetKey = index.byModule.get(fqn) ?? null; + if (targetKey === null) continue; + const classSimple = fqn.slice(fqn.lastIndexOf('.') + 1); + const members = index.membersByFile.get(targetKey); + if (!members) continue; + for (const name of members) { + // Same-file declarations and explicit imports have higher precedence + // than on-demand imports. An unfoldable declaration must remain a skip, + // not be resurrected from a wildcard target. + if ( + mc.literals.has(name) || + mc.exprs.has(name) || + unfoldableDeclarationsOf(mc).has(name) || + explicitImports.has(name) + ) { + continue; + } + const binding = { module: fqn, originalName: `${classSimple}.${name}` }; + const previous = pending.get(name); + if (previous === undefined) pending.set(name, binding); + else if (previous !== null && previous.module !== fqn) pending.set(name, null); + } + } + for (const [name, binding] of pending) { + if (binding !== null) mc.imports.set(name, binding); + } + return mc; +} + +/** Prepare every Java constants entry with one shared suffix index. */ +export function prepareJavaRouteConstants(repo: RepoConstants): JavaConstantIndex { + const index = buildJavaConstantIndex(repo); + for (const [fileKey, mc] of repo) { + expandJavaWildcardStaticImports(mc, fileKey, repo, index); + } + return index; +} diff --git a/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts b/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts index ceb1f92e1..a14dd9ca0 100644 --- a/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts +++ b/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts @@ -95,6 +95,7 @@ * @PostMapping(ApiPaths.ORDERS) // qualified * @PostMapping(com.example.app.api.ApiPaths.ORDERS) // FQN-qualified * @PostMapping(ORDERS) // single-name import + * @PostMapping(ORDERS) after import com.example.api.* // package-star import * @PostMapping(ApiPaths.BASE + "/orders") // inline concat * * Which ANNOTATIONS count as routes is a separate question this module has no @@ -138,6 +139,7 @@ import type Parser from 'tree-sitter'; import { unquoteSpringLiteral } from './spring-shared.js'; import { MAX_FOLD_LENGTH, + unfoldableDeclarationsOf, type ImportBinding, type ModuleConstants, type Operand, @@ -150,6 +152,7 @@ export type { Operand, RepoConstants, } from './constant-resolver.js'; +export { unfoldableDeclarationsOf } from './constant-resolver.js'; /** * What {@link extractKotlinModuleConstants} returns: the agnostic @@ -177,6 +180,8 @@ export interface KotlinModuleConstants extends ModuleConstants { readonly packageName: string; /** Declaration keys whose initializer cannot be folded. */ readonly unfoldableDeclarations: ReadonlySet; + /** Top-level properties and types that shadow lower-priority star imports. */ + readonly topLevelDeclarations: ReadonlySet; } /** @@ -189,15 +194,12 @@ function declaredPackageOf(mc: ModuleConstants | undefined): string | null { return typeof declared === 'string' ? declared : null; } -const NO_UNFOLDABLE_DECLARATIONS: ReadonlySet = new Set(); +const NO_TOP_LEVEL_DECLARATIONS: ReadonlySet = new Set(); -/** - * Kotlin declaration keys known to exist but not fold, or an empty set when - * `mc` came from another language binding. - */ -export function unfoldableDeclarationsOf(mc: ModuleConstants | undefined): ReadonlySet { - const declarations = (mc as KotlinModuleConstants | undefined)?.unfoldableDeclarations; - return declarations instanceof Set ? declarations : NO_UNFOLDABLE_DECLARATIONS; +/** Kotlin top-level names known to shadow package-star imports. */ +function topLevelDeclarationsOf(mc: ModuleConstants | undefined): ReadonlySet { + const declarations = (mc as KotlinModuleConstants | undefined)?.topLevelDeclarations; + return declarations instanceof Set ? declarations : NO_TOP_LEVEL_DECLARATIONS; } /** Source extensions a Kotlin declaration can live in. */ @@ -443,6 +445,16 @@ export interface KotlinConstantIndex { /** Does this file contribute declarations to Kotlin import ambiguity? */ function contributesKotlinConstants(mc: ModuleConstants): boolean { + return ( + mc.literals.size > 0 || + mc.exprs.size > 0 || + unfoldableDeclarationsOf(mc).size > 0 || + topLevelDeclarationsOf(mc).size > 0 + ); +} + +/** Foldable or explicitly unfoldable constants that must live in index projections. */ +function hasIndexedConstants(mc: ModuleConstants): boolean { return mc.literals.size > 0 || mc.exprs.size > 0 || unfoldableDeclarationsOf(mc).size > 0; } @@ -459,6 +471,7 @@ function topLevelDeclarationNames(mc: ModuleConstants): Set { const dot = key.indexOf('.'); names.add(dot < 0 ? key : key.slice(0, dot)); } + for (const name of topLevelDeclarationsOf(mc)) names.add(name); return names; } @@ -509,6 +522,65 @@ export function buildKotlinConstantIndex(repo: RepoConstants): KotlinConstantInd return { repo, constantKeys, byPackage: mutablePackages, byFqn }; } +/** Read-only one-entry overlay without copying the repo-wide constant map. */ +class KotlinConstantOverlay implements ReadonlyMap { + readonly [Symbol.toStringTag] = 'KotlinConstantOverlay'; + + constructor( + private readonly base: RepoConstants, + private readonly overlayKey: string, + private readonly overlayValue: ModuleConstants, + ) {} + + get size(): number { + return this.base.size + (this.base.has(this.overlayKey) ? 0 : 1); + } + + get(key: string): ModuleConstants | undefined { + return key === this.overlayKey ? this.overlayValue : this.base.get(key); + } + + has(key: string): boolean { + return key === this.overlayKey || this.base.has(key); + } + + *entries(): MapIterator<[string, ModuleConstants]> { + let replaced = false; + for (const [key, value] of this.base) { + if (key === this.overlayKey) { + replaced = true; + yield [key, this.overlayValue]; + } else { + yield [key, value]; + } + } + if (!replaced) yield [this.overlayKey, this.overlayValue]; + } + + *keys(): MapIterator { + for (const [key] of this.entries()) yield key; + } + + *values(): MapIterator { + for (const [, value] of this.entries()) yield value; + } + + [Symbol.iterator](): MapIterator<[string, ModuleConstants]> { + return this.entries(); + } + + forEach( + callbackfn: ( + value: ModuleConstants, + key: string, + map: ReadonlyMap, + ) => void, + thisArg?: unknown, + ): void { + for (const [key, value] of this.entries()) callbackfn.call(thisArg, value, key, this); + } +} + /** * Add one scan-time file without rebuilding the base index when it only imports * constants. A newly discovered declaration is rare and rebuilds once for that @@ -519,10 +591,15 @@ export function overlayKotlinConstantIndex( fileKey: string, mc: ModuleConstants, ): KotlinConstantIndex { + // Same-file shadows are read from `mc` itself. Rebuild only when this key's + // constant projections would change; a controller with a class name but no + // foldable constants stays on the overlay so scan stays linear. + const existing = index.repo.get(fileKey); + if (!hasIndexedConstants(mc) && (!existing || !hasIndexedConstants(existing))) { + return { ...index, repo: new KotlinConstantOverlay(index.repo, fileKey, mc) }; + } const repo = new Map(index.repo); - const replacing = repo.has(fileKey); repo.set(fileKey, mc); - if (!replacing && !contributesKotlinConstants(mc)) return { ...index, repo }; return buildKotlinConstantIndex(repo); } @@ -858,9 +935,9 @@ function declaredPackage(root: Parser.SyntaxNode): string { } /** - * Extract the declared package, file-level string constants and import bindings - * of one parsed Kotlin file into the {@link KotlinModuleConstants} shape the - * resolver consumes. + * Extract the declared package, file-level string constants, named imports and + * package-star import scopes of one parsed Kotlin file into the + * {@link KotlinModuleConstants} shape the resolver consumes. * * Constants come from the three carriers Kotlin allows a caller to reach without * an instance: file top level, `object` members, and `companion object` members. @@ -907,21 +984,25 @@ export function extractKotlinModuleConstants(tree: Parser.Tree): KotlinModuleCon const literals = new Map(); const exprs = new Map(); const imports = new Map(); + const wildcardImports: string[] = []; const unfoldableDeclarations = new Set(); + const topLevelDeclarations = new Set(); // Pass 1: imports. const walkImports = (node: Parser.SyntaxNode): void => { if (node.type === 'import_header') { - // `import a.b.*` binds no single name — nothing to key the fold on, and - // guessing which package member a bare reference came from is exactly the - // wrong answer. Skipped, so such a reference floors to skip. const isWildcard = node.children.some((c) => c.type === 'wildcard_import'); const identifier = node.children.find((c) => c.type === 'identifier'); - if (!isWildcard && identifier) { + if (identifier) { const segments = identifier.namedChildren .filter((c) => c.type === 'simple_identifier') .map((c) => unquoteKotlinIdentifier(c.text)); - if (segments.length >= 2) { + if (isWildcard) { + // Package star (`pkg.*`) or classifier star (`Type.*`). Resolution + // decides which reading the specifier actually names. + const scope = segments.join('.'); + if (scope.length > 0 && !wildcardImports.includes(scope)) wildcardImports.push(scope); + } else if (segments.length >= 2) { const spec = segments.join('.'); const originalName = segments[segments.length - 1]; const aliasNode = node.children @@ -999,6 +1080,24 @@ export function extractKotlinModuleConstants(tree: Parser.Tree): KotlinModuleCon const withScope = (scope: string | null, scopes: readonly string[]): readonly string[] => scope === null || scopes[0] === scope ? scopes : [scope, ...scopes]; + // Package-star imports have lower priority than declarations in this file, + // including declarations the string-constant extractor intentionally does + // not harvest (a `var`, plain class, or unfoldable property). Record their + // names separately so a star import cannot turn one of those shadows into a + // false route constant. + for (const child of tree.rootNode.children ?? []) { + if (child.type === 'property_declaration') { + const declaration = child.children.find((c) => c.type === 'variable_declaration'); + const name = declaration?.namedChildren.find((c) => c.type === 'simple_identifier'); + if (name) topLevelDeclarations.add(unquoteKotlinIdentifier(name.text)); + continue; + } + if (child.type === 'object_declaration' || child.type === 'class_declaration') { + const name = child.children.find((c) => c.type === 'type_identifier'); + if (name) topLevelDeclarations.add(unquoteKotlinIdentifier(name.text)); + } + } + const walkDeclarations = ( node: Parser.SyntaxNode, enclosingType: string | null, @@ -1103,8 +1202,10 @@ export function extractKotlinModuleConstants(tree: Parser.Tree): KotlinModuleCon literals, exprs, imports, + wildcardImports, packageName: declaredPackage(tree.rootNode), unfoldableDeclarations, + topLevelDeclarations, }; } @@ -1211,6 +1312,87 @@ function resolveImportedName( return resolveWithState(owner.fileKey, `${owner.localName}.${imp.originalName}`, state, depth); } +/** + * Resolve one name contributed by Kotlin star imports. + * + * Star imports have lower priority than local declarations and explicit + * imports; callers enforce that ordering before reaching this helper. A name + * must identify one declaration across every imported scope. Two stars + * exporting the same name, a duplicated FQN, or a package and a classifier + * that disagree on the target, floor to null rather than guessing. + * + * A specifier is tried as a package (`import pkg.*`) and as a classifier + * (`import Type.*` — object, class, or companion members). Kotlin allows both. + */ +function resolveKotlinWildcardImportTarget( + mc: ModuleConstants, + name: string, + index: KotlinConstantIndex, +): KotlinImportTarget | null { + const scopes = mc.wildcardImports; + if (!scopes || scopes.length === 0) return null; + + let resolved: KotlinImportTarget | null = null; + for (const rawScope of scopes) { + const scope = unquoteKotlinDottedName(rawScope); + const candidates: KotlinImportTarget[] = []; + + const bucket = index.byPackage.get(scope); + if (bucket?.declarers.has(name)) { + const fileKey = bucket.declarers.get(name); + if (fileKey === null || fileKey === undefined) return null; + candidates.push({ fileKey, localName: name }); + } + + const owner = index.byFqn.get(scope); + if (owner === null) return null; + if (owner !== undefined) { + const localName = `${owner.localName}.${name}`; + const target = index.repo.get(owner.fileKey); + if ( + target && + (target.literals.has(localName) || + target.exprs.has(localName) || + unfoldableDeclarationsOf(target).has(localName)) + ) { + candidates.push({ fileKey: owner.fileKey, localName }); + } + } + + for (const candidate of candidates) { + if ( + resolved !== null && + (resolved.fileKey !== candidate.fileKey || resolved.localName !== candidate.localName) + ) { + return null; + } + resolved = candidate; + } + } + return resolved; +} + +/** + * Resolve a top-level name visible from the importing file's own package. + * `undefined` means the package does not declare the name; `null` means it is + * ambiguous and must floor rather than fall through to a star import. + */ +function resolveKotlinSamePackageTarget( + fileKey: string, + name: string, + index: KotlinConstantIndex, +): KotlinImportTarget | null | undefined { + const packageName = declaredPackageOf(index.repo.get(fileKey)); + if (packageName === null) return undefined; + const bucket = index.byPackage.get(packageName); + if (!bucket?.declarers.has(name)) return undefined; + const declaringFile = bucket.declarers.get(name); + if (declaringFile === null || declaringFile === undefined) return null; + // Same-file literals, expressions, and shadows are handled before this step. + if (declaringFile === fileKey) return undefined; + return { fileKey: declaringFile, localName: name }; +} + function computeKotlinFold( fileKey: string, name: string, @@ -1218,6 +1400,8 @@ function computeKotlinFold( depth: number, ): string | null { const { repo } = state.index; + const mc = repo.get(fileKey); + if (!mc) return null; // Qualified reference (`ApiPaths.ORDERS`): constants and imports are keyed by // their IN-FILE name, so a dotted name never hits directly. Split head.tail, // resolve the head through the importing file's type import, then look the @@ -1239,6 +1423,28 @@ function computeKotlinFold( // uses the declaring type's real name. return resolveWithState(target.fileKey, `${target.localName}.${tail}`, state, depth + 1); } + // A same-file top-level declaration outranks every star import. + if (!topLevelDeclarationsOf(mc).has(head)) { + const samePackageTarget = resolveKotlinSamePackageTarget(fileKey, head, state.index); + if (samePackageTarget === null) return null; + if (samePackageTarget !== undefined) { + return resolveWithState( + samePackageTarget.fileKey, + `${samePackageTarget.localName}.${tail}`, + state, + depth + 1, + ); + } + const wildcardTarget = resolveKotlinWildcardImportTarget(mc, head, state.index); + if (wildcardTarget !== null) { + return resolveWithState( + wildcardTarget.fileKey, + `${wildcardTarget.localName}.${tail}`, + state, + depth + 1, + ); + } + } // Un-imported qualified name (FQN form `com.example.app.api.ApiPaths.ORDERS`): // try the longest dotted prefix that resolves to a file. const parts = name.split('.'); @@ -1261,14 +1467,29 @@ function computeKotlinFold( // an operand may itself be a QUALIFIED reference (`X = ApiPaths.Y + "/tail"`) // and the core only knows bare names: it would look `ApiPaths.Y` up in maps // keyed by simple name, miss, and floor the whole chain to null. - const mc = repo.get(fileKey); - if (!mc) return null; const literal = mc.literals.get(name); if (literal !== undefined) return literal; const expr = mc.exprs.get(name); if (expr !== undefined) return foldOperands(fileKey, expr, state, depth + 1); const imp = mc.imports.get(name); if (imp !== undefined) return resolveImportedName(fileKey, imp, state, depth + 1); + // Any local declaration still shadows a lower-priority package-star import, + // including a var/plain type that is absent from the constant maps. + if (topLevelDeclarationsOf(mc).has(name) || unfoldableDeclarationsOf(mc).has(name)) return null; + const samePackageTarget = resolveKotlinSamePackageTarget(fileKey, name, state.index); + if (samePackageTarget === null) return null; + if (samePackageTarget !== undefined) { + return resolveWithState( + samePackageTarget.fileKey, + samePackageTarget.localName, + state, + depth + 1, + ); + } + const wildcardTarget = resolveKotlinWildcardImportTarget(mc, name, state.index); + if (wildcardTarget !== null) { + return resolveWithState(wildcardTarget.fileKey, wildcardTarget.localName, state, depth + 1); + } return null; } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 3f3f70395..f5cd71cbc 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -3086,7 +3086,12 @@ const processFileGroup = ( // without booting a worker. if (provider.extractModuleConstants && shouldHarvestModuleConstants(provider, parseContent)) { const constants = provider.extractModuleConstants(tree); - if (constants.literals.size > 0 || constants.exprs.size > 0 || constants.imports.size > 0) { + if ( + constants.literals.size > 0 || + constants.exprs.size > 0 || + constants.imports.size > 0 || + (constants.wildcardImports?.length ?? 0) > 0 + ) { (result.moduleConstants ??= []).push({ filePath: file.path, constants }); } } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index d8ce3ce1d..ecd7b4466 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -685,7 +685,14 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // cache can replay stale names and declaration metadata. // 85 -> 86: Kotlin interface property accessors now record isAbstract on the // synthetic Method. A warm v85 cache replays them as concrete. -const SCHEMA_BUMP = 86; +// 86 -> 87: ModuleConstants gained wildcardImports (static-import-asterisk +// materialization) — a warm v86 cache has no wildcard bindings, so folding +// would skip them and drop wildcard-imported route constants. origin/main at +// allocation is 86 (#2885). +// 87 -> 88: Java ModuleConstants now preserves unfoldable declaration names +// across worker/cache replay so wildcard expansion cannot resurrect an imported +// member hidden by a local field. A warm v87 cache lacks that shadow metadata. +const SCHEMA_BUMP = 88; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/unit/group/java-const-route-parity.test.ts b/gitnexus/test/unit/group/java-const-route-parity.test.ts index cfdf652a3..62c028e8e 100644 --- a/gitnexus/test/unit/group/java-const-route-parity.test.ts +++ b/gitnexus/test/unit/group/java-const-route-parity.test.ts @@ -26,6 +26,7 @@ import type { HttpDetection } from '../../../src/core/group/extractors/http-patt import { extractSpringRoutes } from '../../../src/core/ingestion/route-extractors/spring.js'; import { javaProvider } from '../../../src/core/ingestion/languages/java.js'; import { + expandJavaWildcardStaticImports, extractJavaModuleConstants, foldJavaOperands, type RepoConstants, @@ -62,6 +63,9 @@ function ingestionRoutes(files: Record): string[] { for (const [rel, src] of Object.entries(files)) { repo.set(rel, extractJavaModuleConstants(parse(src))); } + for (const [rel, mc] of repo) { + expandJavaWildcardStaticImports(mc, rel, repo); + } const out: string[] = []; for (const [rel, src] of Object.entries(files)) { for (const route of extractSpringRoutes(parse(src), rel, 0)) { @@ -199,6 +203,84 @@ public class OrderController { expect(ingestionRoutes(files)).toEqual(['GET /api/v1/orders']); }); + it('resolves a wildcard-static-imported mapping on both sides', () => { + const files = { + [CONSTS]: CONSTS_SRC, + [CTL]: `package com.example; +import static com.example.ApiPaths.*; +public class OrderController { + @GetMapping(ORDERS) + public void list() {} +}`, + }; + expect(javaProvider.moduleConstantHeuristic?.(files[CTL])).toBe(true); + expect(groupProviders(files)).toEqual(['GET /api/v1/orders']); + expect(ingestionRoutes(files)).toEqual(groupProviders(files)); + }); + + it('keeps an unfoldable local field above a wildcard member on both sides', () => { + const files = { + [CONSTS]: CONSTS_SRC, + [CTL]: `package com.example; +import static com.example.ApiPaths.*; +public class OrderController { + static final String ORDERS = runtimePath(); + @GetMapping(ORDERS) + public void list() {} +}`, + }; + expect(groupProviders(files)).toEqual([]); + expect(ingestionRoutes(files)).toEqual([]); + }); + + it('imports only members owned by the wildcard target type on both sides', () => { + const files = { + [CONSTS]: `package com.example; +public class ApiPaths { public static final String ORDERS = "/right"; } +class Other { public static final String ORDERS = "/wrong"; }`, + [CTL]: `package com.example; +import static com.example.ApiPaths.*; +public class OrderController { + @GetMapping(ORDERS) + public void list() {} +}`, + }; + expect(groupProviders(files)).toEqual(['GET /right']); + expect(ingestionRoutes(files)).toEqual(['GET /right']); + }); + + it('floors duplicate wildcard members on both sides', () => { + const files = { + 'src/main/java/a/A.java': `package a; +public class A { public static final String ROUTE = "/a"; }`, + 'src/main/java/b/B.java': `package b; +public class B { public static final String ROUTE = "/b"; }`, + [CTL]: `package com.example; +import static a.A.*; +import static b.B.*; +public class OrderController { + @GetMapping(ROUTE) + public void list() {} +}`, + }; + expect(groupProviders(files)).toEqual([]); + expect(ingestionRoutes(files)).toEqual([]); + }); + + it('does not fold a type-qualified ref from a static wildcard alone', () => { + const files = { + [CONSTS]: CONSTS_SRC, + [CTL]: `package com.example; +import static com.example.ApiPaths.*; +public class OrderController { + @GetMapping(ApiPaths.ORDERS) + public void list() {} +}`, + }; + expect(groupProviders(files)).toEqual([]); + expect(ingestionRoutes(files)).toEqual([]); + }); + it('leaves literal routes unchanged with no constant map at all', () => { const files = { [CTL]: `package com.example; diff --git a/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts b/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts index 93c44d6cf..e38e05079 100644 --- a/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts +++ b/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts @@ -138,6 +138,82 @@ class OrderController { ).toEqual(['GET /api/v1/orders']); }); + it('folds declarations imported through a package star', () => { + expect( + providers({ + [CONSTS]: `package com.example.app.api + +const val ORDERS = "/api/v1/orders" +object ApiPaths { + const val ITEMS = "/api/v1/items" +} +`, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.* + +@RestController +class OrderController { + @GetMapping(ORDERS) + fun list() {} + + @GetMapping(ApiPaths.ITEMS) + fun items() {} +} +`, + }), + ).toEqual(['GET /api/v1/items', 'GET /api/v1/orders']); + }); + + it('folds object members imported through a classifier star', () => { + expect( + providers({ + [CONSTS]: CONSTS_SRC, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths.* + +@RestController +class OrderController { + @GetMapping(ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('prefers same-package sibling declarations over package-star imports', () => { + expect( + providers({ + [CONSTS]: `package com.example.app.api +const val ORDERS = "/imported" +object ApiPaths { + const val ITEMS = "/imported/items" +} +`, + 'src/main/kotlin/com/example/app/web/LocalPaths.kt': `package com.example.app.web +const val ORDERS = "/local" +object ApiPaths { + const val ITEMS = "/local/items" +} +`, + [CONTROLLER]: `package com.example.app.web +import com.example.app.api.* + +@RestController +class OrderController { + @GetMapping(ORDERS) + fun list() {} + + @GetMapping(ApiPaths.ITEMS) + fun items() {} +} +`, + }), + ).toEqual(['GET /local', 'GET /local/items']); + }); + it('folds a constant imported through a nested object', () => { expect( providers({ diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 2be28aee4..6ee5651f1 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -244,12 +244,12 @@ describe('PARSE_CACHE_VERSION', () => { // collided, because each re-checked once and neither re-checked after the // other moved — which is why the rule is re-applied AT MERGE, not when the // number is picked. - it('pins SCHEMA_BUMP to 86 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(86); + it('pins SCHEMA_BUMP to 88 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(88); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, - 82, 83, 84, 85, + 82, 83, 84, 85, 86, 87, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/java-route-const-pipeline-e2e.test.ts b/gitnexus/test/unit/java-route-const-pipeline-e2e.test.ts index 4370edc31..a405de77b 100644 --- a/gitnexus/test/unit/java-route-const-pipeline-e2e.test.ts +++ b/gitnexus/test/unit/java-route-const-pipeline-e2e.test.ts @@ -20,11 +20,12 @@ * src/main/java/com/example/UserController.java — @RequestMapping prefix + * @PostMapping(ApiPaths.X) + * FQN form + concat over a - * static-imported bare ref + * named static import + + * wildcard static import * * Assertions (both runs): * - the emitted Route node carries the FOLDED literal path, not the expr; - * - ALL THREE non-literal shapes survive (qualified, FQN-qualified, concat); + * - ALL FOUR non-literal shapes survive (qualified, FQN-qualified, wildcard, concat); * - a phantom `POST ` / empty path never appears (skip floor); * - the warm run yields the IDENTICAL route set AND is a genuine replay * (`usedWorkerPool === false`) — the harvest result survives the @@ -98,6 +99,7 @@ const USER_CONTROLLER = `package com.example; import com.example.common.ApiPaths; import static com.example.common.ApiPaths.V1; +import static com.example.common.ApiPaths.*; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -114,6 +116,10 @@ public class UserController { @GetMapping(com.example.common.ApiPaths.ORDERS) public void list() {} + // Bare ref materialized by the provider's repo-wide wildcard prep hook. + @GetMapping(ORDERS) + public void listViaWildcard() {} + // Inline concat with a STATIC-IMPORTED bare ref — the shape this fixture // used to only claim: it spelled the operand as the full FQN chain, which // just re-tested the FQN branch above, so bare-name resolution through the @@ -202,8 +208,8 @@ maybeDescribe('#2980 provider-hook constant harvest — real pipeline (cold + wa const paths = routes.map((r) => r.path).sort(); expect(paths).toContain('/api/v1/users'); // qualified ref via import expect(paths).toContain('/api/v1/orders'); // FQN multi-segment chain - // The concat route folds to the same literal as the FQN route. - expect(paths.filter((p) => p === '/api/v1/orders').length).toBeGreaterThanOrEqual(2); + // FQN, wildcard, and concat routes fold to the same literal. + expect(paths.filter((p) => p === '/api/v1/orders').length).toBeGreaterThanOrEqual(3); // Skip floor: no phantom empty/raw-expr paths. for (const p of paths) { expect(p.length).toBeGreaterThan(1); diff --git a/gitnexus/test/unit/java-route-const-resolver.test.ts b/gitnexus/test/unit/java-route-const-resolver.test.ts index 0b628f52e..b1a23daa1 100644 --- a/gitnexus/test/unit/java-route-const-resolver.test.ts +++ b/gitnexus/test/unit/java-route-const-resolver.test.ts @@ -34,6 +34,7 @@ import { describe, expect, it } from 'vitest'; import Parser from 'tree-sitter'; import Java from 'tree-sitter-java'; import { + expandJavaWildcardStaticImports, extractJavaModuleConstants, foldJavaOperands, isJavaConstantFile, @@ -147,7 +148,7 @@ describe('extractJavaModuleConstants', () => { const mcStatic = extractJavaModuleConstants(parse(STATIC_IMPORT_CONTROLLER)); expect(mcStatic.imports.get('DIAGNOSIS_SAVE_V1')).toEqual({ module: 'com.winning.opt.diagnosis.api.constants.ApiPathConstants', - originalName: 'DIAGNOSIS_SAVE_V1', + originalName: 'ApiPathConstants.DIAGNOSIS_SAVE_V1', }); }); @@ -792,3 +793,177 @@ describe('text blocks keep the skip floor', () => { expect(extractJavaModuleConstants(parse(src)).literals.has('X')).toBe(false); }); }); + +describe('wildcard static imports (`import static a.b.C.*`)', () => { + const WILDCARD_CONTROLLER = `package com.y; +import static com.x.ApiPaths.*; +public class C { + @PostMapping(SAVE) + public void save() {} +}`; + + it('records the class FQN and does not bind the class simple name as a field', () => { + const mc = extractJavaModuleConstants(parse(WILDCARD_CONTROLLER)); + expect(mc.wildcardImports).toEqual(['com.x.ApiPaths']); + expect(mc.imports.has('ApiPaths')).toBe(false); + expect(mc.imports.has('SAVE')).toBe(false); + }); + + it('materializes bare names and does not overwrite an explicit static import', () => { + const constants = extractJavaModuleConstants( + parse(`package com.x; +public class ApiPaths { + public static final String SAVE = "/api/v1/save"; + public static final String OTHER = "/api/v1/other"; +}`), + ); + const controller = extractJavaModuleConstants( + parse(`package com.y; +import static com.x.ApiPaths.SAVE; +import static com.x.ApiPaths.*; +public class C {}`), + ); + expect(controller.wildcardImports).toEqual(['com.x.ApiPaths']); + expect(controller.imports.get('SAVE')).toEqual({ + module: 'com.x.ApiPaths', + originalName: 'ApiPaths.SAVE', + }); + const repo: RepoConstants = new Map([ + ['src/com/x/ApiPaths.java', constants], + ['src/com/y/C.java', controller], + ]); + expandJavaWildcardStaticImports(controller, 'src/com/y/C.java', repo); + expect(controller.imports.get('SAVE')).toEqual({ + module: 'com.x.ApiPaths', + originalName: 'ApiPaths.SAVE', + }); + expect(controller.imports.get('OTHER')).toEqual({ + module: 'com.x.ApiPaths', + originalName: 'ApiPaths.OTHER', + }); + }); + + it('folds a wildcard-imported route constant', () => { + const repo = repoOf({ + 'src/main/java/com/x/ApiPaths.java': `package com.x; +public class ApiPaths { public static final String SAVE = "/api/v1/save"; }`, + 'src/main/java/com/y/C.java': WILDCARD_CONTROLLER, + }); + expandJavaWildcardStaticImports( + repo.get('src/main/java/com/y/C.java')!, + 'src/main/java/com/y/C.java', + repo, + ); + expect( + foldJavaOperands('src/main/java/com/y/C.java', [{ kind: 'ref', name: 'SAVE' }], repo), + ).toBe('/api/v1/save'); + }); + + it('unresolved wildcard target stays at the skip floor', () => { + const mc = extractJavaModuleConstants(parse(WILDCARD_CONTROLLER)); + const repo: RepoConstants = new Map([['src/com/y/C.java', mc]]); + expandJavaWildcardStaticImports(mc, 'src/com/y/C.java', repo); + expect(foldJavaOperands('src/com/y/C.java', [{ kind: 'ref', name: 'SAVE' }], repo)).toBeNull(); + }); + + it('does not resurrect a wildcard member shadowed by an unfoldable local field', () => { + const repo = repoOf({ + 'src/com/x/ApiPaths.java': `package com.x; +public class ApiPaths { public static final String SAVE = "/imported"; }`, + 'src/com/y/C.java': `package com.y; +import static com.x.ApiPaths.*; +public class C { + static final String SAVE = compute(); + @PostMapping(SAVE) public void save() {} +}`, + }); + const controller = repo.get('src/com/y/C.java')!; + expandJavaWildcardStaticImports(controller, 'src/com/y/C.java', repo); + expect(controller.imports.has('SAVE')).toBe(false); + expect(foldJavaOperands('src/com/y/C.java', [{ kind: 'ref', name: 'SAVE' }], repo)).toBeNull(); + }); + + it('binds only fields owned by the wildcard target type', () => { + const repo = repoOf({ + 'src/com/x/ApiPaths.java': `package com.x; +public class ApiPaths { public static final String ROUTE = "/right"; } +class Other { + public static final String ROUTE = "/wrong"; + public static final String OTHER_ONLY = "/other"; +}`, + 'src/com/y/C.java': `package com.y; +import static com.x.ApiPaths.*; +public class C {}`, + }); + const controller = repo.get('src/com/y/C.java')!; + expandJavaWildcardStaticImports(controller, 'src/com/y/C.java', repo); + expect(foldJavaOperands('src/com/y/C.java', [{ kind: 'ref', name: 'ROUTE' }], repo)).toBe( + '/right', + ); + expect(controller.imports.has('OTHER_ONLY')).toBe(false); + }); + + it('floors duplicate wildcard members while preserving an explicit import', () => { + const sources = { + 'src/a/A.java': `package a; +public class A { public static final String ROUTE = "/a"; }`, + 'src/b/B.java': `package b; +public class B { public static final String ROUTE = "/b"; }`, + }; + const ambiguous = repoOf({ + ...sources, + 'src/c/C.java': `package c; +import static a.A.*; +import static b.B.*; +public class C {}`, + }); + expandJavaWildcardStaticImports(ambiguous.get('src/c/C.java')!, 'src/c/C.java', ambiguous); + expect( + foldJavaOperands('src/c/C.java', [{ kind: 'ref', name: 'ROUTE' }], ambiguous), + ).toBeNull(); + + const explicit = repoOf({ + ...sources, + 'src/c/C.java': `package c; +import static a.A.*; +import static b.B.*; +import static b.B.ROUTE; +public class C {}`, + }); + expandJavaWildcardStaticImports(explicit.get('src/c/C.java')!, 'src/c/C.java', explicit); + expect(foldJavaOperands('src/c/C.java', [{ kind: 'ref', name: 'ROUTE' }], explicit)).toBe('/b'); + }); + + it('does not bind the wildcard target type name', () => { + const repo = repoOf({ + 'src/com/x/ApiPaths.java': `package com.x; +public class ApiPaths { public static final String SAVE = "/api/v1/save"; }`, + 'src/com/y/C.java': WILDCARD_CONTROLLER, + }); + const controller = repo.get('src/com/y/C.java')!; + expandJavaWildcardStaticImports(controller, 'src/com/y/C.java', repo); + expect(controller.imports.has('ApiPaths')).toBe(false); + expect( + foldJavaOperands('src/com/y/C.java', [{ kind: 'ref', name: 'ApiPaths.SAVE' }], repo), + ).toBeNull(); + expect(foldJavaOperands('src/com/y/C.java', [{ kind: 'ref', name: 'SAVE' }], repo)).toBe( + '/api/v1/save', + ); + }); + + it('admits wildcard-only files on the harvest heuristic', () => { + expect(javaProvider.moduleConstantHeuristic?.(WILDCARD_CONTROLLER)).toBe(true); + expect(javaProvider.moduleConstantHeuristic?.('import com.example.api.*;\nclass C {}')).toBe( + false, + ); + }); +}); + +describe('resolveJavaImport POSIX key compare', () => { + it('matches a backslash-keyed Windows repo path', () => { + const keys = new Set(['src\\main\\java\\com\\x\\ApiPath.java']); + expect(resolveJavaImport('a\\A.java', 'com.x.ApiPath', keys)).toBe( + 'src\\main\\java\\com\\x\\ApiPath.java', + ); + }); +}); diff --git a/gitnexus/test/unit/kotlin-route-const-resolver.test.ts b/gitnexus/test/unit/kotlin-route-const-resolver.test.ts index 5f9a193f8..1c2cd8b48 100644 --- a/gitnexus/test/unit/kotlin-route-const-resolver.test.ts +++ b/gitnexus/test/unit/kotlin-route-const-resolver.test.ts @@ -264,6 +264,7 @@ object Noise${i} { parse(`package com.example.app.web import com.example.app.api.ApiPaths +class OrdersController `), ); const overlaid = overlayKotlinConstantIndex(index, CONTROLLER_KEY, controller); @@ -297,6 +298,28 @@ object ApiPaths { expect(resolveKotlinImportWithIndex('com.example.app.api.ApiPaths', overlaid)).toBeNull(); }); + it('rebuilds when overlay replaces a contributing file with an import-only module', () => { + const repo = repoOf({ + [CONSTS_KEY]: CONSTS_SRC, + [CONTROLLER_KEY]: `package com.example.app.web +const val ORDERS = "/local" +`, + }); + const index = buildKotlinConstantIndex(repo); + expect(index.byPackage.get('com.example.app.web')?.declarers.get('ORDERS')).toBe( + CONTROLLER_KEY, + ); + const importOnly = extractKotlinModuleConstants( + parse(`package com.example.app.web + +import com.example.app.api.ApiPaths +`), + ); + const overlaid = overlayKotlinConstantIndex(index, CONTROLLER_KEY, importOnly); + expect(overlaid.byPackage.has('com.example.app.web')).toBe(false); + expect(overlaid.repo.get(CONTROLLER_KEY)).toBe(importOnly); + }); + it('prefers an exact declared package over a nested path with the same FQN', () => { const parentKey = 'src/main/kotlin/com/example/app/Parent.kt'; const childKey = 'src/main/kotlin/com/example/app/api/ApiPaths.kt'; @@ -414,17 +437,149 @@ import com.example.app.api.ApiPaths expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBe('/api/v1/orders'); }); - it('returns null for a wildcard import', () => { - // `import com.example.app.api.*` binds no single name, so there is nothing - // to key the fold on and no honest way to pick a package member. + it('resolves top-level declarations through a package-star import', () => { const repo = repoOf({ - [CONSTS_KEY]: CONSTS_SRC, + [CONSTS_KEY]: `package com.example.app.api + +const val ORDERS = "/api/v1/orders" +object ApiPaths { + const val ITEMS = "/api/v1/items" +} +`, [CONTROLLER_KEY]: `package com.example.app.web import com.example.app.api.* `, }); - expect(resolveKotlinConstant(CONTROLLER_KEY, 'ORDERS', repo)).toBeNull(); + const controller = repo.get(CONTROLLER_KEY); + expect(controller?.wildcardImports).toEqual(['com.example.app.api']); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ORDERS', repo)).toBe('/api/v1/orders'); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ITEMS', repo)).toBe('/api/v1/items'); + }); + + it('resolves object members through a classifier-star import', () => { + const repo = repoOf({ + [CONSTS_KEY]: CONSTS_SRC, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths.* +`, + }); + expect(repo.get(CONTROLLER_KEY)?.wildcardImports).toEqual(['com.example.app.api.ApiPaths']); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ORDERS', repo)).toBe('/api/v1/orders'); + // A classifier star imports members, not the type name itself. + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBeNull(); + }); + + it('keeps package-star collisions unresolved and lets explicit imports win', () => { + const repo = repoOf({ + 'src/one/Routes.kt': `package one +const val ROUTE = "/one" +`, + 'src/two/Routes.kt': `package two +const val ROUTE = "/two" +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import one.* +import two.* +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ROUTE', repo)).toBeNull(); + + const explicitRepo = repoOf({ + 'src/one/Routes.kt': `package one +const val ROUTE = "/one" +`, + 'src/two/Routes.kt': `package two +const val ROUTE = "/two" +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import one.* +import two.* +import two.ROUTE +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ROUTE', explicitRepo)).toBe('/two'); + }); + + it('lets local non-constant declarations shadow package-star imports', () => { + const repo = repoOf({ + [CONSTS_KEY]: `package com.example.app.api + +const val ROUTE = "/imported" +object ApiPaths { + const val ITEMS = "/imported/items" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.* + +var ROUTE = runtimeRoute() +class ApiPaths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ROUTE', repo)).toBeNull(); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ITEMS', repo)).toBeNull(); + }); + + it('resolves same-package sibling declarations before package-star imports', () => { + const repo = repoOf({ + 'src/web/Local.kt': `package com.example.app.web +const val ROUTE = "/local" +object ApiPaths { + const val ITEMS = "/local/items" +} +`, + [CONSTS_KEY]: `package com.example.app.api +const val ROUTE = "/imported" +object ApiPaths { + const val ITEMS = "/imported/items" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web +import com.example.app.api.* +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ROUTE', repo)).toBe('/local'); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ITEMS', repo)).toBe('/local/items'); + }); + + it('floors a same-package sibling type before package-star imports', () => { + const repo = repoOf({ + 'src/web/Local.kt': `package com.example.app.web +class ApiPaths +`, + [CONSTS_KEY]: `package com.example.app.api +object ApiPaths { + const val ITEMS = "/imported/items" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web +import com.example.app.api.* +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ITEMS', repo)).toBeNull(); + }); + + it('floors ambiguous same-package sibling declarations before package stars', () => { + const repo = repoOf({ + 'src/web/One.kt': `package com.example.app.web +const val ROUTE = "/one" +`, + 'src/web/Two.kt': `package com.example.app.web +const val ROUTE = "/two" +`, + [CONSTS_KEY]: `package com.example.app.api +const val ROUTE = "/imported" +`, + [CONTROLLER_KEY]: `package com.example.app.web +import com.example.app.api.* +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ROUTE', repo)).toBeNull(); }); it('returns null for an unknown reference rather than an empty path', () => { diff --git a/gitnexus/test/unit/language-provider-route-constants.test.ts b/gitnexus/test/unit/language-provider-route-constants.test.ts new file mode 100644 index 000000000..f510ef32f --- /dev/null +++ b/gitnexus/test/unit/language-provider-route-constants.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; +import { prepareRouteConstantsByProvider } from '../../src/core/ingestion/language-provider.js'; +import type { + ModuleConstants, + RepoConstants, +} from '../../src/core/ingestion/route-extractors/constant-resolver.js'; + +const constants = (): ModuleConstants => ({ + literals: new Map(), + exprs: new Map(), + imports: new Map(), +}); + +describe('prepareRouteConstantsByProvider', () => { + it('calls each hook once with only that provider’s files', () => { + const javaHook = vi.fn(); + const kotlinHook = vi.fn(); + const java = { prepareRouteConstants: javaHook }; + const kotlin = { prepareRouteConstants: kotlinHook }; + const python = {}; + const repo: RepoConstants = new Map([ + ['src/A.java', constants()], + ['src/B.java', constants()], + ['src/C.kt', constants()], + ['src/d.py', constants()], + ]); + + prepareRouteConstantsByProvider(repo, (filePath) => { + if (filePath.endsWith('.java')) return java; + if (filePath.endsWith('.kt')) return kotlin; + return python; + }); + + expect(javaHook).toHaveBeenCalledTimes(1); + expect([...javaHook.mock.calls[0][0].keys()]).toEqual(['src/A.java', 'src/B.java']); + expect(kotlinHook).toHaveBeenCalledTimes(1); + expect([...kotlinHook.mock.calls[0][0].keys()]).toEqual(['src/C.kt']); + }); +});