diff --git a/gitnexus/src/core/group/extractors/http-patterns/index.ts b/gitnexus/src/core/group/extractors/http-patterns/index.ts index 4cc758218..e5c03b68c 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/index.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/index.ts @@ -8,7 +8,13 @@ import { PYTHON_HTTP_PLUGIN } from './python.js'; import { PHP_HTTP_PLUGIN } from './php.js'; import { JAVASCRIPT_HTTP_PLUGIN, TYPESCRIPT_HTTP_PLUGIN, TSX_HTTP_PLUGIN } from './node.js'; -export type { HttpDetection, HttpLanguagePlugin, HttpRole } from './types.js'; +export type { + HttpDetection, + HttpFileDetections, + HttpLanguagePlugin, + HttpRole, + HttpScanInput, +} from './types.js'; /** * File-extension → HTTP language plugin registry. The top-level diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 030b6c9d1..48da46765 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -6,7 +6,12 @@ import { unquoteLiteral, type LanguagePatterns, } from '../tree-sitter-scanner.js'; -import type { HttpDetection, HttpLanguagePlugin } from './types.js'; +import type { + HttpDetection, + HttpFileDetections, + HttpLanguagePlugin, + HttpScanInput, +} from './types.js'; /** * Java HTTP plugin. Handles: @@ -46,31 +51,85 @@ const METHOD_ANNOTATION_TO_HTTP: Record = { // route prefixes — e.g. `produces = "application/json"` would corrupt // every method route under that controller). The sibling // `topic-patterns/java.ts` uses the same `key:` constraint approach. -const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ - name: 'java-spring-class-prefix', +interface SpringRouteBinding { + method: string; + path: string; +} + +interface SpringMethodInfo { + name: string; + routes: SpringRouteBinding[]; +} + +interface SpringTypeInfo { + filePath: string; + kind: 'class' | 'interface'; + name: string; + classPrefix: string; + implementedInterfaces: string[]; + isController: boolean; + methods: SpringMethodInfo[]; +} + +// ─── Provider: Spring class/interface-level @RequestMapping prefix ─── +const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({ + name: 'java-spring-type-prefix', language: Java, patterns: [ { meta: {}, query: ` - (class_declaration - (modifiers - (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list (string_literal) @prefix)))) @class + [ + (class_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list (string_literal) @prefix)))) @type + (interface_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list (string_literal) @prefix)))) @type + ] `, }, { meta: {}, query: ` - (class_declaration - (modifiers - (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list - (element_value_pair - key: (identifier) @key (#match? @key "^(path|value)$") - value: (string_literal) @prefix))))) @class + [ + (class_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#match? @key "^(path|value)$") + value: (string_literal) @prefix))))) @type + (interface_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#match? @key "^(path|value)$") + value: (string_literal) @prefix))))) @type + ] + `, + }, + ], +} satisfies LanguagePatterns>); + +const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({ + name: 'java-spring-type-declaration', + language: Java, + patterns: [ + { + meta: {}, + query: ` + [ + (class_declaration name: (identifier) @type_name) @type + (interface_declaration name: (identifier) @type_name) @type + ] `, }, ], @@ -315,9 +374,9 @@ const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({ } satisfies LanguagePatterns>); /** - * Find the nearest enclosing class_declaration ancestor for a node, or - * null if the node is top-level. Tree-sitter's SyntaxNode.parent walks - * one level at a time. + * Find the nearest enclosing class/interface declaration ancestor for + * a node, or null if the node is top-level. Tree-sitter's + * SyntaxNode.parent walks one level at a time. */ function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { let cur: Parser.SyntaxNode | null = node.parent; @@ -337,20 +396,6 @@ function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | nu return null; } -function hasAnnotation(node: Parser.SyntaxNode, annotationName: string): boolean { - for (const child of node.namedChildren) { - if (child.type !== 'modifiers') continue; - for (const modifier of child.namedChildren) { - if (modifier.type !== 'annotation') continue; - const nameNode = modifier.childForFieldName('name'); - if (!nameNode) continue; - const simpleName = nameNode.text.split('.').pop(); - if (nameNode.text === annotationName || simpleName === annotationName) return true; - } - } - return false; -} - /** * Join a class-level prefix and a method-level path into a single URL * path. Mirrors the semantics of the original regex implementation: @@ -364,6 +409,184 @@ function joinPath(prefix: string, methodPath: string): string { return `/${cleanPrefix}/${cleanSub}`; } +function getNodeName(node: Parser.SyntaxNode): string | null { + return node.childForFieldName('name')?.text ?? null; +} + +function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean { + const modifiers = node.namedChildren.find((child) => child.type === 'modifiers'); + if (!modifiers) return false; + const allowed = new Set(typeof names === 'string' ? [names] : names); + const stack = [...modifiers.namedChildren]; + while (stack.length > 0) { + const cur = stack.pop()!; + const annotationName = cur.childForFieldName('name')?.text ?? ''; + const simpleName = annotationName.split('.').pop() ?? annotationName; + if ( + (cur.type === 'annotation' || cur.type === 'marker_annotation') && + (allowed.has(annotationName) || allowed.has(simpleName)) + ) { + return true; + } + stack.push(...cur.namedChildren); + } + return false; +} + +function collectTypePrefixes(tree: Parser.Tree): Map { + const prefixByTypeId = new Map(); + for (const match of runCompiledPatterns(SPRING_TYPE_PREFIX_PATTERNS, tree)) { + const prefixNode = match.captures.prefix; + const typeNode = match.captures.type; + if (!prefixNode || !typeNode) continue; + const prefix = unquoteLiteral(prefixNode.text); + if (prefix !== null) prefixByTypeId.set(typeNode.id, prefix); + } + return prefixByTypeId; +} + +function collectMethodRoutes(tree: Parser.Tree): Map { + const routesByMethodId = new Map(); + for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { + const annNode = match.captures.ann; + const pathNode = match.captures.path; + 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 routes = routesByMethodId.get(methodNode.id) ?? []; + routes.push({ method: httpMethod, path: rawPath }); + routesByMethodId.set(methodNode.id, routes); + } + return routesByMethodId; +} + +function collectDirectMethods(typeNode: Parser.SyntaxNode): Parser.SyntaxNode[] { + const out: Parser.SyntaxNode[] = []; + const visit = (node: Parser.SyntaxNode): void => { + for (const child of node.namedChildren) { + if (child.type === 'method_declaration') { + out.push(child); + continue; + } + if ( + child !== typeNode && + (child.type === 'class_declaration' || child.type === 'interface_declaration') + ) { + continue; + } + visit(child); + } + }; + visit(typeNode); + return out; +} + +function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] { + const interfacesNode = typeNode.childForFieldName('interfaces'); + if (!interfacesNode) return []; + const out: string[] = []; + const visit = (node: Parser.SyntaxNode): void => { + if (node.type === 'type_identifier' || node.type === 'scoped_type_identifier') { + out.push(node.text.split('.').pop() ?? node.text); + return; + } + for (const child of node.namedChildren) visit(child); + }; + visit(interfacesNode); + return out; +} + +function collectSpringTypes(filePath: string, tree: Parser.Tree): SpringTypeInfo[] { + const prefixByTypeId = collectTypePrefixes(tree); + const routesByMethodId = collectMethodRoutes(tree); + const out: SpringTypeInfo[] = []; + + for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) { + const typeNode = match.captures.type; + const typeNameNode = match.captures.type_name; + if (!typeNode || !typeNameNode) continue; + const kind = typeNode.type === 'interface_declaration' ? 'interface' : 'class'; + const methods = collectDirectMethods(typeNode) + .map((methodNode) => ({ + name: getNodeName(methodNode), + routes: routesByMethodId.get(methodNode.id) ?? [], + })) + .filter((method): method is SpringMethodInfo => method.name !== null); + + out.push({ + filePath, + kind, + name: typeNameNode.text, + classPrefix: prefixByTypeId.get(typeNode.id) ?? '', + implementedInterfaces: kind === 'class' ? collectImplementedInterfaces(typeNode) : [], + isController: kind === 'class' && hasAnnotation(typeNode, ['RestController', 'Controller']), + methods, + }); + } + + return out; +} + +function scanSpringProject(files: readonly HttpScanInput[]): HttpFileDetections[] { + const types = files.flatMap((file) => collectSpringTypes(file.filePath, file.tree)); + const interfaceRoutes = new Map | null>(); + + for (const type of types) { + if (type.kind !== 'interface') continue; + if (interfaceRoutes.has(type.name)) { + interfaceRoutes.set(type.name, null); + continue; + } + const methodMap = new Map(); + for (const method of type.methods) { + const routes = method.routes.map((route) => ({ + method: route.method, + path: type.classPrefix ? joinPath(type.classPrefix, route.path) : route.path, + })); + if (routes.length > 0) methodMap.set(method.name, routes); + } + interfaceRoutes.set(type.name, methodMap); + } + + const detectionsByFile = new Map(); + for (const type of types) { + if (type.kind !== 'class' || !type.isController) continue; + for (const method of type.methods) { + if (method.routes.length > 0) continue; + const inheritedRoutes = type.implementedInterfaces.flatMap((interfaceName) => { + const routeMap = interfaceRoutes.get(interfaceName); + if (!routeMap) return []; + const routes = routeMap.get(method.name) ?? []; + return routes.map((route) => ({ + method: route.method, + path: joinPath(type.classPrefix, route.path), + })); + }); + + for (const route of inheritedRoutes) { + const detections = detectionsByFile.get(type.filePath) ?? []; + detections.push({ + role: 'provider', + framework: 'spring', + method: route.method, + path: route.path, + name: method.name, + confidence: 0.8, + }); + detectionsByFile.set(type.filePath, detections); + } + } + } + + return [...detectionsByFile.entries()].map(([filePath, detections]) => ({ + filePath, + detections, + })); +} + export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'java-http', language: Java, @@ -371,14 +594,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { const out: HttpDetection[] = []; // ─── Providers: Spring class prefix + method annotations ──────── - const prefixByClassId = new Map(); - 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); - } + const prefixByTypeId = collectTypePrefixes(tree); const feignPrefixByInterfaceId = new Map(); for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) { @@ -415,7 +631,8 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { continue; } const enclosingClass = findEnclosingClass(methodNode); - const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : ''; + if (!enclosingClass) continue; + const prefix = prefixByTypeId.get(enclosingClass.id) ?? ''; const fullPath = joinPath(prefix, rawPath); out.push({ role: 'provider', @@ -540,4 +757,5 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { return out; }, + scanProject: scanSpringProject, }; diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index 0bafb7a7e..0e56b554b 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -17,18 +17,22 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * named annotation arguments (`@GetMapping(value = "/x")` and * `@GetMapping(path = "/x")`) are supported. * - * **Consumers** (this PR) — three call-site patterns common in Kotlin + * **Consumers** — four call-site patterns common in Kotlin * Spring projects: * - * 1. `restTemplate.getForObject("/x", ...)` and friends - * 2. `webClient.get().uri("/x")` (short form, 1 verb hop + 1 uri hop) - * 3. `Request.Builder().url("/x")` (OkHttp) + * 1. `restTemplate.getForObject("/x", ...)` and friends (#1855) + * 2. `webClient.get().uri("/x")` — short form (#1855) + * 3. `Request.Builder().url("/x")` — OkHttp (#1855) + * 4. `webClient.method(HttpMethod.X).uri("/y")` — long form (this PR) * - * The long-form `webClient.method(HttpMethod.X).uri("/y")` chain is - * intentionally deferred to a follow-up: it requires walk-up logic - * to recover the verb from a sibling `call_expression`, and we can - * land 80% of real-world Kotlin Spring consumer coverage with the - * three simpler patterns above. + * The long form puts the verb on a sibling `call_expression` two hops + * away from the path. Rather than introducing imperative walk-up logic, + * we use a single deeper tree-sitter query that matches the full chain + * structurally — see `WEB_CLIENT_LONG_PATTERNS` below. The verb is + * captured directly as the `simple_identifier` of `HttpMethod.X`, so + * variable-bound verbs (`val verb = HttpMethod.PATCH; webClient.method(verb)...`) + * are intentionally NOT picked up — those need a graph-aware resolver + * and are out of scope for source-scan. * * tree-sitter-kotlin (fwcd) AST shapes used here: * class_declaration @@ -109,6 +113,16 @@ const WEB_CLIENT_SHORT_TO_HTTP: Record = { patch: 'PATCH', }; +/** + * Allowed HTTP verbs for the WebClient long-form path + * `webClient.method(HttpMethod.X).uri("/y")`. Compiled once at module + * load (instead of inside the scan loop) per maintainer feedback on + * PR #1884. Mirrors the keys of `WEB_CLIENT_SHORT_TO_HTTP` above — + * keeping HEAD/OPTIONS/TRACE intentionally excluded for symmetry + * with the short form and the Java plugin. + */ +const WEB_CLIENT_LONG_VERB_RE = /^(GET|POST|PUT|DELETE|PATCH)$/; + /** * Build the plugin only if the Kotlin grammar is available. Compiling * the queries against a null grammar would throw at module load time @@ -265,8 +279,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { // - outer call's first value_argument is a string literal // // The long-form `webClient.method(HttpMethod.GET).uri("/x")` chain - // uses an extra navigation hop and an enum field access — it's - // intentionally out of scope here (see file header). + // uses an extra navigation hop and an enum field access — handled + // by `WEB_CLIENT_LONG_PATTERNS` below, separately so each query is + // straightforward to reason about. const WEB_CLIENT_SHORT_PATTERNS = compilePatterns({ name: 'kotlin-web-client-short', language, @@ -290,6 +305,59 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { ], } satisfies LanguagePatterns>); + // ─── Consumer: Spring WebClient (long form) ─────────────────────────── + // The fluent long form passes the verb as a `HttpMethod.X` enum field + // access through `.method(...)`, then carries the path on a separate + // `.uri(...)` hop further down the chain: + // + // webClient.method(HttpMethod.GET).uri("/x").retrieve().awaitBody() + // + // Compared to the short form there are two extra structural hops: + // - the inner `.method(...)` `call_expression` has a `value_argument` + // whose payload is itself a `navigation_expression` (HttpMethod → .GET) + // - the outer `.uri(...)` is reached via one more + // `navigation_expression` wrapping that inner call + // + // We capture the verb at the `simple_identifier` under `HttpMethod`'s + // `navigation_suffix`. That `simple_identifier` is the literal field + // name (`GET`, `POST`, ...) used in source — Kotlin enum fields by + // convention are upper-case, matching `HttpMethod` from + // `org.springframework.http`. We forward the captured text as-is. + // + // Variable-bound verbs (`val verb = HttpMethod.PATCH; webClient.method(verb)...`) + // do NOT match — they fail the `(navigation_expression ...)` shape + // because the value_argument carries a bare `simple_identifier` instead + // of a `HttpMethod.X` field access. This is intentional: source-scan + // can't follow the binding without graph context. Pinned by an + // anti-overreach test in the consumer suite. + const WEB_CLIENT_LONG_PATTERNS = compilePatterns({ + name: 'kotlin-web-client-long', + language, + patterns: [ + { + meta: {}, + query: ` + (call_expression + (navigation_expression + (call_expression + (navigation_expression + (simple_identifier) @obj (#eq? @obj "webClient") + (navigation_suffix + (simple_identifier) @method_call (#eq? @method_call "method"))) + (call_suffix + (value_arguments + . (value_argument + (navigation_expression + (simple_identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod") + (navigation_suffix (simple_identifier) @verb)))))) + (navigation_suffix (simple_identifier) @uri (#eq? @uri "uri"))) + (call_suffix + (value_arguments . (value_argument . (string_literal) @path)))) + `, + }, + ], + } satisfies LanguagePatterns>); + // ─── Consumer: OkHttp Request.Builder().url("/x") ───────────────────── // Kotlin parses `Request.Builder()` as a `call_expression` whose // callee is a `navigation_expression` (Request → .Builder), NOT as @@ -437,6 +505,33 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { }); } + // ─── Consumers: WebClient long form (.method(HttpMethod.X) → .uri) ─ + for (const match of runCompiledPatterns(WEB_CLIENT_LONG_PATTERNS, tree)) { + const verbNode = match.captures.verb; + const pathNode = match.captures.path; + if (!verbNode || !pathNode) continue; + // The captured text is the literal `HttpMethod.X` field name. + // Spring's `org.springframework.http.HttpMethod` defines GET, + // POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE — we only + // emit for the five verbs we already handle elsewhere, so + // exotic ones are silently skipped (consistent with the + // short form's WEB_CLIENT_SHORT_TO_HTTP guard). The accepted + // verb regex is hoisted to module scope (see + // `WEB_CLIENT_LONG_VERB_RE` near the top of this file). + const verbText = verbNode.text; + if (!WEB_CLIENT_LONG_VERB_RE.test(verbText)) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'spring-web-client', + method: verbText, + path, + name: null, + confidence: 0.7, + }); + } + // ─── Consumers: OkHttp Request.Builder().url("path") ──────────── for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) { const pathNode = match.captures.path; diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index e1c85369d..fb4ab09cb 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -40,6 +40,16 @@ export interface HttpDetection { confidence: number; } +export interface HttpScanInput { + filePath: string; + tree: Parser.Tree; +} + +export interface HttpFileDetections { + filePath: string; + detections: HttpDetection[]; +} + /** * One language-scoped HTTP plugin. The plugin owns the tree-sitter * grammar and the `scan` function that translates a parsed tree into @@ -95,4 +105,10 @@ export interface HttpLanguagePlugin { * single-file plugins can keep their unary `scan(tree)` shape. */ scan(tree: Parser.Tree, repoContext?: RepoContext, fileRel?: string): HttpDetection[]; + /** + * Optional project-level scan hook for language rules that require + * multiple files, such as Java controllers inheriting Spring mappings + * from annotated interfaces. + */ + scanProject?(files: readonly HttpScanInput[]): HttpFileDetections[]; } diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 37237242f..e14b9b701 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -6,7 +6,13 @@ import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js import type { ExtractedContract, RepoHandle } from '../types.js'; import { readSafe } from './fs-utils.js'; import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; -import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-patterns/index.js'; +import { + getPluginForFile, + HTTP_SCAN_GLOB, + type HttpDetection, + type HttpLanguagePlugin, + type HttpScanInput, +} from './http-patterns/index.js'; /** * Language-agnostic orchestrator for HTTP route (provider + consumer) @@ -160,6 +166,12 @@ export class HttpRouteExtractor implements ContractExtractor { // both graph-assisted enrichment and source-scan emission. const parser = new Parser(); const cachedDetections = new Map(); + const cachedInputs = new Map< + string, + { plugin: HttpLanguagePlugin; input: HttpScanInput; repoContext: unknown } | null + >(); + const projectDetections = new Map(); + let projectScanComplete = false; // Per-plugin cross-file context (e.g. Python's FastAPI router → // include_router(prefix=...) map). Built lazily on first @@ -189,32 +201,50 @@ export class HttpRouteExtractor implements ContractExtractor { } }; - const getDetections = async (rel: string): Promise => { - const cached = cachedDetections.get(rel); - if (cached) return cached; + const getScanInput = async ( + rel: string, + ): Promise<{ + plugin: HttpLanguagePlugin; + input: HttpScanInput; + repoContext: unknown; + } | null> => { + if (cachedInputs.has(rel)) return cachedInputs.get(rel) ?? null; const plugin = getPluginForFile(rel); if (!plugin) { - cachedDetections.set(rel, []); - return []; + cachedInputs.set(rel, null); + return null; } const repoContext = await ensureRepoContext(plugin); const content = readSafe(repoPath, rel); if (!content) { - cachedDetections.set(rel, []); - return []; + cachedInputs.set(rel, null); + return null; } try { parser.setLanguage(plugin.language); const tree = parseSourceSafe(parser, content); - const detections = plugin.scan(tree, repoContext, rel); - cachedDetections.set(rel, detections); - return detections; + const input = { filePath: rel, tree }; + const item = { plugin, input, repoContext }; + cachedInputs.set(rel, item); + return item; } catch { - cachedDetections.set(rel, []); - return []; + cachedInputs.set(rel, null); + return null; } }; + const getDetections = async (rel: string): Promise => { + const cached = cachedDetections.get(rel); + if (cached) return cached; + const scanInput = await getScanInput(rel); + const ownDetections = scanInput + ? scanInput.plugin.scan(scanInput.input.tree, scanInput.repoContext, rel) + : []; + const detections = [...ownDetections, ...(projectDetections.get(rel) ?? [])]; + cachedDetections.set(rel, detections); + return detections; + }; + // Glob the source-scan file list at most once per extract() — // both provider and consumer fallback paths share the same list. let scannedFiles: string[] | null = null; @@ -224,20 +254,46 @@ export class HttpRouteExtractor implements ContractExtractor { return scannedFiles; }; + const collectProjectDetections = async (files: string[]): Promise => { + if (projectScanComplete) return; + projectScanComplete = true; + const byPlugin = new Map(); + for (const rel of files) { + const scanInput = await getScanInput(rel); + if (!scanInput?.plugin.scanProject) continue; + const items = byPlugin.get(scanInput.plugin) ?? []; + items.push(scanInput.input); + byPlugin.set(scanInput.plugin, items); + } + + for (const [plugin, inputs] of byPlugin) { + const results = plugin.scanProject?.(inputs) ?? []; + for (const result of results) { + const existing = projectDetections.get(result.filePath) ?? []; + projectDetections.set(result.filePath, [...existing, ...result.detections]); + } + } + + cachedDetections.clear(); + }; + + const files = await getScannedFiles(); + await collectProjectDetections(files); + const graphProviders = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : []; // Source scan always runs to capture routes in languages/files not covered // by graph edges; the glob and per-file parse results are cached above. const providers = this.mergeGraphAndSourceContracts( graphProviders, - await this.extractProvidersSourceScan(await getScannedFiles(), getDetections), + await this.extractProvidersSourceScan(files, getDetections), ); const graphConsumers = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : []; const consumers = this.mergeGraphAndSourceContracts( graphConsumers, - await this.extractConsumersSourceScan(await getScannedFiles(), getDetections), + await this.extractConsumersSourceScan(files, getDetections), ); return [...providers, ...consumers]; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index dc43bc48c..e8b79f68e 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -41,6 +41,8 @@ describe('HttpRouteExtractor', () => { }); }); + const toPosixPath = (filePath: string): string => filePath.replace(/\\/g, '/'); + describe('provider extraction — graph-first (Strategy A)', () => { it('extracts routes from Route/HANDLES_ROUTE graph + source scan for method', async () => { const dir = path.join(tmpDir, 'graph-first'); @@ -832,6 +834,181 @@ class UserController { }, ); + it('does not emit annotated Java interfaces as concrete Spring provider routes', async () => { + const dir = path.join(tmpDir, 'spring-interface-only'); + fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/rest/DepartmentApi.java'), + ` +package com.example.rest; +import org.springframework.web.bind.annotation.*; + +@RequestMapping("/departments") +public interface DepartmentApi { + @GetMapping("") + Object list(); + + @GetMapping("/{name}") + Object getByName(@PathVariable String name); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers).toHaveLength(0); + }); + + it('inherits Spring interface route mappings when controller methods omit annotations', async () => { + const dir = path.join(tmpDir, 'spring-interface-inherited-methods'); + fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + + fs.writeFileSync( + path.join(dir, 'src/rest/StatusApi.java'), + ` +package com.example.rest; +import org.springframework.web.bind.annotation.*; + +@RequestMapping("/status") +public interface StatusApi { + @GetMapping("") + Object getStatus(); +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'src/controller/StatusController.java'), + ` +package com.example.controller; +import com.example.rest.StatusApi; +import org.springframework.web.bind.annotation.*; + +@RestController +public class StatusController implements StatusApi { + @Override + public Object getStatus() { return null; } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const statusRoute = providers.find((c) => c.contractId === 'http::GET::/status'); + expect(statusRoute).toBeDefined(); + expect(toPosixPath(statusRoute!.symbolRef.filePath)).toBe( + 'src/controller/StatusController.java', + ); + expect(statusRoute!.symbolName).toBe('getStatus'); + expect(providers.filter((c) => c.symbolRef.filePath.includes('StatusApi.java'))).toHaveLength( + 0, + ); + }); + + it('combines controller class mapping with inherited interface method mapping', async () => { + const dir = path.join(tmpDir, 'spring-interface-controller-prefix'); + fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + + fs.writeFileSync( + path.join(dir, 'src/rest/UserApi.java'), + ` +package com.example.rest; +import org.springframework.web.bind.annotation.*; + +public interface UserApi { + @GetMapping("/users") + Object listUsers(); +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example.controller; +import com.example.rest.UserApi; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +public class UserController implements UserApi { + @Override + public Object listUsers() { return null; } +} +`, + ); + + 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::/api/users'); + expect(usersRoute).toBeDefined(); + expect(toPosixPath(usersRoute!.symbolRef.filePath)).toBe( + 'src/controller/UserController.java', + ); + }); + + it('skips ambiguous inherited routes when interfaces share a simple name', async () => { + const dir = path.join(tmpDir, 'spring-interface-simple-name-collision'); + fs.mkdirSync(path.join(dir, 'src/a'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'src/b'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + + fs.writeFileSync( + path.join(dir, 'src/a/StatusApi.java'), + ` +package com.example.a; +import org.springframework.web.bind.annotation.*; + +public interface StatusApi { + @GetMapping("/a/status") + Object getStatus(); +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'src/b/StatusApi.java'), + ` +package com.example.b; +import org.springframework.web.bind.annotation.*; + +public interface StatusApi { + @GetMapping("/b/status") + Object getStatus(); +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'src/controller/StatusController.java'), + ` +package com.example.controller; +import com.example.a.StatusApi; +import org.springframework.web.bind.annotation.*; + +@RestController +public class StatusController implements StatusApi { + @Override + public Object getStatus() { return null; } +} +`, + ); + + 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::/b/status')).toBeUndefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/a/status')).toBeUndefined(); + expect( + providers.filter((c) => c.symbolRef.filePath.includes('StatusController.java')), + ).toHaveLength(0); + }); + it('extracts Express router.get patterns', async () => { const dir = path.join(tmpDir, 'express'); fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true }); @@ -1561,7 +1738,7 @@ interface NotFeignClient { const providers = contracts.filter((c) => c.role === 'provider'); expect(consumers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined(); - expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined(); }); it('extracts OpenFeign clients with @RequestMapping interface prefixes', async () => { @@ -1691,12 +1868,13 @@ class HttpClients { ).toBeDefined(); }); - // ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ── + // ─── Kotlin consumers (RestTemplate / WebClient short+long / OkHttp) ── // Same shape as the Java consumer test above, but parsed by the - // tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Three - // consumer flavors covered here (long-form WebClient - // `webClient.method(HttpMethod.X).uri(...)` is intentionally - // deferred to a follow-up — see kotlin.ts file header). + // tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Four + // consumer flavors covered here: RestTemplate (#1855), WebClient + // short form (#1855), OkHttp (#1855), and WebClient long form + // (`webClient.method(HttpMethod.X).uri(...)`, this PR / #1884) — + // see kotlin.ts file header for the full list. // // tree-sitter-kotlin is an optionalDependency. If the binding is // unavailable, `getPluginForFile` returns undefined for `.kt` and @@ -1875,28 +2053,104 @@ class OkPostClient(private val client: OkHttpClient, private val body: RequestBo }, ); + itKotlinConsumer('extracts Kotlin WebClient long form GET', async () => { + const dir = path.join(tmpDir, 'kotlin-web-client-long-get'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'LongGetClient.kt'), + `package com.example +import org.springframework.http.HttpMethod +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody + +class LongGetClient(private val webClient: WebClient) { + suspend fun run() { + val r = webClient.method(HttpMethod.GET).uri("/api/users").retrieve().awaitBody() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const route = consumers.find((c) => c.contractId === 'http::GET::/api/users'); + expect(route).toBeDefined(); + expect(route!.meta.framework).toBe('spring-web-client'); + }); + + itKotlinConsumer('extracts Kotlin WebClient long form POST/PUT/DELETE/PATCH', async () => { + const dir = path.join(tmpDir, 'kotlin-web-client-long-verbs'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'LongVerbClient.kt'), + `package com.example +import org.springframework.http.HttpMethod +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody +import org.springframework.web.reactive.function.client.awaitBodilessEntity + +class LongVerbClient(private val webClient: WebClient) { + suspend fun run() { + webClient.method(HttpMethod.POST).uri("/api/orders").retrieve().awaitBody() + webClient.method(HttpMethod.PUT).uri("/api/orders/1").retrieve().awaitBody() + webClient.method(HttpMethod.DELETE).uri("/api/orders/2").retrieve().awaitBodilessEntity() + webClient.method(HttpMethod.PATCH).uri("/api/orders/3").retrieve().awaitBody() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'), + ).toBeDefined(); + + // All four should be tagged as `spring-web-client` so polyglot + // repos coalesce on the same framework key as the short form. + // The fixture is fully deterministic — exactly 4 long-form calls, + // no short-form / RestTemplate / OkHttp calls mixed in — so an + // exact count is meaningful (DoD §2.7). If a future change + // accidentally emits a 5th consumer (e.g. duplicate query firing, + // or a regressed receiver constraint matching unrelated calls), + // this assertion catches it. + const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client'); + expect(wcConsumers).toHaveLength(4); + }); + itKotlinConsumer( - 'does NOT match Kotlin WebClient long form (deferred to follow-up)', + 'short-form query does NOT also fire on Kotlin WebClient long form (no double-emit)', async () => { - // Anti-overreach: confirm the short-form query does NOT - // accidentally fire on the long-form chain - // `webClient.method(HttpMethod.GET).uri(...)`. The long form - // is intentionally unsupported in this PR; if a future change - // to the short-form query starts capturing it we want a loud - // signal here. Long-form support will arrive in a follow-up - // with a dedicated query + verb walk-up helper. - const dir = path.join(tmpDir, 'kotlin-web-client-long'); + // The long-form query handles `webClient.method(HttpMethod.X).uri(...)`, + // and the short-form query handles `webClient.get().uri(...)`. Both + // queries carry sibling `(navigation_suffix (simple_identifier) @verb)` + // constraints — short form requires the verb name itself + // (`get`/`post`/...), long form requires the literal name + // `method`. The two are disjoint. + // + // This test pins that disjointness: a single `.method(HttpMethod.GET)` + // call must emit ONE consumer, not two (one from each query). + const dir = path.join(tmpDir, 'kotlin-web-client-long-no-double'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); fs.writeFileSync( - path.join(dir, 'src', 'LegacyClient.kt'), + path.join(dir, 'src', 'NoDoubleClient.kt'), `package com.example import org.springframework.http.HttpMethod import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.awaitBody -class LegacyClient(private val webClient: WebClient) { +class NoDoubleClient(private val webClient: WebClient) { suspend fun run() { - val r = webClient.method(HttpMethod.GET).uri("/api/legacy").retrieve().awaitBody() + webClient.method(HttpMethod.GET).uri("/api/single").retrieve().awaitBody() } } `, @@ -1905,12 +2159,49 @@ class LegacyClient(private val webClient: WebClient) { const contracts = await extractor.extract(null, dir, makeRepo(dir)); const consumers = contracts.filter((c) => c.role === 'consumer'); - // No consumer should be emitted from this file by the - // current short-form query. Documented as a known limitation. - const fromLegacy = consumers.filter((c) => - c.symbolRef.filePath.endsWith('LegacyClient.kt'), + const fromThisFile = consumers.filter((c) => + c.symbolRef.filePath.endsWith('NoDoubleClient.kt'), ); - expect(fromLegacy).toHaveLength(0); + expect(fromThisFile).toHaveLength(1); + expect(fromThisFile[0].contractId).toBe('http::GET::/api/single'); + }, + ); + + itKotlinConsumer( + 'does NOT match Kotlin WebClient long form with variable-bound verb', + async () => { + // Anti-overreach: source-scan can't follow `val verb = HttpMethod.X` + // back to the literal — that's a graph-aware concern. The long-form + // query requires `(navigation_expression HttpMethod . verb)` as the + // `value_argument` shape, so a bare `simple_identifier` (the + // variable name) fails to match. Pin this so a future relaxation + // of the value_argument shape cannot silently start guessing the + // verb from arbitrary identifiers. + const dir = path.join(tmpDir, 'kotlin-web-client-long-var-verb'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'VariableVerbClient.kt'), + `package com.example +import org.springframework.http.HttpMethod +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody + +class VariableVerbClient(private val webClient: WebClient) { + suspend fun run() { + val verb = HttpMethod.PATCH + val r = webClient.method(verb).uri("/api/dynamic").retrieve().awaitBody() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const fromThisFile = consumers.filter((c) => + c.symbolRef.filePath.endsWith('VariableVerbClient.kt'), + ); + expect(fromThisFile).toHaveLength(0); }, );