From c96ae23fbc347206dbd96acf4103c94a314d6429 Mon Sep 17 00:00:00 2001 From: Ricardo Costeira Date: Fri, 13 Nov 2026 12:44:58 +0100 Subject: [PATCH] Fix built-in Kotlin class reference lookup across files --- docs/features.md | 11 +- src/kotlin-references.ts | 4 +- src/kotlin-type-references.ts | 216 +++++++++++++++++++++++++++ src/symbol-peek.ts | 17 ++- tests/kotlin-type-references.test.ts | 79 ++++++++++ tests/symbol-peek.test.ts | 43 ++++++ 6 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 src/kotlin-type-references.ts create mode 100644 tests/kotlin-type-references.test.ts diff --git a/docs/features.md b/docs/features.md index 3bccf24..38e72ea 100644 --- a/docs/features.md +++ b/docs/features.md @@ -193,9 +193,14 @@ selected symbol's line and column. Without extra setup, Kotlin (`.kt` and `.kts` can find same-file declaration and usage candidates for properties, functions and parameters. The bundled parser respects explicit lexical scopes and shadowing, excludes comments and plain string text, and distinguishes `this` -members from unknown receivers. Results are labelled **Kotlin syntax**; syntax -the parser cannot read is reported as partial. This does not resolve receiver -types, overloads, imports or references in other files. +members from unknown receivers. Top-level classes, interfaces, objects and type +aliases also have cross-file candidates based on Kotlin packages, explicit +imports (including aliases), qualified names and unambiguous wildcard imports. +Same-named types in different packages stay separate; duplicate declarations +(including expect/actual pairs) need a project-aware provider. Results are +labelled **Kotlin syntax**; unreadable syntax and scan limits are reported. +This does not resolve receiver types, overloads, type-alias expansion, dependency +sources or KMP source sets. Nested and local type lookups still use the fallback. For project-aware results, Kotlin supports `kotlin-lsp --stdio` or `kotlin-language-server` on DiffCompass's `PATH`. diff --git a/src/kotlin-references.ts b/src/kotlin-references.ts index 9ccc57b..bbfe903 100644 --- a/src/kotlin-references.ts +++ b/src/kotlin-references.ts @@ -36,7 +36,7 @@ function parsedContext(node: Node): boolean { return true; } -function declaration(name: Node): Declaration | undefined { +export function kotlinLexicalDeclaration(name: Node): Declaration | undefined { const parent = name.parent; if (!parent) return; let scope: Node | undefined; @@ -105,7 +105,7 @@ export async function kotlinReferences(content: string, symbol: string, line: nu const candidates = identifiers.filter(node => node.startPosition.row === line - 1 && (column === undefined || (node.startPosition.column <= column - 1 && column - 1 < node.endPosition.column))); if (candidates.length !== 1) return; const selected = candidates[0]!; - const declarations = identifiers.map(declaration).filter((value): value is Declaration => !!value); + const declarations = identifiers.map(kotlinLexicalDeclaration).filter((value): value is Declaration => !!value); const byName = new Map(declarations.map(value => [value.name.id, value])); const byScope = new Map(); for (const value of declarations) byScope.set(value.scope.id, [...(byScope.get(value.scope.id) ?? []), value]); diff --git a/src/kotlin-type-references.ts b/src/kotlin-type-references.ts new file mode 100644 index 0000000..c907d83 --- /dev/null +++ b/src/kotlin-type-references.ts @@ -0,0 +1,216 @@ +import type { Node, Parser } from "web-tree-sitter"; +import { kotlinLexicalDeclaration } from "./kotlin-references.ts"; +import { languageParser, SEMANTIC_LIMITS } from "./semantic-diff.ts"; +import { parseWithin } from "./syntax-nodes.ts"; + +// This is a bounded source index, not a compiler: no Gradle, generated sources, +// dependency jars, receiver inference or expect/actual source-set selection. +const LIMITS = { files: 500, bytes: 8 * 1024 * 1024, millis: 2500, usages: 49 }; +const typeDeclarations = new Set(["class_declaration", "object_declaration", "type_alias"]); +const scopeTypes = new Set(["source_file", "statements", "function_declaration", "lambda_literal", "class_declaration", "object_declaration", "object_literal", "companion_object"]); +interface Location { path: string; line: number } +interface Definition extends Location { qualified: string; private: boolean } +interface Import { qualified: string; name: string; star: boolean } +interface Blocker { name: string; start: number; end: number; from: number; type: boolean; target?: string } +interface Occurrence extends Location { + name: string; column: number; endColumn: number; parts: string[]; + direct?: string; blocked: boolean; type: boolean; declaration: boolean; +} +interface Source { + path: string; packageName: string; imports: Import[]; definitions: Definition[]; + occurrences: Occurrence[]; values: string[]; partial: boolean; +} +export interface KotlinTypeReferences { + definition: Location; usages: Location[]; truncated: boolean; partial: boolean; +} +let active = 0; +const nameOf = (node: Node) => node.text.replace(/^`|`$/g, ""); +const qualify = (parent: string, name: string) => parent ? `${parent}.${name}` : name; + +function ancestor(node: Node | null, predicate: (node: Node) => boolean): Node | undefined { + for (let current = node; current; current = current.parent) if (predicate(current)) return current; +} + +function scope(node: Node): Node | undefined { + return ancestor(node.parent, candidate => scopeTypes.has(candidate.type)); +} + +function importInfo(header: Node): Import | undefined { + if (header.hasError) return; + const identifier = header.namedChildren.find(child => child?.type === "identifier"); + if (!identifier) return; + const parts = identifier.namedChildren.filter((child): child is Node => child?.type === "simple_identifier").map(nameOf); + const alias = header.namedChildren.find(child => child?.type === "import_alias")?.namedChild(0); + return { qualified: parts.join("."), name: alias ? nameOf(alias) : parts.at(-1)!, star: header.namedChildren.some(child => child?.type === "wildcard_import") }; +} + +function navigationParts(node: Node): string[] | undefined { + if (node.type === "simple_identifier") return [nameOf(node)]; + if (node.type !== "navigation_expression") return; + const receiver = node.namedChild(0), suffix = node.namedChild(1); + if (!receiver || suffix?.type !== "navigation_suffix" || suffix.child(0)?.text !== "." || suffix.namedChild(0)?.type !== "simple_identifier") return; + const prefix = navigationParts(receiver); + return prefix ? [...prefix, nameOf(suffix.namedChild(0)!)] : undefined; +} + +function parseSource(parser: Parser, path: string, content: string): Source | undefined { + if (Buffer.byteLength(content) > SEMANTIC_LIMITS.bytes || content.split("\n").length > SEMANTIC_LIMITS.lines) return; + const tree = parseWithin(parser, content, SEMANTIC_LIMITS.parseMicros); + if (!tree) return; + try { + const root = tree.rootNode; + const packageHeader = root.namedChildren.find(child => child?.type === "package_header"); + if (packageHeader?.hasError) return; + const packageName = packageHeader?.namedChild(0)?.namedChildren.filter((child): child is Node => child?.type === "simple_identifier").map(nameOf).join(".") ?? ""; + const source: Source = { path, packageName, imports: [], definitions: [], occurrences: [], values: [], partial: root.hasError }; + const nodes: Node[] = [], blockers: Blocker[] = []; + const stack = [{ node: root, depth: 0 }]; + let visited = 0; + while (stack.length) { + const { node, depth } = stack.pop()!; + if (++visited > SEMANTIC_LIMITS.nodes || depth > SEMANTIC_LIMITS.depth) return; + if (node.type === "import_header") { + const entry = importInfo(node); + if (entry) source.imports.push(entry); + } + if (node.type === "simple_identifier" || node.type === "type_identifier") nodes.push(node); + for (const child of node.namedChildren) if (child) stack.push({ node: child, depth: depth + 1 }); + } + for (const node of nodes) { + const parent = node.parent!; + const name = nameOf(node); + if (typeDeclarations.has(parent.type) && parent.namedChildren.find(child => child?.type === "type_identifier" || child?.type === "simple_identifier")?.id === node.id) { + const owner = scope(parent); + if (!owner) continue; + const target = owner.type === "source_file" ? qualify(packageName, name) : undefined; + blockers.push({ name, start: owner.startIndex, end: owner.endIndex, from: owner.type === "statements" ? parent.startIndex : 0, type: true, target }); + if (target) source.definitions.push({ path, line: node.startPosition.row + 1, qualified: target, private: parent.namedChildren.some(child => child?.type === "modifiers" && /\bprivate\b/.test(child.text)) }); + } else if (parent.type === "type_parameter") { + const owner = parent.parent?.parent; + if (owner) blockers.push({ name, start: owner.startIndex, end: owner.endIndex, from: 0, type: true }); + } else if (node.type === "simple_identifier") { + const value = kotlinLexicalDeclaration(node); + if (!value) continue; + blockers.push({ name, start: value.scope.startIndex, end: value.scope.endIndex, from: value.visibleFrom, type: false }); + if (value.scope.type === "source_file") source.values.push(qualify(packageName, name)); + } + } + const blockersByName = new Map(); + for (const block of blockers) { + const entries = blockersByName.get(block.name) ?? []; + entries.push(block); + blockersByName.set(block.name, entries); + } + for (const node of nodes) { + const parent = node.parent!; + if (ancestor(node, current => current.type === "ERROR" || current.isMissing)) continue; + const name = nameOf(node); + let parts = [name], direct: string | undefined, declaration = false; + const type = node.type === "type_identifier"; + const header = ancestor(parent, current => current.type === "import_header"); + if (header) { + const info = importInfo(header); + const identifier = header.namedChildren.find(child => child?.type === "identifier"); + if (!info || info.star || (identifier?.lastNamedChild?.id !== node.id && parent.type !== "import_alias")) continue; + direct = info.qualified; + } else if (typeDeclarations.has(parent.type) && source.definitions.some(def => def.qualified === qualify(packageName, name) && def.line === node.startPosition.row + 1)) { + direct = qualify(packageName, name); + declaration = true; + } else if (type && parent.type === "user_type") { + const prefix = parent.namedChildren.filter((child): child is Node => !!child && child.endIndex <= node.endIndex); + if (prefix.some(child => child.type !== "type_identifier")) continue; // Nested generic receivers need type resolution. + parts = prefix.map(nameOf); + } else if (type && parent.type === "callable_reference") { + if (parent.namedChildCount !== 1) continue; + } else if (node.type === "simple_identifier") { + if (parent.type === "navigation_suffix") { + const navigation = parent.parent; + parts = navigation ? navigationParts(navigation) ?? [] : []; + if (!parts.length) continue; + } else if (!((parent.type === "call_expression" || parent.type === "navigation_expression") && parent.namedChild(0)?.id === node.id) + && !(parent.type === "callable_reference" && parent.namedChildCount === 1)) continue; + } else continue; + if (!direct && ancestor(parent, current => ["property_declaration", "function_declaration"].includes(current.type) && current.hasError)) continue; + const local = (blockersByName.get(parts[0]!) ?? []).filter(block => block.start <= node.startIndex && node.endIndex <= block.end && block.from <= node.startIndex && (!type || block.type)); + // A top-level type is a candidate; all other lexical bindings shadow it. + const blocked = !direct && local.some(block => !block.target); + source.occurrences.push({ path, name, line: node.startPosition.row + 1, column: node.startPosition.column + 1, endColumn: node.endPosition.column + 1, parts, direct, blocked, type, declaration }); + } + return source; + } finally { tree.delete(); } +} + +/** Resolve top-level Kotlin types from current workspace syntax and imports. */ +export async function kotlinTypeReferences( + paths: readonly string[], read: (path: string) => Promise, + request: { path: string; symbol: string; line: number; column?: number }, +): Promise<{ result?: KotlinTypeReferences; notice?: string }> { + if (active >= SEMANTIC_LIMITS.requests) return { notice: "Built-in Kotlin lookup is busy; try again." }; + active++; + let parser: Parser | undefined; + try { + parser = await languageParser("kotlin"); + const selectedContent = await read(request.path); + const selected = parseSource(parser, request.path, selectedContent); + const positions = selected?.occurrences.filter(node => node.name === request.symbol && node.line === request.line && (request.column === undefined || (node.column <= request.column && request.column < node.endColumn))) ?? []; + if (!selected || positions.length !== 1 || positions[0]!.blocked) return {}; + const position = positions[0]!; + const sources = [selected]; + let bytes = Buffer.byteLength(selectedContent), count = 1, truncated = false, partial = selected.partial; + const deadline = performance.now() + LIMITS.millis; + // Filter before spending the budget: PNGs and other non-Kotlin assets must + // not crowd out declarations. Prefer the likely declaration filename. + const imported = selected.imports.find(entry => !entry.star && entry.name === request.symbol); + const likelyName = (position.direct ?? imported?.qualified ?? request.symbol).split(".").at(-1)!; + const candidates = [...new Set(paths)].filter(path => /\.kts?$/i.test(path) && path !== request.path) + .sort((a, b) => Number(b.split("/").at(-1) === `${likelyName}.kt`) - Number(a.split("/").at(-1) === `${likelyName}.kt`)); + for (const path of candidates) { + if (count >= LIMITS.files || bytes >= LIMITS.bytes || performance.now() >= deadline) { truncated = true; break; } + count++; + let content: string; + try { content = await read(path); } catch { partial = true; continue; } + bytes += Buffer.byteLength(content); + if (bytes > LIMITS.bytes) { truncated = true; break; } + const source = parseSource(parser, path, content); + if (!source) { partial = true; continue; } + sources.push(source); + partial ||= source.partial; + } + const definitions = new Map(); + const values = new Set(sources.flatMap(source => source.values)); + for (const source of sources) for (const def of source.definitions) definitions.set(def.qualified, [...(definitions.get(def.qualified) ?? []), def]); + const lookup = (qualified: string, source: Source, occurrence: Occurrence): Definition | undefined => { + const matches = (definitions.get(qualified) ?? []).filter(def => !def.private || def.path === source.path); + if (matches.length !== 1 || (!occurrence.type && !occurrence.direct && values.has(qualified))) return; + return matches[0]; + }; + const resolve = (source: Source, occurrence: Occurrence): Definition | undefined => { + if (occurrence.blocked) return; + if (occurrence.direct) return lookup(occurrence.direct, source, occurrence); + if (occurrence.parts.length > 1) return lookup(occurrence.parts.join("."), source, occurrence); + const imports = source.imports.filter(entry => !entry.star && entry.name === occurrence.name); + if (imports.length) return imports.length === 1 ? lookup(imports[0]!.qualified, source, occurrence) : undefined; + const samePackage = qualify(source.packageName, occurrence.name); + // An alias removes the original unqualified name, including in its own package. + if (source.imports.some(entry => !entry.star && entry.qualified === samePackage && entry.name !== occurrence.name)) return; + if (definitions.has(samePackage) || (!occurrence.type && values.has(samePackage))) return lookup(samePackage, source, occurrence); + const stars = source.imports.filter(entry => entry.star); + // Multiple wildcard scopes may contain dependency types outside this index. + return stars.length === 1 ? lookup(qualify(stars[0]!.qualified, occurrence.name), source, occurrence) : undefined; + }; + const target = resolve(selected, position); + if (!target) return { notice: "Built-in Kotlin lookup could not identify a unique top-level type from packages and imports; the name may be ambiguous or require compiler resolution." }; + const usages = new Map(); + for (const source of sources) for (const occurrence of source.occurrences) { + if (occurrence.declaration || resolve(source, occurrence) !== target) continue; + usages.set(`${occurrence.path}:${occurrence.line}`, { path: occurrence.path, line: occurrence.line }); + } + const locations = [...usages.values()].sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line); + return { result: { definition: { path: target.path, line: target.line }, usages: locations.slice(0, LIMITS.usages), truncated: truncated || locations.length > LIMITS.usages, partial } }; + } catch { + return { notice: "Built-in Kotlin lookup could not parse the selected source." }; + } finally { + parser?.delete(); + active--; + } +} diff --git a/src/symbol-peek.ts b/src/symbol-peek.ts index f6e2230..adb4b26 100644 --- a/src/symbol-peek.ts +++ b/src/symbol-peek.ts @@ -5,6 +5,7 @@ import { loadRepositoryFile } from "./git.ts"; import { ReviewError } from "./errors.ts"; import { runProcess } from "./process.ts"; import { kotlinReferences } from "./kotlin-references.ts"; +import { kotlinTypeReferences } from "./kotlin-type-references.ts"; import type { GitComparison } from "./types.ts"; const MAX_SYMBOL_LENGTH = 200; @@ -471,6 +472,20 @@ export async function resolveSymbolPeek(comparison: GitComparison, input: Symbol if (languageServer.result) return withPartialSymbolSuggestions(comparison, request, languageServer.result); const kotlin = await kotlinSyntaxResult(comparison, request, languageServer.notice); if (kotlin) return kotlin; + let kotlinNotice: string | undefined; + if (request.path && request.line && /\.kts?$/i.test(request.path) && request.side !== "old") { + const types = await kotlinTypeReferences(searchPaths(comparison, request), path => loadRepositoryFile(comparison, path, MAX_FILE_BYTES), { ...request, path: request.path, line: request.line }); + if (types.result) { + const result = types.result; + const locations = await materializeLocations(comparison, [ + { ...result.definition, kind: "definition" as const }, + ...result.usages.map(location => ({ ...location, kind: "usage" as const })), + ]); + return sourceResult(request.symbol, "kotlin-syntax", locations.filter(location => location.kind === "definition"), locations.filter(location => location.kind === "usage"), + `Kotlin type candidates across repository files, following packages and imports, including aliases. Compiler type inference, dependency sources and KMP source-set selection are not available.${result.partial ? " Some Kotlin files could not be fully parsed or read; results may be incomplete." : ""}`, result.truncated); + } + kotlinNotice = types.notice ?? "Built-in Kotlin lookup could not resolve this source position or symbol kind."; + } const search = await searchRepositoryAsync(comparison, request); const definitions: SymbolPeekLocation[] = []; const usages: SymbolPeekLocation[] = []; @@ -479,6 +494,6 @@ export async function resolveSymbolPeek(comparison: GitComparison, input: Symbol const fallback = matches.length ? "CodeGraph and a language server were unavailable; results come from a bounded repository search." : "No definition or usage was found. CodeGraph and a language server were unavailable, and repository search was bounded."; - const notice = `${languageServer.notice ?? ""} ${fallback}`.trim(); + const notice = [kotlinNotice, languageServer.notice, fallback].filter(Boolean).join(" "); return withPartialSymbolSuggestions(comparison, request, sourceResult(request.symbol, "repository-search", definitions, usages, notice, search.truncated)); } diff --git a/tests/kotlin-type-references.test.ts b/tests/kotlin-type-references.test.ts new file mode 100644 index 0000000..d38554d --- /dev/null +++ b/tests/kotlin-type-references.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { kotlinTypeReferences } from "../src/kotlin-type-references.ts"; + +function lookup(sources: Record, path = "Model.kt", symbol = "Model", line = 2, column?: number) { + return kotlinTypeReferences(Object.keys(sources), async name => { + if (!(name in sources)) throw new Error("Missing fixture"); + return sources[name]!; + }, { path, symbol, line, column }); +} + +test("Kotlin type candidates follow one wildcard scope, explicit imports, class literals and constructor references", async () => { + const { result } = await lookup({ + "Model.kt": "package domain\nclass Model\n", + "Use.kt": "package ui\nimport domain.*\nfun make() = Model()\nval ref = ::Model\nval type = Model::class\nval member = Model.Companion\n", + "Ambiguous.kt": "package ui\nimport domain.*\nimport external.*\nfun make() = Model()\n", + "Explicit.kt": "package ui\nimport domain.Model\nimport external.*\nfun make() = Model()\n", + "Hidden.kt": "package ui\nimport external.Model\nimport domain.*\nfun make() = Model()\n", + }); + expect(result?.usages).toEqual([ + { path: "Explicit.kt", line: 2 }, { path: "Explicit.kt", line: 4 }, + ...[3, 4, 5, 6].map(line => ({ path: "Use.kt", line })), + ]); +}); + +test("nested types, type parameters, callable bindings and qualified receiver values cannot become type references", async () => { + const { result } = await lookup({ + "Model.kt": "package domain\nclass Model\n", + "Use.kt": [ + "package ui", "import domain.Model", + "fun identity(value: Model) = value", + "class Outer {", " class Model", " fun make() = Model()", "}", + "fun shadow(Model: () -> Unit) = Model()", + "fun qualified(domain: Other) = domain.Model()", + "fun local() { typealias Model = String; val value: Model = \"\" }", + "fun make(value: Model) = value", + ].join("\n"), + }); + expect(result?.usages).toEqual([{ path: "Use.kt", line: 2 }, { path: "Use.kt", line: 11 }]); +}); + +test("private types stay in their file and aliases hide the original unqualified name", async () => { + const sources = { + "Model.kt": "package domain\nprivate class Model\nfun make() = Model()\n", + "Other.kt": "package domain\nfun bad() = Model()\n", + }; + expect((await lookup(sources)).result?.usages).toEqual([{ path: "Model.kt", line: 3 }]); + expect((await lookup(sources, "Other.kt", "Model", 2)).result).toBeUndefined(); + const { result } = await lookup({ + "Model.kt": "package domain\nclass Model\n", + "Alias.kt": "package domain\nimport domain.Model as Renamed\nfun bad() = Model()\nfun good() = Renamed()\n", + }); + expect(result?.usages).toEqual([{ path: "Alias.kt", line: 2 }, { path: "Alias.kt", line: 4 }]); +}); + +test("type selection uses UTF-16 columns and refuses strings, comments and ambiguous positions", async () => { + const line = 'fun use() { println("🚀"); Model(); other.Model() }'; + const sources = { "Model.kt": `package domain\nclass Model\n${line}\n// Model\nval text = "Model"\n` }; + const result = await lookup(sources, "Model.kt", "Model", 3, line.indexOf("Model") + 1); + expect(result.result?.definition).toEqual({ path: "Model.kt", line: 2 }); + for (const [row, column] of [[3, undefined], [3, line.lastIndexOf("Model") + 1], [4, undefined], [5, undefined]]) { + expect((await lookup(sources, "Model.kt", "Model", row!, column)).result).toBeUndefined(); + } +}); + +test("bounded Kotlin indexing marks partial files and caps results and source scans", async () => { + const result = await lookup({ + "Model.kt": `package domain\nclass Model\n${Array.from({ length: 60 }, (_, i) => `fun use${i}() = Model()`).join("\n")}\n`, + "Broken.kt": "package domain\nfun (", + }); + expect(result.result?.usages).toHaveLength(49); + expect(result.result?.truncated).toBe(true); + expect(result.result?.partial).toBe(true); + const bounded = await lookup({ + "Model.kt": "package domain\nclass Model\n", + ...Object.fromEntries(Array.from({ length: 501 }, (_, i) => [`Empty${i}.kt`, "// empty\n"])), + }); + expect(bounded.result?.definition).toEqual({ path: "Model.kt", line: 2 }); + expect(bounded.result?.truncated).toBe(true); +}); diff --git a/tests/symbol-peek.test.ts b/tests/symbol-peek.test.ts index b5df857..aa98ccf 100644 --- a/tests/symbol-peek.test.ts +++ b/tests/symbol-peek.test.ts @@ -109,6 +109,49 @@ function comparison(root: string, repositoryFiles: string[]): GitComparison { }; } +test("Kotlin classes resolve across packages, imports and aliases without an external server", async () => { + const root = await mkdtemp(join(import.meta.dir, "symbol-peek-")); roots.push(root); + process.env.PATH = root; + const sources: Record = { + "LeaveAllowance.kt": "package leave\ninterface Repository { fun get(): LeaveAllowance? }\ndata class LeaveAllowance(val days: Int)\n", + "Mapping.kt": "package dashboard\nimport leave.LeaveAllowance\nfun map(value: LeaveAllowance) = LeaveAllowance(2)\nfun LeaveAllowance.remaining() = days\n", + "Alias.kt": "package dashboard\nimport leave.LeaveAllowance as Allowance\nfun make(): Allowance = Allowance(3)\n", + "SamePackage.kt": "package leave\nfun make() = LeaveAllowance(4)\n", + "Qualified.kt": "package elsewhere\nfun make(): leave.LeaveAllowance = leave.LeaveAllowance(5)\n", + "Unrelated.kt": "package unrelated\nclass LeaveAllowance\nfun make() = LeaveAllowance()\n", + "OtherImport.kt": "package dashboard\nimport unrelated.LeaveAllowance\nfun make() = LeaveAllowance()\n", + "Noise.kt": 'package dashboard\nimport leave.LeaveAllowance\n// LeaveAllowance\nval text = "LeaveAllowance"\nfun named() = call(LeaveAllowance = 1)\nfun member(other: Other) = other.LeaveAllowance()\nfun generic(value: LeaveAllowance) = value\nfun shadow(LeaveAllowance: () -> Unit) = LeaveAllowance()\nfun local() { class LeaveAllowance; LeaveAllowance() }\n', + }; + await Promise.all(Object.entries(sources).map(([path, source]) => writeFile(join(root, path), source))); + // Screenshot assets must not exhaust the Kotlin source budget. + const reviewed = comparison(root, [...Array.from({ length: 250 }, (_, i) => `image-${i}.png`), ...Object.keys(sources)]); + for (const request of [ + { symbol: "LeaveAllowance", path: "LeaveAllowance.kt", line: 3 }, + { symbol: "LeaveAllowance", path: "Mapping.kt", line: 3, column: 16 }, + { symbol: "Allowance", path: "Alias.kt", line: 3, column: 13 }, + ]) { + const result = await resolveSymbolPeek(reviewed, request); + expect(result.source).toBe("kotlin-syntax"); + expect(result.definitions.map(({ path, line }) => ({ path, line }))).toEqual([{ path: "LeaveAllowance.kt", line: 3 }]); + expect(result.usages.map(({ path, line }) => `${path}:${line}`).sort()).toEqual([ + "LeaveAllowance.kt:2", "Mapping.kt:2", "Mapping.kt:3", "Mapping.kt:4", "Alias.kt:2", "Alias.kt:3", "SamePackage.kt:2", "Qualified.kt:2", "Noise.kt:2", + ].sort()); + expect(result.notice).toContain("packages and imports"); + expect(result.truncated).toBe(false); + } +}); + +test("Kotlin class lookup leaves duplicate KMP declarations ambiguous and labels the fallback", async () => { + const root = await mkdtemp(join(import.meta.dir, "symbol-peek-")); roots.push(root); + process.env.PATH = root; + await writeFile(join(root, "common.kt"), "package leave\nexpect class LeaveAllowance\n"); + await writeFile(join(root, "actual.kt"), "package leave\nactual class LeaveAllowance\n"); + const result = await resolveSymbolPeek(comparison(root, ["common.kt", "actual.kt"]), { symbol: "LeaveAllowance", path: "common.kt", line: 2 }); + expect(result.source).toBe("repository-search"); + expect(result.notice).toContain("Built-in Kotlin lookup"); + expect(result.notice).toContain("ambiguous"); +}); + test("symbol peek returns definitions and usages from a bounded repository search", async () => { const root = await mkdtemp(join(import.meta.dir, "symbol-peek-")); roots.push(root);