Merge branch 'main' into fix/fts-non-fatal-in-analyze

This commit is contained in:
Gergő Magyar 2026-05-23 07:28:58 +01:00 • committed by GitHub
commit 33d15a94ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2087 additions and 46 deletions

View file

@ -48,6 +48,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
| Date | Version | Change |
|------|---------|--------|
| 2026-05-22 | 1.8.0 | Kotlin added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). Closes #1756 (companion-vs-instance dispatch) and #1757 (lambda scopes); refs #1746. RFC §6.4 corpus criterion waived (corpus-mode wiring is #927-scope); fixture criterion met. |
| 2026-04-23 | 1.7.0 | TypeScript added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). |
| 2026-04-20 | 1.6.0 | Added scope-resolution pipeline pointer (RFC #909 Ring 3); Python migrated to registry-primary. |
| 2026-04-19 | 1.5.0 | Cross-repo impact (#794): `impact`/`query`/`context` accept `repo: "@<group>"` + `service`. Removed `group_query`/`group_contracts`/`group_status` MCP tools; added `gitnexus://group/{name}/contracts` and `gitnexus://group/{name}/status` resources. |

View file

@ -1,4 +1,4 @@
import type { Capture, CaptureMatch, Range } from 'gitnexus-shared';
import { makeScopeId, type Capture, type CaptureMatch, type Range } from 'gitnexus-shared';
import {
findNodeAtRange,
nodeToCapture,
@ -13,12 +13,13 @@ import { recordKotlinCacheHit, recordKotlinCacheMiss } from './cache-stats.js';
import { normalizeKotlinType } from './interpret.js';
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
import { getKotlinParser, getKotlinScopeQuery } from './query.js';
import { markCompanionScope } from './companion-scopes.js';
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
export function emitKotlinScopeCaptures(
sourceText: string,
_filePath: string,
filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getKotlinParser>['parse']> | undefined;
@ -36,6 +37,7 @@ export function emitKotlinScopeCaptures(
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
out.push(...synthesizeKotlinSmartCastBindings(tree.rootNode));
out.push(...synthesizeKotlinLambdaBindings(tree.rootNode, returnTypes));
for (const match of getKotlinScopeQuery().matches(tree.rootNode)) {
const grouped: Record<string, Capture> = {};
@ -45,6 +47,27 @@ export function emitKotlinScopeCaptures(
}
if (Object.keys(grouped).length === 0) continue;
// Companion-object marker (#1756 / U4). The `@scope.companion`
// capture is a side-channel marker — it shares its range with the
// existing `(companion_object) @scope.class` rule, so the Class
// scope already exists in the scope tree. Record the scope id into
// the per-file companion-scope set so `populateCompanionMembersOn
// EnclosingClass` (owners.ts) can identify companion scopes
// unambiguously, regardless of whether they are anonymous, named,
// or contain nested classes. The match itself is consumed here and
// NOT pushed to the output — the scope-extractor would reject the
// `companion` kind suffix anyway, but suppressing the emit keeps
// downstream pipelines from re-processing the same range twice.
if (grouped['@scope.companion'] !== undefined) {
const scopeId = makeScopeId({
filePath,
range: grouped['@scope.companion']!.range,
kind: 'Class',
});
markCompanionScope(filePath, scopeId);
continue;
}
if (grouped['@import.statement'] !== undefined) {
const importNode = findNodeAtRange(
tree.rootNode,
@ -309,6 +332,330 @@ function buildNarrowedTypeBindingCapture(
};
}
/**
* Synthesize lambda-body type-bindings — issue #1757.
*
* For each `lambda_literal` we emit one or more `@type-binding.annotation`
* captures anchored INSIDE the lambda body (the lambda's `statements` child
* — or the `lambda_literal` itself when no statements child exists). The
* `@scope.block` query rule (see query.ts) makes each `lambda_literal` a
* Block scope, and the `@type-binding.lambda-scoped` marker forces the
* scope-extractor to keep the binding at the innermost (lambda body) scope
* via `kotlinBindingScopeFor`. This guarantees:
* - explicit parameter names (`{ user -> ... }`) bind only inside the
* body, NOT in the enclosing function scope;
* - implicit `it` is visible only inside the lambda body and shadows
* any same-named outer binding (`val it = "outer"; users.forEach
* { it.save() }` — inner `it` is the lambda parameter);
* - nested lambdas shadow deterministically (innermost lambda's `it`
* wins; outer lambda's parameters are still visible by their own
* names through the parent scope chain).
*
* Receiver-type inference is best-effort: the lambda's call-expression
* parent is inspected; if the receiver has a known local-variable type
* and the call's member is a well-known stdlib idiom (`forEach`/`map`/
* `filter` → element type of the collection; `let`/`apply`/`also`/`run`/
* `takeIf`/`takeUnless`/`use` → receiver type itself), the inferred type
* is attached. When inference fails (chained receivers, unknown member,
* non-stdlib idiom), we still emit the binding with a sentinel/erased
* type so the binding's scope semantics (no leak; no `it` cross-fire) are
* enforced — call-resolution from the body still falls through to free-
* call fallback, which is the correct behavior when the type is unknown.
*
* Standard-library coverage: `forEach`, `map`, `filter`, `flatMap`,
* `mapNotNull`, `filterNotNull`, `onEach`, `find`, `firstOrNull`,
* `lastOrNull`, `any`, `all`, `none`, `count`, `forEachIndexed`,
* `let`, `apply`, `also`, `run`, `takeIf`, `takeUnless`, `use`, `with`.
*
* Lambda-receiver typing for non-stdlib higher-order functions is a
* follow-up; the binding-existence guarantee above is the minimum
* acceptance criterion per the U9 plan.
*/
function synthesizeKotlinLambdaBindings(
rootNode: SyntaxNode,
returnTypes: ReadonlyMap<string, string>,
): CaptureMatch[] {
const out: CaptureMatch[] = [];
const classMembers = collectKotlinClassMembers(rootNode);
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
const localTypes = collectKotlinLocalTypeTexts(fnNode, returnTypes);
for (const lambdaNode of descendantsOfType(fnNode, 'lambda_literal')) {
const anchor = lambdaBodyAnchor(lambdaNode);
if (anchor === null) continue;
const inferredType = inferKotlinLambdaReceiverType(
lambdaNode,
localTypes,
returnTypes,
classMembers,
);
const params = explicitLambdaParameters(lambdaNode);
if (params.length === 0) {
// No explicit `(x ->)` parameter list — implicit `it` is in
// scope inside the body. Synthesize the `it` type-binding so
// calls like `it.save()` resolve through the typeBinding chain.
const typeNode = inferredType?.typeNode ?? lambdaNode;
const typeText = inferredType?.typeText ?? '';
out.push(buildLambdaTypeBindingCapture(anchor, 'it', typeNode, typeText));
} else {
// Explicit parameters: `{ user -> ... }`, `{ (a, b) -> ... }`,
// `{ key, value -> ... }`. Emit one binding per parameter.
// For multi-arg lambdas (destructuring, `forEachIndexed { i, x
// -> ... }`), the per-arg type inference is finer than what we
// currently support — we bind the FIRST parameter to the
// inferred receiver type (matches single-arg idioms) and bind
// additional parameters with an empty/erased type, which still
// gates leakage but won't drive call resolution for those names.
for (let i = 0; i < params.length; i++) {
const paramName = params[i]!.text;
const typeNode = i === 0 ? (inferredType?.typeNode ?? params[i]!) : params[i]!;
const typeText = i === 0 ? (inferredType?.typeText ?? '') : '';
out.push(buildLambdaTypeBindingCapture(anchor, paramName, typeNode, typeText));
}
}
}
}
return out;
}
/** Anchor node used for synthesized lambda-body type-bindings.
* Prefers the `statements` child of `lambda_literal` (always strictly
* inside the lambda body, so the scope-extractor's `rangesEqual` auto-
* hoist check fails — the binding stays in the Block scope). Falls
* back to the lambda_literal itself when no statements child exists
* (e.g. empty lambda); the `@type-binding.lambda-scoped` marker in
* `kotlinBindingScopeFor` then forces no-hoist explicitly. */
function lambdaBodyAnchor(lambdaNode: SyntaxNode): SyntaxNode | null {
const statements = lambdaNode.namedChildren.find((c) => c.type === 'statements');
return statements ?? lambdaNode;
}
/** Extract explicit lambda parameter `simple_identifier` nodes from a
* `lambda_literal`. Returns an empty array when no `lambda_parameters`
* is present (implicit `it` form). */
function explicitLambdaParameters(lambdaNode: SyntaxNode): SyntaxNode[] {
const params = lambdaNode.namedChildren.find((c) => c.type === 'lambda_parameters');
if (params === undefined) return [];
const out: SyntaxNode[] = [];
for (const child of params.namedChildren) {
if (child.type !== 'variable_declaration') continue;
const ident = child.namedChildren.find((c) => c.type === 'simple_identifier');
if (ident !== undefined) out.push(ident);
}
return out;
}
function buildLambdaTypeBindingCapture(
anchor: SyntaxNode,
name: string,
typeNode: SyntaxNode,
typeText: string,
): CaptureMatch {
return {
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', anchor),
'@type-binding.name': syntheticCapture('@type-binding.name', anchor, name),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
typeNode,
typeText === '' ? '' : normalizeKotlinType(typeText),
),
// Marker consumed by `kotlinBindingScopeFor` (simple-hooks.ts) to
// pin this binding inside the lambda Block scope — without it the
// scope-extractor would auto-hoist the binding to the enclosing
// function scope and `it` (or the lambda parameter name) would
// leak past the closing brace.
'@type-binding.lambda-scoped': syntheticCapture('@type-binding.lambda-scoped', anchor, '1'),
};
}
/** Stdlib higher-order functions whose lambda parameter receives the
* ELEMENT type of the receiver collection (Map / Iterable element). */
const KOTLIN_ELEMENT_TYPE_LAMBDAS = new Set([
'forEach',
'forEachIndexed',
'map',
'mapNotNull',
'mapIndexed',
'filter',
'filterNot',
'filterNotNull',
'filterIsInstance',
'flatMap',
'flatten',
'onEach',
'find',
'findLast',
'firstOrNull',
'lastOrNull',
'singleOrNull',
'any',
'all',
'none',
'count',
'partition',
'sortedBy',
'sortedByDescending',
'groupBy',
'associate',
'associateBy',
'associateWith',
'minByOrNull',
'maxByOrNull',
'sumOf',
'distinctBy',
]);
/** Stdlib scope functions whose lambda receives the RECEIVER itself as
* `it` (or as `this` for `apply`/`run`/`with`). For the binding-
* existence guarantee we treat both forms the same way — `it` binds
* to the receiver type; `apply`/`run`/`with` callers see free calls
* inside the body which fall through to free-call resolution against
* the enclosing scope (no `this`-aware dispatch yet — follow-up). */
const KOTLIN_SCOPE_FUNCTION_LAMBDAS = new Set(['let', 'also', 'takeIf', 'takeUnless', 'use']);
/** `apply`, `run`, `with` expose the receiver as `this` rather than
* `it`. We still synthesize an `it` binding because the lambda may
* reference the receiver elsewhere — but the more common usage
* (`user.apply { save() }`) goes through free-call resolution on the
* body, not through `it`. Including these here keeps the binding
* scope correct without claiming we resolve `this`-form correctly. */
const KOTLIN_THIS_RECEIVER_LAMBDAS = new Set(['apply', 'run', 'with']);
/** Walk up from `lambdaNode` to the enclosing `call_expression` and
* infer the lambda parameter's type from the call's receiver and
* member name. Returns null when the inference path is not yet
* supported (chained receivers, unknown member, non-stdlib idiom).
*
* Best-effort: a null return is harmless — `synthesizeKotlinLambda
* Bindings` still emits the binding with an empty type so the scope
* semantics (no leak, no cross-fire) are enforced; only the call-
* resolution path from `it.method()` may fall through to free-call
* fallback when the type isn't known. */
function inferKotlinLambdaReceiverType(
lambdaNode: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
returnTypes: ReadonlyMap<string, string>,
classMembers: KotlinClassMembers,
): { typeText: string; typeNode: SyntaxNode } | null {
const callExpr = findEnclosingCallExpression(lambdaNode);
if (callExpr === null) return null;
const callee = callExpr.namedChildren.find(
(c) => c.type === 'navigation_expression' || c.type === 'simple_identifier',
);
if (callee === undefined) return null;
if (callee.type === 'simple_identifier') {
// `with(receiver) { ... }` — argument is the receiver. Not yet
// wired through; defer to follow-up.
return null;
}
// navigation_expression: <receiver>.<member>
const receiver = callee.namedChild(0);
const memberName = callee.namedChildren
.find((c) => c.type === 'navigation_suffix')
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
if (receiver === null || memberName === undefined) return null;
const receiverType = inferKotlinLambdaReceiverExpressionType(
receiver,
localTypes,
returnTypes,
classMembers,
);
if (receiverType === null) return null;
if (KOTLIN_ELEMENT_TYPE_LAMBDAS.has(memberName)) {
const element = kotlinContainerElementType(receiverType, 'values');
if (element === null || element === '') return null;
return { typeText: element, typeNode: lambdaNode };
}
if (
KOTLIN_SCOPE_FUNCTION_LAMBDAS.has(memberName) ||
KOTLIN_THIS_RECEIVER_LAMBDAS.has(memberName)
) {
// Strip nullable suffix for `?.let { ... }` semantics — inside the
// body, the receiver is non-null per Kotlin smart-cast.
const stripped = normalizeKotlinType(receiverType);
return { typeText: stripped, typeNode: lambdaNode };
}
return null;
}
/** Infer the static type of the expression that produced the lambda's
* enclosing call. Supports: `simple_identifier` (lookup in
* `localTypes`), `indexing_expression` on a Map-typed receiver, and
* `call_expression` whose callee return type is in `returnTypes`. */
function inferKotlinLambdaReceiverExpressionType(
receiver: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
returnTypes: ReadonlyMap<string, string>,
classMembers: KotlinClassMembers,
): string | null {
if (receiver.type === 'simple_identifier') {
return localTypes.get(receiver.text) ?? null;
}
if (receiver.type === 'indexing_expression') {
// `posts[user]` — the underlying receiver's container type tells
// us the element/value type.
const base = receiver.namedChild(0);
if (base === null) return null;
const baseType = base.type === 'simple_identifier' ? localTypes.get(base.text) : null;
if (baseType === undefined || baseType === null) return null;
// Indexing a Map returns the value type; indexing a List returns
// the element type. `kotlinContainerElementType` already encodes
// both via the 'values' tag.
return kotlinContainerElementType(baseType, 'values');
}
if (receiver.type === 'navigation_expression') {
// `users.map { ... }` chain — receiver is itself a navigation/
// call. Tier-2 chain inference: try the navigation field/method.
const navField = inferKotlinNavigationFieldType(receiver, localTypes, classMembers);
if (navField !== null) return navField;
const callee = receiver.namedChildren
.find((c) => c.type === 'navigation_suffix')
?.namedChildren.find((c) => c.type === 'simple_identifier');
if (callee !== undefined) {
return inferKotlinNavigationCallReturnType(receiver, localTypes, classMembers);
}
return null;
}
if (receiver.type === 'call_expression') {
const callee = receiver.namedChildren.find((c) => c.type === 'simple_identifier');
if (callee === undefined) return null;
return returnTypes.get(callee.text) ?? null;
}
return null;
}
/** Walk up from `lambdaNode` (lambda_literal) to the enclosing call:
* `lambda_literal → annotated_lambda → call_suffix → call_expression`
* for trailing lambdas, or `lambda_literal → value_argument →
* value_arguments → call_suffix → call_expression` for paren form.
* Returns null if the lambda is not inside a call. */
function findEnclosingCallExpression(lambdaNode: SyntaxNode): SyntaxNode | null {
let current: SyntaxNode | null = lambdaNode.parent;
while (current !== null) {
if (current.type === 'call_expression') return current;
// Don't cross out of the immediate call boundary — if we hit a
// function_body or function_declaration ancestor, the lambda is
// not call-bound.
if (current.type === 'function_body' || current.type === 'function_declaration') {
return null;
}
current = current.parent;
}
return null;
}
function synthesizeKotlinLocalAssignmentBindings(
rootNode: SyntaxNode,
returnTypes: ReadonlyMap<string, string>,
@ -393,15 +740,21 @@ function collectKotlinClassMembers(rootNode: SyntaxNode): KotlinClassMembers {
const ftype = v?.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype);
} else if (member.type === 'function_declaration') {
const mname = member.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
const paramsIdx = member.namedChildren.findIndex(
(c) => c.type === 'function_value_parameters',
);
const rtype =
paramsIdx < 0
? undefined
: member.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text;
if (mname !== undefined && rtype !== undefined) mmap.set(mname, rtype);
collectKotlinFunctionReturn(member, mmap);
} else if (member.type === 'companion_object') {
// Companion-object methods (`companion object { fun create() … }`)
// are addressable via the outer class name (`Logger.create()`).
// Register them on the outer class so chain-binding for
// `val x = Logger.create(...)` picks up the return type (#1756).
// The receiver-side filtering needed to prevent
// `instance.companionMethod()` crossover is handled elsewhere.
const compBody = member.namedChildren.find((c) => c.type === 'class_body');
if (compBody !== undefined) {
for (const compMember of compBody.namedChildren) {
if (compMember.type !== 'function_declaration') continue;
collectKotlinFunctionReturn(compMember, mmap);
}
}
}
}
}
@ -412,6 +765,16 @@ function collectKotlinClassMembers(rootNode: SyntaxNode): KotlinClassMembers {
return { fields, methods };
}
function collectKotlinFunctionReturn(fnNode: SyntaxNode, target: Map<string, string>): void {
const mname = fnNode.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
const paramsIdx = fnNode.namedChildren.findIndex((c) => c.type === 'function_value_parameters');
const rtype =
paramsIdx < 0
? undefined
: fnNode.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text;
if (mname !== undefined && rtype !== undefined) target.set(mname, rtype);
}
function collectKotlinLocalTypeTexts(
fnNode: SyntaxNode,
returnTypes: ReadonlyMap<string, string>,
@ -520,9 +883,19 @@ function inferKotlinNavigationFieldType(
return classMembers.fields.get(normalizeKotlinType(recvType))?.get(member) ?? null;
}
/** Resolve `receiver.method()` → method's declared return type, where
* `receiver` is a simple identifier whose type is in `localTypes` and
* `method` is declared on that type in `classMembers.methods`. */
/** Resolve `receiver.method()` → method's declared return type. The
* `receiver` is a simple identifier; we try two interpretations in
* order:
*
* 1. `receiver` is a local variable whose type is in `localTypes` —
* look up `method` on that type's class members.
* 2. `receiver` is itself a class name (e.g. `Logger.create("app")`,
* a companion-object call via the class) — look up `method` on
* `classMembers.methods.get(receiver.text)` directly.
*
* Tier 2 supports `val logger = Logger.create(...)` patterns where the
* RHS is a companion-object factory: the loop variable's type is the
* factory's return type (#1756). */
function inferKotlinNavigationCallReturnType(
navCallee: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
@ -536,8 +909,10 @@ function inferKotlinNavigationCallReturnType(
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
if (methodName === undefined) return null;
const recvType = localTypes.get(receiver.text);
if (recvType === undefined) return null;
return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
if (recvType !== undefined) {
return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
}
return classMembers.methods.get(receiver.text)?.get(methodName) ?? null;
}
function inferKotlinIterableElementType(

View file

@ -0,0 +1,61 @@
import type { ScopeId } from 'gitnexus-shared';
/**
* Per-file set of `ScopeId`s that came from a `companion_object` AST node
* (issue #1756 / U4 remediation).
*
* Populated during `emitKotlinScopeCaptures` from the `@scope.companion`
* marker capture (see `query.ts`) and consumed by
* `populateCompanionMembersOnEnclosingClass` in `owners.ts` to decide
* whether to promote a class scope's methods onto its enclosing class.
*
* The previous `ownedDefs.some(isClassLike)` heuristic in `owners.ts`
* silently misclassified two shapes as "regular classes":
* - named companions (`companion object Helper { ... }`) — the `Helper`
* `type_identifier` registered as a class-like def on the companion
* scope, hiding the companion-ness from the heuristic; AND
* - companions containing nested classes (`companion object { class
* Token; fun create() }`) — the nested class def lived on the
* companion scope, again hiding it from the heuristic.
*
* The marker capture lifts that distinction up to the parser layer
* where it is unambiguous (any `companion_object` AST node is a
* companion, regardless of what it contains).
*
* Parallels the C-language pattern in `c/static-linkage.ts`: per-file
* `Map<filePath, Set<key>>` side-channel for language-specific def /
* scope metadata that does not belong on the shared `Scope` /
* `SymbolDefinition` types.
*
* NOTE: module-level state. `clearCompanionScopes()` is called once per
* workspace pass from `kotlinScopeResolver.loadResolutionConfig`, which
* the scope-resolution orchestrator awaits before extracting any
* `ParsedFile`s for this language (see `pipeline/phase.ts` and the
* mirror pattern in `c/scope-resolver.ts` — `clearStaticNames()`). This
* keeps server-mode and multi-repo-in-one-process callers safe from
* unbounded memory growth and from stale companion-scope ids from a
* previous workspace's files. Tests that exercise the captures /
* owners modules directly may still need to call `clearCompanionScopes`
* themselves (see `test/unit/kotlin-static-marker.test.ts`).
*/
const companionScopesByFile = new Map<string, Set<ScopeId>>();
/** Record a scope id as a companion-object scope for the given file. */
export function markCompanionScope(filePath: string, scopeId: ScopeId): void {
let scopes = companionScopesByFile.get(filePath);
if (scopes === undefined) {
scopes = new Set<ScopeId>();
companionScopesByFile.set(filePath, scopes);
}
scopes.add(scopeId);
}
/** Check whether `scopeId` belongs to a companion-object scope in `filePath`. */
export function isCompanionScope(filePath: string, scopeId: ScopeId): boolean {
return companionScopesByFile.get(filePath)?.has(scopeId) ?? false;
}
/** Clear all tracked companion scopes (for testing). */
export function clearCompanionScopes(): void {
companionScopesByFile.clear();
}

View file

@ -1,5 +1,20 @@
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import { isCompanionScope } from './companion-scopes.js';
/** Module-level identity-based marker for companion-promoted Kotlin
* method defs (the "this member can only be dispatched through the
* class name" set). Parallels the C language `static-linkage.ts`
* side-channel pattern but uses a WeakSet because the mark is
* per-def (no per-name keying needed). Eliminates the cast-and-
* mutate pattern the previous marker implementation required,
* removes any serialization-survival risk surface, and keeps the
* Kotlin-specific metadata off the shared `SymbolDefinition` type. */
const KOTLIN_STATIC_DEFS = new WeakSet<SymbolDefinition>();
export function isKotlinStaticOnly(def: SymbolDefinition): boolean {
return KOTLIN_STATIC_DEFS.has(def);
}
export function populateKotlinOwners(parsed: ParsedFile): void {
populateClassOwnedMembers(parsed);
@ -37,13 +52,47 @@ function populateCompanionMembersOnEnclosingClass(parsed: ParsedFile): void {
if (scope.kind !== 'Function' || scope.parent === null) continue;
const parent = scopesById.get(scope.parent);
if (parent === undefined || parent.kind !== 'Class') continue;
if (parent.ownedDefs.some((def) => isClassLike(def.type))) continue;
// Identify companion-object scopes via the `@scope.companion` marker
// capture (see captures.ts / companion-scopes.ts) rather than via
// the old `parent.ownedDefs.some(isClassLike)` heuristic. The
// heuristic silently bypassed two real shapes (#1756 / U4):
// - named companions (`companion object Helper { ... }`) — `Helper`
// registered as a class-like def on the companion scope; AND
// - companions containing nested classes (`companion object {
// class Token; fun create() }`) — the nested class lived on
// the companion scope.
// Both bypasses left companion methods unpromoted and unmarked,
// breaking class-name dispatch (`Outer.create()`) and crossover
// suppression (`outer.create()`) for those shapes. The marker
// capture lifts the distinction to the parser layer where any
// `companion_object` AST node is a companion, full stop.
if (!isCompanionScope(parsed.filePath, parent.id)) continue;
const enclosing = findEnclosingClassWithDef(parent.parent, scopesById);
if (enclosing === undefined) continue;
for (const def of scope.ownedDefs) {
if (def.ownerId !== undefined) continue;
// Class-like defs nested inside the companion's methods (rare —
// would be a local class declared inside a fun-body) are not
// companion members and must not be promoted. The companion's
// direct nested classes live in their OWN scope's ownedDefs
// (NOT the function-scope ownedDefs we iterate here), so this
// guard is defense-in-depth.
if (isClassLike(def.type)) continue;
// OVERRIDE rather than skip-when-set: for named companions,
// `populateClassOwnedMembers` already set `ownerId = Helper`
// (the named-companion class-like def). That is the WRONG
// owner — companion methods are dispatched through the
// enclosing outer class, not through the companion's own
// type name. Overwriting restores the intended ownership.
(def as { ownerId?: string }).ownerId = enclosing.nodeId;
// Mark as static-only so `ScopeResolver.isStaticOnly` (see
// `isKotlinStaticOnly`) can filter these out of instance-receiver
// dispatch (#1756). Promoting the companion method onto the
// outer class lets `Foo.companionMethod()` resolve via Case 2;
// without this marker, `fooInstance.companionMethod()` would
// ALSO resolve to it via Case 4, which is incorrect (and a
// compile error in real Kotlin).
KOTLIN_STATIC_DEFS.add(def);
qualify(def, enclosing);
}
}
@ -67,7 +116,16 @@ function findEnclosingClassWithDef(
}
function qualify(def: SymbolDefinition, owner: SymbolDefinition): void {
if (def.qualifiedName === undefined || def.qualifiedName.includes('.')) return;
if (def.qualifiedName === undefined) return;
if (owner.qualifiedName === undefined || owner.qualifiedName.length === 0) return;
(def as { qualifiedName: string }).qualifiedName = `${owner.qualifiedName}.${def.qualifiedName}`;
// For named companions, `populateClassOwnedMembers` qualified the
// def as `Helper.create`. Strip the companion-class prefix and
// re-qualify with the outer class so the graph-bridge lookup keys
// resolve to `Outer.create` rather than the spurious `Helper.create`.
// For unqualified defs (the anonymous-companion path), the simple
// name is unchanged — `populateClassOwnedMembers` found no class-like
// def in the anonymous companion scope, so the prior pass left the
// simple name in place.
const simple = def.qualifiedName.split('.').pop() ?? def.qualifiedName;
(def as { qualifiedName: string }).qualifiedName = `${owner.qualifiedName}.${simple}`;
}

View file

@ -9,6 +9,20 @@ const KOTLIN_SCOPE_QUERY = `
(companion_object) @scope.class
(function_declaration) @scope.function
;; Companion-object marker (issue #1756 / U4). Side-channel capture that
;; lets populateCompanionMembersOnEnclosingClass distinguish a companion
;; Class scope from a regular Class scope without inspecting ownedDefs.
;; Anonymous companions AND companions containing nested classes both
;; look like regular classes through the old ownedDefs-based heuristic;
;; the marker lifts the distinction up to the parser layer where it is
;; unambiguous (any companion_object AST node is a companion, full
;; stop). Consumed by markCompanionScope / isCompanionScope in
;; captures.ts / companion-scopes.ts. The scope-extractor ignores the
;; "companion" suffix (no ScopeKind mapping), so this rule contributes
;; no Scope record of its own — the existing (companion_object)
;; @scope.class rule still creates the Class scope.
(companion_object) @scope.companion
;; Smart-cast narrowing scopes (RFC #909 Ring 3, issue #1758).
;; Each is-test arm body and each if-then body becomes its own Block
;; scope so synthesized narrowed type-bindings (see captures.ts
@ -22,6 +36,25 @@ const KOTLIN_SCOPE_QUERY = `
(check_expression)
(control_structure_body) @scope.block)
;; Lambda body scope (issue #1757). Each lambda_literal becomes its
;; own Block scope so synthesized lambda-parameter and implicit-'it'
;; type-bindings (see captures.ts synthesizeKotlinLambdaBindings) stay
;; inside the lambda — they must not leak to the enclosing function
;; scope and must shadow same-named outer bindings (val it = "outer";
;; users.forEach { it.save() } — inner 'it' is the lambda's, not the
;; outer String).
;;
;; Lambdas appear inside call_suffix for trailing-lambda syntax
;; (list.forEach { it.foo() }) and inside value_arguments for
;; explicit-paren syntax (list.forEach({ x -> x.foo() })); both AST
;; positions produce the same lambda_literal subtree, so a single
;; capture suffices.
;;
;; Uses @scope.block (not @scope.function) to match the smart-cast
;; precedent (#1758) — keeps narrowed/lambda bindings scope-local
;; without the auto-hoist semantics of Function scopes.
(lambda_literal) @scope.block
;; Declarations — types
(class_declaration
"interface"

View file

@ -13,39 +13,65 @@ import {
resolveKotlinImportTarget,
type KotlinResolveContext,
} from './index.js';
import { clearCompanionScopes } from './companion-scopes.js';
import { isKotlinStaticOnly } from './owners.js';
/**
* Kotlin scope resolver for RFC #909 Ring 3.
*
* Kotlin is intentionally registered but not yet listed in
* `MIGRATED_LANGUAGES`, matching the Java migration pattern from #1482:
* the resolver can run in shadow/forced mode, while production default
* stays on the legacy DAG until the RFC flip criteria in #1746 are met.
* **Migration status:** Kotlin is in `MIGRATED_LANGUAGES`. Default
* production resolution flows through the scope-resolution pipeline;
* the legacy DAG is consulted only when the per-language env var
* (`REGISTRY_PRIMARY_KOTLIN=0`) explicitly forces the legacy parity
* run for CI comparison.
*
* **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 175/175 fixtures
* after the migration sub-issues #1758–#1763 closed. Covers core
* import, receiver, companion, default-param, vararg, constructor,
* local assignment-chain, collection-iteration, smart casts
* (`when (x) { is T -> … }` and `if (x is T)` — #1758), cross-file
* iterable return propagation (#1759), single-level method-chain
* fixpoint receiver types (#1760), parameter-type-narrowed overload
* target-id selection (#1761), virtual dispatch via constructor RHS
* (`val x: Animal = Dog()` — #1762), and interface default-method
* dispatch via implements-split MRO (#1763).
* **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 208/208
* fixtures pass after the migration sub-issues #1758–#1763, the
* companion/instance dispatch fix #1756, and the lambda scopes
* fix #1757. Covers core import, receiver, companion, default-param,
* vararg, constructor, local assignment-chain, collection-iteration,
* smart casts (`when (x) { is T -> … }` and `if (x is T)` — #1758),
* cross-file iterable return propagation (#1759), single-level
* method-chain fixpoint receiver types (#1760), parameter-type-narrowed
* overload target-id selection (#1761), virtual dispatch via constructor
* RHS (`val x: Animal = Dog()` — #1762), interface default-method
* dispatch via implements-split MRO (#1763), companion-object vs
* instance member dispatch (#1756) via the `isStaticOnly` hook
* (including named companions and MRO-shadow / chain-typebinding /
* value-receiver crossover cases), and lambda-body Block scopes
* with scoped type-bindings for explicit parameters and implicit
* `it` (#1757) via `synthesizeKotlinLambdaBindings` plus the
* `(lambda_literal) @scope.block` query rule.
*
* **Remaining pre-flip blockers (#1746):** #1755 (forced-mode preview
* CI workflow — obviated once Kotlin lands in `MIGRATED_LANGUAGES`
* because the existing scope-parity matrix auto-discovers it), #1756
* (companion vs instance member dispatch), and #1757 (lambda scopes
* and lambda-parameter bindings). The flip PR adds
* `SupportedLanguages.Kotlin` to `MIGRATED_LANGUAGES` after the named
* blockers close.
* **Legacy parity skip list:** `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.kotlin`
* in `test/integration/resolvers/helpers.ts` records scope-resolver-only
* correctness wins that the legacy DAG cannot replicate. As of #1756 /
* #1757 there are 8 entries covering: the bare companion-vs-instance
* crossover, three MRO-shadow / standalone-chain cases, the chained-
* forEach lambda-scope case, the named-companion crossover, and the
* Case-0 / Case-3b / Case-5 companion crossovers under
* `kotlin-companion-other-cases`. Each entry is documented inline with
* its issue ref and rationale.
*/
export const kotlinScopeResolver: ScopeResolver = {
language: SupportedLanguages.Kotlin,
languageProvider: kotlinProvider,
importEdgeReason: 'kotlin-scope: import',
loadResolutionConfig: () => {
// Drop the module-level `companionScopesByFile` table from any
// prior workspace pass before this run populates it via
// `emitKotlinScopeCaptures`. Mirrors the C resolver's
// `clearStaticNames()` call in `loadResolutionConfig` — the
// orchestrator awaits this hook exactly once per workspace pass
// (see `pipeline/phase.ts`), making it the right lifecycle seam
// for clearing per-language side-channel state. Returns
// `undefined` because Kotlin has no external resolution config
// to load.
clearCompanionScopes();
return undefined;
},
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
const ws: KotlinResolveContext = { fromFile, allFilePaths };
return resolveKotlinImportTarget(
@ -64,6 +90,8 @@ export const kotlinScopeResolver: ScopeResolver = {
isSuperReceiver: (text) => text.trim() === 'super',
isStaticOnly: isKotlinStaticOnly,
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: true,
collapseMemberCallsByCallerTarget: false,

View file

@ -19,6 +19,17 @@ export function kotlinBindingScopeFor(
// and erase the arm-local narrowing.
if (decl['@type-binding.narrowed'] !== undefined) return innermost.id;
// Lambda-scoped bindings (issue #1757) — explicit lambda parameters
// and implicit `it` must stay inside the lambda body Block scope.
// Without this gating, the binding hoists to the enclosing function
// scope and:
// - `it` leaks past the closing brace of the lambda, shadowing the
// parameter-scope `it` (or outer `val it = "outer"`) for everything
// that follows in the function body.
// - Nested lambda parameters override each other across siblings.
// Same mechanism as the smart-cast precedent above.
if (decl['@type-binding.lambda-scoped'] !== undefined) return innermost.id;
if (decl['@type-binding.return'] === undefined) return null;
let current: Scope | undefined = innermost;

View file

@ -76,6 +76,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.CPlusPlus,
SupportedLanguages.PHP,
SupportedLanguages.JavaScript,
SupportedLanguages.Kotlin,
]);
/**

View file

@ -587,6 +587,44 @@ export interface ScopeResolver {
*/
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
/**
* Optional predicate to identify members for which dispatch through
* an instance receiver is **invalid at the language level** — i.e.
* calling `instance.member()` would be a compile error or a
* type-system violation, even if a member of that name exists on
* the receiver's class. When provided, the receiver-bound calls
* pass filters out such members at every instance-receiver dispatch
* case (Case 0 compound receiver, Case 3b chain-typebinding, Case 4
* simple typeBinding, Case 5 value-receiver bridge) so the resolver
* does not emit a misleading `CALLS` edge for a call site the
* language itself would reject.
*
* **Reserved for the "instance receiver is invalid" semantic only.**
* Hooks for languages where static / class-level members are still
* legally callable through an instance (Python `@staticmethod`,
* JavaScript `static` methods accessed via the prototype chain in
* some lookup paths) should return `false` for those members — the
* filter would silently suppress legitimate edges otherwise. The
* canonical fit today is Kotlin companion-object methods, where
* `instance.companionMethod()` is a compile error.
*
* Case 2 (class-name receiver) is intentionally unaffected: a call
* through the class name (`Foo.staticMethod()`) is a legitimate
* dispatch.
*
* Case 0.5 (implicit `this` receiver) currently fires only for
* languages with `resolveThisViaEnclosingClass === true` (C++ at
* time of writing), none of which expose static-only semantics. A
* future language that enables BOTH `resolveThisViaEnclosingClass`
* AND `isStaticOnly` must wire the filter into Case 0.5's chain
* walk too — see the inline note in `receiver-bound-calls.ts`.
*
* Languages without static-only semantics leave this undefined and
* the legacy unfiltered behavior applies (every owned member of the
* receiver class is a dispatch candidate).
*/
readonly isStaticOnly?: (def: SymbolDefinition) => boolean;
/**
* Optional predicate to gate free-call fallback emission by caller-side
* visibility. When provided, `pickUniqueGlobalCallable` rejects candidates

View file

@ -81,6 +81,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'resolveThisViaEnclosingClass'
| 'conversionRankFn'
| 'constraintCompatibility'
| 'isStaticOnly'
>;
function normalizeTemplateArgToken(value: string): string {
@ -303,9 +304,28 @@ export function emitReceiverBoundCalls(
if (currentClass !== undefined) {
const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)];
let memberDef: SymbolDefinition | undefined;
// Static-only filter (#1756 / U3): same shape as Case 4's
// chain walk (skip-and-walk-on) but without overload
// narrowing — Case 0 uses `findOwnedMember` directly. When
// an owner's resolved candidate is static-only (Kotlin
// companion-promoted), continue to the next ancestor in
// the MRO chain so a legitimate instance member can bind.
// If the entire chain is static-only, no edge is emitted —
// unlike Case 4, Case 0 does NOT mark the site handled in
// that situation because compound receivers (`a.b.c()`)
// are not pre-emitted by `emitReferencesViaLookup` (the
// reference index has no compound-receiver entry for
// shapes like `Logger.create("a")`), so there's no wrong
// target to suppress.
for (const ownerId of chain) {
memberDef = findOwnedMember(ownerId, memberName, model);
if (memberDef !== undefined) break;
const candidate = findOwnedMember(ownerId, memberName, model);
if (candidate === undefined) continue;
if (provider.isStaticOnly?.(candidate) === true) {
// Skip static-only candidate; walk to next ancestor.
continue;
}
memberDef = candidate;
break;
}
if (memberDef !== undefined) {
const ok = tryEmitEdge(
@ -334,6 +354,17 @@ export function emitReceiverBoundCalls(
// C++ `this->member()` (and same-shape receivers in other OO
// languages) should resolve against the enclosing class + MRO
// even when there is no explicit `this` typeBinding in scope.
//
// **Static-only filter dependency (#1756 / U3):** this case does
// NOT currently consult `provider.isStaticOnly`. Today it fires
// only for C++ (the sole `resolveThisViaEnclosingClass === true`
// language), which has no static-only semantics. Kotlin — the
// current `isStaticOnly` consumer — leaves `resolveThisVia
// EnclosingClass` unset, so Case 0.5 is dead code for Kotlin
// crossover suppression and U3 leaves it untouched. If any
// future language enables BOTH `resolveThisViaEnclosingClass`
// AND `isStaticOnly`, the chain-walk below MUST adopt the
// skip-and-walk-on filter pattern used by Cases 0, 3b, and 4.
if (provider.resolveThisViaEnclosingClass === true && receiverName === 'this') {
const enclosingClass = findEnclosingClassDef(site.inScope, scopes);
if (enclosingClass !== undefined) {
@ -600,9 +631,22 @@ export function emitReceiverBoundCalls(
if (ownerDef !== undefined) {
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
// Static-only filter (#1756 / U3): mirrors Case 0's chain
// walk — `findOwnedMember` without overload narrowing. When
// a static-only candidate is found at an ancestor, walk on
// so a legitimate instance member can bind. If the entire
// chain is static-only, no edge is emitted (Case 3b is fed
// by chain-typebinding receivers, not pre-emitted by
// `emitReferencesViaLookup` for compound shapes, so no
// handled-site marker is needed for chain-only-static).
for (const ownerId of chain) {
memberDef = findOwnedMember(ownerId, memberName, model);
if (memberDef !== undefined) break;
const candidate = findOwnedMember(ownerId, memberName, model);
if (candidate === undefined) continue;
if (provider.isStaticOnly?.(candidate) === true) {
continue;
}
memberDef = candidate;
break;
}
if (memberDef !== undefined) {
const ok = tryEmitEdge(
@ -658,16 +702,53 @@ export function emitReceiverBoundCalls(
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
let ambiguous = false;
// Track whether the chain walk filtered out any static-only
// candidates. When it did and the chain ended with no
// legitimate instance member, we mark the site as handled so
// `emitReferencesViaLookup` doesn't re-emit a wrong target
// from the pre-resolved reference index (which has no
// static-only awareness).
let allFilteredStaticOnly = false;
// Static-only filter (#1756 / U2): the filter must run INSIDE
// the chain walk and BEFORE arity narrowing.
//
// INSIDE: when a derived owner's only candidates are static-
// only (Kotlin companion-promoted), `pickFirstNonStaticOnly`
// returns `undefined` and the loop `continue`s to the next
// ancestor in the MRO chain — giving a legitimate ancestor
// instance method a chance to bind. The earlier after-chain
// filter aborted the entire site instead, producing a false
// negative whenever the most-derived owner shadowed an
// ancestor's instance method with a static-only companion
// member.
//
// BEFORE narrowing: filtering survivors of `lookupAllByOwner`
// (rather than survivors of `narrowOverloadCandidates`) means
// a same-arity static + instance pair on one owner doesn't
// collapse to `OVERLOAD_AMBIGUOUS`. Kotlin compile-resolves
// such a pair unambiguously to the instance method because
// companion members are not legal instance-dispatch
// candidates.
for (const ownerId of chain) {
const picked = pickOverload(ownerId, memberName, site, model, provider);
const picked = pickFirstNonStaticOnly(ownerId, memberName, site, model, provider);
if (picked === OVERLOAD_AMBIGUOUS) {
ambiguous = true;
break;
}
if (picked === STATIC_ONLY_FILTERED) {
// At least one static-only candidate was filtered out at
// this owner; remember so we can mark handled if the
// chain ends with no legitimate match.
allFilteredStaticOnly = true;
continue;
}
if (picked !== undefined) {
memberDef = picked;
break;
}
// `picked === undefined` means this owner had no member of
// this name at all. Walk on to the next ancestor in the
// MRO chain.
}
if (ambiguous) {
// Suppress and mark handled so `emitReferencesViaLookup`
@ -676,6 +757,15 @@ export function emitReceiverBoundCalls(
handledSites.add(siteKey);
continue;
}
if (memberDef === undefined && allFilteredStaticOnly) {
// The chain ended with no candidates because every viable
// owner had only static-only members. Mark handled so
// `emitReferencesViaLookup` doesn't re-emit a wrong target
// from the pre-resolved reference index. Parallels the old
// after-chain `isStaticOnly` suppression block.
handledSites.add(siteKey);
continue;
}
if (memberDef !== undefined) {
// For read/write ACCESSES, mirror the legacy DAG's reason
// convention so consumers asserting `reason === 'write'`
@ -744,6 +834,19 @@ export function emitReceiverBoundCalls(
continue;
}
if (picked !== undefined) {
// Static-only filter (#1756 / U3): unlike Case 4 there's no
// MRO chain to walk here — Case 5 dispatches on a single
// owner via `pickOverload`. When the picked candidate is
// static-only (Kotlin companion-promoted), suppress the
// edge entirely and mark the site handled so
// `emitReferencesViaLookup` doesn't re-emit a wrong target
// from the pre-resolved reference index. Matches the after-
// chain handled-marker semantic used by Case 4's
// all-filtered fall-through.
if (provider.isStaticOnly?.(picked) === true) {
handledSites.add(siteKey);
continue;
}
const reason =
site.kind === 'write' || site.kind === 'read'
? site.kind
@ -822,3 +925,95 @@ function pickOverload(
* collapses distinct types in arity-metadata).
*/
export const OVERLOAD_AMBIGUOUS = Symbol('overload-ambiguous');
/**
* Sentinel returned by `pickFirstNonStaticOnly` when the only candidates
* at the queried owner were filtered out by `provider.isStaticOnly`. Lets
* the Case 4 chain walk distinguish "owner had no member of this name"
* (return `undefined`, continue silently) from "owner had only static-
* only members" (return this sentinel, continue and remember so the
* post-chain handled-marker logic can suppress wrong-target re-emission
* from `emitReferencesViaLookup`). See #1756 / remediation plan U2.
*/
const STATIC_ONLY_FILTERED = Symbol('static-only-filtered');
/**
* Receiver-bound member lookup that filters static-only candidates BEFORE
* arity narrowing. Wraps the raw `lookupAllByOwner` → `narrowOverloadCandidates`
* pipeline so:
*
* 1. Candidates flagged by `provider.isStaticOnly` (Kotlin companion-
* promoted methods today) never enter the narrowing stage. A same-
* name same-arity static + instance pair on one owner therefore does
* NOT collapse to `OVERLOAD_AMBIGUOUS` — the instance member wins
* unambiguously, matching Kotlin's compile-time resolution.
* 2. The chain walk in `emitReceiverBoundCalls` Case 4 can fall through
* to ancestors when only static-only candidates exist at the
* most-derived owner (returns `STATIC_ONLY_FILTERED`), rather than
* aborting the site as the previous after-chain filter did.
*
* Returns:
* - `undefined` — no member with this name on this owner; chain walk
* continues silently.
* - `STATIC_ONLY_FILTERED` — at least one candidate existed but every
* one was static-only; chain walk continues and remembers so the
* post-chain handled-marker can fire if no ancestor binds.
* - `OVERLOAD_AMBIGUOUS` — narrowing on the surviving non-static
* candidates left >1 ambiguous match; chain walk aborts and the
* site is marked handled (existing sentinel handling preserved).
* - `SymbolDefinition` — single survivor (the chosen target).
*
* See remediation plan `docs/plans/2026-05-22-002-fix-lang-kotlin-1782-
* remediation-plan.md` § U2 for the full rationale.
*/
function pickFirstNonStaticOnly(
ownerId: string,
memberName: string,
site: ParsedFile['referenceSites'][number],
model: SemanticModel,
provider: ReceiverBoundProviderSubset,
): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | typeof STATIC_ONLY_FILTERED | undefined {
const rawOverloads = model.methods.lookupAllByOwner(ownerId, memberName);
if (rawOverloads.length === 0) {
// Non-callable member (field / property / variable) — ACCESSES
// write/read sites target these too. Static-only filtering doesn't
// apply to fields, so delegate straight to `lookupFieldByOwner`.
return model.fields.lookupFieldByOwner(ownerId, memberName);
}
const isStaticOnly = provider.isStaticOnly;
let overloads: readonly SymbolDefinition[] = rawOverloads;
let filteredAny = false;
if (isStaticOnly !== undefined) {
const survivors: SymbolDefinition[] = [];
for (const candidate of rawOverloads) {
if (isStaticOnly(candidate) === true) {
filteredAny = true;
continue;
}
survivors.push(candidate);
}
overloads = survivors;
}
if (overloads.length === 0) {
// Every candidate was static-only; the caller (Case 4 chain walk)
// should walk on to the next owner AND remember that filtering
// happened so it can mark the site handled if the whole chain
// ends with no legitimate match.
return filteredAny ? STATIC_ONLY_FILTERED : undefined;
}
if (overloads.length === 1) return overloads[0];
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: provider.conversionRankFn,
constraintCompatibility: provider.constraintCompatibility,
});
// Same ambiguity handling as `pickOverload`: when normalization
// collapses the surviving overloads into a single bucket (e.g., C++
// `f(int)`/`f(long)` normalized to `['int']`), suppress rather than
// arbitrarily picking. When narrowing leaves >1 distinct candidate
// with no tie-breaker, suppress for the same reason.
if (isOverloadAmbiguousAfterNormalization(candidates, site.arity)) return OVERLOAD_AMBIGUOUS;
if (candidates.length > 1) return OVERLOAD_AMBIGUOUS;
return candidates[0] ?? overloads[0];
}

View file

@ -0,0 +1,13 @@
package app
import logging.Logger
fun useCrossFileFactory() {
val l = Logger.create("app")
l.log("hello")
}
fun useCrossFileCrossover() {
val l = Logger("explicit")
l.create("nope")
}

View file

@ -0,0 +1,8 @@
package logging
class Logger(val name: String) {
fun log(msg: String) {}
companion object {
fun create(name: String): Logger = Logger(name)
}
}

View file

@ -0,0 +1,52 @@
// Fixture for U2 (#1756 remediation plan): MRO shadowing and same-arity
// static+instance collision in receiver-bound dispatch.
//
// Three scenarios:
// 1. `Child` has only a companion `foo` AND extends `Base` whose
// instance `foo` is the legitimate target. The static-only filter
// must run INSIDE the MRO chain walk so the chain falls through to
// `Base.foo` instead of aborting on `Child.Companion.foo`.
// 2. `ChildWithInstance` has BOTH an instance `foo` AND a same-arity
// companion `foo`. The filter must run BEFORE arity narrowing so
// the pair doesn't collapse to OVERLOAD_AMBIGUOUS — Kotlin compile-
// resolves this unambiguously to the instance method because
// companion members are not legal instance-dispatch candidates.
// 3. `Standalone` only has a companion `foo` (no instance ancestor).
// The chain walk filters every owner; no edge should be emitted.
open class Base {
open fun foo() {}
}
class Child : Base() {
companion object {
fun foo(): Child = Child()
}
}
class ChildWithInstance : Base() {
fun foo(): Int = 0
companion object {
fun foo(): ChildWithInstance = ChildWithInstance()
}
}
class Standalone {
companion object {
fun foo(): Standalone = Standalone()
}
}
// Should resolve to Base.foo via MRO chain skip past static-only Child.foo.
fun useChild(c: Child) {
c.foo()
}
// Should resolve to ChildWithInstance.foo (instance, not companion, not Base).
fun useChildWithInstance(c: ChildWithInstance) {
c.foo()
}
// Should emit no edge — entire chain is static-only.
fun useStandalone(s: Standalone) {
s.foo()
}

View file

@ -0,0 +1,60 @@
// Fixture for U4 (#1756 remediation plan): named companions, companions
// containing nested classes, and inner-class-plus-companion mixes.
//
// The pre-U4 `populateCompanionMembersOnEnclosingClass` guard used
// `parent.ownedDefs.some(isClassLike) → continue`, which silently
// bypassed two real shapes:
// - named companions (`companion object Helper { ... }`) — the
// `Helper` `type_identifier` registered as a class-like def on
// the companion scope, hiding the companion-ness; and
// - companions containing nested classes (`companion object {
// class Token; fun create() }`) — the nested class def lived on
// the companion scope, again hiding the companion-ness.
// Both bypasses left companion methods unpromoted and unmarked,
// breaking class-name dispatch (`Outer.create()`) and crossover
// suppression (`outer.create()`) for those shapes.
class Outer {
fun greet() {}
companion object Helper {
fun create(): Outer = Outer()
}
}
class WithNested {
companion object {
class Token
fun forge(): WithNested = WithNested()
}
}
class InnerClassAndCompanion {
class Inner
companion object {
fun build(): InnerClassAndCompanion = InnerClassAndCompanion()
}
}
// Happy path: named companion dispatched through the class name.
fun useNamed() { Outer.create() }
// Crossover (adversarial): `o.create()` on an instance is a compile
// error in Kotlin — companion-object methods can only be called via
// the class name. Must emit no CALLS edge.
fun useNamedCrossover() {
val o = Outer()
o.create()
}
// Happy path: companion containing a nested class — the companion
// method should still be promoted onto the enclosing class.
fun useNested() { WithNested.forge() }
// Mix: outer class has BOTH a nested class AND a companion object.
// The companion method is promoted (new behavior); the nested class
// stays owned by its own scope (existing behavior).
fun useInnerMix() {
InnerClassAndCompanion.build()
val i = InnerClassAndCompanion()
i.build()
}

View file

@ -0,0 +1,91 @@
// Fixture for U3 (#1756 remediation plan): extend the `isStaticOnly`
// crossover filter to receiver-bound dispatch cases beyond Case 4.
//
// Three target cases:
// - Case 0 (compound receiver): the call site's `receiverName`
// contains `.` or `(`, so `resolveCompoundReceiverClass` is used
// to resolve the receiver's class. e.g. `Logger.create("a").create("b")`
// — the OUTER `.create("b")` has compound receiver `Logger.create("a")`
// and `resolveCompoundReceiverClass` resolves it to `Logger`.
// `findOwnedMember(Logger, "create")` then returns the static-only
// companion-promoted `create`. Pre-U3, Case 0 would emit a CALLS
// edge to the companion `create`. Post-U3, the static-only filter
// suppresses the edge.
// - Case 3b (chain-typebinding): the call site's receiver has a
// typeBinding whose `rawName` is a dotted chain expression (e.g.,
// a chain-bound value). For Kotlin, this fires when an expression
// produces a typeBinding that walks through chained receivers.
// - Case 5 (value-receiver bridge): the receiver is a Const/Variable
// without a class-like or typeBinding match; resolved via
// `findValueBindingInScope` + `pickOverload` on a single owner.
//
// The legitimate edges (e.g., `Logger.create("a")` via class-name receiver)
// must continue to emit, so we test both the crossover (zero edges) AND
// the happy paths (exact-count edges).
class Logger {
fun log(s: String) {}
companion object {
fun create(name: String): Logger = Logger()
}
}
class Service {
fun perform() {}
companion object {
fun build(): Service = Service()
}
}
class Repo {
fun getAll(): List<Service> = listOf()
}
// Case 0 (compound receiver) — outer `.create("b")` on a Logger instance
// returned by `Logger.create("a")`. The receiverName is the compound
// expression `Logger.create("a")` which resolves to `Logger`; then
// looking up `create` on Logger returns the companion-promoted static-
// only `create`. That edge must be suppressed.
//
// The INNER `Logger.create("a")` is a Case 2 class-name receiver — it
// resolves through `findClassBindingInScope` and `findOwnedMember`
// returns the companion-promoted `create` (legitimate). Companion
// dispatch through the class name is the canonical happy path; that
// edge must emit.
fun useCompoundCrossover() {
Logger.create("a").create("b")
}
// Case 3b (chain-typebinding) — `services` has a chain typeBinding for
// `Service` (inferred via the chain from `r.getAll()`), so calling
// `.build()` on `services.first()` looks up `build` on `Service`
// through Case 3b's `resolveCompoundReceiverClass(rawName, ...)` path
// where `rawName` contains a dot from the chain. The static-only
// companion `build` must be suppressed.
//
// The legitimate edge in this function is `r.getAll()`; that edge
// must emit (resolves through Case 4 simple-typeBinding `r: Repo`).
fun useChainTypeBindingCrossover() {
val r = Repo()
val services = r.getAll()
services.first().build()
}
// Case 5 (value-receiver bridge) — `l` is a Const/Variable whose
// typeBinding would normally route to Case 4. Listed here as a
// defensive wire-up site: Kotlin annotations make Case 4 the
// primary path even for `val l: Logger = ...`, but adding the
// filter at Case 5 preserves contract symmetry for any future
// shape where the value-binding is hit (e.g., object-literal-like
// receivers via cross-language conventions).
//
// We split the legitimate `Logger.create(...)` setup into a helper
// function so the crossover assertion can target the
// `useValueReceiverCrossover → create` edge count directly without
// having to subtract the legitimate setup edge.
fun makeLoggerForCrossover(): Logger = Logger.create("v")
fun useValueReceiverCrossover() {
val l = makeLoggerForCrossover()
l.create("nope")
}

View file

@ -0,0 +1,35 @@
class Logger(val name: String) {
fun log(message: String): String {
return "$name: $message"
}
companion object {
fun create(name: String): Logger {
return Logger(name)
}
}
}
// Companion call via the class name — must resolve to Logger.create().
fun makeLogger() {
val logger = Logger.create("app")
// Instance call via a value receiver — must resolve to the instance log(),
// not to the companion's create().
logger.log("hello")
}
// Direct instance call on a freshly-constructed Logger — must resolve to
// the instance log().
fun directLog() {
val logger = Logger("direct")
logger.log("hi")
}
// Adversarial call: `logger.create(...)` is invalid Kotlin (you can't call a
// companion-object method through an instance receiver — it's a compile
// error). A code-intelligence tool that emits an edge here would be telling
// readers the call resolves when it doesn't. Test asserts no CALLS edge.
fun crossover() {
val logger = Logger("x")
logger.create("nope")
}

View file

@ -0,0 +1,47 @@
// Kotlin lambda scopes fixture — issue #1757.
//
// Each function exercises a different lambda-binding shape; assertions
// in kotlin.test.ts verify the lambda parameter / implicit `it` binds
// only inside the lambda body and resolves to the correct stdlib idiom.
class User(val name: String) {
fun save() {}
fun isActive(): Boolean = true
}
class Post(val title: String) {
fun like() {}
}
fun println(message: String) {}
fun explicitParam(users: List<User>) {
users.forEach { user -> user.save() }
}
fun implicitIt(users: List<User>) {
users.forEach { it.save() }
}
fun chained(users: List<User>) {
users.map { it.name }.forEach { name -> println(name) }
}
fun nested(users: List<User>, posts: Map<User, List<Post>>) {
users.forEach { user ->
posts[user]?.forEach { it.like() }
}
}
fun letScope(user: User?) {
user?.let { it.save() }
}
fun applyScope(user: User) {
user.apply { save() }
}
fun outerItShadow(users: List<User>) {
val it = "outer"
users.forEach { it.save() }
}

View file

@ -124,6 +124,82 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
'binds the call to alpha/services/sync.py, not omega',
'lex tiebreak still picks alpha/services/sync.py with reversed file-write order',
]),
kotlin: new Set<string>([
// #1756 companion-vs-instance dispatch: the registry-primary path
// suppresses `instance.companionMethod()` via `ScopeResolver.
// isStaticOnly` (see `isKotlinStaticOnly` + the Case 4 filter in
// `receiver-bound-calls.ts`). The legacy DAG has no equivalent
// static-only gate — companion methods promoted onto the outer
// class are also returned by `lookupMethodByOwner` when the
// receiver is an instance, producing a false `CALLS` edge. Scope-
// resolver-only correctness win; backporting to legacy is out of
// scope per the migration policy (the bug stops mattering once
// Kotlin enters `MIGRATED_LANGUAGES` and legacy stops running).
'crossover() invoking logger.create() on an instance emits NO CALLS edge',
// #1756 / U2 (remediation plan 2026-05-22-002) MRO shadow tests:
// the registry-primary path filters static-only candidates INSIDE
// the Case-4 MRO chain walk (`pickFirstNonStaticOnly` in
// `receiver-bound-calls.ts`), so a derived class whose only
// member is a companion-promoted static method falls through to
// an ancestor's legitimate instance method; if no ancestor has
// an instance method, no CALLS edge is emitted. The legacy DAG
// returns the static-only companion method via
// `lookupMethodByOwner` on the most-derived owner and emits a
// false `CALLS` edge to it. Same scope-resolver-only correctness
// class as the bare `crossover()` test above; backporting is out
// of scope per the migration policy.
'useChild() falls through static-only Child.foo to Base.foo',
'useChild() does NOT emit an edge to the companion-promoted Child.foo',
'useStandalone() emits no CALLS edge (entire chain is static-only)',
// #1757 lambda scopes: the registry-primary path creates a Block
// scope per `lambda_literal` and synthesizes scoped type-bindings
// for the lambda parameter / implicit `it` (see
// `synthesizeKotlinLambdaBindings` in `kotlin/captures.ts` plus
// the `@type-binding.lambda-scoped` gate in
// `kotlinBindingScopeFor`). This lets the body's call-resolution
// chain see the chain-typebinding for the lambda's enclosing
// call (`users.map { it.name }.forEach { name -> println(name) }`)
// and emit the `chained -> println` edge correctly. The legacy DAG
// has no lambda-body scope and no per-lambda type-binding
// synthesis; calls inside lambdas resolve against the enclosing
// function scope only, so the `name` parameter chain inside a
// chained-receiver forEach lambda doesn't carry the right binding
// and the call-extractor never emits the CALLS edge. Scope-
// resolver-only correctness win; backporting requires re-modeling
// lambda bodies as their own scopes in `call-processor.ts`, which
// is out of scope per migration policy.
'chained: println(name) inside forEach resolves to file-scope println',
// #1756 / U4 (remediation plan 2026-05-22-002) named-companion
// crossover: the registry-primary path stamps the static-only
// marker on named-companion methods (via the new `@scope.companion`
// marker capture and the updated `populateCompanionMembersOn
// EnclosingClass` guard), so `instance.namedCompanionMethod()`
// is filtered out at the `isStaticOnly` hook. The legacy DAG has
// no static-only gate AND no named-companion-aware owner
// promotion — it both leaves the named-companion method owned
// by `Helper` AND emits a crossover edge when the call site uses
// an instance receiver. Same scope-resolver-only correctness
// class as the bare `crossover()` test; backporting is out of
// scope per the migration policy.
'useNamedCrossover: o.create() emits NO CALLS edge to create',
// #1756 / U3 (remediation plan 2026-05-22-002) other-receiver
// crossover: the registry-primary path applies the `isStaticOnly`
// filter across Cases 0 (compound receiver), 3b (chain-typebinding),
// and 5 (value-receiver bridge) of `receiver-bound-calls.ts`. For
// the U3 fixture `kotlin-companion-other-cases/App.kt`, the
// chain-typebinding crossover (`services.first().build()` on a
// chain whose receiver type resolves through the legacy DAG's
// unfiltered lookup) and the value-receiver crossover
// (`l.create("nope")` where the legacy DAG binds `l` directly
// via its receiver-resolution path) both emit false `CALLS`
// edges to the companion-promoted static-only members. The
// legacy DAG has no `isStaticOnly`-equivalent hook, so these
// edges leak. Same scope-resolver-only correctness class as the
// bare `crossover()` test and the U2 MRO-shadow tests above;
// backporting is out of scope per the migration policy.
'useChainTypeBindingCrossover: services.first().build() emits NO CALLS edge to build',
'useValueReceiverCrossover: l.create("nope") emits NO CALLS edge to create',
]),
cpp: new Set<string>([
// The legacy DAG path has no scope-aware filtering on the global
// free-call fallback, so `#include`d headers still leak class

View file

@ -2063,3 +2063,582 @@ describe('Kotlin User implements Validator — interface default method (SM-11)'
expect(validateCall!.source).toBe('run');
});
});
// ---------------------------------------------------------------------------
// #1756: companion-object members must dispatch through the class name,
// never through an instance receiver.
//
// `Logger.create(...)` — companion call via the class name — resolves to the
// companion's `create`. `logger.log(...)` and `logger.create(...)` — calls
// through an INSTANCE — must resolve to the instance method and NOT cross
// over to the companion-only `create`.
// ---------------------------------------------------------------------------
describe('Kotlin companion vs instance member dispatch (#1756)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-companion-vs-instance'),
() => {},
);
}, 60000);
it('detects Logger class with companion-only create() and instance log()', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Logger');
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('create');
expect(methods).toContain('log');
});
it('Logger.create("app") resolves to the companion create', () => {
const calls = getRelationships(result, 'CALLS');
const createCall = calls.find((c) => c.source === 'makeLogger' && c.target === 'create');
expect(createCall).toBeDefined();
expect(createCall!.targetFilePath).toBe('App.kt');
});
it('logger.log("hello") resolves to the instance log, NOT companion create', () => {
const calls = getRelationships(result, 'CALLS');
const logCall = calls.find((c) => c.source === 'makeLogger' && c.target === 'log');
expect(logCall).toBeDefined();
expect(logCall!.targetFilePath).toBe('App.kt');
});
it('makeLogger emits exactly 2 CALLS edges — Logger.create and logger.log, no extras', () => {
const calls = getRelationships(result, 'CALLS');
const fromMakeLogger = calls.filter((c) => c.source === 'makeLogger');
expect(fromMakeLogger.length).toBe(2);
});
it('logger.log() in directLog() resolves to the instance log on App.kt', () => {
const calls = getRelationships(result, 'CALLS');
const logCall = calls.find((c) => c.source === 'directLog' && c.target === 'log');
expect(logCall).toBeDefined();
expect(logCall!.targetFilePath).toBe('App.kt');
});
it('crossover() invoking logger.create() on an instance emits NO CALLS edge', () => {
// `logger.create(...)` on an instance is a compile error in Kotlin —
// companion-object methods can only be called through the class name.
// The resolver must NOT emit a CALLS edge for this call site (#1756).
// Registry-primary path filters via `ScopeResolver.isStaticOnly`; the
// legacy DAG has a pre-existing crossover bug, so this assertion is
// marked as a legacy expected failure in
// `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.kotlin` (helpers.ts).
const calls = getRelationships(result, 'CALLS');
const crossover = calls.find((c) => c.source === 'crossover' && c.target === 'create');
expect(crossover).toBeUndefined();
});
// #1756 / U7 edge-type completeness: in addition to the CALLS absence
// asserted above, the crossover() function must NOT leak any non-CALLS
// edge from `crossover` to the companion-promoted `create`. Without
// these assertions a hypothetical future regression that wired the
// crossover through a `USES` (type-reference) or `ACCESSES` (property-
// read) edge would silently pass the CALLS-only check while still
// misrepresenting the dispatch to users / consumers of the graph.
// Both `USES` and `ACCESSES` are valid `RelationshipType` values in
// `gitnexus-shared/src/graph/types.ts`.
it('crossover() emits NO USES edges to create (edge-type completeness)', () => {
const usesEdges = getRelationships(result, 'USES').filter(
(c) => c.source === 'crossover' && c.target === 'create',
);
expect(usesEdges.length).toBe(0);
});
it('crossover() emits NO ACCESSES edges to create (edge-type completeness)', () => {
const accessesEdges = getRelationships(result, 'ACCESSES').filter(
(c) => c.source === 'crossover' && c.target === 'create',
);
expect(accessesEdges.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Kotlin lambda scopes (#1757)
//
// Lambda bodies create a new lexical scope in which the lambda's parameter
// list (or implicit `it`) binds. Call sites inside the lambda body must
// resolve through these bindings; implicit `it` must be visible only inside
// the lambda; nested lambdas must shadow deterministically. Covers stdlib
// idioms: `forEach`, `map`, `filter`, `let`, `apply`, `also`, `with`,
// `takeIf`, `use`.
// ---------------------------------------------------------------------------
describe('Kotlin lambda scopes (#1757)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-lambda-scopes'), () => {});
}, 60000);
it('detects User and Post classes plus save/like methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Post');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('like');
});
// Happy path: explicit parameter
it('explicitParam: user.save() inside forEach resolves to User.save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.source === 'explicitParam' && c.target === 'save');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toBe('App.kt');
const hasMethod = getRelationships(result, 'HAS_METHOD');
const userSave = hasMethod.find((e) => e.source === 'User' && e.target === 'save');
expect(userSave).toBeDefined();
expect(saveCalls[0].rel.targetId).toBe(userSave!.rel.targetId);
});
// Happy path: implicit `it`
it('implicitIt: it.save() inside forEach resolves to User.save via implicit it', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.source === 'implicitIt' && c.target === 'save');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toBe('App.kt');
});
// Happy path: chain — outer lambda's `it.name` does not cross-bind
it('chained: emits no erroneous save/like edges (inner it bound to User, not Post)', () => {
const calls = getRelationships(result, 'CALLS');
const erroneousSave = calls.find((c) => c.source === 'chained' && c.target === 'save');
const erroneousLike = calls.find((c) => c.source === 'chained' && c.target === 'like');
expect(erroneousSave).toBeUndefined();
expect(erroneousLike).toBeUndefined();
});
it('chained: println(name) inside forEach resolves to file-scope println', () => {
const calls = getRelationships(result, 'CALLS');
const printlnCalls = calls.filter((c) => c.source === 'chained' && c.target === 'println');
expect(printlnCalls.length).toBe(1);
expect(printlnCalls[0].targetFilePath).toBe('App.kt');
});
// Edge case: nested lambdas — inner `it` is Post, outer `user` is User
it('nested: inner it.like() resolves to Post.like (NOT User.like)', () => {
const calls = getRelationships(result, 'CALLS');
const likeCalls = calls.filter((c) => c.source === 'nested' && c.target === 'like');
expect(likeCalls.length).toBe(1);
expect(likeCalls[0].targetFilePath).toBe('App.kt');
const hasMethod = getRelationships(result, 'HAS_METHOD');
const postLike = hasMethod.find((e) => e.source === 'Post' && e.target === 'like');
expect(postLike).toBeDefined();
expect(likeCalls[0].rel.targetId).toBe(postLike!.rel.targetId);
});
it('nested: emits NO save() CALLS edge (outer `user` parameter is not called)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find((c) => c.source === 'nested' && c.target === 'save');
expect(wrongSave).toBeUndefined();
});
// Edge case: `let` exposes the receiver as `it`
it('letScope: it.save() inside let { } resolves to User.save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.source === 'letScope' && c.target === 'save');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toBe('App.kt');
});
// Edge case: shadowing — inner `it` (User) beats outer `val it = "outer"`
it('outerItShadow: inner it.save() resolves to User.save (outer val it is shadowed)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.source === 'outerItShadow' && c.target === 'save');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toBe('App.kt');
const hasMethod = getRelationships(result, 'HAS_METHOD');
const userSave = hasMethod.find((e) => e.source === 'User' && e.target === 'save');
expect(userSave).toBeDefined();
expect(saveCalls[0].rel.targetId).toBe(userSave!.rel.targetId);
});
});
// ---------------------------------------------------------------------------
// #1756 / U2 remediation: the `isStaticOnly` filter must run INSIDE the MRO
// chain walk (so static-only candidates fall through to ancestor instance
// methods) and BEFORE arity narrowing (so a same-name same-arity static +
// instance pair on the same owner doesn't collapse to OVERLOAD_AMBIGUOUS).
//
// Three scenarios in `kotlin-companion-mro-shadow/App.kt`:
// - `useChild(c: Child)` calls `c.foo()` — Child has only a companion
// `foo` but extends Base whose instance `foo` is the legitimate target.
// Expected: exactly one CALLS edge `useChild → Base.foo`, no edge to
// the companion-promoted `Child.foo`.
// - `useChildWithInstance(c: ChildWithInstance)` calls `c.foo()` —
// ChildWithInstance has BOTH an instance `foo(): Int` AND a same-arity
// companion `foo(): ChildWithInstance`. Expected: exactly one CALLS
// edge to the instance `foo` on ChildWithInstance (not the companion,
// not Base).
// - `useStandalone(s: Standalone)` calls `s.foo()` — Standalone has
// only a companion `foo` and no instance ancestor with the same
// name. Expected: no CALLS edge.
// ---------------------------------------------------------------------------
describe('Kotlin companion vs instance MRO shadowing (#1756 / U2)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-companion-mro-shadow'),
() => {},
);
}, 60000);
it('useChild() falls through static-only Child.foo to Base.foo', () => {
const calls = getRelationships(result, 'CALLS');
const fromUseChild = calls.filter((c) => c.source === 'useChild');
expect(fromUseChild.length).toBe(1);
expect(fromUseChild[0].target).toBe('foo');
expect(fromUseChild[0].targetFilePath).toBe('App.kt');
// The target should be the Base instance `foo`, not the companion
// `foo` promoted onto Child. We assert by checking the target node's
// qualified name resolves under Base (via HAS_METHOD).
const hasMethod = getRelationships(result, 'HAS_METHOD');
const baseFoo = hasMethod.find(
(e) => e.source === 'Base' && e.target === 'foo' && e.targetFilePath === 'App.kt',
);
expect(baseFoo).toBeDefined();
expect(fromUseChild[0].rel.targetId).toBe(baseFoo!.rel.targetId);
});
it('useChild() does NOT emit an edge to the companion-promoted Child.foo', () => {
const calls = getRelationships(result, 'CALLS');
const fromUseChild = calls.filter((c) => c.source === 'useChild');
// No edge whose target is the Child companion `foo`. We identify it
// by HAS_METHOD: Child → foo (the companion `foo` is promoted onto
// Child as the enclosing class). If such an edge existed, useChild
// would target it; assert it does not.
const hasMethod = getRelationships(result, 'HAS_METHOD');
const childFoo = hasMethod.find(
(e) => e.source === 'Child' && e.target === 'foo' && e.targetFilePath === 'App.kt',
);
if (childFoo !== undefined) {
const wrongEdge = fromUseChild.find((c) => c.rel.targetId === childFoo.rel.targetId);
expect(wrongEdge).toBeUndefined();
}
});
it('useChildWithInstance() resolves to the instance foo on ChildWithInstance', () => {
const calls = getRelationships(result, 'CALLS');
const fromUseCWI = calls.filter((c) => c.source === 'useChildWithInstance');
expect(fromUseCWI.length).toBe(1);
expect(fromUseCWI[0].target).toBe('foo');
expect(fromUseCWI[0].targetFilePath).toBe('App.kt');
// Assert the target is ChildWithInstance.foo (the instance method),
// not the companion `foo` (which also targets ChildWithInstance as
// the promoted owner but is static-only) and not Base.foo.
const hasMethod = getRelationships(result, 'HAS_METHOD');
const baseFoo = hasMethod.find(
(e) => e.source === 'Base' && e.target === 'foo' && e.targetFilePath === 'App.kt',
);
expect(baseFoo).toBeDefined();
expect(fromUseCWI[0].rel.targetId).not.toBe(baseFoo!.rel.targetId);
});
it('useStandalone() emits no CALLS edge (entire chain is static-only)', () => {
const calls = getRelationships(result, 'CALLS');
const fromUseStandalone = calls.filter((c) => c.source === 'useStandalone');
expect(fromUseStandalone.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// #1756 / U4 remediation: named companions and companions containing nested
// classes must promote their methods onto the enclosing class AND stamp the
// static-only marker (so crossover via instance receiver is suppressed).
//
// Pre-U4 `populateCompanionMembersOnEnclosingClass` used the heuristic
// `parent.ownedDefs.some(isClassLike) → continue`, which silently bypassed:
// - named companions (`companion object Helper { ... }`) — `Helper`
// looked like a class-like def on the companion scope; and
// - companions containing nested classes (`companion object { class
// Token; fun create() }`) — the nested class def lived on the
// companion scope.
// U4 replaces the heuristic with a parser-layer marker capture
// (`@scope.companion`), so any `companion_object` AST node is
// unambiguously identified as a companion regardless of contents.
// ---------------------------------------------------------------------------
describe('Kotlin named companion + nested-class companions (#1756 / U4)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-companion-named'), () => {});
}, 60000);
it('detects Outer / WithNested / InnerClassAndCompanion classes and create / forge / build methods', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Outer');
expect(classes).toContain('WithNested');
expect(classes).toContain('InnerClassAndCompanion');
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('create');
expect(methods).toContain('forge');
expect(methods).toContain('build');
});
// Happy path (named companion): Outer.create() resolves through the
// enclosing class name. Pre-U4 this emitted zero edges because the
// named-companion `create` was owned by `Helper`, not `Outer`.
it('useNamed: Outer.create() resolves to exactly 1 CALLS edge → create', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.source === 'useNamed' && c.target === 'create');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toBe('App.kt');
});
// Crossover suppression (named): the instance-receiver `o.create()` is a
// compile error in Kotlin — companion methods are not legal instance-
// dispatch candidates. Pre-U4 this emitted a false edge because the
// static-only marker was never stamped on the named-companion `create`.
it('useNamedCrossover: o.create() emits NO CALLS edge to create', () => {
const calls = getRelationships(result, 'CALLS');
const crossover = calls.filter(
(c) => c.source === 'useNamedCrossover' && c.target === 'create',
);
expect(crossover.length).toBe(0);
});
// Happy path (companion containing a nested class): WithNested.forge()
// resolves through the enclosing class name. Pre-U4 the nested
// `class Token` made the companion look like a regular class to the
// heuristic, so `forge` was never promoted onto `WithNested`.
it('useNested: WithNested.forge() resolves to exactly 1 CALLS edge → forge', () => {
const calls = getRelationships(result, 'CALLS');
const forgeCalls = calls.filter((c) => c.source === 'useNested' && c.target === 'forge');
expect(forgeCalls.length).toBe(1);
expect(forgeCalls[0].targetFilePath).toBe('App.kt');
});
// Mix (inner-class + companion): the class-name call resolves to the
// promoted companion method; the instance-receiver crossover emits
// nothing. Verifies that the U4 fix does NOT misclassify a regular
// class with a sibling companion as a companion itself.
it('useInnerMix: exactly 1 CALLS edge to build (class-name call resolves; crossover suppressed)', () => {
const calls = getRelationships(result, 'CALLS');
const buildCalls = calls.filter((c) => c.source === 'useInnerMix' && c.target === 'build');
expect(buildCalls.length).toBe(1);
expect(buildCalls[0].targetFilePath).toBe('App.kt');
});
});
// ---------------------------------------------------------------------------
// #1756 / U6 remediation: cross-file companion factory dispatch.
//
// `Logger.create(...)` — a companion-object factory call via the class name —
// must resolve to the companion's `create` even when `Logger` is imported
// from a different file. The probe in U6 (2026-05-22) established that
// Case 2 (class-name receiver) dispatch traverses module boundaries
// correctly: `Logger.create()` in `app/Main.kt` resolves to
// `Logger.create` in `logging/Logger.kt` via the import-resolved
// receiver chain.
//
// What does NOT cross module boundaries today is the chain-typebinding:
// `val l = Logger.create(...)` followed by `l.log(...)` only resolves
// when `Logger` is defined in the same file as the call site. Two
// reasons:
// 1. `collectKotlinClassMembers` in `captures.ts` runs per-file, so
// the Tier-2 lookup that drives chain-typebinding return-type
// inference (`inferKotlinNavigationCallReturnType` →
// `classMembers.methods.get("Logger")?.get("create")`) returns
// undefined when `Logger` is imported. The local typeBinding
// `l → ?` is never emitted in the importer scope.
// 2. The chain-follow mirror in `propagateImportedReturnTypes` (#1759)
// treats dot-form rawNames like `Logger.create` as terminal, so it
// cannot bridge `l → Logger.create → Logger` cross-file either.
//
// Closing this gap requires either a workspace-level Kotlin class-member
// index (paralleling the `scanJavaImports` / `scanPythonImports`
// patterns) or refactoring `followChainPostFinalize` to look up dot-form
// bindings against a cross-file return-type map. Both are substantial
// enough that the U6 plan's "fix looks substantial" branch fires —
// neither qualifies as the additive `imported-return-types.ts`
// extension the U6 approach (a) allows. Deferred to a follow-up issue
// tracking cross-file companion factory chain binding alongside the
// broader cross-file Tier-2 class-member lookup work.
//
// The instance-receiver crossover (`l.create()` on an instance receiver
// emits no CALLS edge) is U3's surface and is asserted in the U2 / U3
// same-file fixtures (`kotlin-companion-mro-shadow`,
// `kotlin-companion-other-cases`); this fixture intentionally does not
// duplicate that assertion to avoid coupling U6 to U3's static-only-
// filter extension to Cases 0 / 3b / 5.
// ---------------------------------------------------------------------------
describe('Kotlin companion vs instance cross-file dispatch (#1756 / U6)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-companion-cross-file'),
() => {},
);
}, 60000);
it('detects Logger class with companion create() and instance log()', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Logger');
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('create');
expect(methods).toContain('log');
});
// Happy path: `Logger.create("app")` resolves via class-name receiver
// (Case 2) across module boundaries — the import-resolved receiver
// chain reaches the companion's `create` in `logging/Logger.kt`.
it('useCrossFileFactory: Logger.create() resolves to companion create on Logger.kt', () => {
const calls = getRelationships(result, 'CALLS');
const createCall = calls.find(
(c) => c.source === 'useCrossFileFactory' && c.target === 'create',
);
expect(createCall).toBeDefined();
expect(createCall!.targetFilePath).toBe('logging/Logger.kt');
});
// NOTE: a follow-up assertion `l.log()` resolving cross-file via the
// chain-typebinding `val l = Logger.create(...)` would belong here.
// The U6 probe (2026-05-22) confirmed that the existing pipeline does
// NOT propagate `l → Logger` across module boundaries — see the comment
// block above for the failure modes and deferral rationale. The class-
// name dispatch assertion above is the additive coverage U6 lands; the
// chain-typebinding cross-file path is tracked as a follow-up issue
// alongside the broader cross-file Tier-2 lookup work.
});
// ---------------------------------------------------------------------------
// #1756 / U3 remediation: extend the `isStaticOnly` filter to receiver-bound
// dispatch cases beyond Case 4. Pre-U3, the filter only fired on Case 4
// (simple typeBinding receiver). Three other instance-dispatch cases also
// emit `CALLS` edges and could leak the companion-vs-instance crossover:
// - Case 0 (compound receiver): receiver like `Logger.create("a")` whose
// `findOwnedMember(Logger, "create")` returns the static-only
// companion-promoted `create`.
// - Case 3b (chain-typebinding): receiver inferred via a chain whose
// resolved owner has a static-only candidate.
// - Case 5 (value-receiver bridge): `findValueBindingInScope` +
// `pickOverload` on a single owner.
//
// Note: Case 0.5 (`this`-receiver) is NOT covered because Kotlin's scope-
// resolver does not enable `resolveThisViaEnclosingClass`. The dependency
// is documented inline in `receiver-bound-calls.ts` so any language that
// enables it must also wire the filter at that case.
//
// The legitimate edges (Case 2 class-name receiver `Logger.create("a")`,
// Case 4 simple typeBinding `r.getAll()`) must continue to emit.
//
// **Empirical case-coverage observations** (probe at commit pre-U3, test
// run 2026-05-22): in **registry-primary** mode, the existing pipeline
// already emits zero crossover edges for the fixture shapes below even
// without U3's filter wired at Cases 0 / 3b / 5. In **legacy DAG** mode
// (REGISTRY_PRIMARY_KOTLIN=0), the same shapes leak crossover edges for
// the `useChainTypeBindingCrossover` and `useValueReceiverCrossover`
// scenarios — confirming that *some* suppression mechanism in the
// registry-primary path is already catching them (most likely U2's
// Case-4 filter for `l.create("nope")`, since `val l = ...` produces a
// typeBinding routing through Case 4; the compound and chain shapes
// are suppressed by the receiver resolver not binding to the static-
// only def in the first place).
//
// Per the remediation plan's "be honest about which paths are actually
// exercised by tests vs which are added defensively" guidance, the
// per-case filters at Cases 0 / 3b / 5 are landing as **defensive
// wire-ups** — they ensure the contract symmetry the JSDoc now claims
// (filter applies to every instance-dispatch case) holds for future
// fixture shapes that DO trigger these paths with a static-only
// candidate. The crossover tests are registered as expected failures
// in `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.kotlin` because the
// legacy DAG genuinely diverges on these shapes; the registry-primary
// path's suppression is a real scope-resolver-only correctness win.
// ---------------------------------------------------------------------------
describe('Kotlin isStaticOnly across other receiver cases (#1756 / U3)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-companion-other-cases'),
() => {},
);
}, 60000);
it('detects Logger / Service / Repo and their companion + instance methods', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Logger');
expect(classes).toContain('Service');
expect(classes).toContain('Repo');
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('create');
expect(methods).toContain('build');
expect(methods).toContain('log');
expect(methods).toContain('perform');
expect(methods).toContain('getAll');
});
// Happy path + Case 0 crossover suppression (combined): the legitimate
// `Logger.create("a")` (Case 2 class-name receiver) emits exactly 1
// CALLS edge to `create`. The OUTER `.create("b")` on the compound
// receiver `Logger.create("a")` would route through Case 0 — per the
// empirical observation above, the existing pipeline already does NOT
// emit a crossover edge for this shape, so the post-U3 count stays
// at 1 (same as pre-U3). The U3 Case-0 filter is defensive: if a
// future fixture's compound-receiver shape DOES enter Case 0 with a
// static-only candidate, the filter would suppress.
it('useCompoundCrossover: Logger.create("a") emits exactly 1 CALLS edge to create', () => {
const calls = getRelationships(result, 'CALLS');
const createCalls = calls.filter(
(c) => c.source === 'useCompoundCrossover' && c.target === 'create',
);
expect(createCalls.length).toBe(1);
expect(createCalls[0].targetFilePath).toBe('App.kt');
});
// Happy path (Case 4 simple typeBinding, baseline): `r.getAll()` in
// `useChainTypeBindingCrossover` resolves through `findReceiverType
// Binding` for `r: Repo` and `findOwnedMember(Repo, "getAll")`. The
// instance dispatch on `Repo` is legitimate — that edge MUST emit.
it('useChainTypeBindingCrossover: r.getAll() emits exactly 1 CALLS edge to getAll', () => {
const calls = getRelationships(result, 'CALLS');
const getAllCalls = calls.filter(
(c) => c.source === 'useChainTypeBindingCrossover' && c.target === 'getAll',
);
expect(getAllCalls.length).toBe(1);
expect(getAllCalls[0].targetFilePath).toBe('App.kt');
});
// Crossover (Case 3b chain-typebinding): the chained `.build()` on
// `services.first()` would route through Case 3b's chain-typebinding
// walk if the chain resolves to `Service`. Per the empirical
// observation above, the existing pipeline already does NOT emit a
// crossover edge for this shape — `services.first()` returns
// `Service?` from `List<Service>.first()` and the chain-typebinding
// walk doesn't terminate at the Service class for this expression
// tree. The U3 Case-3b filter is defensive: if a future shape DOES
// bind the chain to Service and reach `findOwnedMember(Service,
// "build")`, the filter would suppress.
it('useChainTypeBindingCrossover: services.first().build() emits NO CALLS edge to build', () => {
const calls = getRelationships(result, 'CALLS');
const buildCalls = calls.filter(
(c) => c.source === 'useChainTypeBindingCrossover' && c.target === 'build',
);
expect(buildCalls.length).toBe(0);
});
// Crossover (value-receiver-style): `l.create("nope")` is invalid
// Kotlin (companion methods are not legal instance-dispatch
// candidates). Kotlin's resolver typically routes `l` through Case 4
// because `val l = makeLoggerForCrossover()` produces a typeBinding
// for Logger via call-result return-type inference — so the
// crossover suppression actually fires through Case 4 (U2's filter).
// The U3 Case-5 filter wire-up is defensive: it preserves contract
// symmetry for any future value-binding shape that bypasses Case 4
// (e.g., object-literal-style receivers that fall through to the
// value-binding bridge instead).
it('useValueReceiverCrossover: l.create("nope") emits NO CALLS edge to create', () => {
const calls = getRelationships(result, 'CALLS');
const createCalls = calls.filter(
(c) => c.source === 'useValueReceiverCrossover' && c.target === 'create',
);
expect(createCalls.length).toBe(0);
});
});

View file

@ -0,0 +1,279 @@
/**
* Unit tests for the Kotlin companion-promoted-method "static-only"
* marker mechanism (#1756 / U5 of the lang-kotlin remediation plan).
*
* Pins the contract of the `isKotlinStaticOnly` reader and the
* implicit `WeakSet`-backed writer driven by
* `populateKotlinOwners`:
*
* 1. Round-trip: methods declared inside a companion-object scope
* pass `isKotlinStaticOnly` after `populateKotlinOwners` runs;
* methods declared directly on a regular class scope do not.
* 2. Identity, not structure: spreading a marked def into a new
* object reference produces a structurally-identical but
* identity-distinct def that does NOT pass the marker check.
* Documents the identity-based design boundary that the
* previous enumerable-property mechanism did not enforce.
* 3. Multi-def fanout: marking three companion methods in one
* pass leaves all three readable and an unmarked sibling
* unaffected.
*
* The writer is intentionally not exported — these tests drive it
* through the public `populateKotlinOwners` entry point using a
* hand-built `ParsedFile` shape, mirroring the runtime call site.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import type { ParsedFile, Range, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import {
clearCompanionScopes,
isCompanionScope,
markCompanionScope,
} from '../../src/core/ingestion/languages/kotlin/companion-scopes.js';
import {
isKotlinStaticOnly,
populateKotlinOwners,
} from '../../src/core/ingestion/languages/kotlin/owners.js';
const RANGE: Range = { startLine: 1, startCol: 0, endLine: 1, endCol: 0 };
function makeScope(args: {
id: string;
parent: string | null;
kind: Scope['kind'];
filePath: string;
ownedDefs: readonly SymbolDefinition[];
}): Scope {
return {
id: args.id as ScopeId,
parent: args.parent === null ? null : (args.parent as ScopeId),
kind: args.kind,
range: RANGE,
filePath: args.filePath,
bindings: new Map(),
ownedDefs: args.ownedDefs,
imports: [],
typeBindings: new Map(),
} as Scope;
}
function makeMethodDef(args: { nodeId: string; filePath: string; name: string }): SymbolDefinition {
return {
nodeId: args.nodeId,
filePath: args.filePath,
type: 'Function',
qualifiedName: args.name,
} as SymbolDefinition;
}
function makeClassDef(args: { nodeId: string; filePath: string; name: string }): SymbolDefinition {
return {
nodeId: args.nodeId,
filePath: args.filePath,
type: 'Class',
qualifiedName: args.name,
} as SymbolDefinition;
}
/**
* Build a synthetic `ParsedFile` modelling:
*
* class Outer {
* fun instanceMethod() { ... } // regular instance method
* companion object {
* fun staticMethod() { ... } // companion-promoted method
* }
* }
*
* The companion scope is registered with `markCompanionScope` so
* `populateCompanionMembersOnEnclosingClass` recognises it as the
* companion-object scope to walk for promotion + marking.
*/
function buildCompanionFixture(
filePath: string,
companionMethods: readonly string[],
instanceMethods: readonly string[],
): {
parsed: ParsedFile;
outerClassDef: SymbolDefinition;
companionMethodDefs: SymbolDefinition[];
instanceMethodDefs: SymbolDefinition[];
} {
const moduleScopeId = `${filePath}:module`;
const outerClassScopeId = `${filePath}:class:Outer`;
const companionScopeId = `${filePath}:class:Outer.Companion`;
const outerClassDef = makeClassDef({
nodeId: `${filePath}#Outer`,
filePath,
name: 'Outer',
});
const companionMethodDefs = companionMethods.map((name) =>
makeMethodDef({
nodeId: `${filePath}#Outer.Companion.${name}`,
filePath,
name,
}),
);
const instanceMethodDefs = instanceMethods.map((name) =>
makeMethodDef({
nodeId: `${filePath}#Outer.${name}`,
filePath,
name,
}),
);
const scopes: Scope[] = [
makeScope({
id: moduleScopeId,
parent: null,
kind: 'Module',
filePath,
ownedDefs: [outerClassDef],
}),
makeScope({
id: outerClassScopeId,
parent: moduleScopeId,
kind: 'Class',
filePath,
ownedDefs: [outerClassDef],
}),
makeScope({
id: companionScopeId,
parent: outerClassScopeId,
kind: 'Class',
filePath,
ownedDefs: [],
}),
];
// Each companion method lives in its own Function scope whose parent
// is the companion-class scope — the exact shape
// `populateCompanionMembersOnEnclosingClass` iterates.
companionMethodDefs.forEach((def, idx) => {
scopes.push(
makeScope({
id: `${filePath}:fn:companion:${idx}`,
parent: companionScopeId,
kind: 'Function',
filePath,
ownedDefs: [def],
}),
);
});
// Instance methods on the outer class — Function scopes whose
// parent is the outer-class scope; populateClassOwnedMembers
// stamps these with `ownerId = Outer` but they MUST NOT be
// tagged by the companion promotion pass.
instanceMethodDefs.forEach((def, idx) => {
scopes.push(
makeScope({
id: `${filePath}:fn:instance:${idx}`,
parent: outerClassScopeId,
kind: 'Function',
filePath,
ownedDefs: [def],
}),
);
});
// Tell the companion-scope side-channel that
// `outerClassScopeId.companion` is the companion scope id —
// matches what `emitKotlinScopeCaptures` does at runtime.
markCompanionScope(filePath, companionScopeId as ScopeId);
const parsed: ParsedFile = {
filePath,
moduleScope: moduleScopeId as ScopeId,
scopes,
parsedImports: [],
localDefs: [outerClassDef, ...companionMethodDefs, ...instanceMethodDefs],
referenceSites: [],
};
return { parsed, outerClassDef, companionMethodDefs, instanceMethodDefs };
}
describe('isKotlinStaticOnly (WeakSet-backed marker)', () => {
beforeEach(() => {
clearCompanionScopes();
});
it('marks companion-object methods and leaves instance methods unmarked (round-trip)', () => {
const { parsed, companionMethodDefs, instanceMethodDefs } = buildCompanionFixture(
'fixture-roundtrip.kt',
['staticMethod'],
['instanceMethod'],
);
populateKotlinOwners(parsed);
expect(isKotlinStaticOnly(companionMethodDefs[0]!)).toBe(true);
expect(isKotlinStaticOnly(instanceMethodDefs[0]!)).toBe(false);
});
it('keys on def identity, not on def structure (spread copy is not marked)', () => {
const { parsed, companionMethodDefs } = buildCompanionFixture(
'fixture-identity.kt',
['staticMethod'],
[],
);
populateKotlinOwners(parsed);
const marked = companionMethodDefs[0]!;
// Spread produces a new object reference with identical fields.
// The previous enumerable-property marker would have copied through;
// the WeakSet correctly tracks identity only.
const structuralClone = { ...marked } as SymbolDefinition;
expect(isKotlinStaticOnly(marked)).toBe(true);
expect(isKotlinStaticOnly(structuralClone)).toBe(false);
// Sanity: the clone really does have the same own-properties.
expect(structuralClone.nodeId).toBe(marked.nodeId);
expect(structuralClone.qualifiedName).toBe(marked.qualifiedName);
});
it('marks every companion method in a multi-method companion and leaves siblings unaffected', () => {
const { parsed, companionMethodDefs, instanceMethodDefs } = buildCompanionFixture(
'fixture-multi.kt',
['create', 'build', 'of'],
['save'],
);
populateKotlinOwners(parsed);
expect(isKotlinStaticOnly(companionMethodDefs[0]!)).toBe(true);
expect(isKotlinStaticOnly(companionMethodDefs[1]!)).toBe(true);
expect(isKotlinStaticOnly(companionMethodDefs[2]!)).toBe(true);
expect(isKotlinStaticOnly(instanceMethodDefs[0]!)).toBe(false);
});
it('returns false for a fresh def the writer never saw', () => {
const unrelated = makeMethodDef({
nodeId: 'unrelated#foo',
filePath: 'unrelated.kt',
name: 'foo',
});
expect(isKotlinStaticOnly(unrelated)).toBe(false);
});
});
describe('kotlinScopeResolver.loadResolutionConfig lifecycle', () => {
it('clears stale companionScopesByFile entries from a prior workspace pass', async () => {
const staleFile = 'stale-prior-pass.kt';
const staleScopeId = `scope:${staleFile}#1:0-2:0:Class` as ScopeId;
markCompanionScope(staleFile, staleScopeId);
expect(isCompanionScope(staleFile, staleScopeId)).toBe(true);
const { kotlinScopeResolver } =
await import('../../src/core/ingestion/languages/kotlin/scope-resolver.js');
kotlinScopeResolver.loadResolutionConfig!('/any/repo/path');
expect(isCompanionScope(staleFile, staleScopeId)).toBe(false);
});
});