Merge branch 'main' into fix/issue-1518-docker-local-path

This commit is contained in:
Gergő Magyar 2026-05-27 11:49:57 +01:00 • committed by GitHub
commit deabb339f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 575 additions and 1 deletions

View file

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

View 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;

View file

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