gitnexus 1.6.6-rc.37 → 1.6.6-rc.39

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.
@@ -156,7 +156,7 @@ async function fileExists(filePath) {
156
156
  * - If file exists without GitNexus section: append
157
157
  * - If file exists with GitNexus section: replace that section
158
158
  */
159
- async function upsertGitNexusSection(filePath, content, projectName, stats) {
159
+ async function upsertGitNexusSection(filePath, content, projectName, stats, noStats) {
160
160
  const exists = await fileExists(filePath);
161
161
  if (!exists) {
162
162
  await fs.writeFile(filePath, content, 'utf-8');
@@ -189,13 +189,22 @@ async function upsertGitNexusSection(filePath, content, projectName, stats) {
189
189
  // like `({target: "symbolName", direction: "upstream"})`
190
190
  // when noStats is set
191
191
  // Passing projectName + stats explicitly makes the contract obvious.
192
- // noStats controls template generation, not keep-section stat updates the user opted into a stats line by keeping it.
192
+ // --no-stats wins in the keep path too (#1706): a lean block committed
193
+ // to git would otherwise churn the volatile counts on every analyze,
194
+ // producing no-value merge conflicts between branches. Under noStats we
195
+ // drop the parenthetical but still refresh the project name so renames
196
+ // propagate.
193
197
  const newStatsInner = `${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows`;
194
- const statsLine = `Indexed as **${projectName}** (${newStatsInner})`;
198
+ const statsLine = noStats
199
+ ? `Indexed as **${projectName}**`
200
+ : `Indexed as **${projectName}** (${newStatsInner})`;
195
201
  // Match either canonical phrasing at line start (`^` with `m` flag) so we
196
202
  // cannot replace prose embedded mid-paragraph. Deliberately no `$`: text
197
- // after the closing `)` on the same line (e.g. ". MCP tools.") stays intact.
198
- const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\* \([^)]+\)/m;
203
+ // after the line on the same line (e.g. ". MCP tools.") stays intact.
204
+ // The parenthetical is optional so a count-free line left by a prior
205
+ // --no-stats run still matches — letting the name refresh, and letting
206
+ // counts return if --no-stats is later dropped.
207
+ const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\*(?: \([^)]+\))?/m;
199
208
  if (statsPattern.test(existingSection)) {
200
209
  const updatedSection = existingSection.replace(statsPattern, statsLine);
201
210
  const before = existingContent.substring(0, startIdx);
@@ -300,11 +309,11 @@ export async function generateAIContextFiles(repoPath, _storagePath, projectName
300
309
  if (!options?.skipAgentsMd) {
301
310
  // Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
302
311
  const agentsPath = path.join(repoPath, 'AGENTS.md');
303
- const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats);
312
+ const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats, options?.noStats);
304
313
  createdFiles.push(`AGENTS.md (${agentsResult})`);
305
314
  // Create CLAUDE.md (for Claude Code)
306
315
  const claudePath = path.join(repoPath, 'CLAUDE.md');
307
- const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats);
316
+ const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats, options?.noStats);
308
317
  createdFiles.push(`CLAUDE.md (${claudeResult})`);
309
318
  }
310
319
  else {
@@ -23,6 +23,7 @@ export function emitKotlinScopeCaptures(sourceText, _filePath, cachedTree) {
23
23
  const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
24
24
  out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
25
25
  out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
26
+ out.push(...synthesizeKotlinSmartCastBindings(tree.rootNode));
26
27
  for (const match of getKotlinScopeQuery().matches(tree.rootNode)) {
27
28
  const grouped = {};
28
29
  for (const capture of match.captures) {
@@ -51,6 +52,27 @@ export function emitKotlinScopeCaptures(sourceText, _filePath, cachedTree) {
51
52
  if (navNode === null || !shouldEmitReadMember(navNode))
52
53
  continue;
53
54
  }
55
+ // Virtual dispatch via constructor type (#1762). When a property
56
+ // declaration carries BOTH an explicit type annotation AND a
57
+ // constructor-style call value (e.g. `val animal: Animal = Dog()`),
58
+ // suppress the annotation capture so the constructor-inferred
59
+ // binding wins. This matches Kotlin's virtual dispatch semantics:
60
+ // `animal.speak()` should resolve to the overriding `Dog.speak`
61
+ // (the dynamic type), not `Animal.speak` (the static annotation).
62
+ //
63
+ // The annotation source has higher precedence than constructor-
64
+ // inferred in the generic scope-extractor (see
65
+ // `typeBindingStrength` in scope-extractor.ts), so the only way to
66
+ // make the constructor type prevail is to drop the annotation at
67
+ // emission time.
68
+ if (grouped['@type-binding.annotation'] !== undefined &&
69
+ grouped['@type-binding.name'] !== undefined &&
70
+ grouped['@type-binding.type'] !== undefined) {
71
+ const annotation = grouped['@type-binding.annotation'];
72
+ if (propertyDeclHasConstructorValue(tree.rootNode, annotation.range)) {
73
+ continue;
74
+ }
75
+ }
54
76
  if (grouped['@scope.function'] !== undefined) {
55
77
  out.push(grouped);
56
78
  const fnNode = findNodeAtRange(tree.rootNode, grouped['@scope.function'].range, 'function_declaration');
@@ -118,12 +140,107 @@ function synthesizeKotlinLoopBindings(rootNode, returnTypes) {
118
140
  }
119
141
  return out;
120
142
  }
143
+ /**
144
+ * Synthesize narrowed type-bindings for Kotlin smart-cast forms — issue #1758.
145
+ *
146
+ * For each `when (x) { is T -> body }` and `if (x is T) body`, emits a
147
+ * `@type-binding.annotation` capture binding `x → T` anchored on the body
148
+ * node. The capture lands in the matching `@scope.block` scope (see query.ts
149
+ * smart-cast scopes), shadowing the outer parameter binding for calls inside
150
+ * the body without leaking across sibling arms or to `else`.
151
+ *
152
+ * Only narrows when:
153
+ * - the `when` subject is a `simple_identifier` (not a call or field chain);
154
+ * - the `when_entry` condition is exactly one `type_test` (skips `!is`,
155
+ * compound conditions, range/`in`/value patterns);
156
+ * - the `if_expression` condition is a `check_expression` of the form
157
+ * `<simple_identifier> is <user_type>` and the then-branch is a
158
+ * `control_structure_body`.
159
+ *
160
+ * `else` arms and non-narrowing conditions emit nothing — the fall-through to
161
+ * the outer scope's declared type is the correct semantic.
162
+ */
163
+ function synthesizeKotlinSmartCastBindings(rootNode) {
164
+ const out = [];
165
+ for (const whenNode of descendantsOfType(rootNode, 'when_expression')) {
166
+ const subjectName = extractWhenSubjectIdentifier(whenNode);
167
+ if (subjectName === null)
168
+ continue;
169
+ for (const entry of whenNode.namedChildren) {
170
+ if (entry.type !== 'when_entry')
171
+ continue;
172
+ const narrowedType = extractIsTestTargetType(entry);
173
+ if (narrowedType === null)
174
+ continue;
175
+ const body = entry.namedChildren.find((child) => child.type === 'control_structure_body');
176
+ if (body === undefined)
177
+ continue;
178
+ out.push(buildNarrowedTypeBindingCapture(subjectName.node, body, narrowedType));
179
+ }
180
+ }
181
+ for (const ifNode of descendantsOfType(rootNode, 'if_expression')) {
182
+ const check = ifNode.namedChildren.find((child) => child.type === 'check_expression');
183
+ if (check === undefined)
184
+ continue;
185
+ const subject = check.namedChildren.find((child) => child.type === 'simple_identifier');
186
+ const typeNode = check.namedChildren.find((child) => isKotlinTypeNode(child));
187
+ if (subject === undefined || typeNode === undefined)
188
+ continue;
189
+ // The first control_structure_body sibling is the then-branch; else
190
+ // branches (when present) appear as the second control_structure_body
191
+ // and are intentionally not narrowed.
192
+ const body = ifNode.namedChildren.find((child) => child.type === 'control_structure_body');
193
+ if (body === undefined)
194
+ continue;
195
+ out.push(buildNarrowedTypeBindingCapture(subject, body, typeNode));
196
+ }
197
+ return out;
198
+ }
199
+ function extractWhenSubjectIdentifier(whenNode) {
200
+ const subject = whenNode.namedChildren.find((child) => child.type === 'when_subject');
201
+ if (subject === undefined)
202
+ return null;
203
+ const ident = subject.namedChildren.find((child) => child.type === 'simple_identifier');
204
+ return ident === undefined ? null : { node: ident };
205
+ }
206
+ function extractIsTestTargetType(whenEntry) {
207
+ const condition = whenEntry.namedChildren.find((child) => child.type === 'when_condition');
208
+ if (condition === undefined)
209
+ return null;
210
+ // Exactly one when_condition child must be a positive type_test.
211
+ // Compound conditions (multiple `when_condition` siblings joined with
212
+ // commas in some grammars) or negated `!is` are not safe to narrow.
213
+ if (condition.namedChildCount !== 1)
214
+ return null;
215
+ const test = condition.namedChild(0);
216
+ if (test === null || test.type !== 'type_test')
217
+ return null;
218
+ // `!is` produces a different node (`negated_type_test` in some grammars,
219
+ // or an extra `!` child in others) — defend by checking text prefix.
220
+ if (test.text.trim().startsWith('!'))
221
+ return null;
222
+ return test.namedChildren.find((child) => isKotlinTypeNode(child)) ?? null;
223
+ }
224
+ function buildNarrowedTypeBindingCapture(subject, bodyAnchor, typeNode) {
225
+ return {
226
+ '@type-binding.annotation': nodeToCapture('@type-binding.annotation', bodyAnchor),
227
+ '@type-binding.name': syntheticCapture('@type-binding.name', subject, subject.text),
228
+ '@type-binding.type': syntheticCapture('@type-binding.type', typeNode, normalizeKotlinType(typeNode.text)),
229
+ // Marker consumed by `kotlinBindingScopeFor` in simple-hooks.ts to
230
+ // override the scope-extractor's auto-hoist. Unbraced arm bodies
231
+ // (`is User -> obj.save()`) make the body anchor coincide with the
232
+ // Block scope's range; without this marker the binding would hoist
233
+ // to the enclosing function scope and lose its arm-local narrowing.
234
+ '@type-binding.narrowed': syntheticCapture('@type-binding.narrowed', bodyAnchor, '1'),
235
+ };
236
+ }
121
237
  function synthesizeKotlinLocalAssignmentBindings(rootNode, returnTypes) {
122
238
  const out = [];
239
+ const classMembers = collectKotlinClassMembers(rootNode);
123
240
  for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
124
241
  const localTypes = new Map();
125
242
  for (const prop of descendantsOfType(fnNode, 'property_declaration')) {
126
- const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes);
243
+ const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes, classMembers);
127
244
  if (inferred === null)
128
245
  continue;
129
246
  localTypes.set(inferred.name.text, inferred.rawType);
@@ -138,6 +255,69 @@ function synthesizeKotlinLocalAssignmentBindings(rootNode, returnTypes) {
138
255
  }
139
256
  return out;
140
257
  }
258
+ /**
259
+ * Per-file class-member index — primary-constructor `val`/`var` params,
260
+ * body property declarations, and method return types. Used by
261
+ * `inferKotlinPropertyType` to walk single-level field and method chains
262
+ * like `val addr = user.address` and `val city = addr.getCity()` (#1760).
263
+ *
264
+ * Indexes by simple class name only. Multi-class collisions inside a
265
+ * single file will pick whichever class was visited last for that name
266
+ * — acceptable because Kotlin forbids same-name top-level classes in
267
+ * one file and per-file resolution is the design boundary here.
268
+ */
269
+ function collectKotlinClassMembers(rootNode) {
270
+ const fields = new Map();
271
+ const methods = new Map();
272
+ for (const cls of descendantsOfType(rootNode, 'class_declaration')) {
273
+ const className = cls.namedChildren.find((child) => child.type === 'type_identifier')?.text;
274
+ if (className === undefined)
275
+ continue;
276
+ const fmap = fields.get(className) ?? new Map();
277
+ const mmap = methods.get(className) ?? new Map();
278
+ const primary = cls.namedChildren.find((child) => child.type === 'primary_constructor');
279
+ if (primary !== undefined) {
280
+ for (const param of primary.namedChildren) {
281
+ if (param.type !== 'class_parameter')
282
+ continue;
283
+ // Constructor params are class fields ONLY when prefixed with
284
+ // `val`/`var` (binding_pattern_kind). Plain `fn(x: Int)`-style
285
+ // params remain locals to the constructor.
286
+ if (param.namedChildren.find((c) => c.type === 'binding_pattern_kind') === undefined) {
287
+ continue;
288
+ }
289
+ const fname = param.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
290
+ const ftype = param.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
291
+ if (fname !== undefined && ftype !== undefined)
292
+ fmap.set(fname, ftype);
293
+ }
294
+ }
295
+ const body = cls.namedChildren.find((child) => child.type === 'class_body');
296
+ if (body !== undefined) {
297
+ for (const member of body.namedChildren) {
298
+ if (member.type === 'property_declaration') {
299
+ const v = member.namedChildren.find((c) => c.type === 'variable_declaration');
300
+ const fname = v?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
301
+ const ftype = v?.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
302
+ if (fname !== undefined && ftype !== undefined)
303
+ fmap.set(fname, ftype);
304
+ }
305
+ else if (member.type === 'function_declaration') {
306
+ const mname = member.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
307
+ const paramsIdx = member.namedChildren.findIndex((c) => c.type === 'function_value_parameters');
308
+ const rtype = paramsIdx < 0
309
+ ? undefined
310
+ : member.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text;
311
+ if (mname !== undefined && rtype !== undefined)
312
+ mmap.set(mname, rtype);
313
+ }
314
+ }
315
+ }
316
+ fields.set(className, fmap);
317
+ methods.set(className, mmap);
318
+ }
319
+ return { fields, methods };
320
+ }
141
321
  function collectKotlinLocalTypeTexts(fnNode, returnTypes) {
142
322
  const out = new Map();
143
323
  for (const node of descendants(fnNode)) {
@@ -169,7 +349,7 @@ function collectKotlinReturnTypeTexts(rootNode) {
169
349
  }
170
350
  return out;
171
351
  }
172
- function inferKotlinPropertyType(prop, localTypes, returnTypes) {
352
+ function inferKotlinPropertyType(prop, localTypes, returnTypes, classMembers) {
173
353
  const variable = prop.namedChildren.find((child) => child.type === 'variable_declaration');
174
354
  const name = variable?.namedChildren.find((child) => child.type === 'simple_identifier');
175
355
  if (variable === undefined || name === undefined)
@@ -183,17 +363,70 @@ function inferKotlinPropertyType(prop, localTypes, returnTypes) {
183
363
  const rawType = localTypes.get(value.text);
184
364
  return rawType === undefined ? null : { name, rawType, source: value, synthetic: true };
185
365
  }
366
+ if (value?.type === 'navigation_expression') {
367
+ // `val addr = user.address` — receiver type → field on that class (#1760).
368
+ const chained = inferKotlinNavigationFieldType(value, localTypes, classMembers);
369
+ if (chained === null)
370
+ return null;
371
+ return { name, rawType: chained, source: value, synthetic: true };
372
+ }
186
373
  if (value?.type === 'call_expression') {
187
- const callee = value.namedChildren.find((child) => child.type === 'simple_identifier');
374
+ const callee = value.namedChildren.find((child) => child.type === 'simple_identifier' || child.type === 'navigation_expression');
188
375
  if (callee === undefined)
189
376
  return null;
190
- const rawType = returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
191
- if (rawType === null)
377
+ if (callee.type === 'simple_identifier') {
378
+ const rawType = returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
379
+ if (rawType === null)
380
+ return null;
381
+ return { name, rawType, source: callee, synthetic: true };
382
+ }
383
+ // `val city = addr.getCity()` — receiver type → method return on that class (#1760).
384
+ const chained = inferKotlinNavigationCallReturnType(callee, localTypes, classMembers);
385
+ if (chained === null)
192
386
  return null;
193
- return { name, rawType, source: callee, synthetic: true };
387
+ return { name, rawType: chained, source: callee, synthetic: true };
194
388
  }
195
389
  return null;
196
390
  }
391
+ /** Resolve `receiver.field` → field's declared type, where `receiver`
392
+ * is a simple identifier whose type is in `localTypes` and `field`
393
+ * is declared on that type in `classMembers.fields`. Returns null
394
+ * when any link in the chain is unknown — safe over-conservative. */
395
+ function inferKotlinNavigationFieldType(nav, localTypes, classMembers) {
396
+ if (classMembers === undefined)
397
+ return null;
398
+ const receiver = nav.namedChild(0);
399
+ if (receiver === null || receiver.type !== 'simple_identifier')
400
+ return null;
401
+ const member = nav.namedChildren
402
+ .find((c) => c.type === 'navigation_suffix')
403
+ ?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
404
+ if (member === undefined)
405
+ return null;
406
+ const recvType = localTypes.get(receiver.text);
407
+ if (recvType === undefined)
408
+ return null;
409
+ return classMembers.fields.get(normalizeKotlinType(recvType))?.get(member) ?? null;
410
+ }
411
+ /** Resolve `receiver.method()` → method's declared return type, where
412
+ * `receiver` is a simple identifier whose type is in `localTypes` and
413
+ * `method` is declared on that type in `classMembers.methods`. */
414
+ function inferKotlinNavigationCallReturnType(navCallee, localTypes, classMembers) {
415
+ if (classMembers === undefined)
416
+ return null;
417
+ const receiver = navCallee.namedChild(0);
418
+ if (receiver === null || receiver.type !== 'simple_identifier')
419
+ return null;
420
+ const methodName = navCallee.namedChildren
421
+ .find((c) => c.type === 'navigation_suffix')
422
+ ?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
423
+ if (methodName === undefined)
424
+ return null;
425
+ const recvType = localTypes.get(receiver.text);
426
+ if (recvType === undefined)
427
+ return null;
428
+ return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
429
+ }
197
430
  function inferKotlinIterableElementType(iterable, localTypes, returnTypes) {
198
431
  if (iterable.type === 'simple_identifier') {
199
432
  const raw = localTypes.get(iterable.text);
@@ -214,7 +447,17 @@ function inferKotlinIterableElementType(iterable, localTypes, returnTypes) {
214
447
  if (callee === undefined)
215
448
  return null;
216
449
  const raw = returnTypes.get(callee.text);
217
- return raw === undefined ? null : kotlinContainerElementType(raw, 'values');
450
+ if (raw !== undefined)
451
+ return kotlinContainerElementType(raw, 'values');
452
+ // Cross-file fallback (#1759): the callee's return type is unknown
453
+ // locally because the function lives in another file. Emit the
454
+ // callee name itself as the binding's rawName; `propagateImported
455
+ // ReturnTypes` will chain-follow `loopvar → callee → <ElementType>`
456
+ // once the imported module's `callee → ElementType` mirror lands at
457
+ // module scope. If `callee` isn't actually an imported callable
458
+ // (e.g. a local lambda or unrelated symbol), chain-follow fails
459
+ // safely and no edge is emitted.
460
+ return callee.text;
218
461
  }
219
462
  return null;
220
463
  }
@@ -306,6 +549,20 @@ function shouldEmitReadMember(navNode) {
306
549
  return false;
307
550
  return true;
308
551
  }
552
+ /** True when the property_declaration anchored at `range` has a
553
+ * `call_expression` value sibling (i.e. `val x: T = Foo()`). Used to
554
+ * suppress the explicit-annotation type-binding capture so the
555
+ * constructor-inferred binding wins (#1762). */
556
+ function propertyDeclHasConstructorValue(rootNode, range) {
557
+ const propNode = findNodeAtRange(rootNode, range, 'property_declaration');
558
+ if (propNode === null)
559
+ return false;
560
+ const variable = propNode.namedChildren.find((c) => c.type === 'variable_declaration');
561
+ if (variable === undefined)
562
+ return false;
563
+ const value = propNode.namedChildren.find((c) => c.id !== variable.id && c.type !== 'binding_pattern_kind');
564
+ return value?.type === 'call_expression';
565
+ }
309
566
  function callArguments(callNode) {
310
567
  const suffix = callNode.namedChildren.find((child) => child.type === 'call_suffix');
311
568
  if (suffix === undefined)
@@ -3,4 +3,4 @@ export interface KotlinResolveContext {
3
3
  readonly fromFile: string;
4
4
  readonly allFilePaths: ReadonlySet<string>;
5
5
  }
6
- export declare function resolveKotlinImportTarget(parsedImport: ParsedImport, workspaceIndex: WorkspaceIndex): string | null;
6
+ export declare function resolveKotlinImportTarget(parsedImport: ParsedImport, workspaceIndex: WorkspaceIndex): string | readonly string[] | null;
@@ -13,19 +13,41 @@ export function resolveKotlinImportTarget(parsedImport, workspaceIndex) {
13
13
  ? parsedImport.targetRaw.slice(0, -2)
14
14
  : parsedImport.targetRaw;
15
15
  const pathLike = target.replace(/\./g, '/');
16
+ // Resolution tiers, most-specific first:
17
+ // 1. The full `pathLike` matches a `.kt`/`.kts` file directly
18
+ // (`import util.User` → `util/User.kt`).
19
+ // 2. Stripped (last-segment removed) `pathLike` matches a file
20
+ // directly (`import util.OneArg.writeAudit` → `util/OneArg.kt`,
21
+ // a class-or-object holding `writeAudit`).
22
+ // 3. Stripped `pathLike` matches a *package directory* — fan out to
23
+ // every `.kt`/`.kts` file inside it (`import models.getRepo` →
24
+ // `[models/User.kt, models/Repo.kt]`). The finalize pass walks
25
+ // each candidate and picks the one whose `localDefs` actually
26
+ // export the imported name (#1759).
27
+ // 4. Progressive prefix strip for deeper namespace aliases that
28
+ // don't map 1:1 to directories.
29
+ const stripped = pathLike.split('/').slice(0, -1).join('/');
16
30
  return (findKotlinFile(ctx.allFilePaths, pathLike) ??
17
- findKotlinFile(ctx.allFilePaths, pathLike.split('/').slice(0, -1).join('/')) ??
31
+ findKotlinExactOrSuffix(ctx.allFilePaths, stripped) ??
32
+ findKotlinPackageFiles(ctx.allFilePaths, stripped) ??
18
33
  findByProgressivePrefixStrip(ctx.allFilePaths, pathLike));
19
34
  }
20
35
  function findKotlinFile(allFilePaths, pathLike) {
36
+ return (findKotlinExactOrSuffix(allFilePaths, pathLike) ??
37
+ findKotlinDirectoryChild(allFilePaths, pathLike));
38
+ }
39
+ /** Exact (`file === pathLike+ext`) or suffix (`file ends with /pathLike+ext`)
40
+ * match — does NOT fall back to picking an arbitrary file inside a
41
+ * `pathLike/` directory. Used by the stripped-path tier in
42
+ * `resolveKotlinImportTarget` so a package import like `models.getRepo`
43
+ * delegates to `findKotlinPackageFiles` (multi-file fan-out) instead of
44
+ * silently committing to the first directory child. */
45
+ function findKotlinExactOrSuffix(allFilePaths, pathLike) {
21
46
  if (pathLike === '')
22
47
  return null;
23
48
  const extensions = ['.kt', '.kts'];
24
49
  const suffix = `/${pathLike}`;
25
- const dirPrefix = `${pathLike}/`;
26
- const suffixDirPrefix = `/${dirPrefix}`;
27
50
  let suffixFile = null;
28
- let directoryChild = null;
29
51
  for (const raw of allFilePaths) {
30
52
  const file = raw.replace(/\\/g, '/');
31
53
  if (!extensions.some((ext) => file.endsWith(ext)))
@@ -36,18 +58,68 @@ function findKotlinFile(allFilePaths, pathLike) {
36
58
  if (suffixFile === null && file.endsWith(`${suffix}${ext}`))
37
59
  suffixFile = raw;
38
60
  }
39
- if (directoryChild === null) {
40
- const atRoot = file.startsWith(dirPrefix);
41
- const atNested = file.includes(suffixDirPrefix);
42
- if (atRoot || atNested) {
43
- const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
44
- const after = file.slice(idx + dirPrefix.length);
45
- if (after.length > 0 && !after.includes('/'))
46
- directoryChild = raw;
47
- }
48
- }
49
61
  }
50
- return suffixFile ?? directoryChild;
62
+ return suffixFile;
63
+ }
64
+ /** First directory child of `pathLike/` — preserves the legacy single-
65
+ * file fallback for cases where `pathLike` itself is an unqualified
66
+ * package reference (rare in real Kotlin code; some fixtures rely on
67
+ * it). Multi-file package fan-out goes through
68
+ * `findKotlinPackageFiles` instead. */
69
+ function findKotlinDirectoryChild(allFilePaths, pathLike) {
70
+ if (pathLike === '')
71
+ return null;
72
+ const extensions = ['.kt', '.kts'];
73
+ const dirPrefix = `${pathLike}/`;
74
+ const suffixDirPrefix = `/${dirPrefix}`;
75
+ for (const raw of allFilePaths) {
76
+ const file = raw.replace(/\\/g, '/');
77
+ if (!extensions.some((ext) => file.endsWith(ext)))
78
+ continue;
79
+ const atRoot = file.startsWith(dirPrefix);
80
+ const atNested = file.includes(suffixDirPrefix);
81
+ if (!atRoot && !atNested)
82
+ continue;
83
+ const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
84
+ const after = file.slice(idx + dirPrefix.length);
85
+ if (after.length > 0 && !after.includes('/'))
86
+ return raw;
87
+ }
88
+ return null;
89
+ }
90
+ /**
91
+ * Return every `.kt`/`.kts` file inside the package directory `dirPath`
92
+ * (e.g. `models` → `['models/User.kt', 'models/Repo.kt']`). Used as a
93
+ * fallback when an import like `models.getRepo` does not resolve to a
94
+ * file named after the symbol — in Kotlin the symbol can live in any
95
+ * file inside the package directory. The finalize pass walks each
96
+ * candidate and picks the one whose `localDefs` actually export the
97
+ * imported name (#1759).
98
+ */
99
+ function findKotlinPackageFiles(allFilePaths, dirPath) {
100
+ if (dirPath === '')
101
+ return null;
102
+ const extensions = ['.kt', '.kts'];
103
+ const dirPrefix = `${dirPath}/`;
104
+ const suffixDirPrefix = `/${dirPrefix}`;
105
+ const out = [];
106
+ for (const raw of allFilePaths) {
107
+ const file = raw.replace(/\\/g, '/');
108
+ if (!extensions.some((ext) => file.endsWith(ext)))
109
+ continue;
110
+ const atRoot = file.startsWith(dirPrefix);
111
+ const atNested = file.includes(suffixDirPrefix);
112
+ if (!atRoot && !atNested)
113
+ continue;
114
+ const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
115
+ const after = file.slice(idx + dirPrefix.length);
116
+ // Direct children only — `models/sub/Util.kt` is a different package
117
+ // (`models.sub`) and must not be merged with `models`.
118
+ if (after.length === 0 || after.includes('/'))
119
+ continue;
120
+ out.push(raw);
121
+ }
122
+ return out.length === 0 ? null : out;
51
123
  }
52
124
  function findByProgressivePrefixStrip(allFilePaths, pathLike) {
53
125
  const segments = pathLike.split('/').filter(Boolean);
@@ -2,6 +2,30 @@ import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/s
2
2
  export function populateKotlinOwners(parsed) {
3
3
  populateClassOwnedMembers(parsed);
4
4
  populateCompanionMembersOnEnclosingClass(parsed);
5
+ upgradeClassOwnedFunctionsToMethods(parsed);
6
+ }
7
+ /**
8
+ * Align scope-resolution `def.type` with the graph's node-label
9
+ * conventions: a `Function` def that lives inside a class body becomes
10
+ * a `Method`. The Kotlin extractor labels every `function_declaration`
11
+ * as `Function`, but the graph parsing-processor emits a `Method`
12
+ * graph-node label for class members. Without this realignment,
13
+ * `resolveDefGraphId`'s parameter-typed key lookup (gated on
14
+ * `def.type === 'Method'`) falls through to the simple-name fallback
15
+ * for class methods, collapsing same-name same-arity overloads onto
16
+ * the first-registered node (#1761).
17
+ *
18
+ * Only Method-bearing types are touched. Methods have a class owner
19
+ * (set by `populateClassOwnedMembers`) and a class-qualified name.
20
+ */
21
+ function upgradeClassOwnedFunctionsToMethods(parsed) {
22
+ for (const def of parsed.localDefs) {
23
+ if (def.type !== 'Function')
24
+ continue;
25
+ if (def.ownerId === undefined)
26
+ continue;
27
+ def.type = 'Method';
28
+ }
5
29
  }
6
30
  function populateCompanionMembersOnEnclosingClass(parsed) {
7
31
  const scopesById = new Map();
@@ -8,6 +8,19 @@ const KOTLIN_SCOPE_QUERY = `
8
8
  (companion_object) @scope.class
9
9
  (function_declaration) @scope.function
10
10
 
11
+ ;; Smart-cast narrowing scopes (RFC #909 Ring 3, issue #1758).
12
+ ;; Each is-test arm body and each if-then body becomes its own Block
13
+ ;; scope so synthesized narrowed type-bindings (see captures.ts
14
+ ;; synthesizeKotlinSmartCastBindings) shadow the outer parameter
15
+ ;; binding for calls inside the body — without leaking across arms.
16
+ (when_entry
17
+ (when_condition (type_test))
18
+ (control_structure_body) @scope.block)
19
+
20
+ (if_expression
21
+ (check_expression)
22
+ (control_structure_body) @scope.block)
23
+
11
24
  ;; Declarations — types
12
25
  (class_declaration
13
26
  "interface"
@@ -1,5 +1,7 @@
1
1
  import { SupportedLanguages } from '../../../../_shared/index.js';
2
2
  import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
3
+ import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
4
+ import { isClassLike } from '../../scope-resolution/scope/walkers.js';
3
5
  import { kotlinProvider } from '../kotlin.js';
4
6
  import { kotlinArityCompatibility, kotlinMergeBindings, populateKotlinOwners, resolveKotlinImportTarget, } from './index.js';
5
7
  /**
@@ -27,7 +29,7 @@ export const kotlinScopeResolver = {
27
29
  },
28
30
  mergeBindings: (existing, incoming) => [...kotlinMergeBindings([...existing, ...incoming])],
29
31
  arityCompatibility: (callsite, def) => kotlinArityCompatibility(def, callsite),
30
- buildMro: (graph, parsedFiles, nodeLookup) => buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
32
+ buildMro: (graph, parsedFiles, nodeLookup) => buildKotlinMro(graph, parsedFiles, nodeLookup),
31
33
  populateOwners: (parsed) => populateKotlinOwners(parsed),
32
34
  isSuperReceiver: (text) => text.trim() === 'super',
33
35
  fieldFallbackOnMethodLookup: false,
@@ -35,3 +37,88 @@ export const kotlinScopeResolver = {
35
37
  collapseMemberCallsByCallerTarget: false,
36
38
  hoistTypeBindingsToModule: true,
37
39
  };
40
+ /**
41
+ * Kotlin MRO builder — extends `defaultLinearize` (EXTENDS-only) with
42
+ * interface ancestors discovered via `IMPLEMENTS` edges. Interface
43
+ * default methods (`interface Validator { fun validate(): Boolean = true }`)
44
+ * are inherited by implementing classes without an explicit override;
45
+ * the generic MRO would not surface them because the implementor has
46
+ * no `EXTENDS` link to the interface (#1763).
47
+ *
48
+ * Interfaces are appended after the EXTENDS chain (Kotlin resolves
49
+ * conflicts by requiring an explicit override, so first-seen-in-MRO
50
+ * ordering is a reasonable approximation for method lookup). Transitive
51
+ * interface inheritance (`interface A : B`) is closed via BFS.
52
+ */
53
+ function buildKotlinMro(graph, parsedFiles, nodeLookup) {
54
+ const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
55
+ const defIdByGraphId = new Map();
56
+ for (const parsed of parsedFiles) {
57
+ for (const def of parsed.localDefs) {
58
+ if (!isClassLike(def.type))
59
+ continue;
60
+ const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
61
+ if (graphId !== undefined)
62
+ defIdByGraphId.set(graphId, def.nodeId);
63
+ }
64
+ }
65
+ // Direct IMPLEMENTS targets per class-like def.
66
+ const directImpls = new Map();
67
+ for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
68
+ const source = defIdByGraphId.get(rel.sourceId);
69
+ const target = defIdByGraphId.get(rel.targetId);
70
+ if (source === undefined || target === undefined)
71
+ continue;
72
+ let list = directImpls.get(source);
73
+ if (list === undefined) {
74
+ list = [];
75
+ directImpls.set(source, list);
76
+ }
77
+ if (!list.includes(target))
78
+ list.push(target);
79
+ }
80
+ // For each class, append the transitive closure of interfaces reachable
81
+ // through its own + ancestor classes' IMPLEMENTS edges. Walking
82
+ // ancestors picks up interfaces inherited via the EXTENDS chain
83
+ // (e.g. `class C : B; class B : A; interface A` — C inherits A's
84
+ // interface methods through B).
85
+ for (const [classDefId, extendsMro] of mro) {
86
+ const ancestorChain = [classDefId, ...extendsMro];
87
+ const seeds = [];
88
+ for (const ancestorId of ancestorChain) {
89
+ for (const ifaceId of directImpls.get(ancestorId) ?? []) {
90
+ seeds.push(ifaceId);
91
+ }
92
+ }
93
+ if (seeds.length === 0)
94
+ continue;
95
+ const interfaces = closeInterfaces(seeds, directImpls);
96
+ mro.set(classDefId, [...extendsMro, ...interfaces.filter((i) => !extendsMro.includes(i))]);
97
+ }
98
+ // Classes with no EXTENDS still need an MRO entry when they implement
99
+ // interfaces (e.g. `class User : Validator` — no `mro` entry from the
100
+ // EXTENDS-only pass because no EXTENDS edges exist).
101
+ for (const [classDefId, ifaces] of directImpls) {
102
+ if (mro.has(classDefId))
103
+ continue;
104
+ mro.set(classDefId, closeInterfaces([...ifaces], directImpls));
105
+ }
106
+ return mro;
107
+ }
108
+ function closeInterfaces(seeds, directImpls) {
109
+ const out = [];
110
+ const seen = new Set();
111
+ const queue = [...seeds];
112
+ while (queue.length > 0) {
113
+ const cur = queue.shift();
114
+ if (seen.has(cur))
115
+ continue;
116
+ seen.add(cur);
117
+ out.push(cur);
118
+ for (const next of directImpls.get(cur) ?? []) {
119
+ if (!seen.has(next))
120
+ queue.push(next);
121
+ }
122
+ }
123
+ return out;
124
+ }
@@ -1,4 +1,11 @@
1
1
  export function kotlinBindingScopeFor(decl, innermost, tree) {
2
+ // Smart-cast narrowed bindings (issue #1758) must stay at the innermost
3
+ // (Block) scope. Their anchor coincides with the Block's range for
4
+ // unbraced arm bodies (`is User -> obj.save()`), which would otherwise
5
+ // trigger scope-extractor auto-hoist into the enclosing function scope
6
+ // and erase the arm-local narrowing.
7
+ if (decl['@type-binding.narrowed'] !== undefined)
8
+ return innermost.id;
2
9
  if (decl['@type-binding.return'] === undefined)
3
10
  return null;
4
11
  let current = innermost;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.6-rc.37",
3
+ "version": "1.6.6-rc.39",
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",