gitnexus 1.6.11-rc.32 → 1.6.11-rc.33

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.
@@ -324,7 +324,9 @@ export function resolveRouteHandlerSymbols(model, extractedRoutes, decoratorRout
324
324
  : uniqueById(model.methods.lookupAllByOwner(owner.nodeId, member))?.nodeId;
325
325
  };
326
326
  const claim = (routePath, prefix, httpMethod, symbolId) => {
327
- if (!routePath)
327
+ // An empty path is a valid, pathless mapping and normalizes to either `/`
328
+ // or its class/router prefix. Only null means the extractor had no route.
329
+ if (routePath === null)
328
330
  return;
329
331
  const url = normalizeExtractedRoutePath(routePath, prefix);
330
332
  const key = routeNodeKey(normalizeRouteMethod(httpMethod), url);
@@ -123,7 +123,14 @@ export function parseSpringAnnotationArguments(annotationText) {
123
123
  if (body.length === 0)
124
124
  return [];
125
125
  const rawArguments = splitTopLevel(body, ',');
126
- if (rawArguments === null || rawArguments.some((argument) => argument.length === 0))
126
+ if (rawArguments === null)
127
+ return null;
128
+ // Kotlin (and some formatters) allow a trailing comma. An empty *middle*
129
+ // argument is still invalid and fail-closed.
130
+ while (rawArguments.at(-1)?.length === 0) {
131
+ rawArguments.pop();
132
+ }
133
+ if (rawArguments.some((argument) => argument.length === 0))
127
134
  return null;
128
135
  const parsed = [];
129
136
  for (const raw of rawArguments) {
@@ -30,6 +30,8 @@ import { kotlinVariableConfig } from '../variable-extractors/configs/jvm.js';
30
30
  import { collectKotlinCaptureSideChannel, emitKotlinScopeCaptures, interpretKotlinImport, interpretKotlinTypeBinding, kotlinArityCompatibility, kotlinBindingScopeFor, kotlinImportOwningScope, kotlinMergeBindings, kotlinReceiverBinding, } from './kotlin/index.js';
31
31
  import { synthesizeLombokAccessors } from './kotlin/lombok-synthesizer.js';
32
32
  import { extractKotlinRuntimeSymbolProperties, kotlinRuntimeSymbolStrategy, } from './kotlin/spring-actuator.js';
33
+ import { extractKotlinSpringRoutes } from '../route-extractors/kotlin-spring.js';
34
+ import { extractKotlinModuleConstants, foldKotlinOperands, } from '../route-extractors/kotlin-const-resolver.js';
33
35
  /** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
34
36
  * Kotlin grammar uses function_declaration for both top-level functions and class methods.
35
37
  * Returns true when the captured definition node has a class_body ancestor. */
@@ -188,4 +190,8 @@ export const kotlinProvider = defineLanguage({
188
190
  receiverBinding: kotlinReceiverBinding,
189
191
  arityCompatibility: kotlinArityCompatibility,
190
192
  synthesizeStructureMembers: synthesizeLombokAccessors,
193
+ // ── Spring decorator routes + composed path constants (#3130) ──
194
+ extractDecoratorRoutes: extractKotlinSpringRoutes,
195
+ extractModuleConstants: extractKotlinModuleConstants,
196
+ foldRoutePathOperands: foldKotlinOperands,
191
197
  });
@@ -123,16 +123,11 @@
123
123
  *
124
124
  * WHERE THIS IS WIRED. Java reaches its binding from BOTH layers: the group
125
125
  * extractor (`group/extractors/http-patterns/java.ts`) and the ingestion
126
- * provider (`languages/java.ts`, via `extractModuleConstants` +
127
- * `foldRoutePathOperands`). Kotlin is wired into the GROUP layer only, because
128
- * the ingestion fold in `pipeline-phases/parse-impl.ts` runs exclusively over
129
- * `decoratorRoutes` — and `languages/kotlin.ts` declares no
130
- * `extractDecoratorRoutes`, since the ingestion Spring extractor (`spring.ts`)
131
- * is bound to `tree-sitter-java` and its node types. Declaring the constant
132
- * hooks on the Kotlin provider today would harvest a map on every Kotlin file
133
- * that nothing consumes. An ingestion-side Kotlin route extractor is the
134
- * prerequisite; when it lands, this binding is what its provider hooks should
135
- * point at, and no change here is needed.
126
+ * provider (`languages/java.ts`). Kotlin now does the same: the group extractor
127
+ * (`group/extractors/http-patterns/kotlin.ts`) plus `languages/kotlin.ts`
128
+ * (`extractDecoratorRoutes`, `extractModuleConstants`, `foldRoutePathOperands`).
129
+ * The dedicated ingestion walker is `route-extractors/kotlin-spring.ts`; it
130
+ * does not reuse Java `spring.ts`.
136
131
  */
137
132
  import type Parser from 'tree-sitter';
138
133
  import { type ModuleConstants, type Operand, type RepoConstants } from './constant-resolver.js';
@@ -123,16 +123,11 @@
123
123
  *
124
124
  * WHERE THIS IS WIRED. Java reaches its binding from BOTH layers: the group
125
125
  * extractor (`group/extractors/http-patterns/java.ts`) and the ingestion
126
- * provider (`languages/java.ts`, via `extractModuleConstants` +
127
- * `foldRoutePathOperands`). Kotlin is wired into the GROUP layer only, because
128
- * the ingestion fold in `pipeline-phases/parse-impl.ts` runs exclusively over
129
- * `decoratorRoutes` — and `languages/kotlin.ts` declares no
130
- * `extractDecoratorRoutes`, since the ingestion Spring extractor (`spring.ts`)
131
- * is bound to `tree-sitter-java` and its node types. Declaring the constant
132
- * hooks on the Kotlin provider today would harvest a map on every Kotlin file
133
- * that nothing consumes. An ingestion-side Kotlin route extractor is the
134
- * prerequisite; when it lands, this binding is what its provider hooks should
135
- * point at, and no change here is needed.
126
+ * provider (`languages/java.ts`). Kotlin now does the same: the group extractor
127
+ * (`group/extractors/http-patterns/kotlin.ts`) plus `languages/kotlin.ts`
128
+ * (`extractDecoratorRoutes`, `extractModuleConstants`, `foldRoutePathOperands`).
129
+ * The dedicated ingestion walker is `route-extractors/kotlin-spring.ts`; it
130
+ * does not reuse Java `spring.ts`.
136
131
  */
137
132
  import { unquoteSpringLiteral } from './spring-shared.js';
138
133
  import { MAX_FOLD_LENGTH, unfoldableDeclarationsOf, } from './constant-resolver.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Kotlin Spring MVC route annotations for the ingestion pipeline (#3130).
3
+ *
4
+ * This module only walks a caller-provided tree. In particular, it does not
5
+ * import or load tree-sitter-kotlin at module initialization, so platforms
6
+ * without the optional grammar can still load the language-provider registry.
7
+ */
8
+ import type Parser from 'tree-sitter';
9
+ import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
10
+ /**
11
+ * Extract direct Spring handler methods from concrete Kotlin RestControllers.
12
+ */
13
+ export declare function extractKotlinSpringRoutes(tree: Parser.Tree, filePath: string, lineOffset?: number): ExtractedDecoratorRoute[];
@@ -0,0 +1,266 @@
1
+ import { intersectSpringHttpMethods, springAnnotationHttpMethods, unquoteSpringLiteral, } from './spring-shared.js';
2
+ import { extractKotlinModuleConstants, parseKotlinConstOperands, unfoldableDeclarationsOf, unquoteKotlinIdentifier, } from './kotlin-const-resolver.js';
3
+ /** Direct declaration annotations in source order. */
4
+ function declarationAnnotations(node) {
5
+ const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
6
+ return modifiers?.namedChildren.filter((child) => child.type === 'annotation') ?? [];
7
+ }
8
+ /** `@Foo`, `@Foo(...)`, or `@a.b.Foo(...)` → `Foo`. */
9
+ function annotationName(annotation) {
10
+ const constructor = annotation.namedChildren.find((child) => child.type === 'constructor_invocation');
11
+ const userType = annotation.namedChildren.find((child) => child.type === 'user_type') ??
12
+ constructor?.namedChildren.find((child) => child.type === 'user_type');
13
+ const identifiers = userType?.namedChildren.filter((child) => child.type === 'type_identifier') ?? [];
14
+ const identifier = identifiers.at(-1);
15
+ return identifier ? unquoteKotlinIdentifier(identifier.text) : null;
16
+ }
17
+ function annotationArguments(annotation) {
18
+ const constructor = annotation.namedChildren.find((child) => child.type === 'constructor_invocation');
19
+ const values = constructor?.namedChildren.find((child) => child.type === 'value_arguments');
20
+ return values?.namedChildren.filter((child) => child.type === 'value_argument') ?? [];
21
+ }
22
+ /**
23
+ * Kotlin represents positional and named annotation arguments with the same
24
+ * `value_argument` node. A direct `=` token distinguishes the named form.
25
+ */
26
+ function readAnnotationArgument(argument) {
27
+ const named = argument.children.some((child) => child.type === '=');
28
+ if (!named) {
29
+ const expression = argument.namedChild(0);
30
+ return expression ? { expression } : null;
31
+ }
32
+ const key = argument.namedChild(0);
33
+ const expression = argument.namedChild(1);
34
+ if (key?.type !== 'simple_identifier' || !expression)
35
+ return null;
36
+ return { name: unquoteKotlinIdentifier(key.text), expression };
37
+ }
38
+ function routeArguments(annotation) {
39
+ const out = [];
40
+ for (const argument of annotationArguments(annotation)) {
41
+ const parsed = readAnnotationArgument(argument);
42
+ if (!parsed)
43
+ return null;
44
+ if (parsed.name === undefined || parsed.name === 'path' || parsed.name === 'value') {
45
+ out.push(parsed);
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+ /** A fully static Kotlin string literal: no `$name` or `${expr}` children. */
51
+ function isPlainStringLiteral(node) {
52
+ return (node.type === 'string_literal' &&
53
+ node.namedChildren.every((child) => child.type === 'string_content'));
54
+ }
55
+ /**
56
+ * Empty `[]` / `arrayOf()` is Spring "no path", not an unresolvable prefix.
57
+ * tree-sitter-kotlin may put a zero-width recovery child inside `[]`.
58
+ */
59
+ function isEmptyKotlinPathCollection(node) {
60
+ if (node.type === 'collection_literal') {
61
+ return node.namedChildren.every((child) => child.text.length === 0);
62
+ }
63
+ if (node.type !== 'call_expression')
64
+ return false;
65
+ const callee = node.namedChild(0);
66
+ if (callee?.type !== 'simple_identifier' || unquoteKotlinIdentifier(callee.text) !== 'arrayOf') {
67
+ return false;
68
+ }
69
+ const suffix = node.namedChildren.find((child) => child.type === 'call_suffix');
70
+ const args = suffix?.namedChildren.find((child) => child.type === 'value_arguments');
71
+ if (!args)
72
+ return true;
73
+ return args.namedChildren.every((child) => child.type !== 'value_argument');
74
+ }
75
+ function isKotlinInterface(node) {
76
+ return node.children.some((child) => child.type === 'interface');
77
+ }
78
+ function isAbstractOrSealedClass(node) {
79
+ const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
80
+ return (modifiers?.namedChildren.some((child) => {
81
+ if (child.type !== 'inheritance_modifier' && child.type !== 'class_modifier') {
82
+ return false;
83
+ }
84
+ const text = child.text.trim();
85
+ return text === 'abstract' || text === 'sealed';
86
+ }) === true);
87
+ }
88
+ function directFunctions(node) {
89
+ const body = node.namedChildren.find((child) => child.type === 'class_body');
90
+ return body?.namedChildren.filter((child) => child.type === 'function_declaration') ?? [];
91
+ }
92
+ function functionName(node) {
93
+ const field = node.childForFieldName('name');
94
+ const identifier = field?.type === 'simple_identifier'
95
+ ? field
96
+ : node.namedChildren.find((child) => child.type === 'simple_identifier');
97
+ return identifier ? unquoteKotlinIdentifier(identifier.text) : null;
98
+ }
99
+ /**
100
+ * `springAnnotationHttpMethods` parses Java `{A, B}` collections.
101
+ * Translate only Kotlin `method = [A, B]` before delegating.
102
+ */
103
+ function kotlinSpringHttpMethods(name, annotation) {
104
+ if (name !== 'RequestMapping')
105
+ return springAnnotationHttpMethods(name, annotation.text);
106
+ const normalized = annotation.text.replace(/(\bmethod\s*=\s*)\[([^\]]*)\]/gs, (_match, assignment, values) => `${assignment}{${values}}`);
107
+ return springAnnotationHttpMethods(name, normalized);
108
+ }
109
+ function typeName(node) {
110
+ const identifier = node.children.find((child) => child.type === 'type_identifier');
111
+ return identifier ? unquoteKotlinIdentifier(identifier.text) : null;
112
+ }
113
+ /**
114
+ * Qualified enclosing type paths, innermost first. Kotlin companion members
115
+ * are keyed through their enclosing class, so companion_object itself adds no
116
+ * segment.
117
+ */
118
+ function enclosingTypeNames(node) {
119
+ const simpleNames = [];
120
+ for (let current = node.parent; current; current = current.parent) {
121
+ if (current.type !== 'class_declaration' && current.type !== 'object_declaration')
122
+ continue;
123
+ const name = typeName(current);
124
+ if (name)
125
+ simpleNames.push(name);
126
+ }
127
+ return simpleNames.map((_, index) => simpleNames.slice(index).reverse().join('.'));
128
+ }
129
+ function declarationExists(constants, name) {
130
+ return (constants.literals.has(name) ||
131
+ constants.exprs.has(name) ||
132
+ unfoldableDeclarationsOf(constants).has(name));
133
+ }
134
+ /**
135
+ * The provider fold hook has a language-neutral three-argument signature and
136
+ * cannot receive a Kotlin reference site's enclosing type chain. Qualify only
137
+ * names whose owner is proven by this same tree; unresolved/imported names are
138
+ * left untouched for the repo-wide resolver.
139
+ */
140
+ function qualifySameFileOperands(operands, functionNode, constants) {
141
+ const enclosingTypes = enclosingTypeNames(functionNode);
142
+ return operands.map((operand) => {
143
+ if (operand.kind === 'literal')
144
+ return operand;
145
+ for (const owner of enclosingTypes) {
146
+ const qualified = `${owner}.${operand.name}`;
147
+ if (declarationExists(constants, qualified)) {
148
+ return { kind: 'ref', name: qualified };
149
+ }
150
+ }
151
+ return operand;
152
+ });
153
+ }
154
+ /**
155
+ * Read the one optional class-level RequestMapping. A present route member must
156
+ * be exactly one plain string literal, an empty `[]`/`arrayOf()` (no prefix),
157
+ * or absent; constants, interpolation, non-empty collections, duplicate
158
+ * mappings, and dynamic expressions fail closed for the whole class.
159
+ */
160
+ function classMapping(annotations) {
161
+ const mappings = annotations.filter((annotation) => annotationName(annotation) === 'RequestMapping');
162
+ if (mappings.length === 0)
163
+ return { prefix: '', methods: ['*'] };
164
+ if (mappings.length !== 1)
165
+ return null;
166
+ const mapping = mappings[0];
167
+ const paths = routeArguments(mapping);
168
+ if (paths === null || paths.length > 1)
169
+ return null;
170
+ let prefix = '';
171
+ if (paths.length === 1) {
172
+ const path = paths[0].expression;
173
+ if (isEmptyKotlinPathCollection(path)) {
174
+ prefix = '';
175
+ }
176
+ else if (isPlainStringLiteral(path)) {
177
+ const literal = unquoteSpringLiteral(path.text);
178
+ if (literal === null)
179
+ return null;
180
+ prefix = literal;
181
+ }
182
+ else {
183
+ return null;
184
+ }
185
+ }
186
+ const methods = kotlinSpringHttpMethods('RequestMapping', mapping);
187
+ return methods.length === 0 ? null : { prefix, methods };
188
+ }
189
+ /**
190
+ * Extract direct Spring handler methods from concrete Kotlin RestControllers.
191
+ */
192
+ export function extractKotlinSpringRoutes(tree, filePath, lineOffset = 0) {
193
+ const routes = [];
194
+ let moduleConstants;
195
+ for (const classNode of tree.rootNode.descendantsOfType('class_declaration')) {
196
+ if (isKotlinInterface(classNode) || isAbstractOrSealedClass(classNode))
197
+ continue;
198
+ const annotations = declarationAnnotations(classNode);
199
+ const annotationNames = annotations.map(annotationName);
200
+ if (!annotationNames.includes('RestController'))
201
+ continue;
202
+ if (annotationNames.includes('FeignClient'))
203
+ continue;
204
+ const ownerMapping = classMapping(annotations);
205
+ if (ownerMapping === null)
206
+ continue;
207
+ for (const functionNode of directFunctions(classNode)) {
208
+ const handlerName = functionName(functionNode);
209
+ if (!handlerName)
210
+ continue;
211
+ for (const annotation of declarationAnnotations(functionNode)) {
212
+ const decoratorName = annotationName(annotation);
213
+ if (!decoratorName)
214
+ continue;
215
+ const methodMethods = kotlinSpringHttpMethods(decoratorName, annotation);
216
+ const methods = intersectSpringHttpMethods(ownerMapping.methods, methodMethods);
217
+ if (methods.length === 0)
218
+ continue;
219
+ const paths = routeArguments(annotation);
220
+ if (paths === null || paths.length > 1)
221
+ continue;
222
+ let routePath = '';
223
+ let routePathExpr;
224
+ let routePathOperands;
225
+ if (paths.length === 1) {
226
+ const expression = paths[0].expression;
227
+ if (isEmptyKotlinPathCollection(expression)) {
228
+ routePath = '';
229
+ }
230
+ else if (isPlainStringLiteral(expression)) {
231
+ const literal = unquoteSpringLiteral(expression.text);
232
+ if (literal === null)
233
+ continue;
234
+ routePath = literal;
235
+ }
236
+ else {
237
+ const operands = parseKotlinConstOperands(expression);
238
+ if (operands === null)
239
+ continue;
240
+ moduleConstants ??= extractKotlinModuleConstants(tree);
241
+ routePathExpr = expression.text;
242
+ routePathOperands = qualifySameFileOperands(operands, functionNode, moduleConstants);
243
+ }
244
+ }
245
+ for (const httpMethod of methods) {
246
+ routes.push({
247
+ filePath,
248
+ routePath,
249
+ httpMethod,
250
+ decoratorName,
251
+ lineNumber: annotation.startPosition.row + lineOffset,
252
+ ...(ownerMapping.prefix ? { prefix: ownerMapping.prefix } : {}),
253
+ handlerName,
254
+ ...(routePathExpr === undefined
255
+ ? {}
256
+ : {
257
+ routePathExpr,
258
+ routePathOperands,
259
+ }),
260
+ });
261
+ }
262
+ }
263
+ }
264
+ }
265
+ return routes;
266
+ }
@@ -946,6 +946,7 @@ export function extractORMQueries(filePath, content, out) {
946
946
  // import the function and its types directly from `route-extractors/`.
947
947
  import { extractFastAPIRouterBindings } from '../route-extractors/fastapi-router-bindings.js';
948
948
  import { parseConstOperands, } from '../route-extractors/python-const-resolver.js';
949
+ import { unfoldableDeclarationsOf } from '../route-extractors/constant-resolver.js';
949
950
  /**
950
951
  * Report a non-fatal worker issue to the pool over IPC so a caught error is not
951
952
  * invisible to the operator (#2264). The pool logs it on the main thread AND
@@ -2331,10 +2332,13 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
2331
2332
  // without booting a worker.
2332
2333
  if (provider.extractModuleConstants && shouldHarvestModuleConstants(provider, parseContent)) {
2333
2334
  const constants = provider.extractModuleConstants(tree);
2335
+ const topLevelDeclarations = constants.topLevelDeclarations;
2334
2336
  if (constants.literals.size > 0 ||
2335
2337
  constants.exprs.size > 0 ||
2336
2338
  constants.imports.size > 0 ||
2337
- (constants.wildcardImports?.length ?? 0) > 0) {
2339
+ (constants.wildcardImports?.length ?? 0) > 0 ||
2340
+ unfoldableDeclarationsOf(constants).size > 0 ||
2341
+ (topLevelDeclarations instanceof Set && topLevelDeclarations.size > 0)) {
2338
2342
  (result.moduleConstants ??= []).push({ filePath: file.path, constants });
2339
2343
  }
2340
2344
  }
@@ -711,7 +711,11 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
711
711
  // (89) and above every in-flight claim found by scanning open PRs'
712
712
  // parse-cache.ts at their exact head SHAs (highest other open claim was still
713
713
  // ≤88). RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
714
- const SCHEMA_BUMP = 90;
714
+ // 90 -> 91 (#3130): Kotlin providers now emit Spring decoratorRoutes plus
715
+ // ModuleConstants shadow metadata. A warm v90 cache would replay unchanged
716
+ // Kotlin files with neither route candidates nor the constant declarations
717
+ // needed to fold them, leaving the new ingestion path silently inert.
718
+ const SCHEMA_BUMP = 91;
715
719
  const GITNEXUS_PKG_VERSION = (() => {
716
720
  try {
717
721
  // 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.11-rc.32",
3
+ "version": "1.6.11-rc.33",
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",