diff --git a/AGENTS.md b/AGENTS.md index 46960b967..7971154aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: "@"` + `service`. Removed `group_query`/`group_contracts`/`group_status` MCP tools; added `gitnexus://group/{name}/contracts` and `gitnexus://group/{name}/status` resources. | diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 4864d7d38..2fd8fac8e 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -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['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 = {}; @@ -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, +): 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, + returnTypes: ReadonlyMap, + 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: . + 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, + returnTypes: ReadonlyMap, + 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, @@ -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): 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, @@ -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, @@ -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( diff --git a/gitnexus/src/core/ingestion/languages/kotlin/companion-scopes.ts b/gitnexus/src/core/ingestion/languages/kotlin/companion-scopes.ts new file mode 100644 index 000000000..ac4034620 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/companion-scopes.ts @@ -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>` 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>(); + +/** 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(); + 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(); +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/owners.ts b/gitnexus/src/core/ingestion/languages/kotlin/owners.ts index f034bafd7..25d085fd5 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/owners.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/owners.ts @@ -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(); + +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}`; } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts index 5a2968055..9209655d8 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/query.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts @@ -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" diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 591e79bfc..3603c2698 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts index bbd34e160..3ea5bbb6b 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 94fc172dc..9016a68b5 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -76,6 +76,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set 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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 6a9469693..ac7164c70 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -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]; +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/app/Main.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/app/Main.kt new file mode 100644 index 000000000..1ee3a27aa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/app/Main.kt @@ -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") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/logging/Logger.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/logging/Logger.kt new file mode 100644 index 000000000..50e4ec0df --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/logging/Logger.kt @@ -0,0 +1,8 @@ +package logging + +class Logger(val name: String) { + fun log(msg: String) {} + companion object { + fun create(name: String): Logger = Logger(name) + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-mro-shadow/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-mro-shadow/App.kt new file mode 100644 index 000000000..ee95998aa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-mro-shadow/App.kt @@ -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() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-named/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-named/App.kt new file mode 100644 index 000000000..3750cc339 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-named/App.kt @@ -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() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-other-cases/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-other-cases/App.kt new file mode 100644 index 000000000..bb2a5773b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-other-cases/App.kt @@ -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 = 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") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-vs-instance/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-vs-instance/App.kt new file mode 100644 index 000000000..3e9e592fd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-vs-instance/App.kt @@ -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") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-lambda-scopes/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-lambda-scopes/App.kt new file mode 100644 index 000000000..c8db3bd34 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-lambda-scopes/App.kt @@ -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) { + users.forEach { user -> user.save() } +} + +fun implicitIt(users: List) { + users.forEach { it.save() } +} + +fun chained(users: List) { + users.map { it.name }.forEach { name -> println(name) } +} + +fun nested(users: List, posts: Map>) { + 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) { + val it = "outer" + users.forEach { it.save() } +} diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index c7dff0947..74152c6ea 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -124,6 +124,82 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([ + // #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([ // The legacy DAG path has no scope-aware filtering on the global // free-call fallback, so `#include`d headers still leak class diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index d70a240d1..f2d241950 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -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.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); + }); +}); diff --git a/gitnexus/test/unit/kotlin-static-marker.test.ts b/gitnexus/test/unit/kotlin-static-marker.test.ts new file mode 100644 index 000000000..51bdb7c95 --- /dev/null +++ b/gitnexus/test/unit/kotlin-static-marker.test.ts @@ -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); + }); +});