gitnexus 1.6.9-rc.20 → 1.6.9-rc.22

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.
@@ -1,7 +1,7 @@
1
1
  import Java from 'tree-sitter-java';
2
2
  import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
3
- import { METHOD_ANNOTATION_TO_HTTP, isRouteMemberKey, findEnclosingClass, } from '../../../ingestion/route-extractors/spring-shared.js';
4
- import { REST_TEMPLATE_TO_HTTP, WEB_CLIENT_SHORT_TO_HTTP, WEB_CLIENT_LONG_VERB_RE, EXCHANGE_ANNOTATION_TO_HTTP, parseRequestLine, pushPrefix, joinPath, scanSpringInheritanceProject, OPENFEIGN_FRAMEWORK, HTTP_INTERFACE_FRAMEWORK, FEIGN_CONFIDENCE, REQUEST_LINE_CONFIDENCE, EXCHANGE_CONFIDENCE, } from './spring-consumer-shared.js';
3
+ import { METHOD_ANNOTATION_TO_HTTP, isRouteMemberKey, findEnclosingClass, joinPath, } from '../../../ingestion/route-extractors/spring-shared.js';
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 { extractStaticPathExpression, inferOkHttpMethod, inferHttpClientMethod, okHttpUrlRootsAtBuilder, httpClientUriRootsAtNewBuilder, httpClientChainHasUriCall, } from './java-static-path.js';
6
6
  /**
7
7
  * Java HTTP plugin. Handles:
@@ -579,12 +579,13 @@ export const JAVA_HTTP_PLUGIN = {
579
579
  // ingestion does NOT emit a Route node for (1) a method-level array route
580
580
  // nested under a class-level array-form `@RequestMapping` (ingestion suppresses
581
581
  // it rather than drop the prefix; bare/scalar-prefixed array methods ARE now
582
- // emitted — see #2280), (2) interface-inherited Spring routes, or (3) the 2nd
583
- // verb of a same-URL GET+POST pair (Route nodes are URL-keyed). Declaring
584
- // 'complete' here would let the parse-skip drop those group-only providers.
582
+ // emitted — see #2280), or (2) the 2nd verb of a same-URL GET+POST pair (Route
583
+ // nodes are URL-keyed). Interface-inherited Spring routes ARE now emitted by
584
+ // ingestion (#2288), so they are no longer a coverage gap. Declaring 'complete'
585
+ // here would let the parse-skip drop those remaining group-only providers.
585
586
  // Java flips to 'complete' only once ingestion provider extraction matches this
586
- // scan (a follow-up: class-level array-form prefix support + interface-
587
- // inheritance emission + per-verb Route identity — tracked in #2280).
587
+ // scan (a follow-up: class-level array-form prefix support + per-verb Route
588
+ // identity — tracked in #2280).
588
589
  // `hasConsumerSignals` below is kept ready for that flip.
589
590
  // Consumer signals this plugin's scan() can detect: RestTemplate / WebClient /
590
591
  // OkHttp / Java-HttpClient / Apache-HttpClient call sites, OpenFeign
@@ -1,7 +1,7 @@
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, } from '../../../ingestion/route-extractors/spring-shared.js';
4
- import { REST_TEMPLATE_TO_HTTP, WEB_CLIENT_SHORT_TO_HTTP, WEB_CLIENT_LONG_VERB_RE, EXCHANGE_ANNOTATION_TO_HTTP, parseRequestLine, pushPrefix, joinPath, scanSpringInheritanceProject, OPENFEIGN_FRAMEWORK, HTTP_INTERFACE_FRAMEWORK, FEIGN_CONFIDENCE, REQUEST_LINE_CONFIDENCE, EXCHANGE_CONFIDENCE, } from './spring-consumer-shared.js';
3
+ import { METHOD_ANNOTATION_TO_HTTP, findEnclosingClass, joinPath, } from '../../../ingestion/route-extractors/spring-shared.js';
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
  /**
6
6
  * Kotlin HTTP plugin (Spring providers + consumers).
7
7
  *
@@ -15,6 +15,7 @@
15
15
  * the group layer beside the plugins that use it.
16
16
  */
17
17
  import type { HttpFileDetections } from './types.js';
18
+ import { type SharedSpringType } from '../../../ingestion/route-extractors/spring-shared.js';
18
19
  /**
19
20
  * RestTemplate method-name → HTTP verb. Source-scan only: the receiver must be
20
21
  * named exactly `restTemplate` (the per-language query enforces that).
@@ -84,54 +85,13 @@ export declare function parseRequestLine(raw: string): {
84
85
  path: string;
85
86
  } | null;
86
87
  /**
87
- * Join a class/interface-level prefix and a method-level path into a single
88
- * URL path: strip leading/trailing slashes on the prefix and leading slashes
89
- * on the method path, then ensure exactly one slash between them.
90
- */
91
- export declare function joinPath(prefix: string, methodPath: string): string;
92
- /**
93
- * Join a controller's own class prefix with a route inherited from an interface
94
- * (interface-based controllers, #1743). The inherited path already has the
95
- * interface's own class prefix (`inheritedOwnerPrefix`) baked in; when the
96
- * controller repeats that same prefix we must NOT prepend it twice (#2057).
97
- * Shared by both plugins' `scanProject` so Java and Kotlin agree.
98
- */
99
- export declare function joinInheritedSpringPath(controllerPrefix: string, inheritedPath: string, inheritedOwnerPrefix?: string): string;
100
- /**
101
- * Language-agnostic view of a Spring class/interface that each plugin's
102
- * grammar-specific collector produces. The interface-based-controller
103
- * inheritance algorithm (`scanSpringInheritanceProject`) operates only on this
104
- * shape, so the Java and Kotlin plugins share one algorithm and cannot drift.
105
- *
106
- * `methods[].routes` carry only `{ method, path }` — the interface's own class
107
- * prefix is applied *inside* `scanSpringInheritanceProject` (it is not part of
108
- * the collector's output).
109
- */
110
- export interface SharedSpringType {
111
- filePath: string;
112
- kind: 'class' | 'interface';
113
- name: string;
114
- /** Class-level `@RequestMapping` prefixes — one per array element. */
115
- classPrefixes: string[];
116
- implementedInterfaces: string[];
117
- isController: boolean;
118
- methods: Array<{
119
- name: string;
120
- routes: Array<{
121
- method: string;
122
- path: string;
123
- }>;
124
- }>;
125
- }
126
- /**
127
- * Resolve interface-based-controller provider routes (#1743): a concrete
88
+ * Resolve interface-based-controller provider *detections* (#1743): a concrete
128
89
  * `@RestController`/`@Controller` class inherits the `@(Get|...)Mapping` routes
129
- * declared on the interface it implements. Shared by the Java and Kotlin plugins
130
- * so both emit byte-identical provider contracts.
131
- *
132
- * An interface name that resolves to two distinct interfaces is ambiguous and
133
- * its routes are dropped (the `null` marker). The controller's own class
134
- * prefix(es) cross-product the inherited routes; `joinInheritedSpringPath`
135
- * avoids doubling a prefix the interface already baked in (#2057).
90
+ * declared on the interface it implements. Thin group-layer adapter over the
91
+ * shared, language-agnostic `resolveInheritedSpringRoutes` (in
92
+ * `ingestion/route-extractors/spring-shared.ts`) — it maps each inherited route
93
+ * to a provider `HttpDetection`. Shared by the Java and Kotlin plugins so both
94
+ * emit byte-identical provider contracts; the ingestion route extractor calls
95
+ * the same underlying algorithm so all three stay in parity.
136
96
  */
137
97
  export declare function scanSpringInheritanceProject(types: SharedSpringType[]): HttpFileDetections[];
@@ -14,6 +14,7 @@
14
14
  * route extractor). This module is the consumer-side counterpart and lives in
15
15
  * the group layer beside the plugins that use it.
16
16
  */
17
+ import { resolveInheritedSpringRoutes, } from '../../../ingestion/route-extractors/spring-shared.js';
17
18
  /**
18
19
  * RestTemplate method-name → HTTP verb. Source-scan only: the receiver must be
19
20
  * named exactly `restTemplate` (the per-language query enforces that).
@@ -117,111 +118,28 @@ export function parseRequestLine(raw) {
117
118
  return { method: verb.toUpperCase(), path: pathOnly };
118
119
  }
119
120
  /**
120
- * Join a class/interface-level prefix and a method-level path into a single
121
- * URL path: strip leading/trailing slashes on the prefix and leading slashes
122
- * on the method path, then ensure exactly one slash between them.
123
- */
124
- export function joinPath(prefix, methodPath) {
125
- const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
126
- const cleanSub = methodPath.replace(/^\/+/, '');
127
- if (!cleanPrefix)
128
- return `/${cleanSub}`;
129
- return `/${cleanPrefix}/${cleanSub}`;
130
- }
131
- /**
132
- * Join a controller's own class prefix with a route inherited from an interface
133
- * (interface-based controllers, #1743). The inherited path already has the
134
- * interface's own class prefix (`inheritedOwnerPrefix`) baked in; when the
135
- * controller repeats that same prefix we must NOT prepend it twice (#2057).
136
- * Shared by both plugins' `scanProject` so Java and Kotlin agree.
137
- */
138
- export function joinInheritedSpringPath(controllerPrefix, inheritedPath, inheritedOwnerPrefix = '') {
139
- const joined = joinPath(controllerPrefix, inheritedPath);
140
- const cleanPrefix = controllerPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
141
- const cleanOwnerPrefix = inheritedOwnerPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
142
- const cleanInherited = inheritedPath.replace(/^\/+/, '');
143
- if (!cleanPrefix)
144
- return joined;
145
- if (cleanPrefix === cleanOwnerPrefix &&
146
- (cleanInherited === cleanPrefix || cleanInherited.startsWith(`${cleanPrefix}/`))) {
147
- return `/${cleanInherited}`;
148
- }
149
- return joined;
150
- }
151
- /**
152
- * Resolve interface-based-controller provider routes (#1743): a concrete
121
+ * Resolve interface-based-controller provider *detections* (#1743): a concrete
153
122
  * `@RestController`/`@Controller` class inherits the `@(Get|...)Mapping` routes
154
- * declared on the interface it implements. Shared by the Java and Kotlin plugins
155
- * so both emit byte-identical provider contracts.
156
- *
157
- * An interface name that resolves to two distinct interfaces is ambiguous and
158
- * its routes are dropped (the `null` marker). The controller's own class
159
- * prefix(es) cross-product the inherited routes; `joinInheritedSpringPath`
160
- * avoids doubling a prefix the interface already baked in (#2057).
123
+ * declared on the interface it implements. Thin group-layer adapter over the
124
+ * shared, language-agnostic `resolveInheritedSpringRoutes` (in
125
+ * `ingestion/route-extractors/spring-shared.ts`) — it maps each inherited route
126
+ * to a provider `HttpDetection`. Shared by the Java and Kotlin plugins so both
127
+ * emit byte-identical provider contracts; the ingestion route extractor calls
128
+ * the same underlying algorithm so all three stay in parity.
161
129
  */
162
130
  export function scanSpringInheritanceProject(types) {
163
- const interfaceRoutes = new Map();
164
- for (const type of types) {
165
- if (type.kind !== 'interface')
166
- continue;
167
- if (interfaceRoutes.has(type.name)) {
168
- interfaceRoutes.set(type.name, null);
169
- continue;
170
- }
171
- const prefixes = type.classPrefixes.length ? type.classPrefixes : [''];
172
- const methodMap = new Map();
173
- for (const method of type.methods) {
174
- // Cross-product the interface's class prefixes with each method route, so a
175
- // multi-element `@RequestMapping(["/a","/b"])` interface yields N bindings.
176
- const routes = method.routes.flatMap((route) => prefixes.map((prefix) => ({
177
- method: route.method,
178
- path: prefix ? joinPath(prefix, route.path) : route.path,
179
- ownerPrefix: prefix,
180
- })));
181
- if (routes.length > 0)
182
- methodMap.set(method.name, routes);
183
- }
184
- interfaceRoutes.set(type.name, methodMap);
185
- }
186
131
  const detectionsByFile = new Map();
187
- for (const type of types) {
188
- if (type.kind !== 'class' || !type.isController)
189
- continue;
190
- // Cross-product the controller's own class prefixes with each inherited
191
- // route; `['']` keeps the common no-prefix controller emitting the
192
- // interface path unchanged.
193
- const controllerPrefixes = type.classPrefixes.length ? type.classPrefixes : [''];
194
- for (const method of type.methods) {
195
- if (method.routes.length > 0)
196
- continue; // own @*Mapping → already a provider via scan()
197
- const inherited = type.implementedInterfaces.flatMap((iface) => {
198
- const routeMap = interfaceRoutes.get(iface);
199
- if (!routeMap)
200
- return [];
201
- const routes = routeMap.get(method.name) ?? [];
202
- return routes.flatMap((route) => controllerPrefixes.map((controllerPrefix) => ({
203
- method: route.method,
204
- path: joinInheritedSpringPath(controllerPrefix, route.path, route.ownerPrefix),
205
- })));
206
- });
207
- const seen = new Set();
208
- for (const route of inherited) {
209
- const key = `${route.method} ${route.path}`;
210
- if (seen.has(key))
211
- continue;
212
- seen.add(key);
213
- const detections = detectionsByFile.get(type.filePath) ?? [];
214
- detections.push({
215
- role: 'provider',
216
- framework: 'spring',
217
- method: route.method,
218
- path: route.path,
219
- name: method.name,
220
- confidence: 0.8,
221
- });
222
- detectionsByFile.set(type.filePath, detections);
223
- }
224
- }
132
+ for (const route of resolveInheritedSpringRoutes(types)) {
133
+ const detections = detectionsByFile.get(route.filePath) ?? [];
134
+ detections.push({
135
+ role: 'provider',
136
+ framework: 'spring',
137
+ method: route.method,
138
+ path: route.path,
139
+ name: route.methodName,
140
+ confidence: 0.8,
141
+ });
142
+ detectionsByFile.set(route.filePath, detections);
225
143
  }
226
144
  return [...detectionsByFile.entries()].map(([filePath, detections]) => ({
227
145
  filePath,
@@ -22,6 +22,7 @@ import type { SyntaxNode } from './utils/ast-helpers.js';
22
22
  import type { CfgVisitor } from './cfg/types.js';
23
23
  import type { NodeLabel } from '../../_shared/index.js';
24
24
  import type { ExtractedRoute } from './route-extractors/laravel.js';
25
+ import type { SharedSpringType } from './route-extractors/spring-shared.js';
25
26
  import type Parser from 'tree-sitter';
26
27
  import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
27
28
  /** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
@@ -226,6 +227,19 @@ interface LanguageProviderConfig {
226
227
  * Default: undefined (no language-specific decorator route extraction).
227
228
  */
228
229
  readonly extractDecoratorRoutes?: (tree: Parser.Tree, filePath: string, lineOffset: number) => ExtractedDecoratorRoute[];
230
+ /**
231
+ * Collect a project-wide, language-agnostic view of route-defining
232
+ * class/interface declarations (`SharedSpringType`) from a parsed file.
233
+ *
234
+ * When defined, the parse worker calls this per file and the parse phase
235
+ * aggregates the results, then runs a cross-file pass that resolves
236
+ * interface-inherited routes (a concrete controller inherits the `@*Mapping`s
237
+ * its interfaces declare) and appends them to `decoratorRoutes`. Separate from
238
+ * `extractDecoratorRoutes` because inheritance needs all files, not one.
239
+ *
240
+ * Default: undefined (no interface-inheritance route resolution).
241
+ */
242
+ readonly extractRouteInheritanceTypes?: (tree: Parser.Tree, filePath: string) => SharedSpringType[];
229
243
  /** Built-in/stdlib names that should be filtered from the call graph for this language.
230
244
  * Default: undefined (no language-specific filtering). */
231
245
  readonly builtInNames?: ReadonlySet<string>;
@@ -12,7 +12,7 @@ import { javaClassConfig } from '../class-extractors/configs/jvm.js';
12
12
  import { defineLanguage } from '../language-provider.js';
13
13
  import { createLeadingDocDescriptionExtractor } from '../utils/ast-helpers.js';
14
14
  import { javaTypeConfig } from '../type-extractors/jvm.js';
15
- import { extractSpringRoutes } from '../route-extractors/spring.js';
15
+ import { extractSpringRoutes, extractSpringTypes } from '../route-extractors/spring.js';
16
16
  import { javaExportChecker } from '../export-detection.js';
17
17
  import { createImportResolver } from '../import-resolvers/resolver-factory.js';
18
18
  import { javaImportConfig } from '../import-resolvers/configs/jvm.js';
@@ -112,4 +112,5 @@ export const javaProvider = defineLanguage({
112
112
  orderSameNameTypeCandidates: orderJavaSameNameTypeCandidates,
113
113
  // ── Route extraction ──
114
114
  extractDecoratorRoutes: extractSpringRoutes,
115
+ extractRouteInheritanceTypes: extractSpringTypes,
115
116
  });
@@ -5,6 +5,7 @@ import type { ParsedFile } from '../../_shared/index.js';
5
5
  import { WorkerPool } from './workers/worker-pool.js';
6
6
  import type { ParseWorkerResult, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, ExtractedToolDef, FileScopeBindings, ExtractedORMQuery, FetchWrapperDef } from './workers/parse-worker.js';
7
7
  import type { ExtractedRouterImport, ExtractedRouterInclude, ExtractedRouterModuleAlias } from './route-extractors/fastapi-router-bindings.js';
8
+ import type { SharedSpringType } from './route-extractors/spring-shared.js';
8
9
  export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
9
10
  export interface WorkerExtractedData {
10
11
  routes: ExtractedRoute[];
@@ -16,6 +17,8 @@ export interface WorkerExtractedData {
16
17
  routerModuleAliases: ExtractedRouterModuleAlias[];
17
18
  toolDefs: ExtractedToolDef[];
18
19
  ormQueries: ExtractedORMQuery[];
20
+ /** Project-wide Spring class/interface views for the #2288 inheritance pass. */
21
+ springTypes: SharedSpringType[];
19
22
  fileScopeBindings: FileScopeBindings[];
20
23
  /**
21
24
  * Per-file `ParsedFile` artifacts from the new scope-based resolution
@@ -23,6 +23,7 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults, exportedType
23
23
  const allRouterIncludes = [];
24
24
  const allRouterImports = [];
25
25
  const allRouterModuleAliases = [];
26
+ const allSpringTypes = [];
26
27
  const allToolDefs = [];
27
28
  const allORMQueries = [];
28
29
  const fileScopeBindingsByFile = [];
@@ -71,6 +72,8 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults, exportedType
71
72
  allRouterImports.push(item);
72
73
  for (const item of result.routerModuleAliases ?? [])
73
74
  allRouterModuleAliases.push(item);
75
+ for (const item of result.springTypes ?? [])
76
+ allSpringTypes.push(item);
74
77
  for (const item of result.toolDefs)
75
78
  allToolDefs.push(item);
76
79
  if (result.ormQueries)
@@ -93,6 +96,7 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults, exportedType
93
96
  routerModuleAliases: allRouterModuleAliases,
94
97
  toolDefs: allToolDefs,
95
98
  ormQueries: allORMQueries,
99
+ springTypes: allSpringTypes,
96
100
  fileScopeBindings: fileScopeBindingsByFile,
97
101
  parsedFiles: allParsedFiles,
98
102
  };
@@ -27,6 +27,7 @@ import { isLanguageAvailable, isGrammarRuntimeSkipped, createParserForLanguage,
27
27
  import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
28
28
  import { getProvider, providers } from '../languages/index.js';
29
29
  import { createWorkerPool, workerPoolDisabledByEnv, resolveAutoPoolSize, WorkerPoolInitializationError, WorkerPoolDisabledError, } from '../workers/worker-pool.js';
30
+ import { resolveInheritedSpringRoutes, } from '../route-extractors/spring-shared.js';
30
31
  import fs from 'node:fs';
31
32
  import path from 'node:path';
32
33
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -461,6 +462,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
461
462
  const allRouterIncludes = [];
462
463
  const allRouterImports = [];
463
464
  const allRouterModuleAliases = [];
465
+ const allSpringTypes = [];
464
466
  const allToolDefs = [];
465
467
  const allORMQueries = [];
466
468
  // Aggregated per-file ParsedFile artifacts produced by workers' calls
@@ -601,6 +603,10 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
601
603
  for (const item of chunkWorkerData.routerModuleAliases)
602
604
  allRouterModuleAliases.push(item);
603
605
  }
606
+ if (chunkWorkerData.springTypes?.length) {
607
+ for (const item of chunkWorkerData.springTypes)
608
+ allSpringTypes.push(item);
609
+ }
604
610
  if (chunkWorkerData.toolDefs?.length) {
605
611
  for (const item of chunkWorkerData.toolDefs)
606
612
  allToolDefs.push(item);
@@ -1047,6 +1053,25 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1047
1053
  allDecoratorRoutes.push(dr);
1048
1054
  }
1049
1055
  }
1056
+ // Cross-file Spring interface-inheritance pass (#2288): a concrete
1057
+ // `@RestController` inherits the `@*Mapping`s declared on the interfaces it
1058
+ // implements. The per-file `SharedSpringType` views collected by the Java
1059
+ // provider's `extractRouteInheritanceTypes` hook are resolved here, project-
1060
+ // wide, into decorator routes attributed to the implementing controller (the
1061
+ // interface's own per-file routes were suppressed at extraction). Mirrors the
1062
+ // group layer via the shared `resolveInheritedSpringRoutes` so both agree.
1063
+ if (allSpringTypes.length > 0) {
1064
+ for (const inherited of resolveInheritedSpringRoutes(allSpringTypes)) {
1065
+ allDecoratorRoutes.push({
1066
+ filePath: inherited.filePath,
1067
+ routePath: inherited.path,
1068
+ httpMethod: inherited.method,
1069
+ decoratorName: 'inherited-mapping',
1070
+ lineNumber: 0,
1071
+ handlerName: inherited.methodName,
1072
+ });
1073
+ }
1074
+ }
1050
1075
  logHeapProbe('parse-impl-return', `exportedTypeMap=${exportedTypeMap.size} parsedFiles=${allParsedFiles.length} nodes=${graph.nodeCount}`);
1051
1076
  // Part 2 (#2138): resolve each route's handler to a real symbol UID now that
1052
1077
  // the model is fully populated and decorator-route prefixes are finalized.
@@ -38,6 +38,16 @@ export declare function isRouteMemberKey(keyNode: Parser.SyntaxNode | undefined)
38
38
  * at a time.
39
39
  */
40
40
  export declare function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null;
41
+ /**
42
+ * Find the nearest enclosing Java type declaration (class OR interface) for a
43
+ * node, reporting its kind. Used by the ingestion route extractor to tell an
44
+ * interface-declared `@*Mapping` (handled by the cross-file inheritance pass,
45
+ * #2288) apart from a concrete class route, and by the type collector.
46
+ */
47
+ export declare function findEnclosingType(node: Parser.SyntaxNode): {
48
+ node: Parser.SyntaxNode;
49
+ kind: 'class' | 'interface';
50
+ } | null;
41
51
  /**
42
52
  * Strip enclosing quotes from a tree-sitter string-literal node's text.
43
53
  * Handles single / double / template (backtick) quotes and triple-quoted
@@ -48,3 +58,71 @@ export declare function findEnclosingClass(node: Parser.SyntaxNode): Parser.Synt
48
58
  * content without quotes already).
49
59
  */
50
60
  export declare function unquoteSpringLiteral(raw: string): string | null;
61
+ /**
62
+ * Join a class/interface-level prefix and a method-level path into a single
63
+ * URL path: strip leading/trailing slashes on the prefix and leading slashes
64
+ * on the method path, then ensure exactly one slash between them.
65
+ *
66
+ * Lives here (the lower shared layer) so the ingestion route extractor and the
67
+ * group-layer Spring/Kotlin plugins join prefixes identically; the group
68
+ * `spring-consumer-shared.ts` re-exports it for its existing importers.
69
+ */
70
+ export declare function joinPath(prefix: string, methodPath: string): string;
71
+ /**
72
+ * Join a controller's own class prefix with a route inherited from an interface
73
+ * (interface-based controllers, #1743). The inherited path already has the
74
+ * interface's own class prefix (`inheritedOwnerPrefix`) baked in; when the
75
+ * controller repeats that same prefix we must NOT prepend it twice (#2057).
76
+ */
77
+ export declare function joinInheritedSpringPath(controllerPrefix: string, inheritedPath: string, inheritedOwnerPrefix?: string): string;
78
+ /**
79
+ * Language-agnostic view of a Spring class/interface that each extractor's
80
+ * grammar-specific collector produces. The interface-based-controller
81
+ * inheritance algorithm (`resolveInheritedSpringRoutes`) operates only on this
82
+ * shape, so the ingestion route extractor and the group Java/Kotlin plugins
83
+ * share one algorithm and cannot drift.
84
+ *
85
+ * `methods[].routes` carry only `{ method, path }` — the interface's own class
86
+ * prefix is applied *inside* `resolveInheritedSpringRoutes` (it is not part of
87
+ * the collector's output).
88
+ */
89
+ export interface SharedSpringType {
90
+ filePath: string;
91
+ kind: 'class' | 'interface';
92
+ name: string;
93
+ /** Class-level `@RequestMapping` prefixes — one per array element. */
94
+ classPrefixes: string[];
95
+ implementedInterfaces: string[];
96
+ isController: boolean;
97
+ methods: Array<{
98
+ name: string;
99
+ routes: Array<{
100
+ method: string;
101
+ path: string;
102
+ }>;
103
+ }>;
104
+ }
105
+ /** One provider route a concrete controller inherits from an interface. */
106
+ interface InheritedSpringRoute {
107
+ /** File of the implementing controller (where the route should be attributed). */
108
+ filePath: string;
109
+ /** Name of the controller method that inherits the route. */
110
+ methodName: string;
111
+ method: string;
112
+ path: string;
113
+ }
114
+ /**
115
+ * Resolve interface-based-controller provider routes (#1743): a concrete
116
+ * `@RestController`/`@Controller` class inherits the `@(Get|...)Mapping` routes
117
+ * declared on the interface it implements. Pure and language-agnostic — shared
118
+ * by the ingestion route extractor and the group Java/Kotlin plugins so all
119
+ * three emit the same inherited routes.
120
+ *
121
+ * An interface name that resolves to two distinct interfaces is ambiguous and
122
+ * its routes are dropped (the `null` marker). The controller's own class
123
+ * prefix(es) cross-product the inherited routes; `joinInheritedSpringPath`
124
+ * avoids doubling a prefix the interface already baked in (#2057). Duplicate
125
+ * `(method, path)` results per controller method are de-duped.
126
+ */
127
+ export declare function resolveInheritedSpringRoutes(types: SharedSpringType[]): InheritedSpringRoute[];
128
+ export {};
@@ -55,6 +55,23 @@ export function findEnclosingClass(node) {
55
55
  }
56
56
  return null;
57
57
  }
58
+ /**
59
+ * Find the nearest enclosing Java type declaration (class OR interface) for a
60
+ * node, reporting its kind. Used by the ingestion route extractor to tell an
61
+ * interface-declared `@*Mapping` (handled by the cross-file inheritance pass,
62
+ * #2288) apart from a concrete class route, and by the type collector.
63
+ */
64
+ export function findEnclosingType(node) {
65
+ let cur = node.parent;
66
+ while (cur) {
67
+ if (cur.type === 'class_declaration')
68
+ return { node: cur, kind: 'class' };
69
+ if (cur.type === 'interface_declaration')
70
+ return { node: cur, kind: 'interface' };
71
+ cur = cur.parent;
72
+ }
73
+ return null;
74
+ }
58
75
  /**
59
76
  * Strip enclosing quotes from a tree-sitter string-literal node's text.
60
77
  * Handles single / double / template (backtick) quotes and triple-quoted
@@ -78,3 +95,115 @@ export function unquoteSpringLiteral(raw) {
78
95
  }
79
96
  return raw;
80
97
  }
98
+ /**
99
+ * Join a class/interface-level prefix and a method-level path into a single
100
+ * URL path: strip leading/trailing slashes on the prefix and leading slashes
101
+ * on the method path, then ensure exactly one slash between them.
102
+ *
103
+ * Lives here (the lower shared layer) so the ingestion route extractor and the
104
+ * group-layer Spring/Kotlin plugins join prefixes identically; the group
105
+ * `spring-consumer-shared.ts` re-exports it for its existing importers.
106
+ */
107
+ export function joinPath(prefix, methodPath) {
108
+ const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
109
+ const cleanSub = methodPath.replace(/^\/+/, '');
110
+ if (!cleanPrefix)
111
+ return `/${cleanSub}`;
112
+ return `/${cleanPrefix}/${cleanSub}`;
113
+ }
114
+ /**
115
+ * Join a controller's own class prefix with a route inherited from an interface
116
+ * (interface-based controllers, #1743). The inherited path already has the
117
+ * interface's own class prefix (`inheritedOwnerPrefix`) baked in; when the
118
+ * controller repeats that same prefix we must NOT prepend it twice (#2057).
119
+ */
120
+ export function joinInheritedSpringPath(controllerPrefix, inheritedPath, inheritedOwnerPrefix = '') {
121
+ const joined = joinPath(controllerPrefix, inheritedPath);
122
+ const cleanPrefix = controllerPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
123
+ const cleanOwnerPrefix = inheritedOwnerPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
124
+ const cleanInherited = inheritedPath.replace(/^\/+/, '');
125
+ if (!cleanPrefix)
126
+ return joined;
127
+ if (cleanPrefix === cleanOwnerPrefix &&
128
+ (cleanInherited === cleanPrefix || cleanInherited.startsWith(`${cleanPrefix}/`))) {
129
+ return `/${cleanInherited}`;
130
+ }
131
+ return joined;
132
+ }
133
+ /**
134
+ * Resolve interface-based-controller provider routes (#1743): a concrete
135
+ * `@RestController`/`@Controller` class inherits the `@(Get|...)Mapping` routes
136
+ * declared on the interface it implements. Pure and language-agnostic — shared
137
+ * by the ingestion route extractor and the group Java/Kotlin plugins so all
138
+ * three emit the same inherited routes.
139
+ *
140
+ * An interface name that resolves to two distinct interfaces is ambiguous and
141
+ * its routes are dropped (the `null` marker). The controller's own class
142
+ * prefix(es) cross-product the inherited routes; `joinInheritedSpringPath`
143
+ * avoids doubling a prefix the interface already baked in (#2057). Duplicate
144
+ * `(method, path)` results per controller method are de-duped.
145
+ */
146
+ export function resolveInheritedSpringRoutes(types) {
147
+ // interface name → (method name → routes). `null` marks an ambiguous
148
+ // (duplicated) interface name. `IntermediateRoute` is declared at module scope.
149
+ const interfaceRoutes = new Map();
150
+ for (const type of types) {
151
+ if (type.kind !== 'interface')
152
+ continue;
153
+ if (interfaceRoutes.has(type.name)) {
154
+ interfaceRoutes.set(type.name, null);
155
+ continue;
156
+ }
157
+ const prefixes = type.classPrefixes.length ? type.classPrefixes : [''];
158
+ const methodMap = new Map();
159
+ for (const method of type.methods) {
160
+ // Cross-product the interface's class prefixes with each method route, so a
161
+ // multi-element `@RequestMapping(["/a","/b"])` interface yields N bindings.
162
+ const routes = method.routes.flatMap((route) => prefixes.map((prefix) => ({
163
+ method: route.method,
164
+ path: prefix ? joinPath(prefix, route.path) : route.path,
165
+ ownerPrefix: prefix,
166
+ })));
167
+ if (routes.length > 0)
168
+ methodMap.set(method.name, routes);
169
+ }
170
+ interfaceRoutes.set(type.name, methodMap);
171
+ }
172
+ const out = [];
173
+ for (const type of types) {
174
+ if (type.kind !== 'class' || !type.isController)
175
+ continue;
176
+ // Cross-product the controller's own class prefixes with each inherited
177
+ // route; `['']` keeps the common no-prefix controller emitting the
178
+ // interface path unchanged.
179
+ const controllerPrefixes = type.classPrefixes.length ? type.classPrefixes : [''];
180
+ for (const method of type.methods) {
181
+ if (method.routes.length > 0)
182
+ continue; // own @*Mapping → already a provider
183
+ const inherited = type.implementedInterfaces.flatMap((iface) => {
184
+ const routeMap = interfaceRoutes.get(iface);
185
+ if (!routeMap)
186
+ return [];
187
+ const routes = routeMap.get(method.name) ?? [];
188
+ return routes.flatMap((route) => controllerPrefixes.map((controllerPrefix) => ({
189
+ method: route.method,
190
+ path: joinInheritedSpringPath(controllerPrefix, route.path, route.ownerPrefix),
191
+ })));
192
+ });
193
+ const seen = new Set();
194
+ for (const route of inherited) {
195
+ const key = `${route.method} ${route.path}`;
196
+ if (seen.has(key))
197
+ continue;
198
+ seen.add(key);
199
+ out.push({
200
+ filePath: type.filePath,
201
+ methodName: method.name,
202
+ method: route.method,
203
+ path: route.path,
204
+ });
205
+ }
206
+ }
207
+ }
208
+ return out;
209
+ }
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import Parser from 'tree-sitter';
22
22
  import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
23
+ import { type SharedSpringType } from './spring-shared.js';
23
24
  /**
24
25
  * Extract Spring route annotations from a parsed Java file.
25
26
  *
@@ -33,3 +34,15 @@ import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
33
34
  * @returns Decorator routes with prefix already set per-class
34
35
  */
35
36
  export declare function extractSpringRoutes(tree: Parser.Tree, filePath: string, lineOffset?: number): ExtractedDecoratorRoute[];
37
+ /**
38
+ * Build the project-wide `SharedSpringType` view for one Java file: every class
39
+ * and interface with its class prefixes, implemented interfaces, controller
40
+ * flag, and per-method route annotations. The cross-file inheritance pass
41
+ * (#2288) feeds these into the shared `resolveInheritedSpringRoutes` so a
42
+ * concrete controller inherits the `@*Mapping`s declared on its interfaces.
43
+ *
44
+ * This is the ingestion counterpart of the group layer's `collectSpringTypes`
45
+ * (`group/extractors/http-patterns/java.ts`); both produce the same neutral
46
+ * shape so the two layers resolve inheritance identically (#2078 parity).
47
+ */
48
+ export declare function extractSpringTypes(tree: Parser.Tree, filePath: string): SharedSpringType[];
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import Parser from 'tree-sitter';
22
22
  import Java from 'tree-sitter-java';
23
- import { METHOD_ANNOTATION_TO_HTTP, isRouteMemberKey, findEnclosingClass, unquoteSpringLiteral, } from './spring-shared.js';
23
+ import { METHOD_ANNOTATION_TO_HTTP, isRouteMemberKey, findEnclosingType, unquoteSpringLiteral, } from './spring-shared.js';
24
24
  /**
25
25
  * Single predicate-free tree-sitter query that captures all route annotations
26
26
  * on classes and methods. Discrimination by annotation name and node type
@@ -150,7 +150,15 @@ export function extractSpringRoutes(tree, filePath, lineOffset = 0) {
150
150
  const routePath = unquoteSpringLiteral(valueNode.text);
151
151
  if (routePath === null)
152
152
  continue;
153
- const enclosingClass = findEnclosingClass(node);
153
+ const enclosingType = findEnclosingType(node);
154
+ // Interface-declared `@*Mapping`s are not concrete routes on their own — the
155
+ // implementing controller inherits them. Skip here; the cross-file
156
+ // inheritance pass (#2288) re-emits them attributed to the controller, with
157
+ // both the interface's and the controller's class prefixes resolved. Emitting
158
+ // the interface route directly would be wrong (unprefixed, wrong owner).
159
+ if (enclosingType?.kind === 'interface')
160
+ continue;
161
+ const enclosingClass = enclosingType?.kind === 'class' ? enclosingType.node : null;
154
162
  // Suppress a method-level *array-form* route nested under a class-level
155
163
  // array-form @RequestMapping. The class prefix is one of several values that
156
164
  // cannot be resolved to a single string here, so emitting the route would
@@ -179,3 +187,163 @@ export function extractSpringRoutes(tree, filePath, lineOffset = 0) {
179
187
  }
180
188
  return routes;
181
189
  }
190
+ /**
191
+ * Tree-sitter query capturing every Java type declaration (class + interface),
192
+ * used by `extractSpringTypes` to build the project-wide `SharedSpringType`
193
+ * view the cross-file interface-inheritance pass consumes (#2288).
194
+ */
195
+ const TYPE_DECLARATION_QUERY = new Parser.Query(Java, `[(class_declaration) @type (interface_declaration) @type]`);
196
+ /** Direct annotations on a type/method declaration (reads its `modifiers` child). */
197
+ function declarationAnnotations(node) {
198
+ // `modifiers` is a NAMED CHILD of the declaration (not a named field) in
199
+ // tree-sitter-java — matching the group layer's `hasAnnotation`.
200
+ const modifiers = node.namedChildren.find((c) => c.type === 'modifiers');
201
+ if (!modifiers)
202
+ return [];
203
+ return modifiers.namedChildren.filter((c) => c.type === 'annotation' || c.type === 'marker_annotation');
204
+ }
205
+ const annotationName = (ann) => {
206
+ // Trailing segment of a possibly fully-qualified annotation name
207
+ // (`org.springframework.web.bind.annotation.GetMapping` → `GetMapping`), so a
208
+ // FQN annotation is classified the same as its simple form — matching the
209
+ // group layer's `simpleName` normalization. A simple name maps to itself.
210
+ const text = ann.childForFieldName('name')?.text;
211
+ return text?.split('.').pop() ?? text;
212
+ };
213
+ /**
214
+ * Collect the route path(s) carried by an annotation's argument list, honoring
215
+ * both positional and `path =`/`value =` named arguments and both the bare
216
+ * string and array forms. Non-route named args (`consumes`, `produces`, …) are
217
+ * dropped via `isRouteMemberKey`. Returns one entry per string element.
218
+ */
219
+ function annotationRoutePaths(ann) {
220
+ const args = ann.childForFieldName('arguments');
221
+ if (!args)
222
+ return [];
223
+ const out = [];
224
+ const pushLiteral = (lit) => {
225
+ const v = unquoteSpringLiteral(lit.text);
226
+ if (v !== null)
227
+ out.push(v);
228
+ };
229
+ const pushFromValue = (valueNode) => {
230
+ if (valueNode.type === 'string_literal')
231
+ pushLiteral(valueNode);
232
+ else if (valueNode.type === 'element_value_array_initializer') {
233
+ for (const el of valueNode.namedChildren)
234
+ if (el.type === 'string_literal')
235
+ pushLiteral(el);
236
+ }
237
+ };
238
+ for (const child of args.namedChildren) {
239
+ if (child.type === 'string_literal' || child.type === 'element_value_array_initializer') {
240
+ pushFromValue(child); // positional
241
+ }
242
+ else if (child.type === 'element_value_pair') {
243
+ const key = child.childForFieldName('key');
244
+ if (!isRouteMemberKey(key ?? undefined))
245
+ continue;
246
+ const value = child.childForFieldName('value');
247
+ if (value)
248
+ pushFromValue(value);
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+ /** Class-level `@RequestMapping` prefixes for a type (array-aware; may be []). */
254
+ function typeClassPrefixes(typeNode) {
255
+ const prefixes = [];
256
+ for (const ann of declarationAnnotations(typeNode)) {
257
+ if (annotationName(ann) === 'RequestMapping')
258
+ prefixes.push(...annotationRoutePaths(ann));
259
+ }
260
+ return prefixes;
261
+ }
262
+ /** The simple names of the interfaces a class declares via `implements`. */
263
+ function implementedInterfaceNames(typeNode) {
264
+ const interfacesNode = typeNode.childForFieldName('interfaces');
265
+ if (!interfacesNode)
266
+ return [];
267
+ const out = [];
268
+ const visit = (node) => {
269
+ if (node.type === 'type_identifier' || node.type === 'scoped_type_identifier') {
270
+ out.push(node.text.split('.').pop() ?? node.text);
271
+ return;
272
+ }
273
+ for (const child of node.namedChildren)
274
+ visit(child);
275
+ };
276
+ visit(interfacesNode);
277
+ return out;
278
+ }
279
+ /** Direct `method_declaration` children of a type (not methods of nested types). */
280
+ function directMethods(typeNode) {
281
+ const out = [];
282
+ const visit = (node) => {
283
+ for (const child of node.namedChildren) {
284
+ if (child.type === 'method_declaration') {
285
+ out.push(child);
286
+ continue;
287
+ }
288
+ // Don't descend into a nested type — its methods aren't this type's.
289
+ if (child !== typeNode &&
290
+ (child.type === 'class_declaration' || child.type === 'interface_declaration')) {
291
+ continue;
292
+ }
293
+ visit(child);
294
+ }
295
+ };
296
+ visit(typeNode);
297
+ return out;
298
+ }
299
+ /**
300
+ * Build the project-wide `SharedSpringType` view for one Java file: every class
301
+ * and interface with its class prefixes, implemented interfaces, controller
302
+ * flag, and per-method route annotations. The cross-file inheritance pass
303
+ * (#2288) feeds these into the shared `resolveInheritedSpringRoutes` so a
304
+ * concrete controller inherits the `@*Mapping`s declared on its interfaces.
305
+ *
306
+ * This is the ingestion counterpart of the group layer's `collectSpringTypes`
307
+ * (`group/extractors/http-patterns/java.ts`); both produce the same neutral
308
+ * shape so the two layers resolve inheritance identically (#2078 parity).
309
+ */
310
+ export function extractSpringTypes(tree, filePath) {
311
+ const out = [];
312
+ for (const match of TYPE_DECLARATION_QUERY.matches(tree.rootNode)) {
313
+ const typeNode = match.captures.find((c) => c.name === 'type')?.node;
314
+ if (!typeNode)
315
+ continue;
316
+ const name = typeNode.childForFieldName('name')?.text;
317
+ if (!name)
318
+ continue;
319
+ const kind = typeNode.type === 'interface_declaration' ? 'interface' : 'class';
320
+ const annNames = declarationAnnotations(typeNode).map(annotationName);
321
+ const isController = kind === 'class' && (annNames.includes('RestController') || annNames.includes('Controller'));
322
+ const methods = directMethods(typeNode)
323
+ .map((methodNode) => {
324
+ const methodName = methodNode.childForFieldName('name')?.text;
325
+ if (!methodName)
326
+ return null;
327
+ const routes = [];
328
+ for (const ann of declarationAnnotations(methodNode)) {
329
+ const verb = METHOD_ANNOTATION_TO_HTTP[annotationName(ann) ?? ''];
330
+ if (!verb)
331
+ continue;
332
+ for (const path of annotationRoutePaths(ann))
333
+ routes.push({ method: verb, path });
334
+ }
335
+ return { name: methodName, routes };
336
+ })
337
+ .filter((m) => m !== null);
338
+ out.push({
339
+ filePath,
340
+ kind,
341
+ name,
342
+ classPrefixes: typeClassPrefixes(typeNode),
343
+ implementedInterfaces: kind === 'class' ? implementedInterfaceNames(typeNode) : [],
344
+ isController,
345
+ methods,
346
+ });
347
+ }
348
+ return out;
349
+ }
@@ -6,6 +6,7 @@ import type { ConstructorBinding } from '../type-env.js';
6
6
  import type { NodeLabel, ParameterTypeClass } from '../../../_shared/index.js';
7
7
  import type { ParsedFile } from '../../../_shared/index.js';
8
8
  import { type ExtractedRoute } from '../route-extractors/laravel.js';
9
+ import type { SharedSpringType } from '../route-extractors/spring-shared.js';
9
10
  import { type CfgSkipCounts } from '../cfg/collect.js';
10
11
  export type { ExtractedRoute } from '../route-extractors/laravel.js';
11
12
  interface ParsedNode {
@@ -190,6 +191,15 @@ export interface ParseWorkerResult {
190
191
  decoratorRoutes: ExtractedDecoratorRoute[];
191
192
  routerIncludes: ExtractedRouterInclude[];
192
193
  routerImports: ExtractedRouterImport[];
194
+ /**
195
+ * Optional. Project-wide `SharedSpringType` view of route-defining
196
+ * class/interface declarations, produced by the provider's
197
+ * `extractRouteInheritanceTypes` hook (Java/Spring). parse-impl aggregates
198
+ * these and runs a cross-file pass that resolves interface-inherited routes
199
+ * into additional `decoratorRoutes` (#2288). Optional for cache backward
200
+ * compatibility; consumers must guard with `?? []`.
201
+ */
202
+ springTypes?: SharedSpringType[];
193
203
  /**
194
204
  * Optional. `from <pkg> import <module>` records from Python files
195
205
  * where `<module>` is later used as a Shape-A include receiver
@@ -1832,6 +1832,14 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
1832
1832
  for (const r of frameworkRoutes)
1833
1833
  result.decoratorRoutes.push(r);
1834
1834
  }
1835
+ // Project-wide route-inheritance type collection via provider hook (#2288).
1836
+ // The per-file SharedSpringType views are aggregated by the parse phase,
1837
+ // which then resolves interface-inherited routes cross-file.
1838
+ if (provider.extractRouteInheritanceTypes) {
1839
+ const springTypes = provider.extractRouteInheritanceTypes(tree, file.path);
1840
+ if (springTypes.length > 0)
1841
+ (result.springTypes ??= []).push(...springTypes);
1842
+ }
1835
1843
  // Vue: emit CALLS edges for components used in <template>
1836
1844
  if (language === SupportedLanguages.Vue) {
1837
1845
  const templateComponents = extractTemplateComponents(file.content);
@@ -28,6 +28,10 @@ export const mergeResult = (target, src) => {
28
28
  target.routerModuleAliases ??= [];
29
29
  appendAll(target.routerModuleAliases, src.routerModuleAliases);
30
30
  }
31
+ if (src.springTypes) {
32
+ target.springTypes ??= [];
33
+ appendAll(target.springTypes, src.springTypes);
34
+ }
31
35
  appendAll(target.toolDefs, src.toolDefs);
32
36
  appendAll(target.ormQueries, src.ormQueries);
33
37
  appendAll(target.constructorBindings, src.constructorBindings);
@@ -353,6 +353,20 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
353
353
  }
354
354
  try {
355
355
  await initLbug(lbugPath);
356
+ // Gate on FTS availability BEFORE touching any index. createSearchFTSIndexes
357
+ // now DROPs each index before recreating it (so schema changes reach existing
358
+ // DBs); if the extension were unavailable, the drops would run and leave the
359
+ // DB index-less, only failing at the create step. Fail loudly first — mirrors
360
+ // the analyze path's `if (ftsAvailable)` gate below — so an unavailable
361
+ // extension never destroys the existing indexes.
362
+ const repairFtsAvailable = await loadFTSExtension(undefined, {
363
+ policy: resolveAnalyzeInstallPolicy(),
364
+ });
365
+ if (!repairFtsAvailable) {
366
+ throw new Error('Cannot repair FTS indexes: the LadybugDB FTS extension is unavailable ' +
367
+ '(not pre-installed and could not be installed on this machine). ' +
368
+ 'Run `gitnexus doctor` to install it, then retry `--repair-fts`.');
369
+ }
356
370
  progress('fts', 85, 'Repairing search indexes...');
357
371
  await createSearchFTSIndexes({
358
372
  onIndexStart: options.verbose
@@ -1,32 +1,48 @@
1
- import { createFTSIndex } from '../lbug/lbug-adapter.js';
1
+ import { createFTSIndex, dropFTSIndex } from '../lbug/lbug-adapter.js';
2
2
  import { FTS_INDEXES } from './fts-schema.js';
3
3
  export async function createSearchFTSIndexes(options) {
4
4
  for (const { table, indexName, properties } of FTS_INDEXES) {
5
5
  options?.onIndexStart?.(table, indexName);
6
+ // Drop first so the live `properties` always win. `createFTSIndex` is
7
+ // idempotent-by-name (skips when the index already exists), so without the
8
+ // drop a schema change — e.g. adding `description` (#2299) — would never
9
+ // reach an existing `.lbug` DB on an incremental re-analyze or `--repair-fts`;
10
+ // the old name+content index would silently persist. `dropFTSIndex` no-ops
11
+ // when the index is absent (first-ever analyze) and clears the per-connection
12
+ // memo so the create below actually runs.
13
+ // ponytail: this rebuilds every FTS index on every analyze instead of
14
+ // skipping when present; FTS build is proportional to symbol-table size and
15
+ // runs inside the existing FTS phase. Gate on a stored schema fingerprint if
16
+ // this rebuild cost ever shows up in analyze profiles.
17
+ await dropFTSIndex(table, indexName);
6
18
  await createFTSIndex(table, indexName, [...properties]);
7
19
  options?.onIndexReady?.(table, indexName);
8
20
  }
9
21
  }
10
22
  export async function verifySearchFTSIndexes(executeQuery) {
11
- const safeIdentifier = (value) => {
12
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
13
- throw new Error(`Invalid FTS identifier: ${value}`);
14
- }
15
- return value;
16
- };
23
+ // Read the catalog once and check each configured index both EXISTS and
24
+ // covers its expected columns. A queryability-only probe (CALL QUERY_FTS_INDEX
25
+ // ... catch) is not enough: a stale `name+content`-only index left on a
26
+ // pre-#2299 DB stays queryable yet silently misses `description`, so the probe
27
+ // would pass while doc-comment search is still broken (#2299). SHOW_INDEXES
28
+ // exposes `property_names` (STRING[]) per index, so we assert coverage directly.
29
+ const rows = await executeQuery('CALL SHOW_INDEXES() RETURN *');
30
+ const propsByIndex = new Map();
31
+ for (const row of rows) {
32
+ if (typeof row !== 'object' || row === null)
33
+ continue;
34
+ const record = row;
35
+ const indexName = record.index_name;
36
+ const propertyNames = record.property_names;
37
+ if (typeof indexName !== 'string' || !Array.isArray(propertyNames))
38
+ continue;
39
+ propsByIndex.set(indexName, propertyNames.filter((p) => typeof p === 'string'));
40
+ }
17
41
  const missing = [];
18
- for (const { table, indexName } of FTS_INDEXES) {
19
- const safeTable = safeIdentifier(table);
20
- const safeIndex = safeIdentifier(indexName);
21
- const probe = `
22
- CALL QUERY_FTS_INDEX('${safeTable}', '${safeIndex}', '__gitnexus_fts_probe__', conjunctive := false)
23
- RETURN score
24
- LIMIT 1
25
- `;
26
- try {
27
- await executeQuery(probe);
28
- }
29
- catch {
42
+ for (const { table, indexName, properties } of FTS_INDEXES) {
43
+ const actual = propsByIndex.get(indexName);
44
+ // Absent from the catalog, or present but not covering every expected column.
45
+ if (!actual || !properties.every((p) => actual.includes(p))) {
30
46
  missing.push(`${table}.${indexName}`);
31
47
  }
32
48
  }
@@ -1,7 +1,40 @@
1
+ // Shared by both index creation (`createSearchFTSIndexes`) and querying
2
+ // (`searchFTSFromLbug` / `verifySearchFTSIndexes`) — the single source of truth
3
+ // for which tables/columns are full-text searchable. Adding `description` here
4
+ // makes doc comments (Javadoc/KDoc/JSDoc/Doxygen/godoc/RDoc) keyword-searchable
5
+ // once they are populated by `descriptionExtractor` (#2270/#2286, issue #2299).
6
+ //
7
+ // IMPORTANT: every property must be a real column on its table (see
8
+ // `core/lbug/schema.ts`). `File` has no `description` column, so it stays
9
+ // name+content. All other entries below carry a `description` column.
10
+ //
11
+ // Tables beyond the original 5 mirror `EMBEDDABLE_LABELS` (embeddings/types.ts):
12
+ // indexing the same set keeps a symbol's doc comment both keyword- and
13
+ // semantically-searchable.
14
+ const FTS_PROPERTIES = ['name', 'content', 'description'];
1
15
  export const FTS_INDEXES = [
16
+ // File has no `description` column — keep it name+content only.
2
17
  { table: 'File', indexName: 'file_fts', properties: ['name', 'content'] },
3
- { table: 'Function', indexName: 'function_fts', properties: ['name', 'content'] },
4
- { table: 'Class', indexName: 'class_fts', properties: ['name', 'content'] },
5
- { table: 'Method', indexName: 'method_fts', properties: ['name', 'content'] },
6
- { table: 'Interface', indexName: 'interface_fts', properties: ['name', 'content'] },
18
+ // Original 5 (minus File) gain `description`.
19
+ { table: 'Function', indexName: 'function_fts', properties: FTS_PROPERTIES },
20
+ { table: 'Class', indexName: 'class_fts', properties: FTS_PROPERTIES },
21
+ { table: 'Method', indexName: 'method_fts', properties: FTS_PROPERTIES },
22
+ { table: 'Interface', indexName: 'interface_fts', properties: FTS_PROPERTIES },
23
+ // Remaining EMBEDDABLE_LABELS symbol tables — all CODE_ELEMENT_BASE-shaped
24
+ // (or a superset), so all carry name + content + description columns.
25
+ { table: 'Constructor', indexName: 'constructor_fts', properties: FTS_PROPERTIES },
26
+ { table: 'Struct', indexName: 'struct_fts', properties: FTS_PROPERTIES },
27
+ { table: 'Enum', indexName: 'enum_fts', properties: FTS_PROPERTIES },
28
+ { table: 'Trait', indexName: 'trait_fts', properties: FTS_PROPERTIES },
29
+ { table: 'Impl', indexName: 'impl_fts', properties: FTS_PROPERTIES },
30
+ { table: 'Macro', indexName: 'macro_fts', properties: FTS_PROPERTIES },
31
+ { table: 'Namespace', indexName: 'namespace_fts', properties: FTS_PROPERTIES },
32
+ { table: 'TypeAlias', indexName: 'type_alias_fts', properties: FTS_PROPERTIES },
33
+ { table: 'Typedef', indexName: 'typedef_fts', properties: FTS_PROPERTIES },
34
+ { table: 'Const', indexName: 'const_fts', properties: FTS_PROPERTIES },
35
+ { table: 'Property', indexName: 'property_fts', properties: FTS_PROPERTIES },
36
+ { table: 'Record', indexName: 'record_fts', properties: FTS_PROPERTIES },
37
+ { table: 'Union', indexName: 'union_fts', properties: FTS_PROPERTIES },
38
+ { table: 'Static', indexName: 'static_fts', properties: FTS_PROPERTIES },
39
+ { table: 'Variable', indexName: 'variable_fts', properties: FTS_PROPERTIES },
7
40
  ];
@@ -52,7 +52,7 @@ import { fileURLToPath } from 'url';
52
52
  // the main thread (the #1983 OOM). Because the two stores share this version,
53
53
  // any future change to the `ParsedFile` serialization shape MUST bump
54
54
  // SCHEMA_BUMP so both invalidate in lockstep.
55
- const SCHEMA_BUMP = 7; // #2138 Part 2: ExtractedDecoratorRoute gained `handlerName` (route handler symbol resolution)
55
+ const SCHEMA_BUMP = 8; // #2288: ParseWorkerResult gained `springTypes` (Spring interface-inheritance) + `decoratorRoutes` semantics changed (interface routes suppressed at extraction, inherited routes appended in parse-impl)
56
56
  const GITNEXUS_PKG_VERSION = (() => {
57
57
  try {
58
58
  // package.json sits at gitnexus/package.json — two levels up from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.9-rc.20",
3
+ "version": "1.6.9-rc.22",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -70,6 +70,7 @@ const LBUG_NATIVE = [
70
70
  'test/integration/local-backend-calltool.test.ts',
71
71
  'test/integration/search-core.test.ts',
72
72
  'test/integration/search-pool.test.ts',
73
+ 'test/integration/fts-description-search.test.ts',
73
74
  'test/integration/staleness-and-stability.test.ts',
74
75
  'test/integration/analyze-wal-checkpoint-failure.test.ts',
75
76
  ];