fix(group): suppress Kotlin routes only when the class prefix resolves to no literal

Class-prefix suppression decided "is this prefix unresolvable?" from a
three-element allow-list of node types (`simple_identifier`,
`navigation_expression`, `additive_expression`). An allow-list is safe for
FOLDING, where a forgotten shape yields no route, but it is the wrong shape
for SUPPRESSION, where a forgotten shape means "emit unprefixed" — a route
the application does not serve. `java.ts` gates on the ABSENCE of a literal
(`if (!valueNode)`) for exactly this reason.

The predicate is now inverted: a class is marked unless its `path`/`value`
argument is provably literal, recursing into `[…]` and `arrayOf(…)`
elements and refusing an interpolated `string_literal`. Measured against
the previous behavior, with `@PostMapping(ApiPaths.ORDERS)` under each
class prefix, on an app serving `/api/v1/orders`:

  * `[ApiPaths.BASE]`                   `POST /orders` -> dropped
  * `arrayOf(ApiPaths.BASE)`            `POST /orders` -> dropped
  * `value = [ApiPaths.BASE]`           `POST /orders` -> dropped
  * `buildPath()`                       `POST /orders` -> dropped
  * `if (USE_V2) "/api/v2" else …`      `POST /orders` -> dropped
  * `"${ApiPaths.BASE}"`   `POST /${ApiPaths.BASE}/orders` -> dropped

The last one published raw source text as a served path; refusing an
interpolated literal also fixes it for LITERAL method routes, which emitted
`/${ApiPaths.BASE}/list` before this branch existed.

Two regressions this suppression had introduced are repaired, both by
consulting the literal-prefix map that the pass above already built and
declining to mark a class that has an entry in it:

  * `@RequestMapping("/lit", ApiPaths.BASE)` + `@GetMapping("/list")` lost
    `GET /lit/list` entirely. Kotlin's vararg spelling leaves a resolvable
    arm behind, and suppression exists to avoid wrong routes, not to
    discard right ones.
  * `@FeignClient(path = "/api")` + `@RequestMapping(ApiPaths.BASE)` lost
    its consumer, though `path` outranks `@RequestMapping` when the URL is
    assembled and made the prefix perfectly knowable.

Two Feign emission paths never consulted the unfoldable set at all:

  * `@FeignClient(path = CONST)` was invisible to the analysis, which
    matches `@RequestMapping` only, so the client fell through to the
    no-prefix fallback and published `GET /orders` for a call the service
    makes to `/api/v1/orders`. Collected as its own set, kept separate
    because `path` outranks `@RequestMapping` in both directions.
  * The `@RequestLine` loop resolves through the identical "path wins"
    fallback chain but had no guard, so one interface could suppress its
    `@(Get|…)Mapping` route and publish its `@RequestLine` route under the
    very same unresolvable prefix. Both lanes now judge alike.

Note for reviewers: the `@RequestLine` guard is not a regression fix — that
lane emitted a wrong unprefixed consumer before this branch too. It moves a
wrong route to no route, on both sides of the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Borozdenets Ilya 2026-08-28 09:36:07 +03:00
parent 6d5055db2f
commit 8fd58b490d
2 changed files with 450 additions and 48 deletions

View file

@ -55,9 +55,16 @@ import {
* is folded against a repo-wide Kotlin constant map built once per `extract()`
* run by `prepareRepo`, mirroring what the Java plugin does for the same shape
* in `java.ts`. An unresolvable fold skips the route (never a guessed path), and
* a CONSTANT class prefix suppresses every method route under that class — the
* rule `java.ts` applies too, because emitting those routes unprefixed would
* publish paths the application does not serve.
* a class prefix that resolves to NO literal at all suppresses every method
* route under that class — the rule `java.ts` applies too, because emitting
* those routes unprefixed would publish paths the application does not serve.
* A prefix that resolves only PARTLY (Kotlin's vararg spelling
* `@RequestMapping("/lit", ApiPaths.BASE)`) still publishes its resolvable arm:
* suppression exists to avoid wrong routes, not to discard right ones. On a
* `@FeignClient` the same rule is applied to whichever prefix GOVERNS, in the
* "path wins" order the URL is assembled in — `@FeignClient(path)` first, then
* the interface's `@RequestMapping` — and to both consumer lanes, `@(Get|...)Mapping`
* and `@RequestLine`.
*
* **Consumers** — four call-site patterns common in Kotlin
* Spring projects:
@ -149,9 +156,16 @@ const arrayOfArg = (cap: string): string => `(call_expression
(call_suffix (value_arguments (value_argument (string_literal) ${cap}))))`;
/**
* Expression node types a route path can be FOLDED from. A `string_literal` is
* deliberately absent: literal paths are already captured by the dedicated
* literal patterns, so admitting one here would emit the same route twice.
* Expression node types a METHOD route path can be FOLDED from. A
* `string_literal` is deliberately absent: literal paths are already captured by
* the dedicated literal patterns, so admitting one here would emit the same
* route twice.
*
* This is an allow-list on purpose, and only safe because it gates FOLDING: a
* shape missing from it yields no route, which is the skip floor. The
* unfoldable-CLASS-PREFIX analysis must not be written this way — there a shape
* missing from the list means "emit unprefixed", a wrong route — so it inverts
* the test instead (see `hasResolvableLiteralPathElement`).
*/
const FOLDABLE_PATH_EXPRESSIONS: ReadonlySet<string> = new Set([
'simple_identifier',
@ -178,6 +192,83 @@ function kotlinRouteArgumentExpression(arg: Parser.SyntaxNode): Parser.SyntaxNod
return arg.namedChild(1);
}
/**
* The `path = …` expression of one `@FeignClient` argument, or null.
*
* Deliberately narrower than {@link kotlinRouteArgumentExpression}: on a Feign
* client the positional argument and `value =` name a SERVICE, not a path, so
* only the explicit `path` key contributes a URL prefix. This mirrors the
* `#eq? @key "path"` guard the literal `@FeignClient` patterns use, and the
* `keyNode.text !== 'path'` guard `java.ts` applies to the same annotation.
*/
function kotlinFeignPathArgumentExpression(arg: Parser.SyntaxNode): Parser.SyntaxNode | null {
const first = arg.namedChild(0);
if (!first || first.type !== 'simple_identifier') return null;
if (!arg.children.some((c) => c.type === '=')) return null;
if (first.text !== 'path') return null;
return arg.namedChild(1);
}
/**
* Is `node` a string literal whose value is fully known at parse time — that is,
* a literal carrying no interpolation?
*
* tree-sitter-kotlin models `"$base/x"` and `"${base}/x"` as a `string_literal`
* whose named children INTERLEAVE `string_content` runs with interpolation nodes
* — `interpolation_identifier_start`/`interpolated_identifier` for the `$name`
* form, `interpolation_expression_start`/`interpolated_expression`/
* `interpolation_expression_end` for `${…}` — so the test has to be `every`, not
* `some`: `"pre${A.B}post"` carries `string_content` too. The route layer
* unquotes the RAW TEXT, so treating one as a literal publishes the source
* spelling — `/${ApiPaths.BASE}/orders` — as though the application served it.
* Escape sequences are NOT separate nodes in this grammar (`"/a\nb"` is one
* `string_content`), so this accepts exactly what it accepted before; a future
* grammar that split them would floor to "unknown" rather than to a de-escaped
* guess. Same test the constant resolver's `stringLiteralValue` applies, so a
* path is either literal on both sides or folded on neither.
*/
function isPlainStringLiteral(node: Parser.SyntaxNode): boolean {
if (node.type !== 'string_literal') return false;
return node.namedChildren.every((child) => child.type === 'string_content');
}
/**
* Element expressions of a Kotlin `arrayOf(...)` call, or null when `node` is
* not one. The JS mirror of the {@link arrayOfArg} query fragment, so the
* unfoldable-prefix analysis inspects exactly the elements the literal prefix
* patterns harvest.
*/
function kotlinArrayOfElements(node: Parser.SyntaxNode): Parser.SyntaxNode[] | null {
if (node.type !== 'call_expression') return null;
const callee = node.namedChild(0);
if (callee?.type !== 'simple_identifier' || callee.text !== 'arrayOf') return null;
const suffix = node.namedChildren.find((c) => c.type === 'call_suffix');
const args = suffix?.namedChildren.find((c) => c.type === 'value_arguments');
if (!args) return null;
return args.namedChildren
.filter((c) => c.type === 'value_argument')
.map((c) => c.namedChild(0))
.filter((c): c is Parser.SyntaxNode => c !== null);
}
/**
* Does this route-annotation path expression carry at least one element the
* literal prefix patterns can resolve to a real path?
*
* Accepts exactly the three shapes those patterns harvest — a bare literal, a
* `[…]` collection element, an `arrayOf(…)` element — and only when the literal
* is uninterpolated. Anything else (`ApiPaths.BASE`, `buildPath()`,
* `if (…) "/a" else "/b"`, a template) is NOT resolvable, which is the whole
* predicate the unfoldable-prefix analysis inverts.
*/
function hasResolvableLiteralPathElement(expr: Parser.SyntaxNode): boolean {
if (isPlainStringLiteral(expr)) return true;
if (expr.type === 'collection_literal') return expr.namedChildren.some(isPlainStringLiteral);
const elements = kotlinArrayOfElements(expr);
if (elements) return elements.some(isPlainStringLiteral);
return false;
}
// ─── Kotlin OkHttp builder verb-walk (parity with java-static-path.ts) ──
// Mirrors `inferOkHttpMethod`, adapted to the Kotlin grammar: a call `X.name(args)`
// is a `call_expression` whose callee is a `navigation_expression` (receiver +
@ -455,9 +546,11 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
// uses one `value_argument` node for both forms and 0.21.x has no negation to
// test the `=` token with.
//
// These deliberately match LITERAL arguments too (any `value_argument` does);
// the scan loops drop those via `FOLDABLE_PATH_EXPRESSIONS` so a literal route
// is emitted once, by the literal patterns.
// These deliberately match LITERAL arguments too (any `value_argument` does).
// The method-route loop drops those via `FOLDABLE_PATH_EXPRESSIONS` so a
// literal route is emitted once, by the literal patterns; the class-prefix
// collector instead KEEPS them and tests them for literalness, which is how a
// prefix that no literal pattern could resolve gets noticed at all.
const SPRING_CONST_CLASS_PREFIX_PATTERNS = compilePatterns({
name: 'kotlin-spring-const-class-prefix',
language,
@ -496,27 +589,94 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
],
} satisfies LanguagePatterns<Record<string, never>>);
const SPRING_CONST_FEIGN_PATH_PATTERNS = compilePatterns({
name: 'kotlin-spring-const-feign-path',
language,
patterns: [
{
meta: {},
query: `
(class_declaration
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#eq? @ann "FeignClient"))
(value_arguments (value_argument) @arg))))) @class
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
/**
* Ids of classes whose `@RequestMapping` prefix is a CONSTANT reference rather
* than a literal.
* Ids of classes whose `@RequestMapping` prefix cannot be resolved to any
* literal, so no route under them can be published at a path the application
* actually serves.
*
* The prefix is not folded: it also feeds the cross-file interface-inheritance
* pass, which has no repo context, so folding it in `scan` alone would make
* the two views disagree. Every route under such a class is dropped instead —
* dropping the prefix would publish the method at a path the application never
* serves, turning a missing fact into a wrong one. Same rule `java.ts` applies
* The predicate is INVERTED rather than an allow-list of non-literal node
* types: a class is marked unless its `path`/`value` argument is provably
* literal (recursing into `[…]` and `arrayOf(…)` elements, and refusing an
* interpolated `string_literal`). An allow-list has to enumerate every
* non-literal spelling and silently passes the ones it forgot —
* `[ApiPaths.BASE]`, `arrayOf(ApiPaths.BASE)`, `buildPath()`,
* `if (…) "/a" else "/b"` — each of which then publishes its methods at their
* UNPREFIXED path, a route the application does not serve. `java.ts` gates on
* the ABSENCE of a literal (`if (!valueNode)`) for the same reason.
*
* `resolvedPrefixes` is the literal prefix map built by the pass ABOVE, and a
* class holding an entry there is deliberately NOT marked: Kotlin's vararg
* spelling `@RequestMapping("/lit", ApiPaths.BASE)` leaves a resolvable `/lit`
* behind, and suppressing it would drop a route that IS derivable — trading a
* wrong route for a missing one, which is not the bargain this suppression
* exists to make. The prefix set is then partial (the constant arm is absent)
* exactly as it was before constant folding existed.
*
* The prefix is never folded here: it also feeds the cross-file
* interface-inheritance pass, which has no repo context, so folding it in
* `scan` alone would make the two views disagree. Same rule `java.ts` applies
* (`typesWithUnfoldablePrefix`); folding class prefixes cross-file is a
* follow-up on both sides. Used by BOTH `scan` and the inheritance-view
* collector, so the two cannot drift apart.
* collector — with the prefix map each has already built — so the two cannot
* drift apart.
*/
const collectUnfoldablePrefixClassIds = (tree: Parser.Tree): Set<number> => {
const collectUnfoldablePrefixClassIds = (
tree: Parser.Tree,
resolvedPrefixes: ReadonlyMap<number, string[]>,
): Set<number> => {
const ids = new Set<number>();
for (const match of runCompiledPatterns(SPRING_CONST_CLASS_PREFIX_PATTERNS, tree)) {
const argNode = match.captures.arg;
const classNode = match.captures.class;
if (!argNode || !classNode) continue;
if ((resolvedPrefixes.get(classNode.id) ?? []).length > 0) continue;
const expr = kotlinRouteArgumentExpression(argNode);
if (!expr || !FOLDABLE_PATH_EXPRESSIONS.has(expr.type)) continue;
if (!expr || hasResolvableLiteralPathElement(expr)) continue;
ids.add(classNode.id);
}
return ids;
};
/**
* Ids of `@FeignClient` interfaces whose `path` argument is present but not
* resolvable to a literal.
*
* `collectUnfoldablePrefixClassIds` cannot see these: it matches
* `@RequestMapping` only, so `@FeignClient(path = ApiPaths.BASE)` fell through
* to the `['']` prefix fallback and published the consumer at its unprefixed
* path — a call the service never makes. Kept as its own set rather than
* merged into the `@RequestMapping` one because `path` OUTRANKS
* `@RequestMapping` on a Feign client: an unresolvable `path` is fatal
* whatever the `@RequestMapping` says, and a resolvable `path` rescues a route
* whose `@RequestMapping` is a constant. The consumer lanes therefore consult
* the two in that same "path wins" order.
*/
const collectFeignUnfoldablePathClassIds = (tree: Parser.Tree): Set<number> => {
const ids = new Set<number>();
for (const match of runCompiledPatterns(SPRING_CONST_FEIGN_PATH_PATTERNS, tree)) {
const argNode = match.captures.arg;
const classNode = match.captures.class;
if (!argNode || !classNode) continue;
const expr = kotlinFeignPathArgumentExpression(argNode);
if (!expr || hasResolvableLiteralPathElement(expr)) continue;
ids.add(classNode.id);
}
return ids;
@ -998,6 +1158,12 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
const prefixNode = match.captures.prefix;
const classNode = match.captures.class;
if (!prefixNode || !classNode) continue;
// An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — unquoting
// its raw text would carry the source spelling into the shared type view
// as a served prefix. Refusing it here is also what lets the unfoldable
// analysis below mark such a class (it skips classes with a resolved
// prefix), so the two stay one decision rather than two.
if (!isPlainStringLiteral(prefixNode)) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) pushPrefix(prefixByClassId, classNode.id, prefix);
}
@ -1009,7 +1175,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
// noise into the shared type view, so it is left out — the same skip floor
// `java.ts`'s `collectSpringTypes` keeps.
const routesByMethodId = new Map<number, Array<{ method: string; path: string }>>();
const unfoldablePrefixClassIds = collectUnfoldablePrefixClassIds(tree);
const unfoldablePrefixClassIds = collectUnfoldablePrefixClassIds(tree, prefixByClassId);
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const pathNode = match.captures.path;
@ -1137,11 +1303,16 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
const prefixNode = match.captures.prefix;
const classNode = match.captures.class;
if (!prefixNode || !classNode) continue;
// An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — see
// `isPlainStringLiteral`. Refusing it here also lets the unfoldable
// analysis below mark such a class, since that skips classes whose
// prefix already resolved.
if (!isPlainStringLiteral(prefixNode)) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) pushPrefix(prefixByClassId, classNode.id, prefix);
}
const classesWithUnfoldablePrefix = collectUnfoldablePrefixClassIds(tree);
const classesWithUnfoldablePrefix = collectUnfoldablePrefixClassIds(tree, prefixByClassId);
// ─── OpenFeign client interfaces + HTTP Interface type prefixes ──
// In tree-sitter-kotlin an `interface` is a `class_declaration`, so a
@ -1155,11 +1326,12 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
if (!classNode) continue;
feignClassIds.add(classNode.id);
const prefixNode = match.captures.prefix;
if (prefixNode) {
if (prefixNode && isPlainStringLiteral(prefixNode)) {
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) pushPrefix(feignPrefixByClassId, classNode.id, prefix);
}
}
const feignClassesWithUnfoldablePath = collectFeignUnfoldablePathClassIds(tree);
const httpExchangePrefixByClassId = new Map<number, string[]>();
for (const match of runCompiledPatterns(SPRING_HTTP_EXCHANGE_CLASS_PATTERNS, tree)) {
const classNode = match.captures.class;
@ -1222,27 +1394,30 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
for (const { httpMethod, rawPath, nameNode, methodNode } of methodRoutes) {
const enclosingClass = findEnclosingClass(methodNode);
// A constant-valued class prefix cannot be resolved here, so every route
// under such a class is dropped rather than emitted at a wrong
// (unprefixed) path — the rule `java.ts` applies for Java.
//
// This reaches a @FeignClient INTERFACE too, because tree-sitter-kotlin
// models `interface` as a `class_declaration`, and it should: Spring
// Cloud prepends a type-level @RequestMapping to every method of the
// client, so an unfoldable prefix makes the remote URL unknowable
// whether or not @FeignClient(path) is also present. Java diverges here
// only by accident of its grammar — `findEnclosingClass` skips
// `interface_declaration`, so `java.ts` still emits such a consumer at
// its unprefixed path. Aligning Java is a change to Java's behavior and
// belongs in its own follow-up, not in the Kotlin binding.
if (enclosingClass && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue;
// A @(Get|...)Mapping inside a @FeignClient interface is an OpenFeign
// consumer (a remote call), not a route this service serves.
if (enclosingClass && feignClassIds.has(enclosingClass.id)) {
// Whichever prefix GOVERNS must be resolvable, or the remote URL is
// unknowable and an unprefixed consumer would be a call this service
// never makes. Checked in the same "path wins" order the fallback
// below resolves in, so an unresolvable `@RequestMapping` does not
// suppress a client whose literal `@FeignClient(path)` outranks it,
// and an unresolvable `path` is fatal even when `@RequestMapping` is
// a literal.
//
// This reaches a Feign INTERFACE at all because tree-sitter-kotlin
// models `interface` as a `class_declaration`, and it should: Spring
// Cloud prepends the governing prefix to every method of the client.
// Java diverges only by accident of its grammar — `findEnclosingClass`
// skips `interface_declaration`, so `java.ts` still emits such a
// consumer at its unprefixed path. Aligning Java is a change to Java's
// behavior and belongs in its own follow-up, not in the Kotlin binding.
if (feignClassesWithUnfoldablePath.has(enclosingClass.id)) continue;
const feignPrefixes = feignPrefixByClassId.get(enclosingClass.id);
if (!feignPrefixes && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue;
// @FeignClient(path) wins over @RequestMapping; a multi-element prefix
// yields one consumer per (prefix × this route).
const prefixes = feignPrefixByClassId.get(enclosingClass.id) ??
prefixByClassId.get(enclosingClass.id) ?? [''];
const prefixes = feignPrefixes ?? prefixByClassId.get(enclosingClass.id) ?? [''];
for (const prefix of prefixes) {
out.push({
role: 'consumer',
@ -1256,6 +1431,10 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
}
continue;
}
// An unresolvable class prefix leaves no path this service serves, so
// every route under such a class is dropped rather than emitted at a
// wrong (unprefixed) one — the rule `java.ts` applies for Java.
if (enclosingClass && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue;
// A @(Get|...)Mapping on a (non-Feign) interface declares a route
// *contract*, not a route this service serves — the implementing
// @RestController is the provider, emitted via scanProject's interface
@ -1425,13 +1604,21 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
if (!parsed) continue;
const enclosingClass = findEnclosingClass(methodNode);
if (!enclosingClass || !isKotlinInterface(enclosingClass)) continue;
// The same governing-prefix resolvability guard the @(Get|...)Mapping-in-Feign
// lane applies, in the same "path wins" order — this loop resolves through
// the identical fallback chain, so an unresolvable governing prefix leaves
// the remote URL just as unknowable here. Without it a single interface
// could suppress its @(Get|...)Mapping routes and publish its @RequestLine
// routes under the very same unresolvable prefix.
if (feignClassesWithUnfoldablePath.has(enclosingClass.id)) continue;
const feignPrefixes = feignPrefixByClassId.get(enclosingClass.id);
if (!feignPrefixes && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue;
// Mirror java.ts (which pre-merges the @RequestMapping fallback into
// feignPrefixByInterfaceId, "path wins"): @FeignClient(path) wins, else
// the interface's class-level @RequestMapping prefix, else none. Without
// the prefixByClassId fallback Kotlin dropped the class prefix that Java
// applies — the same fallback chain the @GetMapping-in-Feign path uses above.
const prefixes = feignPrefixByClassId.get(enclosingClass.id) ??
prefixByClassId.get(enclosingClass.id) ?? [''];
const prefixes = feignPrefixes ?? prefixByClassId.get(enclosingClass.id) ?? [''];
for (const prefix of prefixes) {
out.push({
role: 'consumer',

View file

@ -10,14 +10,26 @@
* Asserted:
* • the four reference forms fold to the right provider contract — qualified
* access, fully-qualified name, single-name import, `+`-concatenation;
* • a CONSTANT class prefix suppresses every method route under that class,
* literal ones included (the prefix is not knowable here, and emitting the
* methods unprefixed would publish paths the application does not serve) —
* the rule `java.ts` already applies — in both the positional and the
* `value =` spelling, which take different branches of
* `kotlinRouteArgumentExpression`;
* • an OpenFeign consumer folds a constant method path, and is suppressed by a
* constant interface prefix for the same reason a provider is;
* • a class prefix that resolves to NO literal suppresses every method route
* under that class, literal ones included (the prefix is not knowable here,
* and emitting the methods unprefixed would publish paths the application
* does not serve) — the rule `java.ts` already applies. Pinned across every
* spelling that reaches the suppression, because the analysis inverts a
* literalness test rather than listing node types: a bare constant, both
* argument spellings, `[…]`, `arrayOf(…)`, a call, an `if`, and an
* interpolated string;
* • a prefix that resolves only PARTLY still publishes its resolvable arm —
* Kotlin's vararg `@RequestMapping("/lit", ApiPaths.BASE)` keeps `/lit`,
* because suppression exists to avoid wrong routes, not to discard right
* ones;
* • a `@RequestMapping` with no path argument at all is not a prefix and does
* not suppress anything;
* • an OpenFeign consumer folds a constant method path, and both consumer
* lanes (`@(Get|…)Mapping` and `@RequestLine`) are suppressed by an
* unresolvable governing prefix for the same reason a provider is —
* resolved in "path wins" order, so a literal `@FeignClient(path)` rescues
* an interface whose `@RequestMapping` is a constant, and an unresolvable
* `path` is fatal on its own;
* • an unresolvable constant emits nothing rather than a guessed path;
* • without a repo context the plugin emits nothing (the documented skip
* floor, and the branch the 1-argument guards cannot reach);
@ -208,6 +220,99 @@ class OrderController {
).toEqual([]);
});
/**
* A controller carrying `prefix` as its class-level `@RequestMapping`, with
* one constant-valued and one literal route under it. `decls` holds any
* top-level declaration the prefix expression refers to.
*/
const controllerWithPrefix = (prefix: string, decls = ''): Record<string, string> => ({
[CONSTS]: CONSTS_SRC,
[CONTROLLER]: `package com.example.app.web
import com.example.app.api.ApiPaths
${decls}
@RestController
@RequestMapping(${prefix})
class OrderController {
@GetMapping(ApiPaths.ORDERS)
fun list() {}
@GetMapping("/literal")
fun literal() {}
}
`,
});
// Every prefix spelling that resolves to no literal, and so must suppress.
// This is a table rather than one representative case on purpose: the two
// tests above pin a BARE constant, which any node-type allow-list would also
// catch. These are the shapes such a list forgets — and forgetting one does
// not degrade to "no route", it publishes every method of the class at its
// UNPREFIXED path, which the application does not serve. The `if` and the
// interpolated string are the two that need no constant map at all to go
// wrong, and the `[…]` / `arrayOf(…)` pair matters because the literal
// prefix patterns DO reach inside both — so a naive "is it a literal
// container?" test would pass them straight through.
it.each([
['a collection literal holding a constant', '[ApiPaths.BASE]', ''],
['an arrayOf(…) holding a constant', 'arrayOf(ApiPaths.BASE)', ''],
['a named collection literal holding a constant', 'value = [ApiPaths.BASE]', ''],
['a function call', 'buildPath()', '\nfun buildPath(): String = ApiPaths.BASE\n'],
['an interpolated string', '"${ApiPaths.BASE}"', ''],
['an if expression', 'if (USE_V2) "/api/v2" else "/api/v1"', '\nconst val USE_V2 = false\n'],
])('suppresses every method route under a class prefix that is %s', (_label, prefix, decls) => {
expect(providers(controllerWithPrefix(prefix, decls))).toEqual([]);
});
it('keeps both routes when that same class prefix is a plain literal', () => {
// The control for the table above: same two methods, same helper, a prefix
// the extractor can resolve. Without it an empty result there would be
// indistinguishable from the fixture failing to produce routes at all.
expect(providers(controllerWithPrefix('"/api"'))).toEqual([
'GET /api/api/v1/orders',
'GET /api/literal',
]);
});
it('keeps the resolvable arm of a PARTLY resolvable class prefix', () => {
// Kotlin's vararg spelling. `/lit` is a real prefix the application really
// serves, so the routes under it are derivable and must survive; only the
// `ApiPaths.BASE` arm is missing from the result, exactly as it was before
// constant folding existed. Marking the class unfoldable here would trade a
// wrong route for a missing one, which is not the bargain suppression makes.
expect(providers(controllerWithPrefix('"/lit", ApiPaths.BASE'))).toEqual([
'GET /lit/api/v1/orders',
'GET /lit/literal',
]);
// Same shape spelled as one collection argument.
expect(providers(controllerWithPrefix('["/lit", ApiPaths.BASE]'))).toEqual([
'GET /lit/api/v1/orders',
'GET /lit/literal',
]);
});
it('does not treat a @RequestMapping without a path argument as a prefix', () => {
// `produces` is not a path, so this class has no prefix — not an
// unresolvable one. Suppressing here would drop routes that are correct and
// complete as written.
expect(
providers({
[CONSTS]: CONSTS_SRC,
[CONTROLLER]: `package com.example.app.web
import com.example.app.api.ApiPaths
@RestController
@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE])
class OrderController {
@GetMapping(ApiPaths.ORDERS)
fun list() {}
}
`,
}),
).toEqual(['GET /api/v1/orders']);
});
it('still applies a LITERAL class prefix to a folded method path', () => {
expect(
providers({
@ -317,6 +422,116 @@ interface OrderClient {
).toEqual(['GET /api/v1/orders']);
});
it('drops a @FeignClient consumer whose `path` argument is a CONSTANT', () => {
// `path` is the Feign client's own prefix and is never a `@RequestMapping`,
// so the class-prefix analysis cannot see it. Left unchecked, this interface
// falls through to the no-prefix fallback and publishes a remote call to
// `/api/v1/orders` as a call to `/orders` — a consumer edge pointing at a
// route no service serves.
const files = {
[CONSTS]: CONSTS_SRC,
[CLIENT]: `package com.example.app.client
import com.example.app.api.ApiPaths
@FeignClient(name = "orders", path = ApiPaths.BASE)
interface OrderClient {
@GetMapping(ApiPaths.ORDERS)
fun list()
}
`,
};
expect(consumers(files)).toEqual([]);
// Control: the same interface with a LITERAL `path` is still detected.
expect(
consumers({
...files,
[CLIENT]: files[CLIENT].replace('path = ApiPaths.BASE', 'path = "/svc"'),
}),
).toEqual(['GET /svc/api/v1/orders']);
});
it('lets a literal @FeignClient(path) outrank a CONSTANT @RequestMapping', () => {
// `path` wins over `@RequestMapping` when the URL is assembled, so it has to
// win when resolvability is judged too — otherwise an interface whose real
// prefix is perfectly knowable loses its consumer to a `@RequestMapping`
// that never governed it.
expect(
consumers({
[CONSTS]: CONSTS_SRC,
[CLIENT]: `package com.example.app.client
import com.example.app.api.ApiPaths
@FeignClient(name = "orders", path = "/svc")
@RequestMapping(ApiPaths.BASE)
interface OrderClient {
@GetMapping("/orders")
fun list()
}
`,
}),
).toEqual(['GET /svc/orders']);
});
it('drops a @RequestLine consumer under an unresolvable interface prefix', () => {
// `@RequestLine` carries its own verb and path but is still prefixed by the
// interface, and it resolves through the same "path wins" fallback chain as
// the `@(Get|…)Mapping` lane — so an unresolvable governing prefix leaves
// the remote URL just as unknowable here.
const files = {
[CONSTS]: CONSTS_SRC,
[CLIENT]: `package com.example.app.client
import com.example.app.api.ApiPaths
@FeignClient(name = "orders")
@RequestMapping(ApiPaths.BASE)
interface OrderClient {
@RequestLine("GET /list")
fun list()
}
`,
};
expect(consumers(files)).toEqual([]);
// Control: a literal interface prefix still yields the prefixed consumer.
expect(
consumers({
...files,
[CLIENT]: files[CLIENT].replace(
'@RequestMapping(ApiPaths.BASE)',
'@RequestMapping("/lit")',
),
}),
).toEqual(['GET /lit/list']);
});
it('judges @RequestLine and @(Get|…)Mapping alike on ONE interface', () => {
// Both lanes read the same prefix through the same fallback chain, so they
// must reach the same verdict on it. A guard on only one of them lets the
// interface suppress one route and publish the other under the very same
// unresolvable prefix — a self-inconsistency visible in a single scan.
expect(
consumers({
[CONSTS]: CONSTS_SRC,
[CLIENT]: `package com.example.app.client
import com.example.app.api.ApiPaths
@FeignClient(name = "orders")
@RequestMapping(ApiPaths.BASE)
interface OrderClient {
@GetMapping(ApiPaths.ORDERS)
fun list()
@RequestLine("GET /list")
fun listLegacy()
}
`,
}),
).toEqual([]);
});
it('leaves literal routes unchanged and emits each exactly once', () => {
expect(
providers({