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.
This commit is contained in:
ChunxueLi 2026-08-08 06:04:02 +08:00 committed by ChunxueLi
parent 031e123731
commit 60e2685782
2 changed files with 255 additions and 2 deletions

View file

@ -24,7 +24,7 @@ import { parseSpringAnnotationArguments } from '../frameworks/spring/annotation-
*
* `@RequestMapping` is intentionally absent: on a method it carries no implicit
* verb (the verb lives in its `method = RequestMethod.X` attribute), and on a
* class it is a URL prefix rather than a route. Callers handle `@RequestMapping`
* class it is a URL prefix rather than a route. Callers handle `RequestMapping`
* separately.
*/
export const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
@ -35,6 +35,45 @@ export const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
PatchMapping: 'PATCH',
};
/**
* All recognised Spring mapping-annotation simple names (shortcut + base).
* Sorted longest-first so {@link resolveSpringAnnotationAlias} prefers the most
* specific suffix (e.g. `PostMapping` before any hypothetical shorter overlap).
*/
const SPRING_MAPPING_NAMES: readonly string[] = [
...Object.keys(METHOD_ANNOTATION_TO_HTTP),
'RequestMapping',
].sort((a, b) => b.length - a.length);
/**
* Resolve a custom (vendor-derived) Spring mapping annotation to the built-in
* annotation it wraps, by naming suffix.
*
* Frameworks commonly wrap Spring's built-in annotations with company-specific
* variants e.g. Winning Health's `@WinPostMapping` is meta-annotated with
* `@PostMapping`. The definition lives in a binary JAR (not in source), so the
* meta-annotation cannot be read statically. Instead we resolve by suffix:
* `WinPostMapping` `PostMapping`, `WinRequestMapping` `RequestMapping`.
*
* This matches the universal Java convention of naming a derived annotation
* with the base name as a suffix. The false-positive risk is negligible: a
* non-HTTP annotation ending in `PostMapping`/`GetMapping`/ is unprecedented
* in the ecosystem.
*
* Returns the base annotation name (`PostMapping`, `RequestMapping`, ) for
* names that are NOT themselves a known mapping annotation but end with one,
* or `undefined` otherwise. Exact-known names (`PostMapping`, `RequestMapping`,
* ) return `undefined` callers handle those directly.
*/
export function resolveSpringAnnotationAlias(annotationName: string): string | undefined {
for (const base of SPRING_MAPPING_NAMES) {
if (annotationName.length > base.length && annotationName.endsWith(base)) {
return base;
}
}
return undefined;
}
/**
* Parse one `RequestMethod.X` literal or a Java annotation array of literals.
* An empty array is valid and means Spring's unrestricted/default method set.
@ -89,14 +128,28 @@ function parseRequestMethodValues(value: string): readonly string[] | null {
* one or more static `RequestMethod.X` values; when its `method` member is
* absent or an empty array, `'*'` preserves Spring's method-agnostic semantics.
* A present but non-static method expression yields no methods (fail closed).
*
* Vendor-derived aliases (e.g. `@WinPostMapping`) are resolved by suffix to
* their base annotation before the above logic applies see
* {@link resolveSpringAnnotationAlias}.
*/
export function springAnnotationHttpMethods(
annotationName: string,
annotationText: string,
): readonly string[] {
// Exact shortcut match (PostMapping → POST, etc.)
const shortcut = METHOD_ANNOTATION_TO_HTTP[annotationName];
if (shortcut) return [shortcut];
if (annotationName !== 'RequestMapping') return [];
// Resolve vendor alias by suffix (WinPostMapping → PostMapping, etc.)
const base = resolveSpringAnnotationAlias(annotationName) ?? annotationName;
// Alias of a shortcut annotation
const aliasShortcut = METHOD_ANNOTATION_TO_HTTP[base];
if (aliasShortcut) return [aliasShortcut];
// Direct or aliased @RequestMapping: parse the method= attribute
if (base !== 'RequestMapping') return [];
const args = parseSpringAnnotationArguments(annotationText);
if (args === null) return [];

View file

@ -0,0 +1,200 @@
/**
* 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 } 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';
function parse(code: string): Parser.Tree {
const parser = new Parser();
parser.setLanguage(Java);
return parser.parse(code);
}
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');
expect(resolveSpringAnnotationAlias('CustomRequestMapping')).toBe('RequestMapping');
});
it('works with arbitrary vendor prefixes', () => {
expect(resolveSpringAnnotationAlias('CompanyPostMapping')).toBe('PostMapping');
expect(resolveSpringAnnotationAlias('XyzGetMapping')).toBe('GetMapping');
});
it('does not match annotations that merely contain a mapping name', () => {
expect(resolveSpringAnnotationAlias('PostMappingHelper')).toBeUndefined();
expect(resolveSpringAnnotationAlias('GetMappingInfo')).toBeUndefined();
expect(resolveSpringAnnotationAlias('PreMapping')).toBeUndefined();
});
});
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('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',
]);
});
});