gitnexus 1.6.6-rc.45 → 1.6.6-rc.46

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,2 +1,2 @@
1
- import type { CaptureMatch } from '../../../../_shared/index.js';
2
- export declare function emitKotlinScopeCaptures(sourceText: string, _filePath: string, cachedTree?: unknown): readonly CaptureMatch[];
1
+ import { type CaptureMatch } from '../../../../_shared/index.js';
2
+ export declare function emitKotlinScopeCaptures(sourceText: string, filePath: string, cachedTree?: unknown): readonly CaptureMatch[];
@@ -1,3 +1,4 @@
1
+ import { makeScopeId } from '../../../../_shared/index.js';
1
2
  import { findNodeAtRange, nodeToCapture, syntheticCapture, } from '../../utils/ast-helpers.js';
2
3
  import { getTreeSitterBufferSize } from '../../constants.js';
3
4
  import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
@@ -7,8 +8,9 @@ import { recordKotlinCacheHit, recordKotlinCacheMiss } from './cache-stats.js';
7
8
  import { normalizeKotlinType } from './interpret.js';
8
9
  import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
9
10
  import { getKotlinParser, getKotlinScopeQuery } from './query.js';
11
+ import { markCompanionScope } from './companion-scopes.js';
10
12
  const FUNCTION_DECL_TAGS = ['@declaration.function'];
11
- export function emitKotlinScopeCaptures(sourceText, _filePath, cachedTree) {
13
+ export function emitKotlinScopeCaptures(sourceText, filePath, cachedTree) {
12
14
  let tree = cachedTree;
13
15
  if (tree === undefined) {
14
16
  tree = parseSourceSafe(getKotlinParser(), sourceText, undefined, {
@@ -24,6 +26,7 @@ export function emitKotlinScopeCaptures(sourceText, _filePath, cachedTree) {
24
26
  out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
25
27
  out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
26
28
  out.push(...synthesizeKotlinSmartCastBindings(tree.rootNode));
29
+ out.push(...synthesizeKotlinLambdaBindings(tree.rootNode, returnTypes));
27
30
  for (const match of getKotlinScopeQuery().matches(tree.rootNode)) {
28
31
  const grouped = {};
29
32
  for (const capture of match.captures) {
@@ -32,6 +35,26 @@ export function emitKotlinScopeCaptures(sourceText, _filePath, cachedTree) {
32
35
  }
33
36
  if (Object.keys(grouped).length === 0)
34
37
  continue;
38
+ // Companion-object marker (#1756 / U4). The `@scope.companion`
39
+ // capture is a side-channel marker — it shares its range with the
40
+ // existing `(companion_object) @scope.class` rule, so the Class
41
+ // scope already exists in the scope tree. Record the scope id into
42
+ // the per-file companion-scope set so `populateCompanionMembersOn
43
+ // EnclosingClass` (owners.ts) can identify companion scopes
44
+ // unambiguously, regardless of whether they are anonymous, named,
45
+ // or contain nested classes. The match itself is consumed here and
46
+ // NOT pushed to the output — the scope-extractor would reject the
47
+ // `companion` kind suffix anyway, but suppressing the emit keeps
48
+ // downstream pipelines from re-processing the same range twice.
49
+ if (grouped['@scope.companion'] !== undefined) {
50
+ const scopeId = makeScopeId({
51
+ filePath,
52
+ range: grouped['@scope.companion'].range,
53
+ kind: 'Class',
54
+ });
55
+ markCompanionScope(filePath, scopeId);
56
+ continue;
57
+ }
35
58
  if (grouped['@import.statement'] !== undefined) {
36
59
  const importNode = findNodeAtRange(tree.rootNode, grouped['@import.statement'].range, 'import_header');
37
60
  if (importNode !== null) {
@@ -234,6 +257,286 @@ function buildNarrowedTypeBindingCapture(subject, bodyAnchor, typeNode) {
234
257
  '@type-binding.narrowed': syntheticCapture('@type-binding.narrowed', bodyAnchor, '1'),
235
258
  };
236
259
  }
260
+ /**
261
+ * Synthesize lambda-body type-bindings — issue #1757.
262
+ *
263
+ * For each `lambda_literal` we emit one or more `@type-binding.annotation`
264
+ * captures anchored INSIDE the lambda body (the lambda's `statements` child
265
+ * — or the `lambda_literal` itself when no statements child exists). The
266
+ * `@scope.block` query rule (see query.ts) makes each `lambda_literal` a
267
+ * Block scope, and the `@type-binding.lambda-scoped` marker forces the
268
+ * scope-extractor to keep the binding at the innermost (lambda body) scope
269
+ * via `kotlinBindingScopeFor`. This guarantees:
270
+ * - explicit parameter names (`{ user -> ... }`) bind only inside the
271
+ * body, NOT in the enclosing function scope;
272
+ * - implicit `it` is visible only inside the lambda body and shadows
273
+ * any same-named outer binding (`val it = "outer"; users.forEach
274
+ * { it.save() }` — inner `it` is the lambda parameter);
275
+ * - nested lambdas shadow deterministically (innermost lambda's `it`
276
+ * wins; outer lambda's parameters are still visible by their own
277
+ * names through the parent scope chain).
278
+ *
279
+ * Receiver-type inference is best-effort: the lambda's call-expression
280
+ * parent is inspected; if the receiver has a known local-variable type
281
+ * and the call's member is a well-known stdlib idiom (`forEach`/`map`/
282
+ * `filter` → element type of the collection; `let`/`apply`/`also`/`run`/
283
+ * `takeIf`/`takeUnless`/`use` → receiver type itself), the inferred type
284
+ * is attached. When inference fails (chained receivers, unknown member,
285
+ * non-stdlib idiom), we still emit the binding with a sentinel/erased
286
+ * type so the binding's scope semantics (no leak; no `it` cross-fire) are
287
+ * enforced — call-resolution from the body still falls through to free-
288
+ * call fallback, which is the correct behavior when the type is unknown.
289
+ *
290
+ * Standard-library coverage: `forEach`, `map`, `filter`, `flatMap`,
291
+ * `mapNotNull`, `filterNotNull`, `onEach`, `find`, `firstOrNull`,
292
+ * `lastOrNull`, `any`, `all`, `none`, `count`, `forEachIndexed`,
293
+ * `let`, `apply`, `also`, `run`, `takeIf`, `takeUnless`, `use`, `with`.
294
+ *
295
+ * Lambda-receiver typing for non-stdlib higher-order functions is a
296
+ * follow-up; the binding-existence guarantee above is the minimum
297
+ * acceptance criterion per the U9 plan.
298
+ */
299
+ function synthesizeKotlinLambdaBindings(rootNode, returnTypes) {
300
+ const out = [];
301
+ const classMembers = collectKotlinClassMembers(rootNode);
302
+ for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
303
+ const localTypes = collectKotlinLocalTypeTexts(fnNode, returnTypes);
304
+ for (const lambdaNode of descendantsOfType(fnNode, 'lambda_literal')) {
305
+ const anchor = lambdaBodyAnchor(lambdaNode);
306
+ if (anchor === null)
307
+ continue;
308
+ const inferredType = inferKotlinLambdaReceiverType(lambdaNode, localTypes, returnTypes, classMembers);
309
+ const params = explicitLambdaParameters(lambdaNode);
310
+ if (params.length === 0) {
311
+ // No explicit `(x ->)` parameter list — implicit `it` is in
312
+ // scope inside the body. Synthesize the `it` type-binding so
313
+ // calls like `it.save()` resolve through the typeBinding chain.
314
+ const typeNode = inferredType?.typeNode ?? lambdaNode;
315
+ const typeText = inferredType?.typeText ?? '';
316
+ out.push(buildLambdaTypeBindingCapture(anchor, 'it', typeNode, typeText));
317
+ }
318
+ else {
319
+ // Explicit parameters: `{ user -> ... }`, `{ (a, b) -> ... }`,
320
+ // `{ key, value -> ... }`. Emit one binding per parameter.
321
+ // For multi-arg lambdas (destructuring, `forEachIndexed { i, x
322
+ // -> ... }`), the per-arg type inference is finer than what we
323
+ // currently support — we bind the FIRST parameter to the
324
+ // inferred receiver type (matches single-arg idioms) and bind
325
+ // additional parameters with an empty/erased type, which still
326
+ // gates leakage but won't drive call resolution for those names.
327
+ for (let i = 0; i < params.length; i++) {
328
+ const paramName = params[i].text;
329
+ const typeNode = i === 0 ? (inferredType?.typeNode ?? params[i]) : params[i];
330
+ const typeText = i === 0 ? (inferredType?.typeText ?? '') : '';
331
+ out.push(buildLambdaTypeBindingCapture(anchor, paramName, typeNode, typeText));
332
+ }
333
+ }
334
+ }
335
+ }
336
+ return out;
337
+ }
338
+ /** Anchor node used for synthesized lambda-body type-bindings.
339
+ * Prefers the `statements` child of `lambda_literal` (always strictly
340
+ * inside the lambda body, so the scope-extractor's `rangesEqual` auto-
341
+ * hoist check fails — the binding stays in the Block scope). Falls
342
+ * back to the lambda_literal itself when no statements child exists
343
+ * (e.g. empty lambda); the `@type-binding.lambda-scoped` marker in
344
+ * `kotlinBindingScopeFor` then forces no-hoist explicitly. */
345
+ function lambdaBodyAnchor(lambdaNode) {
346
+ const statements = lambdaNode.namedChildren.find((c) => c.type === 'statements');
347
+ return statements ?? lambdaNode;
348
+ }
349
+ /** Extract explicit lambda parameter `simple_identifier` nodes from a
350
+ * `lambda_literal`. Returns an empty array when no `lambda_parameters`
351
+ * is present (implicit `it` form). */
352
+ function explicitLambdaParameters(lambdaNode) {
353
+ const params = lambdaNode.namedChildren.find((c) => c.type === 'lambda_parameters');
354
+ if (params === undefined)
355
+ return [];
356
+ const out = [];
357
+ for (const child of params.namedChildren) {
358
+ if (child.type !== 'variable_declaration')
359
+ continue;
360
+ const ident = child.namedChildren.find((c) => c.type === 'simple_identifier');
361
+ if (ident !== undefined)
362
+ out.push(ident);
363
+ }
364
+ return out;
365
+ }
366
+ function buildLambdaTypeBindingCapture(anchor, name, typeNode, typeText) {
367
+ return {
368
+ '@type-binding.annotation': nodeToCapture('@type-binding.annotation', anchor),
369
+ '@type-binding.name': syntheticCapture('@type-binding.name', anchor, name),
370
+ '@type-binding.type': syntheticCapture('@type-binding.type', typeNode, typeText === '' ? '' : normalizeKotlinType(typeText)),
371
+ // Marker consumed by `kotlinBindingScopeFor` (simple-hooks.ts) to
372
+ // pin this binding inside the lambda Block scope — without it the
373
+ // scope-extractor would auto-hoist the binding to the enclosing
374
+ // function scope and `it` (or the lambda parameter name) would
375
+ // leak past the closing brace.
376
+ '@type-binding.lambda-scoped': syntheticCapture('@type-binding.lambda-scoped', anchor, '1'),
377
+ };
378
+ }
379
+ /** Stdlib higher-order functions whose lambda parameter receives the
380
+ * ELEMENT type of the receiver collection (Map / Iterable element). */
381
+ const KOTLIN_ELEMENT_TYPE_LAMBDAS = new Set([
382
+ 'forEach',
383
+ 'forEachIndexed',
384
+ 'map',
385
+ 'mapNotNull',
386
+ 'mapIndexed',
387
+ 'filter',
388
+ 'filterNot',
389
+ 'filterNotNull',
390
+ 'filterIsInstance',
391
+ 'flatMap',
392
+ 'flatten',
393
+ 'onEach',
394
+ 'find',
395
+ 'findLast',
396
+ 'firstOrNull',
397
+ 'lastOrNull',
398
+ 'singleOrNull',
399
+ 'any',
400
+ 'all',
401
+ 'none',
402
+ 'count',
403
+ 'partition',
404
+ 'sortedBy',
405
+ 'sortedByDescending',
406
+ 'groupBy',
407
+ 'associate',
408
+ 'associateBy',
409
+ 'associateWith',
410
+ 'minByOrNull',
411
+ 'maxByOrNull',
412
+ 'sumOf',
413
+ 'distinctBy',
414
+ ]);
415
+ /** Stdlib scope functions whose lambda receives the RECEIVER itself as
416
+ * `it` (or as `this` for `apply`/`run`/`with`). For the binding-
417
+ * existence guarantee we treat both forms the same way — `it` binds
418
+ * to the receiver type; `apply`/`run`/`with` callers see free calls
419
+ * inside the body which fall through to free-call resolution against
420
+ * the enclosing scope (no `this`-aware dispatch yet — follow-up). */
421
+ const KOTLIN_SCOPE_FUNCTION_LAMBDAS = new Set(['let', 'also', 'takeIf', 'takeUnless', 'use']);
422
+ /** `apply`, `run`, `with` expose the receiver as `this` rather than
423
+ * `it`. We still synthesize an `it` binding because the lambda may
424
+ * reference the receiver elsewhere — but the more common usage
425
+ * (`user.apply { save() }`) goes through free-call resolution on the
426
+ * body, not through `it`. Including these here keeps the binding
427
+ * scope correct without claiming we resolve `this`-form correctly. */
428
+ const KOTLIN_THIS_RECEIVER_LAMBDAS = new Set(['apply', 'run', 'with']);
429
+ /** Walk up from `lambdaNode` to the enclosing `call_expression` and
430
+ * infer the lambda parameter's type from the call's receiver and
431
+ * member name. Returns null when the inference path is not yet
432
+ * supported (chained receivers, unknown member, non-stdlib idiom).
433
+ *
434
+ * Best-effort: a null return is harmless — `synthesizeKotlinLambda
435
+ * Bindings` still emits the binding with an empty type so the scope
436
+ * semantics (no leak, no cross-fire) are enforced; only the call-
437
+ * resolution path from `it.method()` may fall through to free-call
438
+ * fallback when the type isn't known. */
439
+ function inferKotlinLambdaReceiverType(lambdaNode, localTypes, returnTypes, classMembers) {
440
+ const callExpr = findEnclosingCallExpression(lambdaNode);
441
+ if (callExpr === null)
442
+ return null;
443
+ const callee = callExpr.namedChildren.find((c) => c.type === 'navigation_expression' || c.type === 'simple_identifier');
444
+ if (callee === undefined)
445
+ return null;
446
+ if (callee.type === 'simple_identifier') {
447
+ // `with(receiver) { ... }` — argument is the receiver. Not yet
448
+ // wired through; defer to follow-up.
449
+ return null;
450
+ }
451
+ // navigation_expression: <receiver>.<member>
452
+ const receiver = callee.namedChild(0);
453
+ const memberName = callee.namedChildren
454
+ .find((c) => c.type === 'navigation_suffix')
455
+ ?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
456
+ if (receiver === null || memberName === undefined)
457
+ return null;
458
+ const receiverType = inferKotlinLambdaReceiverExpressionType(receiver, localTypes, returnTypes, classMembers);
459
+ if (receiverType === null)
460
+ return null;
461
+ if (KOTLIN_ELEMENT_TYPE_LAMBDAS.has(memberName)) {
462
+ const element = kotlinContainerElementType(receiverType, 'values');
463
+ if (element === null || element === '')
464
+ return null;
465
+ return { typeText: element, typeNode: lambdaNode };
466
+ }
467
+ if (KOTLIN_SCOPE_FUNCTION_LAMBDAS.has(memberName) ||
468
+ KOTLIN_THIS_RECEIVER_LAMBDAS.has(memberName)) {
469
+ // Strip nullable suffix for `?.let { ... }` semantics — inside the
470
+ // body, the receiver is non-null per Kotlin smart-cast.
471
+ const stripped = normalizeKotlinType(receiverType);
472
+ return { typeText: stripped, typeNode: lambdaNode };
473
+ }
474
+ return null;
475
+ }
476
+ /** Infer the static type of the expression that produced the lambda's
477
+ * enclosing call. Supports: `simple_identifier` (lookup in
478
+ * `localTypes`), `indexing_expression` on a Map-typed receiver, and
479
+ * `call_expression` whose callee return type is in `returnTypes`. */
480
+ function inferKotlinLambdaReceiverExpressionType(receiver, localTypes, returnTypes, classMembers) {
481
+ if (receiver.type === 'simple_identifier') {
482
+ return localTypes.get(receiver.text) ?? null;
483
+ }
484
+ if (receiver.type === 'indexing_expression') {
485
+ // `posts[user]` — the underlying receiver's container type tells
486
+ // us the element/value type.
487
+ const base = receiver.namedChild(0);
488
+ if (base === null)
489
+ return null;
490
+ const baseType = base.type === 'simple_identifier' ? localTypes.get(base.text) : null;
491
+ if (baseType === undefined || baseType === null)
492
+ return null;
493
+ // Indexing a Map returns the value type; indexing a List returns
494
+ // the element type. `kotlinContainerElementType` already encodes
495
+ // both via the 'values' tag.
496
+ return kotlinContainerElementType(baseType, 'values');
497
+ }
498
+ if (receiver.type === 'navigation_expression') {
499
+ // `users.map { ... }` chain — receiver is itself a navigation/
500
+ // call. Tier-2 chain inference: try the navigation field/method.
501
+ const navField = inferKotlinNavigationFieldType(receiver, localTypes, classMembers);
502
+ if (navField !== null)
503
+ return navField;
504
+ const callee = receiver.namedChildren
505
+ .find((c) => c.type === 'navigation_suffix')
506
+ ?.namedChildren.find((c) => c.type === 'simple_identifier');
507
+ if (callee !== undefined) {
508
+ return inferKotlinNavigationCallReturnType(receiver, localTypes, classMembers);
509
+ }
510
+ return null;
511
+ }
512
+ if (receiver.type === 'call_expression') {
513
+ const callee = receiver.namedChildren.find((c) => c.type === 'simple_identifier');
514
+ if (callee === undefined)
515
+ return null;
516
+ return returnTypes.get(callee.text) ?? null;
517
+ }
518
+ return null;
519
+ }
520
+ /** Walk up from `lambdaNode` (lambda_literal) to the enclosing call:
521
+ * `lambda_literal → annotated_lambda → call_suffix → call_expression`
522
+ * for trailing lambdas, or `lambda_literal → value_argument →
523
+ * value_arguments → call_suffix → call_expression` for paren form.
524
+ * Returns null if the lambda is not inside a call. */
525
+ function findEnclosingCallExpression(lambdaNode) {
526
+ let current = lambdaNode.parent;
527
+ while (current !== null) {
528
+ if (current.type === 'call_expression')
529
+ return current;
530
+ // Don't cross out of the immediate call boundary — if we hit a
531
+ // function_body or function_declaration ancestor, the lambda is
532
+ // not call-bound.
533
+ if (current.type === 'function_body' || current.type === 'function_declaration') {
534
+ return null;
535
+ }
536
+ current = current.parent;
537
+ }
538
+ return null;
539
+ }
237
540
  function synthesizeKotlinLocalAssignmentBindings(rootNode, returnTypes) {
238
541
  const out = [];
239
542
  const classMembers = collectKotlinClassMembers(rootNode);
@@ -303,13 +606,23 @@ function collectKotlinClassMembers(rootNode) {
303
606
  fmap.set(fname, ftype);
304
607
  }
305
608
  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);
609
+ collectKotlinFunctionReturn(member, mmap);
610
+ }
611
+ else if (member.type === 'companion_object') {
612
+ // Companion-object methods (`companion object { fun create() … }`)
613
+ // are addressable via the outer class name (`Logger.create()`).
614
+ // Register them on the outer class so chain-binding for
615
+ // `val x = Logger.create(...)` picks up the return type (#1756).
616
+ // The receiver-side filtering needed to prevent
617
+ // `instance.companionMethod()` crossover is handled elsewhere.
618
+ const compBody = member.namedChildren.find((c) => c.type === 'class_body');
619
+ if (compBody !== undefined) {
620
+ for (const compMember of compBody.namedChildren) {
621
+ if (compMember.type !== 'function_declaration')
622
+ continue;
623
+ collectKotlinFunctionReturn(compMember, mmap);
624
+ }
625
+ }
313
626
  }
314
627
  }
315
628
  }
@@ -318,6 +631,15 @@ function collectKotlinClassMembers(rootNode) {
318
631
  }
319
632
  return { fields, methods };
320
633
  }
634
+ function collectKotlinFunctionReturn(fnNode, target) {
635
+ const mname = fnNode.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
636
+ const paramsIdx = fnNode.namedChildren.findIndex((c) => c.type === 'function_value_parameters');
637
+ const rtype = paramsIdx < 0
638
+ ? undefined
639
+ : fnNode.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text;
640
+ if (mname !== undefined && rtype !== undefined)
641
+ target.set(mname, rtype);
642
+ }
321
643
  function collectKotlinLocalTypeTexts(fnNode, returnTypes) {
322
644
  const out = new Map();
323
645
  for (const node of descendants(fnNode)) {
@@ -408,9 +730,19 @@ function inferKotlinNavigationFieldType(nav, localTypes, classMembers) {
408
730
  return null;
409
731
  return classMembers.fields.get(normalizeKotlinType(recvType))?.get(member) ?? null;
410
732
  }
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`. */
733
+ /** Resolve `receiver.method()` → method's declared return type. The
734
+ * `receiver` is a simple identifier; we try two interpretations in
735
+ * order:
736
+ *
737
+ * 1. `receiver` is a local variable whose type is in `localTypes` —
738
+ * look up `method` on that type's class members.
739
+ * 2. `receiver` is itself a class name (e.g. `Logger.create("app")`,
740
+ * a companion-object call via the class) — look up `method` on
741
+ * `classMembers.methods.get(receiver.text)` directly.
742
+ *
743
+ * Tier 2 supports `val logger = Logger.create(...)` patterns where the
744
+ * RHS is a companion-object factory: the loop variable's type is the
745
+ * factory's return type (#1756). */
414
746
  function inferKotlinNavigationCallReturnType(navCallee, localTypes, classMembers) {
415
747
  if (classMembers === undefined)
416
748
  return null;
@@ -423,9 +755,10 @@ function inferKotlinNavigationCallReturnType(navCallee, localTypes, classMembers
423
755
  if (methodName === undefined)
424
756
  return null;
425
757
  const recvType = localTypes.get(receiver.text);
426
- if (recvType === undefined)
427
- return null;
428
- return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
758
+ if (recvType !== undefined) {
759
+ return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
760
+ }
761
+ return classMembers.methods.get(receiver.text)?.get(methodName) ?? null;
429
762
  }
430
763
  function inferKotlinIterableElementType(iterable, localTypes, returnTypes) {
431
764
  if (iterable.type === 'simple_identifier') {
@@ -0,0 +1,7 @@
1
+ import type { ScopeId } from '../../../../_shared/index.js';
2
+ /** Record a scope id as a companion-object scope for the given file. */
3
+ export declare function markCompanionScope(filePath: string, scopeId: ScopeId): void;
4
+ /** Check whether `scopeId` belongs to a companion-object scope in `filePath`. */
5
+ export declare function isCompanionScope(filePath: string, scopeId: ScopeId): boolean;
6
+ /** Clear all tracked companion scopes (for testing). */
7
+ export declare function clearCompanionScopes(): void;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Per-file set of `ScopeId`s that came from a `companion_object` AST node
3
+ * (issue #1756 / U4 remediation).
4
+ *
5
+ * Populated during `emitKotlinScopeCaptures` from the `@scope.companion`
6
+ * marker capture (see `query.ts`) and consumed by
7
+ * `populateCompanionMembersOnEnclosingClass` in `owners.ts` to decide
8
+ * whether to promote a class scope's methods onto its enclosing class.
9
+ *
10
+ * The previous `ownedDefs.some(isClassLike)` heuristic in `owners.ts`
11
+ * silently misclassified two shapes as "regular classes":
12
+ * - named companions (`companion object Helper { ... }`) — the `Helper`
13
+ * `type_identifier` registered as a class-like def on the companion
14
+ * scope, hiding the companion-ness from the heuristic; AND
15
+ * - companions containing nested classes (`companion object { class
16
+ * Token; fun create() }`) — the nested class def lived on the
17
+ * companion scope, again hiding it from the heuristic.
18
+ *
19
+ * The marker capture lifts that distinction up to the parser layer
20
+ * where it is unambiguous (any `companion_object` AST node is a
21
+ * companion, regardless of what it contains).
22
+ *
23
+ * Parallels the C-language pattern in `c/static-linkage.ts`: per-file
24
+ * `Map<filePath, Set<key>>` side-channel for language-specific def /
25
+ * scope metadata that does not belong on the shared `Scope` /
26
+ * `SymbolDefinition` types.
27
+ *
28
+ * NOTE: module-level state. `clearCompanionScopes()` is called once per
29
+ * workspace pass from `kotlinScopeResolver.loadResolutionConfig`, which
30
+ * the scope-resolution orchestrator awaits before extracting any
31
+ * `ParsedFile`s for this language (see `pipeline/phase.ts` and the
32
+ * mirror pattern in `c/scope-resolver.ts` — `clearStaticNames()`). This
33
+ * keeps server-mode and multi-repo-in-one-process callers safe from
34
+ * unbounded memory growth and from stale companion-scope ids from a
35
+ * previous workspace's files. Tests that exercise the captures /
36
+ * owners modules directly may still need to call `clearCompanionScopes`
37
+ * themselves (see `test/unit/kotlin-static-marker.test.ts`).
38
+ */
39
+ const companionScopesByFile = new Map();
40
+ /** Record a scope id as a companion-object scope for the given file. */
41
+ export function markCompanionScope(filePath, scopeId) {
42
+ let scopes = companionScopesByFile.get(filePath);
43
+ if (scopes === undefined) {
44
+ scopes = new Set();
45
+ companionScopesByFile.set(filePath, scopes);
46
+ }
47
+ scopes.add(scopeId);
48
+ }
49
+ /** Check whether `scopeId` belongs to a companion-object scope in `filePath`. */
50
+ export function isCompanionScope(filePath, scopeId) {
51
+ return companionScopesByFile.get(filePath)?.has(scopeId) ?? false;
52
+ }
53
+ /** Clear all tracked companion scopes (for testing). */
54
+ export function clearCompanionScopes() {
55
+ companionScopesByFile.clear();
56
+ }
@@ -1,2 +1,3 @@
1
- import type { ParsedFile } from '../../../../_shared/index.js';
1
+ import type { ParsedFile, SymbolDefinition } from '../../../../_shared/index.js';
2
+ export declare function isKotlinStaticOnly(def: SymbolDefinition): boolean;
2
3
  export declare function populateKotlinOwners(parsed: ParsedFile): void;
@@ -1,4 +1,17 @@
1
1
  import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
2
+ import { isCompanionScope } from './companion-scopes.js';
3
+ /** Module-level identity-based marker for companion-promoted Kotlin
4
+ * method defs (the "this member can only be dispatched through the
5
+ * class name" set). Parallels the C language `static-linkage.ts`
6
+ * side-channel pattern but uses a WeakSet because the mark is
7
+ * per-def (no per-name keying needed). Eliminates the cast-and-
8
+ * mutate pattern the previous marker implementation required,
9
+ * removes any serialization-survival risk surface, and keeps the
10
+ * Kotlin-specific metadata off the shared `SymbolDefinition` type. */
11
+ const KOTLIN_STATIC_DEFS = new WeakSet();
12
+ export function isKotlinStaticOnly(def) {
13
+ return KOTLIN_STATIC_DEFS.has(def);
14
+ }
2
15
  export function populateKotlinOwners(parsed) {
3
16
  populateClassOwnedMembers(parsed);
4
17
  populateCompanionMembersOnEnclosingClass(parsed);
@@ -37,15 +50,49 @@ function populateCompanionMembersOnEnclosingClass(parsed) {
37
50
  const parent = scopesById.get(scope.parent);
38
51
  if (parent === undefined || parent.kind !== 'Class')
39
52
  continue;
40
- if (parent.ownedDefs.some((def) => isClassLike(def.type)))
53
+ // Identify companion-object scopes via the `@scope.companion` marker
54
+ // capture (see captures.ts / companion-scopes.ts) rather than via
55
+ // the old `parent.ownedDefs.some(isClassLike)` heuristic. The
56
+ // heuristic silently bypassed two real shapes (#1756 / U4):
57
+ // - named companions (`companion object Helper { ... }`) — `Helper`
58
+ // registered as a class-like def on the companion scope; AND
59
+ // - companions containing nested classes (`companion object {
60
+ // class Token; fun create() }`) — the nested class lived on
61
+ // the companion scope.
62
+ // Both bypasses left companion methods unpromoted and unmarked,
63
+ // breaking class-name dispatch (`Outer.create()`) and crossover
64
+ // suppression (`outer.create()`) for those shapes. The marker
65
+ // capture lifts the distinction to the parser layer where any
66
+ // `companion_object` AST node is a companion, full stop.
67
+ if (!isCompanionScope(parsed.filePath, parent.id))
41
68
  continue;
42
69
  const enclosing = findEnclosingClassWithDef(parent.parent, scopesById);
43
70
  if (enclosing === undefined)
44
71
  continue;
45
72
  for (const def of scope.ownedDefs) {
46
- if (def.ownerId !== undefined)
73
+ // Class-like defs nested inside the companion's methods (rare
74
+ // would be a local class declared inside a fun-body) are not
75
+ // companion members and must not be promoted. The companion's
76
+ // direct nested classes live in their OWN scope's ownedDefs
77
+ // (NOT the function-scope ownedDefs we iterate here), so this
78
+ // guard is defense-in-depth.
79
+ if (isClassLike(def.type))
47
80
  continue;
81
+ // OVERRIDE rather than skip-when-set: for named companions,
82
+ // `populateClassOwnedMembers` already set `ownerId = Helper`
83
+ // (the named-companion class-like def). That is the WRONG
84
+ // owner — companion methods are dispatched through the
85
+ // enclosing outer class, not through the companion's own
86
+ // type name. Overwriting restores the intended ownership.
48
87
  def.ownerId = enclosing.nodeId;
88
+ // Mark as static-only so `ScopeResolver.isStaticOnly` (see
89
+ // `isKotlinStaticOnly`) can filter these out of instance-receiver
90
+ // dispatch (#1756). Promoting the companion method onto the
91
+ // outer class lets `Foo.companionMethod()` resolve via Case 2;
92
+ // without this marker, `fooInstance.companionMethod()` would
93
+ // ALSO resolve to it via Case 4, which is incorrect (and a
94
+ // compile error in real Kotlin).
95
+ KOTLIN_STATIC_DEFS.add(def);
49
96
  qualify(def, enclosing);
50
97
  }
51
98
  }
@@ -66,9 +113,18 @@ function findEnclosingClassWithDef(start, scopesById) {
66
113
  return undefined;
67
114
  }
68
115
  function qualify(def, owner) {
69
- if (def.qualifiedName === undefined || def.qualifiedName.includes('.'))
116
+ if (def.qualifiedName === undefined)
70
117
  return;
71
118
  if (owner.qualifiedName === undefined || owner.qualifiedName.length === 0)
72
119
  return;
73
- def.qualifiedName = `${owner.qualifiedName}.${def.qualifiedName}`;
120
+ // For named companions, `populateClassOwnedMembers` qualified the
121
+ // def as `Helper.create`. Strip the companion-class prefix and
122
+ // re-qualify with the outer class so the graph-bridge lookup keys
123
+ // resolve to `Outer.create` rather than the spurious `Helper.create`.
124
+ // For unqualified defs (the anonymous-companion path), the simple
125
+ // name is unchanged — `populateClassOwnedMembers` found no class-like
126
+ // def in the anonymous companion scope, so the prior pass left the
127
+ // simple name in place.
128
+ const simple = def.qualifiedName.split('.').pop() ?? def.qualifiedName;
129
+ def.qualifiedName = `${owner.qualifiedName}.${simple}`;
74
130
  }
@@ -8,6 +8,20 @@ const KOTLIN_SCOPE_QUERY = `
8
8
  (companion_object) @scope.class
9
9
  (function_declaration) @scope.function
10
10
 
11
+ ;; Companion-object marker (issue #1756 / U4). Side-channel capture that
12
+ ;; lets populateCompanionMembersOnEnclosingClass distinguish a companion
13
+ ;; Class scope from a regular Class scope without inspecting ownedDefs.
14
+ ;; Anonymous companions AND companions containing nested classes both
15
+ ;; look like regular classes through the old ownedDefs-based heuristic;
16
+ ;; the marker lifts the distinction up to the parser layer where it is
17
+ ;; unambiguous (any companion_object AST node is a companion, full
18
+ ;; stop). Consumed by markCompanionScope / isCompanionScope in
19
+ ;; captures.ts / companion-scopes.ts. The scope-extractor ignores the
20
+ ;; "companion" suffix (no ScopeKind mapping), so this rule contributes
21
+ ;; no Scope record of its own — the existing (companion_object)
22
+ ;; @scope.class rule still creates the Class scope.
23
+ (companion_object) @scope.companion
24
+
11
25
  ;; Smart-cast narrowing scopes (RFC #909 Ring 3, issue #1758).
12
26
  ;; Each is-test arm body and each if-then body becomes its own Block
13
27
  ;; scope so synthesized narrowed type-bindings (see captures.ts
@@ -21,6 +35,25 @@ const KOTLIN_SCOPE_QUERY = `
21
35
  (check_expression)
22
36
  (control_structure_body) @scope.block)
23
37
 
38
+ ;; Lambda body scope (issue #1757). Each lambda_literal becomes its
39
+ ;; own Block scope so synthesized lambda-parameter and implicit-'it'
40
+ ;; type-bindings (see captures.ts synthesizeKotlinLambdaBindings) stay
41
+ ;; inside the lambda — they must not leak to the enclosing function
42
+ ;; scope and must shadow same-named outer bindings (val it = "outer";
43
+ ;; users.forEach { it.save() } — inner 'it' is the lambda's, not the
44
+ ;; outer String).
45
+ ;;
46
+ ;; Lambdas appear inside call_suffix for trailing-lambda syntax
47
+ ;; (list.forEach { it.foo() }) and inside value_arguments for
48
+ ;; explicit-paren syntax (list.forEach({ x -> x.foo() })); both AST
49
+ ;; positions produce the same lambda_literal subtree, so a single
50
+ ;; capture suffices.
51
+ ;;
52
+ ;; Uses @scope.block (not @scope.function) to match the smart-cast
53
+ ;; precedent (#1758) — keeps narrowed/lambda bindings scope-local
54
+ ;; without the auto-hoist semantics of Function scopes.
55
+ (lambda_literal) @scope.block
56
+
24
57
  ;; Declarations — types
25
58
  (class_declaration
26
59
  "interface"
@@ -2,28 +2,38 @@ import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolv
2
2
  /**
3
3
  * Kotlin scope resolver for RFC #909 Ring 3.
4
4
  *
5
- * Kotlin is intentionally registered but not yet listed in
6
- * `MIGRATED_LANGUAGES`, matching the Java migration pattern from #1482:
7
- * the resolver can run in shadow/forced mode, while production default
8
- * stays on the legacy DAG until the RFC flip criteria in #1746 are met.
5
+ * **Migration status:** Kotlin is in `MIGRATED_LANGUAGES`. Default
6
+ * production resolution flows through the scope-resolution pipeline;
7
+ * the legacy DAG is consulted only when the per-language env var
8
+ * (`REGISTRY_PRIMARY_KOTLIN=0`) explicitly forces the legacy parity
9
+ * run for CI comparison.
9
10
  *
10
- * **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 175/175 fixtures
11
- * after the migration sub-issues #1758–#1763 closed. Covers core
12
- * import, receiver, companion, default-param, vararg, constructor,
13
- * local assignment-chain, collection-iteration, smart casts
14
- * (`when (x) { is T -> … }` and `if (x is T)` — #1758), cross-file
15
- * iterable return propagation (#1759), single-level method-chain
16
- * fixpoint receiver types (#1760), parameter-type-narrowed overload
17
- * target-id selection (#1761), virtual dispatch via constructor RHS
18
- * (`val x: Animal = Dog()` — #1762), and interface default-method
19
- * dispatch via implements-split MRO (#1763).
11
+ * **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 208/208
12
+ * fixtures pass after the migration sub-issues #1758–#1763, the
13
+ * companion/instance dispatch fix #1756, and the lambda scopes
14
+ * fix #1757. Covers core import, receiver, companion, default-param,
15
+ * vararg, constructor, local assignment-chain, collection-iteration,
16
+ * smart casts (`when (x) { is T -> … }` and `if (x is T)` — #1758),
17
+ * cross-file iterable return propagation (#1759), single-level
18
+ * method-chain fixpoint receiver types (#1760), parameter-type-narrowed
19
+ * overload target-id selection (#1761), virtual dispatch via constructor
20
+ * RHS (`val x: Animal = Dog()` — #1762), interface default-method
21
+ * dispatch via implements-split MRO (#1763), companion-object vs
22
+ * instance member dispatch (#1756) via the `isStaticOnly` hook
23
+ * (including named companions and MRO-shadow / chain-typebinding /
24
+ * value-receiver crossover cases), and lambda-body Block scopes
25
+ * with scoped type-bindings for explicit parameters and implicit
26
+ * `it` (#1757) via `synthesizeKotlinLambdaBindings` plus the
27
+ * `(lambda_literal) @scope.block` query rule.
20
28
  *
21
- * **Remaining pre-flip blockers (#1746):** #1755 (forced-mode preview
22
- * CI workflow — obviated once Kotlin lands in `MIGRATED_LANGUAGES`
23
- * because the existing scope-parity matrix auto-discovers it), #1756
24
- * (companion vs instance member dispatch), and #1757 (lambda scopes
25
- * and lambda-parameter bindings). The flip PR adds
26
- * `SupportedLanguages.Kotlin` to `MIGRATED_LANGUAGES` after the named
27
- * blockers close.
29
+ * **Legacy parity skip list:** `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.kotlin`
30
+ * in `test/integration/resolvers/helpers.ts` records scope-resolver-only
31
+ * correctness wins that the legacy DAG cannot replicate. As of #1756 /
32
+ * #1757 there are 8 entries covering: the bare companion-vs-instance
33
+ * crossover, three MRO-shadow / standalone-chain cases, the chained-
34
+ * forEach lambda-scope case, the named-companion crossover, and the
35
+ * Case-0 / Case-3b / Case-5 companion crossovers under
36
+ * `kotlin-companion-other-cases`. Each entry is documented inline with
37
+ * its issue ref and rationale.
28
38
  */
29
39
  export declare const kotlinScopeResolver: ScopeResolver;
@@ -4,37 +4,62 @@ import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
4
4
  import { isClassLike } from '../../scope-resolution/scope/walkers.js';
5
5
  import { kotlinProvider } from '../kotlin.js';
6
6
  import { kotlinArityCompatibility, kotlinMergeBindings, populateKotlinOwners, resolveKotlinImportTarget, } from './index.js';
7
+ import { clearCompanionScopes } from './companion-scopes.js';
8
+ import { isKotlinStaticOnly } from './owners.js';
7
9
  /**
8
10
  * Kotlin scope resolver for RFC #909 Ring 3.
9
11
  *
10
- * Kotlin is intentionally registered but not yet listed in
11
- * `MIGRATED_LANGUAGES`, matching the Java migration pattern from #1482:
12
- * the resolver can run in shadow/forced mode, while production default
13
- * stays on the legacy DAG until the RFC flip criteria in #1746 are met.
12
+ * **Migration status:** Kotlin is in `MIGRATED_LANGUAGES`. Default
13
+ * production resolution flows through the scope-resolution pipeline;
14
+ * the legacy DAG is consulted only when the per-language env var
15
+ * (`REGISTRY_PRIMARY_KOTLIN=0`) explicitly forces the legacy parity
16
+ * run for CI comparison.
14
17
  *
15
- * **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 175/175 fixtures
16
- * after the migration sub-issues #1758–#1763 closed. Covers core
17
- * import, receiver, companion, default-param, vararg, constructor,
18
- * local assignment-chain, collection-iteration, smart casts
19
- * (`when (x) { is T -> … }` and `if (x is T)` — #1758), cross-file
20
- * iterable return propagation (#1759), single-level method-chain
21
- * fixpoint receiver types (#1760), parameter-type-narrowed overload
22
- * target-id selection (#1761), virtual dispatch via constructor RHS
23
- * (`val x: Animal = Dog()` — #1762), and interface default-method
24
- * dispatch via implements-split MRO (#1763).
18
+ * **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 208/208
19
+ * fixtures pass after the migration sub-issues #1758–#1763, the
20
+ * companion/instance dispatch fix #1756, and the lambda scopes
21
+ * fix #1757. Covers core import, receiver, companion, default-param,
22
+ * vararg, constructor, local assignment-chain, collection-iteration,
23
+ * smart casts (`when (x) { is T -> … }` and `if (x is T)` — #1758),
24
+ * cross-file iterable return propagation (#1759), single-level
25
+ * method-chain fixpoint receiver types (#1760), parameter-type-narrowed
26
+ * overload target-id selection (#1761), virtual dispatch via constructor
27
+ * RHS (`val x: Animal = Dog()` — #1762), interface default-method
28
+ * dispatch via implements-split MRO (#1763), companion-object vs
29
+ * instance member dispatch (#1756) via the `isStaticOnly` hook
30
+ * (including named companions and MRO-shadow / chain-typebinding /
31
+ * value-receiver crossover cases), and lambda-body Block scopes
32
+ * with scoped type-bindings for explicit parameters and implicit
33
+ * `it` (#1757) via `synthesizeKotlinLambdaBindings` plus the
34
+ * `(lambda_literal) @scope.block` query rule.
25
35
  *
26
- * **Remaining pre-flip blockers (#1746):** #1755 (forced-mode preview
27
- * CI workflow — obviated once Kotlin lands in `MIGRATED_LANGUAGES`
28
- * because the existing scope-parity matrix auto-discovers it), #1756
29
- * (companion vs instance member dispatch), and #1757 (lambda scopes
30
- * and lambda-parameter bindings). The flip PR adds
31
- * `SupportedLanguages.Kotlin` to `MIGRATED_LANGUAGES` after the named
32
- * blockers close.
36
+ * **Legacy parity skip list:** `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.kotlin`
37
+ * in `test/integration/resolvers/helpers.ts` records scope-resolver-only
38
+ * correctness wins that the legacy DAG cannot replicate. As of #1756 /
39
+ * #1757 there are 8 entries covering: the bare companion-vs-instance
40
+ * crossover, three MRO-shadow / standalone-chain cases, the chained-
41
+ * forEach lambda-scope case, the named-companion crossover, and the
42
+ * Case-0 / Case-3b / Case-5 companion crossovers under
43
+ * `kotlin-companion-other-cases`. Each entry is documented inline with
44
+ * its issue ref and rationale.
33
45
  */
34
46
  export const kotlinScopeResolver = {
35
47
  language: SupportedLanguages.Kotlin,
36
48
  languageProvider: kotlinProvider,
37
49
  importEdgeReason: 'kotlin-scope: import',
50
+ loadResolutionConfig: () => {
51
+ // Drop the module-level `companionScopesByFile` table from any
52
+ // prior workspace pass before this run populates it via
53
+ // `emitKotlinScopeCaptures`. Mirrors the C resolver's
54
+ // `clearStaticNames()` call in `loadResolutionConfig` — the
55
+ // orchestrator awaits this hook exactly once per workspace pass
56
+ // (see `pipeline/phase.ts`), making it the right lifecycle seam
57
+ // for clearing per-language side-channel state. Returns
58
+ // `undefined` because Kotlin has no external resolution config
59
+ // to load.
60
+ clearCompanionScopes();
61
+ return undefined;
62
+ },
38
63
  resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
39
64
  const ws = { fromFile, allFilePaths };
40
65
  return resolveKotlinImportTarget({ kind: 'named', localName: '_', importedName: '_', targetRaw }, ws);
@@ -44,6 +69,7 @@ export const kotlinScopeResolver = {
44
69
  buildMro: (graph, parsedFiles, nodeLookup) => buildKotlinMro(graph, parsedFiles, nodeLookup),
45
70
  populateOwners: (parsed) => populateKotlinOwners(parsed),
46
71
  isSuperReceiver: (text) => text.trim() === 'super',
72
+ isStaticOnly: isKotlinStaticOnly,
47
73
  fieldFallbackOnMethodLookup: false,
48
74
  propagatesReturnTypesAcrossImports: true,
49
75
  collapseMemberCallsByCallerTarget: false,
@@ -6,6 +6,17 @@ export function kotlinBindingScopeFor(decl, innermost, tree) {
6
6
  // and erase the arm-local narrowing.
7
7
  if (decl['@type-binding.narrowed'] !== undefined)
8
8
  return innermost.id;
9
+ // Lambda-scoped bindings (issue #1757) — explicit lambda parameters
10
+ // and implicit `it` must stay inside the lambda body Block scope.
11
+ // Without this gating, the binding hoists to the enclosing function
12
+ // scope and:
13
+ // - `it` leaks past the closing brace of the lambda, shadowing the
14
+ // parameter-scope `it` (or outer `val it = "outer"`) for everything
15
+ // that follows in the function body.
16
+ // - Nested lambda parameters override each other across siblings.
17
+ // Same mechanism as the smart-cast precedent above.
18
+ if (decl['@type-binding.lambda-scoped'] !== undefined)
19
+ return innermost.id;
9
20
  if (decl['@type-binding.return'] === undefined)
10
21
  return null;
11
22
  let current = innermost;
@@ -74,6 +74,7 @@ export const MIGRATED_LANGUAGES = new Set([
74
74
  SupportedLanguages.CPlusPlus,
75
75
  SupportedLanguages.PHP,
76
76
  SupportedLanguages.JavaScript,
77
+ SupportedLanguages.Kotlin,
77
78
  ]);
78
79
  /**
79
80
  * Return the env-var name that controls a given language's registry-
@@ -510,6 +510,43 @@ export interface ScopeResolver {
510
510
  * Languages without file-local linkage semantics leave this undefined.
511
511
  */
512
512
  readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
513
+ /**
514
+ * Optional predicate to identify members for which dispatch through
515
+ * an instance receiver is **invalid at the language level** — i.e.
516
+ * calling `instance.member()` would be a compile error or a
517
+ * type-system violation, even if a member of that name exists on
518
+ * the receiver's class. When provided, the receiver-bound calls
519
+ * pass filters out such members at every instance-receiver dispatch
520
+ * case (Case 0 compound receiver, Case 3b chain-typebinding, Case 4
521
+ * simple typeBinding, Case 5 value-receiver bridge) so the resolver
522
+ * does not emit a misleading `CALLS` edge for a call site the
523
+ * language itself would reject.
524
+ *
525
+ * **Reserved for the "instance receiver is invalid" semantic only.**
526
+ * Hooks for languages where static / class-level members are still
527
+ * legally callable through an instance (Python `@staticmethod`,
528
+ * JavaScript `static` methods accessed via the prototype chain in
529
+ * some lookup paths) should return `false` for those members — the
530
+ * filter would silently suppress legitimate edges otherwise. The
531
+ * canonical fit today is Kotlin companion-object methods, where
532
+ * `instance.companionMethod()` is a compile error.
533
+ *
534
+ * Case 2 (class-name receiver) is intentionally unaffected: a call
535
+ * through the class name (`Foo.staticMethod()`) is a legitimate
536
+ * dispatch.
537
+ *
538
+ * Case 0.5 (implicit `this` receiver) currently fires only for
539
+ * languages with `resolveThisViaEnclosingClass === true` (C++ at
540
+ * time of writing), none of which expose static-only semantics. A
541
+ * future language that enables BOTH `resolveThisViaEnclosingClass`
542
+ * AND `isStaticOnly` must wire the filter into Case 0.5's chain
543
+ * walk too — see the inline note in `receiver-bound-calls.ts`.
544
+ *
545
+ * Languages without static-only semantics leave this undefined and
546
+ * the legacy unfiltered behavior applies (every owned member of the
547
+ * receiver class is a dispatch candidate).
548
+ */
549
+ readonly isStaticOnly?: (def: SymbolDefinition) => boolean;
513
550
  /**
514
551
  * Optional predicate to gate free-call fallback emission by caller-side
515
552
  * visibility. When provided, `pickUniqueGlobalCallable` rejects candidates
@@ -46,7 +46,7 @@ import type { WorkspaceResolutionIndex } from '../workspace-index.js';
46
46
  /** Subset of `ScopeResolver` consumed by this pass. Accepting the
47
47
  * subset rather than the full provider keeps tests and partial
48
48
  * refactors lighter — callers only need to populate what we read. */
49
- type ReceiverBoundProviderSubset = Pick<ScopeResolver, 'isSuperReceiver' | 'isSuperReceiverInContext' | 'fieldFallbackOnMethodLookup' | 'collapseMemberCallsByCallerTarget' | 'unwrapCollectionAccessor' | 'hoistTypeBindingsToModule' | 'resolveQualifiedReceiverMember' | 'resolveThisViaEnclosingClass' | 'conversionRankFn' | 'constraintCompatibility'>;
49
+ type ReceiverBoundProviderSubset = Pick<ScopeResolver, 'isSuperReceiver' | 'isSuperReceiverInContext' | 'fieldFallbackOnMethodLookup' | 'collapseMemberCallsByCallerTarget' | 'unwrapCollectionAccessor' | 'hoistTypeBindingsToModule' | 'resolveQualifiedReceiverMember' | 'resolveThisViaEnclosingClass' | 'conversionRankFn' | 'constraintCompatibility' | 'isStaticOnly'>;
50
50
  export declare function emitReceiverBoundCalls(graph: KnowledgeGraph, scopes: ScopeResolutionIndexes, parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, handledSites: Set<string>, provider: ReceiverBoundProviderSubset, index: WorkspaceResolutionIndex, model: SemanticModel): number;
51
51
  /**
52
52
  * Sentinel returned by `pickOverload` when narrowing leaves >1 candidate
@@ -219,10 +219,29 @@ export function emitReceiverBoundCalls(graph, scopes, parsedFiles, nodeLookup, h
219
219
  if (currentClass !== undefined) {
220
220
  const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)];
221
221
  let memberDef;
222
+ // Static-only filter (#1756 / U3): same shape as Case 4's
223
+ // chain walk (skip-and-walk-on) but without overload
224
+ // narrowing — Case 0 uses `findOwnedMember` directly. When
225
+ // an owner's resolved candidate is static-only (Kotlin
226
+ // companion-promoted), continue to the next ancestor in
227
+ // the MRO chain so a legitimate instance member can bind.
228
+ // If the entire chain is static-only, no edge is emitted —
229
+ // unlike Case 4, Case 0 does NOT mark the site handled in
230
+ // that situation because compound receivers (`a.b.c()`)
231
+ // are not pre-emitted by `emitReferencesViaLookup` (the
232
+ // reference index has no compound-receiver entry for
233
+ // shapes like `Logger.create("a")`), so there's no wrong
234
+ // target to suppress.
222
235
  for (const ownerId of chain) {
223
- memberDef = findOwnedMember(ownerId, memberName, model);
224
- if (memberDef !== undefined)
225
- break;
236
+ const candidate = findOwnedMember(ownerId, memberName, model);
237
+ if (candidate === undefined)
238
+ continue;
239
+ if (provider.isStaticOnly?.(candidate) === true) {
240
+ // Skip static-only candidate; walk to next ancestor.
241
+ continue;
242
+ }
243
+ memberDef = candidate;
244
+ break;
226
245
  }
227
246
  if (memberDef !== undefined) {
228
247
  const ok = tryEmitEdge(graph, scopes, nodeLookup, site, memberDef, memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', seen, 0.85, collapse);
@@ -241,6 +260,17 @@ export function emitReceiverBoundCalls(graph, scopes, parsedFiles, nodeLookup, h
241
260
  // C++ `this->member()` (and same-shape receivers in other OO
242
261
  // languages) should resolve against the enclosing class + MRO
243
262
  // even when there is no explicit `this` typeBinding in scope.
263
+ //
264
+ // **Static-only filter dependency (#1756 / U3):** this case does
265
+ // NOT currently consult `provider.isStaticOnly`. Today it fires
266
+ // only for C++ (the sole `resolveThisViaEnclosingClass === true`
267
+ // language), which has no static-only semantics. Kotlin — the
268
+ // current `isStaticOnly` consumer — leaves `resolveThisVia
269
+ // EnclosingClass` unset, so Case 0.5 is dead code for Kotlin
270
+ // crossover suppression and U3 leaves it untouched. If any
271
+ // future language enables BOTH `resolveThisViaEnclosingClass`
272
+ // AND `isStaticOnly`, the chain-walk below MUST adopt the
273
+ // skip-and-walk-on filter pattern used by Cases 0, 3b, and 4.
244
274
  if (provider.resolveThisViaEnclosingClass === true && receiverName === 'this') {
245
275
  const enclosingClass = findEnclosingClassDef(site.inScope, scopes);
246
276
  if (enclosingClass !== undefined) {
@@ -431,10 +461,23 @@ export function emitReceiverBoundCalls(graph, scopes, parsedFiles, nodeLookup, h
431
461
  if (ownerDef !== undefined) {
432
462
  const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
433
463
  let memberDef;
464
+ // Static-only filter (#1756 / U3): mirrors Case 0's chain
465
+ // walk — `findOwnedMember` without overload narrowing. When
466
+ // a static-only candidate is found at an ancestor, walk on
467
+ // so a legitimate instance member can bind. If the entire
468
+ // chain is static-only, no edge is emitted (Case 3b is fed
469
+ // by chain-typebinding receivers, not pre-emitted by
470
+ // `emitReferencesViaLookup` for compound shapes, so no
471
+ // handled-site marker is needed for chain-only-static).
434
472
  for (const ownerId of chain) {
435
- memberDef = findOwnedMember(ownerId, memberName, model);
436
- if (memberDef !== undefined)
437
- break;
473
+ const candidate = findOwnedMember(ownerId, memberName, model);
474
+ if (candidate === undefined)
475
+ continue;
476
+ if (provider.isStaticOnly?.(candidate) === true) {
477
+ continue;
478
+ }
479
+ memberDef = candidate;
480
+ break;
438
481
  }
439
482
  if (memberDef !== undefined) {
440
483
  const ok = tryEmitEdge(graph, scopes, nodeLookup, site, memberDef, memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', seen, 0.85, collapse);
@@ -475,16 +518,53 @@ export function emitReceiverBoundCalls(graph, scopes, parsedFiles, nodeLookup, h
475
518
  const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
476
519
  let memberDef;
477
520
  let ambiguous = false;
521
+ // Track whether the chain walk filtered out any static-only
522
+ // candidates. When it did and the chain ended with no
523
+ // legitimate instance member, we mark the site as handled so
524
+ // `emitReferencesViaLookup` doesn't re-emit a wrong target
525
+ // from the pre-resolved reference index (which has no
526
+ // static-only awareness).
527
+ let allFilteredStaticOnly = false;
528
+ // Static-only filter (#1756 / U2): the filter must run INSIDE
529
+ // the chain walk and BEFORE arity narrowing.
530
+ //
531
+ // INSIDE: when a derived owner's only candidates are static-
532
+ // only (Kotlin companion-promoted), `pickFirstNonStaticOnly`
533
+ // returns `undefined` and the loop `continue`s to the next
534
+ // ancestor in the MRO chain — giving a legitimate ancestor
535
+ // instance method a chance to bind. The earlier after-chain
536
+ // filter aborted the entire site instead, producing a false
537
+ // negative whenever the most-derived owner shadowed an
538
+ // ancestor's instance method with a static-only companion
539
+ // member.
540
+ //
541
+ // BEFORE narrowing: filtering survivors of `lookupAllByOwner`
542
+ // (rather than survivors of `narrowOverloadCandidates`) means
543
+ // a same-arity static + instance pair on one owner doesn't
544
+ // collapse to `OVERLOAD_AMBIGUOUS`. Kotlin compile-resolves
545
+ // such a pair unambiguously to the instance method because
546
+ // companion members are not legal instance-dispatch
547
+ // candidates.
478
548
  for (const ownerId of chain) {
479
- const picked = pickOverload(ownerId, memberName, site, model, provider);
549
+ const picked = pickFirstNonStaticOnly(ownerId, memberName, site, model, provider);
480
550
  if (picked === OVERLOAD_AMBIGUOUS) {
481
551
  ambiguous = true;
482
552
  break;
483
553
  }
554
+ if (picked === STATIC_ONLY_FILTERED) {
555
+ // At least one static-only candidate was filtered out at
556
+ // this owner; remember so we can mark handled if the
557
+ // chain ends with no legitimate match.
558
+ allFilteredStaticOnly = true;
559
+ continue;
560
+ }
484
561
  if (picked !== undefined) {
485
562
  memberDef = picked;
486
563
  break;
487
564
  }
565
+ // `picked === undefined` means this owner had no member of
566
+ // this name at all. Walk on to the next ancestor in the
567
+ // MRO chain.
488
568
  }
489
569
  if (ambiguous) {
490
570
  // Suppress and mark handled so `emitReferencesViaLookup`
@@ -493,6 +573,15 @@ export function emitReceiverBoundCalls(graph, scopes, parsedFiles, nodeLookup, h
493
573
  handledSites.add(siteKey);
494
574
  continue;
495
575
  }
576
+ if (memberDef === undefined && allFilteredStaticOnly) {
577
+ // The chain ended with no candidates because every viable
578
+ // owner had only static-only members. Mark handled so
579
+ // `emitReferencesViaLookup` doesn't re-emit a wrong target
580
+ // from the pre-resolved reference index. Parallels the old
581
+ // after-chain `isStaticOnly` suppression block.
582
+ handledSites.add(siteKey);
583
+ continue;
584
+ }
496
585
  if (memberDef !== undefined) {
497
586
  // For read/write ACCESSES, mirror the legacy DAG's reason
498
587
  // convention so consumers asserting `reason === 'write'`
@@ -549,6 +638,19 @@ export function emitReceiverBoundCalls(graph, scopes, parsedFiles, nodeLookup, h
549
638
  continue;
550
639
  }
551
640
  if (picked !== undefined) {
641
+ // Static-only filter (#1756 / U3): unlike Case 4 there's no
642
+ // MRO chain to walk here — Case 5 dispatches on a single
643
+ // owner via `pickOverload`. When the picked candidate is
644
+ // static-only (Kotlin companion-promoted), suppress the
645
+ // edge entirely and mark the site handled so
646
+ // `emitReferencesViaLookup` doesn't re-emit a wrong target
647
+ // from the pre-resolved reference index. Matches the after-
648
+ // chain handled-marker semantic used by Case 4's
649
+ // all-filtered fall-through.
650
+ if (provider.isStaticOnly?.(picked) === true) {
651
+ handledSites.add(siteKey);
652
+ continue;
653
+ }
552
654
  const reason = site.kind === 'write' || site.kind === 'read'
553
655
  ? site.kind
554
656
  : picked.filePath !== parsed.filePath
@@ -610,3 +712,89 @@ function pickOverload(ownerId, memberName, site, model, provider) {
610
712
  * collapses distinct types in arity-metadata).
611
713
  */
612
714
  export const OVERLOAD_AMBIGUOUS = Symbol('overload-ambiguous');
715
+ /**
716
+ * Sentinel returned by `pickFirstNonStaticOnly` when the only candidates
717
+ * at the queried owner were filtered out by `provider.isStaticOnly`. Lets
718
+ * the Case 4 chain walk distinguish "owner had no member of this name"
719
+ * (return `undefined`, continue silently) from "owner had only static-
720
+ * only members" (return this sentinel, continue and remember so the
721
+ * post-chain handled-marker logic can suppress wrong-target re-emission
722
+ * from `emitReferencesViaLookup`). See #1756 / remediation plan U2.
723
+ */
724
+ const STATIC_ONLY_FILTERED = Symbol('static-only-filtered');
725
+ /**
726
+ * Receiver-bound member lookup that filters static-only candidates BEFORE
727
+ * arity narrowing. Wraps the raw `lookupAllByOwner` → `narrowOverloadCandidates`
728
+ * pipeline so:
729
+ *
730
+ * 1. Candidates flagged by `provider.isStaticOnly` (Kotlin companion-
731
+ * promoted methods today) never enter the narrowing stage. A same-
732
+ * name same-arity static + instance pair on one owner therefore does
733
+ * NOT collapse to `OVERLOAD_AMBIGUOUS` — the instance member wins
734
+ * unambiguously, matching Kotlin's compile-time resolution.
735
+ * 2. The chain walk in `emitReceiverBoundCalls` Case 4 can fall through
736
+ * to ancestors when only static-only candidates exist at the
737
+ * most-derived owner (returns `STATIC_ONLY_FILTERED`), rather than
738
+ * aborting the site as the previous after-chain filter did.
739
+ *
740
+ * Returns:
741
+ * - `undefined` — no member with this name on this owner; chain walk
742
+ * continues silently.
743
+ * - `STATIC_ONLY_FILTERED` — at least one candidate existed but every
744
+ * one was static-only; chain walk continues and remembers so the
745
+ * post-chain handled-marker can fire if no ancestor binds.
746
+ * - `OVERLOAD_AMBIGUOUS` — narrowing on the surviving non-static
747
+ * candidates left >1 ambiguous match; chain walk aborts and the
748
+ * site is marked handled (existing sentinel handling preserved).
749
+ * - `SymbolDefinition` — single survivor (the chosen target).
750
+ *
751
+ * See remediation plan `docs/plans/2026-05-22-002-fix-lang-kotlin-1782-
752
+ * remediation-plan.md` § U2 for the full rationale.
753
+ */
754
+ function pickFirstNonStaticOnly(ownerId, memberName, site, model, provider) {
755
+ const rawOverloads = model.methods.lookupAllByOwner(ownerId, memberName);
756
+ if (rawOverloads.length === 0) {
757
+ // Non-callable member (field / property / variable) — ACCESSES
758
+ // write/read sites target these too. Static-only filtering doesn't
759
+ // apply to fields, so delegate straight to `lookupFieldByOwner`.
760
+ return model.fields.lookupFieldByOwner(ownerId, memberName);
761
+ }
762
+ const isStaticOnly = provider.isStaticOnly;
763
+ let overloads = rawOverloads;
764
+ let filteredAny = false;
765
+ if (isStaticOnly !== undefined) {
766
+ const survivors = [];
767
+ for (const candidate of rawOverloads) {
768
+ if (isStaticOnly(candidate) === true) {
769
+ filteredAny = true;
770
+ continue;
771
+ }
772
+ survivors.push(candidate);
773
+ }
774
+ overloads = survivors;
775
+ }
776
+ if (overloads.length === 0) {
777
+ // Every candidate was static-only; the caller (Case 4 chain walk)
778
+ // should walk on to the next owner AND remember that filtering
779
+ // happened so it can mark the site handled if the whole chain
780
+ // ends with no legitimate match.
781
+ return filteredAny ? STATIC_ONLY_FILTERED : undefined;
782
+ }
783
+ if (overloads.length === 1)
784
+ return overloads[0];
785
+ const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
786
+ argumentTypeClasses: site.argumentTypeClasses,
787
+ conversionRankFn: provider.conversionRankFn,
788
+ constraintCompatibility: provider.constraintCompatibility,
789
+ });
790
+ // Same ambiguity handling as `pickOverload`: when normalization
791
+ // collapses the surviving overloads into a single bucket (e.g., C++
792
+ // `f(int)`/`f(long)` normalized to `['int']`), suppress rather than
793
+ // arbitrarily picking. When narrowing leaves >1 distinct candidate
794
+ // with no tie-breaker, suppress for the same reason.
795
+ if (isOverloadAmbiguousAfterNormalization(candidates, site.arity))
796
+ return OVERLOAD_AMBIGUOUS;
797
+ if (candidates.length > 1)
798
+ return OVERLOAD_AMBIGUOUS;
799
+ return candidates[0] ?? overloads[0];
800
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.6-rc.45",
3
+ "version": "1.6.6-rc.46",
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",