fix(group): admit backtick-quoted constants, and overlay a same-file val that imports nothing

Two gate defects, both reported by the review bot and both reproduced before
being fixed.

`isKotlinConstantFile` matched only `\w+` for a declaration's name, so a file
whose constants are backtick-quoted — `const val ` + "`ORDERS`" + ` = "/orders"` — failed both
arms and was never parsed into the repo constant map. The resolver supports
backtick identifiers everywhere else: `unquoteKotlinIdentifier` strips the
quoting at every point a name becomes a key or a lookup. So the gate was
NARROWER than the extractor, which is the one direction its arms exist to
exclude, and a cross-file reference to such a constant floored to skip.
Measured: the route emitted nothing, and emits `GET /orders` now.

The on-demand overlay in `scan` admitted the file's extraction only when it had
imports. A file declaring a top-level non-`const` `val` is already excluded from
the pre-pass map (no `const`, no `object`), so this branch is its only chance,
and an import-only test discarded exactly the constants the route needed. The
guard now matches the admission test the pre-pass itself applies.

The bot stated this second one more broadly than it holds. Measured, any import
at all masks it — a realistic Spring controller always has one — so the failure
needs all three of: a top-level non-`const` `val`, no `object` in the file, and
no imports. Narrow, but real, and the fix costs one predicate.

Verified with the differential probe: both cases go from no detection to the
correct route, and all 41 existing fixtures are byte-identical before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Borozdenets Ilya 2026-08-28 13:40:45 +03:00
parent e0da781714
commit b9a4d7edff
4 changed files with 102 additions and 3 deletions

View file

@ -1374,7 +1374,15 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
if (kotlinCtx.constants.has(fileKey)) return foldConstants;
try {
const mc = extractKotlinModuleConstants(tree);
if (mc.imports.size > 0) {
// Same admission test the pre-pass applies above. It used to be
// `imports.size > 0` alone, which dropped a file declaring a top-level
// non-`const` `val` and importing nothing: the gate excludes it from
// the pre-pass map (no `const`, no `object`), so this branch is its
// only chance, and an import-only test threw it away. Any import at
// all masked the bug, which is why a realistic controller never hit
// it — the failure needs a file with a top-level `val`, no `object`
// and no imports.
if (mc.literals.size > 0 || mc.exprs.size > 0 || mc.imports.size > 0) {
const merged = new Map(kotlinCtx.constants);
merged.set(fileKey, mc);
foldConstants = merged;

View file

@ -279,10 +279,20 @@ const MAX_FOLD_DEPTH = 32;
* contains any `val … =`, function locals included — very nearly the whole
* repository, in a pass whose entire purpose is to avoid parsing it. That trade
* has not been measured, so the gap is recorded here rather than papered over.
*
* Both name arms accept a BACKTICK-QUOTED identifier as well as a bare one,
* because the extractor does: `unquoteKotlinIdentifier` strips the quoting
* everywhere a name becomes a key, so `const val \`ORDERS\` = "/orders"` is a
* constant this module resolves. A gate that matched only `\w+` rejected the
* file outright and the reference floored to skip — a gate narrower than the
* extractor, which is the one direction the arms above are meant to exclude.
*/
const CONST_VAL_RE = /\bconst\s+val\s+\w+\s*(?::[^=\n{}()]{0,60})?=/;
const KOTLIN_NAME = String.raw`(?:\w+|\`[^\`\n]+\`)`;
const CONST_VAL_RE = new RegExp(
String.raw`\bconst\s+val\s+${KOTLIN_NAME}\s*(?::[^=\n{}()]{0,60})?=`,
);
const OBJECT_DECL_RE = /\bobject\b/;
const VAL_BINDING_RE = /\bval\s+\w+\s*(?::[^=\n{}()]{0,60})?=/;
const VAL_BINDING_RE = new RegExp(String.raw`\bval\s+${KOTLIN_NAME}\s*(?::[^=\n{}()]{0,60})?=`);
export function isKotlinConstantFile(source: string): boolean {
if (CONST_VAL_RE.test(source)) return true;

View file

@ -933,6 +933,57 @@ object ApiPaths {
).toEqual(['GET /api/v1/orders']);
});
it('folds a same-file top-level `val` even when the file imports nothing', () => {
// Three conditions have to line up for this to break, which is why a
// realistic controller never hit it: the constant is a top-level non-`const`
// `val` (so `isKotlinConstantFile` rejects the file and the pre-pass never
// indexes it), the file declares no `object` (the gate's other arm), and it
// imports nothing. The on-demand overlay in `scan` is the file's only
// remaining chance, and it used to admit the extraction only when the file
// had imports — throwing away the very constants the route needs.
expect(
providers({
[CONTROLLER]: `package com.example.app.web
val PATH = "/orders"
@RestController
@RequestMapping("/api/v1")
class OrderController {
@GetMapping(PATH)
fun list() {}
}
`,
}),
).toEqual(['GET /api/v1/orders']);
});
it('folds a backtick-quoted constant declared in its own file', () => {
// The gate decides whether a file is parsed into the repo map at all, so a
// gate that rejects backticks silently drops a constant the resolver can
// fold — a cross-file reference to it then floors to skip.
expect(
providers({
[CONSTS]: `package com.example.app.api
object ApiPaths {
const val \`ORDERS\` = "/api/v1/orders"
}
`,
[CONTROLLER]: `package com.example.app.web
import com.example.app.api.ApiPaths
@RestController
class OrderController {
@GetMapping(ApiPaths.ORDERS)
fun list() {}
}
`,
}),
).toEqual(['GET /api/v1/orders']);
});
it('leaves literal routes unchanged and emits each exactly once', () => {
expect(
providers({

View file

@ -1182,6 +1182,36 @@ object \`ApiPaths\` {
class OrderService {
fun list(): List<String> = emptyList()
}
`),
).toBe(false);
});
it('admits a backtick-quoted name, because the extractor resolves one', () => {
// A gate NARROWER than the extractor costs a fact: the file is never
// parsed into the repo map, so a cross-file reference to the constant
// floors to skip. `unquoteKotlinIdentifier` strips the quoting everywhere
// a name becomes a key, so these declarations are ones this module folds.
expect(isKotlinConstantFile('const val `ORDERS` = "/api/v1/orders"')).toBe(true);
expect(isKotlinConstantFile('object O { val `ORDERS`: String = "/api/v1/orders" }')).toBe(
true,
);
expect(
isKotlinConstantFile('class C { companion object { const val `O` = "/orders" } }'),
).toBe(true);
});
it('does not let the backtick arm widen into a file with no carrier', () => {
// The control for the arm above: accepting backticks must not turn the
// gate into "any file mentioning val", which is the whole repository.
expect(
isKotlinConstantFile(`package com.example.app.web
class OrderService {
fun list(): List<String> {
val \`local name\` = "not a constant"
return listOf(\`local name\`)
}
}
`),
).toBe(false);
});