/** * 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 for the * shapes ingestion claims to cover: bare, named-arg, and array-form method * routes under a *scalar* (or absent) class prefix. * * Known, deliberate divergence (NOT covered by the equality assertions): a * method-level array route nested under a class-level *array-form* * @RequestMapping. Ingestion suppresses it (it can't resolve which of several * class prefixes to apply, and a dropped-prefix route is a wrong signal), while * the group layer emits the full cross-product. The last test pins this so the * suppression can't silently regress into emitting wrong routes; full * class-array support is tracked in #2280. */ 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)), ); } function groupConsumers(src: string): Set { return new Set( JAVA_HTTP_PLUGIN.scan(parse(src)) .filter((d) => d.role === 'consumer' && d.framework === 'openfeign') .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)); }); it('agree on named-arg ARRAY forms (path = {...} / value = {...})', () => { // Guards the spring.ts named-array query branch specifically: positional // arrays alone would not exercise it. const src = `package com.example; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api") public class NamedArrayController { @PutMapping(value = {"/update", "/modify"}) public Object upd() { return null; } @DeleteMapping(path = {"/x", "/y"}) public Object del() { return null; } } `; const group = groupProviders(src); expect(group).toEqual( new Set(['PUT /api/update', 'PUT /api/modify', 'DELETE /api/x', 'DELETE /api/y']), ); expect(ingestionProviders(src)).toEqual(group); }); it('agree on method-level @RequestMapping verbs and wildcard mappings', () => { const src = `package com.example; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api") public class LegacyController { @RequestMapping("/bare") public Object bare() { return null; } @RequestMapping(value = "/named") public Object named() { return null; } @RequestMapping(value = "/save", method = RequestMethod.POST) public Object save() { return null; } @RequestMapping(path = "/multi", method = {RequestMethod.GET, RequestMethod.HEAD}) public Object multi() { return null; } @RequestMapping(path = {"/one", "/two"}, method = {RequestMethod.GET, RequestMethod.POST}) public Object crossProduct() { return null; } @RequestMapping(path = "/empty", method = {}) public Object empty() { return null; } @RequestMapping(path = "/spaced", method = RequestMethod . PUT) public Object spaced() { return null; } @RequestMapping(path = "/commented", method = { RequestMethod.GET, // read /* write */ RequestMethod.POST, }) public Object commented() { return null; } @org.springframework.web.bind.annotation.RequestMapping( path = "/fqn", method = org.springframework.web.bind.annotation.RequestMethod.DELETE ) public Object fqn() { return null; } } `; const expected = new Set([ '* /api/bare', '* /api/named', 'POST /api/save', 'GET /api/multi', 'HEAD /api/multi', 'GET /api/one', 'POST /api/one', 'GET /api/two', 'POST /api/two', '* /api/empty', 'PUT /api/spaced', 'GET /api/commented', 'POST /api/commented', 'DELETE /api/fqn', ]); expect(groupProviders(src)).toEqual(expected); expect(ingestionProviders(src)).toEqual(expected); }); it('intersects class verbs and emits pathless method mappings at the class prefix', () => { const src = `package com.example; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping(path = "/api", method = RequestMethod.GET) public class ConstrainedController { @RequestMapping("/wild") public Object wildcard() { return null; } @RequestMapping(method = RequestMethod.GET) public Object root() { return null; } @GetMapping public Object shortcut() { return null; } @PostMapping("/blocked") public Object blocked() { return null; } } `; const expected = new Set(['GET /api/wild', 'GET /api']); expect(groupProviders(src)).toEqual(expected); expect(ingestionProviders(src)).toEqual(expected); }); it('uses GET for verb-less Feign RequestMapping and rejects multi-method contracts', () => { const src = `package com.example; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.*; @FeignClient(name = "orders") @RequestMapping("/api") public interface OrdersClient { @RequestMapping("/default") Object defaultCall(); @RequestMapping(path = "/multi", method = {RequestMethod.GET, RequestMethod.POST}) Object ambiguous(); } `; expect(groupConsumers(src)).toEqual(new Set(['GET /api/default'])); }); it('fails closed when @RequestMapping method is not statically resolvable', () => { const src = `package com.example; import org.springframework.web.bind.annotation.*; @RestController public class DynamicController { private static final RequestMethod VERB = RequestMethod.POST; @RequestMapping(path = "/dynamic", method = VERB) public Object dynamic() { return null; } } `; expect(groupProviders(src)).toEqual(new Set()); expect(ingestionProviders(src)).toEqual(new Set()); }); it('do not leak non-route arrays (consumes/produces) as routes — array analogue', () => { // The scalar `produces` anti-regression already exists in the route tests; // this is its array form. `consumes`/`produces` arrays must never surface as // provider routes; only the path value does. const src = `package com.example; import org.springframework.web.bind.annotation.*; @RestController public class ContentTypeController { @GetMapping(value = "/v", consumes = {"application/json", "application/xml"}, produces = {"application/json"}) public Object v() { return null; } @PostMapping(consumes = {"application/json"}) public Object noPath() { return null; } } `; const ingestion = ingestionProviders(src); const group = groupProviders(src); // Non-route arrays never become paths; the pathless POST binds to root. expect(group).toEqual(new Set(['GET /v', 'POST /'])); expect(ingestion).toEqual(group); // Pure consumes/produces members still describe pathless root mappings; // their media-type values must never be misread as route paths. const consumesOnly = `package com.example; import org.springframework.web.bind.annotation.*; @RestController public class ConsumesOnlyController { @PostMapping(consumes = {"application/json", "application/xml"}) public Object a() { return null; } @PutMapping(produces = {"application/json"}) public Object b() { return null; } } `; expect(groupProviders(consumesOnly)).toEqual(new Set(['POST /', 'PUT /'])); expect(ingestionProviders(consumesOnly)).toEqual(new Set(['POST /', 'PUT /'])); }); it('pins the deliberate class-array divergence: ingestion suppresses, group emits cross-product (#2280)', () => { // A method-level array under a class-level ARRAY-form @RequestMapping. There // is no single class prefix to apply, so ingestion suppresses the route // rather than emit it unprefixed (a wrong signal). The group layer emits the // full cross-product. This is a KNOWN gap (#2280), pinned here so the // suppression can't silently regress into emitting wrong unprefixed routes. const src = `package com.example; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping({"/base/one", "/base/two"}) public class MultiPrefixController { @GetMapping({"/primary", "/alias"}) public Object x() { return null; } } `; const ingestion = ingestionProviders(src); const group = groupProviders(src); // group: correct cross-product of the two class prefixes × two method paths. expect(group).toEqual( new Set([ 'GET /base/one/primary', 'GET /base/two/primary', 'GET /base/one/alias', 'GET /base/two/alias', ]), ); // ingestion: suppressed — emits NO route for the array method (never a wrong // unprefixed `GET /primary` / `GET /alias`). expect(ingestion).toEqual(new Set()); // And the divergence is asymmetric-by-design: ingestion ⊊ group here. expect(ingestion).not.toEqual(group); }); });