mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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>
This commit is contained in:
parent
b9613ee86b
commit
131d9f93fd
14 changed files with 786 additions and 128 deletions
26
gitnexus/src/core/analysis-feature-registry.ts
Normal file
26
gitnexus/src/core/analysis-feature-registry.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from './analysis-features.js';
|
||||
import {
|
||||
SPRING_AOP_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||
SPRING_ROUTE_BINDINGS_FEATURE,
|
||||
} from './ingestion/frameworks/spring/analysis-features.js';
|
||||
import {
|
||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
} from './ingestion/languages/java/analysis-features.js';
|
||||
|
||||
/** Production registry of independently versioned analysis capabilities. */
|
||||
export const ANALYSIS_FEATURES = [
|
||||
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
|
||||
SPRING_AOP_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||
SPRING_ROUTE_BINDINGS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
||||
] as const;
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
intersectSpringHttpMethods,
|
||||
isRouteMemberKey,
|
||||
findEnclosingClass,
|
||||
isClassLevelMappingAnnotation,
|
||||
joinPath,
|
||||
type SharedSpringType,
|
||||
} from '../../../ingestion/route-extractors/spring-shared.js';
|
||||
|
|
@ -467,13 +468,15 @@ function annotationHasRouteMember(annotation: Parser.SyntaxNode): boolean {
|
|||
}
|
||||
|
||||
function typeRequestMethods(typeNode: Parser.SyntaxNode): readonly string[] {
|
||||
const mappings = declarationAnnotations(typeNode).filter(
|
||||
(annotation) =>
|
||||
simpleName(annotation.childForFieldName('name')?.text ?? '') === 'RequestMapping',
|
||||
const mappings = declarationAnnotations(typeNode).filter((annotation) =>
|
||||
isClassLevelMappingAnnotation(simpleName(annotation.childForFieldName('name')?.text ?? '')),
|
||||
);
|
||||
if (mappings.length === 0) return ['*'];
|
||||
if (mappings.length !== 1) return [];
|
||||
return springAnnotationHttpMethods('RequestMapping', mappings[0].text);
|
||||
return springAnnotationHttpMethods(
|
||||
simpleName(mappings[0].childForFieldName('name')?.text ?? 'RequestMapping'),
|
||||
mappings[0].text,
|
||||
);
|
||||
}
|
||||
|
||||
function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean {
|
||||
|
|
@ -675,7 +678,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
|
||||
// Type-level (class or interface): a Spring `@RequestMapping` URL prefix, or
|
||||
// — on an interface — an OpenFeign `@FeignClient(path = "...")` prefix.
|
||||
if (ann === 'RequestMapping') {
|
||||
if (isClassLevelMappingAnnotation(ann)) {
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
if (!valueNode) {
|
||||
// Constant-valued class prefix — see `typesWithUnfoldablePrefix`.
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ import type {
|
|||
HttpScanInput,
|
||||
} from './types.js';
|
||||
import {
|
||||
METHOD_ANNOTATION_TO_HTTP,
|
||||
findEnclosingClass,
|
||||
intersectSpringHttpMethods,
|
||||
isClassLevelMappingAnnotation,
|
||||
joinPath,
|
||||
springAnnotationHttpMethods,
|
||||
type SharedSpringType,
|
||||
} from '../../../ingestion/route-extractors/spring-shared.js';
|
||||
import {
|
||||
|
|
@ -448,6 +450,13 @@ function inferKotlinOkHttpMethod(urlCall: Parser.SyntaxNode): string | null {
|
|||
return name === null ? 'GET' : name.toUpperCase();
|
||||
}
|
||||
|
||||
function enclosingAnnotationText(node: Parser.SyntaxNode): string {
|
||||
for (let current: Parser.SyntaxNode | null = node; current; current = current.parent) {
|
||||
if (current.type === 'annotation') return current.text;
|
||||
}
|
||||
return node.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the plugin only if the Kotlin grammar is available. Compiling
|
||||
* the queries against a null grammar would throw at module load time
|
||||
|
|
@ -485,7 +494,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
|
||||
(user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
|
||||
(value_arguments
|
||||
(value_argument . [(string_literal) @prefix (collection_literal (string_literal) @prefix)])))))
|
||||
(type_identifier) @cls) @class
|
||||
|
|
@ -498,7 +507,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
|
||||
(user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
|
||||
(value_arguments
|
||||
(value_argument
|
||||
(simple_identifier) @key (#match? @key "^(path|value)$")
|
||||
|
|
@ -513,7 +522,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
|
||||
(user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
|
||||
(value_arguments
|
||||
(value_argument . ${arrayOfArg('@prefix')})))))
|
||||
(type_identifier) @cls) @class
|
||||
|
|
@ -526,7 +535,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
|
||||
(user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
|
||||
(value_arguments
|
||||
(value_argument
|
||||
(simple_identifier) @key (#match? @key "^(path|value)$")
|
||||
|
|
@ -552,7 +561,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(value_arguments
|
||||
(value_argument . [(string_literal) @path (collection_literal (string_literal) @path)])))))
|
||||
(simple_identifier) @method_name) @method
|
||||
|
|
@ -565,7 +574,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(value_arguments
|
||||
(value_argument
|
||||
(simple_identifier) @key (#match? @key "^(path|value)$")
|
||||
|
|
@ -580,7 +589,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(value_arguments
|
||||
(value_argument . ${arrayOfArg('@path')})))))
|
||||
(simple_identifier) @method_name) @method
|
||||
|
|
@ -593,7 +602,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(value_arguments
|
||||
(value_argument
|
||||
(simple_identifier) @key (#match? @key "^(path|value)$")
|
||||
|
|
@ -629,7 +638,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
|
||||
(user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
|
||||
(value_arguments (value_argument) @arg))))
|
||||
(type_identifier) @cls) @class
|
||||
`,
|
||||
|
|
@ -648,7 +657,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(value_arguments (value_argument) @arg))))
|
||||
(simple_identifier) @method_name) @method
|
||||
`,
|
||||
|
|
@ -713,7 +722,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
for (const match of runCompiledPatterns(SPRING_CONST_CLASS_PREFIX_PATTERNS, tree)) {
|
||||
const argNode = match.captures.arg;
|
||||
const classNode = match.captures.class;
|
||||
const annNode = match.captures.ann;
|
||||
if (!argNode || !classNode) continue;
|
||||
if (annNode && !isClassLevelMappingAnnotation(annNode.text)) continue;
|
||||
if ((resolvedPrefixes.get(classNode.id) ?? []).length > 0) continue;
|
||||
const expr = kotlinRouteArgumentExpression(argNode);
|
||||
if (!expr || classifyPathArgument(expr) !== 'unresolvable') continue;
|
||||
|
|
@ -1218,13 +1229,36 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
const kotlinFunctionName = (fn: Parser.SyntaxNode): string | null =>
|
||||
fn.namedChildren.find((c) => c.type === 'simple_identifier')?.text ?? null;
|
||||
|
||||
const kotlinTypeRequestMethods = (typeNode: Parser.SyntaxNode): readonly string[] => {
|
||||
const modifiers = typeNode.namedChildren.find((child) => child.type === 'modifiers');
|
||||
const mappings = (modifiers?.namedChildren ?? []).filter((annotation) => {
|
||||
if (annotation.type !== 'annotation') return false;
|
||||
return isClassLevelMappingAnnotation(kotlinAnnotationName(annotation) ?? '');
|
||||
});
|
||||
if (mappings.length === 0) return ['*'];
|
||||
if (mappings.length !== 1) return [];
|
||||
const mapping = mappings[0];
|
||||
const mappingName = kotlinAnnotationName(mapping);
|
||||
if (!mappingName) return [];
|
||||
return springAnnotationHttpMethods(mappingName, mapping.text);
|
||||
};
|
||||
|
||||
const kotlinClassHttpMethodsById = (tree: Parser.Tree) =>
|
||||
new Map(
|
||||
tree.rootNode
|
||||
.descendantsOfType('class_declaration')
|
||||
.map((typeNode) => [typeNode.id, kotlinTypeRequestMethods(typeNode)] as const),
|
||||
);
|
||||
|
||||
const collectKotlinSpringTypes = (filePath: string, tree: Parser.Tree): SharedSpringType[] => {
|
||||
// Class-level @RequestMapping prefixes (reuse the provider class-prefix query).
|
||||
const prefixByClassId = new Map<number, string[]>();
|
||||
for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
|
||||
const prefixNode = match.captures.prefix;
|
||||
const classNode = match.captures.class;
|
||||
const annNode = match.captures.ann;
|
||||
if (!prefixNode || !classNode) continue;
|
||||
if (annNode && !isClassLevelMappingAnnotation(annNode.text)) continue;
|
||||
// An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — unquoting
|
||||
// its raw text would carry the source spelling into the shared type view
|
||||
// as a served prefix. Refusing it here is also what lets the unfoldable
|
||||
|
|
@ -1242,22 +1276,32 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
// noise into the shared type view, so it is left out — the same skip floor
|
||||
// `java.ts`'s `collectSpringTypes` keeps.
|
||||
const routesByMethodId = new Map<number, Array<{ method: string; path: string }>>();
|
||||
const classHttpMethodsById = kotlinClassHttpMethodsById(tree);
|
||||
const unfoldablePrefixClassIds = collectUnfoldablePrefixClassIds(tree, prefixByClassId);
|
||||
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
|
||||
const annNode = match.captures.ann;
|
||||
const pathNode = match.captures.path;
|
||||
const methodNode = match.captures.method;
|
||||
if (!annNode || !pathNode || !methodNode) continue;
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const httpMethods = springAnnotationHttpMethods(
|
||||
annNode.text,
|
||||
enclosingAnnotationText(annNode),
|
||||
);
|
||||
if (httpMethods.length === 0) continue;
|
||||
const rawPath = unquoteLiteral(pathNode.text);
|
||||
if (rawPath === null) continue;
|
||||
// A constant class prefix leaves no single prefix string for the
|
||||
// inheritance view to carry, so this route would be published unprefixed.
|
||||
const owner = findEnclosingClass(methodNode);
|
||||
if (owner && unfoldablePrefixClassIds.has(owner.id)) continue;
|
||||
const constrainedMethods = intersectSpringHttpMethods(
|
||||
owner ? (classHttpMethodsById.get(owner.id) ?? ['*']) : ['*'],
|
||||
httpMethods,
|
||||
);
|
||||
const arr = routesByMethodId.get(methodNode.id) ?? [];
|
||||
arr.push({ method: httpMethod, path: rawPath });
|
||||
for (const httpMethod of constrainedMethods) {
|
||||
arr.push({ method: httpMethod, path: rawPath });
|
||||
}
|
||||
routesByMethodId.set(methodNode.id, arr);
|
||||
}
|
||||
|
||||
|
|
@ -1390,7 +1434,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
|
||||
const prefixNode = match.captures.prefix;
|
||||
const classNode = match.captures.class;
|
||||
const annNode = match.captures.ann;
|
||||
if (!prefixNode || !classNode) continue;
|
||||
if (annNode && !isClassLevelMappingAnnotation(annNode.text)) continue;
|
||||
// An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — see
|
||||
// `isPlainStringLiteral`. Refusing it here also lets the unfoldable
|
||||
// analysis below mark such a class, since that skips classes whose
|
||||
|
|
@ -1438,29 +1484,38 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
nameNode: Parser.SyntaxNode | undefined;
|
||||
methodNode: Parser.SyntaxNode;
|
||||
}> = [];
|
||||
const classHttpMethodsById = kotlinClassHttpMethodsById(tree);
|
||||
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
|
||||
const annNode = match.captures.ann;
|
||||
const pathNode = match.captures.path;
|
||||
const methodNode = match.captures.method;
|
||||
if (!annNode || !pathNode || !methodNode) continue;
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const httpMethods = springAnnotationHttpMethods(
|
||||
annNode.text,
|
||||
enclosingAnnotationText(annNode),
|
||||
);
|
||||
if (httpMethods.length === 0) continue;
|
||||
const rawPath = unquoteLiteral(pathNode.text);
|
||||
if (rawPath === null) continue;
|
||||
methodRoutes.push({
|
||||
httpMethod,
|
||||
rawPath,
|
||||
nameNode: match.captures.method_name,
|
||||
methodNode,
|
||||
});
|
||||
for (const httpMethod of httpMethods) {
|
||||
methodRoutes.push({
|
||||
httpMethod,
|
||||
rawPath,
|
||||
nameNode: match.captures.method_name,
|
||||
methodNode,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const match of runCompiledPatterns(SPRING_CONST_METHOD_ROUTE_PATTERNS, tree)) {
|
||||
const annNode = match.captures.ann;
|
||||
const argNode = match.captures.arg;
|
||||
const methodNode = match.captures.method;
|
||||
if (!annNode || !argNode || !methodNode) continue;
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const httpMethods = springAnnotationHttpMethods(
|
||||
annNode.text,
|
||||
enclosingAnnotationText(annNode),
|
||||
);
|
||||
if (httpMethods.length === 0) continue;
|
||||
const expr = kotlinRouteArgumentExpression(argNode);
|
||||
if (!expr || !FOLDABLE_PATH_EXPRESSIONS.has(expr.type)) continue;
|
||||
// No repo context (context-less fallback scanning) means no constant map
|
||||
|
|
@ -1481,15 +1536,26 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
index,
|
||||
);
|
||||
if (rawPath === null) continue;
|
||||
methodRoutes.push({
|
||||
httpMethod,
|
||||
rawPath,
|
||||
nameNode: match.captures.method_name,
|
||||
methodNode,
|
||||
});
|
||||
for (const httpMethod of httpMethods) {
|
||||
methodRoutes.push({
|
||||
httpMethod,
|
||||
rawPath,
|
||||
nameNode: match.captures.method_name,
|
||||
methodNode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { httpMethod, rawPath, nameNode, methodNode } of methodRoutes) {
|
||||
const constrainedMethodRoutes = methodRoutes.flatMap((route) => {
|
||||
const owner = findEnclosingClass(route.methodNode);
|
||||
const classMethods = owner ? (classHttpMethodsById.get(owner.id) ?? ['*']) : ['*'];
|
||||
return intersectSpringHttpMethods(classMethods, [route.httpMethod]).map((httpMethod) => ({
|
||||
...route,
|
||||
httpMethod,
|
||||
}));
|
||||
});
|
||||
|
||||
for (const { httpMethod, rawPath, nameNode, methodNode } of constrainedMethodRoutes) {
|
||||
const enclosingClass = findEnclosingClass(methodNode);
|
||||
// A @(Get|...)Mapping inside a @FeignClient interface is an OpenFeign
|
||||
// consumer (a remote call), not a route this service serves.
|
||||
|
|
|
|||
|
|
@ -50,3 +50,13 @@ export const SPRING_NON_HTTP_HANDLERS_FEATURE: AnalysisFeatureDescriptor = {
|
|||
version: 1,
|
||||
appliesTo: (filePaths) => filePaths.some(isJvmSourceFile),
|
||||
};
|
||||
|
||||
/**
|
||||
* Route/handler binding extraction, including vendor `@Win*Mapping` aliases.
|
||||
* Existing indexes keep a stale Route set until this version is stamped.
|
||||
*/
|
||||
export const SPRING_ROUTE_BINDINGS_FEATURE: AnalysisFeatureDescriptor = {
|
||||
id: 'spring.route-bindings',
|
||||
version: 2,
|
||||
appliesTo: (filePaths) => filePaths.some(isJvmSourceFile),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
const DEFAULT_SPRING_VENDOR_PREFIXES = 'Win';
|
||||
|
||||
let cachedRawValue: string | undefined;
|
||||
let cachedPrefixes: ReadonlySet<string> | undefined;
|
||||
|
||||
/** Return the configured vendor prefixes as a canonical, duplicate-free set. */
|
||||
export function springVendorPrefixes(): ReadonlySet<string> {
|
||||
const rawValue = process.env.GITNEXUS_SPRING_VENDOR_PREFIXES ?? DEFAULT_SPRING_VENDOR_PREFIXES;
|
||||
if (cachedPrefixes && cachedRawValue === rawValue) return cachedPrefixes;
|
||||
|
||||
cachedRawValue = rawValue;
|
||||
cachedPrefixes = new Set(
|
||||
rawValue
|
||||
.split(',')
|
||||
.map((prefix) => prefix.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
return cachedPrefixes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable metadata value for the route semantics controlled by the prefix list.
|
||||
* Sorting makes equivalent lists independent of declaration order.
|
||||
*/
|
||||
export function springVendorPrefixesKey(): string {
|
||||
return JSON.stringify([...springVendorPrefixes()].sort());
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import type Parser from 'tree-sitter';
|
|||
import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
|
||||
import {
|
||||
intersectSpringHttpMethods,
|
||||
isClassLevelMappingAnnotation,
|
||||
springAnnotationHttpMethods,
|
||||
unquoteSpringLiteral,
|
||||
} from './spring-shared.js';
|
||||
|
|
@ -140,19 +141,6 @@ function functionName(node: Parser.SyntaxNode): string | null {
|
|||
return identifier ? unquoteKotlinIdentifier(identifier.text) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `springAnnotationHttpMethods` parses Java `{A, B}` collections.
|
||||
* Translate only Kotlin `method = [A, B]` before delegating.
|
||||
*/
|
||||
function kotlinSpringHttpMethods(name: string, annotation: Parser.SyntaxNode): readonly string[] {
|
||||
if (name !== 'RequestMapping') return springAnnotationHttpMethods(name, annotation.text);
|
||||
const normalized = annotation.text.replace(
|
||||
/(\bmethod\s*=\s*)\[([^\]]*)\]/gs,
|
||||
(_match, assignment: string, values: string) => `${assignment}{${values}}`,
|
||||
);
|
||||
return springAnnotationHttpMethods(name, normalized);
|
||||
}
|
||||
|
||||
function typeName(node: Parser.SyntaxNode): string | null {
|
||||
const identifier = node.children.find((child) => child.type === 'type_identifier');
|
||||
return identifier ? unquoteKotlinIdentifier(identifier.text) : null;
|
||||
|
|
@ -217,8 +205,8 @@ interface ClassMapping {
|
|||
* mappings, and dynamic expressions fail closed for the whole class.
|
||||
*/
|
||||
function classMapping(annotations: readonly Parser.SyntaxNode[]): ClassMapping | null {
|
||||
const mappings = annotations.filter(
|
||||
(annotation) => annotationName(annotation) === 'RequestMapping',
|
||||
const mappings = annotations.filter((annotation) =>
|
||||
isClassLevelMappingAnnotation(annotationName(annotation) ?? ''),
|
||||
);
|
||||
if (mappings.length === 0) return { prefix: '', methods: ['*'] };
|
||||
if (mappings.length !== 1) return null;
|
||||
|
|
@ -241,7 +229,9 @@ function classMapping(annotations: readonly Parser.SyntaxNode[]): ClassMapping |
|
|||
}
|
||||
}
|
||||
|
||||
const methods = kotlinSpringHttpMethods('RequestMapping', mapping);
|
||||
const mappingName = annotationName(mapping);
|
||||
if (!mappingName) return null;
|
||||
const methods = springAnnotationHttpMethods(mappingName, mapping.text);
|
||||
return methods.length === 0 ? null : { prefix, methods };
|
||||
}
|
||||
|
||||
|
|
@ -274,7 +264,7 @@ export function extractKotlinSpringRoutes(
|
|||
const decoratorName = annotationName(annotation);
|
||||
if (!decoratorName) continue;
|
||||
|
||||
const methodMethods = kotlinSpringHttpMethods(decoratorName, annotation);
|
||||
const methodMethods = springAnnotationHttpMethods(decoratorName, annotation.text);
|
||||
const methods = intersectSpringHttpMethods(ownerMapping.methods, methodMethods);
|
||||
if (methods.length === 0) continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -18,13 +18,14 @@
|
|||
|
||||
import type Parser from 'tree-sitter';
|
||||
import { parseSpringAnnotationArguments } from '../frameworks/spring/annotation-arguments.js';
|
||||
import { springVendorPrefixes } from '../frameworks/spring/vendor-prefixes.js';
|
||||
|
||||
/**
|
||||
* Spring shortcut method-annotation → HTTP verb.
|
||||
*
|
||||
* `@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> = {
|
||||
|
|
@ -36,7 +37,46 @@ export const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Parse one `RequestMethod.X` literal or a Java annotation array of literals.
|
||||
* 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 REGISTERED vendor-derived Spring mapping annotation to its base.
|
||||
*
|
||||
* Vendor definitions often live in binary dependencies, so their Spring
|
||||
* meta-annotations cannot be inspected from repository source. Resolution uses
|
||||
* the conventional `<vendorPrefix><baseAnnotation>` name instead.
|
||||
*
|
||||
* Suffix matching alone accepted unrelated annotations (`@AuditPostMapping`
|
||||
* produced a phantom route — review P2). Resolution now requires the name to
|
||||
* be `<registeredPrefix><base>` with the prefix drawn from a small registry:
|
||||
* `Win` by default (Winning Health), extendable via
|
||||
* `GITNEXUS_SPRING_VENDOR_PREFIXES=Win,Acme,Other`. Changing the registry
|
||||
* invalidates persisted JVM route evidence on the next analysis. Exact-known
|
||||
* Spring annotation names return `undefined`; callers handle those directly.
|
||||
*/
|
||||
export function resolveSpringAnnotationAlias(annotationName: string): string | undefined {
|
||||
const registeredVendorPrefixes = springVendorPrefixes();
|
||||
for (const base of SPRING_MAPPING_NAMES) {
|
||||
if (annotationName.length > base.length && annotationName.endsWith(base)) {
|
||||
const prefix = annotationName.slice(0, annotationName.length - base.length);
|
||||
if (registeredVendorPrefixes.has(prefix)) {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one `RequestMethod.X` literal or a Java `{…}` / Kotlin `[…]` array of
|
||||
* those literals.
|
||||
* An empty array is valid and means Spring's unrestricted/default method set.
|
||||
* Runtime expressions fail closed instead of producing a guessed route.
|
||||
*/
|
||||
|
|
@ -60,16 +100,25 @@ function parseRequestMethodValues(value: string): readonly string[] | null {
|
|||
}
|
||||
trimmed += char;
|
||||
}
|
||||
const hasOpeningBrace = trimmed.startsWith('{');
|
||||
const hasClosingBrace = trimmed.endsWith('}');
|
||||
if (hasOpeningBrace !== hasClosingBrace) return null;
|
||||
const body = hasOpeningBrace ? trimmed.slice(1, -1).trim() : trimmed;
|
||||
const wrapped =
|
||||
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'));
|
||||
if (
|
||||
!wrapped &&
|
||||
(trimmed.startsWith('{') ||
|
||||
trimmed.startsWith('[') ||
|
||||
trimmed.endsWith('}') ||
|
||||
trimmed.endsWith(']'))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const body = wrapped ? trimmed.slice(1, -1).trim() : trimmed;
|
||||
if (body.length === 0) return [];
|
||||
if (!hasOpeningBrace && body.includes(',')) return null;
|
||||
if (!wrapped && body.includes(',')) return null;
|
||||
|
||||
const methods: string[] = [];
|
||||
const parts = body.split(',');
|
||||
if (hasOpeningBrace && parts[parts.length - 1].trim() === '') parts.pop();
|
||||
if (wrapped && parts[parts.length - 1].trim() === '') parts.pop();
|
||||
for (const rawPart of parts) {
|
||||
const part = rawPart.trim();
|
||||
const match =
|
||||
|
|
@ -89,14 +138,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 [];
|
||||
|
|
@ -109,6 +172,19 @@ export function springAnnotationHttpMethods(
|
|||
return methods.length > 0 ? methods : ['*'];
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an annotation name is a class-level request-mapping annotation —
|
||||
* either Spring's own `@RequestMapping` or a registered vendor alias that
|
||||
* resolves to it (`@WinRequestMapping`). Class-level handling in both the
|
||||
* group extractor and the ingestion route extractor routes through this
|
||||
* predicate so vendor aliases get the same prefix/constraint semantics as
|
||||
* the base annotation (review P1).
|
||||
*/
|
||||
export function isClassLevelMappingAnnotation(annotationName: string): boolean {
|
||||
if (annotationName === 'RequestMapping') return true;
|
||||
return resolveSpringAnnotationAlias(annotationName) === 'RequestMapping';
|
||||
}
|
||||
|
||||
/** Intersect class- and method-level Spring mapping constraints. */
|
||||
export function intersectSpringHttpMethods(
|
||||
classMethods: readonly string[],
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
springAnnotationHttpMethods,
|
||||
isRouteMemberKey,
|
||||
findEnclosingType,
|
||||
isClassLevelMappingAnnotation,
|
||||
unquoteSpringLiteral,
|
||||
type SharedSpringType,
|
||||
} from './spring-shared.js';
|
||||
|
|
@ -192,7 +193,10 @@ export function extractSpringRoutes(
|
|||
if (!annNode || !node || (!valueNode && !valueExprNode)) continue;
|
||||
|
||||
const capturedAnnotationName = annNode.text.split('.').pop() ?? annNode.text;
|
||||
if (node.type === 'class_declaration' && capturedAnnotationName === 'RequestMapping') {
|
||||
if (
|
||||
node.type === 'class_declaration' &&
|
||||
isClassLevelMappingAnnotation(capturedAnnotationName)
|
||||
) {
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
if (!valueNode) {
|
||||
classesWithUnfoldablePrefix.add(node.id);
|
||||
|
|
@ -437,12 +441,14 @@ function annotationHasRouteMember(ann: Parser.SyntaxNode): boolean {
|
|||
|
||||
/** Static class/interface-level RequestMapping method constraint, or wildcard by default. */
|
||||
function typeRequestMethods(typeNode: Parser.SyntaxNode): readonly string[] {
|
||||
const mappings = declarationAnnotations(typeNode).filter(
|
||||
(ann) => annotationName(ann) === 'RequestMapping',
|
||||
const mappings = declarationAnnotations(typeNode).filter((ann) =>
|
||||
isClassLevelMappingAnnotation(annotationName(ann) ?? ''),
|
||||
);
|
||||
if (mappings.length === 0) return ['*'];
|
||||
if (mappings.length !== 1) return [];
|
||||
return springAnnotationHttpMethods('RequestMapping', mappings[0].text);
|
||||
const mappingName = annotationName(mappings[0]);
|
||||
if (!mappingName) return [];
|
||||
return springAnnotationHttpMethods(mappingName, mappings[0].text);
|
||||
}
|
||||
|
||||
function annotationRoutePathsOrDefault(ann: Parser.SyntaxNode): string[] {
|
||||
|
|
@ -455,7 +461,8 @@ function annotationRoutePathsOrDefault(ann: Parser.SyntaxNode): string[] {
|
|||
function typeClassPrefixes(typeNode: Parser.SyntaxNode): string[] {
|
||||
const prefixes: string[] = [];
|
||||
for (const ann of declarationAnnotations(typeNode)) {
|
||||
if (annotationName(ann) === 'RequestMapping') prefixes.push(...annotationRoutePaths(ann));
|
||||
if (isClassLevelMappingAnnotation(annotationName(ann) ?? ''))
|
||||
prefixes.push(...annotationRoutePaths(ann));
|
||||
}
|
||||
return prefixes;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,22 +190,13 @@ import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/b
|
|||
import { isSpringBeanFactoryDeclaration } from './ingestion/frameworks/spring/bean-factories.js';
|
||||
import { SPRING_CONFIG_UNRESOLVED_PREFIX } from './ingestion/frameworks/spring/config-bindings.js';
|
||||
import { classifySpringConfigFile } from './ingestion/pipeline-phases/spring-config.js';
|
||||
import { SPRING_ROUTE_BINDINGS_FEATURE } from './ingestion/frameworks/spring/analysis-features.js';
|
||||
import { springVendorPrefixesKey } from './ingestion/frameworks/spring/vendor-prefixes.js';
|
||||
import {
|
||||
SPRING_AOP_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||
} from './ingestion/frameworks/spring/analysis-features.js';
|
||||
import {
|
||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
} from './ingestion/languages/java/analysis-features.js';
|
||||
import {
|
||||
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
|
||||
findAnalysisFeatureMismatches,
|
||||
resolveAnalysisFeatureVersions,
|
||||
} from './analysis-features.js';
|
||||
import { ANALYSIS_FEATURES } from './analysis-feature-registry.js';
|
||||
import {
|
||||
analyzerRunnerIdentitiesEqual,
|
||||
finalizeAnalyzerRunnerIdentity,
|
||||
|
|
@ -247,17 +238,6 @@ import type { EmbeddingCheckpoint } from './embedding-checkpoint.js';
|
|||
const stripControlCharacters = (msg: string): string =>
|
||||
msg.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
|
||||
|
||||
const ANALYSIS_FEATURES = [
|
||||
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
|
||||
SPRING_AOP_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
||||
] as const;
|
||||
|
||||
interface PersistedFrameworkAnnotationRow {
|
||||
readonly id?: unknown;
|
||||
readonly frameworkAnnotations?: unknown;
|
||||
|
|
@ -1597,6 +1577,20 @@ async function runFullAnalysisInner(
|
|||
analysisFeatureMismatchLogged = true;
|
||||
}
|
||||
|
||||
const currentSpringVendorPrefixes = springVendorPrefixesKey();
|
||||
const persistedRouteBindings = existingMeta?.analysisFeatures?.[SPRING_ROUTE_BINDINGS_FEATURE.id];
|
||||
if (
|
||||
existingMeta &&
|
||||
persistedRouteBindings === SPRING_ROUTE_BINDINGS_FEATURE.version &&
|
||||
existingMeta.springVendorPrefixes !== currentSpringVendorPrefixes
|
||||
) {
|
||||
log(
|
||||
'Spring vendor mapping prefixes changed; forcing a full rebuild so persisted Route ' +
|
||||
'evidence matches the configured aliases.',
|
||||
);
|
||||
options = { ...options, force: true };
|
||||
}
|
||||
|
||||
// Analyzer provenance is part of freshness, not merely diagnostics. A
|
||||
// same-commit fast path must not preserve metadata produced by an older,
|
||||
// malformed, or dependency/native-different runner. Force a real rebuild so
|
||||
|
|
@ -3911,6 +3905,7 @@ async function runFullAnalysisInner(
|
|||
? existingMeta?.undecidedInterfaceSatisfaction
|
||||
: summarizeUndecidedSatisfaction(pipelineResult.undecidedSatisfaction),
|
||||
analysisFeatures: currentAnalysisFeatures,
|
||||
springVendorPrefixes: currentSpringVendorPrefixes,
|
||||
// Always stamped with the live resolved mode (#2331/#2339) — unlike
|
||||
// `pdg` below, 'none' is a meaningful value to compare, not an
|
||||
// absence, so this is never conditionally omitted.
|
||||
|
|
|
|||
|
|
@ -202,6 +202,11 @@ export interface RepoMeta {
|
|||
* containing relevant source files.
|
||||
*/
|
||||
analysisFeatures?: Record<string, number>;
|
||||
/**
|
||||
* Canonical registered-prefix list used to resolve vendor Spring mapping
|
||||
* annotations. A changed value invalidates persisted JVM Route evidence.
|
||||
*/
|
||||
springVendorPrefixes?: string;
|
||||
/**
|
||||
* The resolved GITNEXUS_FTS_CJK_SEGMENTATION mode ('none' | 'bigram') the
|
||||
* existing index's content/description columns were last written under
|
||||
|
|
|
|||
|
|
@ -5,35 +5,14 @@ import {
|
|||
resolveAnalysisFeatureVersions,
|
||||
type AnalysisFeatureDescriptor,
|
||||
} from '../../src/core/analysis-features.js';
|
||||
import {
|
||||
SPRING_AOP_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||
import {
|
||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
} from '../../src/core/ingestion/languages/java/analysis-features.js';
|
||||
|
||||
const FEATURES = [
|
||||
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
|
||||
SPRING_AOP_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
||||
] as const;
|
||||
import { ANALYSIS_FEATURES } from '../../src/core/analysis-feature-registry.js';
|
||||
|
||||
describe('analysis feature versions', () => {
|
||||
it('separates the global Class schema capability from JVM-only Bean evidence', () => {
|
||||
expect(resolveAnalysisFeatureVersions(FEATURES, ['src/app.ts'])).toEqual({
|
||||
expect(resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, ['src/app.ts'])).toEqual({
|
||||
'graph.class-framework-annotations': 1,
|
||||
});
|
||||
expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({
|
||||
expect(resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, ['src/App.java'])).toEqual({
|
||||
'graph.class-framework-annotations': 1,
|
||||
'java.heritage-captures': 1,
|
||||
'java.record-component-accessors': 1,
|
||||
|
|
@ -42,24 +21,27 @@ describe('analysis feature versions', () => {
|
|||
'spring.conditionals-auto-configuration': 1,
|
||||
'spring.config-bindings': 2,
|
||||
'spring.non-http-handlers': 1,
|
||||
'spring.route-bindings': 2,
|
||||
});
|
||||
expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.kt'])).toEqual({
|
||||
expect(resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, ['src/App.kt'])).toEqual({
|
||||
'graph.class-framework-annotations': 1,
|
||||
'spring.aop-advice': 1,
|
||||
'spring.bean-inventory': 2,
|
||||
'spring.conditionals-auto-configuration': 1,
|
||||
'spring.config-bindings': 2,
|
||||
'spring.non-http-handlers': 1,
|
||||
'spring.route-bindings': 2,
|
||||
});
|
||||
expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({
|
||||
expect(resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({
|
||||
'graph.class-framework-annotations': 1,
|
||||
'spring.aop-advice': 1,
|
||||
'spring.bean-inventory': 2,
|
||||
'spring.conditionals-auto-configuration': 1,
|
||||
'spring.non-http-handlers': 1,
|
||||
'spring.route-bindings': 2,
|
||||
});
|
||||
expect(
|
||||
resolveAnalysisFeatureVersions(FEATURES, [
|
||||
resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, [
|
||||
'src/main/resources/application-local.yml',
|
||||
'README.md',
|
||||
]),
|
||||
|
|
@ -68,7 +50,7 @@ describe('analysis feature versions', () => {
|
|||
'spring.config-bindings': 2,
|
||||
});
|
||||
expect(
|
||||
resolveAnalysisFeatureVersions(FEATURES, [
|
||||
resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, [
|
||||
'src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports',
|
||||
]),
|
||||
).toEqual({
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ import {
|
|||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||
SPRING_ROUTE_BINDINGS_FEATURE,
|
||||
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||
import { springVendorPrefixesKey } from '../../src/core/ingestion/frameworks/spring/vendor-prefixes.js';
|
||||
import {
|
||||
decodeSpringAopReason,
|
||||
SPRING_AOP_EVIDENCE_ID_PREFIX,
|
||||
|
|
@ -799,6 +801,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
|
|||
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
||||
[SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version,
|
||||
[SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version,
|
||||
[SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version,
|
||||
});
|
||||
|
||||
await saveMeta(storagePath, withoutAnalysisFeature(meta!, SPRING_BEAN_INVENTORY_FEATURE.id));
|
||||
|
|
@ -819,12 +822,45 @@ describe('runFullAnalysis — incremental orchestration', () => {
|
|||
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
||||
[SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version,
|
||||
[SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version,
|
||||
[SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
it('rebuilds a JVM index when the registered Spring vendor prefixes change', async () => {
|
||||
const repo = await setupSpringBeanIncrementalRepo();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Win');
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
||||
const { storagePath } = getStoragePaths(repo.dbPath);
|
||||
expect((await loadMeta(storagePath))?.springVendorPrefixes).toBe(springVendorPrefixesKey());
|
||||
|
||||
vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Acme,Win');
|
||||
const logs: string[] = [];
|
||||
const rebuilt = await runFullAnalysis(
|
||||
repo.dbPath,
|
||||
{ skipAgentsMd: true },
|
||||
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
||||
);
|
||||
|
||||
expect(rebuilt.alreadyUpToDate).toBeUndefined();
|
||||
expect(logs.join('\n')).toContain('Spring vendor mapping prefixes changed');
|
||||
expect((await loadMeta(storagePath))?.springVendorPrefixes).toBe(springVendorPrefixesKey());
|
||||
|
||||
const steady = await runFullAnalysis(
|
||||
repo.dbPath,
|
||||
{ skipAgentsMd: true },
|
||||
{ onProgress: () => {} },
|
||||
);
|
||||
expect(steady.alreadyUpToDate).toBe(true);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
it('a config-only index missing Spring config evidence rebuilds and restores the scoped stamp', async () => {
|
||||
const repo = await setupSpringConfigIncrementalRepo();
|
||||
try {
|
||||
|
|
@ -927,6 +963,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
|
|||
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
||||
[SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version,
|
||||
[SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version,
|
||||
[SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* The Kotlin grammar is optional. Importing the extractor itself must not load
|
||||
* that grammar; only this guarded test setup does.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js';
|
||||
import { extractKotlinSpringRoutes } from '../../src/core/ingestion/route-extractors/kotlin-spring.js';
|
||||
|
|
@ -29,16 +29,23 @@ if (Kotlin) parser.setLanguage(Kotlin as Parser.Language);
|
|||
const parse = (source: string): Parser.Tree => parser.parse(source);
|
||||
const describeKotlin = Kotlin ? describe : describe.skip;
|
||||
|
||||
function constantsOf(files: Record<string, string>): RepoConstants {
|
||||
return new Map(
|
||||
Object.entries(files).map(([filePath, source]) => [
|
||||
filePath,
|
||||
extractKotlinModuleConstants(parse(source)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describeKotlin('extractKotlinSpringRoutes', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Win');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function constantsOf(files: Record<string, string>): RepoConstants {
|
||||
return new Map(
|
||||
Object.entries(files).map(([filePath, source]) => [
|
||||
filePath,
|
||||
extractKotlinModuleConstants(parse(source)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
it('extracts direct RestController functions with independent class prefixes and handlers', () => {
|
||||
const routes = extractKotlinSpringRoutes(
|
||||
parse(`
|
||||
|
|
@ -627,4 +634,134 @@ class PetsController {
|
|||
|
||||
expect(ingestion).toEqual(group);
|
||||
});
|
||||
|
||||
it('resolves vendor-derived mapping aliases like Java (WinGetMapping / WinRequestMapping)', () => {
|
||||
expect(KOTLIN_HTTP_PLUGIN).not.toBeNull();
|
||||
if (!KOTLIN_HTTP_PLUGIN) throw new Error('expected Kotlin HTTP plugin');
|
||||
const source = `
|
||||
@RestController
|
||||
@WinRequestMapping("/vendor")
|
||||
class VendorController {
|
||||
@WinGetMapping("/users")
|
||||
fun users(): String = "ok"
|
||||
}
|
||||
`;
|
||||
const tree = parse(source);
|
||||
const ingestion = extractKotlinSpringRoutes(tree, 'VendorController.kt');
|
||||
expect(ingestion).toHaveLength(1);
|
||||
expect(ingestion[0]?.httpMethod).toBe('GET');
|
||||
expect(ingestion[0]?.prefix).toBe('/vendor');
|
||||
expect(ingestion[0]?.routePath).toBe('/users');
|
||||
|
||||
const group = KOTLIN_HTTP_PLUGIN.scan(tree, undefined, 'VendorController.kt').filter(
|
||||
(detection) => detection.role === 'provider',
|
||||
);
|
||||
expect(group).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ method: 'GET', path: '/vendor/users' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes Kotlin method arrays for aliased RequestMapping annotations', () => {
|
||||
expect(KOTLIN_HTTP_PLUGIN).not.toBeNull();
|
||||
if (!KOTLIN_HTTP_PLUGIN) throw new Error('expected Kotlin HTTP plugin');
|
||||
const source = `
|
||||
@RestController
|
||||
@WinRequestMapping("/vendor")
|
||||
class VendorController {
|
||||
@WinRequestMapping(path = "/inspect", method = [RequestMethod.GET, RequestMethod.HEAD])
|
||||
fun inspect(): String = "ok"
|
||||
}
|
||||
`;
|
||||
const tree = parse(source);
|
||||
const ingestion = new Set(
|
||||
extractKotlinSpringRoutes(tree, 'VendorController.kt').map(
|
||||
(route) => `${route.httpMethod} ${joinPath(route.prefix ?? '', route.routePath)}`,
|
||||
),
|
||||
);
|
||||
const group = new Set(
|
||||
KOTLIN_HTTP_PLUGIN.scan(tree, undefined, 'VendorController.kt')
|
||||
.filter((detection) => detection.role === 'provider')
|
||||
.map((detection) => `${detection.method} ${detection.path}`),
|
||||
);
|
||||
|
||||
expect(ingestion).toEqual(new Set(['GET /vendor/inspect', 'HEAD /vendor/inspect']));
|
||||
expect(group).toEqual(ingestion);
|
||||
});
|
||||
|
||||
it('applies aliased class-level Kotlin method arrays to handler routes', () => {
|
||||
expect(KOTLIN_HTTP_PLUGIN).not.toBeNull();
|
||||
if (!KOTLIN_HTTP_PLUGIN) throw new Error('expected Kotlin HTTP plugin');
|
||||
const tree = parse(`
|
||||
@RestController
|
||||
@WinRequestMapping(path = "/vendor", method = [RequestMethod.GET, RequestMethod.HEAD])
|
||||
class VendorController {
|
||||
@WinRequestMapping("/inspect")
|
||||
fun inspect(): String = "ok"
|
||||
}
|
||||
`);
|
||||
const ingestion = new Set(
|
||||
extractKotlinSpringRoutes(tree, 'VendorController.kt').map(
|
||||
(route) => `${route.httpMethod} ${joinPath(route.prefix ?? '', route.routePath)}`,
|
||||
),
|
||||
);
|
||||
const group = new Set(
|
||||
KOTLIN_HTTP_PLUGIN.scan(tree, undefined, 'VendorController.kt')
|
||||
.filter((detection) => detection.role === 'provider')
|
||||
.map((detection) => `${detection.method} ${detection.path}`),
|
||||
);
|
||||
|
||||
expect(ingestion).toEqual(new Set(['GET /vendor/inspect', 'HEAD /vendor/inspect']));
|
||||
expect(group).toEqual(ingestion);
|
||||
});
|
||||
|
||||
it('keeps aliased class method constraints in inherited group contracts', () => {
|
||||
expect(KOTLIN_HTTP_PLUGIN?.scanProject).toBeDefined();
|
||||
if (!KOTLIN_HTTP_PLUGIN?.scanProject) throw new Error('expected Kotlin project scanner');
|
||||
const tree = parse(`
|
||||
@WinRequestMapping(path = "/contract", method = [RequestMethod.GET])
|
||||
interface Contract {
|
||||
@WinRequestMapping(path = "/items", method = [RequestMethod.GET, RequestMethod.POST])
|
||||
fun inspect(): String
|
||||
}
|
||||
|
||||
@RestController
|
||||
@WinRequestMapping("/impl")
|
||||
class VendorController : Contract {
|
||||
override fun inspect(): String = "ok"
|
||||
}
|
||||
`);
|
||||
|
||||
const detections = KOTLIN_HTTP_PLUGIN.scanProject([
|
||||
{ filePath: 'VendorController.kt', tree },
|
||||
]).flatMap((file) => file.detections);
|
||||
|
||||
expect(detections).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'provider',
|
||||
method: 'GET',
|
||||
path: '/impl/contract/items',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not treat unregistered suffix annotations as Kotlin routes', () => {
|
||||
const source = `
|
||||
@RestController
|
||||
class AuditController {
|
||||
@AuditPostMapping("/audit")
|
||||
fun audit(): String = "x"
|
||||
|
||||
@AuditRequestMapping(path = "/request", method = [RequestMethod.POST])
|
||||
fun request(): String = "x"
|
||||
}
|
||||
`;
|
||||
const tree = parse(source);
|
||||
expect(extractKotlinSpringRoutes(tree, 'AuditController.kt')).toHaveLength(0);
|
||||
expect(KOTLIN_HTTP_PLUGIN).not.toBeNull();
|
||||
expect(
|
||||
KOTLIN_HTTP_PLUGIN?.scan(tree, undefined, 'AuditController.kt').filter(
|
||||
(detection) => detection.role === 'provider',
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
297
gitnexus/test/unit/spring-vendor-annotation-alias.test.ts
Normal file
297
gitnexus/test/unit/spring-vendor-annotation-alias.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue