diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 2a7f450d7..610280c4f 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -406,7 +406,9 @@ export function resolveRouteHandlerSymbols( httpMethod: string | null | undefined, symbolId: string | undefined, ) => { - if (!routePath) return; + // An empty path is a valid, pathless mapping and normalizes to either `/` + // or its class/router prefix. Only null means the extractor had no route. + if (routePath === null) return; const url = normalizeExtractedRoutePath(routePath, prefix); const key = routeNodeKey(normalizeRouteMethod(httpMethod), url); if (claimed.has(key)) return; // first-writer-wins: later same-key routes can't override diff --git a/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts b/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts index 928f3e805..9754d40c8 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts @@ -124,7 +124,13 @@ export function parseSpringAnnotationArguments( const body = annotationText.slice(open + 1, close).trim(); if (body.length === 0) return []; const rawArguments = splitTopLevel(body, ','); - if (rawArguments === null || rawArguments.some((argument) => argument.length === 0)) return null; + if (rawArguments === null) return null; + // Kotlin (and some formatters) allow a trailing comma. An empty *middle* + // argument is still invalid and fail-closed. + while (rawArguments.at(-1)?.length === 0) { + rawArguments.pop(); + } + if (rawArguments.some((argument) => argument.length === 0)) return null; const parsed: SpringAnnotationArgument[] = []; for (const raw of rawArguments) { diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 19ad900b9..35a0854b3 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -46,6 +46,11 @@ import { extractKotlinRuntimeSymbolProperties, kotlinRuntimeSymbolStrategy, } from './kotlin/spring-actuator.js'; +import { extractKotlinSpringRoutes } from '../route-extractors/kotlin-spring.js'; +import { + extractKotlinModuleConstants, + foldKotlinOperands, +} from '../route-extractors/kotlin-const-resolver.js'; /** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method). * Kotlin grammar uses function_declaration for both top-level functions and class methods. @@ -210,4 +215,9 @@ export const kotlinProvider = defineLanguage({ receiverBinding: kotlinReceiverBinding, arityCompatibility: kotlinArityCompatibility, synthesizeStructureMembers: synthesizeLombokAccessors, + + // ── Spring decorator routes + composed path constants (#3130) ── + extractDecoratorRoutes: extractKotlinSpringRoutes, + extractModuleConstants: extractKotlinModuleConstants, + foldRoutePathOperands: foldKotlinOperands, }); diff --git a/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts b/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts index a14dd9ca0..311612e2d 100644 --- a/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts +++ b/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts @@ -123,16 +123,11 @@ * * WHERE THIS IS WIRED. Java reaches its binding from BOTH layers: the group * extractor (`group/extractors/http-patterns/java.ts`) and the ingestion - * provider (`languages/java.ts`, via `extractModuleConstants` + - * `foldRoutePathOperands`). Kotlin is wired into the GROUP layer only, because - * the ingestion fold in `pipeline-phases/parse-impl.ts` runs exclusively over - * `decoratorRoutes` — and `languages/kotlin.ts` declares no - * `extractDecoratorRoutes`, since the ingestion Spring extractor (`spring.ts`) - * is bound to `tree-sitter-java` and its node types. Declaring the constant - * hooks on the Kotlin provider today would harvest a map on every Kotlin file - * that nothing consumes. An ingestion-side Kotlin route extractor is the - * prerequisite; when it lands, this binding is what its provider hooks should - * point at, and no change here is needed. + * provider (`languages/java.ts`). Kotlin now does the same: the group extractor + * (`group/extractors/http-patterns/kotlin.ts`) plus `languages/kotlin.ts` + * (`extractDecoratorRoutes`, `extractModuleConstants`, `foldRoutePathOperands`). + * The dedicated ingestion walker is `route-extractors/kotlin-spring.ts`; it + * does not reuse Java `spring.ts`. */ import type Parser from 'tree-sitter'; diff --git a/gitnexus/src/core/ingestion/route-extractors/kotlin-spring.ts b/gitnexus/src/core/ingestion/route-extractors/kotlin-spring.ts new file mode 100644 index 000000000..1ac21e2f9 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/kotlin-spring.ts @@ -0,0 +1,326 @@ +/** + * Kotlin Spring MVC route annotations for the ingestion pipeline (#3130). + * + * This module only walks a caller-provided tree. In particular, it does not + * import or load tree-sitter-kotlin at module initialization, so platforms + * without the optional grammar can still load the language-provider registry. + */ +import type Parser from 'tree-sitter'; +import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js'; +import { + intersectSpringHttpMethods, + springAnnotationHttpMethods, + unquoteSpringLiteral, +} from './spring-shared.js'; +import { + extractKotlinModuleConstants, + parseKotlinConstOperands, + unfoldableDeclarationsOf, + unquoteKotlinIdentifier, + type KotlinModuleConstants, + type Operand, +} from './kotlin-const-resolver.js'; + +/** Direct declaration annotations in source order. */ +function declarationAnnotations(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const modifiers = node.namedChildren.find((child) => child.type === 'modifiers'); + return modifiers?.namedChildren.filter((child) => child.type === 'annotation') ?? []; +} + +/** `@Foo`, `@Foo(...)`, or `@a.b.Foo(...)` → `Foo`. */ +function annotationName(annotation: Parser.SyntaxNode): string | null { + const constructor = annotation.namedChildren.find( + (child) => child.type === 'constructor_invocation', + ); + const userType = + annotation.namedChildren.find((child) => child.type === 'user_type') ?? + constructor?.namedChildren.find((child) => child.type === 'user_type'); + const identifiers = + userType?.namedChildren.filter((child) => child.type === 'type_identifier') ?? []; + const identifier = identifiers.at(-1); + return identifier ? unquoteKotlinIdentifier(identifier.text) : null; +} + +function annotationArguments(annotation: Parser.SyntaxNode): Parser.SyntaxNode[] { + const constructor = annotation.namedChildren.find( + (child) => child.type === 'constructor_invocation', + ); + const values = constructor?.namedChildren.find((child) => child.type === 'value_arguments'); + return values?.namedChildren.filter((child) => child.type === 'value_argument') ?? []; +} + +interface AnnotationArgument { + readonly name?: string; + readonly expression: Parser.SyntaxNode; +} + +/** + * Kotlin represents positional and named annotation arguments with the same + * `value_argument` node. A direct `=` token distinguishes the named form. + */ +function readAnnotationArgument(argument: Parser.SyntaxNode): AnnotationArgument | null { + const named = argument.children.some((child) => child.type === '='); + if (!named) { + const expression = argument.namedChild(0); + return expression ? { expression } : null; + } + const key = argument.namedChild(0); + const expression = argument.namedChild(1); + if (key?.type !== 'simple_identifier' || !expression) return null; + return { name: unquoteKotlinIdentifier(key.text), expression }; +} + +function routeArguments(annotation: Parser.SyntaxNode): AnnotationArgument[] | null { + const out: AnnotationArgument[] = []; + for (const argument of annotationArguments(annotation)) { + const parsed = readAnnotationArgument(argument); + if (!parsed) return null; + if (parsed.name === undefined || parsed.name === 'path' || parsed.name === 'value') { + out.push(parsed); + } + } + return out; +} + +/** A fully static Kotlin string literal: no `$name` or `${expr}` children. */ +function isPlainStringLiteral(node: Parser.SyntaxNode): boolean { + return ( + node.type === 'string_literal' && + node.namedChildren.every((child) => child.type === 'string_content') + ); +} + +/** + * Empty `[]` / `arrayOf()` is Spring "no path", not an unresolvable prefix. + * tree-sitter-kotlin may put a zero-width recovery child inside `[]`. + */ +function isEmptyKotlinPathCollection(node: Parser.SyntaxNode): boolean { + if (node.type === 'collection_literal') { + return node.namedChildren.every((child) => child.text.length === 0); + } + if (node.type !== 'call_expression') return false; + const callee = node.namedChild(0); + if (callee?.type !== 'simple_identifier' || unquoteKotlinIdentifier(callee.text) !== 'arrayOf') { + return false; + } + const suffix = node.namedChildren.find((child) => child.type === 'call_suffix'); + const args = suffix?.namedChildren.find((child) => child.type === 'value_arguments'); + if (!args) return true; + return args.namedChildren.every((child) => child.type !== 'value_argument'); +} + +function isKotlinInterface(node: Parser.SyntaxNode): boolean { + return node.children.some((child) => child.type === 'interface'); +} + +function isAbstractOrSealedClass(node: Parser.SyntaxNode): boolean { + const modifiers = node.namedChildren.find((child) => child.type === 'modifiers'); + return ( + modifiers?.namedChildren.some((child) => { + if (child.type !== 'inheritance_modifier' && child.type !== 'class_modifier') { + return false; + } + const text = child.text.trim(); + return text === 'abstract' || text === 'sealed'; + }) === true + ); +} + +function directFunctions(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const body = node.namedChildren.find((child) => child.type === 'class_body'); + return body?.namedChildren.filter((child) => child.type === 'function_declaration') ?? []; +} + +function functionName(node: Parser.SyntaxNode): string | null { + const field = node.childForFieldName('name'); + const identifier = + field?.type === 'simple_identifier' + ? field + : node.namedChildren.find((child) => child.type === 'simple_identifier'); + return identifier ? unquoteKotlinIdentifier(identifier.text) : null; +} + +/** + * `springAnnotationHttpMethods` parses Java `{A, B}` collections. + * Translate only Kotlin `method = [A, B]` before delegating. + */ +function kotlinSpringHttpMethods(name: string, annotation: Parser.SyntaxNode): readonly string[] { + if (name !== 'RequestMapping') return springAnnotationHttpMethods(name, annotation.text); + const normalized = annotation.text.replace( + /(\bmethod\s*=\s*)\[([^\]]*)\]/gs, + (_match, assignment: string, values: string) => `${assignment}{${values}}`, + ); + return springAnnotationHttpMethods(name, normalized); +} + +function typeName(node: Parser.SyntaxNode): string | null { + const identifier = node.children.find((child) => child.type === 'type_identifier'); + return identifier ? unquoteKotlinIdentifier(identifier.text) : null; +} + +/** + * Qualified enclosing type paths, innermost first. Kotlin companion members + * are keyed through their enclosing class, so companion_object itself adds no + * segment. + */ +function enclosingTypeNames(node: Parser.SyntaxNode): string[] { + const simpleNames: string[] = []; + for (let current: Parser.SyntaxNode | null = node.parent; current; current = current.parent) { + if (current.type !== 'class_declaration' && current.type !== 'object_declaration') continue; + const name = typeName(current); + if (name) simpleNames.push(name); + } + return simpleNames.map((_, index) => simpleNames.slice(index).reverse().join('.')); +} + +function declarationExists(constants: KotlinModuleConstants, name: string): boolean { + return ( + constants.literals.has(name) || + constants.exprs.has(name) || + unfoldableDeclarationsOf(constants).has(name) + ); +} + +/** + * The provider fold hook has a language-neutral three-argument signature and + * cannot receive a Kotlin reference site's enclosing type chain. Qualify only + * names whose owner is proven by this same tree; unresolved/imported names are + * left untouched for the repo-wide resolver. + */ +function qualifySameFileOperands( + operands: readonly Operand[], + functionNode: Parser.SyntaxNode, + constants: KotlinModuleConstants, +): Operand[] { + const enclosingTypes = enclosingTypeNames(functionNode); + return operands.map((operand) => { + if (operand.kind === 'literal') return operand; + for (const owner of enclosingTypes) { + const qualified = `${owner}.${operand.name}`; + if (declarationExists(constants, qualified)) { + return { kind: 'ref', name: qualified }; + } + } + return operand; + }); +} + +interface ClassMapping { + readonly prefix: string; + readonly methods: readonly string[]; +} + +/** + * Read the one optional class-level RequestMapping. A present route member must + * be exactly one plain string literal, an empty `[]`/`arrayOf()` (no prefix), + * or absent; constants, interpolation, non-empty collections, duplicate + * mappings, and dynamic expressions fail closed for the whole class. + */ +function classMapping(annotations: readonly Parser.SyntaxNode[]): ClassMapping | null { + const mappings = annotations.filter( + (annotation) => annotationName(annotation) === 'RequestMapping', + ); + if (mappings.length === 0) return { prefix: '', methods: ['*'] }; + if (mappings.length !== 1) return null; + + const mapping = mappings[0]; + const paths = routeArguments(mapping); + if (paths === null || paths.length > 1) return null; + + let prefix = ''; + if (paths.length === 1) { + const path = paths[0].expression; + if (isEmptyKotlinPathCollection(path)) { + prefix = ''; + } else if (isPlainStringLiteral(path)) { + const literal = unquoteSpringLiteral(path.text); + if (literal === null) return null; + prefix = literal; + } else { + return null; + } + } + + const methods = kotlinSpringHttpMethods('RequestMapping', mapping); + return methods.length === 0 ? null : { prefix, methods }; +} + +/** + * Extract direct Spring handler methods from concrete Kotlin RestControllers. + */ +export function extractKotlinSpringRoutes( + tree: Parser.Tree, + filePath: string, + lineOffset = 0, +): ExtractedDecoratorRoute[] { + const routes: ExtractedDecoratorRoute[] = []; + let moduleConstants: KotlinModuleConstants | undefined; + + for (const classNode of tree.rootNode.descendantsOfType('class_declaration')) { + if (isKotlinInterface(classNode) || isAbstractOrSealedClass(classNode)) continue; + const annotations = declarationAnnotations(classNode); + const annotationNames = annotations.map(annotationName); + if (!annotationNames.includes('RestController')) continue; + if (annotationNames.includes('FeignClient')) continue; + + const ownerMapping = classMapping(annotations); + if (ownerMapping === null) continue; + + for (const functionNode of directFunctions(classNode)) { + const handlerName = functionName(functionNode); + if (!handlerName) continue; + + for (const annotation of declarationAnnotations(functionNode)) { + const decoratorName = annotationName(annotation); + if (!decoratorName) continue; + + const methodMethods = kotlinSpringHttpMethods(decoratorName, annotation); + const methods = intersectSpringHttpMethods(ownerMapping.methods, methodMethods); + if (methods.length === 0) continue; + + const paths = routeArguments(annotation); + if (paths === null || paths.length > 1) continue; + + let routePath = ''; + let routePathExpr: string | undefined; + let routePathOperands: Operand[] | undefined; + if (paths.length === 1) { + const expression = paths[0].expression; + if (isEmptyKotlinPathCollection(expression)) { + routePath = ''; + } else if (isPlainStringLiteral(expression)) { + const literal = unquoteSpringLiteral(expression.text); + if (literal === null) continue; + routePath = literal; + } else { + const operands = parseKotlinConstOperands(expression); + if (operands === null) continue; + moduleConstants ??= extractKotlinModuleConstants(tree); + routePathExpr = expression.text; + routePathOperands = qualifySameFileOperands(operands, functionNode, moduleConstants); + } + } + + for (const httpMethod of methods) { + routes.push({ + filePath, + routePath, + httpMethod, + decoratorName, + lineNumber: annotation.startPosition.row + lineOffset, + ...(ownerMapping.prefix ? { prefix: ownerMapping.prefix } : {}), + handlerName, + ...(routePathExpr === undefined + ? {} + : { + routePathExpr, + routePathOperands, + }), + }); + } + } + } + } + + return routes; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 071b31784..3fd98a3d4 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -1476,6 +1476,7 @@ import { type ModuleConstants, type Operand, } from '../route-extractors/python-const-resolver.js'; +import { unfoldableDeclarationsOf } from '../route-extractors/constant-resolver.js'; /** * Report a non-fatal worker issue to the pool over IPC so a caught error is not @@ -3089,11 +3090,16 @@ const processFileGroup = ( // without booting a worker. if (provider.extractModuleConstants && shouldHarvestModuleConstants(provider, parseContent)) { const constants = provider.extractModuleConstants(tree); + const topLevelDeclarations = ( + constants as ModuleConstants & { readonly topLevelDeclarations?: unknown } + ).topLevelDeclarations; if ( constants.literals.size > 0 || constants.exprs.size > 0 || constants.imports.size > 0 || - (constants.wildcardImports?.length ?? 0) > 0 + (constants.wildcardImports?.length ?? 0) > 0 || + unfoldableDeclarationsOf(constants).size > 0 || + (topLevelDeclarations instanceof Set && topLevelDeclarations.size > 0) ) { (result.moduleConstants ??= []).push({ filePath: file.path, constants }); } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index bd3b961ec..e69c6de76 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -716,7 +716,11 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // (89) and above every in-flight claim found by scanning open PRs' // parse-cache.ts at their exact head SHAs (highest other open claim was still // ≤88). RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. -const SCHEMA_BUMP = 90; +// 90 -> 91 (#3130): Kotlin providers now emit Spring decoratorRoutes plus +// ModuleConstants shadow metadata. A warm v90 cache would replay unchanged +// Kotlin files with neither route candidates nor the constant declarations +// needed to fold them, leaving the new ingestion path silently inert. +const SCHEMA_BUMP = 91; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/api/ApiPaths.kt b/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/api/ApiPaths.kt new file mode 100644 index 000000000..a8159cd53 --- /dev/null +++ b/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/api/ApiPaths.kt @@ -0,0 +1,9 @@ +package com.example.api + +const val WILDCARD = "/wild" + +object ApiPaths { + const val BASE = "/api" + const val PETS = "/pets" + const val ROOT = "" +} diff --git a/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/shadow/ApiPaths.kt b/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/shadow/ApiPaths.kt new file mode 100644 index 000000000..a8b670bfa --- /dev/null +++ b/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/shadow/ApiPaths.kt @@ -0,0 +1,5 @@ +package com.example.shadow + +// This non-constant same-package declaration must shadow the package-star +// imported com.example.api.ApiPaths in ShadowController. +class ApiPaths diff --git a/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/shadow/ShadowController.kt b/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/shadow/ShadowController.kt new file mode 100644 index 000000000..f5af3bc27 --- /dev/null +++ b/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/shadow/ShadowController.kt @@ -0,0 +1,9 @@ +package com.example.shadow + +import com.example.api.* + +@RestController +class ShadowController { + @GetMapping(ApiPaths.PETS) + fun shadowed(): String = "must not become a route" +} diff --git a/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/web/DuplicateControllers.kt b/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/web/DuplicateControllers.kt new file mode 100644 index 000000000..64c0a1992 --- /dev/null +++ b/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/web/DuplicateControllers.kt @@ -0,0 +1,13 @@ +package com.example.web + +@RestController +class FirstDuplicateController { + @GetMapping("/duplicate/first") + fun list(): String = "first" +} + +@RestController +class SecondDuplicateController { + @GetMapping("/duplicate/second") + fun list(id: String): String = id +} diff --git a/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/web/PetsController.kt b/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/web/PetsController.kt new file mode 100644 index 000000000..0fd1f0997 --- /dev/null +++ b/gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/web/PetsController.kt @@ -0,0 +1,45 @@ +package com.example.web + +import com.example.api.ApiPaths +import com.example.api.* + +@RestController +@RequestMapping("/api") +class PetsController { + companion object { + const val OWN = "/own" + } + + @GetMapping(ApiPaths.PETS) + fun list(): String = "pets" + + @PostMapping(ApiPaths.BASE + "/items") + fun create(): String = "item" + + @PutMapping(WILDCARD) + fun wildcard(): String = "wild" + + @PatchMapping(OWN) + fun own(): String = "own" + + @GetMapping(ApiPaths.ROOT) + fun root(): String = "root" + + @DeleteMapping(ApiPaths.MISSING) + fun missing(): String = "missing" +} + +@RestController +@RequestMapping("/empty") +class EmptyController + +class OrdinaryController { + @GetMapping("/ordinary") + fun ordinary() {} +} + +@FeignClient(name = "remote") +interface RemoteClient { + @GetMapping("/remote") + fun remote() +} diff --git a/gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts b/gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts new file mode 100644 index 000000000..c7f29f912 --- /dev/null +++ b/gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts @@ -0,0 +1,206 @@ +/** + * End-to-end Kotlin Spring decorator route ingestion (#3130). + * + * Covers literal and composed identities, handler attribution, fail-closed + * constants/shadows, ambiguous same-file names, and serialized warm-cache replay. + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import type { PipelineResult } from '../../types/pipeline.js'; +import { + loadParseCache, + PARSE_CACHE_VERSION, + pruneCache, + saveParseCache, + type ParseCache, +} from '../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../src/storage/parsedfile-store.js'; + +const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'kotlin-spring-route-app'); + +let Kotlin: unknown; +try { + Kotlin = requireVendoredGrammar('tree-sitter-kotlin'); +} catch { + // Optional grammar: platforms without it skip this Kotlin-positive suite. +} + +const describeKotlin = Kotlin ? describe : describe.skip; + +interface RouteRecord { + readonly id: string; + readonly method: string; + readonly path: string; + readonly filePath: string; + readonly handlerSymbolId?: string; +} + +function routeRecords(result: PipelineResult): RouteRecord[] { + return [...result.graph.iterNodes()] + .filter((node) => node.label === 'Route') + .map((node) => ({ + id: node.id, + method: String(node.properties.method ?? '*'), + path: String(node.properties.name), + filePath: String(node.properties.filePath), + ...(typeof node.properties.handlerSymbolId === 'string' + ? { handlerSymbolId: node.properties.handlerSymbolId } + : {}), + })) + .sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`)); +} + +function routeByIdentity(result: PipelineResult, method: string, routePath: string): RouteRecord { + const route = routeRecords(result).find( + (candidate) => candidate.method === method && candidate.path === routePath, + ); + expect(route, `expected ${method} ${routePath}`).toBeDefined(); + if (!route) throw new Error(`expected ${method} ${routePath}`); + return route; +} + +function handlerName(result: PipelineResult, route: RouteRecord): string | undefined { + if (!route.handlerSymbolId) return undefined; + return String(result.graph.getNode(route.handlerSymbolId)?.properties.name); +} + +function handlesRouteSources(result: PipelineResult, routeId: string): string[] { + return [...result.graph.iterRelationshipsByType('HANDLES_ROUTE')] + .filter((relationship) => relationship.targetId === routeId) + .map((relationship) => relationship.sourceId) + .sort(); +} + +function routeSnapshot(result: PipelineResult): { + readonly routes: RouteRecord[]; + readonly handles: Array; +} { + const routes = routeRecords(result); + return { + routes, + handles: routes.map((route) => [route.id, handlesRouteSources(result, route.id)] as const), + }; +} + +describeKotlin('Kotlin Spring route ingestion pipeline', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(FIXTURE, () => {}, { workerPoolSize: 1 }); + }, 120_000); + + it('creates normalized literal, imported, wildcard, concatenated, companion, and root routes', () => { + const expected = [ + ['GET', '/api/pets'], + ['POST', '/api/api/items'], + ['PUT', '/api/wild'], + ['PATCH', '/api/own'], + ['GET', '/api'], + ] as const; + + for (const [method, routePath] of expected) { + expect(routeByIdentity(result, method, routePath)).toBeDefined(); + } + }); + + it('stamps each unique handlerSymbolId and emits file plus definition HANDLES_ROUTE edges', () => { + const expected = new Map([ + ['GET /api/pets', 'list'], + ['POST /api/api/items', 'create'], + ['PUT /api/wild', 'wildcard'], + ['PATCH /api/own', 'own'], + ['GET /api', 'root'], + ]); + + for (const [identity, expectedHandler] of expected) { + const split = identity.indexOf(' '); + const route = routeByIdentity(result, identity.slice(0, split), identity.slice(split + 1)); + expect(route.handlerSymbolId, `${identity} handlerSymbolId`).toBeTruthy(); + const handlerSymbolId = route.handlerSymbolId; + if (!handlerSymbolId) throw new Error(`expected ${identity} handlerSymbolId`); + expect(handlerName(result, route)).toBe(expectedHandler); + const handler = result.graph.getNode(handlerSymbolId); + expect(handler?.label).toBe('Method'); + expect(String(handler?.properties.filePath)).toContain('PetsController.kt'); + + const sources = handlesRouteSources(result, route.id); + expect(sources).toContain(handlerSymbolId); + expect(sources.some((sourceId) => result.graph.getNode(sourceId)?.label === 'File')).toBe( + true, + ); + } + }); + + it('omits missing constants, same-package shadows, ordinary classes, Feign consumers, and class-only mappings', () => { + const routes = routeRecords(result); + const identities = new Set(routes.map((route) => `${route.method} ${route.path}`)); + + expect(identities).not.toContain('DELETE /api'); + expect(identities).not.toContain('GET /ordinary'); + expect(identities).not.toContain('GET /remote'); + expect(identities).not.toContain('GET /pets'); + expect(routes.some((route) => handlerName(result, route) === 'shadowed')).toBe(false); + expect(routes.some((route) => handlerName(result, route) === 'EmptyController')).toBe(false); + }); + + it('keeps file-level attribution but omits handler ids for ambiguous same-file names', () => { + for (const routePath of ['/duplicate/first', '/duplicate/second']) { + const route = routeByIdentity(result, 'GET', routePath); + expect(route.handlerSymbolId).toBeUndefined(); + const sources = handlesRouteSources(result, route.id); + expect(sources).toHaveLength(1); + const source = result.graph.getNode(sources[0]); + expect(source?.label).toBe('File'); + expect(String(source?.properties.name)).toContain('DuplicateControllers.kt'); + } + }); + + it('replays identical routes, handler ids, and both edge levels from an all-hit warm cache', async () => { + const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-routes-warm-')); + try { + const cold: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: storageDir, + onDiskKeys: new Set(), + }; + const coldResult = await runPipelineFromRepo(FIXTURE, () => {}, { + parseCache: cold, + workerPoolSize: 1, + }); + expect(coldResult.usedWorkerPool).toBe(true); + + pruneCache(cold, cold.usedKeys); + const savedKeys = await saveParseCache(storageDir, cold); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storageDir), + PARSE_CACHE_VERSION, + new Set(savedKeys), + ); + + const warm = await loadParseCache(storageDir); + const replay = await runPipelineFromRepo(FIXTURE, () => {}, { + parseCache: warm, + workerPoolSize: 1, + }); + expect(replay.usedWorkerPool).toBe(false); + expect(routeSnapshot(replay)).toEqual(routeSnapshot(coldResult)); + + const root = routeByIdentity(replay, 'GET', '/api'); + expect(handlerName(replay, root)).toBe('root'); + expect(root.handlerSymbolId).toBeTruthy(); + if (!root.handlerSymbolId) throw new Error('expected root handlerSymbolId'); + expect(handlesRouteSources(replay, root.id)).toContain(root.handlerSymbolId); + } finally { + fs.rmSync(storageDir, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 058f12217..32af47eaf 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -249,12 +249,14 @@ describe('PARSE_CACHE_VERSION', () => { // still unused by other open PRs' parse-cache.ts heads — the same // collision the paragraph above describes, caught this time by re-checking // at merge. - it('pins SCHEMA_BUMP to 90 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(90); + // Moved 90 -> 91 for #3130's Kotlin Spring decoratorRoutes and Kotlin + // ModuleConstants shadow metadata, both persisted worker output. + it('pins SCHEMA_BUMP to 91 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(91); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, - 82, 83, 84, 85, 86, 87, 88, 89, + 82, 83, 84, 85, 86, 87, 88, 89, 90, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts b/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts new file mode 100644 index 000000000..7ec94df5a --- /dev/null +++ b/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts @@ -0,0 +1,630 @@ +/** + * Kotlin Spring decorator-route extraction for the ingestion pipeline (#3130). + * + * The Kotlin grammar is optional. Importing the extractor itself must not load + * that grammar; only this guarded test setup does. + */ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js'; +import { extractKotlinSpringRoutes } from '../../src/core/ingestion/route-extractors/kotlin-spring.js'; +import { kotlinProvider } from '../../src/core/ingestion/languages/kotlin.js'; +import { + extractKotlinModuleConstants, + type RepoConstants, +} from '../../src/core/ingestion/route-extractors/kotlin-const-resolver.js'; +import { joinPath } from '../../src/core/ingestion/route-extractors/spring-shared.js'; +import { KOTLIN_HTTP_PLUGIN } from '../../src/core/group/extractors/http-patterns/kotlin.js'; + +let Kotlin: unknown; +try { + Kotlin = requireVendoredGrammar('tree-sitter-kotlin'); +} catch { + // Optional grammar; skip positive assertions when its native binding is absent. +} + +const parser = new Parser(); +if (Kotlin) parser.setLanguage(Kotlin as Parser.Language); + +const parse = (source: string): Parser.Tree => parser.parse(source); +const describeKotlin = Kotlin ? describe : describe.skip; + +function constantsOf(files: Record): RepoConstants { + return new Map( + Object.entries(files).map(([filePath, source]) => [ + filePath, + extractKotlinModuleConstants(parse(source)), + ]), + ); +} + +describeKotlin('extractKotlinSpringRoutes', () => { + it('extracts direct RestController functions with independent class prefixes and handlers', () => { + const routes = extractKotlinSpringRoutes( + parse(` +@RestController +@RequestMapping("/api") +class ApiController { + @GetMapping("/pets") + fun list(): String = "pets" + + @PostMapping(path = "/items") + fun create(): String = "item" +} + +@RestController +@RequestMapping(value = "/admin") +class AdminController { + @PutMapping("/items") + fun replace() {} + + @DeleteMapping(value = "/items") + fun remove() {} + + @PatchMapping("/items") + fun patch() {} +} +`), + 'Controllers.kt', + ); + + expect( + routes + .map((route) => ({ + method: route.httpMethod, + path: route.routePath, + prefix: route.prefix, + handler: route.handlerName, + })) + .sort((a, b) => `${a.method}:${a.handler}`.localeCompare(`${b.method}:${b.handler}`)), + ).toEqual([ + { method: 'DELETE', path: '/items', prefix: '/admin', handler: 'remove' }, + { method: 'GET', path: '/pets', prefix: '/api', handler: 'list' }, + { method: 'PATCH', path: '/items', prefix: '/admin', handler: 'patch' }, + { method: 'POST', path: '/items', prefix: '/api', handler: 'create' }, + { method: 'PUT', path: '/items', prefix: '/admin', handler: 'replace' }, + ]); + }); + + it('recognizes marker, constructor, constructor-argument, and FQN RestController forms', () => { + const routes = extractKotlinSpringRoutes( + parse(` +@RestController +class MarkerController { + @GetMapping("/marker") + fun marker() {} +} + +@RestController() +class ConstructorController { + @GetMapping("/constructor") + fun constructor() {} +} + +@RestController("namedBean") +class NamedController { + @GetMapping("/named") + fun named() {} +} + +@org.springframework.web.bind.annotation.RestController +class FqnController { + @org.springframework.web.bind.annotation.GetMapping("/fqn") + fun fqn() {} +} +`), + 'ControllerForms.kt', + ); + + expect(routes.map((route) => [route.routePath, route.handlerName]).sort()).toEqual([ + ['/constructor', 'constructor'], + ['/fqn', 'fqn'], + ['/marker', 'marker'], + ['/named', 'named'], + ]); + }); + + it('emits pathless mappings and method-level RequestMapping constraints', () => { + const routes = extractKotlinSpringRoutes( + parse(` +@RestController +class RequestMappings { + @GetMapping + fun root() {} + + @RequestMapping("/any") + fun any() {} + + @RequestMapping(path = "/get", method = RequestMethod.GET) + fun get() {} + + @RequestMapping(value = "/inspect", method = [RequestMethod.GET, RequestMethod.HEAD]) + fun inspect() {} +} +`), + 'RequestMappings.kt', + ); + + expect( + routes + .map((route) => [route.httpMethod, route.routePath, route.handlerName]) + .sort((a, b) => a.join(':').localeCompare(b.join(':'))), + ).toEqual([ + ['*', '/any', 'any'], + ['GET', '', 'root'], + ['GET', '/get', 'get'], + ['GET', '/inspect', 'inspect'], + ['HEAD', '/inspect', 'inspect'], + ]); + }); + + it('intersects class-level and function-level RequestMapping HTTP methods', () => { + const routes = extractKotlinSpringRoutes( + parse(` +@RestController +@RequestMapping(path = "/api", method = RequestMethod.GET) +class GetOnlyController { + @PostMapping("/rejected") + fun rejected() {} + + @RequestMapping("/inherited") + fun inherited() {} + + @RequestMapping(path = "/also-get", method = [RequestMethod.GET, RequestMethod.POST]) + fun alsoGet() {} +} +`), + 'Constrained.kt', + ); + + expect( + routes.map((route) => [route.httpMethod, route.routePath, route.handlerName]).sort(), + ).toEqual([ + ['GET', '/also-get', 'alsoGet'], + ['GET', '/inherited', 'inherited'], + ]); + }); + + it('restricts extraction to concrete RestController classes and their direct functions', () => { + const routes = extractKotlinSpringRoutes( + parse(` +@Controller +class MvcController { + @GetMapping("/mvc") + fun mvc() {} +} + +class Ordinary { + @GetMapping("/ordinary") + fun ordinary() {} +} + +@RestController +interface Contract { + @GetMapping("/contract") + fun contract() +} + +@RestController +@FeignClient(name = "remote") +interface RemoteClient { + @GetMapping("/remote") + fun remote() +} + +@RestController +class Concrete { + @GetMapping("/direct") + fun direct() { + @GetMapping("/local") + fun local() {} + } + + class Nested { + @GetMapping("/nested") + fun nested() {} + } +} +`), + 'Boundaries.kt', + ); + + expect(routes).toHaveLength(1); + expect(routes[0]).toMatchObject({ + httpMethod: 'GET', + routePath: '/direct', + handlerName: 'direct', + }); + }); + + it('drops abstract and sealed RestController classes', () => { + const routes = extractKotlinSpringRoutes( + parse(` +package app + +@RestController +abstract class AbstractApi { + @GetMapping("/abstract") + fun abstractHandler() {} +} + +@RestController +sealed class SealedApi { + @GetMapping("/sealed") + fun sealedHandler() {} +} + +@RestController +class ConcreteApi { + @GetMapping("/ok") + fun ok() {} +} +`), + 'Abstract.kt', + ); + + expect(routes).toHaveLength(1); + expect(routes[0]).toMatchObject({ + httpMethod: 'GET', + routePath: '/ok', + handlerName: 'ok', + }); + }); + + it('parses Kotlin RequestMapping method arrays without changing Java brace parsing', () => { + const routes = extractKotlinSpringRoutes( + parse(` +package app + +@RestController +class Api { + @RequestMapping("/items", method = [RequestMethod.GET, RequestMethod.HEAD]) + fun items() {} +} +`), + 'Items.kt', + ); + + expect(routes).toHaveLength(2); + expect(routes.map((route) => route.httpMethod).sort()).toEqual(['GET', 'HEAD']); + expect(routes.every((route) => route.routePath === '/items')).toBe(true); + }); + + it('does not manufacture a handler from class-only annotations', () => { + const routes = extractKotlinSpringRoutes( + parse(` +@RestController +@RequestMapping("/api") +class EmptyController +`), + 'EmptyController.kt', + ); + + expect(routes).toEqual([]); + }); + + it('suppresses every function when a class prefix is not one plain scalar string', () => { + const routes = extractKotlinSpringRoutes( + parse(` +@RestController +@RequestMapping(ApiPaths.BASE) +class ConstantPrefix { + @GetMapping("/items") + fun items() {} +} + +@RestController +@RequestMapping(["/a", "/b"]) +class ArrayPrefix { + @GetMapping("/items") + fun items() {} +} + +@RestController +@RequestMapping("/\${dynamic}") +class InterpolatedPrefix { + @GetMapping("/items") + fun items() {} +} +`), + 'UnknownPrefixes.kt', + ); + + expect(routes).toEqual([]); + }); + + it('treats an empty class path arrayOf() as no prefix', () => { + const routes = extractKotlinSpringRoutes( + parse(` +@RestController +@RequestMapping(arrayOf()) +class ArrayOfEmpty { + @GetMapping("/pets") + fun pets() {} +} +`), + 'ArrayOfEmpty.kt', + ); + + expect(routes).toHaveLength(1); + expect(routes[0]).toMatchObject({ routePath: '/pets', httpMethod: 'GET', handlerName: 'pets' }); + expect(routes[0].prefix).toBeUndefined(); + }); + + it('treats an empty method path collection_literal as a pathless mapping', () => { + // Class-level `@RequestMapping([])` is often recovered as prefix_expression + // (no class_declaration) by tree-sitter-kotlin; method-level `[]` still + // exercises the same empty-collection classification. + const routes = extractKotlinSpringRoutes( + parse(` +@RestController +class MethodEmpty { + @GetMapping([]) + fun pets() {} +} +`), + 'MethodEmpty.kt', + ); + + expect(routes).toHaveLength(1); + expect(routes[0]).toMatchObject({ + routePath: '', + httpMethod: 'GET', + handlerName: 'pets', + }); + }); + + it('keeps a trailing comma on RequestMapping from dropping the controller', () => { + const routes = extractKotlinSpringRoutes( + parse(` +@RestController +@RequestMapping("/api",) +class Trailing { + @GetMapping("/pets",) + fun pets() {} +} +`), + 'Trailing.kt', + ); + + expect(routes).toHaveLength(1); + expect(routes[0]).toMatchObject({ + prefix: '/api', + routePath: '/pets', + handlerName: 'pets', + httpMethod: 'GET', + }); + }); + + it('skips interpolated, array, and dynamic function paths', () => { + const routes = extractKotlinSpringRoutes( + parse(` +@RestController +class DynamicPaths { + @GetMapping("/$id") + fun interpolated() {} + + @GetMapping(["/a", "/b"]) + fun array() {} + + @GetMapping(buildPath()) + fun dynamic() {} + + @GetMapping("/literal") + fun literal() {} +} +`), + 'DynamicPaths.kt', + ); + + expect(routes).toHaveLength(1); + expect(routes[0]).toMatchObject({ routePath: '/literal', handlerName: 'literal' }); + }); + + it('emits constant operands without inventing a path', () => { + const routes = extractKotlinSpringRoutes( + parse(` +@RestController +class ConstantController { + @GetMapping(ApiPaths.PETS) + fun pets() {} + + @PostMapping(value = ApiPaths.BASE + "/items") + fun items() {} +} +`), + 'ConstantController.kt', + ); + + expect(routes).toHaveLength(2); + expect(routes[0]).toMatchObject({ + routePath: '', + routePathExpr: 'ApiPaths.PETS', + routePathOperands: [{ kind: 'ref', name: 'ApiPaths.PETS' }], + handlerName: 'pets', + }); + expect(routes[1]).toMatchObject({ + routePath: '', + routePathExpr: 'ApiPaths.BASE + "/items"', + routePathOperands: [ + { kind: 'ref', name: 'ApiPaths.BASE' }, + { kind: 'literal', value: '/items' }, + ], + handlerName: 'items', + }); + }); + + it('qualifies same-file companion and enclosing-object operands proven by the AST', () => { + const routes = extractKotlinSpringRoutes( + parse(` +object Outer { + object Paths { + const val NESTED = "/nested" + } + + @RestController + class NestedController { + companion object { + const val OWN = "/own" + } + + @GetMapping(OWN) + fun own() {} + + @GetMapping(Outer.Paths.NESTED) + fun nested() {} + } +} +`), + 'OwnedConstants.kt', + ); + + expect(routes.map((route) => route.routePathOperands)).toEqual([ + [{ kind: 'ref', name: 'Outer.NestedController.OWN' }], + [{ kind: 'ref', name: 'Outer.Paths.NESTED' }], + ]); + }); + + it('registers only the dedicated route and constant hooks on kotlinProvider', () => { + expect(kotlinProvider.extractDecoratorRoutes).toBe(extractKotlinSpringRoutes); + expect(kotlinProvider.extractModuleConstants).toBe(extractKotlinModuleConstants); + expect(kotlinProvider.foldRoutePathOperands).toBeTypeOf('function'); + expect(kotlinProvider.moduleConstantHeuristic).toBeUndefined(); + expect(kotlinProvider.decoratorRouteHandlerName).toBeUndefined(); + }); + + it('folds imported, wildcard, concatenated, and same-file companion route operands', () => { + const constantsKey = 'src/main/kotlin/com/example/api/ApiPaths.kt'; + const controllerKey = 'src/main/kotlin/com/example/web/PetsController.kt'; + const files = { + [constantsKey]: `package com.example.api + +const val WILDCARD = "/wildcard" +object ApiPaths { + const val BASE = "/api" + const val PETS = BASE + "/pets" +} +`, + [controllerKey]: `package com.example.web + +import com.example.api.ApiPaths +import com.example.api.* + +@RestController +class PetsController { + companion object { + const val OWN = "/own" + } + + @GetMapping(ApiPaths.PETS) + fun imported() {} + + @PostMapping(ApiPaths.BASE + "/items") + fun concatenated() {} + + @PutMapping(WILDCARD) + fun wildcard() {} + + @PatchMapping(OWN) + fun companion() {} +} +`, + }; + const repo = constantsOf(files); + const fold = kotlinProvider.foldRoutePathOperands; + expect(fold).toBeTypeOf('function'); + if (!fold) throw new Error('expected Kotlin route operand fold hook'); + const routes = extractKotlinSpringRoutes(parse(files[controllerKey]), controllerKey); + const folded = new Map( + routes.map((route) => [ + route.handlerName, + fold(route.filePath, route.routePathOperands ?? [], repo), + ]), + ); + + expect(folded).toEqual( + new Map([ + ['imported', '/api/pets'], + ['concatenated', '/api/items'], + ['wildcard', '/wildcard'], + ['companion', '/own'], + ]), + ); + }); + + it('keeps unresolved and unfoldable route operands on the skip floor', () => { + const controllerKey = 'src/main/kotlin/com/example/web/PetsController.kt'; + const source = `package com.example.web + +@RestController +class PetsController { + @GetMapping(ApiPaths.MISSING) + fun missing() {} + + @GetMapping(BROKEN) + fun broken() {} +} + +val BROKEN = buildPath() +`; + const repo = constantsOf({ [controllerKey]: source }); + const routes = extractKotlinSpringRoutes(parse(source), controllerKey); + const fold = kotlinProvider.foldRoutePathOperands; + if (!fold) throw new Error('expected Kotlin route operand fold hook'); + + expect(routes.map((route) => route.handlerName)).toEqual(['missing', 'broken']); + for (const route of routes) { + expect(fold(route.filePath, route.routePathOperands ?? [], repo)).toBeNull(); + } + }); + + it('preserves a successful empty-string fold and its handler name', () => { + const key = 'src/main/kotlin/com/example/web/RootController.kt'; + const source = `package com.example.web + +const val ROOT = "" + +@RestController +class RootController { + @GetMapping(ROOT) + fun root() {} +} +`; + const repo = constantsOf({ [key]: source }); + const [route] = extractKotlinSpringRoutes(parse(source), key); + const fold = kotlinProvider.foldRoutePathOperands; + if (!route || !fold) throw new Error('expected root route and Kotlin fold hook'); + + expect(route).toMatchObject({ routePath: '', handlerName: 'root' }); + expect(fold(key, route.routePathOperands ?? [], repo)).toBe(''); + }); + + it('matches the group Kotlin plugin for an in-scope literal controller fixture', () => { + expect(KOTLIN_HTTP_PLUGIN).not.toBeNull(); + if (!KOTLIN_HTTP_PLUGIN) throw new Error('expected Kotlin HTTP plugin'); + const source = ` +@RestController +@RequestMapping("/api") +class PetsController { + @GetMapping("/pets") + fun list() {} + + @PostMapping("/pets") + fun create() {} +} +`; + const tree = parse(source); + const ingestion = new Set( + extractKotlinSpringRoutes(tree, 'PetsController.kt').map( + (route) => `${route.httpMethod} ${joinPath(route.prefix ?? '', route.routePath)}`, + ), + ); + const group = new Set( + KOTLIN_HTTP_PLUGIN.scan(tree, undefined, 'PetsController.kt') + .filter((detection) => detection.role === 'provider') + .map((detection) => `${detection.method} ${detection.path}`), + ); + + expect(ingestion).toEqual(group); + }); +}); diff --git a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts index bd754927e..0eb67d995 100644 --- a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts +++ b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts @@ -65,6 +65,19 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => { expect(out.has(GET_ORDERS)).toBe(false); }); + it('treats an empty routePath as a valid root route when resolving its handler', () => { + const model = createSemanticModel(); + model.symbols.add(FILE, 'root', 'method:OrderController.root', 'Method'); + + const out = resolveRouteHandlerSymbols( + model, + [], + [decoratorRoute({ routePath: '', handlerName: 'root' })], + ); + + expect(out.get(routeNodeKey('GET', '/'))).toBe('method:OrderController.root'); + }); + it('same-identity collision: an unresolvable first route reserves the slot so a later resolvable route cannot stamp it', () => { const model = createSemanticModel(); // Only the SECOND route's handler exists in the model. diff --git a/gitnexus/test/unit/spring-annotation-arguments.test.ts b/gitnexus/test/unit/spring-annotation-arguments.test.ts index faaf9997e..b558e6bbc 100644 --- a/gitnexus/test/unit/spring-annotation-arguments.test.ts +++ b/gitnexus/test/unit/spring-annotation-arguments.test.ts @@ -32,6 +32,22 @@ describe('Spring annotation static arguments', () => { expect(parseStaticStringValues('NAMES')).toBeNull(); }); + it('tolerates a trailing comma in the top-level argument list', () => { + expect(parseSpringAnnotationArguments('@RequestMapping("/api",)')).toEqual([ + { value: '"/api"' }, + ]); + expect(parseSpringAnnotationArguments('@RequestMapping(path = "/api",)')).toEqual([ + { name: 'path', value: '"/api"' }, + ]); + expect(springAnnotationHttpMethods('RequestMapping', '@RequestMapping("/api",)')).toEqual([ + '*', + ]); + expect(parseSpringAnnotationArguments('@RequestMapping("/api",,)')).toEqual([ + { value: '"/api"' }, + ]); + expect(parseSpringAnnotationArguments('@RequestMapping("/api", , method = GET)')).toBeNull(); + }); + it('keeps generic call expressions intact while splitting top-level arguments', () => { expect( parseSpringAnnotationArguments(