Merge branch 'main' into feat/zig-language-support

This commit is contained in:
Navid EMAD 2026-09-02 23:40:06 +02:00 committed by GitHub
commit dd2977aefe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1297 additions and 141 deletions

View 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;

View file

@ -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`.

View file

@ -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.

View file

@ -13,6 +13,7 @@ import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js'
import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js';
import {
DATA_ROUTE_TABLE_SOURCE,
propertyName,
scanDataRouteTables,
} from '../../../ingestion/route-extractors/data-route-table.js';
import { extractNestRoutes } from '../../../ingestion/route-extractors/nest.js';
@ -152,6 +153,37 @@ const AXIOS_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
`,
};
// ─── Consumer: wrapped client X.request({ url, method }) ────────────
// Enterprise wrapper shape: an axios instance (or a named request helper)
// re-exported under a local name — `httpClient.request({ url, method })`
// from `@winex-plugin/win-request`, `$http.request(...)`. Generic names
// like `api` need axios.create/import proof (`isHttpClientRef`); spelling
// alone is too common (graphql-request helpers, domain `api` objects).
// The member property is `request` (not an HTTP verb), so this cannot
// collide with the Express provider pattern (`router.get`) or the axios
// member form (`axios.get`). Option keys are resolved programmatically,
// same as the jQuery ajax / axios object forms.
//
// The query captures the receiver so scan can reject unrelated
// `.request({ url })` APIs (`cy.request`, `queue.request`).
const REQUEST_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (member_expression
object: (_) @obj
property: (property_identifier) @fn (#eq? @fn "request"))
arguments: (arguments . (object) @options))
`,
};
/**
* Receivers admitted as wrapped HTTP clients without axios.create proof.
* Spelling-only: the last identifier in `obj.text` (`this.$http` `$http`).
* Keep this set small every extra name is a false-positive surface.
*/
const WRAPPED_REQUEST_RECEIVERS = new Set(['httpClient', '$http']);
interface NodePatternBundle {
express: CompiledPatterns<Record<string, never>>;
fetchNoOptions: CompiledPatterns<Record<string, never>>;
@ -160,6 +192,7 @@ interface NodePatternBundle {
jqueryShorthand: CompiledPatterns<Record<string, never>>;
jqueryAjax: CompiledPatterns<Record<string, never>>;
axiosObject: CompiledPatterns<Record<string, never>>;
requestObject: CompiledPatterns<Record<string, never>>;
}
function compileBundle(language: unknown, name: string): NodePatternBundle {
@ -177,6 +210,7 @@ function compileBundle(language: unknown, name: string): NodePatternBundle {
jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'),
jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'),
axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'),
requestObject: mk(REQUEST_OBJECT_SPEC, 'request-object'),
};
}
@ -190,6 +224,7 @@ const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http');
* of `keyNames`. Returns null when no matching pair is present or the
* value is not a string literal. Used by the jQuery ajax / axios object
* consumers to resolve `url` / `method` / `type` keys in any order.
* Keys use shared `propertyName` so quoted `"method"` matches `method`.
*/
function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string[]): string | null {
for (let i = 0; i < objectNode.namedChildCount; i++) {
@ -198,7 +233,8 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string
const keyNode = pair.childForFieldName('key');
const valueNode = pair.childForFieldName('value');
if (!keyNode || !valueNode) continue;
if (!keyNames.includes(keyNode.text)) continue;
const key = propertyName(keyNode);
if (key === null || !keyNames.includes(key)) continue;
if (valueNode.type !== 'string' && valueNode.type !== 'template_string') continue;
const lit = unquoteLiteral(valueNode.text);
if (lit !== null) return lit;
@ -206,6 +242,80 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string
return null;
}
/**
* Verb for wrapped `X.request({ url, method|type })`. Absent key GET
* (same default as fetch-without-options / jQuery ajax). Present but not a
* string/template, supplied only via object spread, or later overwritten by
* a duplicate key / spread `*` so matching can still link without pinning GET.
* Later properties win, matching JavaScript object-literal evaluation.
*/
function readRequestMethod(
objectNode: Parser.SyntaxNode,
keyNames: readonly string[] = ['method', 'type'],
): string {
type Verb = { kind: 'absent' } | { kind: 'literal'; value: string } | { kind: 'unknown' };
let last: Verb = { kind: 'absent' };
for (let i = 0; i < objectNode.namedChildCount; i++) {
const child = objectNode.namedChild(i);
if (!child) continue;
if (child.type === 'spread_element') {
last = { kind: 'unknown' };
continue;
}
if (
child.type === 'shorthand_property_identifier' ||
child.type === 'shorthand_property_identifier_pattern'
) {
if (keyNames.includes(child.text)) last = { kind: 'unknown' };
continue;
}
if (child.type !== 'pair') continue;
const keyNode = child.childForFieldName('key');
const valueNode = child.childForFieldName('value');
if (!keyNode) continue;
const key = propertyName(keyNode);
if (key === null || !keyNames.includes(key)) continue;
if (!valueNode || (valueNode.type !== 'string' && valueNode.type !== 'template_string')) {
last = { kind: 'unknown' };
continue;
}
const lit = unquoteLiteral(valueNode.text);
if (lit === null || lit.includes('${')) {
last = { kind: 'unknown' };
continue;
}
last = { kind: 'literal', value: lit };
}
if (last.kind === 'literal') return last.value.toUpperCase();
if (last.kind === 'unknown') return '*';
return 'GET';
}
function wrappedRequestReceiverName(receiver: string): string {
const parts = receiver.split('.');
return parts[parts.length - 1] ?? receiver;
}
/** Axios module / axios.create instance, or a registered wrapper identifier. */
function isAdmittedWrappedRequestReceiver(
receiver: string,
fileKey: string | undefined,
facts: JsRepoFacts | null,
): boolean {
if (WRAPPED_REQUEST_RECEIVERS.has(wrappedRequestReceiverName(receiver))) return true;
try {
const isModule =
facts === null || fileKey === undefined
? receiver === 'axios'
: isAxiosNamespace(fileKey, receiver, facts);
if (isModule) return true;
if (!facts || fileKey === undefined) return false;
return isHttpClientRef(fileKey, receiver, facts);
} catch {
return false;
}
}
/**
* Map each named import's LOCAL binding to its DECLARED export name and source
* module, by walking the file's `import { x as y } from 'm'` statements. Lets
@ -390,9 +500,10 @@ function resolveFactsFor(
*
* `/{param}` matches every one-segment provider route in the group, and
* `matching.exclude_links_param_only_paths` defaults to `false`. A path whose
* leading term is an unresolved placeholder is refused for the same reason
* nothing pins where it starts. (`resolveJsPathExpression` already refuses those
* it folded itself; this also covers the literal fallback below.)
* leading term is an unresolved placeholder is refused unless the next
* character is `/` that is the gateway-prefix shape
* `` `${serviceClient}/api/v1/x` `` that `stripLeadingTemplatePrefix` keeps.
* Bare `{param}` and `{param}api/x` stay rejected: nothing pins a route.
*/
function looksLikeHttpPath(path: string): boolean {
if (path === '') return false;
@ -404,7 +515,7 @@ function looksLikeHttpPath(path: string): boolean {
// unresolved term happened to contain a space.
const shape = path.replace(/\$\{[^}]+\}/g, '{param}');
if (/\s/.test(shape)) return false;
if (shape.startsWith('{param}')) return false;
if (shape.startsWith('{param}') && !shape.startsWith('{param}/')) return false;
// An all-digit string is a path only when it is written as one. A leading
// slash is that evidence: `client.get('/123')` is a route whose segment the
// consumer normalizer reads as `{param}`, while a bare `"5000"` folded out of
@ -672,8 +783,7 @@ function scanBundle(
if (!optionsNode) continue;
const path = readStringProp(optionsNode, ['url']);
if (path === null) continue;
const rawMethod = readStringProp(optionsNode, ['method']);
const method = (rawMethod ?? 'GET').toUpperCase();
const method = readRequestMethod(optionsNode, ['method']);
out.push({
role: 'consumer',
framework: 'axios',
@ -685,6 +795,37 @@ function scanBundle(
});
}
// Consumer: wrapped client `X.request({ url, method })` — the shared
// enterprise axios-instance shape (`httpClient.request` from
// win-request and friends). Emit the raw url (templates intact) so
// shared `normalizeConsumerPath` can strip a leading `${…}` gateway
// prefix and fold mid/tail interpolations to `{param}`. A plugin-side
// longest-slash-segment reducer would truncate those mid-templates
// before the shared normalizer ever saw them. This scan drops only
// static relative urls (no `${`, no leading `/`, not `https?://`).
// That filter is request-wrapper-specific: fetch/axios member forms
// already admit absolute urls and leave host stripping to
// `normalizeConsumerPath`.
for (const match of runCompiledPatterns(bundle.requestObject, tree)) {
const optionsNode = match.captures.options;
const objNode = match.captures.obj;
if (!optionsNode || !objNode) continue;
if (!isAdmittedWrappedRequestReceiver(objNode.text, fileKey, facts)) continue;
const rawUrl = readStringProp(optionsNode, ['url']);
if (rawUrl === null) continue;
const url = rawUrl.trim();
if (!url.includes('${') && !url.startsWith('/') && !/^https?:\/\//i.test(url)) continue;
out.push({
role: 'consumer',
framework: 'request',
method: readRequestMethod(optionsNode),
path: url,
name: null,
line: optionsNode.startPosition.row + 1,
confidence: 0.65,
});
}
for (const route of scanDataRouteTables(tree)) {
const imported =
route.handlerLocalName === undefined ? undefined : importMap.get(route.handlerLocalName);

View file

@ -292,13 +292,57 @@ export function normalizeHttpPath(p: string): string {
}
/**
* Consumer-side normalization is more aggressive:
* - template literals (`${x}`) `{param}`
* - strip protocol + host if the URL is absolute
* - numeric segments `{param}` (so `/api/orders/42` `/api/orders/{param}`)
* Strip LEADING template interpolations from a consumer url as gateway/host
* bindings the enterprise wrapper shape `` `${serviceClient}/api/v1/x` ``
* where `${serviceClient}` selects the gateway service, not a route segment.
* This is consumer-path framework semantics (mirrors how an absolute
* `https://host/path` url keeps only its path), so it lives here rather than
* in any one language plugin:
* - `` `${c}/api/x` `` `/api/x` (clean prefix; `${c}${d}/api/x` `/api/x`)
* - `` `${c}/api/x/${id}` `` `/api/x/${id}` (mid/tail interpolations are left for the `{param}` pass)
* Returns null when the stripped remainder is not a single-slash path:
* - remainder without `/` relative fragment (`${c}api/x`) or scheme/host
* (`${scheme}://${host}/api/x`); whether it is a path depends on
* unverifiable runtime state, so it is dropped rather than guessed at;
* - remainder starting with `//` protocol-relative (`${proto}//host/api/x`);
* keeping it would later collapse to `/host/api/x`.
*
* The `?` in a query string cannot leak into the brace matching: `${...}`
* spans are matched by braces here (before any `{param}` replacement), and
* `normalizeHttpPath` splits on `?` only after the whole `${...}` span
* including any `?` inside it has been collapsed to `{param}`. So
* `` `${c}/api/x?id=${id}` `` reduces to `/api/x` on both orderings.
*/
function normalizeConsumerPath(url: string): string {
const templated = url.replace(/\$\{[^}]+\}/g, '{param}').trim();
function stripLeadingTemplatePrefix(url: string): string | null {
if (!url.startsWith('${')) return url;
const rest = url.replace(/^(?:\$\{[^}]*\})+/, '');
return rest.startsWith('/') && !rest.startsWith('//') ? rest : null;
}
/**
* Placeholder substituted for `${...}` before WHATWG `URL` parsing so the
* parser cannot percent-encode our own `{param}` markers. A genuine encoded
* segment like `%7Bfoo%7D` then survives as a literal, instead of being
* rewritten into braces and folded into `{param}`.
*
* Private-use U+E000 cannot appear in a real URL path, so a literal
* `__gitnexus_http_param__` segment is not rewritten into `{param}`.
*/
const CONSUMER_PARAM_SENTINEL = '\uE000';
const CONSUMER_PARAM_SENTINEL_ENC = '%ee%80%80';
function restoreConsumerParamSentinel(pathOnly: string): string {
return pathOnly
.split(CONSUMER_PARAM_SENTINEL)
.join('{param}')
.replace(new RegExp(CONSUMER_PARAM_SENTINEL_ENC, 'gi'), '{param}');
}
/** Canonicalize a consumer URL after `stripLeadingTemplatePrefix`. */
function normalizeConsumerPath(url: string): string | null {
const stripped = stripLeadingTemplatePrefix(url.trim());
if (stripped === null) return null;
const templated = stripped.replace(/\$\{[^}]+\}/g, CONSUMER_PARAM_SENTINEL).trim();
let pathOnly = templated;
if (/^https?:\/\//i.test(templated)) {
try {
@ -307,6 +351,7 @@ function normalizeConsumerPath(url: string): string {
pathOnly = templated.replace(/^https?:\/\/[^/]+/i, '');
}
}
pathOnly = restoreConsumerParamSentinel(pathOnly);
const normalized = normalizeHttpPath(pathOnly || '/');
const segments = normalized
.split('/')
@ -999,6 +1044,12 @@ export class HttpRouteExtractor implements ContractExtractor {
for (const d of detections) {
if (d.role !== 'consumer') continue;
const pathNorm = normalizeConsumerPath(d.path);
// A consumer url that cannot be reduced to a routable path (e.g. a
// leading template binding that is neither a clean prefix nor a
// remainder that starts with `/`) is dropped here rather than emitted
// as a never-matching contract — same treatment the plugins give
// static relative urls at scan time.
if (pathNorm === null) continue;
// Resolve the function CONTAINING the fetch/axios call so the consumer
// contract carries a real symbolUid (was always '' — the gap that left
// cross-repo trace/impact unable to traverse HTTP links).

View file

@ -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),
};

View file

@ -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());
}

View file

@ -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;

View file

@ -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[],

View file

@ -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;
}

View file

@ -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.

View file

@ -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

View file

@ -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({

View file

@ -2764,6 +2764,12 @@ export function updateUser(id: string, data: unknown) {
export function listDefaults() {
return axios({ url: '/api/defaults' });
}
export function ignoreJqueryTypeKey() {
return axios({ url: '/api/typed', type: 'POST' });
}
export function quotedAxiosMethod() {
return axios({ url: '/api/quoted-ax', "method": 'DELETE' });
}
`,
);
@ -2773,6 +2779,111 @@ export function listDefaults() {
expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/defaults')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::POST::/api/typed')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/typed')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::DELETE::/api/quoted-ax')).toBeDefined();
});
it('extracts wrapped X.request({ url, method }) with shared prefix-strip and * verbs', async () => {
const dir = path.join(tmpDir, 'wrapped-request');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/client.ts'),
`
import axios from 'axios';
export function listOrders(httpClient, serviceClient, tenant, verb) {
return httpClient.request({ url: \`\${serviceClient}/api/v1/orders\`, method: 'post' });
}
export function getTenantOrder(httpClient, client, tenant) {
return httpClient.request({ url: \`\${client}/api/\${tenant}/orders\`, method: 'GET' });
}
export function rootPing(httpClient, client) {
return httpClient.request({ url: \`\${client}/\`, method: 'GET' });
}
export function dynamicVerb(httpClient) {
return httpClient.request({ url: '/api/orders', method: verb });
}
export function missingMethod(httpClient) {
return httpClient.request({ url: '/api/defaults' });
}
export function dropHostTemplate(httpClient, scheme, host) {
return httpClient.request({ url: \`\${scheme}://\${host}/api/x\`, method: 'GET' });
}
export function absTemplateParam(httpClient, id) {
return httpClient.request({ url: \`https://host/api/\${id}\`, method: 'GET' });
}
export function quotedMethod(httpClient) {
return httpClient.request({ url: '/api/quoted', "method": 'PATCH' });
}
export function spreadMethod(httpClient, config) {
return httpClient.request({ url: '/api/spread', ...config });
}
export function staticAbsolute(httpClient) {
return httpClient.request({ url: 'https://host/api/static', method: 'GET' });
}
export function protocolRelative(httpClient, proto) {
return httpClient.request({ url: \`\${proto}//host/api/proto\`, method: 'GET' });
}
export async function fetchGateway(gateway) {
return fetch(\`\${gateway}/api/users\`);
}
export function axiosGateway(gateway) {
return axios.get(\`\${gateway}/api/users\`);
}
export async function encodedBraceLiteral() {
return fetch('https://host/api/%7Bfoo%7D');
}
export async function literalSentinelSegment() {
return fetch('https://host/api/__gitnexus_http_param__');
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.find((c) => c.contractId === 'http::POST::/api/v1/orders')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/{param}/orders'),
).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::*::/api/orders')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/defaults')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/x')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/orders')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/{param}')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::PATCH::/api/quoted')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::*::/api/spread')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/static')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/proto')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/host/api/proto')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/%7bfoo%7d')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/__gitnexus_http_param__'),
).toBeDefined();
});
it('does not mint HTTP consumers for ungated .request({ url }) helpers', async () => {
const dir = path.join(tmpDir, 'wrapped-request-negative');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/misc.ts'),
`
export function e2e(cy) {
return cy.request({ url: '/api/v1/orders', method: 'GET' });
}
export function enqueue(queue) {
return queue.request({ url: '/admin', method: 'DELETE' });
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.find((c) => c.contractId === 'http::GET::/api/v1/orders')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::DELETE::/admin')).toBeUndefined();
});
it('does not emit consumers for unrelated object-literal calls (negative control)', async () => {

View file

@ -843,3 +843,198 @@ describe('resolveJsImport', () => {
);
});
});
describe('wrapped X.request({ url, method }) detections', () => {
it('keeps the raw template so mid-path interpolations survive to the normalizer', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse(
'httpClient.request({ url: `${client}/api/${tenant}/orders`, method: "GET" });',
),
),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.framework).toBe('request');
expect(detections[0]?.method).toBe('GET');
expect(detections[0]?.path).toBe('${client}/api/${tenant}/orders');
});
it('emits * when method is present but not a literal', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse('httpClient.request({ url: "/api/orders", method: verb });'),
),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.method).toBe('*');
});
it('uses the last duplicate method key, matching JS evaluation', () => {
const dynamicLast = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: '/api/orders', method: 'GET', method: verb });"),
),
);
expect(dynamicLast[0]?.method).toBe('*');
const literalLast = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: '/api/orders', method: verb, method: 'POST' });"),
),
);
expect(literalLast[0]?.method).toBe('POST');
});
it('defaults to GET only when method/type is absent', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(jsParser.parse('httpClient.request({ url: "/api/orders" });')),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.method).toBe('GET');
});
it('drops a static relative url at scan time', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: 'api/orders', method: 'GET' });"),
),
);
expect(detections).toHaveLength(0);
});
it('does not treat cy.request or queue.request as HTTP consumers', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse(`
cy.request({ url: '/api/v1/orders', method: 'GET' });
queue.request({ url: '/admin', method: 'DELETE' });
`),
),
);
expect(detections).toHaveLength(0);
});
it('ignores a config object that is not the first request argument', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request('/actual', { url: '/metadata', method: 'GET' });"),
),
);
expect(detections).toHaveLength(0);
});
it('admits $http by spelling but not a bare api without axios proof', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse(`
$http.request({ url: '/api/orders', method: 'GET' });
api.request({ url: '/api/users', method: 'POST' });
`),
),
);
expect(detections).toEqual([
expect.objectContaining({ method: 'GET', path: '/api/orders', framework: 'request' }),
]);
});
it('reads quoted method/type keys and type: as the verb', () => {
const quoted = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse('httpClient.request({ url: "/api/orders", "method": "POST" });'),
),
);
expect(quoted[0]?.method).toBe('POST');
const typed = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: '/api/items', type: 'PUT' });"),
),
);
expect(typed[0]?.method).toBe('PUT');
});
it('emits * when method may arrive via object spread', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse('httpClient.request({ url: "/api/orders", ...config });'),
),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.method).toBe('*');
});
it('keeps static absolute wrapped-request urls for host stripping', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: 'https://host/api/x', method: 'GET' });"),
),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.path).toBe('https://host/api/x');
});
it('admits axios.create instances calling .request', () => {
const detections = consumers(
scanRepo(
{
'src/lib/client.ts': `
import axios from 'axios';
export const api = axios.create({ baseURL: '/' });
`,
'src/api/orders.ts': `
import { api } from '../lib/client';
export const create = () => api.request({ url: '/api/orders', method: 'POST' });
`,
},
'src/api/orders.ts',
),
);
expect(detections).toContainEqual(
expect.objectContaining({ role: 'consumer', method: 'POST', path: '/api/orders' }),
);
});
it('admits member-verb calls with a gateway-prefixed template', () => {
const detections = consumers(
scanRepo(
{
'src/lib/client.ts': `
import axios from 'axios';
export default axios.create({ baseURL: '/' });
`,
'src/api/users.ts': `
import api from '../lib/client';
export const list = (gateway: string) => api.get(\`\${gateway}/api/v1/users\`);
export const bare = (id: string) => api.get(\`\${id}\`);
export const glue = (c: string) => api.get(\`\${c}api/x\`);
`,
},
'src/api/users.ts',
),
);
expect(detections.map((d) => d.path)).toEqual(['${gateway}/api/v1/users']);
});
it('trims whitespace-prefixed absolute paths at scan time', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: ' /api/orders', method: 'GET' });"),
),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.path).toBe('/api/orders');
});
it('emits * for interpolated method templates and shorthand method keys', () => {
const interpolated = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse('httpClient.request({ url: "/api/orders", method: `${verb}` });'),
),
);
expect(interpolated[0]?.method).toBe('*');
const shorthand = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: '/api/orders', method });"),
),
);
expect(shorthand[0]?.method).toBe('*');
});
});

View file

@ -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();

View file

@ -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([]);
});
});

View 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();
}
});
});