mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-09 22:33:39 +00:00
Merge branch 'main' into feat/zig-language-support
This commit is contained in:
commit
dd2977aefe
18 changed files with 1297 additions and 141 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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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('*');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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