From aa2b9f6ef73641c7f85d3b20f6bf11804e1dfe42 Mon Sep 17 00:00:00 2001 From: henry Date: Fri, 29 May 2026 18:51:36 +0800 Subject: [PATCH 1/6] feat(group): extract OpenFeign @RequestLine consumer contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Java HTTP plugin support for the native OpenFeign annotation `@RequestLine("METHOD /path")`. Previously only `@FeignClient` interfaces using Spring MVC method annotations (`@GetMapping` etc.) were detected; the native annotation form — required by Feign Builder users and non-Spring Feign deployments — was silently ignored. Implementation: - New `FEIGN_REQUEST_LINE_PATTERNS` covers both positional and named-arg (`value =`) forms. - New `parseRequestLine()` parses the verb+path string and drops any query string (consistent with how RestTemplate/WebClient consumers handle inline literal URLs). - The enclosing interface MUST carry `@FeignClient`; otherwise the detection is dropped to avoid false positives from same-named annotations in unrelated libraries. - Reuses the existing `feignPrefixByInterfaceId` map so `@FeignClient(path=)` and `@RequestMapping` interface prefixes apply uniformly across both Spring MVC and `@RequestLine` methods. - Confidence 0.75 — slightly higher than the 0.7 used for Spring MVC annotations because the verb is a string-literal value, not inferred from the annotation name (less ambiguous). Six new unit tests cover: basic two-method extraction; `@FeignClient(path=)` prefix joining; query-string stripping; rejection of `@RequestLine` on non-Feign interfaces; mixing with `@GetMapping` on the same interface; named-argument form (`value = "..."`). Verification: `npx tsc --noEmit`, full `test/unit/group` (31 files / 563 tests), `http-route-extractor.test.ts` (83/83 incl. 6 new), `prettier --check` and `eslint` on touched files all pass. --- .../group/extractors/http-patterns/java.ts | 102 +++++++++- .../unit/group/http-route-extractor.test.ts | 192 ++++++++++++++++++ 2 files changed, 293 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 48da46765e..cafbc517d4 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -19,7 +19,8 @@ import type { * - Spring `RestTemplate.getForObject/...`, `exchange(...)` * - Spring `WebClient.method(HttpMethod.X, ...)`, `WebClient.get().uri(...)` * - OkHttp `new Request.Builder().url("...")` - * - OpenFeign interfaces with Spring MVC method annotations + * - OpenFeign interfaces with Spring MVC method annotations or + * native `@RequestLine("METHOD /path")` annotations * - Java / Apache HttpClient literal request construction * * The plugin runs two pattern bundles: one to collect class-level @@ -135,6 +136,77 @@ const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); +// ─── Consumer: OpenFeign `@RequestLine("METHOD /path")` ────────────── +// OpenFeign's native annotation pairs an HTTP method and path in a single +// string literal — see https://github.com/OpenFeign/feign#interface-annotations. +// It is method-level only and is mutually exclusive with Spring MVC +// `@GetMapping` / `@PostMapping` etc. on the same method (mixing them +// requires a different Feign Contract — they are not combined here). +// +// Examples: +// @RequestLine("GET /users/{id}") +// @RequestLine("POST /users?status=active") +// +// Captured tokens: +// @line → the literal `@RequestLine` +// @value → the request-line string literal (e.g. `"GET /users/{id}"`) +// @method → the enclosing method node, used for enclosing-interface lookup +const FEIGN_REQUEST_LINE_PATTERNS = compilePatterns({ + name: 'java-feign-request-line', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_declaration + (modifiers + (annotation + name: (identifier) @line (#eq? @line "RequestLine") + arguments: (annotation_argument_list (string_literal) @value))) + name: (identifier) @method_name) @method + `, + }, + { + meta: {}, + query: ` + (method_declaration + (modifiers + (annotation + name: (identifier) @line (#eq? @line "RequestLine") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#eq? @key "value") + value: (string_literal) @value)))) + name: (identifier) @method_name) @method + `, + }, + ], +} satisfies LanguagePatterns>); + +const REQUEST_LINE_VERB_RE = /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S.*?)\s*$/i; + +/** + * Parse a Feign `@RequestLine` value into a method + path pair. + * + * `@RequestLine("METHOD /path[?query]")` packs both fields in one string; + * the query portion is dropped because contract IDs are method+path only + * (consistent with how other consumers like RestTemplate/WebClient drop + * query strings when their values are inline literals). + * + * Returns null if the value is not a recognized HTTP verb followed by a + * path beginning with `/`. + */ +function parseRequestLine(raw: string): { method: string; path: string } | null { + const match = REQUEST_LINE_VERB_RE.exec(raw); + if (!match) return null; + const [, verb, rest] = match; + if (typeof verb !== 'string' || typeof rest !== 'string') return null; + const queryIdx = rest.indexOf('?'); + const pathOnly = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).trim(); + if (!pathOnly.startsWith('/')) return null; + return { method: verb.toUpperCase(), path: pathOnly }; +} + // ─── Consumer: OpenFeign interface-level prefixes ─────────────────── // Feign's `name`/`value` attributes identify a service, not an HTTP path, // so only `path` is used as a URL prefix. `@RequestMapping` on a Feign @@ -644,6 +716,34 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { }); } + // ─── Consumers: OpenFeign `@RequestLine("METHOD /path")` ──────── + // The native Feign annotation. Method-level only; the enclosing + // interface MUST carry `@FeignClient`, otherwise the pattern is + // a false positive (the same annotation name exists in non-Feign + // libraries and could appear in non-client code). + for (const match of runCompiledPatterns(FEIGN_REQUEST_LINE_PATTERNS, tree)) { + const valueNode = match.captures.value; + const nameNode = match.captures.method_name; + const methodNode = match.captures.method; + if (!valueNode || !methodNode) continue; + const raw = unquoteLiteral(valueNode.text); + if (raw === null) continue; + const parsed = parseRequestLine(raw); + if (!parsed) continue; + const enclosingInterface = findEnclosingInterface(methodNode); + if (!enclosingInterface || !hasAnnotation(enclosingInterface, 'FeignClient')) continue; + const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? ''; + const fullPath = joinPath(prefix, parsed.path); + out.push({ + role: 'consumer', + framework: 'openfeign', + method: parsed.method, + path: fullPath, + name: nameNode?.text ?? null, + confidence: 0.75, + }); + } + // ─── Consumers: RestTemplate ──────────────────────────────────── for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) { const methodNode = match.captures.method; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 58db213e9c..20c684c2a7 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -1774,6 +1774,198 @@ interface PrecedenceClient { expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined(); }); + it('extracts native @RequestLine consumers on @FeignClient interfaces', async () => { + const dir = path.join(tmpDir, 'java-feign-request-line-basic'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'AiClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import feign.RequestLine; + +@FeignClient(name = "ai-backend") +interface AiClient { + @RequestLine("POST /ai/summarize") + String summarize(); + + @RequestLine("GET /ai/health") + String health(); +} +`, + ); + + 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::/ai/summarize' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/ai/health' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + }); + + it('joins @FeignClient(path=...) prefix with @RequestLine paths', async () => { + const dir = path.join(tmpDir, 'java-feign-request-line-prefix'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OrderClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import feign.RequestLine; + +@FeignClient(name = "order-service", path = "/api") +interface OrderClient { + @RequestLine("GET /orders/{id}") + OrderDto get(Long id); + + @RequestLine("DELETE /orders/{id}") + void delete(Long id); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'), + ).toBeDefined(); + }); + + it('strips query strings from @RequestLine values when forming contract IDs', async () => { + const dir = path.join(tmpDir, 'java-feign-request-line-query'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'SearchClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import feign.RequestLine; + +@FeignClient(name = "search-service") +interface SearchClient { + @RequestLine("GET /search?q={query}&limit={limit}") + SearchResult search(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // Query string is dropped — contract ID is method+path only. + expect(consumers.find((c) => c.contractId === 'http::GET::/search')).toBeDefined(); + expect( + consumers.find((c) => c.contractId.includes('?') || c.contractId.includes('limit')), + ).toBeUndefined(); + }); + + it('ignores @RequestLine on interfaces without @FeignClient', async () => { + const dir = path.join(tmpDir, 'java-request-line-no-feign'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'PlainInterface.java'), + ` +import feign.RequestLine; + +interface PlainInterface { + @RequestLine("GET /not-a-feign-client") + String shouldNotBeExtracted(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::GET::/not-a-feign-client'), + ).toBeUndefined(); + }); + + it('mixes @RequestLine and @GetMapping methods on the same @FeignClient interface', async () => { + const dir = path.join(tmpDir, 'java-feign-mixed-annotations'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'MixedClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import feign.RequestLine; + +@FeignClient(name = "mixed-service", path = "/api") +interface MixedClient { + @GetMapping("/spring-style") + String springStyle(); + + @RequestLine("GET /native-style") + String nativeStyle(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // Both annotation styles produce contracts — they don't conflict. + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/api/spring-style' && + c.meta.framework === 'openfeign' && + c.confidence === 0.7, + ), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/api/native-style' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + }); + + it('extracts @RequestLine values written with the named "value" argument', async () => { + const dir = path.join(tmpDir, 'java-feign-request-line-named'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'NamedArgClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import feign.RequestLine; + +@FeignClient(name = "named-arg-service") +interface NamedArgClient { + @RequestLine(value = "POST /create") + String create(); +} +`, + ); + + 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::/create' && c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + }); + it('extracts Java and Apache HttpClient literal request construction', async () => { const dir = path.join(tmpDir, 'java-http-client-consumer'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); From f10854d3906a256dca00ce555a757e1471fae2bd Mon Sep 17 00:00:00 2001 From: henry Date: Fri, 29 May 2026 19:16:32 +0800 Subject: [PATCH 2/6] refactor(group): collapse @RequestLine positional + named-arg into one query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @magyargergo's review on PR #1904 — uses tree-sitter alternation `[(...) (...)]` so the positional and named-argument forms of the `@RequestLine` annotation are matched by a single compiled query and invoked through one `runCompiledPatterns` pass instead of two. --- .../group/extractors/http-patterns/java.ts | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index cafbc517d4..e3f50464c0 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -147,9 +147,13 @@ const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({ // @RequestLine("GET /users/{id}") // @RequestLine("POST /users?status=active") // +// Both positional and named-argument (`value = "..."`) forms are matched +// in a single query via tree-sitter alternation, so `compilePatterns` +// builds and `runCompiledPatterns` invokes exactly one query. +// // Captured tokens: -// @line → the literal `@RequestLine` -// @value → the request-line string literal (e.g. `"GET /users/{id}"`) +// @line → the literal `@RequestLine` +// @value → the request-line string literal (e.g. `"GET /users/{id}"`) // @method → the enclosing method node, used for enclosing-interface lookup const FEIGN_REQUEST_LINE_PATTERNS = compilePatterns({ name: 'java-feign-request-line', @@ -158,26 +162,23 @@ const FEIGN_REQUEST_LINE_PATTERNS = compilePatterns({ { meta: {}, query: ` - (method_declaration - (modifiers - (annotation - name: (identifier) @line (#eq? @line "RequestLine") - arguments: (annotation_argument_list (string_literal) @value))) - name: (identifier) @method_name) @method - `, - }, - { - meta: {}, - query: ` - (method_declaration - (modifiers - (annotation - name: (identifier) @line (#eq? @line "RequestLine") - arguments: (annotation_argument_list - (element_value_pair - key: (identifier) @key (#eq? @key "value") - value: (string_literal) @value)))) - name: (identifier) @method_name) @method + [ + (method_declaration + (modifiers + (annotation + name: (identifier) @line (#eq? @line "RequestLine") + arguments: (annotation_argument_list (string_literal) @value))) + name: (identifier) @method_name) @method + (method_declaration + (modifiers + (annotation + name: (identifier) @line (#eq? @line "RequestLine") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#eq? @key "value") + value: (string_literal) @value)))) + name: (identifier) @method_name) @method + ] `, }, ], From e39eb5f015c0a822ac54702f7e9ce42219cdaae8 Mon Sep 17 00:00:00 2001 From: henry Date: Fri, 29 May 2026 22:36:01 +0800 Subject: [PATCH 3/6] refactor(group): drop framework prefixes from java http pattern constant names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on #1904 — renames the four route-mapper pattern constants to framework-agnostic names (the per-constant comments already document which framework each targets): SPRING_TYPE_PREFIX_PATTERNS -> TYPE_PREFIX_PATTERNS FEIGN_REQUEST_LINE_PATTERNS -> REQUEST_LINE_PATTERNS FEIGN_INTERFACE_PREFIX_PATTERNS -> INTERFACE_PREFIX_PATTERNS SPRING_METHOD_ROUTE_PATTERNS -> METHOD_ROUTE_PATTERNS --- .../group/extractors/http-patterns/java.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index e3f50464c0..38514d3d8e 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -73,7 +73,7 @@ interface SpringTypeInfo { } // ─── Provider: Spring class/interface-level @RequestMapping prefix ─── -const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({ +const TYPE_PREFIX_PATTERNS = compilePatterns({ name: 'java-spring-type-prefix', language: Java, patterns: [ @@ -155,7 +155,7 @@ const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({ // @line → the literal `@RequestLine` // @value → the request-line string literal (e.g. `"GET /users/{id}"`) // @method → the enclosing method node, used for enclosing-interface lookup -const FEIGN_REQUEST_LINE_PATTERNS = compilePatterns({ +const REQUEST_LINE_PATTERNS = compilePatterns({ name: 'java-feign-request-line', language: Java, patterns: [ @@ -212,7 +212,7 @@ function parseRequestLine(raw: string): { method: string; path: string } | null // Feign's `name`/`value` attributes identify a service, not an HTTP path, // so only `path` is used as a URL prefix. `@RequestMapping` on a Feign // interface is also common and does carry a path prefix. -const FEIGN_INTERFACE_PREFIX_PATTERNS = compilePatterns({ +const INTERFACE_PREFIX_PATTERNS = compilePatterns({ name: 'java-feign-interface-prefix', language: Java, patterns: [ @@ -260,7 +260,7 @@ const FEIGN_INTERFACE_PREFIX_PATTERNS = compilePatterns({ // pattern restricts the annotation member name to `path`/`value` to // avoid capturing unrelated string-valued attributes // (`produces`, `consumes`, `headers`, `name`, `params`, ...). -const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ +const METHOD_ROUTE_PATTERNS = compilePatterns({ name: 'java-spring-method-route', language: Java, patterns: [ @@ -508,7 +508,7 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[ function collectTypePrefixes(tree: Parser.Tree): Map { const prefixByTypeId = new Map(); - for (const match of runCompiledPatterns(SPRING_TYPE_PREFIX_PATTERNS, tree)) { + for (const match of runCompiledPatterns(TYPE_PREFIX_PATTERNS, tree)) { const prefixNode = match.captures.prefix; const typeNode = match.captures.type; if (!prefixNode || !typeNode) continue; @@ -520,7 +520,7 @@ function collectTypePrefixes(tree: Parser.Tree): Map { function collectMethodRoutes(tree: Parser.Tree): Map { const routesByMethodId = new Map(); - for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { + for (const match of runCompiledPatterns(METHOD_ROUTE_PATTERNS, tree)) { const annNode = match.captures.ann; const pathNode = match.captures.path; const methodNode = match.captures.method; @@ -670,7 +670,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { const prefixByTypeId = collectTypePrefixes(tree); const feignPrefixByInterfaceId = new Map(); - for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) { + for (const match of runCompiledPatterns(INTERFACE_PREFIX_PATTERNS, tree)) { const prefixNode = match.captures.prefix; const interfaceNode = match.captures.interface; if (!prefixNode || !interfaceNode) continue; @@ -679,7 +679,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { feignPrefixByInterfaceId.set(interfaceNode.id, prefix); } - for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { + for (const match of runCompiledPatterns(METHOD_ROUTE_PATTERNS, tree)) { const annNode = match.captures.ann; const pathNode = match.captures.path; const nameNode = match.captures.method_name; @@ -722,7 +722,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { // interface MUST carry `@FeignClient`, otherwise the pattern is // a false positive (the same annotation name exists in non-Feign // libraries and could appear in non-client code). - for (const match of runCompiledPatterns(FEIGN_REQUEST_LINE_PATTERNS, tree)) { + for (const match of runCompiledPatterns(REQUEST_LINE_PATTERNS, tree)) { const valueNode = match.captures.value; const nameNode = match.captures.method_name; const methodNode = match.captures.method; From 14a078b29628ae6e5a00d74931f2339d94262e7d Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sat, 30 May 2026 06:18:25 +0000 Subject: [PATCH 4/6] refactor(group): collapse Java route-mapper annotations into one query Merge the four annotation pattern bundles (Spring @RequestMapping type prefix, @FeignClient(path) prefix, @(Get|Post|Put|Delete|Patch)Mapping method routes and native @RequestLine) into a single JAVA_ROUTE_ANNOTATION_PATTERNS query, read by scanRouteAnnotations() in exactly one matches() pass per file. Variants are tagged by branch-local captures and discriminated in JS (METHOD_ANNOTATION_TO_HTTP, isRouteMemberKey), per review feedback. This drops the per-file annotation passes from 4->1 in scan() and 2->1 in collectSpringTypes(), and removes the interface-@RequestMapping / @FeignClient prefix redundancy. Verb and path/value key filtering stay in JS rather than in-query: under the pinned tree-sitter 0.21.1 binding a top-level [...] alternation compiles to one pattern whose text predicates share a single bucket keyed by capture name. A #match? against a capture absent from the matched branch evaluates FALSE and silently drops every sibling-branch match, whereas #eq? against an absent capture is vacuously true. So only fixed annotation names use in-query #eq? (on branch-local captures); the variable verb name and member key carry no in-query predicate. Behaviour is unchanged for all compilable Java; existing http-route tests (93) and the full group suite remain green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../group/extractors/http-patterns/java.ts | 482 +++++++++--------- 1 file changed, 244 insertions(+), 238 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 38514d3d8e..e300c515b1 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -23,11 +23,14 @@ import type { * native `@RequestLine("METHOD /path")` annotations * - Java / Apache HttpClient literal request construction * - * The plugin runs two pattern bundles: one to collect class-level - * `@RequestMapping` prefixes keyed by the enclosing class node, and a - * second to match method-level annotations. The `scan` function walks - * up from each matched annotation to find its enclosing class and - * combines the prefix with the method path. + * Every route-defining annotation (class/interface `@RequestMapping` + * prefixes, `@FeignClient(path)` prefixes, `@(Get|...)Mapping` method + * routes and native `@RequestLine`s) is matched by a single consolidated + * query (`JAVA_ROUTE_ANNOTATION_PATTERNS`) in one pass via + * `scanRouteAnnotations`. The `scan` function then walks up from each + * matched method to its enclosing class/interface to combine the prefix + * with the method path. Call-site consumers (RestTemplate, WebClient, + * OkHttp, Java/Apache HttpClient) keep their own focused queries. */ const METHOD_ANNOTATION_TO_HTTP: Record = { @@ -38,20 +41,17 @@ const METHOD_ANNOTATION_TO_HTTP: Record = { PatchMapping: 'PATCH', }; -// ─── Provider: Spring class-level @RequestMapping prefix ────────────── -// Two patterns are needed because the AST shape differs depending on -// whether the annotation uses a positional argument or a named one: +// Each route-defining annotation has two AST shapes — a positional argument +// and a named one — that must both be matched: // @RequestMapping("/api") → (annotation_argument_list (string_literal)) // @RequestMapping(path = "/api") → (annotation_argument_list (element_value_pair key:(identifier) value:(string_literal))) // @RequestMapping(value = "/api") → same as above -// -// The named-argument pattern MUST constrain the `key` field to the route -// member names (`path`/`value`); without it, the query also captures -// non-route attributes such as `produces`, `consumes`, `headers`, `name`, -// `params` (their right-hand string literals would be mis-extracted as -// 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. +// For named arguments only the route member keys (`path`/`value`) carry a URL; +// non-route attributes (`produces`, `consumes`, `headers`, `name`, `params`) +// would otherwise be mis-extracted (e.g. `produces = "application/json"` would +// corrupt every route). That key filtering is done in `isRouteMemberKey`, and +// all of these annotations are matched by the one `JAVA_ROUTE_ANNOTATION_PATTERNS` +// query below (see its header for why the filtering lives in JS, not the query). interface SpringRouteBinding { method: string; path: string; @@ -72,9 +72,32 @@ interface SpringTypeInfo { methods: SpringMethodInfo[]; } -// ─── Provider: Spring class/interface-level @RequestMapping prefix ─── -const TYPE_PREFIX_PATTERNS = compilePatterns({ - name: 'java-spring-type-prefix', +// ─── Consolidated route-defining annotations (one query, one pass) ──── +// Every Java route-mapper annotation is matched by this SINGLE query so the +// scan walks the tree exactly once per file. `scanRouteAnnotations` branches +// on which captures each match carries to build the prefix maps and the +// per-method route / request-line lists. +// +// Variants (each in both positional `"..."` and named `path = "..."` shapes): +// • Spring class/interface `@RequestMapping(...)` prefix → @reqmap_type / @reqmap_prefix / @reqmap_key +// • OpenFeign `@FeignClient(path = "...")` prefix → @feign_interface / @feign_prefix +// • Spring `@(Get|Post|Put|Delete|Patch)Mapping(...)` → @method / @method_name / @mapping_ann / @mapping_path / @mapping_key +// • OpenFeign native `@RequestLine("METHOD /path")` → @method / @method_name / @line_value +// +// tree-sitter 0.21.x binding constraint (verified empirically): a top-level +// `[ ... ]` alternation compiles to ONE pattern whose text predicates share a +// single bucket and are applied to every match, keyed by capture name. Two +// behaviours follow and shape this query: +// 1. `#eq?` against a capture absent from the matched branch is vacuously +// true, so fixed annotation names are safely pinned with branch-local +// `#eq?` captures (`@reqmap_ann`, `@feign_ann`, `@line_ann`). +// 2. `#match?` against an absent capture is FALSE — a single `#match?` in the +// alternation would silently drop every sibling-branch match. So the +// variable discriminators (the HTTP verb annotation name and the +// `path`/`value` member key) carry NO in-query predicate and are filtered +// in JS (`METHOD_ANNOTATION_TO_HTTP`, `isRouteMemberKey`). +const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({ + name: 'java-route-annotation', language: Java, patterns: [ { @@ -84,36 +107,70 @@ const TYPE_PREFIX_PATTERNS = compilePatterns({ (class_declaration (modifiers (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list (string_literal) @prefix)))) @type + name: (identifier) @reqmap_ann (#eq? @reqmap_ann "RequestMapping") + arguments: (annotation_argument_list (string_literal) @reqmap_prefix)))) @reqmap_type (interface_declaration (modifiers (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list (string_literal) @prefix)))) @type - ] - `, - }, - { - meta: {}, - query: ` - [ + name: (identifier) @reqmap_ann (#eq? @reqmap_ann "RequestMapping") + arguments: (annotation_argument_list (string_literal) @reqmap_prefix)))) @reqmap_type (class_declaration (modifiers (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") + name: (identifier) @reqmap_ann (#eq? @reqmap_ann "RequestMapping") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @reqmap_key + value: (string_literal) @reqmap_prefix))))) @reqmap_type + (interface_declaration + (modifiers + (annotation + name: (identifier) @reqmap_ann (#eq? @reqmap_ann "RequestMapping") arguments: (annotation_argument_list (element_value_pair - key: (identifier) @key (#match? @key "^(path|value)$") - value: (string_literal) @prefix))))) @type + key: (identifier) @reqmap_key + value: (string_literal) @reqmap_prefix))))) @reqmap_type + (interface_declaration (modifiers (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") + name: (identifier) @feign_ann (#eq? @feign_ann "FeignClient") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @feign_key (#eq? @feign_key "path") + value: (string_literal) @feign_prefix))))) @feign_interface + + (method_declaration + (modifiers + (annotation + name: (identifier) @mapping_ann + arguments: (annotation_argument_list (string_literal) @mapping_path))) + name: (identifier) @method_name) @method + (method_declaration + (modifiers + (annotation + name: (identifier) @mapping_ann + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @mapping_key + value: (string_literal) @mapping_path)))) + name: (identifier) @method_name) @method + + (method_declaration + (modifiers + (annotation + name: (identifier) @line_ann (#eq? @line_ann "RequestLine") + arguments: (annotation_argument_list (string_literal) @line_value))) + name: (identifier) @method_name) @method + (method_declaration + (modifiers + (annotation + name: (identifier) @line_ann (#eq? @line_ann "RequestLine") arguments: (annotation_argument_list (element_value_pair - key: (identifier) @key (#match? @key "^(path|value)$") - value: (string_literal) @prefix))))) @type + key: (identifier) @line_key (#eq? @line_key "value") + value: (string_literal) @line_value)))) + name: (identifier) @method_name) @method ] `, }, @@ -136,54 +193,18 @@ const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); -// ─── Consumer: OpenFeign `@RequestLine("METHOD /path")` ────────────── +// ─── Consumer: OpenFeign `@RequestLine("METHOD /path")` parsing ─────── // OpenFeign's native annotation pairs an HTTP method and path in a single // string literal — see https://github.com/OpenFeign/feign#interface-annotations. // It is method-level only and is mutually exclusive with Spring MVC // `@GetMapping` / `@PostMapping` etc. on the same method (mixing them -// requires a different Feign Contract — they are not combined here). +// requires a different Feign Contract — they are not combined). The match +// itself comes from `JAVA_ROUTE_ANNOTATION_PATTERNS`; this regex splits the +// verb from the path of the captured literal. // // Examples: // @RequestLine("GET /users/{id}") // @RequestLine("POST /users?status=active") -// -// Both positional and named-argument (`value = "..."`) forms are matched -// in a single query via tree-sitter alternation, so `compilePatterns` -// builds and `runCompiledPatterns` invokes exactly one query. -// -// Captured tokens: -// @line → the literal `@RequestLine` -// @value → the request-line string literal (e.g. `"GET /users/{id}"`) -// @method → the enclosing method node, used for enclosing-interface lookup -const REQUEST_LINE_PATTERNS = compilePatterns({ - name: 'java-feign-request-line', - language: Java, - patterns: [ - { - meta: {}, - query: ` - [ - (method_declaration - (modifiers - (annotation - name: (identifier) @line (#eq? @line "RequestLine") - arguments: (annotation_argument_list (string_literal) @value))) - name: (identifier) @method_name) @method - (method_declaration - (modifiers - (annotation - name: (identifier) @line (#eq? @line "RequestLine") - arguments: (annotation_argument_list - (element_value_pair - key: (identifier) @key (#eq? @key "value") - value: (string_literal) @value)))) - name: (identifier) @method_name) @method - ] - `, - }, - ], -} satisfies LanguagePatterns>); - const REQUEST_LINE_VERB_RE = /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S.*?)\s*$/i; /** @@ -208,90 +229,6 @@ function parseRequestLine(raw: string): { method: string; path: string } | null return { method: verb.toUpperCase(), path: pathOnly }; } -// ─── Consumer: OpenFeign interface-level prefixes ─────────────────── -// Feign's `name`/`value` attributes identify a service, not an HTTP path, -// so only `path` is used as a URL prefix. `@RequestMapping` on a Feign -// interface is also common and does carry a path prefix. -const INTERFACE_PREFIX_PATTERNS = compilePatterns({ - name: 'java-feign-interface-prefix', - language: Java, - patterns: [ - { - meta: {}, - query: ` - (interface_declaration - (modifiers - (annotation - name: (identifier) @ann (#eq? @ann "FeignClient") - arguments: (annotation_argument_list - (element_value_pair - key: (identifier) @key (#eq? @key "path") - value: (string_literal) @prefix))))) @interface - `, - }, - { - meta: {}, - query: ` - (interface_declaration - (modifiers - (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list (string_literal) @prefix)))) @interface - `, - }, - { - meta: {}, - query: ` - (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))))) @interface - `, - }, - ], -} satisfies LanguagePatterns>); - -// ─── Provider: Spring @(Get|Post|...)Mapping method annotations ─────── -// Same dual-pattern approach: positional vs named argument. The named -// pattern restricts the annotation member name to `path`/`value` to -// avoid capturing unrelated string-valued attributes -// (`produces`, `consumes`, `headers`, `name`, `params`, ...). -const METHOD_ROUTE_PATTERNS = compilePatterns({ - name: 'java-spring-method-route', - language: Java, - patterns: [ - { - meta: {}, - query: ` - (method_declaration - (modifiers - (annotation - name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$") - arguments: (annotation_argument_list (string_literal) @path))) - name: (identifier) @method_name) @method - `, - }, - { - meta: {}, - query: ` - (method_declaration - (modifiers - (annotation - name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$") - arguments: (annotation_argument_list - (element_value_pair - key: (identifier) @key (#match? @key "^(path|value)$") - value: (string_literal) @path)))) - name: (identifier) @method_name) @method - `, - }, - ], -} satisfies LanguagePatterns>); - // ─── Consumer: Spring RestTemplate (object-named + method-named) ────── // RestTemplate.getForObject / getForEntity → GET // RestTemplate.postForObject / postForEntity → POST @@ -506,34 +443,121 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[ return false; } -function collectTypePrefixes(tree: Parser.Tree): Map { +/** + * A named annotation argument contributes a route only when its member key is + * `path` or `value`; a positional argument (no key node) always qualifies. + * This is the JS-side replacement for the in-query `^(path|value)$` filter and + * drops Spring's non-route string attributes (`produces`, `consumes`, + * `headers`, `name`, `params`) that would otherwise be mis-read as routes. + */ +function isRouteMemberKey(keyNode: Parser.SyntaxNode | undefined): boolean { + if (!keyNode) return true; + return keyNode.text === 'path' || keyNode.text === 'value'; +} + +interface MethodRouteAnnotation { + methodNode: Parser.SyntaxNode; + methodName: string | null; + httpMethod: string; + rawPath: string; +} + +interface RequestLineAnnotation { + methodNode: Parser.SyntaxNode; + methodName: string | null; + parsed: { method: string; path: string }; +} + +interface RouteAnnotationScan { + /** Spring `@RequestMapping` URL prefix per class/interface node id (last write wins). */ + prefixByTypeId: Map; + /** OpenFeign interface prefix per interface node id; `@FeignClient(path)` wins over `@RequestMapping`. */ + feignPrefixByInterfaceId: Map; + /** One entry per resolved Spring `@(Get|...)Mapping` route — a method with N mappings yields N entries. */ + methodRoutes: MethodRouteAnnotation[]; + /** One entry per OpenFeign `@RequestLine` whose value parses to a verb + path. */ + requestLines: RequestLineAnnotation[]; +} + +/** + * Resolve every Java route-defining annotation in a single tree-sitter pass. + * + * All variants come from the one `JAVA_ROUTE_ANNOTATION_PATTERNS` query; this + * function branches on which captures a match carries to build the prefix maps + * and the per-method route / request-line lists. The HTTP verb annotation name + * and the `path`/`value` member key are filtered here (see the query header for + * why that discrimination cannot live in the alternation predicates). + */ +function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { + const matches = runCompiledPatterns(JAVA_ROUTE_ANNOTATION_PATTERNS, tree); + const prefixByTypeId = new Map(); - for (const match of runCompiledPatterns(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); + const feignPrefixByInterfaceId = new Map(); + const methodRoutes: MethodRouteAnnotation[] = []; + const requestLines: RequestLineAnnotation[] = []; + // Interface `@RequestMapping` prefixes rank below `@FeignClient(path)`; + // collect them and apply only after the FeignClient pass below. + const interfaceRequestMappingPrefixes: Array<{ id: number; prefix: string }> = []; + + for (const { captures } of matches) { + // Spring `@RequestMapping` class/interface prefix. + const reqmapType = captures.reqmap_type; + const reqmapPrefix = captures.reqmap_prefix; + if (reqmapType && reqmapPrefix && isRouteMemberKey(captures.reqmap_key)) { + const prefix = unquoteLiteral(reqmapPrefix.text); + if (prefix !== null) { + prefixByTypeId.set(reqmapType.id, prefix); + if (reqmapType.type === 'interface_declaration') { + interfaceRequestMappingPrefixes.push({ id: reqmapType.id, prefix }); + } + } + } + + // OpenFeign `@FeignClient(path = "...")` interface prefix (highest precedence, first wins). + const feignInterface = captures.feign_interface; + const feignPrefix = captures.feign_prefix; + if (feignInterface && feignPrefix && !feignPrefixByInterfaceId.has(feignInterface.id)) { + const prefix = unquoteLiteral(feignPrefix.text); + if (prefix !== null) feignPrefixByInterfaceId.set(feignInterface.id, prefix); + } + + // Spring `@(Get|Post|Put|Delete|Patch)Mapping` method route. + const methodNode = captures.method; + const mappingAnn = captures.mapping_ann; + const mappingPath = captures.mapping_path; + if (methodNode && mappingAnn && mappingPath && isRouteMemberKey(captures.mapping_key)) { + const httpMethod = METHOD_ANNOTATION_TO_HTTP[mappingAnn.text]; + const rawPath = httpMethod ? unquoteLiteral(mappingPath.text) : null; + if (httpMethod && rawPath !== null) { + methodRoutes.push({ + methodNode, + methodName: captures.method_name?.text ?? null, + httpMethod, + rawPath, + }); + } + } + + // OpenFeign native `@RequestLine("METHOD /path")`. + const lineValue = captures.line_value; + if (methodNode && lineValue) { + const raw = unquoteLiteral(lineValue.text); + const parsed = raw !== null ? parseRequestLine(raw) : null; + if (parsed) { + requestLines.push({ + methodNode, + methodName: captures.method_name?.text ?? null, + parsed, + }); + } + } } - return prefixByTypeId; -} -function collectMethodRoutes(tree: Parser.Tree): Map { - const routesByMethodId = new Map(); - for (const match of runCompiledPatterns(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); + for (const { id, prefix } of interfaceRequestMappingPrefixes) { + if (!feignPrefixByInterfaceId.has(id)) feignPrefixByInterfaceId.set(id, prefix); } - return routesByMethodId; + + return { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines }; } function collectDirectMethods(typeNode: Parser.SyntaxNode): Parser.SyntaxNode[] { @@ -573,8 +597,13 @@ function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] { } function collectSpringTypes(filePath: string, tree: Parser.Tree): SpringTypeInfo[] { - const prefixByTypeId = collectTypePrefixes(tree); - const routesByMethodId = collectMethodRoutes(tree); + const { prefixByTypeId, methodRoutes } = scanRouteAnnotations(tree); + const routesByMethodId = new Map(); + for (const route of methodRoutes) { + const routes = routesByMethodId.get(route.methodNode.id) ?? []; + routes.push({ method: route.httpMethod, path: route.rawPath }); + routesByMethodId.set(route.methodNode.id, routes); + } const out: SpringTypeInfo[] = []; for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) { @@ -666,81 +695,58 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { scan(tree) { const out: HttpDetection[] = []; - // ─── Providers: Spring class prefix + method annotations ──────── - const prefixByTypeId = collectTypePrefixes(tree); - - const feignPrefixByInterfaceId = new Map(); - for (const match of runCompiledPatterns(INTERFACE_PREFIX_PATTERNS, tree)) { - const prefixNode = match.captures.prefix; - const interfaceNode = match.captures.interface; - if (!prefixNode || !interfaceNode) continue; - const prefix = unquoteLiteral(prefixNode.text); - if (prefix !== null && !feignPrefixByInterfaceId.has(interfaceNode.id)) - feignPrefixByInterfaceId.set(interfaceNode.id, prefix); - } - - for (const match of runCompiledPatterns(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 enclosingInterface = findEnclosingInterface(methodNode); + // ─── Spring providers + OpenFeign consumers (one query pass) ──── + // `scanRouteAnnotations` resolves every route-defining annotation — + // class/interface prefixes, method `@(Get|...)Mapping`s and native + // `@RequestLine`s — from a single `matches()` pass over the tree. + const { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines } = + scanRouteAnnotations(tree); + + // A `@(Get|...)Mapping` inside a `@FeignClient` interface is an OpenFeign + // *consumer* (it describes a remote call); the same annotation inside a + // class is a Spring *provider*. A mapping on a non-Feign interface has no + // enclosing class and is dropped here — interface→controller inheritance is + // handled by `scanProject`. + for (const route of methodRoutes) { + const enclosingInterface = findEnclosingInterface(route.methodNode); if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) { const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? ''; - const fullPath = joinPath(prefix, rawPath); out.push({ role: 'consumer', framework: 'openfeign', - method: httpMethod, - path: fullPath, - name: nameNode?.text ?? null, + method: route.httpMethod, + path: joinPath(prefix, route.rawPath), + name: route.methodName, confidence: 0.7, }); continue; } - const enclosingClass = findEnclosingClass(methodNode); + const enclosingClass = findEnclosingClass(route.methodNode); if (!enclosingClass) continue; const prefix = prefixByTypeId.get(enclosingClass.id) ?? ''; - const fullPath = joinPath(prefix, rawPath); out.push({ role: 'provider', framework: 'spring', - method: httpMethod, - path: fullPath, - name: nameNode?.text ?? null, + method: route.httpMethod, + path: joinPath(prefix, route.rawPath), + name: route.methodName, confidence: 0.8, }); } - // ─── Consumers: OpenFeign `@RequestLine("METHOD /path")` ──────── - // The native Feign annotation. Method-level only; the enclosing - // interface MUST carry `@FeignClient`, otherwise the pattern is - // a false positive (the same annotation name exists in non-Feign - // libraries and could appear in non-client code). - for (const match of runCompiledPatterns(REQUEST_LINE_PATTERNS, tree)) { - const valueNode = match.captures.value; - const nameNode = match.captures.method_name; - const methodNode = match.captures.method; - if (!valueNode || !methodNode) continue; - const raw = unquoteLiteral(valueNode.text); - if (raw === null) continue; - const parsed = parseRequestLine(raw); - if (!parsed) continue; - const enclosingInterface = findEnclosingInterface(methodNode); + // Native OpenFeign `@RequestLine("METHOD /path")`. Method-level only; the + // enclosing interface MUST carry `@FeignClient`, otherwise the same + // annotation name in unrelated libraries would be a false positive. + for (const requestLine of requestLines) { + const enclosingInterface = findEnclosingInterface(requestLine.methodNode); if (!enclosingInterface || !hasAnnotation(enclosingInterface, 'FeignClient')) continue; const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? ''; - const fullPath = joinPath(prefix, parsed.path); out.push({ role: 'consumer', framework: 'openfeign', - method: parsed.method, - path: fullPath, - name: nameNode?.text ?? null, + method: requestLine.parsed.method, + path: joinPath(prefix, requestLine.parsed.path), + name: requestLine.methodName, confidence: 0.75, }); } From 9f497157a0265dc7664d7d81696c6838e9527b65 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sat, 30 May 2026 06:39:29 +0000 Subject: [PATCH 5/6] refactor(group): make Java route-annotation query generic, match name in loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse JAVA_ROUTE_ANNOTATION_PATTERNS from 9 annotation-name-pinned branches to 6 generic structural branches (class/interface/method x positional/named) that capture the annotation name (@ann), declaration (@node), argument (@value) and member key (@key) generically. The query now carries NO #eq?/#match? predicates at all; scanRouteAnnotations reads @ann.text and @node.type in its for-loop to decide what each match means (RequestMapping prefix, FeignClient(path) prefix, @(Get|...)Mapping route, or @RequestLine), ignoring unrecognised annotations. This makes the query framework-agnostic and extensible — adding a new route annotation is a change to the loop and the lookup maps, not the query — and removes the last tree-sitter-0.21.1 shared-predicate-bucket footgun, since a predicate-free alternation cannot drop sibling branches. Behaviour is byte-identical: 93 targeted http-route tests and the full 569-test group suite stay green; tsc and prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../group/extractors/http-patterns/java.ts | 207 ++++++++---------- 1 file changed, 93 insertions(+), 114 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index e300c515b1..c736d6fb99 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -72,30 +72,29 @@ interface SpringTypeInfo { methods: SpringMethodInfo[]; } -// ─── Consolidated route-defining annotations (one query, one pass) ──── -// Every Java route-mapper annotation is matched by this SINGLE query so the -// scan walks the tree exactly once per file. `scanRouteAnnotations` branches -// on which captures each match carries to build the prefix maps and the -// per-method route / request-line lists. +// ─── Route-defining annotations (one generic query, one pass) ───────── +// Every Java route-mapper annotation shares one shape: an annotation carrying a +// single string argument — positional `"..."` or named `key = "..."` — on a +// class, interface, or method. This SINGLE query matches that shape generically; +// `scanRouteAnnotations` then reads the annotation NAME (`@ann`) and declaration +// kind (`@node.type`) in its for-loop to decide what each match means. Adding a +// new framework annotation is a change to that loop (and the lookup maps), not +// to this query. // -// Variants (each in both positional `"..."` and named `path = "..."` shapes): -// • Spring class/interface `@RequestMapping(...)` prefix → @reqmap_type / @reqmap_prefix / @reqmap_key -// • OpenFeign `@FeignClient(path = "...")` prefix → @feign_interface / @feign_prefix -// • Spring `@(Get|Post|Put|Delete|Patch)Mapping(...)` → @method / @method_name / @mapping_ann / @mapping_path / @mapping_key -// • OpenFeign native `@RequestLine("METHOD /path")` → @method / @method_name / @line_value +// Captures (shared across all branches; intentionally framework-agnostic): +// @ann → the annotation name identifier (RequestMapping, GetMapping, RequestLine, …) +// @node → the enclosing declaration (class_declaration | interface_declaration | method_declaration) +// @value → the string-literal argument +// @key → the named-argument member key (absent for the positional shape) +// @member → the method name (method_declaration branches only) // -// tree-sitter 0.21.x binding constraint (verified empirically): a top-level -// `[ ... ]` alternation compiles to ONE pattern whose text predicates share a -// single bucket and are applied to every match, keyed by capture name. Two -// behaviours follow and shape this query: -// 1. `#eq?` against a capture absent from the matched branch is vacuously -// true, so fixed annotation names are safely pinned with branch-local -// `#eq?` captures (`@reqmap_ann`, `@feign_ann`, `@line_ann`). -// 2. `#match?` against an absent capture is FALSE — a single `#match?` in the -// alternation would silently drop every sibling-branch match. So the -// variable discriminators (the HTTP verb annotation name and the -// `path`/`value` member key) carry NO in-query predicate and are filtered -// in JS (`METHOD_ANNOTATION_TO_HTTP`, `isRouteMemberKey`). +// The query carries NO `#eq?` / `#match?` predicates. Under the pinned +// tree-sitter 0.21.x binding a top-level `[ ... ]` alternation compiles to one +// pattern whose text predicates share a single bucket keyed by capture name, and +// a `#match?` against a capture absent from the matched branch evaluates FALSE — +// silently dropping sibling-branch matches. Keeping the query predicate-free +// sidesteps that hazard entirely; all name/key discrimination lives in the +// for-loop, where it reads as straight-line code. const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({ name: 'java-route-annotation', language: Java, @@ -107,70 +106,44 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({ (class_declaration (modifiers (annotation - name: (identifier) @reqmap_ann (#eq? @reqmap_ann "RequestMapping") - arguments: (annotation_argument_list (string_literal) @reqmap_prefix)))) @reqmap_type + name: (identifier) @ann + arguments: (annotation_argument_list (string_literal) @value)))) @node (interface_declaration (modifiers (annotation - name: (identifier) @reqmap_ann (#eq? @reqmap_ann "RequestMapping") - arguments: (annotation_argument_list (string_literal) @reqmap_prefix)))) @reqmap_type + name: (identifier) @ann + arguments: (annotation_argument_list (string_literal) @value)))) @node (class_declaration (modifiers (annotation - name: (identifier) @reqmap_ann (#eq? @reqmap_ann "RequestMapping") + name: (identifier) @ann arguments: (annotation_argument_list (element_value_pair - key: (identifier) @reqmap_key - value: (string_literal) @reqmap_prefix))))) @reqmap_type + key: (identifier) @key + value: (string_literal) @value))))) @node (interface_declaration (modifiers (annotation - name: (identifier) @reqmap_ann (#eq? @reqmap_ann "RequestMapping") + name: (identifier) @ann arguments: (annotation_argument_list (element_value_pair - key: (identifier) @reqmap_key - value: (string_literal) @reqmap_prefix))))) @reqmap_type - - (interface_declaration - (modifiers - (annotation - name: (identifier) @feign_ann (#eq? @feign_ann "FeignClient") - arguments: (annotation_argument_list - (element_value_pair - key: (identifier) @feign_key (#eq? @feign_key "path") - value: (string_literal) @feign_prefix))))) @feign_interface - + key: (identifier) @key + value: (string_literal) @value))))) @node (method_declaration (modifiers (annotation - name: (identifier) @mapping_ann - arguments: (annotation_argument_list (string_literal) @mapping_path))) - name: (identifier) @method_name) @method + name: (identifier) @ann + arguments: (annotation_argument_list (string_literal) @value))) + name: (identifier) @member) @node (method_declaration (modifiers (annotation - name: (identifier) @mapping_ann + name: (identifier) @ann arguments: (annotation_argument_list (element_value_pair - key: (identifier) @mapping_key - value: (string_literal) @mapping_path)))) - name: (identifier) @method_name) @method - - (method_declaration - (modifiers - (annotation - name: (identifier) @line_ann (#eq? @line_ann "RequestLine") - arguments: (annotation_argument_list (string_literal) @line_value))) - name: (identifier) @method_name) @method - (method_declaration - (modifiers - (annotation - name: (identifier) @line_ann (#eq? @line_ann "RequestLine") - arguments: (annotation_argument_list - (element_value_pair - key: (identifier) @line_key (#eq? @line_key "value") - value: (string_literal) @line_value)))) - name: (identifier) @method_name) @method + key: (identifier) @key + value: (string_literal) @value)))) + name: (identifier) @member) @node ] `, }, @@ -482,11 +455,12 @@ interface RouteAnnotationScan { /** * Resolve every Java route-defining annotation in a single tree-sitter pass. * - * All variants come from the one `JAVA_ROUTE_ANNOTATION_PATTERNS` query; this - * function branches on which captures a match carries to build the prefix maps - * and the per-method route / request-line lists. The HTTP verb annotation name - * and the `path`/`value` member key are filtered here (see the query header for - * why that discrimination cannot live in the alternation predicates). + * The generic `JAVA_ROUTE_ANNOTATION_PATTERNS` query yields one match per + * annotation-carrying-a-string-argument on any class / interface / method. This + * loop reads the annotation name and declaration kind to decide what each match + * means, ignoring annotations it does not recognise. The HTTP verb map + * (`METHOD_ANNOTATION_TO_HTTP`) and the `path`/`value` key filter + * (`isRouteMemberKey`) live here rather than in the query (see its header). */ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { const matches = runCompiledPatterns(JAVA_ROUTE_ANNOTATION_PATTERNS, tree); @@ -500,55 +474,60 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { const interfaceRequestMappingPrefixes: Array<{ id: number; prefix: string }> = []; for (const { captures } of matches) { - // Spring `@RequestMapping` class/interface prefix. - const reqmapType = captures.reqmap_type; - const reqmapPrefix = captures.reqmap_prefix; - if (reqmapType && reqmapPrefix && isRouteMemberKey(captures.reqmap_key)) { - const prefix = unquoteLiteral(reqmapPrefix.text); - if (prefix !== null) { - prefixByTypeId.set(reqmapType.id, prefix); - if (reqmapType.type === 'interface_declaration') { - interfaceRequestMappingPrefixes.push({ id: reqmapType.id, prefix }); + const annNode = captures.ann; + const node = captures.node; + const valueNode = captures.value; + if (!annNode || !node || !valueNode) continue; + const ann = annNode.text; + const keyNode = captures.key; // undefined for the positional shape + + if (node.type === 'method_declaration') { + // Method-level: a Spring `@(Get|...)Mapping` route, or native `@RequestLine`. + const httpMethod = METHOD_ANNOTATION_TO_HTTP[ann]; + if (httpMethod) { + if (!isRouteMemberKey(keyNode)) continue; + const rawPath = unquoteLiteral(valueNode.text); + if (rawPath !== null) { + methodRoutes.push({ + methodNode: node, + methodName: captures.member?.text ?? null, + httpMethod, + rawPath, + }); + } + } else if (ann === 'RequestLine') { + // Feign packs verb + path in one literal; its only named argument is `value`. + if (keyNode && keyNode.text !== 'value') continue; + const raw = unquoteLiteral(valueNode.text); + const parsed = raw !== null ? parseRequestLine(raw) : null; + if (parsed) { + requestLines.push({ + methodNode: node, + methodName: captures.member?.text ?? null, + parsed, + }); } } + continue; } - // OpenFeign `@FeignClient(path = "...")` interface prefix (highest precedence, first wins). - const feignInterface = captures.feign_interface; - const feignPrefix = captures.feign_prefix; - if (feignInterface && feignPrefix && !feignPrefixByInterfaceId.has(feignInterface.id)) { - const prefix = unquoteLiteral(feignPrefix.text); - if (prefix !== null) feignPrefixByInterfaceId.set(feignInterface.id, prefix); - } - - // Spring `@(Get|Post|Put|Delete|Patch)Mapping` method route. - const methodNode = captures.method; - const mappingAnn = captures.mapping_ann; - const mappingPath = captures.mapping_path; - if (methodNode && mappingAnn && mappingPath && isRouteMemberKey(captures.mapping_key)) { - const httpMethod = METHOD_ANNOTATION_TO_HTTP[mappingAnn.text]; - const rawPath = httpMethod ? unquoteLiteral(mappingPath.text) : null; - if (httpMethod && rawPath !== null) { - methodRoutes.push({ - methodNode, - methodName: captures.method_name?.text ?? null, - httpMethod, - rawPath, - }); + // Type-level (class or interface): a Spring `@RequestMapping` URL prefix, or + // — on an interface — an OpenFeign `@FeignClient(path = "...")` prefix. + if (ann === 'RequestMapping') { + if (!isRouteMemberKey(keyNode)) continue; + const prefix = unquoteLiteral(valueNode.text); + if (prefix !== null) { + prefixByTypeId.set(node.id, prefix); + if (node.type === 'interface_declaration') { + interfaceRequestMappingPrefixes.push({ id: node.id, prefix }); + } } - } - - // OpenFeign native `@RequestLine("METHOD /path")`. - const lineValue = captures.line_value; - if (methodNode && lineValue) { - const raw = unquoteLiteral(lineValue.text); - const parsed = raw !== null ? parseRequestLine(raw) : null; - if (parsed) { - requestLines.push({ - methodNode, - methodName: captures.method_name?.text ?? null, - parsed, - }); + } else if (ann === 'FeignClient' && node.type === 'interface_declaration') { + // Feign's `name`/`value` identify a service, not a path — only `path` is a prefix. + if (!keyNode || keyNode.text !== 'path') continue; + const prefix = unquoteLiteral(valueNode.text); + if (prefix !== null && !feignPrefixByInterfaceId.has(node.id)) { + feignPrefixByInterfaceId.set(node.id, prefix); } } } From ad88bd3fbcc228bf648d409f09c42ab0249eea1b Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sat, 30 May 2026 06:57:25 +0000 Subject: [PATCH 6/6] test(group): pin newly-reachable Java route-annotation JS branches; clarify invariants Code-review follow-up to the route-annotation query consolidation. No behaviour change to the extractor: - Add two regression tests for branches the generic predicate-free query made reachable in scanRouteAnnotations: (1) a @RequestLine whose named argument is not `value` must be dropped (the in-query `#eq? @key "value"` guard now lives in JS); (2) @FeignClient(path) must win over @RequestMapping even when @RequestMapping is the first annotation in source order, covering the deferred interfaceRequestMappingPrefixes apply (the existing precedence test only covered @FeignClient-first). - Document two invariants flagged in review: why prefixByTypeId and feignPrefixByInterfaceId intentionally diverge for the same interface node (Spring provider vs OpenFeign consumer prefix), and that the query's single-string-argument shape excludes array-valued annotations. http-route-extractor + multi-verb suites: 95/95 (was 93); tsc + prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../group/extractors/http-patterns/java.ts | 12 +++- .../unit/group/http-route-extractor.test.ts | 56 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index c736d6fb99..0a4e7fcece 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -78,8 +78,11 @@ interface SpringTypeInfo { // class, interface, or method. This SINGLE query matches that shape generically; // `scanRouteAnnotations` then reads the annotation NAME (`@ann`) and declaration // kind (`@node.type`) in its for-loop to decide what each match means. Adding a -// new framework annotation is a change to that loop (and the lookup maps), not -// to this query. +// new framework annotation that follows this single-string-argument shape is a +// change to that loop (and the lookup maps), not to this query. Annotations with +// a different argument shape — e.g. an array value `@RequestMapping({"/a","/b"})` +// — are out of scope here (as they were for the prior queries) and would need a +// new branch. // // Captures (shared across all branches; intentionally framework-agnostic): // @ann → the annotation name identifier (RequestMapping, GetMapping, RequestLine, …) @@ -465,6 +468,11 @@ interface RouteAnnotationScan { function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { const matches = runCompiledPatterns(JAVA_ROUTE_ANNOTATION_PATTERNS, tree); + // The two prefix maps intentionally diverge for the same interface node: + // `prefixByTypeId` feeds the Spring *provider* path (class prefix + + // collectSpringTypes cross-file inheritance), while `feignPrefixByInterfaceId` + // feeds the OpenFeign *consumer* path in scan(). An interface carrying both + // `@RequestMapping` and `@FeignClient(path)` lands a different value in each. const prefixByTypeId = new Map(); const feignPrefixByInterfaceId = new Map(); const methodRoutes: MethodRouteAnnotation[] = []; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 20c684c2a7..90f4d97e9d 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -1966,6 +1966,62 @@ interface NamedArgClient { ).toBeDefined(); }); + it('ignores @RequestLine whose named argument is not "value"', async () => { + // The consolidated query matches every named annotation argument; the + // scanRouteAnnotations loop drops a @RequestLine whose key is not `value`. + const dir = path.join(tmpDir, 'java-feign-request-line-wrong-key'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'WrongKeyClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import feign.RequestLine; + +@FeignClient(name = "wrong-key-service") +interface WrongKeyClient { + @RequestLine(name = "GET /should-not-extract") + String shouldNotBeExtracted(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::GET::/should-not-extract'), + ).toBeUndefined(); + }); + + it('prefers @FeignClient(path=...) over @RequestMapping when @RequestMapping appears first', async () => { + // Reverse-order companion to the precedence test above: @FeignClient(path) + // must win even when @RequestMapping is the first annotation in source, + // exercising the deferred interfaceRequestMappingPrefixes apply. + const dir = path.join(tmpDir, 'java-openfeign-prefix-precedence-reversed'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ReversedPrecedenceClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@RequestMapping("/rm-path") +@FeignClient(name = "order-service", path = "/feign-path") +interface ReversedPrecedenceClient { + @GetMapping("/orders") + OrderDto getOrders(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/feign-path/orders')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined(); + }); + it('extracts Java and Apache HttpClient literal request construction', async () => { const dir = path.join(tmpDir, 'java-http-client-consumer'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true });