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