feat(jvm): fold Java static wildcards and Kotlin star imports for route constants (#3110)

* feat(java): expand wildcard static imports + POSIX-space import resolution

- `import static a.b.C.*` records the class FQN at extract time
  (ModuleConstants.wildcardImports) and expandJavaWildcardStaticImports
  materializes the bare-name bindings once the repo constants map exists,
  mirroring explicit single-member static imports. Wired in both the
  group-side prepareRepo fold and the ingestion-side parse-impl pass so
  the two query surfaces cannot diverge. Without this, wildcard-imported
  route constants (~693 routes on our monorepo) silently failed to fold
  and their provider contracts were dropped.
- resolveJavaImport compares in POSIX space (backslash-normalized repo
  keys) — on Windows the '/'-joined class file never matched a
  backslash-keyed repo (observed: 675 calls, zero hits).
- Wildcard bindings never overwrite single imports (a member shadowing
  its own wildcard is honored); unresolved wildcards degrade to the
  existing skip floor.

Rebased onto current main: the parse-worker gate this originally carried
is superseded by the provider moduleConstantHeuristic architecture; only
the resolver-side wildcard expansion and POSIX tolerance remain.

* chore(cache): claim SCHEMA_BUMP 83 (82 taken upstream by Spring lookup facts)

* style: prettier

* fix(java): make wildcard static imports actually resolve route constants

extractJavaModuleConstants never populated wildcardImports, so
`import static a.b.C.*;` was inert: the asterisk is a sibling of
scoped_identifier in tree-sitter-java, not a path segment. Record the
class FQN there and stop binding the class simple name as a field.

Wildcard-only files also fell through every harvest gate (the java
provider heuristic regex, the parse worker emit check, and the group
prepareRepo filter), so the constants never reached either layer.
Expansion now resolves targets against constant-defining files only,
keeping ingestion and group in parity (#2980 R4).

Adds unit coverage for extraction, expansion/shadowing, the unresolved
skip floor, the harvest heuristic, Windows path keys, and group <->
ingestion parity.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(kotlin): fold package-star imports for route constants

Kotlin `import pkg.*` now records star scopes and resolves unique
top-level names after local and explicit imports, matching the Java
wildcard path without accepting invalid object-star imports.

Reuse a Java constant-file suffix index across expansion so
wildcard materialization stays linear as both constants and
importers scale. Add named-vs-star benches and CI --check gates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(jvm): fold wildcard imports without shadowing locals or skipping same-package names

Keep Java unfoldable declarations from being resurrected by static wildcards, bind only the target type's members, and prefer Kotlin same-package names over package-star imports. Move repo-wide preparation behind a language-provider hook so the shared parse phase stays language-agnostic.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3110)

- Stop treating a Java static wildcard as a type import for qualified refs.
- Harvest only class and static (including on-demand) imports, not import pkg.*.
- Let harvested Kotlin top-level names shadow same-package star imports.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3110)

- Fold Kotlin classifier-star imports (`import Type.*`) the same way package stars already fold.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3110)

- Rebuild the Kotlin constant index when overlay replaces a contributing file.
- Document the wildcard shape in the Java pipeline e2e fixture.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: l.cx <l.cx@winning.com.cn>
Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ChunxueLi 2026-09-01 05:58:40 +08:00 committed by GitHub
parent 66b44afe8c
commit 72ff2b9453
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1500 additions and 62 deletions

View file

@ -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

View file

@ -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."
}

View file

@ -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);

View file

@ -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."
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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<LanguageProviderConfig, 'mroStrat
readonly isBuiltInName: (name: string) => 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<LanguageProvider, 'prepareRouteConstants'> | null,
): void {
const slices = new Map<
Pick<LanguageProvider, 'prepareRouteConstants'>,
Map<string, ModuleConstants>
>();
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<LanguageProvider, 'mroStrategy'> = {
mroStrategy: 'first-wins',
};

View file

@ -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,
});

View file

@ -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) {

View file

@ -66,6 +66,30 @@ export interface ModuleConstants {
readonly literals: Map<string, string>;
readonly exprs: Map<string, readonly Operand[]>;
readonly imports: Map<string, ImportBinding>;
/**
* 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<string> = new Set<string>();
/**
* 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<string> {
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

View file

@ -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<string>;
}
/**
* 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<string, string>();
const exprs = new Map<string, readonly Operand[]>();
const imports = new Map<string, ImportBinding>();
const unfoldableDeclarations = new Set<string>();
// 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<string, ImportBinding> };
return {
literals,
exprs,
imports: imports as Map<string, ImportBinding>,
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<string> {
const repoKeys = new Set<string>();
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<string> {
const members = new Set<string>();
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<string>;
/** Every resolvable path suffix as a dotted module name; null means ambiguous. */
readonly byModule: ReadonlyMap<string, string | null>;
/** Direct members by constant-defining file, built once for all importers. */
readonly membersByFile: ReadonlyMap<string, ReadonlySet<string>>;
}
/**
* 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<string, string | null>();
const membersByFile = new Map<string, ReadonlySet<string>>();
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<string, ImportBinding | null>();
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;
}

View file

@ -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<string>;
/** Top-level properties and types that shadow lower-priority star imports. */
readonly topLevelDeclarations: ReadonlySet<string>;
}
/**
@ -189,15 +194,12 @@ function declaredPackageOf(mc: ModuleConstants | undefined): string | null {
return typeof declared === 'string' ? declared : null;
}
const NO_UNFOLDABLE_DECLARATIONS: ReadonlySet<string> = new Set<string>();
const NO_TOP_LEVEL_DECLARATIONS: ReadonlySet<string> = new Set<string>();
/**
* 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<string> {
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<string> {
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<string> {
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<string, ModuleConstants> {
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<string> {
for (const [key] of this.entries()) yield key;
}
*values(): MapIterator<ModuleConstants> {
for (const [, value] of this.entries()) yield value;
}
[Symbol.iterator](): MapIterator<[string, ModuleConstants]> {
return this.entries();
}
forEach(
callbackfn: (
value: ModuleConstants,
key: string,
map: ReadonlyMap<string, ModuleConstants>,
) => 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<string, string>();
const exprs = new Map<string, readonly Operand[]>();
const imports = new Map<string, ImportBinding>();
const wildcardImports: string[] = [];
const unfoldableDeclarations = new Set<string>();
const topLevelDeclarations = new Set<string>();
// 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;
}

View file

@ -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 });
}
}

View file

@ -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

View file

@ -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, string>): 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;

View file

@ -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({

View file

@ -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);
}

View file

@ -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);

View file

@ -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',
);
});
});

View file

@ -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', () => {

View file

@ -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 providers 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']);
});
});