From 46eb0ebf5688c99967d6d40bb36ae929017555e7 Mon Sep 17 00:00:00 2001 From: henry201605 <31428013+henry201605@users.noreply.github.com> Date: Wed, 27 May 2026 16:33:52 +0800 Subject: [PATCH] feat(group): add Kotlin Spring HTTP route extraction (named + positional) (#1849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(group): add Kotlin Spring HTTP route extraction (named + positional) Mirror the Java Spring named-argument fix for Kotlin Spring Boot controllers. Adds a new `http-patterns/kotlin.ts` plugin behind the optional `tree-sitter-kotlin` grammar, registered for `.kt`/`.kts`. Both annotation forms produce providers: @RequestMapping("/api") / @GetMapping("/users") @RequestMapping(path = "/api") / @GetMapping(value = "/users") @RequestMapping(value = "/api") / @GetMapping(path = "/users") The Kotlin AST (fwcd/tree-sitter-kotlin) shares one node type (`value_argument`) for positional and named forms, so the queries are split: - positional: anchors `string_literal` as the first named child of `value_argument` via the immediate-child anchor `.` - named: explicitly captures `simple_identifier` and constrains it to `^(path|value)$` via `#match?`, mirroring the same safety bar enforced by `http-patterns/java.ts` and `topic-patterns/java.ts`. Without this constraint the query would also capture non-route attributes like `produces`, `consumes`, `headers`, `name`, `params`. `tree-sitter-kotlin` is an optionalDependency (parser-loader.ts, parse-worker.ts pattern). When the native binding is unavailable the plugin exports `null` and `index.ts` skips registering `.kt`/`.kts` so the orchestrator stays healthy. Scope: providers only. Consumer detection (RestTemplate, WebClient, OkHttp) on Kotlin call-site ASTs differs enough from Java's `method_invocation` shape to warrant a separate, focused PR. Tests: 11 new cases under `provider extraction — source-scan fallback (Strategy B)`, gated by the kotlin grammar availability. positive (8) - class @RequestMapping("/api/v1") (positional) - class @RequestMapping(path = "/api/v2") - class @RequestMapping(value = "/orders") - method @GetMapping(value = "/users") - method @GetMapping(path = "/users") - method @PostMapping(path = "/users") - mixed: class named-arg + method positional - mixed: class positional + method named-arg anti-regression (3) - @GetMapping(produces = "application/json") emits no provider - @GetMapping(name = "x", value = "/users") emits exactly one provider - @RequestMapping(path = "/api", name = "myApi") prefix stays /api Reverse-validated: removing the `(#match? @key "^(path|value)$")` constraint causes precisely the 3 anti-regression tests to fail. Local validation: - test/unit/group/http-route-extractor.test.ts: 54/54 - test/unit/group: 534/534 - npx tsc --noEmit: clean (modulo the pre-existing TS2339 in user-defined-conversions.ts merged from main, unrelated) * style(test): apply prettier line wrapping to long itKotlin titles --------- Co-authored-by: henry --- .../group/extractors/http-patterns/index.ts | 18 +- .../group/extractors/http-patterns/kotlin.ts | 239 +++++++++++++ .../unit/group/http-route-extractor.test.ts | 319 ++++++++++++++++++ 3 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 gitnexus/src/core/group/extractors/http-patterns/kotlin.ts diff --git a/gitnexus/src/core/group/extractors/http-patterns/index.ts b/gitnexus/src/core/group/extractors/http-patterns/index.ts index c62eb618c..4cc758218 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/index.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/index.ts @@ -2,6 +2,7 @@ import * as path from 'node:path'; import { isBladeTemplateFilename } from 'gitnexus-shared'; import type { HttpLanguagePlugin } from './types.js'; import { JAVA_HTTP_PLUGIN } from './java.js'; +import { KOTLIN_HTTP_PLUGIN } from './kotlin.js'; import { GO_HTTP_PLUGIN } from './go.js'; import { PYTHON_HTTP_PLUGIN } from './python.js'; import { PHP_HTTP_PLUGIN } from './php.js'; @@ -18,6 +19,11 @@ export type { HttpDetection, HttpLanguagePlugin, HttpRole } from './types.js'; * new language, drop a `http-patterns/.ts` that exports a * `HttpLanguagePlugin`, import it here and register the extension(s). * No edits to `http-route-extractor.ts` are required. + * + * Optional grammar plugins (e.g. `kotlin.ts`, which depends on the + * optionalDependency `tree-sitter-kotlin`) export `null` when the + * native binding is unavailable; we skip registration in that case so + * a missing optional grammar never crashes the orchestrator. */ const REGISTRY: Record = { '.java': JAVA_HTTP_PLUGIN, @@ -30,16 +36,26 @@ const REGISTRY: Record = { '.tsx': TSX_HTTP_PLUGIN, }; +if (KOTLIN_HTTP_PLUGIN) { + REGISTRY['.kt'] = KOTLIN_HTTP_PLUGIN; + REGISTRY['.kts'] = KOTLIN_HTTP_PLUGIN; +} + /** * Glob for files worth scanning for HTTP routes. Kept alongside the * registry so adding a new language widens the glob in one edit. * + * `.kt`/`.kts` are always present in the glob even when the optional + * `tree-sitter-kotlin` grammar isn't installed — `getPluginForFile` + * will return `undefined` for those files in that case, so the + * orchestrator simply skips them at scan time without erroring. + * * `.vue` / `.svelte` files are intentionally omitted for the source-scan * path — they need their own grammar-aware extraction and the existing * regex fallback for them was never very accurate. The graph-assisted * Strategy A still handles them via the ingestion pipeline. */ -export const HTTP_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,go,py,php}'; +export const HTTP_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,kt,kts,go,py,php}'; /** * Return the HTTP plugin registered for the given file's extension, diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts new file mode 100644 index 000000000..403beaf0d --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -0,0 +1,239 @@ +import Parser from 'tree-sitter'; +import { createRequire } from 'node:module'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * Kotlin HTTP plugin (Spring providers). + * + * Mirrors the Java plugin for Spring `@RequestMapping` class prefixes + * and `@(Get|Post|...)Mapping` method annotations on Kotlin Spring + * Boot controllers. Both positional shorthand (`@GetMapping("/x")`) + * and named annotation arguments (`@GetMapping(value = "/x")` and + * `@GetMapping(path = "/x")`) are supported. + * + * Consumer detection (RestTemplate / WebClient / OkHttp) is intentionally + * out of scope for this plugin — Kotlin call-site ASTs are sufficiently + * different from Java's `method_invocation` shape that they warrant a + * separate, focused follow-up. + * + * tree-sitter-kotlin (fwcd) AST shapes used here: + * class_declaration + * modifiers + * annotation + * constructor_invocation + * user_type → type_identifier ← annotation name + * value_arguments + * value_argument + * (simple_identifier "=")? ← absent for positional, present for named + * string_literal + * type_identifier ← class name + * + * tree-sitter-kotlin is an optional npm dependency — when its native + * binding is unavailable the plugin gracefully exports `null` and + * `http-patterns/index.ts` skips registration for `.kt`/`.kts` files. + */ + +const _require = createRequire(import.meta.url); + +/** Loaded lazily; null when the grammar binding isn't installed. */ +let Kotlin: unknown | null = null; +try { + Kotlin = _require('tree-sitter-kotlin'); +} catch { + Kotlin = null; +} + +const METHOD_ANNOTATION_TO_HTTP: Record = { + GetMapping: 'GET', + PostMapping: 'POST', + PutMapping: 'PUT', + DeleteMapping: 'DELETE', + PatchMapping: 'PATCH', +}; + +/** + * Build the plugin only if the Kotlin grammar is available. Compiling + * the queries against a null grammar would throw at module load time + * and abort the whole http-route-extractor module. + */ +function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { + // ─── Provider: Spring class-level @RequestMapping prefix ────────────── + // Two patterns mirror the Java plugin's positional vs named split: + // @RequestMapping("/api") → value_argument has string_literal as its first named child + // @RequestMapping(path = "/api") → value_argument has [simple_identifier @key, string_literal] + // @RequestMapping(value = "/api") → same as above, with key="value" + // + // Tree-sitter-kotlin grammar (fwcd 0.3.8) does NOT have a separate + // node for named arguments — both positional and named forms share + // `value_argument`. The positional pattern uses the immediate-child + // anchor `.` so it only matches when the string_literal is the FIRST + // named child (i.e. no preceding simple_identifier "=" prefix). The + // named pattern explicitly captures the simple_identifier and uses + // `#match?` to restrict it to `path`/`value`, matching the same + // safety bar that the Java plugin enforces (see java.ts and the + // sibling topic-patterns/java.ts for the analogous constraint). + // + // Without the `key:` constraint the named query would also capture + // unrelated attributes like `produces`, `consumes`, `headers`, + // `name`, `params` — emitting bogus route contracts (a regression + // identical to the one Claude flagged on PR #1834 for Java). + const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ + name: 'kotlin-spring-class-prefix', + language, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (value_arguments + (value_argument . (string_literal) @prefix))))) + (type_identifier) @cls) @class + `, + }, + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (value_arguments + (value_argument + (simple_identifier) @key (#match? @key "^(path|value)$") + (string_literal) @prefix))))) + (type_identifier) @cls) @class + `, + }, + ], + } satisfies LanguagePatterns>); + + // ─── Provider: Spring @(Get|Post|...)Mapping method annotations ─────── + // Same dual-pattern positional/named approach. The Kotlin AST puts the + // function name (`simple_identifier`) outside the `modifiers` subtree, + // so we capture it from `function_declaration` directly. + const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ + name: 'kotlin-spring-method-route', + language, + patterns: [ + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (value_arguments + (value_argument . (string_literal) @path))))) + (simple_identifier) @method_name) @method + `, + }, + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (value_arguments + (value_argument + (simple_identifier) @key (#match? @key "^(path|value)$") + (string_literal) @path))))) + (simple_identifier) @method_name) @method + `, + }, + ], + } satisfies LanguagePatterns>); + + /** + * Find the nearest enclosing class_declaration ancestor for a node, or + * null if the node is top-level. Mirrors the Java plugin's helper. + */ + function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let cur: Parser.SyntaxNode | null = node.parent; + while (cur) { + if (cur.type === 'class_declaration') return cur; + cur = cur.parent; + } + return null; + } + + /** + * Join a class-level prefix and a method-level path. Identical + * semantics to the Java plugin: strip leading/trailing slashes on + * the prefix, strip leading slashes on the method path, ensure a + * single slash between them. + */ + function joinPath(prefix: string, methodPath: string): string { + const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, ''); + const cleanSub = methodPath.replace(/^\/+/, ''); + if (!cleanPrefix) return `/${cleanSub}`; + return `/${cleanPrefix}/${cleanSub}`; + } + + return { + name: 'kotlin-http', + language, + scan(tree) { + const out: HttpDetection[] = []; + + // ─── Class prefixes ───────────────────────────────────────────── + const prefixByClassId = new Map(); + for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) { + const prefixNode = match.captures.prefix; + const classNode = match.captures.class; + if (!prefixNode || !classNode) continue; + const prefix = unquoteLiteral(prefixNode.text); + if (prefix !== null) prefixByClassId.set(classNode.id, prefix); + } + + // ─── Method routes ────────────────────────────────────────────── + for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { + const annNode = match.captures.ann; + const pathNode = match.captures.path; + const nameNode = match.captures.method_name; + const methodNode = match.captures.method; + if (!annNode || !pathNode || !methodNode) continue; + const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; + if (!httpMethod) continue; + const rawPath = unquoteLiteral(pathNode.text); + if (rawPath === null) continue; + const enclosingClass = findEnclosingClass(methodNode); + const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : ''; + const fullPath = joinPath(prefix, rawPath); + out.push({ + role: 'provider', + framework: 'spring', + method: httpMethod, + path: fullPath, + name: nameNode?.text ?? null, + confidence: 0.8, + }); + } + + return out; + }, + }; +} + +/** + * The exported plugin is `null` when tree-sitter-kotlin's native + * binding is unavailable. `http-patterns/index.ts` checks for null + * before registering `.kt`/`.kts` so missing optional grammars never + * crash the orchestrator. + */ +export const KOTLIN_HTTP_PLUGIN: HttpLanguagePlugin | null = Kotlin + ? buildKotlinPlugin(Kotlin) + : null; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 5a11cabc9..19e12cb8a 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -513,6 +513,325 @@ public class UserController { expect(providers.find((c) => c.contractId === 'http::GET::/myApi/users')).toBeUndefined(); }); + // ─── #1834 follow-up — Spring on Kotlin ────────────────────────── + // The same positional / named-argument distinction applies to + // Kotlin Spring Boot controllers. The Kotlin tree-sitter grammar + // (fwcd/tree-sitter-kotlin) produces a different AST shape than + // tree-sitter-java — both forms share `value_argument`, with the + // optional leading `simple_identifier "="` distinguishing named + // from positional. The plugin in `http-patterns/kotlin.ts` mirrors + // the safety bar from java.ts: positional uses `.` to anchor the + // string_literal as the first named child of `value_argument`, + // and the named pattern restricts the `simple_identifier` key to + // `^(path|value)$` to avoid capturing `produces`, `consumes`, + // `headers`, `name`, `params`, etc. + // + // tree-sitter-kotlin is an optionalDependency. If the binding is + // unavailable in the current test environment, `getPluginForFile` + // returns undefined for `.kt` files and we skip the suite. + const kotlinAvailable = getPluginForFile('Probe.kt') !== undefined; + const itKotlin = kotlinAvailable ? it : it.skip; + + itKotlin('extracts Kotlin @RequestMapping("/api/v1") (positional class prefix)', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-class-positional'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1") +class UserController { + @GetMapping("/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/api/v1/users'); + expect(route).toBeDefined(); + expect(route!.symbolName).toBe('list'); + expect(route!.meta.framework).toBe('spring'); + }); + + itKotlin('extracts Kotlin @RequestMapping(path = "/api/v2") (named class prefix)', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-class-named-path'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(path = "/api/v2") +class UserController { + @GetMapping("/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/api/v2/users'); + expect(route).toBeDefined(); + }); + + itKotlin( + 'extracts Kotlin @RequestMapping(value = "/orders") (named class prefix)', + async () => { + const dir = path.join(tmpDir, 'kotlin-spring-class-named-value'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/OrderController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(value = "/orders") +class OrderController { + @GetMapping("/list") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/orders/list')).toBeDefined(); + }, + ); + + itKotlin('extracts Kotlin method-level @GetMapping(value = "/users")', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-method-named-value'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserController { + @GetMapping(value = "/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/users'); + expect(route).toBeDefined(); + expect(route!.symbolName).toBe('list'); + }); + + itKotlin('extracts Kotlin method-level @GetMapping(path = "/users")', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-method-named-path-get'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserController { + @GetMapping(path = "/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined(); + }); + + itKotlin('extracts Kotlin method-level @PostMapping(path = "/users")', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-method-named-path-post'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserController { + @PostMapping(path = "/users") fun create() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::POST::/users'); + expect(route).toBeDefined(); + expect(route!.symbolName).toBe('create'); + }); + + itKotlin('combines Kotlin class named-arg prefix with method positional path', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-mixed-class-named-method-pos'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(path = "/api") +class UserController { + @GetMapping("/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + }); + + itKotlin('combines Kotlin class positional prefix with method named-arg path', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-mixed-class-pos-method-named'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api") +class UserController { + @GetMapping(value = "/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + }); + + itKotlin( + 'does NOT emit a Kotlin provider for @GetMapping(produces = ...) without path/value', + async () => { + // Anti-regression: without the `simple_identifier` key + // constraint, the named-arg query would capture + // `produces = "application/json"` and emit a bogus + // `http::GET::/application/json` contract. + const dir = path.join(tmpDir, 'kotlin-spring-produces-only'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/MisleadingController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class MisleadingController { + @GetMapping(produces = "application/json") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect( + providers.find((c) => c.contractId === 'http::GET::/application/json'), + ).toBeUndefined(); + const fromThisFile = providers.filter((c) => + c.symbolRef.filePath.endsWith('MisleadingController.kt'), + ); + expect(fromThisFile).toHaveLength(0); + }, + ); + + itKotlin( + 'emits exactly one Kotlin provider for @GetMapping(name = "...", value = "/users")', + async () => { + // Anti-regression: without the key constraint, both string + // literals would be captured as method paths, emitting two + // contracts (`/listUsers` + `/users`). + const dir = path.join(tmpDir, 'kotlin-spring-name-and-value'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserController { + @GetMapping(name = "listUsers", value = "/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const usersRoute = providers.find((c) => c.contractId === 'http::GET::/users'); + expect(usersRoute).toBeDefined(); + expect(usersRoute!.symbolName).toBe('list'); + + expect(providers.find((c) => c.contractId === 'http::GET::/listUsers')).toBeUndefined(); + + const fromThisFile = providers.filter((c) => + c.symbolRef.filePath.endsWith('UserController.kt'), + ); + expect(fromThisFile).toHaveLength(1); + }, + ); + + itKotlin( + 'uses Kotlin `path` (not non-route key) as class prefix when both appear', + async () => { + // Anti-regression: without the key constraint, the LAST captured + // value_argument would win in the prefix map. Here `name = "myApi"` + // appears after `path = "/api"` — the prefix must remain `/api`. + const dir = path.join(tmpDir, 'kotlin-spring-class-prefix-key-wins'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(path = "/api", name = "myApi") +class UserController { + @GetMapping("/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/myApi/users')).toBeUndefined(); + }, + ); + it('extracts Express router.get patterns', async () => { const dir = path.join(tmpDir, 'express'); fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });