gitnexus 1.6.10 → 1.6.11-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/core/group/extractors/http-patterns/kotlin.js +522 -7
  2. package/dist/core/group/extractors/http-patterns/php.js +279 -11
  3. package/dist/core/index-freshness.d.ts +2 -2
  4. package/dist/core/index-freshness.js +13 -0
  5. package/dist/core/ingestion/parsing-processor.d.ts +2 -0
  6. package/dist/core/ingestion/parsing-processor.js +5 -0
  7. package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +3 -0
  8. package/dist/core/ingestion/pipeline-phases/parse-impl.js +7 -0
  9. package/dist/core/ingestion/pipeline-phases/parse.d.ts +4 -0
  10. package/dist/core/ingestion/pipeline.js +4 -1
  11. package/dist/core/ingestion/route-extractors/kotlin-const-resolver.d.ts +377 -0
  12. package/dist/core/ingestion/route-extractors/kotlin-const-resolver.js +1203 -0
  13. package/dist/core/ingestion/scope-extractor-bridge.js +8 -2
  14. package/dist/core/ingestion/scope-resolution/pipeline/phase.d.ts +2 -0
  15. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +14 -2
  16. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +2 -0
  17. package/dist/core/ingestion/scope-resolution/pipeline/run.js +11 -1
  18. package/dist/core/ingestion/scope-resolution/scope-extraction-failures.d.ts +14 -0
  19. package/dist/core/ingestion/scope-resolution/scope-extraction-failures.js +35 -0
  20. package/dist/core/ingestion/workers/parse-worker.d.ts +6 -0
  21. package/dist/core/ingestion/workers/parse-worker.js +7 -1
  22. package/dist/core/ingestion/workers/result-merge.js +4 -1
  23. package/dist/core/run-analyze.js +6 -0
  24. package/dist/mcp/local/local-backend.d.ts +2 -0
  25. package/dist/mcp/local/local-backend.js +29 -0
  26. package/dist/mcp/tools.js +6 -4
  27. package/dist/storage/parse-cache.js +6 -6
  28. package/dist/storage/repo-meta.d.ts +17 -1
  29. package/dist/storage/repo-meta.js +1 -1
  30. package/dist/types/pipeline.d.ts +4 -0
  31. package/package.json +1 -1
@@ -0,0 +1,1203 @@
1
+ /**
2
+ * Kotlin binding for the language-agnostic constant resolver (#2391 core).
3
+ *
4
+ * Supplies the two Kotlin-specific pieces — {@link resolveKotlinImport} (import
5
+ * specifier → file, honoring JVM package rules) and
6
+ * {@link extractKotlinModuleConstants} (tree → {@link ModuleConstants}) — plus
7
+ * the folding entry points {@link resolveKotlinConstant} and
8
+ * {@link foldKotlinOperands}, so callers stay language-oblivious.
9
+ *
10
+ * WHAT IS ACTUALLY SHARED WITH THE AGNOSTIC CORE. One value —
11
+ * {@link MAX_FOLD_LENGTH} — and five types. The core's own `resolveConstant` /
12
+ * `resolveOperands` are NOT called: the fold state machine below (cycle guard,
13
+ * success memo, depth caps, operand concatenation — roughly 200 of this file's
14
+ * lines) is a local fork, close enough to `java-const-resolver.ts`'s already
15
+ * forked copy that the two read as the same code with the language name
16
+ * swapped.
17
+ *
18
+ * That fork is a consequence, not an oversight. The core keys its maps by
19
+ * SIMPLE name, and a Kotlin operand can be a QUALIFIED reference at any
20
+ * position (`X = ApiPaths.Y + "/tail"`); handed to the core, `ApiPaths.Y` misses
21
+ * every map and floors the whole chain to null — see {@link computeKotlinFold},
22
+ * which resolves operands through the qualified-aware walk for exactly this
23
+ * reason. The import chase is Kotlin-specific too: a member import is spelled
24
+ * identically to a type import, so {@link resolveImportedName} has to try both
25
+ * readings, and the core exposes no hook for that. Java forked first on the same
26
+ * grounds. Teaching the core qualified names, and retiring both copies against
27
+ * it, is the standing follow-up; until then the honest description of this file
28
+ * is "a second fork", not "a binding over a shared fold".
29
+ *
30
+ * Kotlin shares the JVM package/import model with Java, so this binding mirrors
31
+ * `java-const-resolver.ts` in structure, naming and skip-floor discipline. The
32
+ * four places Kotlin genuinely differs are handled explicitly, not translated:
33
+ *
34
+ * 1. **Where a constant can live.** Java has one carrier (`static final` on a
35
+ * type). Kotlin has three: a top-level `const val`/`val`, a member of an
36
+ * `object`, and a member of a `companion object` — the last is referenced
37
+ * through its ENCLOSING class (`Holder.NAME`), not through `Companion`.
38
+ * 2. **No `String` type gate.** Kotlin infers property types, so
39
+ * `const val ORDERS = "/orders"` carries no type node to check. The
40
+ * initializer decides: anything {@link parseKotlinConstOperands} cannot fold
41
+ * to a string (a number, a call, a template) drops the constant.
42
+ * 3. **File names and directories are free.** `object ApiPaths` may live in
43
+ * `Constants.kt`, and a file's `package` need not match the directory it
44
+ * sits in, so a `<package>/<Name>.kt` PATH lookup is a convention and not a
45
+ * rule. The authority is each file's DECLARED `package`, which
46
+ * {@link extractKotlinModuleConstants} records and {@link resolveKotlinImport}
47
+ * requires an exact match on; the path is only a tie-break among files that
48
+ * already declare the right package.
49
+ * 4. **Member imports are unmarked.** Java spells them `import static a.b.C.F`;
50
+ * Kotlin writes `import a.b.C.F`, which is byte-identical to a type import
51
+ * of a class `F` in package `a.b.C`. Nothing in the syntax says which, so
52
+ * the fold tries both readings (see `resolveImportedName`) instead of
53
+ * guessing from casing.
54
+ * 5. **Any identifier may be backtick-quoted.** `` package com.example.`api` ``
55
+ * and `package com.example.api` are the SAME package to the compiler, and a
56
+ * keyword segment (`` com.example.`fun` ``) can only be spelled the quoted
57
+ * way. The grammar keeps the backticks in the node text, so every identifier is
58
+ * read through {@link unquoteKotlinIdentifier} before it becomes a map key
59
+ * or a lookup name — see that function for what a verbatim comparison cost.
60
+ *
61
+ * THREE PLACES THIS BINDING NO LONGER MIRRORS JAVA, each because the mirrored
62
+ * behavior was wrong rather than merely different, and each open as a Java
63
+ * follow-up rather than fixed here:
64
+ *
65
+ * * `java-const-resolver.ts` flattens nested types into one file-level
66
+ * namespace and argues the collision away — "qualified refs carry the class
67
+ * name, so nesting only matters for same-name fields, which flatten
68
+ * last-wins". The argument does not hold: the collision is one level BELOW
69
+ * the qualification, in the initializer, so a fully qualified `A.ROUTE` whose
70
+ * initializer names a bare sibling `BASE` still resolves through whichever
71
+ * same-named sibling was walked last. {@link extractKotlinModuleConstants}
72
+ * keys by Kotlin's own visibility instead.
73
+ * * Java's import resolution can lean on the `<package>/<Name>.java` layout the
74
+ * language enforces. Kotlin's cannot, and inferring the package from the path
75
+ * lets a path-suffix twin outrank the real declaration — so
76
+ * {@link resolveKotlinImport} reads the declared `package` instead.
77
+ * * Java's fold entry points take a file and a name, because a Java `static
78
+ * final` reachable by simple name is reachable that way from anywhere in the
79
+ * file. A Kotlin COMPANION member is not: it is bound unqualified only inside
80
+ * its enclosing class body. {@link foldKotlinOperands} therefore also takes
81
+ * the enclosing type chain of the reference site, which is what lets the
82
+ * binding answer a bare reference by Kotlin's scoping rather than by "whoever
83
+ * was walked last" — see {@link qualifyKotlinRefInEnclosingTypes}.
84
+ *
85
+ * Constant shapes this binding harvests:
86
+ *
87
+ * const val TOP_LEVEL = "/api/v1" // file top level
88
+ * object ApiPaths { // object member
89
+ * const val BASE = "/api/v1"
90
+ * val ORDERS = BASE + "/orders"
91
+ * }
92
+ * class Holder { companion object { const val H = "/h" } } // → Holder.H
93
+ *
94
+ * Reference shapes at annotation sites this binding resolves:
95
+ * @PostMapping(ApiPaths.ORDERS) // qualified
96
+ * @PostMapping(com.example.app.api.ApiPaths.ORDERS) // FQN-qualified
97
+ * @PostMapping(ORDERS) // single-name import
98
+ * @PostMapping(ApiPaths.BASE + "/orders") // inline concat
99
+ *
100
+ * Which ANNOTATIONS count as routes is a separate question this module has no
101
+ * say in — `spring-shared.ts` owns that map. Folding and annotation recognition
102
+ * compose; neither implies the other.
103
+ *
104
+ * Keying (parity with the Java and Python bindings): the repo map is keyed by
105
+ * unique POSIX file path, and an import that cannot be pinned to exactly one
106
+ * file returns null (skip floor), never a wrong path. A missing route is a
107
+ * missing fact; a wrongly folded one is a false edge in the graph. "Exactly one
108
+ * file" is decided from the DECLARED package, not from the path: a path is a
109
+ * repository-layout accident that any decoy directory can imitate, whereas the
110
+ * `package` header is the declaration the compiler itself resolves against.
111
+ *
112
+ * POSIX keys are a PRECONDITION this module cannot check cheaply, so it is
113
+ * enforced at the one boundary that produces them: `http-patterns/kotlin.ts`
114
+ * normalizes separators on both the write side (the `prepareRepo` map keys) and
115
+ * the read side (`scan`'s `fileRel`). It has to, because the orchestrator's file
116
+ * list comes from glob v13, which has no `posix: true` and joins with the
117
+ * platform separator — so on Windows the keys arrive backslashed and every
118
+ * `<pkg>/<Name>.kt` test in {@link resolveKotlinImport} would miss, silently
119
+ * disabling cross-file folding on that platform alone. Normalizing INSIDE this
120
+ * module instead cannot work: the resolver returns the key it matched, and a
121
+ * normalized return value would then miss in a map that was never normalized.
122
+ *
123
+ * WHERE THIS IS WIRED. Java reaches its binding from BOTH layers: the group
124
+ * extractor (`group/extractors/http-patterns/java.ts`) and the ingestion
125
+ * provider (`languages/java.ts`, via `extractModuleConstants` +
126
+ * `foldRoutePathOperands`). Kotlin is wired into the GROUP layer only, because
127
+ * the ingestion fold in `pipeline-phases/parse-impl.ts` runs exclusively over
128
+ * `decoratorRoutes` — and `languages/kotlin.ts` declares no
129
+ * `extractDecoratorRoutes`, since the ingestion Spring extractor (`spring.ts`)
130
+ * is bound to `tree-sitter-java` and its node types. Declaring the constant
131
+ * hooks on the Kotlin provider today would harvest a map on every Kotlin file
132
+ * that nothing consumes. An ingestion-side Kotlin route extractor is the
133
+ * prerequisite; when it lands, this binding is what its provider hooks should
134
+ * point at, and no change here is needed.
135
+ */
136
+ import { unquoteSpringLiteral } from './spring-shared.js';
137
+ import { MAX_FOLD_LENGTH, } from './constant-resolver.js';
138
+ /**
139
+ * The declared `package` of the file `mc` describes, or null when the entry did
140
+ * not come from {@link extractKotlinModuleConstants} and therefore cannot be
141
+ * matched against an import specifier.
142
+ */
143
+ function declaredPackageOf(mc) {
144
+ const declared = mc?.packageName;
145
+ return typeof declared === 'string' ? declared : null;
146
+ }
147
+ const NO_UNFOLDABLE_DECLARATIONS = new Set();
148
+ /**
149
+ * Kotlin declaration keys known to exist but not fold, or an empty set when
150
+ * `mc` came from another language binding.
151
+ */
152
+ export function unfoldableDeclarationsOf(mc) {
153
+ const declarations = mc?.unfoldableDeclarations;
154
+ return declarations instanceof Set ? declarations : NO_UNFOLDABLE_DECLARATIONS;
155
+ }
156
+ /** Source extensions a Kotlin declaration can live in. */
157
+ const KOTLIN_EXTENSIONS = ['.kt', '.kts'];
158
+ /**
159
+ * The name a backtick-quoted Kotlin identifier denotes: `` `api` `` → `api`.
160
+ *
161
+ * Quotes are spelling, not part of the name. tree-sitter-kotlin keeps them in
162
+ * node text, so every identifier that becomes a map key or lookup is read
163
+ * through here. Applied per dot-separated segment — a quoted identifier cannot
164
+ * contain `.`. Both the declaration side ({@link declaredPackage}) and the
165
+ * import side ({@link resolveKotlinImport}) are normalized, because either may
166
+ * carry the quotes while the other spells the same name plainly.
167
+ */
168
+ export function unquoteKotlinIdentifier(text) {
169
+ return text.length >= 2 && text.startsWith('`') && text.endsWith('`') ? text.slice(1, -1) : text;
170
+ }
171
+ /** {@link unquoteKotlinIdentifier} applied to every segment of a dotted name. */
172
+ function unquoteKotlinDottedName(text) {
173
+ return text.includes('`') ? text.split('.').map(unquoteKotlinIdentifier).join('.') : text;
174
+ }
175
+ /**
176
+ * Recursion ceiling for {@link parseKotlinConstOperands}, counted in `+` links.
177
+ *
178
+ * This bounds SYNTAX depth, not resolution: `A + B + C` nests one
179
+ * `additive_expression` per link, so the cap is really "how long a concatenation
180
+ * may one initializer be". Deliberately loose — generated route tables do
181
+ * concatenate a dozen fragments, and overrunning costs a skipped route, so the
182
+ * cap is a guard against pathological input rather than a statement about
183
+ * reasonable code.
184
+ */
185
+ const MAX_OPERAND_PARSE_DEPTH = 64;
186
+ /**
187
+ * Recursion ceiling for the fold, counted in REFERENCE hops (`A = B`, `B = C`).
188
+ *
189
+ * Larger than the agnostic core's own `MAX_RESOLVE_DEPTH` (8), which is
190
+ * module-private in `constant-resolver.ts` and therefore cannot simply be
191
+ * reused, and equal to the value the Java binding spells inline. It backstops
192
+ * the cycle guard, which terminates loops but not a long acyclic chain; the
193
+ * memo makes reaching it cheap. Both caps floor to null, i.e. to a skipped
194
+ * route.
195
+ */
196
+ const MAX_FOLD_DEPTH = 32;
197
+ /**
198
+ * Cheap content gate: can this Kotlin file DEFINE a string constant that a route
199
+ * annotation might reference?
200
+ *
201
+ * Exported so every caller uses the same predicate and none can disagree with
202
+ * {@link extractKotlinModuleConstants} about which files carry constants — the
203
+ * defect class the Java binding's shared `isJavaConstantFile` exists to prevent.
204
+ *
205
+ * Arms, all intended to be WIDER than the extractor (a gate may over-admit — it
206
+ * only costs a parse — while rejecting a file the extractor would accept costs a
207
+ * fact):
208
+ * - `const val NAME [: T] =`. `const` is legal only at a file's top level or in
209
+ * an `object`/`companion object`, i.e. exactly the carriers the extractor
210
+ * harvests, so this arm needs no scope check.
211
+ * - an `object` (or `companion object`) declaration together with a `val NAME =`
212
+ * binding. A non-`const` `val` is the other half of the extractor's input and
213
+ * carries no keyword of its own; requiring an `object` nearby keeps a file
214
+ * whose only `val`s are function locals from costing a parse. It still admits
215
+ * a top-level `val` in a file that happens to declare an object elsewhere,
216
+ * which is the harmless direction.
217
+ * - a `val NAME =` binding at file scope. A small lexical walk tracks braces
218
+ * and parentheses while skipping comments and literals, admitting the
219
+ * top-level non-`const` property the extractor harvests without turning every
220
+ * function-local `val` or constructor property into an extra parse.
221
+ *
222
+ * Both name arms accept a BACKTICK-QUOTED identifier as well as a bare one,
223
+ * because the extractor does: `unquoteKotlinIdentifier` strips the quoting
224
+ * everywhere a name becomes a key, so `const val \`ORDERS\` = "/orders"` is a
225
+ * constant this module resolves. A gate that matched only `\w+` rejected the
226
+ * file outright and the reference floored to skip — a gate narrower than the
227
+ * extractor, which is the one direction the arms above are meant to exclude.
228
+ */
229
+ const KOTLIN_NAME = String.raw `(?:\w+|\`[^\`\n]+\`)`;
230
+ const CONST_VAL_AT = new RegExp(String.raw `const\s+val\s+${KOTLIN_NAME}(?=\s|:|=|$)`, 'y');
231
+ const VAL_DECLARATION_AT = new RegExp(String.raw `val\s+${KOTLIN_NAME}(?=\s|:|=|by\b|$)`, 'y');
232
+ /** Is `source[index...]` the keyword `word`, rather than part of an identifier? */
233
+ function keywordAt(source, index, word) {
234
+ if (!source.startsWith(word, index))
235
+ return false;
236
+ const before = index === 0 ? '' : source[index - 1];
237
+ const after = source[index + word.length] ?? '';
238
+ return !/[\w$]/.test(before) && !/[\w$]/.test(after);
239
+ }
240
+ /** Test a sticky declaration pattern at one source offset without slicing. */
241
+ function declarationAt(pattern, source, index) {
242
+ pattern.lastIndex = index;
243
+ return pattern.test(source);
244
+ }
245
+ export function isKotlinConstantFile(source) {
246
+ let braces = 0;
247
+ let parens = 0;
248
+ let blockCommentDepth = 0;
249
+ let sawObject = false;
250
+ for (let i = 0; i < source.length; i++) {
251
+ if (blockCommentDepth > 0) {
252
+ if (source.startsWith('/*', i)) {
253
+ blockCommentDepth++;
254
+ i++;
255
+ }
256
+ else if (source.startsWith('*/', i)) {
257
+ blockCommentDepth--;
258
+ i++;
259
+ }
260
+ continue;
261
+ }
262
+ if (source.startsWith('//', i)) {
263
+ const newline = source.indexOf('\n', i + 2);
264
+ if (newline < 0)
265
+ break;
266
+ i = newline;
267
+ continue;
268
+ }
269
+ if (source.startsWith('/*', i)) {
270
+ blockCommentDepth = 1;
271
+ i++;
272
+ continue;
273
+ }
274
+ const quote = source[i];
275
+ if (source.startsWith('"""', i)) {
276
+ const end = source.indexOf('"""', i + 3);
277
+ if (end < 0)
278
+ break;
279
+ i = end + 2;
280
+ continue;
281
+ }
282
+ if (quote === '"' || quote === "'") {
283
+ for (i++; i < source.length; i++) {
284
+ if (source[i] === '\\') {
285
+ i++;
286
+ continue;
287
+ }
288
+ if (source[i] === quote)
289
+ break;
290
+ }
291
+ continue;
292
+ }
293
+ if (quote === '`') {
294
+ const end = source.indexOf('`', i + 1);
295
+ if (end < 0)
296
+ break;
297
+ i = end;
298
+ continue;
299
+ }
300
+ if (quote === '{') {
301
+ braces++;
302
+ continue;
303
+ }
304
+ if (quote === '}') {
305
+ braces = Math.max(0, braces - 1);
306
+ continue;
307
+ }
308
+ if (quote === '(') {
309
+ parens++;
310
+ continue;
311
+ }
312
+ if (quote === ')') {
313
+ parens = Math.max(0, parens - 1);
314
+ continue;
315
+ }
316
+ if (keywordAt(source, i, 'object')) {
317
+ sawObject = true;
318
+ i += 'object'.length - 1;
319
+ continue;
320
+ }
321
+ if (keywordAt(source, i, 'const') && declarationAt(CONST_VAL_AT, source, i))
322
+ return true;
323
+ if (keywordAt(source, i, 'val') && declarationAt(VAL_DECLARATION_AT, source, i)) {
324
+ if (sawObject || (braces === 0 && parens === 0))
325
+ return true;
326
+ i += 'val'.length - 1;
327
+ }
328
+ }
329
+ return false;
330
+ }
331
+ /** Does `key` name the file `<asPath>.kt` / `<asPath>.kts`? */
332
+ function isFileNamedAfterDeclaration(key, asPath) {
333
+ for (const ext of KOTLIN_EXTENSIONS) {
334
+ const candidate = `${asPath}${ext}`;
335
+ if (key === candidate || key.endsWith(`/${candidate}`))
336
+ return true;
337
+ }
338
+ return false;
339
+ }
340
+ /**
341
+ * Does the file `mc` describes declare a top-level entity called `name` — an
342
+ * `object`/companion carrier whose members are keyed `name.<MEMBER>`, or a
343
+ * top-level constant keyed `name` outright?
344
+ *
345
+ * A true result is strong enough to select a unique declaring file before path
346
+ * fallbacks: the tested key set is a superset of every local key the fold may
347
+ * subsequently read for that imported name. Two matching files are therefore
348
+ * ambiguous; one is authoritative. A miss falls back to the conservative path
349
+ * heuristics below, whose result is still verified by the actual map lookup.
350
+ */
351
+ function declaresTopLevelName(mc, name) {
352
+ const prefix = `${name}.`;
353
+ for (const map of [mc.literals, mc.exprs]) {
354
+ if (map.has(name))
355
+ return true;
356
+ for (const key of map.keys())
357
+ if (key.startsWith(prefix))
358
+ return true;
359
+ }
360
+ for (const key of unfoldableDeclarationsOf(mc)) {
361
+ if (key === name || key.startsWith(prefix))
362
+ return true;
363
+ }
364
+ return false;
365
+ }
366
+ /** Does this file contribute declarations to Kotlin import ambiguity? */
367
+ function contributesKotlinConstants(mc) {
368
+ return mc.literals.size > 0 || mc.exprs.size > 0 || unfoldableDeclarationsOf(mc).size > 0;
369
+ }
370
+ /** Top-level names declared by one file (`Outer.X` contributes `Outer`). */
371
+ function topLevelDeclarationNames(mc) {
372
+ const names = new Set();
373
+ for (const map of [mc.literals, mc.exprs]) {
374
+ for (const key of map.keys()) {
375
+ const dot = key.indexOf('.');
376
+ names.add(dot < 0 ? key : key.slice(0, dot));
377
+ }
378
+ }
379
+ for (const key of unfoldableDeclarationsOf(mc)) {
380
+ const dot = key.indexOf('.');
381
+ names.add(dot < 0 ? key : key.slice(0, dot));
382
+ }
383
+ return names;
384
+ }
385
+ /** Every declaration key recorded for a file, foldable or not. */
386
+ function declarationKeys(mc) {
387
+ return new Set([...mc.literals.keys(), ...mc.exprs.keys(), ...unfoldableDeclarationsOf(mc)]);
388
+ }
389
+ /** Build the immutable import projections once for a repo constant map. */
390
+ export function buildKotlinConstantIndex(repo) {
391
+ const constantKeys = new Set();
392
+ const byFqn = new Map();
393
+ const mutablePackages = new Map();
394
+ for (const [key, mc] of repo) {
395
+ if (!contributesKotlinConstants(mc))
396
+ continue;
397
+ constantKeys.add(key);
398
+ const packageName = declaredPackageOf(mc);
399
+ if (packageName === null)
400
+ continue;
401
+ let bucket = mutablePackages.get(packageName);
402
+ if (!bucket) {
403
+ bucket = { files: [], declarers: new Map() };
404
+ mutablePackages.set(packageName, bucket);
405
+ }
406
+ bucket.files.push(key);
407
+ for (const name of topLevelDeclarationNames(mc)) {
408
+ if (!bucket.declarers.has(name))
409
+ bucket.declarers.set(name, key);
410
+ else if (bucket.declarers.get(name) !== key)
411
+ bucket.declarers.set(name, null);
412
+ }
413
+ for (const declaration of declarationKeys(mc)) {
414
+ const parts = declaration.split('.');
415
+ // A member key `Outer.Inner.Q` proves the file declares both owner paths
416
+ // as well as the member itself. This lets imports of nested objects and
417
+ // their members retain the complete in-file lookup path.
418
+ for (let length = 1; length <= parts.length; length++) {
419
+ const localName = parts.slice(0, length).join('.');
420
+ const fqn = packageName === '' ? localName : `${packageName}.${localName}`;
421
+ const existing = byFqn.get(fqn);
422
+ if (existing === undefined)
423
+ byFqn.set(fqn, { fileKey: key, localName });
424
+ else if (existing !== null && existing.fileKey !== key)
425
+ byFqn.set(fqn, null);
426
+ }
427
+ }
428
+ }
429
+ return { repo, constantKeys, byPackage: mutablePackages, byFqn };
430
+ }
431
+ /**
432
+ * Add one scan-time file without rebuilding the base index when it only imports
433
+ * constants. A newly discovered declaration is rare and rebuilds once for that
434
+ * file's scan, never once per route.
435
+ */
436
+ export function overlayKotlinConstantIndex(index, fileKey, mc) {
437
+ const repo = new Map(index.repo);
438
+ const replacing = repo.has(fileKey);
439
+ repo.set(fileKey, mc);
440
+ if (!replacing && !contributesKotlinConstants(mc))
441
+ return { ...index, repo };
442
+ return buildKotlinConstantIndex(repo);
443
+ }
444
+ /**
445
+ * Map a fully-qualified import specifier to the unique file key it refers to, or
446
+ * null when it cannot be pinned to exactly one file.
447
+ *
448
+ * A specifier is split at its last dot into the package it names and the
449
+ * declaration inside it (`com.example.app.api` + `ApiPaths`). Resolution then
450
+ * runs in three steps, all of them "unique or nothing":
451
+ *
452
+ * 0. **Declared package** — only files whose `package` header is EXACTLY the
453
+ * sought package can carry the declaration (compared after
454
+ * {@link unquoteKotlinIdentifier}, since backtick quoting is spelling and
455
+ * not identity). This is the authority, and it
456
+ * is checked first. Kotlin does not require a file's directory to match its
457
+ * package, so the reverse test — "does this path end with the package?" —
458
+ * answers a different question, one any decoy directory can satisfy: a file
459
+ * at `src/x/com/example/api/ApiPaths.kt` declaring `package x.com.example.api`
460
+ * is not `com.example.api.ApiPaths` and must never be folded as it, and a
461
+ * path-suffix test also lets a root-level `package data` be impersonated by
462
+ * `…/com/example/data/`. An entry with no recorded package is rejected, not
463
+ * assumed to be the default package.
464
+ * 1. **Declared name** — when exactly one file declares the sought name, use
465
+ * it. When two do, the FQN itself is duplicated in the repository and names
466
+ * no single declaration, so return null. This is the general form of the
467
+ * same-FQN check step 2 could only make for files that happen to follow the
468
+ * file-name convention, and it is what stops a `src/test/…` copy of a
469
+ * production constant from being folded into a production route.
470
+ * 2. **File named after the declaration** — when declaration metadata found no
471
+ * owner, try the package-matching file ending
472
+ * `com/example/app/api/ApiPaths.kt`. Kotlin does not require this (`object
473
+ * ApiPaths` may live in `Constants.kt`), so it is only a fallback candidate;
474
+ * the subsequent map lookup must still prove that it carries the value.
475
+ * 3. **Sole file in the package** — when declaration metadata cannot identify
476
+ * the name, use the unique package-matching candidate. The set passed in
477
+ * contains files with foldable or explicitly unfoldable declarations, so
478
+ * unrelated files cannot create ambiguity once step 1 identifies a unique
479
+ * declarer. With 2+ unidentified candidates it returns null.
480
+ *
481
+ * Steps 2 and 3 can still hand back a file that does not declare the wanted name
482
+ * (its package is right and it is the only candidate, but the name lives
483
+ * elsewhere or nowhere). That remains safe by construction: the fold looks the
484
+ * name up in that file's map, misses, and returns null.
485
+ *
486
+ * A "nearest shared directory" tie-break is deliberately NOT applied when a step
487
+ * has several candidates, for the reason the Java binding records: the JVM
488
+ * resolves duplicate FQNs by classpath order, not directory proximity, so a test
489
+ * fixture copy sitting closer in the tree can outrank the real dependency and
490
+ * yield a silently wrong literal. In a resolver whose whole contract is
491
+ * skip-or-correct, a plausible guess is the one answer that cannot be allowed.
492
+ *
493
+ * This can no longer be typed as the agnostic {@link ModuleConstants} consumer's
494
+ * `ImportResolver`, whose signature carries only file KEYS: deciding a candidate
495
+ * on its declared package needs the map those keys index. Nothing is lost — the
496
+ * core's own fold is not used here either (see the module header), and the
497
+ * alternative is a resolver that must guess from a path.
498
+ */
499
+ export function resolveKotlinImport(_importingFileKey, rawModuleSpec, candidateKeys, repo) {
500
+ // Normalized here as well as at extraction, so the function answers the same
501
+ // question however a caller spells the specifier.
502
+ const moduleSpec = unquoteKotlinDottedName(rawModuleSpec);
503
+ const lastDot = moduleSpec.lastIndexOf('.');
504
+ const packageName = lastDot < 0 ? '' : moduleSpec.slice(0, lastDot);
505
+ const simpleName = lastDot < 0 ? moduleSpec : moduleSpec.slice(lastDot + 1);
506
+ // Step 0 + step 1 in one pass over the candidates.
507
+ const inPackage = [];
508
+ let declaring = null;
509
+ for (const key of candidateKeys) {
510
+ const mc = repo.get(key);
511
+ if (!mc || declaredPackageOf(mc) !== packageName)
512
+ continue;
513
+ inPackage.push(key);
514
+ if (declaresTopLevelName(mc, simpleName)) {
515
+ if (declaring !== null)
516
+ return null; // 2+ files declare this FQN
517
+ declaring = key;
518
+ }
519
+ }
520
+ if (inPackage.length === 0)
521
+ return null;
522
+ if (declaring !== null)
523
+ return declaring;
524
+ if (inPackage.length === 1)
525
+ return inPackage[0]; // steps 2 and 3 agree
526
+ // Step 2: the file-name convention, as a tie-break among valid candidates.
527
+ const asPath = moduleSpec.replace(/\./g, '/');
528
+ let named = null;
529
+ for (const key of inPackage) {
530
+ if (!isFileNamedAfterDeclaration(key, asPath))
531
+ continue;
532
+ if (named !== null)
533
+ return null; // 2+ files spell the convention
534
+ named = key;
535
+ }
536
+ // Step 3 is "the sole candidate", already returned above.
537
+ return named;
538
+ }
539
+ /** Indexed equivalent of {@link resolveKotlinImport}, with identical fallbacks. */
540
+ export function resolveKotlinImportWithIndex(rawModuleSpec, index) {
541
+ return resolveKotlinImportTarget(rawModuleSpec, index)?.fileKey ?? null;
542
+ }
543
+ /** Resolve an import to both its file and complete in-file declaration path. */
544
+ function resolveKotlinImportTarget(rawModuleSpec, index) {
545
+ const moduleSpec = unquoteKotlinDottedName(rawModuleSpec);
546
+ const lastDot = moduleSpec.lastIndexOf('.');
547
+ const packageName = lastDot < 0 ? '' : moduleSpec.slice(0, lastDot);
548
+ const simpleName = lastDot < 0 ? moduleSpec : moduleSpec.slice(lastDot + 1);
549
+ const bucket = index.byPackage.get(packageName);
550
+ // Preserve the top-level interpretation whenever the exact declared package
551
+ // exists. A parent package may legally contain nested objects whose joined
552
+ // path spells the same FQN; letting that projection win would make a nested
553
+ // decoy override the real top-level declaration.
554
+ if (!bucket)
555
+ return index.byFqn.get(moduleSpec) ?? null;
556
+ if (bucket.declarers.has(simpleName)) {
557
+ const fileKey = bucket.declarers.get(simpleName);
558
+ return fileKey === null || fileKey === undefined ? null : { fileKey, localName: simpleName };
559
+ }
560
+ if (bucket.files.length === 1)
561
+ return { fileKey: bucket.files[0], localName: simpleName };
562
+ const asPath = moduleSpec.replace(/\./g, '/');
563
+ let named = null;
564
+ for (const key of bucket.files) {
565
+ if (!isFileNamedAfterDeclaration(key, asPath))
566
+ continue;
567
+ if (named !== null)
568
+ return null;
569
+ named = key;
570
+ }
571
+ return named === null ? null : { fileKey: named, localName: simpleName };
572
+ }
573
+ /**
574
+ * Is `node` a Kotlin string literal, and if so what value does the route layer
575
+ * give it?
576
+ *
577
+ * Two rejections, both floors rather than guesses:
578
+ * - **String templates.** `"$base/orders"` parses as a `string_literal` whose
579
+ * children include an interpolation alongside the `string_content` runs.
580
+ * Joining the content runs would silently DELETE the interpolated part and
581
+ * publish `/orders` — a path the application does not serve. Any named child
582
+ * that is not `string_content` means the value is not statically knowable, so
583
+ * the literal is refused. (The same test makes the function safe against a
584
+ * grammar that splits escape sequences into their own nodes: it would floor
585
+ * to skip, never to a de-escaped path.)
586
+ * - **Multi-line raw strings.** A single-line `"""/api"""` is exact — unlike a
587
+ * Java text block, a Kotlin raw string performs no escape processing and no
588
+ * incidental-indentation stripping, so it folds to precisely its content. A
589
+ * multi-line one carries newlines (and usually a `.trimIndent()` call this
590
+ * layer cannot fold), so it is refused.
591
+ *
592
+ * Otherwise the quotes are sliced off the RAW TEXT via
593
+ * {@link unquoteSpringLiteral} — the same function the literal path uses — so
594
+ * `@GetMapping(ApiPaths.USER_REGEX)` and `@GetMapping("/user/{id:\\d+}")` emit
595
+ * the same path for the same Kotlin source.
596
+ */
597
+ function stringLiteralValue(node) {
598
+ if (node.type !== 'string_literal')
599
+ return null;
600
+ for (const child of node.namedChildren) {
601
+ if (child.type !== 'string_content')
602
+ return null;
603
+ }
604
+ const raw = node.text;
605
+ if (raw.startsWith('"""') && raw.includes('\n'))
606
+ return null;
607
+ return unquoteSpringLiteral(raw);
608
+ }
609
+ /**
610
+ * Flatten a navigation expression (`ApiPaths`, `com.example.app.ApiPaths`) to
611
+ * its dotted text, or null when any segment is not a plain identifier (calls,
612
+ * `this`, indexing, safe navigation — not a constant shape).
613
+ */
614
+ function flattenNavigation(node) {
615
+ if (node.type === 'simple_identifier')
616
+ return unquoteKotlinIdentifier(node.text);
617
+ if (node.type === 'navigation_expression') {
618
+ const target = node.namedChild(0);
619
+ const suffix = node.namedChildren.find((c) => c.type === 'navigation_suffix');
620
+ const field = suffix?.namedChildren.find((c) => c.type === 'simple_identifier');
621
+ if (target && field) {
622
+ const head = flattenNavigation(target);
623
+ return head === null ? null : `${head}.${unquoteKotlinIdentifier(field.text)}`;
624
+ }
625
+ }
626
+ return null;
627
+ }
628
+ /**
629
+ * Parse a Kotlin constant initializer (or an inline annotation argument) into an
630
+ * operand list, or null when it is not a foldable string expression. Handles a
631
+ * bare string literal, a bare identifier (`X = Y`), a qualified reference
632
+ * (`X = ApiPaths.Y` — recorded as ONE ref named `ApiPaths.Y`), and
633
+ * left-associative `+` chains of the three. Everything else — numbers, calls,
634
+ * `when`/`if` expressions, templates, `buildString` — returns null, which makes
635
+ * the constant unresolvable (→ skip floor), never a wrong value.
636
+ *
637
+ * A chain nests: tree-sitter-kotlin parses `A + B + C` as
638
+ * `additive_expression(additive_expression(A, B), C)`, so every node here has
639
+ * exactly two operands and arbitrary-length chains fold by recursion. The same
640
+ * node type also carries `-`, which is not a string operation, so a `+` token
641
+ * must be present.
642
+ *
643
+ * A PARENTHESIZED operand (`(A + B) + "/c"`) is deliberately NOT unwrapped,
644
+ * matching `parseJavaConstOperands`, which has no parenthesis arm either. The
645
+ * shape is vanishingly rare in a route annotation and the cost of omitting it is
646
+ * a skipped route, not a wrong one; adding it to both bindings at once is the
647
+ * only way to keep them in parity, so it is left to a follow-up.
648
+ */
649
+ export function parseKotlinConstOperands(node, depth = 0) {
650
+ if (!node)
651
+ return null;
652
+ if (depth > MAX_OPERAND_PARSE_DEPTH)
653
+ return null;
654
+ if (node.type === 'string_literal') {
655
+ const value = stringLiteralValue(node);
656
+ return value === null ? null : [{ kind: 'literal', value }];
657
+ }
658
+ if (node.type === 'simple_identifier') {
659
+ return [{ kind: 'ref', name: unquoteKotlinIdentifier(node.text) }];
660
+ }
661
+ if (node.type === 'navigation_expression') {
662
+ const name = flattenNavigation(node);
663
+ return name === null ? null : [{ kind: 'ref', name }];
664
+ }
665
+ // `additive_expression` covers both `+` and `-` in tree-sitter-kotlin; only a
666
+ // `+` chain concatenates strings.
667
+ if (node.type === 'additive_expression') {
668
+ if (!(node.children ?? []).some((c) => c.type === '+'))
669
+ return null;
670
+ const operandNodes = node.namedChildren;
671
+ if (operandNodes.length !== 2)
672
+ return null;
673
+ const left = parseKotlinConstOperands(operandNodes[0], depth + 1);
674
+ const right = parseKotlinConstOperands(operandNodes[1], depth + 1);
675
+ if (left === null || right === null)
676
+ return null;
677
+ return [...left, ...right];
678
+ }
679
+ return null;
680
+ }
681
+ /** The `val`/`var` keyword a property declaration binds with, or null. */
682
+ function bindingKind(property) {
683
+ return property.children.find((c) => c.type === 'binding_pattern_kind')?.text ?? null;
684
+ }
685
+ /**
686
+ * The initializer expression of a property declaration, or null when it has
687
+ * none.
688
+ *
689
+ * Reads the `=` that is a DIRECT child of the `property_declaration`, so a
690
+ * custom getter (`val X: String get() = "/g"`, whose `=` lives under `getter`)
691
+ * and a delegate (`val X by lazy { … }`, which has no `=` at all) both yield
692
+ * null. Both are computed at access time and are not constants.
693
+ */
694
+ function initializerOf(property) {
695
+ let equalsIndex = -1;
696
+ for (let i = 0; i < property.childCount; i++) {
697
+ if (property.child(i)?.type === '=') {
698
+ equalsIndex = i;
699
+ break;
700
+ }
701
+ }
702
+ if (equalsIndex < 0)
703
+ return null;
704
+ for (let i = equalsIndex + 1; i < property.childCount; i++) {
705
+ const child = property.child(i);
706
+ if (child?.isNamed)
707
+ return child;
708
+ }
709
+ return null;
710
+ }
711
+ /**
712
+ * The file's declared `package`, or `''` when it declares none (default
713
+ * package). Shaped exactly like the import walk below: `package_header` holds
714
+ * one `identifier` whose `simple_identifier` children are the dotted segments.
715
+ *
716
+ * Each segment is unquoted (see {@link unquoteKotlinIdentifier}), so a package
717
+ * declared `` com.example.`api` `` is recorded — and therefore matched — as the
718
+ * same package an import spells `com.example.api`.
719
+ */
720
+ function declaredPackage(root) {
721
+ const header = root.children.find((c) => c.type === 'package_header');
722
+ const identifier = header?.children.find((c) => c.type === 'identifier');
723
+ if (!identifier)
724
+ return '';
725
+ return identifier.namedChildren
726
+ .filter((c) => c.type === 'simple_identifier')
727
+ .map((c) => unquoteKotlinIdentifier(c.text))
728
+ .join('.');
729
+ }
730
+ /**
731
+ * Extract the declared package, file-level string constants and import bindings
732
+ * of one parsed Kotlin file into the {@link KotlinModuleConstants} shape the
733
+ * resolver consumes.
734
+ *
735
+ * Constants come from the three carriers Kotlin allows a caller to reach without
736
+ * an instance: file top level, `object` members, and `companion object` members.
737
+ * A `val` in a plain class or interface body is per-instance or abstract and is
738
+ * NOT collected — the Kotlin analogue of Java's `static final` requirement. `var`
739
+ * is rejected outright.
740
+ *
741
+ * KEYS FOLLOW KOTLIN'S OWN VISIBILITY, not a flattened namespace. Every constant
742
+ * is recorded under `<DeclaringType>.<NAME>`, the spelling a qualified reference
743
+ * uses, with a companion member keyed under its ENCLOSING CLASS (`Holder.NAME`)
744
+ * because that is how Kotlin source refers to it — `Companion` never appears in
745
+ * a reference. The SIMPLE name is recorded only for a TOP-LEVEL `val`, the one
746
+ * carrier whose bare binding really does span the file. A member of a named
747
+ * `object` gets no bare key, because `BASE` alone does not name `A.BASE` from
748
+ * anywhere outside `object A`'s own body. Writing one anyway (as this binding
749
+ * and the Java one both used to) fabricates a binding the language does not
750
+ * have, and a fabricated key outranks the genuine `import com.example.api.Paths.ORDERS`
751
+ * that {@link computeKotlinFold} consults only after literals and expressions.
752
+ *
753
+ * A COMPANION member gets no bare key either: it is bound unqualified inside
754
+ * its enclosing class body and nowhere else. {@link qualifyKotlinRefInEnclosingTypes}
755
+ * rewrites a bare name to `<EnclosingType>.<NAME>` when an enclosing type
756
+ * declares it, so the companion wins inside its own class and loses everywhere
757
+ * else.
758
+ *
759
+ * An initializer that names a SIBLING is resolved the same way, against its own
760
+ * scope chain, innermost first, before the file level: inside
761
+ * `object A { const val BASE = "/right"; const val ROUTE = BASE + "/m" }` the
762
+ * operand `BASE` is rewritten to `A.BASE`. Collecting every declaration before
763
+ * recording any keeps that independent of declaration order.
764
+ *
765
+ * A TOP-LEVEL initializer has an EMPTY scope chain, so its bare operands stay
766
+ * bare and resolve at file level — they must not pick up a companion key.
767
+ *
768
+ * A non-foldable rebind (`X = compute()`) DROPS X to unresolvable rather than
769
+ * leaving a stale literal — and drops a same-named import with it whenever the
770
+ * declaration shadows that import ANYWHERE (top level, or a companion inside its
771
+ * class). The import map has no scopes, so a companion's shadow is applied
772
+ * file-wide: the conservative direction, costing a route rather than publishing
773
+ * the imported value at a reference the compiler binds to the unfoldable member.
774
+ * An `object` member shadows nothing and must leave the import alone.
775
+ */
776
+ export function extractKotlinModuleConstants(tree) {
777
+ const literals = new Map();
778
+ const exprs = new Map();
779
+ const imports = new Map();
780
+ const unfoldableDeclarations = new Set();
781
+ // Pass 1: imports.
782
+ const walkImports = (node) => {
783
+ if (node.type === 'import_header') {
784
+ // `import a.b.*` binds no single name — nothing to key the fold on, and
785
+ // guessing which package member a bare reference came from is exactly the
786
+ // wrong answer. Skipped, so such a reference floors to skip.
787
+ const isWildcard = node.children.some((c) => c.type === 'wildcard_import');
788
+ const identifier = node.children.find((c) => c.type === 'identifier');
789
+ if (!isWildcard && identifier) {
790
+ const segments = identifier.namedChildren
791
+ .filter((c) => c.type === 'simple_identifier')
792
+ .map((c) => unquoteKotlinIdentifier(c.text));
793
+ if (segments.length >= 2) {
794
+ const spec = segments.join('.');
795
+ const originalName = segments[segments.length - 1];
796
+ const aliasNode = node.children
797
+ .find((c) => c.type === 'import_alias')
798
+ ?.namedChildren.find((c) => c.type === 'type_identifier');
799
+ const alias = aliasNode ? unquoteKotlinIdentifier(aliasNode.text) : undefined;
800
+ // `module` is the specifier AS WRITTEN, complete. Kotlin does not mark
801
+ // member imports, so the fold — not the extractor — decides whether the
802
+ // trailing segment is a declaration or one of its members.
803
+ imports.set(alias ?? originalName, { module: spec, originalName });
804
+ }
805
+ }
806
+ return;
807
+ }
808
+ for (const child of node.children ?? [])
809
+ walkImports(child);
810
+ };
811
+ walkImports(tree.rootNode);
812
+ // Pass 2a: collect every declaration, writing nothing yet. Which member each
813
+ // unqualified operand means depends on the whole file, so no key can be
814
+ // written — and no operand rewritten — until the last declaration is in.
815
+ const declarations = [];
816
+ /** Declaring scope → the simple names it declares, foldable or not. */
817
+ const membersByScope = new Map();
818
+ const collectProperties = (body, declaringType, scopes, fileLevelName, shadowsImport) => {
819
+ for (const member of body.children ?? []) {
820
+ if (member.type !== 'property_declaration')
821
+ continue;
822
+ if (bindingKind(member) !== 'val')
823
+ continue;
824
+ const declaration = member.children.find((c) => c.type === 'variable_declaration');
825
+ const nameNode = declaration?.namedChildren.find((c) => c.type === 'simple_identifier');
826
+ if (!nameNode)
827
+ continue;
828
+ const name = unquoteKotlinIdentifier(nameNode.text);
829
+ if (declaringType !== null) {
830
+ let members = membersByScope.get(declaringType);
831
+ if (!members)
832
+ membersByScope.set(declaringType, (members = new Set()));
833
+ // Recorded even when the initializer does not fold: a sibling reference
834
+ // to an unfoldable member must resolve to that member and then MISS,
835
+ // not fall through to a same-named constant at file level.
836
+ members.add(name);
837
+ }
838
+ declarations.push({
839
+ name,
840
+ qualified: declaringType === null ? null : `${declaringType}.${name}`,
841
+ scopes,
842
+ fileLevelName,
843
+ shadowsImport,
844
+ operands: parseKotlinConstOperands(initializerOf(member)),
845
+ });
846
+ }
847
+ };
848
+ const bodyOf = (node) => node.children.find((c) => c.type === 'class_body');
849
+ /** The declared name of an `object_declaration` / `class_declaration`. */
850
+ const typeNameOf = (node) => {
851
+ const ident = node.children.find((c) => c.type === 'type_identifier');
852
+ return ident ? unquoteKotlinIdentifier(ident.text) : null;
853
+ };
854
+ /** Append one simple type name to its enclosing qualified type path. */
855
+ const nestedTypeName = (enclosingType, name) => {
856
+ if (name === null)
857
+ return enclosingType;
858
+ return enclosingType === null ? name : `${enclosingType}.${name}`;
859
+ };
860
+ /** Prepend a qualified scope unless it is already the innermost scope. */
861
+ const withScope = (scope, scopes) => scope === null || scopes[0] === scope ? scopes : [scope, ...scopes];
862
+ const walkDeclarations = (node, enclosingType, scopes) => {
863
+ for (const child of node.children ?? []) {
864
+ if (child.type === 'object_declaration') {
865
+ const name = typeNameOf(child);
866
+ const body = bodyOf(child);
867
+ if (!body)
868
+ continue;
869
+ // Carry the full path: a nested object member is `Outer.Inner.NAME`, not
870
+ // `Inner.NAME`. Inside the body a bare name searches that qualified
871
+ // scope first, then each enclosing type.
872
+ const declaredType = nestedTypeName(enclosingType, name);
873
+ const inner = withScope(declaredType, scopes);
874
+ collectProperties(body, declaredType, inner, false, false);
875
+ walkDeclarations(body, declaredType, inner);
876
+ continue;
877
+ }
878
+ if (child.type === 'companion_object') {
879
+ const body = bodyOf(child);
880
+ if (!body)
881
+ continue;
882
+ // Referenced through the enclosing class (`Holder.NAME`), never through
883
+ // `Companion` — so the qualified alias is keyed on `enclosingType`. The
884
+ // simple name is bound inside that class body only, which is a SCOPE and
885
+ // not a file-level key: it is reached from the reference site by
886
+ // `qualifyKotlinRefInEnclosingTypes`, through this same `Holder.NAME`.
887
+ const inner = withScope(enclosingType, scopes);
888
+ collectProperties(body, enclosingType, inner, false, true);
889
+ walkDeclarations(body, enclosingType, inner);
890
+ continue;
891
+ }
892
+ if (child.type === 'class_declaration') {
893
+ // A class/interface body's own `val`s are per-instance or abstract, so
894
+ // only its nested objects and companion contribute constants.
895
+ const name = typeNameOf(child);
896
+ const body = bodyOf(child);
897
+ const declaredType = nestedTypeName(enclosingType, name);
898
+ if (body)
899
+ walkDeclarations(body, declaredType, withScope(declaredType, scopes));
900
+ continue;
901
+ }
902
+ walkDeclarations(child, enclosingType, scopes);
903
+ }
904
+ };
905
+ collectProperties(tree.rootNode, null, [], true, true);
906
+ walkDeclarations(tree.rootNode, null, []);
907
+ // Pass 2b: rewrite each initializer's unqualified operands against the scope
908
+ // chain that encloses it, then record. Only a top-level declaration writes a
909
+ // bare key, so nothing here can collide across scopes; a companion's
910
+ // unqualified binding is applied at the reference site instead.
911
+ // A PARTIALLY qualified reference is resolved here too, not just a bare one:
912
+ // inside `object Outer`, the initializer `Inner.Q + "/m"` names `Outer.Inner.Q`,
913
+ // and taking a dotted name as already complete looked up a key nothing
914
+ // declares. Split at the last dot and prefix the scope onto the OWNER, so the
915
+ // bare case (`ownerSuffix === null`) stays exactly what it was.
916
+ const qualifyRef = (refName, scopes) => {
917
+ const lastDot = refName.lastIndexOf('.');
918
+ const ownerSuffix = lastDot < 0 ? null : refName.slice(0, lastDot);
919
+ const member = lastDot < 0 ? refName : refName.slice(lastDot + 1);
920
+ for (const scope of scopes) {
921
+ const declaringType = ownerSuffix === null ? scope : `${scope}.${ownerSuffix}`;
922
+ if (membersByScope.get(declaringType)?.has(member))
923
+ return `${declaringType}.${member}`;
924
+ }
925
+ return refName; // file level, or unresolvable — the fold decides
926
+ };
927
+ for (const decl of declarations) {
928
+ const keys = [];
929
+ if (decl.fileLevelName)
930
+ keys.push(decl.name);
931
+ if (decl.qualified !== null)
932
+ keys.push(decl.qualified);
933
+ if (decl.operands === null) {
934
+ for (const key of keys) {
935
+ literals.delete(key);
936
+ exprs.delete(key);
937
+ unfoldableDeclarations.add(key);
938
+ }
939
+ if (decl.shadowsImport)
940
+ imports.delete(decl.name);
941
+ continue;
942
+ }
943
+ const operands = decl.operands.map((op) => op.kind === 'ref' ? { kind: 'ref', name: qualifyRef(op.name, decl.scopes) } : op);
944
+ const literalValue = operands.length === 1 && operands[0].kind === 'literal' ? operands[0].value : null;
945
+ for (const key of keys) {
946
+ unfoldableDeclarations.delete(key);
947
+ if (literalValue !== null) {
948
+ literals.set(key, literalValue);
949
+ exprs.delete(key);
950
+ }
951
+ else {
952
+ exprs.set(key, operands);
953
+ literals.delete(key);
954
+ }
955
+ }
956
+ }
957
+ return {
958
+ literals,
959
+ exprs,
960
+ imports,
961
+ packageName: declaredPackage(tree.rootNode),
962
+ unfoldableDeclarations,
963
+ };
964
+ }
965
+ function newFoldState(repo, index) {
966
+ return {
967
+ index: index ?? buildKotlinConstantIndex(repo),
968
+ visited: new Set(),
969
+ memo: new Map(),
970
+ };
971
+ }
972
+ /**
973
+ * Resolve a single Kotlin constant referenced in `fileKey` to its literal string
974
+ * value, folding `+` concatenation and following import chains via
975
+ * {@link resolveKotlinImport}, or null when it cannot be fully folded.
976
+ *
977
+ * `name` may be simple (`ORDERS`, resolved via a single-name import or a
978
+ * same-file constant) or qualified (`ApiPaths.ORDERS`, resolved via the type
979
+ * import plus the target file's qualified alias).
980
+ */
981
+ export function resolveKotlinConstant(fileKey, name, repo, depth = 0, index) {
982
+ return resolveWithState(fileKey, name, newFoldState(repo, index), depth);
983
+ }
984
+ function resolveWithState(fileKey, name, state, depth) {
985
+ if (depth > MAX_FOLD_DEPTH)
986
+ return null;
987
+ const guard = `${fileKey}::${name}`;
988
+ const memoized = state.memo.get(guard);
989
+ if (memoized !== undefined)
990
+ return memoized;
991
+ if (state.visited.has(guard))
992
+ return null; // cycle: `name` is on the active stack
993
+ state.visited.add(guard);
994
+ try {
995
+ const result = computeKotlinFold(fileKey, name, state, depth);
996
+ if (result !== null)
997
+ state.memo.set(guard, result);
998
+ return result;
999
+ }
1000
+ finally {
1001
+ state.visited.delete(guard);
1002
+ }
1003
+ }
1004
+ /**
1005
+ * Resolve a name bound by an import, trying both readings of the specifier.
1006
+ *
1007
+ * Kotlin writes a member import exactly like a type import, so
1008
+ * `import com.example.app.api.ApiPaths.ORDERS` is syntactically
1009
+ * indistinguishable from a type import of `ORDERS` in package
1010
+ * `com.example.app.api.ApiPaths`. Rather than guess from casing — a convention,
1011
+ * not a rule, and one that quietly breaks on `object apiPaths` or `const val
1012
+ * Orders` — both readings are attempted and the first that actually RESOLVES
1013
+ * wins. A reading that resolves to no constant simply falls through.
1014
+ */
1015
+ function resolveImportedName(fileKey, imp, state, depth) {
1016
+ // Reading A: the specifier names the declaration itself (a top-level
1017
+ // `const val`, or a type whose file we then search).
1018
+ const direct = resolveKotlinImportTarget(imp.module, state.index);
1019
+ if (direct !== null) {
1020
+ const value = resolveWithState(direct.fileKey, direct.localName, state, depth);
1021
+ if (value !== null)
1022
+ return value;
1023
+ }
1024
+ // Reading B: the specifier names a MEMBER of the declaration one segment up
1025
+ // (`…ApiPaths.ORDERS` → member `ORDERS` of `ApiPaths`).
1026
+ const dot = imp.module.lastIndexOf('.');
1027
+ if (dot <= 0)
1028
+ return null;
1029
+ const ownerSpec = imp.module.slice(0, dot);
1030
+ const owner = resolveKotlinImportTarget(ownerSpec, state.index);
1031
+ if (owner === null)
1032
+ return null;
1033
+ return resolveWithState(owner.fileKey, `${owner.localName}.${imp.originalName}`, state, depth);
1034
+ }
1035
+ function computeKotlinFold(fileKey, name, state, depth) {
1036
+ const { repo } = state.index;
1037
+ // Qualified reference (`ApiPaths.ORDERS`): constants and imports are keyed by
1038
+ // their IN-FILE name, so a dotted name never hits directly. Split head.tail,
1039
+ // resolve the head through the importing file's type import, then look the
1040
+ // member up in the target file under its declaring name.
1041
+ //
1042
+ // Unlike the Java binding there is NO bare-`tail` fallback: in Kotlin
1043
+ // `Head.TAIL` means TAIL is a member of the object or companion `Head`, so a
1044
+ // top-level `TAIL` in the target file is a different declaration and matching
1045
+ // it would fabricate a value.
1046
+ const dot = name.indexOf('.');
1047
+ if (dot > 0) {
1048
+ const head = name.slice(0, dot);
1049
+ const tail = name.slice(dot + 1);
1050
+ const imp = repo.get(fileKey)?.imports.get(head);
1051
+ if (imp) {
1052
+ const target = resolveKotlinImportTarget(imp.module, state.index);
1053
+ if (target === null)
1054
+ return null;
1055
+ // `originalName` un-aliases `import … .ApiPaths as Paths`, so the lookup
1056
+ // uses the declaring type's real name.
1057
+ return resolveWithState(target.fileKey, `${target.localName}.${tail}`, state, depth + 1);
1058
+ }
1059
+ // Un-imported qualified name (FQN form `com.example.app.api.ApiPaths.ORDERS`):
1060
+ // try the longest dotted prefix that resolves to a file.
1061
+ const parts = name.split('.');
1062
+ for (let cut = parts.length - 2; cut >= 1; cut--) {
1063
+ const fqn = parts.slice(0, cut + 1).join('.');
1064
+ const target = resolveKotlinImportTarget(fqn, state.index);
1065
+ if (target !== null) {
1066
+ const member = parts.slice(cut + 1).join('.');
1067
+ return resolveWithState(target.fileKey, `${target.localName}.${member}`, state, depth + 1);
1068
+ }
1069
+ }
1070
+ // No import bound the head and no FQN prefix resolved — fall through. A
1071
+ // dotted name is ALSO a valid key in this file's own maps, so a same-file
1072
+ // qualified reference (`ApiPaths.ORDERS` inside the file declaring
1073
+ // `object ApiPaths`) resolves below.
1074
+ }
1075
+ // Name lookup: literals, then same-file expressions, then the import chase.
1076
+ // Expressions are folded HERE rather than handed to the agnostic core because
1077
+ // an operand may itself be a QUALIFIED reference (`X = ApiPaths.Y + "/tail"`)
1078
+ // and the core only knows bare names: it would look `ApiPaths.Y` up in maps
1079
+ // keyed by simple name, miss, and floor the whole chain to null.
1080
+ const mc = repo.get(fileKey);
1081
+ if (!mc)
1082
+ return null;
1083
+ const literal = mc.literals.get(name);
1084
+ if (literal !== undefined)
1085
+ return literal;
1086
+ const expr = mc.exprs.get(name);
1087
+ if (expr !== undefined)
1088
+ return foldOperands(fileKey, expr, state, depth + 1);
1089
+ const imp = mc.imports.get(name);
1090
+ if (imp !== undefined)
1091
+ return resolveImportedName(fileKey, imp, state, depth + 1);
1092
+ return null;
1093
+ }
1094
+ /**
1095
+ * Concatenate an operand list, resolving each `ref` through the qualified-aware
1096
+ * walk so `ApiPaths.BASE` works at every position, not just at the entry point.
1097
+ *
1098
+ * Bounded by {@link MAX_FOLD_LENGTH}: the depth cap bounds RECURSION but not
1099
+ * OUTPUT, which grows multiplicatively (`X = A + A; A = B + B; …`), so a
1100
+ * pathological chain would build a gigabyte-scale string before any cap fired.
1101
+ * Overrun floors to null.
1102
+ */
1103
+ function foldOperands(fileKey, operands, state, depth) {
1104
+ let out = '';
1105
+ for (const op of operands) {
1106
+ if (op.kind === 'literal') {
1107
+ out += op.value;
1108
+ }
1109
+ else {
1110
+ const piece = resolveWithState(fileKey, op.name, state, depth);
1111
+ if (piece === null)
1112
+ return null;
1113
+ out += piece;
1114
+ }
1115
+ if (out.length > MAX_FOLD_LENGTH)
1116
+ return null;
1117
+ }
1118
+ return out;
1119
+ }
1120
+ /**
1121
+ * Rewrite one BARE reference to the enclosing type that binds it, or leave it
1122
+ * bare when none does — the reference-site twin of the `qualifyRef` that
1123
+ * {@link extractKotlinModuleConstants} applies to sibling initializers.
1124
+ *
1125
+ * `enclosingTypes` is the chain of qualified type paths the reference sits
1126
+ * inside, INNERMOST FIRST (`['Outer.Inner', 'Outer']`). A companion member is
1127
+ * keyed `<EnclosingClass>.<NAME>` and is bound unqualified exactly within that
1128
+ * class body — including its nested types, which is why the whole chain is
1129
+ * walked and not just the innermost link. An `object`'s own members are in scope
1130
+ * inside its body under the same `<Owner>.<NAME>` key, so the same walk covers
1131
+ * both.
1132
+ *
1133
+ * Innermost-first, and BEFORE the file-level maps the fold consults next, is
1134
+ * Kotlin's own order: a companion member shadows a same-named top-level
1135
+ * declaration and a same-named import throughout its class. Outside that class
1136
+ * the bare name never means the companion at all, which is precisely what an
1137
+ * empty chain expresses.
1138
+ */
1139
+ function qualifyKotlinRefInEnclosingTypes(fileKey, name, repo, enclosingTypes) {
1140
+ // A dotted reference carries AN owner, not necessarily its OWN full one, so it
1141
+ // is resolved against the enclosing scopes exactly like a bare name. Kotlin
1142
+ // binds `Inner.Q` inside `object Outer` to `Outer.Inner.Q`, and returning it
1143
+ // unchanged looked for a key nothing declares. Worse, when the partial owner
1144
+ // also names a top-level declaration the unchanged form MATCHES it: with a
1145
+ // top-level `object ApiPaths` beside a nested one, `@GetMapping(ApiPaths.ORDERS)`
1146
+ // inside the class holding the nested object resolved to the top-level value —
1147
+ // a path the application does not serve, where the compiler binds the nested
1148
+ // one. The scopes are already qualified (`kotlinEnclosingTypeNames`), so
1149
+ // prefixing them onto whatever the reference spells is the whole rule.
1150
+ const mc = repo.get(fileKey);
1151
+ if (!mc)
1152
+ return name;
1153
+ const unfoldableDeclarations = unfoldableDeclarationsOf(mc);
1154
+ for (const type of enclosingTypes) {
1155
+ const key = `${type}.${name}`;
1156
+ if (mc.literals.has(key) || mc.exprs.has(key) || unfoldableDeclarations.has(key)) {
1157
+ return key;
1158
+ }
1159
+ }
1160
+ return name;
1161
+ }
1162
+ /**
1163
+ * Fold an inline operand list (e.g. `ApiPaths.BASE + "/orders"`) against
1164
+ * `fileKey`, or null when any piece is unresolvable (skip floor).
1165
+ *
1166
+ * `enclosingTypes` is the chain of type declarations the REFERENCE sits inside
1167
+ * (innermost first), and it is applied to the entry operands only — everything
1168
+ * deeper is either already qualified by
1169
+ * {@link extractKotlinModuleConstants} against its own declaring scope, or lives
1170
+ * in another file where this chain means nothing. Passing it empty answers
1171
+ * "what does this name mean at file level", which is the right question for a
1172
+ * reference outside any type and the only one a caller without position
1173
+ * information can honestly ask.
1174
+ *
1175
+ * An empty result is a SUCCESS, not a skip. `const val ROOT = ""` folds to `""`,
1176
+ * which `joinPath` then resolves against the class-level prefix exactly as it
1177
+ * resolves the literal `@GetMapping("")` — both mean "the prefix itself", the
1178
+ * Spring idiom for a collection root. Collapsing it into `null` would make a
1179
+ * resolved-empty path indistinguishable from an unresolvable one — the skip
1180
+ * floor is reserved for "could not fold", and nothing else in the resolver
1181
+ * conflates the two: {@link resolveKotlinConstant} returns `''` for an empty
1182
+ * constant, and `resolveOperands` in the shared core returns its fold
1183
+ * unfiltered. Matches `foldJavaOperands`, so the two JVM bindings do not
1184
+ * diverge on the same input.
1185
+ */
1186
+ export function foldKotlinOperands(fileKey, operands, repo, enclosingTypes = [], index) {
1187
+ // Allocation gate only: skip the map when there is nothing to qualify against
1188
+ // or no reference to qualify. It must not restate the rule — a dotted operand
1189
+ // is qualified too, so testing for a bare one here decided the result instead
1190
+ // of merely avoiding an allocation, and did so per-OPERAND-LIST: the same
1191
+ // `Inner.Q` folded or not depending on whether a SIBLING operand happened to
1192
+ // be bare.
1193
+ const needsQualify = enclosingTypes.length > 0 && operands.some((op) => op.kind === 'ref');
1194
+ const scoped = needsQualify
1195
+ ? operands.map((op) => op.kind === 'ref'
1196
+ ? {
1197
+ kind: 'ref',
1198
+ name: qualifyKotlinRefInEnclosingTypes(fileKey, op.name, repo, enclosingTypes),
1199
+ }
1200
+ : op)
1201
+ : operands;
1202
+ return foldOperands(fileKey, scoped, newFoldState(repo, index), 0);
1203
+ }