GitNexus/gitnexus/test/unit/spring-vendor-annotation-alias.test.ts
ChunxueLi 131d9f93fd
feat(route): resolve vendor-derived Spring mapping annotations by suffix (#2883)
* feat(route): resolve vendor-derived Spring mapping annotations by suffix

Frameworks commonly wrap Spring's built-in annotations with company-specific
variants (e.g. Winning Health's @WinPostMapping wraps @PostMapping). The
annotation definition lives in a binary JAR — not in source — so the
meta-annotation cannot be read statically.

Add resolveSpringAnnotationAlias(): resolves custom annotations by naming
suffix (WinPostMapping → PostMapping → POST). This matches the universal
Java convention of naming derived annotations with the base name as a suffix.
Works for any vendor prefix, not just one company.

The fix is in springAnnotationHttpMethods() (spring-shared.ts), which both
the ingestion extractor (spring.ts) and the group extractor (java.ts) call.
A single-function change propagates to both layers automatically.

Zero configuration: no .gitnexusrc, no annotation allowlist. If an annotation
name ends with a known Spring mapping suffix, it inherits that annotation's
HTTP semantics. False-positive risk is negligible.

Tests: 16 new unit tests covering resolveSpringAnnotationAlias directly,
springAnnotationHttpMethods with aliased annotations, end-to-end
extractSpringRoutes with vendor annotations, and ingestion/group parity.

Existing route tests (260) continue to pass.

* fix(route): address review findings — class-level aliases, registered prefixes

P1: class-level @WinRequestMapping now gets the same prefix/constraint
semantics as @RequestMapping — all five class-level exact-match sites
(spring.ts phase-1 collect, typeRequestMethods, typeClassPrefixes; group
http-patterns java.ts typeRequestMethods + type-level branch) route
through the new shared isClassLevelMappingAnnotation predicate, and the
hard-coded 'RequestMapping' argument in springAnnotationHttpMethods calls
is replaced with the actual annotation name so alias resolution applies.
P2: suffix-only alias matching accepted unrelated annotations
(@AuditPostMapping emitted a phantom POST /audit). Alias resolution now
requires a REGISTERED vendor prefix — 'Win' by default, extendable via
GITNEXUS_SPRING_VENDOR_PREFIXES=Win,Acme without a rebuild.
Tests: negative e2e for the unregistered-suffix phantom route, vendor
class-prefix parity e2e, predicate unit matrix, env-registration test.

* style: prettier

* chore: drop accidental gitnexus-shared/dist worktree symlink from prettier commit

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#2883)

Wire vendor Spring mapping aliases into Kotlin ingestion and group extraction, restore GITNEXUS_SPRING_VENDOR_PREFIXES after the env test, and stamp spring.route-bindings so existing indexes rebuild.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(route): honor Kotlin vendor aliases and prefix freshness

Parse Kotlin RequestMapping method arrays in the shared Spring helper, bump spring.route-bindings, and rebuild when registered vendor prefixes change.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-09-02 21:01:59 +00:00

297 lines
12 KiB
TypeScript

/**
* Unit test: vendor-derived Spring mapping annotation alias resolution.
*
* Frameworks wrap Spring's built-in annotations with company-specific variants
* (e.g. Winning Health's `@WinPostMapping`). The tree-sitter query captures
* these annotations like any other, but `springAnnotationHttpMethods` must
* resolve them to the correct HTTP verb via suffix matching.
*
* These tests cover:
* 1. `resolveSpringAnnotationAlias` directly (unit)
* 2. `springAnnotationHttpMethods` with aliased annotations (unit)
* 3. End-to-end `extractSpringRoutes` with a fixture using vendor annotations
* 4. Parity: both ingestion and group extractors surface the same routes
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import {
resolveSpringAnnotationAlias,
springAnnotationHttpMethods,
} from '../../src/core/ingestion/route-extractors/spring-shared.js';
import { extractSpringRoutes } from '../../src/core/ingestion/route-extractors/spring.js';
import { JAVA_HTTP_PLUGIN } from '../../src/core/group/extractors/http-patterns/java.js';
import { normalizeExtractedRoutePath } from '../../src/core/ingestion/route-extractors/route-path.js';
import { springVendorPrefixesKey } from '../../src/core/ingestion/frameworks/spring/vendor-prefixes.js';
function parse(code: string): Parser.Tree {
const parser = new Parser();
parser.setLanguage(Java);
return parser.parse(code);
}
beforeEach(() => {
vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Win');
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('resolveSpringAnnotationAlias', () => {
it('returns undefined for exact built-in shortcut annotations', () => {
expect(resolveSpringAnnotationAlias('PostMapping')).toBeUndefined();
expect(resolveSpringAnnotationAlias('GetMapping')).toBeUndefined();
expect(resolveSpringAnnotationAlias('PutMapping')).toBeUndefined();
expect(resolveSpringAnnotationAlias('DeleteMapping')).toBeUndefined();
expect(resolveSpringAnnotationAlias('PatchMapping')).toBeUndefined();
});
it('returns undefined for exact RequestMapping', () => {
expect(resolveSpringAnnotationAlias('RequestMapping')).toBeUndefined();
});
it('returns undefined for unrelated annotations', () => {
expect(resolveSpringAnnotationAlias('Override')).toBeUndefined();
expect(resolveSpringAnnotationAlias('Autowired')).toBeUndefined();
expect(resolveSpringAnnotationAlias('Component')).toBeUndefined();
expect(resolveSpringAnnotationAlias('Data')).toBeUndefined();
});
it('resolves vendor shortcut annotations by suffix', () => {
expect(resolveSpringAnnotationAlias('WinPostMapping')).toBe('PostMapping');
expect(resolveSpringAnnotationAlias('WinGetMapping')).toBe('GetMapping');
expect(resolveSpringAnnotationAlias('WinPutMapping')).toBe('PutMapping');
expect(resolveSpringAnnotationAlias('WinDeleteMapping')).toBe('DeleteMapping');
expect(resolveSpringAnnotationAlias('WinPatchMapping')).toBe('PatchMapping');
});
it('resolves vendor RequestMapping variants', () => {
expect(resolveSpringAnnotationAlias('WinRequestMapping')).toBe('RequestMapping');
});
it('ignores unregistered vendor prefixes (review: suffix-only accepted @AuditPostMapping)', () => {
// Suffix matching alone produced phantom routes from unrelated
// annotations like @AuditPostMapping — resolution now requires a
// registered prefix (Win by default).
expect(resolveSpringAnnotationAlias('AuditPostMapping')).toBeUndefined();
expect(resolveSpringAnnotationAlias('CompanyPostMapping')).toBeUndefined();
expect(resolveSpringAnnotationAlias('XyzGetMapping')).toBeUndefined();
});
it('does not match annotations that merely contain a mapping name', () => {
expect(resolveSpringAnnotationAlias('PostMappingHelper')).toBeUndefined();
expect(resolveSpringAnnotationAlias('GetMappingInfo')).toBeUndefined();
expect(resolveSpringAnnotationAlias('PreMapping')).toBeUndefined();
});
});
describe('Spring vendor prefix freshness', () => {
it('canonicalizes equivalent lists regardless of order and duplicates', () => {
vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', ' Win,Acme,Win ');
const first = springVendorPrefixesKey();
vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Acme,Win');
const second = springVendorPrefixesKey();
expect(first).toBe('["Acme","Win"]');
expect(second).toBe(first);
expect(first).not.toBe('["Win"]');
});
});
describe('springAnnotationHttpMethods with vendor aliases', () => {
it('resolves WinPostMapping to POST', () => {
expect(springAnnotationHttpMethods('WinPostMapping', '@WinPostMapping("/api")')).toEqual([
'POST',
]);
});
it('resolves WinGetMapping to GET', () => {
expect(springAnnotationHttpMethods('WinGetMapping', '@WinGetMapping("/api")')).toEqual(['GET']);
});
it('resolves WinDeleteMapping to DELETE', () => {
expect(springAnnotationHttpMethods('WinDeleteMapping', '@WinDeleteMapping("/api")')).toEqual([
'DELETE',
]);
});
it('resolves WinRequestMapping without method attribute to wildcard', () => {
expect(springAnnotationHttpMethods('WinRequestMapping', '@WinRequestMapping("/api")')).toEqual([
'*',
]);
});
it('resolves WinRequestMapping with method attribute', () => {
const text = '@WinRequestMapping(value = "/api", method = RequestMethod.POST)';
expect(springAnnotationHttpMethods('WinRequestMapping', text)).toEqual(['POST']);
});
it('accepts Kotlin collection syntax for RequestMapping method arrays', () => {
const text =
'@WinRequestMapping(value = "/api", method = [RequestMethod.GET, RequestMethod.HEAD])';
expect(springAnnotationHttpMethods('WinRequestMapping', text)).toEqual(['GET', 'HEAD']);
});
it('fail-closes mismatched RequestMapping method collection delimiters', () => {
const text = '@WinRequestMapping(method = {RequestMethod.GET])';
expect(springAnnotationHttpMethods('WinRequestMapping', text)).toEqual([]);
});
it('returns empty for unrelated annotations', () => {
expect(springAnnotationHttpMethods('Component', '@Component')).toEqual([]);
expect(springAnnotationHttpMethods('Override', '@Override')).toEqual([]);
});
});
describe('extractSpringRoutes with vendor annotations', () => {
it('extracts routes from a controller using @Win annotations', () => {
const tree = parse(`
package com.winning.opt.controller;
@RestController
@RequestMapping("/api/opt")
public class OrderController {
@WinPostMapping("/create")
public String create() { return "{}"; }
@WinGetMapping("/query")
public String query() { return "[]"; }
@WinPostMapping(value = "/update")
public String update() { return "{}"; }
}
`);
const routes = extractSpringRoutes(tree, 'OrderController.java');
expect(routes).toHaveLength(3);
const postRoutes = routes.filter((r) => r.httpMethod === 'POST');
expect(postRoutes).toHaveLength(2);
const postPaths = postRoutes.map((r) => r.routePath).sort();
expect(postPaths).toEqual(['/create', '/update']);
for (const r of postRoutes) {
expect(r.prefix).toBe('/api/opt');
}
const getRoute = routes.find((r) => r.httpMethod === 'GET')!;
expect(getRoute.routePath).toBe('/query');
expect(getRoute.prefix).toBe('/api/opt');
});
it('extracts routes when vendor and standard annotations are mixed', () => {
const tree = parse(`
@RestController
@RequestMapping("/api/mix")
public class MixedController {
@WinPostMapping("/win-create")
public String winCreate() { return "{}"; }
@PostMapping("/std-create")
public String stdCreate() { return "{}"; }
@GetMapping("/std-get")
public String stdGet() { return "[]"; }
}
`);
const routes = extractSpringRoutes(tree, 'MixedController.java');
expect(routes).toHaveLength(3);
const paths = routes.map((r) => r.routePath).sort();
expect(paths).toEqual(['/std-create', '/std-get', '/win-create']);
});
it('ingestion and group extractors agree on vendor annotation routes', () => {
const tree = parse(`
@RestController
@RequestMapping("/api/parity")
public class ParityController {
@WinPostMapping("/create")
public String create() { return "{}"; }
@WinGetMapping("/query")
public String query() { return "[]"; }
}
`);
const ingestionRoutes = new Set(
extractSpringRoutes(tree, 'ParityController.java').map(
(r) => `${r.httpMethod} ${normalizeExtractedRoutePath(r.routePath, r.prefix ?? null)}`,
),
);
const groupRoutes = new Set(
JAVA_HTTP_PLUGIN.scan(tree)
.filter((d) => d.role === 'provider')
.map((d) => `${d.method} ${normalizeExtractedRoutePath(d.path, null)}`),
);
expect([...ingestionRoutes].sort()).toEqual([...groupRoutes].sort());
expect([...ingestionRoutes].sort()).toEqual([
'GET /api/parity/query',
'POST /api/parity/create',
]);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Review regressions (magyargergo, 2026-08-29)
// ═══════════════════════════════════════════════════════════════════════════
describe('review regressions: class-level aliases', () => {
it('P1: isClassLevelMappingAnnotation accepts @WinRequestMapping like @RequestMapping', async () => {
const { isClassLevelMappingAnnotation } =
await import('../../src/core/ingestion/route-extractors/spring-shared.js');
expect(isClassLevelMappingAnnotation('RequestMapping')).toBe(true);
expect(isClassLevelMappingAnnotation('WinRequestMapping')).toBe(true);
expect(isClassLevelMappingAnnotation('WinPostMapping')).toBe(false);
expect(isClassLevelMappingAnnotation('AuditRequestMapping')).toBe(false);
expect(isClassLevelMappingAnnotation('GetMapping')).toBe(false);
});
it('P1: vendor class prefix flows into route paths (@WinRequestMapping + @WinGetMapping)', () => {
const tree = parse(`
@WinRequestMapping("/vendor")
public class VendorController {
@WinGetMapping("/users")
public String list() { return "ok"; }
}
`);
const routes = extractSpringRoutes(tree, 'VendorController.java');
expect(routes).toHaveLength(1);
// Class prefix /vendor comes from the aliased @WinRequestMapping — the
// exact path the old exact-match-only class handling missed (review P1).
expect(routes[0].prefix).toBe('/vendor');
expect(routes[0].routePath).toBe('/users');
expect(routes[0].httpMethod).toBe('GET');
expect(
JAVA_HTTP_PLUGIN.scan(tree)
.filter((detection) => detection.role === 'provider')
.map((detection) => `${detection.method} ${detection.path}`),
).toEqual(['GET /vendor/users']);
});
it('P2: unregistered suffix no longer emits a phantom route (end-to-end)', () => {
const tree = parse(`
public class AuditController {
@AuditPostMapping("/audit")
public String audit() { return "x"; }
}
`);
const routes = extractSpringRoutes(tree, 'AuditController.java');
expect(routes).toHaveLength(0);
expect(
JAVA_HTTP_PLUGIN.scan(tree).filter((detection) => detection.role === 'provider'),
).toEqual([]);
});
it('P2: extra vendor prefixes can be registered via env', () => {
vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Win,Acme');
try {
expect(resolveSpringAnnotationAlias('AcmePostMapping')).toBe('PostMapping');
expect(resolveSpringAnnotationAlias('OtherPostMapping')).toBeUndefined();
} finally {
vi.unstubAllEnvs();
}
});
});