gitnexus 1.6.11-rc.37 → 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 (34) 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/ingestion/asyncapi/document.d.ts +226 -0
  11. package/dist/core/ingestion/asyncapi/document.js +758 -0
  12. package/dist/core/ingestion/asyncapi/protocol.d.ts +79 -0
  13. package/dist/core/ingestion/asyncapi/protocol.js +203 -0
  14. package/dist/core/ingestion/frameworks/spring/analysis-features.d.ts +5 -0
  15. package/dist/core/ingestion/frameworks/spring/analysis-features.js +9 -0
  16. package/dist/core/ingestion/frameworks/spring/destinations.d.ts +25 -0
  17. package/dist/core/ingestion/frameworks/spring/vendor-prefixes.d.ts +7 -0
  18. package/dist/core/ingestion/frameworks/spring/vendor-prefixes.js +22 -0
  19. package/dist/core/ingestion/pipeline-phases/spring-destinations.d.ts +43 -2
  20. package/dist/core/ingestion/pipeline-phases/spring-destinations.js +202 -3
  21. package/dist/core/ingestion/pipeline.d.ts +14 -0
  22. package/dist/core/ingestion/route-extractors/kotlin-spring.js +7 -14
  23. package/dist/core/ingestion/route-extractors/spring-shared.d.ts +30 -1
  24. package/dist/core/ingestion/route-extractors/spring-shared.js +77 -9
  25. package/dist/core/ingestion/route-extractors/spring.js +9 -5
  26. package/dist/core/run-analyze.d.ts +5 -0
  27. package/dist/core/run-analyze.js +49 -13
  28. package/dist/mcp/resources.js +7 -0
  29. package/dist/server/analyze-launch.d.ts +1 -0
  30. package/dist/server/analyze-launch.js +1 -0
  31. package/dist/server/api.js +7 -1
  32. package/dist/storage/repo-meta.d.ts +22 -0
  33. package/package.json +1 -1
  34. 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.
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Read AsyncAPI 3.x documents off disk and normalize their operations into
3
+ * broker addresses.
4
+ *
5
+ * Deliberately OUTSIDE `frameworks/spring/`. An AsyncAPI document is a
6
+ * published artifact, not a Spring one: it is emitted by generators across
7
+ * Java, Kotlin, TypeScript, Go and Python toolchains, and it is written by hand
8
+ * as often as it is generated. The entry criterion here is therefore the
9
+ * DOCUMENT FORMAT — a root `asyncapi` key — and never the generator. Nothing in
10
+ * this module may branch on `x-generator`, on a vendor extension, or on the
11
+ * shape of an operation key: the moment it does, every service whose toolchain
12
+ * spells things differently stops being read, and the failure is silent.
13
+ *
14
+ * ── WHY THIS IS WORTH READING AT ALL ──────────────────────────────────────
15
+ *
16
+ * A `@KafkaListener(topics = "${app.topic.in}")` names a configuration key, not
17
+ * an address, and the address cascade correctly refuses to resolve it — two
18
+ * services that merely wrote the same placeholder have said nothing about each
19
+ * other. But the service's own published document states the address outright,
20
+ * fully resolved, because the generator ran with the configuration applied.
21
+ * That is a fact about the service that no amount of reading its source can
22
+ * recover.
23
+ *
24
+ * ── WHAT THIS MODULE IS AFRAID OF ─────────────────────────────────────────
25
+ *
26
+ * Everything it emits becomes half of a JOIN KEY. A destination minted here
27
+ * meets every other site in every other repository that names the same address
28
+ * on the same broker — that is the whole value, and it is the whole hazard. A
29
+ * missing destination is a visible gap; a wrong one is reported as a fact. So
30
+ * the refusals below are not defensive clutter: each one is a case where the
31
+ * document says something that LOOKS like an address and is not one, and where
32
+ * accepting it would connect two services that have said nothing about each
33
+ * other. The taxonomy is closed and countable for the same reason the source
34
+ * cascade's is — a feature judged on its unresolved fraction needs the fraction
35
+ * broken down by cause, or nobody can tell it what to go and fix.
36
+ *
37
+ * ── VERSION 2.x IS REFUSED, NOT MAPPED ────────────────────────────────────
38
+ *
39
+ * AsyncAPI 2.x describes a channel from the READER's point of view: `publish`
40
+ * means "you may publish here", so the documenting application RECEIVES, and
41
+ * `subscribe` means the application SENDS. Version 3.0 renamed these to the
42
+ * application's own `receive` / `send`. Mapping 2.x naively therefore reverses
43
+ * every direction in the async graph — and reverses it INVISIBLY, because both
44
+ * roles still exist, every edge is still emitted, and the graph stays
45
+ * connected. Nothing fails; the arrows simply point the wrong way.
46
+ *
47
+ * The inversion is one line to write and impossible to test against a real
48
+ * corpus we do not have, and the 2.x wording confused implementers badly enough
49
+ * that some generators emitted it backwards. So 2.x is refused under its own
50
+ * countable reason instead. A silent skip would be indistinguishable from "this
51
+ * service publishes no document", which is the one thing the count has to be
52
+ * able to tell us: if the refusal tally shows 2.x documents in the field, the
53
+ * inversion earns its way in with evidence behind it.
54
+ */
55
+ /**
56
+ * Why a document, or one operation inside it, produced no address.
57
+ *
58
+ * A CLOSED, COUNTABLE set, and deliberately NOT `SpringDestinationRefusal`.
59
+ * That union is documented as the reasons a *source-level candidate* produced
60
+ * no address, and it is the denominator of the unresolved fraction the address
61
+ * work is judged on. Folding document-level failures into it would silently
62
+ * change what that number means — a repository whose specification directory
63
+ * was mistyped would report a worse SOURCE, which is the opposite of the truth.
64
+ *
65
+ * Members are split wherever two causes are different FACTS about the input.
66
+ * A tally whose member says "the document contradicts itself" when the document
67
+ * is merely multi-protocol sends an operator to fix the wrong thing, and this
68
+ * tally is the number the whole feature is judged on.
69
+ */
70
+ export type AsyncApiRefusal =
71
+ /** The file parsed but has no root `asyncapi` key: not a document at all. */
72
+ 'not-a-document'
73
+ /** Root `asyncapi: 2.x`. See the header — refused, never mapped. */
74
+ | 'asyncapi-2-unsupported'
75
+ /** A root `asyncapi` key naming a version this module does not read. */
76
+ | 'unsupported-version'
77
+ /** Malformed YAML/JSON, or a root that is not an object. */
78
+ | 'unparsable'
79
+ /** The file could not be read, or is not a regular file (a FIFO, a device). */
80
+ | 'unreadable'
81
+ /** A subdirectory could not be listed. Counted rather than skipped: under a
82
+ * mixed-permission cache half the documents can be invisible while the run
83
+ * otherwise reports a clean, complete read. */
84
+ | 'directory-unreadable'
85
+ /** Larger than {@link MAX_DOCUMENT_BYTES}. */
86
+ | 'oversized'
87
+ /** The document held more operations than one run will examine. */
88
+ | 'operation-cap'
89
+ /** The run as a whole reached {@link MAX_TOTAL_OPERATIONS}. */
90
+ | 'total-operation-cap'
91
+ /** The document declares more servers than the channel-inheritance rule will
92
+ * read. */
93
+ | 'server-cap'
94
+ /** The walk hit a bound before it finished, so the document set is a floor
95
+ * rather than the whole of what the configured path holds. A truncated read
96
+ * that reported nothing would be indistinguishable from a complete one. */
97
+ | 'walk-truncated'
98
+ /** `operations[].channel.$ref` is absent or not a local channel pointer. */
99
+ | 'no-channel-reference'
100
+ /** The `$ref` resolved to no channel in this document. */
101
+ | 'channel-not-found'
102
+ /** The channel entry is itself a Reference Object, which this module does not
103
+ * follow. Distinct from `no-address` on purpose: a `$ref`-ed channel HAS an
104
+ * address, somewhere this reader did not look, and filing it under
105
+ * `no-address` tells an operator their documents omit addresses when the
106
+ * real answer is that the reader stops one hop short. */
107
+ | 'unresolved-channel-reference'
108
+ /** The channel names no `address`, so there is nothing to key on. */
109
+ | 'no-address'
110
+ /**
111
+ * The address is a TEMPLATE, not an address: the channel declares non-empty
112
+ * `parameters`, or the address carries a `{…}` placeholder.
113
+ *
114
+ * This is the document-side twin of the source cascade's
115
+ * `overridable-config-default`, and it exists for the identical reason. Two
116
+ * services that both publish `{env}.orders` have named a pattern they share,
117
+ * not a queue they share — one deploys with `env=prod` and the other with
118
+ * `env=staging`, and keying on the template text merges them into a single
119
+ * node with a publisher on one side and a subscriber on the other. That is a
120
+ * false connection built entirely from conformant AsyncAPI: `parameters` and
121
+ * `{param}` are core 3.x vocabulary, not a vendor quirk.
122
+ */
123
+ | 'templated-address'
124
+ /** Longer than {@link MAX_ADDRESS_LENGTH}. */
125
+ | 'address-too-long'
126
+ /** Longer than {@link MAX_OPERATION_ID_LENGTH}. */
127
+ | 'operation-id-too-long'
128
+ /** `action` is neither `send` nor `receive`. */
129
+ | 'unrecognized-action'
130
+ /** Neither the operation's bindings nor the servers its channel resolves to
131
+ * name a protocol. Silence about the broker is not a claim about it, but a
132
+ * `Destination` cannot be keyed without one. */
133
+ | 'protocol-unknown'
134
+ /** The operation's OWN two statements about its broker — its bindings and the
135
+ * servers its channel explicitly lists — name different brokers. The
136
+ * document contradicts itself, and a destination keyed on the wrong broker
137
+ * joins a stranger. */
138
+ | 'protocol-disagreement'
139
+ /** The channel lists no `servers`, so it inherits all of them, and they do
140
+ * not agree on one broker. The document does NOT contradict itself here —
141
+ * it is simply multi-protocol and this channel did not choose — which is why
142
+ * this is not `protocol-disagreement`. */
143
+ | 'ambiguous-server-default'
144
+ /**
145
+ * The channel inherits the document's servers, but that map was CAPPED at
146
+ * {@link MAX_SERVERS_PER_DOCUMENT}, so the brokers read are a subset.
147
+ *
148
+ * Distinct from `ambiguous-server-default`, and the distinction is the whole
149
+ * point: unanimity across a subset is not unanimity. A document whose first
150
+ * thousand servers are Kafka and whose thousand-and-first is JMS reads as
151
+ * unanimously Kafka, and every operation inheriting it would be attributed to
152
+ * a broker the complete set does not agree on.
153
+ */
154
+ | 'capped-server-default'
155
+ /**
156
+ * A server this operation depends on is a Reference Object this reader could
157
+ * not resolve — a pointer outside `#/servers` and `#/components/servers`, a
158
+ * name that is absent, or a reference to another reference.
159
+ *
160
+ * Refused rather than skipped. Skipping one server of several silently
161
+ * narrows the evidence, and a narrowed set is what makes a mixed document
162
+ * look like it agrees with itself.
163
+ */
164
+ | 'unresolved-server-reference'
165
+ /** The broker is HTTP or WebSocket, where the host rather than the address is
166
+ * the namespace. See `isNonDestinationBroker`. */
167
+ | 'not-a-destination-protocol';
168
+ export interface AsyncApiOperation {
169
+ /** Absolute path of the document this operation came from. */
170
+ readonly documentPath: string;
171
+ /** The `operations` map key, kept for provenance and carried into the edge
172
+ * `reason` so a reader can find the operation the edge came from. */
173
+ readonly operationId: string;
174
+ readonly action: 'send' | 'receive';
175
+ readonly address: string;
176
+ /** Normalized broker — the first half of the `Destination` key. */
177
+ readonly broker: string;
178
+ }
179
+ export interface AsyncApiReadResult {
180
+ readonly operations: readonly AsyncApiOperation[];
181
+ /** Files considered — every candidate extension under the configured path. */
182
+ readonly documentsScanned: number;
183
+ /** Files that parsed as an AsyncAPI 3.x document and yielded an operation. */
184
+ readonly documentsAccepted: number;
185
+ /** Entries skipped because they were symbolic links. Not a refusal — the skip
186
+ * is deliberate — but counted, because a cache written by other tooling is
187
+ * very often a symlink farm, and an operator whose whole cache was skipped
188
+ * would otherwise see a result identical to a wrong path. */
189
+ readonly symlinksSkipped: number;
190
+ /** True when a bound stopped the walk or the operation count, so every number
191
+ * here is a floor rather than a total. */
192
+ readonly truncated: boolean;
193
+ /** Every refusal, document-level and operation-level, by reason. */
194
+ readonly refusals: Readonly<Partial<Record<AsyncApiRefusal, number>>>;
195
+ }
196
+ export interface NormalizedDocument {
197
+ operations: AsyncApiOperation[];
198
+ refusals: Partial<Record<AsyncApiRefusal, number>>;
199
+ /** Operations EXAMINED, which is what the caps count. */
200
+ examined: number;
201
+ /** A bound stopped this document short. */
202
+ truncated: boolean;
203
+ }
204
+ /**
205
+ * Normalize one parsed document. Pure — no filesystem, so the whole refusal
206
+ * surface is testable from inline document literals.
207
+ *
208
+ * `budget` is the number of operations the RUN may still examine.
209
+ */
210
+ export declare function normalizeAsyncApiDocument(parsed: unknown, documentPath: string, budget?: number): NormalizedDocument;
211
+ /**
212
+ * Read every AsyncAPI 3.x document under an explicitly configured path.
213
+ *
214
+ * `configuredPath` is resolved against the repository root, so an absolute path
215
+ * to a cache populated out of band and a repo-relative directory of committed
216
+ * documents are both natural — the same shape `springActuatorPath` offers for
217
+ * Actuator snapshots. The READ is wider than that neighbour's, and the
218
+ * difference is worth stating rather than glossed as "the same contract": the
219
+ * Actuator loader probes five fixed filenames in one directory, while this
220
+ * walks recursively under the caps above and opens every candidate it finds.
221
+ *
222
+ * There is deliberately NO glob-based auto-discovery. Scanning a repository for
223
+ * anything that parses as a document would make every existing index grow nodes
224
+ * on its next run with nobody having asked for it.
225
+ */
226
+ export declare function readAsyncApiDocuments(repoPath: string, configuredPath: string): Promise<AsyncApiReadResult>;