mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-15 09:11:33 +00:00
Merge branch 'main' into feat/unified-deployment-enhancement
This commit is contained in:
commit
b2720cfe63
17 changed files with 1314 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
326
gitnexus/src/core/ingestion/route-extractors/kotlin-spring.ts
Normal file
326
gitnexus/src/core/ingestion/route-extractors/kotlin-spring.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
9
gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/api/ApiPaths.kt
vendored
Normal file
9
gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/api/ApiPaths.kt
vendored
Normal 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 = ""
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
45
gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/web/PetsController.kt
vendored
Normal file
45
gitnexus/test/fixtures/kotlin-spring-route-app/src/main/kotlin/com/example/web/PetsController.kt
vendored
Normal 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()
|
||||
}
|
||||
206
gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts
Normal file
206
gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
630
gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts
Normal file
630
gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue