gitnexus 1.6.11-rc.36 → 1.6.11-rc.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +6 -0
  2. package/dist/cli/analyze-options.d.ts +7 -0
  3. package/dist/cli/analyze-watch.js +5 -0
  4. package/dist/cli/analyze.js +11 -0
  5. package/dist/cli/index.js +2 -0
  6. package/dist/core/analysis-feature-registry.d.ts +2 -0
  7. package/dist/core/analysis-feature-registry.js +15 -0
  8. package/dist/core/group/extractors/http-patterns/java.js +4 -4
  9. package/dist/core/group/extractors/http-patterns/kotlin.js +84 -31
  10. package/dist/core/group/extractors/http-patterns/node.js +145 -8
  11. package/dist/core/group/extractors/http-route-extractor.js +56 -5
  12. package/dist/core/ingestion/asyncapi/document.d.ts +226 -0
  13. package/dist/core/ingestion/asyncapi/document.js +758 -0
  14. package/dist/core/ingestion/asyncapi/protocol.d.ts +79 -0
  15. package/dist/core/ingestion/asyncapi/protocol.js +203 -0
  16. package/dist/core/ingestion/frameworks/spring/analysis-features.d.ts +5 -0
  17. package/dist/core/ingestion/frameworks/spring/analysis-features.js +9 -0
  18. package/dist/core/ingestion/frameworks/spring/destinations.d.ts +25 -0
  19. package/dist/core/ingestion/frameworks/spring/vendor-prefixes.d.ts +7 -0
  20. package/dist/core/ingestion/frameworks/spring/vendor-prefixes.js +22 -0
  21. package/dist/core/ingestion/pipeline-phases/spring-destinations.d.ts +43 -2
  22. package/dist/core/ingestion/pipeline-phases/spring-destinations.js +202 -3
  23. package/dist/core/ingestion/pipeline.d.ts +14 -0
  24. package/dist/core/ingestion/route-extractors/kotlin-spring.js +7 -14
  25. package/dist/core/ingestion/route-extractors/spring-shared.d.ts +30 -1
  26. package/dist/core/ingestion/route-extractors/spring-shared.js +77 -9
  27. package/dist/core/ingestion/route-extractors/spring.js +9 -5
  28. package/dist/core/run-analyze.d.ts +5 -0
  29. package/dist/core/run-analyze.js +49 -13
  30. package/dist/mcp/resources.js +7 -0
  31. package/dist/server/analyze-launch.d.ts +1 -0
  32. package/dist/server/analyze-launch.js +1 -0
  33. package/dist/server/api.js +7 -1
  34. package/dist/storage/repo-meta.d.ts +22 -0
  35. package/package.json +1 -1
  36. package/skills/gitnexus-cli.md +1 -0
package/README.md CHANGED
@@ -349,6 +349,12 @@ anchors are deliberately omitted. Add common infrastructure fields such as `/hea
349
349
 
350
350
  `--spring-actuator` is explicitly opt-in. The path may be a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. Runtime mappings and beans confirm matching static nodes; conditions and configuration property keys enrich existing evidence, with conservative runtime-only nodes added when no match exists. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Enabled runs always rebuild because runtime snapshots are external to git freshness; omitting the option later rebuilds once to remove runtime evidence. Project config can set the same path with `springActuator` in `.gitnexusrc`.
351
351
 
352
+ `--asyncapi-spec` is explicitly opt-in and accepts a directory of AsyncAPI documents or a single document; the path is resolved against the repository root, so a committed `docs/asyncapi` and an absolute cache written by something else both work. Each `operations[]` entry of an **AsyncAPI 3.x** document can contribute a `Destination` node keyed by broker and address, with `action: send` emitting `PUBLISHES_TO` and `action: receive` emitting `CONSUMES_FROM`, so a document and source code that name one address on one broker land on the same node. Edges start at the document, not at a callable — a document states that the service talks to an address, not which method does — and no address a document names is ever attached to an unresolved source site.
353
+
354
+ An operation must name a protocol, either through its own `bindings` or through the `servers[].protocol` of the servers its channel resolves to (a channel that lists no `servers` resolves to all of them); operations that name none are refused, as are operations whose two readings name different brokers, and channels that inherit a multi-protocol server set without choosing. HTTP and WebSocket documents are refused for destination minting: there the host rather than the address names the place, and an HTTP endpoint is already modelled as a `Route`. A parameterized address — a channel declaring `parameters`, or an address containing `{` — is refused rather than keyed: two services publishing `{env}.orders` share a pattern, not a queue. AsyncAPI **2.x is refused** under its own counted reason and never mapped, because its `publish`/`subscribe` are inverted relative to 3.x `send`/`receive` and a naive mapping would reverse the async graph while leaving it connected. Every refusal is counted, and a configured path that yields nothing is reported rather than passed over in silence.
355
+
356
+ Like Actuator snapshots, documents are external to git freshness — replacing one moves no commit and dirties no file — so an enabled run always rebuilds, and the first later run without the option rebuilds once to remove document-derived evidence. There is no glob-based auto-discovery, and the option is unsupported with `--watch`.
357
+
352
358
  > **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
353
359
 
354
360
  ## Remote Embeddings
@@ -129,6 +129,13 @@ export interface AnalyzeOptions {
129
129
  * bundle or a directory containing endpoint JSON files. Disabled by default.
130
130
  */
131
131
  springActuator?: string;
132
+ /**
133
+ * Explicit local AsyncAPI 3.x document input. Accepts a directory of
134
+ * documents or a single document, resolved against the repository root so an
135
+ * out-of-band cache and a committed directory are equally usable. Disabled by
136
+ * default.
137
+ */
138
+ asyncapiSpec?: string;
132
139
  /** OpenAI-compatible embeddings base URL (incl. /v1). Overrides GITNEXUS_EMBEDDING_URL. */
133
140
  embeddingBaseUrl?: string;
134
141
  /** Embedding model name. Overrides GITNEXUS_EMBEDDING_MODEL. */
@@ -76,6 +76,11 @@ export async function resolveWatchOptions(repoPath, cli, baseline, reportIgnored
76
76
  ['--index-only', cli.indexOnly],
77
77
  ['--skip-git', cli.skipGit],
78
78
  ['--spring-actuator', cli.springActuator],
79
+ // Rejected under --watch for the same reason as --spring-actuator: the
80
+ // watcher reacts to source changes, and nothing watches an out-of-band
81
+ // document directory. Honouring the flag here would read the documents once
82
+ // and then quietly serve a stale answer for the rest of the session.
83
+ ['--asyncapi-spec', cli.asyncapiSpec],
79
84
  ['walCheckpointThreshold', cli.walCheckpointThreshold],
80
85
  ['embeddingThreads', cli.embeddingThreads],
81
86
  ['embeddingBatchSize', cli.embeddingBatchSize],
@@ -786,6 +786,16 @@ const analyzeCommandImpl = async (inputPath, cliOptions, runnerIdentityAtBootstr
786
786
  !setPositiveEnv('--embedding-sub-batch-size', 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', options.embeddingSubBatchSize)) {
787
787
  return;
788
788
  }
789
+ // An empty value resolves to the repository root, so `--asyncapi-spec ""`
790
+ // walks the whole tree — defeating the module's own rule that there is no
791
+ // glob-based auto-discovery, and spending the walk budget on `node_modules`.
792
+ // The HTTP entry point already rejects exactly this value; two doors onto one
793
+ // option must not hold different rules.
794
+ if (options.asyncapiSpec !== undefined && options.asyncapiSpec.trim() === '') {
795
+ cliError(' --asyncapi-spec must be a non-empty path.\n');
796
+ process.exitCode = 1;
797
+ return;
798
+ }
789
799
  if (options.embeddingDevice) {
790
800
  const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
791
801
  if (!allowed.has(options.embeddingDevice)) {
@@ -1115,6 +1125,7 @@ const analyzeCommandImpl = async (inputPath, cliOptions, runnerIdentityAtBootstr
1115
1125
  // forwarded to the routes phase consumer scan.
1116
1126
  fetchWrappers: options.fetchWrappers,
1117
1127
  springActuatorPath: options.springActuator,
1128
+ asyncApiSpecPath: options.asyncapiSpec,
1118
1129
  // The CLI always process.exit()s after this returns (success path at the
1119
1130
  // end of analyzeCommandImpl, error/interrupt paths via process.exit too),
1120
1131
  // so the finalize close skips the native conn/db close — it can double-free
package/dist/cli/index.js CHANGED
@@ -85,6 +85,8 @@ program
85
85
  .option('--workers <n>', 'Parse worker pool size (>=1). Default: cores-1 capped at 16, auto-sized to the repo.')
86
86
  .option('--spring-actuator <path>', 'Import local Spring Boot Actuator JSON snapshots (mappings, beans, conditions, ' +
87
87
  'configprops, env). Explicit opt-in; disabled by default.')
88
+ .option('--asyncapi-spec <path>', 'Read AsyncAPI 3.x documents from this directory or file and resolve broker ' +
89
+ 'addresses from them. Explicit opt-in; disabled by default.')
88
90
  .option('--embedding-threads <n>', 'Limit local ONNX embedding CPU threads')
89
91
  .option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
90
92
  .option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
@@ -0,0 +1,2 @@
1
+ /** Production registry of independently versioned analysis capabilities. */
2
+ export declare const ANALYSIS_FEATURES: readonly [import("./analysis-features.js").AnalysisFeatureDescriptor, import("./analysis-features.js").AnalysisFeatureDescriptor, import("./analysis-features.js").AnalysisFeatureDescriptor, import("./analysis-features.js").AnalysisFeatureDescriptor, import("./analysis-features.js").AnalysisFeatureDescriptor, import("./analysis-features.js").AnalysisFeatureDescriptor, import("./analysis-features.js").AnalysisFeatureDescriptor, import("./analysis-features.js").AnalysisFeatureDescriptor, import("./analysis-features.js").AnalysisFeatureDescriptor];
@@ -0,0 +1,15 @@
1
+ import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from './analysis-features.js';
2
+ 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';
3
+ import { JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, } from './ingestion/languages/java/analysis-features.js';
4
+ /** Production registry of independently versioned analysis capabilities. */
5
+ export const ANALYSIS_FEATURES = [
6
+ CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
7
+ SPRING_AOP_FEATURE,
8
+ SPRING_BEAN_INVENTORY_FEATURE,
9
+ SPRING_CONDITIONALS_FEATURE,
10
+ SPRING_NON_HTTP_HANDLERS_FEATURE,
11
+ SPRING_ROUTE_BINDINGS_FEATURE,
12
+ SPRING_CONFIG_BINDINGS_FEATURE,
13
+ JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
14
+ JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
15
+ ];
@@ -1,6 +1,6 @@
1
1
  import Java from 'tree-sitter-java';
2
2
  import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
3
- import { springAnnotationHttpMethods, intersectSpringHttpMethods, isRouteMemberKey, findEnclosingClass, joinPath, } from '../../../ingestion/route-extractors/spring-shared.js';
3
+ import { springAnnotationHttpMethods, intersectSpringHttpMethods, isRouteMemberKey, findEnclosingClass, isClassLevelMappingAnnotation, joinPath, } from '../../../ingestion/route-extractors/spring-shared.js';
4
4
  import { REST_TEMPLATE_TO_HTTP, WEB_CLIENT_SHORT_TO_HTTP, WEB_CLIENT_LONG_VERB_RE, EXCHANGE_ANNOTATION_TO_HTTP, parseRequestLine, pushPrefix, scanSpringInheritanceProject, OPENFEIGN_FRAMEWORK, HTTP_INTERFACE_FRAMEWORK, FEIGN_CONFIDENCE, REQUEST_LINE_CONFIDENCE, EXCHANGE_CONFIDENCE, } from './spring-consumer-shared.js';
5
5
  import { expandJavaWildcardStaticImports, extractJavaModuleConstants, foldJavaOperands, isJavaConstantFile, parseJavaConstOperands, prepareJavaRouteConstants, } from '../../../ingestion/route-extractors/java-const-resolver.js';
6
6
  import { extractStaticPathExpression, inferOkHttpMethod, inferHttpClientMethod, okHttpUrlRootsAtBuilder, httpClientUriRootsAtNewBuilder, httpClientChainHasUriCall, } from './java-static-path.js';
@@ -394,12 +394,12 @@ function annotationHasRouteMember(annotation) {
394
394
  return false;
395
395
  }
396
396
  function typeRequestMethods(typeNode) {
397
- const mappings = declarationAnnotations(typeNode).filter((annotation) => simpleName(annotation.childForFieldName('name')?.text ?? '') === 'RequestMapping');
397
+ const mappings = declarationAnnotations(typeNode).filter((annotation) => isClassLevelMappingAnnotation(simpleName(annotation.childForFieldName('name')?.text ?? '')));
398
398
  if (mappings.length === 0)
399
399
  return ['*'];
400
400
  if (mappings.length !== 1)
401
401
  return [];
402
- return springAnnotationHttpMethods('RequestMapping', mappings[0].text);
402
+ return springAnnotationHttpMethods(simpleName(mappings[0].childForFieldName('name')?.text ?? 'RequestMapping'), mappings[0].text);
403
403
  }
404
404
  function hasAnnotation(node, names) {
405
405
  const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
@@ -551,7 +551,7 @@ function scanRouteAnnotations(tree) {
551
551
  }
552
552
  // Type-level (class or interface): a Spring `@RequestMapping` URL prefix, or
553
553
  // — on an interface — an OpenFeign `@FeignClient(path = "...")` prefix.
554
- if (ann === 'RequestMapping') {
554
+ if (isClassLevelMappingAnnotation(ann)) {
555
555
  if (!isRouteMemberKey(keyNode))
556
556
  continue;
557
557
  if (!valueNode) {
@@ -1,6 +1,6 @@
1
1
  import { requireVendoredGrammar } from '../../../tree-sitter/vendored-grammars.js';
2
2
  import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
3
- import { METHOD_ANNOTATION_TO_HTTP, findEnclosingClass, joinPath, } from '../../../ingestion/route-extractors/spring-shared.js';
3
+ import { findEnclosingClass, intersectSpringHttpMethods, isClassLevelMappingAnnotation, joinPath, springAnnotationHttpMethods, } from '../../../ingestion/route-extractors/spring-shared.js';
4
4
  import { buildKotlinConstantIndex, extractKotlinModuleConstants, foldKotlinOperands, isKotlinConstantFile, overlayKotlinConstantIndex, parseKotlinConstOperands, unfoldableDeclarationsOf, unquoteKotlinIdentifier, } from '../../../ingestion/route-extractors/kotlin-const-resolver.js';
5
5
  import { REST_TEMPLATE_TO_HTTP, WEB_CLIENT_SHORT_TO_HTTP, WEB_CLIENT_LONG_VERB_RE, EXCHANGE_ANNOTATION_TO_HTTP, parseRequestLine, pushPrefix, scanSpringInheritanceProject, OPENFEIGN_FRAMEWORK, HTTP_INTERFACE_FRAMEWORK, FEIGN_CONFIDENCE, REQUEST_LINE_CONFIDENCE, EXCHANGE_CONFIDENCE, } from './spring-consumer-shared.js';
6
6
  /**
@@ -392,6 +392,13 @@ function inferKotlinOkHttpMethod(urlCall) {
392
392
  }
393
393
  return name === null ? 'GET' : name.toUpperCase();
394
394
  }
395
+ function enclosingAnnotationText(node) {
396
+ for (let current = node; current; current = current.parent) {
397
+ if (current.type === 'annotation')
398
+ return current.text;
399
+ }
400
+ return node.text;
401
+ }
395
402
  /**
396
403
  * Build the plugin only if the Kotlin grammar is available. Compiling
397
404
  * the queries against a null grammar would throw at module load time
@@ -429,7 +436,7 @@ function buildKotlinPlugin(language) {
429
436
  (modifiers
430
437
  (annotation
431
438
  (constructor_invocation
432
- (user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
439
+ (user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
433
440
  (value_arguments
434
441
  (value_argument . [(string_literal) @prefix (collection_literal (string_literal) @prefix)])))))
435
442
  (type_identifier) @cls) @class
@@ -442,7 +449,7 @@ function buildKotlinPlugin(language) {
442
449
  (modifiers
443
450
  (annotation
444
451
  (constructor_invocation
445
- (user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
452
+ (user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
446
453
  (value_arguments
447
454
  (value_argument
448
455
  (simple_identifier) @key (#match? @key "^(path|value)$")
@@ -457,7 +464,7 @@ function buildKotlinPlugin(language) {
457
464
  (modifiers
458
465
  (annotation
459
466
  (constructor_invocation
460
- (user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
467
+ (user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
461
468
  (value_arguments
462
469
  (value_argument . ${arrayOfArg('@prefix')})))))
463
470
  (type_identifier) @cls) @class
@@ -470,7 +477,7 @@ function buildKotlinPlugin(language) {
470
477
  (modifiers
471
478
  (annotation
472
479
  (constructor_invocation
473
- (user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
480
+ (user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
474
481
  (value_arguments
475
482
  (value_argument
476
483
  (simple_identifier) @key (#match? @key "^(path|value)$")
@@ -495,7 +502,7 @@ function buildKotlinPlugin(language) {
495
502
  (modifiers
496
503
  (annotation
497
504
  (constructor_invocation
498
- (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
505
+ (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
499
506
  (value_arguments
500
507
  (value_argument . [(string_literal) @path (collection_literal (string_literal) @path)])))))
501
508
  (simple_identifier) @method_name) @method
@@ -508,7 +515,7 @@ function buildKotlinPlugin(language) {
508
515
  (modifiers
509
516
  (annotation
510
517
  (constructor_invocation
511
- (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
518
+ (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
512
519
  (value_arguments
513
520
  (value_argument
514
521
  (simple_identifier) @key (#match? @key "^(path|value)$")
@@ -523,7 +530,7 @@ function buildKotlinPlugin(language) {
523
530
  (modifiers
524
531
  (annotation
525
532
  (constructor_invocation
526
- (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
533
+ (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
527
534
  (value_arguments
528
535
  (value_argument . ${arrayOfArg('@path')})))))
529
536
  (simple_identifier) @method_name) @method
@@ -536,7 +543,7 @@ function buildKotlinPlugin(language) {
536
543
  (modifiers
537
544
  (annotation
538
545
  (constructor_invocation
539
- (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
546
+ (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
540
547
  (value_arguments
541
548
  (value_argument
542
549
  (simple_identifier) @key (#match? @key "^(path|value)$")
@@ -571,7 +578,7 @@ function buildKotlinPlugin(language) {
571
578
  (modifiers
572
579
  (annotation
573
580
  (constructor_invocation
574
- (user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
581
+ (user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
575
582
  (value_arguments (value_argument) @arg))))
576
583
  (type_identifier) @cls) @class
577
584
  `,
@@ -589,7 +596,7 @@ function buildKotlinPlugin(language) {
589
596
  (modifiers
590
597
  (annotation
591
598
  (constructor_invocation
592
- (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
599
+ (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
593
600
  (value_arguments (value_argument) @arg))))
594
601
  (simple_identifier) @method_name) @method
595
602
  `,
@@ -649,8 +656,11 @@ function buildKotlinPlugin(language) {
649
656
  for (const match of runCompiledPatterns(SPRING_CONST_CLASS_PREFIX_PATTERNS, tree)) {
650
657
  const argNode = match.captures.arg;
651
658
  const classNode = match.captures.class;
659
+ const annNode = match.captures.ann;
652
660
  if (!argNode || !classNode)
653
661
  continue;
662
+ if (annNode && !isClassLevelMappingAnnotation(annNode.text))
663
+ continue;
654
664
  if ((resolvedPrefixes.get(classNode.id) ?? []).length > 0)
655
665
  continue;
656
666
  const expr = kotlinRouteArgumentExpression(argNode);
@@ -1145,14 +1155,37 @@ function buildKotlinPlugin(language) {
1145
1155
  return body.namedChildren.filter((c) => c.type === 'function_declaration');
1146
1156
  };
1147
1157
  const kotlinFunctionName = (fn) => fn.namedChildren.find((c) => c.type === 'simple_identifier')?.text ?? null;
1158
+ const kotlinTypeRequestMethods = (typeNode) => {
1159
+ const modifiers = typeNode.namedChildren.find((child) => child.type === 'modifiers');
1160
+ const mappings = (modifiers?.namedChildren ?? []).filter((annotation) => {
1161
+ if (annotation.type !== 'annotation')
1162
+ return false;
1163
+ return isClassLevelMappingAnnotation(kotlinAnnotationName(annotation) ?? '');
1164
+ });
1165
+ if (mappings.length === 0)
1166
+ return ['*'];
1167
+ if (mappings.length !== 1)
1168
+ return [];
1169
+ const mapping = mappings[0];
1170
+ const mappingName = kotlinAnnotationName(mapping);
1171
+ if (!mappingName)
1172
+ return [];
1173
+ return springAnnotationHttpMethods(mappingName, mapping.text);
1174
+ };
1175
+ const kotlinClassHttpMethodsById = (tree) => new Map(tree.rootNode
1176
+ .descendantsOfType('class_declaration')
1177
+ .map((typeNode) => [typeNode.id, kotlinTypeRequestMethods(typeNode)]));
1148
1178
  const collectKotlinSpringTypes = (filePath, tree) => {
1149
1179
  // Class-level @RequestMapping prefixes (reuse the provider class-prefix query).
1150
1180
  const prefixByClassId = new Map();
1151
1181
  for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
1152
1182
  const prefixNode = match.captures.prefix;
1153
1183
  const classNode = match.captures.class;
1184
+ const annNode = match.captures.ann;
1154
1185
  if (!prefixNode || !classNode)
1155
1186
  continue;
1187
+ if (annNode && !isClassLevelMappingAnnotation(annNode.text))
1188
+ continue;
1156
1189
  // An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — unquoting
1157
1190
  // its raw text would carry the source spelling into the shared type view
1158
1191
  // as a served prefix. Refusing it here is also what lets the unfoldable
@@ -1172,6 +1205,7 @@ function buildKotlinPlugin(language) {
1172
1205
  // noise into the shared type view, so it is left out — the same skip floor
1173
1206
  // `java.ts`'s `collectSpringTypes` keeps.
1174
1207
  const routesByMethodId = new Map();
1208
+ const classHttpMethodsById = kotlinClassHttpMethodsById(tree);
1175
1209
  const unfoldablePrefixClassIds = collectUnfoldablePrefixClassIds(tree, prefixByClassId);
1176
1210
  for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
1177
1211
  const annNode = match.captures.ann;
@@ -1179,8 +1213,8 @@ function buildKotlinPlugin(language) {
1179
1213
  const methodNode = match.captures.method;
1180
1214
  if (!annNode || !pathNode || !methodNode)
1181
1215
  continue;
1182
- const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
1183
- if (!httpMethod)
1216
+ const httpMethods = springAnnotationHttpMethods(annNode.text, enclosingAnnotationText(annNode));
1217
+ if (httpMethods.length === 0)
1184
1218
  continue;
1185
1219
  const rawPath = unquoteLiteral(pathNode.text);
1186
1220
  if (rawPath === null)
@@ -1190,8 +1224,11 @@ function buildKotlinPlugin(language) {
1190
1224
  const owner = findEnclosingClass(methodNode);
1191
1225
  if (owner && unfoldablePrefixClassIds.has(owner.id))
1192
1226
  continue;
1227
+ const constrainedMethods = intersectSpringHttpMethods(owner ? (classHttpMethodsById.get(owner.id) ?? ['*']) : ['*'], httpMethods);
1193
1228
  const arr = routesByMethodId.get(methodNode.id) ?? [];
1194
- arr.push({ method: httpMethod, path: rawPath });
1229
+ for (const httpMethod of constrainedMethods) {
1230
+ arr.push({ method: httpMethod, path: rawPath });
1231
+ }
1195
1232
  routesByMethodId.set(methodNode.id, arr);
1196
1233
  }
1197
1234
  const out = [];
@@ -1319,8 +1356,11 @@ function buildKotlinPlugin(language) {
1319
1356
  for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
1320
1357
  const prefixNode = match.captures.prefix;
1321
1358
  const classNode = match.captures.class;
1359
+ const annNode = match.captures.ann;
1322
1360
  if (!prefixNode || !classNode)
1323
1361
  continue;
1362
+ if (annNode && !isClassLevelMappingAnnotation(annNode.text))
1363
+ continue;
1324
1364
  // An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — see
1325
1365
  // `isPlainStringLiteral`. Refusing it here also lets the unfoldable
1326
1366
  // analysis below mark such a class, since that skips classes whose
@@ -1366,24 +1406,27 @@ function buildKotlinPlugin(language) {
1366
1406
  // Literal and constant-valued paths are normalized into one candidate list
1367
1407
  // so both reach the same Feign/interface/prefix classification below.
1368
1408
  const methodRoutes = [];
1409
+ const classHttpMethodsById = kotlinClassHttpMethodsById(tree);
1369
1410
  for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
1370
1411
  const annNode = match.captures.ann;
1371
1412
  const pathNode = match.captures.path;
1372
1413
  const methodNode = match.captures.method;
1373
1414
  if (!annNode || !pathNode || !methodNode)
1374
1415
  continue;
1375
- const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
1376
- if (!httpMethod)
1416
+ const httpMethods = springAnnotationHttpMethods(annNode.text, enclosingAnnotationText(annNode));
1417
+ if (httpMethods.length === 0)
1377
1418
  continue;
1378
1419
  const rawPath = unquoteLiteral(pathNode.text);
1379
1420
  if (rawPath === null)
1380
1421
  continue;
1381
- methodRoutes.push({
1382
- httpMethod,
1383
- rawPath,
1384
- nameNode: match.captures.method_name,
1385
- methodNode,
1386
- });
1422
+ for (const httpMethod of httpMethods) {
1423
+ methodRoutes.push({
1424
+ httpMethod,
1425
+ rawPath,
1426
+ nameNode: match.captures.method_name,
1427
+ methodNode,
1428
+ });
1429
+ }
1387
1430
  }
1388
1431
  for (const match of runCompiledPatterns(SPRING_CONST_METHOD_ROUTE_PATTERNS, tree)) {
1389
1432
  const annNode = match.captures.ann;
@@ -1391,8 +1434,8 @@ function buildKotlinPlugin(language) {
1391
1434
  const methodNode = match.captures.method;
1392
1435
  if (!annNode || !argNode || !methodNode)
1393
1436
  continue;
1394
- const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
1395
- if (!httpMethod)
1437
+ const httpMethods = springAnnotationHttpMethods(annNode.text, enclosingAnnotationText(annNode));
1438
+ if (httpMethods.length === 0)
1396
1439
  continue;
1397
1440
  const expr = kotlinRouteArgumentExpression(argNode);
1398
1441
  if (!expr || !FOLDABLE_PATH_EXPRESSIONS.has(expr.type))
@@ -1413,14 +1456,24 @@ function buildKotlinPlugin(language) {
1413
1456
  const rawPath = foldKotlinOperands(fileKey, operands, index.repo, kotlinEnclosingTypeNames(methodNode), index);
1414
1457
  if (rawPath === null)
1415
1458
  continue;
1416
- methodRoutes.push({
1417
- httpMethod,
1418
- rawPath,
1419
- nameNode: match.captures.method_name,
1420
- methodNode,
1421
- });
1459
+ for (const httpMethod of httpMethods) {
1460
+ methodRoutes.push({
1461
+ httpMethod,
1462
+ rawPath,
1463
+ nameNode: match.captures.method_name,
1464
+ methodNode,
1465
+ });
1466
+ }
1422
1467
  }
1423
- for (const { httpMethod, rawPath, nameNode, methodNode } of methodRoutes) {
1468
+ const constrainedMethodRoutes = methodRoutes.flatMap((route) => {
1469
+ const owner = findEnclosingClass(route.methodNode);
1470
+ const classMethods = owner ? (classHttpMethodsById.get(owner.id) ?? ['*']) : ['*'];
1471
+ return intersectSpringHttpMethods(classMethods, [route.httpMethod]).map((httpMethod) => ({
1472
+ ...route,
1473
+ httpMethod,
1474
+ }));
1475
+ });
1476
+ for (const { httpMethod, rawPath, nameNode, methodNode } of constrainedMethodRoutes) {
1424
1477
  const enclosingClass = findEnclosingClass(methodNode);
1425
1478
  // A @(Get|...)Mapping inside a @FeignClient interface is an OpenFeign
1426
1479
  // consumer (a remote call), not a route this service serves.
@@ -3,7 +3,7 @@ import JavaScript from 'tree-sitter-javascript';
3
3
  import TypeScript from 'tree-sitter-typescript';
4
4
  import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
5
5
  import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js';
6
- import { DATA_ROUTE_TABLE_SOURCE, scanDataRouteTables, } from '../../../ingestion/route-extractors/data-route-table.js';
6
+ import { DATA_ROUTE_TABLE_SOURCE, propertyName, scanDataRouteTables, } from '../../../ingestion/route-extractors/data-route-table.js';
7
7
  import { extractNestRoutes } from '../../../ingestion/route-extractors/nest.js';
8
8
  import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js';
9
9
  import { buildJsRepoFacts, extractJsModuleFacts, isAxiosNamespace, isHttpClientRef, resolveJsPathExpression, } from '../../../ingestion/route-extractors/js-const-resolver.js';
@@ -123,6 +123,35 @@ const AXIOS_OBJECT_SPEC = {
123
123
  arguments: (arguments (object) @options))
124
124
  `,
125
125
  };
126
+ // ─── Consumer: wrapped client X.request({ url, method }) ────────────
127
+ // Enterprise wrapper shape: an axios instance (or a named request helper)
128
+ // re-exported under a local name — `httpClient.request({ url, method })`
129
+ // from `@winex-plugin/win-request`, `$http.request(...)`. Generic names
130
+ // like `api` need axios.create/import proof (`isHttpClientRef`); spelling
131
+ // alone is too common (graphql-request helpers, domain `api` objects).
132
+ // The member property is `request` (not an HTTP verb), so this cannot
133
+ // collide with the Express provider pattern (`router.get`) or the axios
134
+ // member form (`axios.get`). Option keys are resolved programmatically,
135
+ // same as the jQuery ajax / axios object forms.
136
+ //
137
+ // The query captures the receiver so scan can reject unrelated
138
+ // `.request({ url })` APIs (`cy.request`, `queue.request`).
139
+ const REQUEST_OBJECT_SPEC = {
140
+ meta: {},
141
+ query: `
142
+ (call_expression
143
+ function: (member_expression
144
+ object: (_) @obj
145
+ property: (property_identifier) @fn (#eq? @fn "request"))
146
+ arguments: (arguments . (object) @options))
147
+ `,
148
+ };
149
+ /**
150
+ * Receivers admitted as wrapped HTTP clients without axios.create proof.
151
+ * Spelling-only: the last identifier in `obj.text` (`this.$http` → `$http`).
152
+ * Keep this set small — every extra name is a false-positive surface.
153
+ */
154
+ const WRAPPED_REQUEST_RECEIVERS = new Set(['httpClient', '$http']);
126
155
  function compileBundle(language, name) {
127
156
  const mk = (spec, suffix) => compilePatterns({
128
157
  name: `${name}-${suffix}`,
@@ -137,6 +166,7 @@ function compileBundle(language, name) {
137
166
  jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'),
138
167
  jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'),
139
168
  axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'),
169
+ requestObject: mk(REQUEST_OBJECT_SPEC, 'request-object'),
140
170
  };
141
171
  }
142
172
  const JAVASCRIPT_BUNDLE = compileBundle(JavaScript, 'javascript-http');
@@ -148,6 +178,7 @@ const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http');
148
178
  * of `keyNames`. Returns null when no matching pair is present or the
149
179
  * value is not a string literal. Used by the jQuery ajax / axios object
150
180
  * consumers to resolve `url` / `method` / `type` keys in any order.
181
+ * Keys use shared `propertyName` so quoted `"method"` matches `method`.
151
182
  */
152
183
  function readStringProp(objectNode, keyNames) {
153
184
  for (let i = 0; i < objectNode.namedChildCount; i++) {
@@ -158,7 +189,8 @@ function readStringProp(objectNode, keyNames) {
158
189
  const valueNode = pair.childForFieldName('value');
159
190
  if (!keyNode || !valueNode)
160
191
  continue;
161
- if (!keyNames.includes(keyNode.text))
192
+ const key = propertyName(keyNode);
193
+ if (key === null || !keyNames.includes(key))
162
194
  continue;
163
195
  if (valueNode.type !== 'string' && valueNode.type !== 'template_string')
164
196
  continue;
@@ -168,6 +200,77 @@ function readStringProp(objectNode, keyNames) {
168
200
  }
169
201
  return null;
170
202
  }
203
+ /**
204
+ * Verb for wrapped `X.request({ url, method|type })`. Absent key → GET
205
+ * (same default as fetch-without-options / jQuery ajax). Present but not a
206
+ * string/template, supplied only via object spread, or later overwritten by
207
+ * a duplicate key / spread → `*` so matching can still link without pinning GET.
208
+ * Later properties win, matching JavaScript object-literal evaluation.
209
+ */
210
+ function readRequestMethod(objectNode, keyNames = ['method', 'type']) {
211
+ let last = { kind: 'absent' };
212
+ for (let i = 0; i < objectNode.namedChildCount; i++) {
213
+ const child = objectNode.namedChild(i);
214
+ if (!child)
215
+ continue;
216
+ if (child.type === 'spread_element') {
217
+ last = { kind: 'unknown' };
218
+ continue;
219
+ }
220
+ if (child.type === 'shorthand_property_identifier' ||
221
+ child.type === 'shorthand_property_identifier_pattern') {
222
+ if (keyNames.includes(child.text))
223
+ last = { kind: 'unknown' };
224
+ continue;
225
+ }
226
+ if (child.type !== 'pair')
227
+ continue;
228
+ const keyNode = child.childForFieldName('key');
229
+ const valueNode = child.childForFieldName('value');
230
+ if (!keyNode)
231
+ continue;
232
+ const key = propertyName(keyNode);
233
+ if (key === null || !keyNames.includes(key))
234
+ continue;
235
+ if (!valueNode || (valueNode.type !== 'string' && valueNode.type !== 'template_string')) {
236
+ last = { kind: 'unknown' };
237
+ continue;
238
+ }
239
+ const lit = unquoteLiteral(valueNode.text);
240
+ if (lit === null || lit.includes('${')) {
241
+ last = { kind: 'unknown' };
242
+ continue;
243
+ }
244
+ last = { kind: 'literal', value: lit };
245
+ }
246
+ if (last.kind === 'literal')
247
+ return last.value.toUpperCase();
248
+ if (last.kind === 'unknown')
249
+ return '*';
250
+ return 'GET';
251
+ }
252
+ function wrappedRequestReceiverName(receiver) {
253
+ const parts = receiver.split('.');
254
+ return parts[parts.length - 1] ?? receiver;
255
+ }
256
+ /** Axios module / axios.create instance, or a registered wrapper identifier. */
257
+ function isAdmittedWrappedRequestReceiver(receiver, fileKey, facts) {
258
+ if (WRAPPED_REQUEST_RECEIVERS.has(wrappedRequestReceiverName(receiver)))
259
+ return true;
260
+ try {
261
+ const isModule = facts === null || fileKey === undefined
262
+ ? receiver === 'axios'
263
+ : isAxiosNamespace(fileKey, receiver, facts);
264
+ if (isModule)
265
+ return true;
266
+ if (!facts || fileKey === undefined)
267
+ return false;
268
+ return isHttpClientRef(fileKey, receiver, facts);
269
+ }
270
+ catch {
271
+ return false;
272
+ }
273
+ }
171
274
  /**
172
275
  * Map each named import's LOCAL binding to its DECLARED export name and source
173
276
  * module, by walking the file's `import { x as y } from 'm'` statements. Lets
@@ -335,9 +438,10 @@ function resolveFactsFor(repoContext, fileRel) {
335
438
  *
336
439
  * `/{param}` matches every one-segment provider route in the group, and
337
440
  * `matching.exclude_links_param_only_paths` defaults to `false`. A path whose
338
- * leading term is an unresolved placeholder is refused for the same reason —
339
- * nothing pins where it starts. (`resolveJsPathExpression` already refuses those
340
- * it folded itself; this also covers the literal fallback below.)
441
+ * leading term is an unresolved placeholder is refused unless the next
442
+ * character is `/` — that is the gateway-prefix shape
443
+ * `` `${serviceClient}/api/v1/x` `` that `stripLeadingTemplatePrefix` keeps.
444
+ * Bare `{param}` and `{param}api/x` stay rejected: nothing pins a route.
341
445
  */
342
446
  function looksLikeHttpPath(path) {
343
447
  if (path === '')
@@ -352,7 +456,7 @@ function looksLikeHttpPath(path) {
352
456
  const shape = path.replace(/\$\{[^}]+\}/g, '{param}');
353
457
  if (/\s/.test(shape))
354
458
  return false;
355
- if (shape.startsWith('{param}'))
459
+ if (shape.startsWith('{param}') && !shape.startsWith('{param}/'))
356
460
  return false;
357
461
  // An all-digit string is a path only when it is written as one. A leading
358
462
  // slash is that evidence: `client.get('/123')` is a route whose segment the
@@ -613,8 +717,7 @@ function scanBundle(bundle, tree, repoContext, fileRel) {
613
717
  const path = readStringProp(optionsNode, ['url']);
614
718
  if (path === null)
615
719
  continue;
616
- const rawMethod = readStringProp(optionsNode, ['method']);
617
- const method = (rawMethod ?? 'GET').toUpperCase();
720
+ const method = readRequestMethod(optionsNode, ['method']);
618
721
  out.push({
619
722
  role: 'consumer',
620
723
  framework: 'axios',
@@ -625,6 +728,40 @@ function scanBundle(bundle, tree, repoContext, fileRel) {
625
728
  confidence: 0.7,
626
729
  });
627
730
  }
731
+ // Consumer: wrapped client `X.request({ url, method })` — the shared
732
+ // enterprise axios-instance shape (`httpClient.request` from
733
+ // win-request and friends). Emit the raw url (templates intact) so
734
+ // shared `normalizeConsumerPath` can strip a leading `${…}` gateway
735
+ // prefix and fold mid/tail interpolations to `{param}`. A plugin-side
736
+ // longest-slash-segment reducer would truncate those mid-templates
737
+ // before the shared normalizer ever saw them. This scan drops only
738
+ // static relative urls (no `${`, no leading `/`, not `https?://`).
739
+ // That filter is request-wrapper-specific: fetch/axios member forms
740
+ // already admit absolute urls and leave host stripping to
741
+ // `normalizeConsumerPath`.
742
+ for (const match of runCompiledPatterns(bundle.requestObject, tree)) {
743
+ const optionsNode = match.captures.options;
744
+ const objNode = match.captures.obj;
745
+ if (!optionsNode || !objNode)
746
+ continue;
747
+ if (!isAdmittedWrappedRequestReceiver(objNode.text, fileKey, facts))
748
+ continue;
749
+ const rawUrl = readStringProp(optionsNode, ['url']);
750
+ if (rawUrl === null)
751
+ continue;
752
+ const url = rawUrl.trim();
753
+ if (!url.includes('${') && !url.startsWith('/') && !/^https?:\/\//i.test(url))
754
+ continue;
755
+ out.push({
756
+ role: 'consumer',
757
+ framework: 'request',
758
+ method: readRequestMethod(optionsNode),
759
+ path: url,
760
+ name: null,
761
+ line: optionsNode.startPosition.row + 1,
762
+ confidence: 0.65,
763
+ });
764
+ }
628
765
  for (const route of scanDataRouteTables(tree)) {
629
766
  const imported = route.handlerLocalName === undefined ? undefined : importMap.get(route.handlerLocalName);
630
767
  out.push({