From 1630df87ae9807568d26893eee5fa57998251568 Mon Sep 17 00:00:00 2001 From: henry Date: Tue, 23 Jun 2026 18:24:05 +0800 Subject: [PATCH] feat(routes): extract Spring method-level array-form routes in ingestion + extractor parity test (#2138 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ingestion's `extractSpringRoutes` (route-extractors/spring.ts) matched only a single string literal on `@(Get|...)Mapping`, so the array form `@GetMapping({"/a","/b"})` produced no graph Route node — while the group-layer `java.ts` scan did match it. That divergence was the root of the #2265 array-form parse-skip gap. - spring.ts: add the array-form alternation `[(string_literal) @value (element_value_array_initializer (string_literal) @value)]` to the two method-declaration query branches (positional + `path=`/`value=`), mirroring the group query. A multi-element array yields one match per element, so the Phase 2 loop emits one route per path with no other change. Class-level `@RequestMapping` array prefixes remain single-literal (rare; left to a follow-up). - test: spring-route-parity runs one shared Java fixture through BOTH extractors (ingestion `extractSpringRoutes` + group `JAVA_HTTP_PLUGIN.scan`) and asserts identical provider {method,path} sets — the parity guard the maintainer asked for in #2078, so the two Spring extractors can't silently drift again (verified: reverting the array branch turns the parity test red). --- .../core/ingestion/route-extractors/spring.ts | 16 ++- .../unit/group/spring-route-parity.test.ts | 97 +++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/unit/group/spring-route-parity.test.ts diff --git a/gitnexus/src/core/ingestion/route-extractors/spring.ts b/gitnexus/src/core/ingestion/route-extractors/spring.ts index a92d09afe..a7435ae08 100644 --- a/gitnexus/src/core/ingestion/route-extractors/spring.ts +++ b/gitnexus/src/core/ingestion/route-extractors/spring.ts @@ -39,6 +39,15 @@ import { * @node → enclosing declaration (class_declaration | method_declaration) * @value → the string-literal argument * @key → the named-argument member key (absent for positional form) + * + * Method-level routes accept both the bare string form `@GetMapping("/x")` and + * the array form `@GetMapping({"/a","/b"})` (positional or `path =`/`value =`): + * a multi-element array yields one match per element, so the Phase 2 loop emits + * one route per path with no special-casing. This mirrors the group-layer + * `java.ts` query so the two Spring extractors stay in parity (#2138 follow-up; + * the divergence here was the root of the #2265 array-form gap). Array form on a + * class-level `@RequestMapping` prefix is not matched here yet (rare; left to a + * follow-up) — `@value` on the class branches stays a single string literal. */ const ROUTE_ANNOTATION_QUERY = new Parser.Query( Java, @@ -61,7 +70,9 @@ const ROUTE_ANNOTATION_QUERY = new Parser.Query( (modifiers (annotation name: (identifier) @ann - arguments: (annotation_argument_list (string_literal) @value)))) @node + arguments: (annotation_argument_list + [(string_literal) @value + (element_value_array_initializer (string_literal) @value)])))) @node (method_declaration (modifiers (annotation @@ -69,7 +80,8 @@ const ROUTE_ANNOTATION_QUERY = new Parser.Query( arguments: (annotation_argument_list (element_value_pair key: (identifier) @key - value: (string_literal) @value))))) @node + value: [(string_literal) @value + (element_value_array_initializer (string_literal) @value)]))))) @node ] `, ); diff --git a/gitnexus/test/unit/group/spring-route-parity.test.ts b/gitnexus/test/unit/group/spring-route-parity.test.ts new file mode 100644 index 000000000..c91a4da98 --- /dev/null +++ b/gitnexus/test/unit/group/spring-route-parity.test.ts @@ -0,0 +1,97 @@ +/** + * Parity test for the two Spring route extractors (#2078 maintainer request, + * #2138 follow-up). + * + * GitNexus parses Spring `@(Get|Post|...)Mapping` annotations in TWO places: + * - ingestion `route-extractors/spring.ts` → `extractSpringRoutes` (produces + * graph `Route` nodes) + * - group `http-patterns/java.ts` → `JAVA_HTTP_PLUGIN.scan` (produces + * cross-repo HTTP contracts) + * + * They serve different layers and stay separate, but they MUST agree on the + * set of provider (method, path) routes they recognise for the same source — + * otherwise the graph under-covers what the group scan sees, which is exactly + * the divergence behind the #2265 array-form gap (the group query matched + * `@GetMapping({"/a","/b"})`, ingestion's didn't). This test runs one shared + * fixture through both and asserts the provider sets are identical, so the two + * can't silently drift again. + */ +import { describe, it, expect } from 'vitest'; +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; +import { extractSpringRoutes } from '../../../src/core/ingestion/route-extractors/spring.js'; +import { JAVA_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/java.js'; + +function parse(src: string): Parser.Tree { + const p = new Parser(); + p.setLanguage(Java); + return p.parse(src); +} + +/** Canonical `METHOD /a/b` form so prefix-join / slash / case differences wash out. */ +function canon(method: string, ...segments: string[]): string { + const path = `/${segments.join('/').split('/').filter(Boolean).join('/')}`; + return `${method.toUpperCase()} ${path.toLowerCase()}`; +} + +/** ingestion side: join the class prefix + method path the way the routes phase does. */ +function ingestionProviders(src: string): Set { + return new Set( + extractSpringRoutes(parse(src), 'X.java').map((r) => + canon(r.httpMethod, r.prefix ?? '', r.routePath), + ), + ); +} + +/** group side: provider detections (path already prefix-joined by the plugin). */ +function groupProviders(src: string): Set { + return new Set( + JAVA_HTTP_PLUGIN.scan(parse(src)) + .filter((d) => d.role === 'provider') + .map((d) => canon(d.method, d.path)), + ); +} + +describe('Spring route extractor parity — ingestion spring.ts vs group java.ts', () => { + it('agree on bare, named-arg, and array-form method routes under a class prefix', () => { + const src = `package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/orders") +public class OrderController { + @GetMapping("/list") public Object list() { return null; } + @PostMapping(path = "/make") public Object make() { return null; } + @PutMapping(value = "/update") public Object update() { return null; } + @GetMapping({"/a", "/b"}) public Object multi() { return null; } +} +`; + const ingestion = ingestionProviders(src); + const group = groupProviders(src); + + // The array form is the regression that motivated this: both must see all four. + expect(group).toEqual( + new Set([ + 'GET /api/orders/list', + 'POST /api/orders/make', + 'PUT /api/orders/update', + 'GET /api/orders/a', + 'GET /api/orders/b', + ]), + ); + expect(ingestion).toEqual(group); + }); + + it('agree on a no-prefix controller with a positional array', () => { + const src = `package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +public class PlainController { + @GetMapping("/solo") public Object solo() { return null; } + @DeleteMapping({"/x", "/y", "/z"}) public Object many() { return null; } +} +`; + expect(ingestionProviders(src)).toEqual(groupProviders(src)); + }); +});