mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
031e123731
commit
60e2685782
2 changed files with 255 additions and 2 deletions
|
|
@ -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 [];
|
||||
|
|
|
|||
200
gitnexus/test/unit/spring-vendor-annotation-alias.test.ts
Normal file
200
gitnexus/test/unit/spring-vendor-annotation-alias.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue