Merge branch 'main' into feat/unified-deployment-enhancement

This commit is contained in:
Gergő Magyar 2026-09-01 23:15:44 +01:00 • committed by GitHub
commit b2720cfe63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1314 additions and 17 deletions

View file

@ -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

View file

@ -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) {

View file

@ -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,
});

View file

@ -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';

View file

@ -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;
}

View file

@ -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 });
}

View file

@ -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

View file

@ -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 = ""
}

View file

@ -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

View file

@ -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"
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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<readonly [string, readonly string[]]>;
} {
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<string>(),
storagePath: storageDir,
onDiskKeys: new Set<string>(),
};
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);
});

View file

@ -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);
}

View file

@ -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<string, string>): 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);
});
});

View file

@ -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.

View file

@ -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(