feat(ingestion): add Kotlin scope resolver (#1727)

* feat(ingestion): add Kotlin scope resolver

* fix(ingestion): tighten Kotlin scope captures

---------

Co-authored-by: Shining <xuenning@qiyi.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
MyShining 2026-05-21 15:52:23 +08:00 committed by GitHub
parent c34c36036f
commit 1b5c6e5b6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1250 additions and 1 deletions

View file

@ -29,6 +29,16 @@ import { kotlinMethodConfig } from '../method-extractors/configs/jvm.js';
import { createVariableExtractor } from '../variable-extractors/generic.js';
import { kotlinVariableConfig } from '../variable-extractors/configs/jvm.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import {
emitKotlinScopeCaptures,
interpretKotlinImport,
interpretKotlinTypeBinding,
kotlinArityCompatibility,
kotlinBindingScopeFor,
kotlinImportOwningScope,
kotlinMergeBindings,
kotlinReceiverBinding,
} from './kotlin/index.js';
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
@ -166,4 +176,14 @@ export const kotlinProvider = defineLanguage({
if (isKotlinClassMethod(functionNode)) return 'Method';
return defaultLabel;
},
// ── RFC #909 Ring 3: scope-based resolution hooks ──
emitScopeCaptures: emitKotlinScopeCaptures,
interpretImport: interpretKotlinImport,
interpretTypeBinding: interpretKotlinTypeBinding,
bindingScopeFor: kotlinBindingScopeFor,
importOwningScope: kotlinImportOwningScope,
mergeBindings: (_scope, bindings) => kotlinMergeBindings(bindings),
receiverBinding: kotlinReceiverBinding,
arityCompatibility: kotlinArityCompatibility,
});

View file

@ -0,0 +1,26 @@
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { kotlinMethodConfig } from '../../method-extractors/configs/jvm.js';
export interface KotlinArityMetadata {
readonly parameterCount: number | undefined;
readonly requiredParameterCount: number | undefined;
readonly parameterTypes: readonly string[] | undefined;
}
export function computeKotlinArityMetadata(fnNode: SyntaxNode): KotlinArityMetadata {
const params = kotlinMethodConfig.extractParameters?.(fnNode) ?? [];
let hasVararg = false;
const parameterTypes: string[] = [];
for (const param of params) {
if (param.isVariadic) hasVararg = true;
if (param.type !== null) parameterTypes.push(param.type);
}
if (hasVararg) parameterTypes.push('vararg');
const required = params.filter((p) => !p.isOptional && !p.isVariadic).length;
return {
parameterCount: hasVararg ? undefined : params.length,
requiredParameterCount: required,
parameterTypes: parameterTypes.length > 0 ? parameterTypes : undefined,
};
}

View file

@ -0,0 +1,18 @@
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
export function kotlinArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const min = def.requiredParameterCount;
const max = def.parameterCount;
if (min === undefined && max === undefined) return 'unknown';
const argCount = callsite.arity;
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
const hasVararg = def.parameterTypes?.some((t) => t === 'vararg') ?? false;
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasVararg) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,19 @@
let hits = 0;
let misses = 0;
export function recordKotlinCacheHit(): void {
hits += 1;
}
export function recordKotlinCacheMiss(): void {
misses += 1;
}
export function getKotlinCaptureCacheStats(): { readonly hits: number; readonly misses: number } {
return { hits, misses };
}
export function resetKotlinCaptureCacheStats(): void {
hits = 0;
misses = 0;
}

View file

@ -0,0 +1,484 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import {
findNodeAtRange,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { computeKotlinArityMetadata } from './arity-metadata.js';
import { splitKotlinImportHeader } from './import-decomposer.js';
import { recordKotlinCacheHit, recordKotlinCacheMiss } from './cache-stats.js';
import { normalizeKotlinType } from './interpret.js';
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
import { getKotlinParser, getKotlinScopeQuery } from './query.js';
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
export function emitKotlinScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getKotlinParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getKotlinParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
recordKotlinCacheMiss();
} else {
recordKotlinCacheHit();
}
const out: CaptureMatch[] = [];
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
for (const match of getKotlinScopeQuery().matches(tree.rootNode)) {
const grouped: Record<string, Capture> = {};
for (const capture of match.captures) {
const tag = '@' + capture.name;
grouped[tag] = nodeToCapture(tag, capture.node);
}
if (Object.keys(grouped).length === 0) continue;
if (grouped['@import.statement'] !== undefined) {
const importNode = findNodeAtRange(
tree.rootNode,
grouped['@import.statement']!.range,
'import_header',
);
if (importNode !== null) {
const decomposed = splitKotlinImportHeader(importNode);
if (decomposed !== null) {
out.push(decomposed);
continue;
}
}
}
if (
grouped['@reference.call.free'] !== undefined &&
grouped['@reference.receiver'] !== undefined
) {
continue;
}
if (grouped['@reference.read.member'] !== undefined) {
const anchor = grouped['@reference.read.member']!;
const navNode = findNodeAtRange(tree.rootNode, anchor.range, 'navigation_expression');
if (navNode === null || !shouldEmitReadMember(navNode)) continue;
}
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const fnNode = findNodeAtRange(
tree.rootNode,
grouped['@scope.function']!.range,
'function_declaration',
);
if (fnNode !== null) {
out.push(...synthesizeKotlinReceiverBinding(fnNode));
}
continue;
}
const declTag = FUNCTION_DECL_TAGS.find((tag) => grouped[tag] !== undefined);
if (declTag !== undefined) {
const fnNode = findNodeAtRange(
tree.rootNode,
grouped[declTag]!.range,
'function_declaration',
);
if (fnNode !== null) {
const arity = computeKotlinArityMetadata(fnNode);
if (arity.parameterCount !== undefined) {
grouped['@declaration.parameter-count'] = syntheticCapture(
'@declaration.parameter-count',
fnNode,
String(arity.parameterCount),
);
}
if (arity.requiredParameterCount !== undefined) {
grouped['@declaration.required-parameter-count'] = syntheticCapture(
'@declaration.required-parameter-count',
fnNode,
String(arity.requiredParameterCount),
);
}
if (arity.parameterTypes !== undefined) {
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
}
}
const callTag = (
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
).find((tag) => grouped[tag] !== undefined);
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
const callNode = findNodeAtRange(tree.rootNode, grouped[callTag]!.range, 'call_expression');
if (callNode !== null) {
const args = callArguments(callNode);
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(args.length),
);
grouped['@reference.parameter-types'] = syntheticCapture(
'@reference.parameter-types',
callNode,
JSON.stringify(args.map(inferArgType)),
);
}
}
out.push(grouped);
const extensionFallback = extensionFreeCallFallback(grouped, tree.rootNode);
if (extensionFallback !== null) out.push(extensionFallback);
}
return out;
}
function synthesizeKotlinLoopBindings(
rootNode: SyntaxNode,
returnTypes: ReadonlyMap<string, string>,
): CaptureMatch[] {
const out: CaptureMatch[] = [];
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
const localTypes = collectKotlinLocalTypeTexts(fnNode, returnTypes);
for (const forNode of descendantsOfType(fnNode, 'for_statement')) {
const variable = forNode.namedChildren.find((child) => child.type === 'variable_declaration');
const name = variable?.namedChildren.find((child) => child.type === 'simple_identifier');
if (variable === undefined || name === undefined) continue;
const explicitType = variable.namedChildren.find((child) => isKotlinTypeNode(child));
const iterable = forNode.namedChildren.find(
(child) => child.id !== variable.id && child.type !== 'control_structure_body',
);
const rawType =
explicitType?.text ??
(iterable === undefined
? null
: inferKotlinIterableElementType(iterable, localTypes, returnTypes));
if (rawType === null || rawType.trim() === '') continue;
const anchor =
forNode.namedChildren.find((child) => child.type === 'control_structure_body') ?? forNode;
out.push({
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', anchor),
'@type-binding.name': syntheticCapture('@type-binding.name', name, name.text),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
explicitType ?? iterable ?? name,
normalizeKotlinType(rawType),
),
});
}
}
return out;
}
function synthesizeKotlinLocalAssignmentBindings(
rootNode: SyntaxNode,
returnTypes: ReadonlyMap<string, string>,
): CaptureMatch[] {
const out: CaptureMatch[] = [];
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
const localTypes = new Map<string, string>();
for (const prop of descendantsOfType(fnNode, 'property_declaration')) {
const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes);
if (inferred === null) continue;
localTypes.set(inferred.name.text, inferred.rawType);
if (inferred.synthetic) {
out.push({
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', prop),
'@type-binding.name': syntheticCapture(
'@type-binding.name',
inferred.name,
inferred.name.text,
),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
inferred.source,
normalizeKotlinType(inferred.rawType),
),
});
}
}
}
return out;
}
function collectKotlinLocalTypeTexts(
fnNode: SyntaxNode,
returnTypes: ReadonlyMap<string, string>,
): Map<string, string> {
const out = new Map<string, string>();
for (const node of descendants(fnNode)) {
if (node.type === 'parameter') {
const name = descendantsOfType(node, 'simple_identifier')[0];
const type = node.namedChildren.find((child) => isKotlinTypeNode(child));
if (name !== undefined && type !== undefined) out.set(name.text, type.text);
continue;
}
if (node.type === 'property_declaration') {
const inferred = inferKotlinPropertyType(node, out, returnTypes);
if (inferred !== null) out.set(inferred.name.text, inferred.rawType);
}
}
return out;
}
function collectKotlinReturnTypeTexts(rootNode: SyntaxNode): Map<string, string> {
const out = new Map<string, string>();
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
const name = fnNode.namedChildren.find((child) => child.type === 'simple_identifier');
const paramsIndex = fnNode.namedChildren.findIndex(
(child) => child.type === 'function_value_parameters',
);
const type =
paramsIndex < 0
? undefined
: fnNode.namedChildren.slice(paramsIndex + 1).find((child) => isKotlinTypeNode(child));
if (name !== undefined && type !== undefined) out.set(name.text, type.text);
}
return out;
}
function inferKotlinPropertyType(
prop: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
returnTypes: ReadonlyMap<string, string>,
): { name: SyntaxNode; rawType: string; source: SyntaxNode; synthetic: boolean } | null {
const variable = prop.namedChildren.find((child) => child.type === 'variable_declaration');
const name = variable?.namedChildren.find((child) => child.type === 'simple_identifier');
if (variable === undefined || name === undefined) return null;
const explicitType = variable.namedChildren.find((child) => isKotlinTypeNode(child));
if (explicitType !== undefined) {
return { name, rawType: explicitType.text, source: explicitType, synthetic: false };
}
const value = prop.namedChildren.find(
(child) => child.id !== variable.id && child.type !== 'binding_pattern_kind',
);
if (value?.type === 'simple_identifier') {
const rawType = localTypes.get(value.text);
return rawType === undefined ? null : { name, rawType, source: value, synthetic: true };
}
if (value?.type === 'call_expression') {
const callee = value.namedChildren.find((child) => child.type === 'simple_identifier');
if (callee === undefined) return null;
const rawType =
returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
if (rawType === null) return null;
return { name, rawType, source: callee, synthetic: true };
}
return null;
}
function inferKotlinIterableElementType(
iterable: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
returnTypes: ReadonlyMap<string, string>,
): string | null {
if (iterable.type === 'simple_identifier') {
const raw = localTypes.get(iterable.text);
return raw === undefined ? null : kotlinContainerElementType(raw, 'values');
}
if (iterable.type === 'navigation_expression') {
const receiver = iterable.namedChildren[0];
const member = iterable.namedChildren
.find((child) => child.type === 'navigation_suffix')
?.namedChildren.find((child) => child.type === 'simple_identifier')?.text;
if (receiver?.type !== 'simple_identifier') return null;
const raw = localTypes.get(receiver.text);
return raw === undefined ? null : kotlinContainerElementType(raw, member ?? 'values');
}
if (iterable.type === 'call_expression') {
const callee = iterable.namedChildren.find((child) => child.type === 'simple_identifier');
if (callee === undefined) return null;
const raw = returnTypes.get(callee.text);
return raw === undefined ? null : kotlinContainerElementType(raw, 'values');
}
return null;
}
function isUppercaseName(text: string): boolean {
return /^[A-Z]/.test(text);
}
function kotlinContainerElementType(rawType: string, member: string): string | null {
const parsed = parseKotlinGeneric(rawType);
if (parsed === null) return normalizeKotlinType(rawType);
const base = parsed.base.split('.').pop() ?? parsed.base;
if (isKotlinMapType(base)) {
if (member === 'keys') return parsed.args[0] ?? null;
return parsed.args[1] ?? null;
}
if (isKotlinIterableType(base)) return parsed.args[0] ?? null;
return normalizeKotlinType(rawType);
}
function parseKotlinGeneric(text: string): { base: string; args: string[] } | null {
const trimmed = text.trim().replace(/\?$/, '');
const open = trimmed.indexOf('<');
const close = trimmed.lastIndexOf('>');
if (open < 0 || close < open) return null;
return {
base: trimmed.slice(0, open).trim(),
args: splitTopLevelKotlinArgs(trimmed.slice(open + 1, close)),
};
}
function splitTopLevelKotlinArgs(text: string): string[] {
const out: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '<') depth++;
else if (ch === '>') depth--;
else if (ch === ',' && depth === 0) {
out.push(text.slice(start, i).trim());
start = i + 1;
}
}
out.push(text.slice(start).trim());
return out.filter((arg) => arg.length > 0);
}
function isKotlinMapType(base: string): boolean {
return ['Map', 'MutableMap', 'HashMap', 'LinkedHashMap'].includes(base);
}
function isKotlinIterableType(base: string): boolean {
return [
'List',
'MutableList',
'ArrayList',
'Set',
'MutableSet',
'Collection',
'Iterable',
'Sequence',
'Array',
].includes(base);
}
function isKotlinTypeNode(node: SyntaxNode): boolean {
return (
node.type === 'user_type' || node.type === 'nullable_type' || node.type === 'function_type'
);
}
function descendantsOfType(node: SyntaxNode, type: string): SyntaxNode[] {
return descendants(node).filter((child) => child.type === type);
}
function descendants(node: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child === null) continue;
out.push(child, ...descendants(child));
}
return out;
}
function shouldEmitReadMember(navNode: SyntaxNode): boolean {
const parent = navNode.parent;
if (parent === null) return true;
if (parent.type === 'call_expression') return false;
if (parent.type === 'directly_assignable_expression') return false;
return true;
}
function callArguments(callNode: SyntaxNode): SyntaxNode[] {
const suffix = callNode.namedChildren.find((child) => child.type === 'call_suffix');
if (suffix === undefined) return [];
const valueArgs = suffix?.namedChildren.find((child) => child.type === 'value_arguments');
const args = valueArgs?.namedChildren.filter((child) => child.type === 'value_argument') ?? [];
const trailingLambdas = suffix.namedChildren.filter((child) => child.type === 'annotated_lambda');
return [...args, ...trailingLambdas];
}
function inferArgType(argNode: SyntaxNode): string {
const value = argNode.namedChild(0) ?? argNode;
switch (value.type) {
case 'integer_literal':
case 'long_literal':
return 'Int';
case 'real_literal':
return 'Double';
case 'string_literal':
case 'line_string_literal':
case 'multi_line_string_literal':
return 'String';
case 'character_literal':
return 'Char';
case 'boolean_literal':
return 'Boolean';
case 'call_expression': {
const first = value.namedChild(0);
return first?.type === 'simple_identifier' ? first.text : '';
}
default:
return '';
}
}
function extensionFreeCallFallback(
grouped: Record<string, Capture>,
rootNode: SyntaxNode,
): CaptureMatch | null {
const member = grouped['@reference.call.member'];
const receiver = grouped['@reference.receiver'];
const name = grouped['@reference.name'];
if (member === undefined || receiver === undefined || name === undefined) return null;
const callNode = findNodeAtRange(rootNode, member.range, 'call_expression');
if (callNode === null) return null;
const receiverNode = findNodeAtRange(rootNode, receiver.range);
if (receiverNode === null || !isLiteralReceiver(receiverNode)) return null;
const out: Record<string, Capture> = {
'@reference.call.free': syntheticCapture('@reference.call.free', callNode, callNode.text),
'@reference.name': syntheticCapture('@reference.name', callNode, name.text),
};
if (grouped['@reference.arity'] !== undefined)
out['@reference.arity'] = grouped['@reference.arity'];
if (grouped['@reference.parameter-types'] !== undefined) {
out['@reference.parameter-types'] = grouped['@reference.parameter-types'];
}
return out;
}
function isLiteralReceiver(node: SyntaxNode): boolean {
return [
'integer_literal',
'long_literal',
'real_literal',
'string_literal',
'line_string_literal',
'multi_line_string_literal',
'character_literal',
'boolean_literal',
].includes(node.type);
}

View file

@ -0,0 +1,49 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
type KotlinImportKind = 'named' | 'alias' | 'wildcard';
interface KotlinImportSpec {
readonly kind: KotlinImportKind;
readonly source: string;
readonly name: string;
readonly alias?: string;
readonly atNode: SyntaxNode;
}
export function splitKotlinImportHeader(importNode: SyntaxNode): CaptureMatch | null {
if (importNode.type !== 'import_header') return null;
const spec = parseKotlinImport(importNode);
if (spec === null) return null;
const out: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', importNode),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
};
if (spec.alias !== undefined) {
out['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
}
return out;
}
function parseKotlinImport(node: SyntaxNode): KotlinImportSpec | null {
const identifier = node.namedChildren.find((child) => child.type === 'identifier');
if (identifier === undefined) return null;
const source = identifier.text.trim();
if (source.length === 0) return null;
const hasWildcard = node.namedChildren.some((child) => child.type === 'wildcard_import');
if (hasWildcard) {
return { kind: 'wildcard', source, name: '*', atNode: node };
}
const aliasNode = node.namedChildren.find((child) => child.type === 'import_alias');
const alias = aliasNode?.namedChildren.find((child) => child.type === 'type_identifier')?.text;
const importedName = source.split('.').pop() ?? source;
if (alias !== undefined && alias.length > 0) {
return { kind: 'alias', source, name: importedName, alias, atNode: node };
}
return { kind: 'named', source, name: importedName, atNode: node };
}

View file

@ -0,0 +1,76 @@
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
export interface KotlinResolveContext {
readonly fromFile: string;
readonly allFilePaths: ReadonlySet<string>;
}
export function resolveKotlinImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
const ctx = workspaceIndex as KotlinResolveContext | undefined;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
) {
return null;
}
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
const target = parsedImport.targetRaw.endsWith('.*')
? parsedImport.targetRaw.slice(0, -2)
: parsedImport.targetRaw;
const pathLike = target.replace(/\./g, '/');
return (
findKotlinFile(ctx.allFilePaths, pathLike) ??
findKotlinFile(ctx.allFilePaths, pathLike.split('/').slice(0, -1).join('/')) ??
findByProgressivePrefixStrip(ctx.allFilePaths, pathLike)
);
}
function findKotlinFile(allFilePaths: ReadonlySet<string>, pathLike: string): string | null {
if (pathLike === '') return null;
const extensions = ['.kt', '.kts'];
const suffix = `/${pathLike}`;
const dirPrefix = `${pathLike}/`;
const suffixDirPrefix = `/${dirPrefix}`;
let suffixFile: string | null = null;
let directoryChild: string | null = null;
for (const raw of allFilePaths) {
const file = raw.replace(/\\/g, '/');
if (!extensions.some((ext) => file.endsWith(ext))) continue;
for (const ext of extensions) {
if (file === `${pathLike}${ext}`) return raw;
if (suffixFile === null && file.endsWith(`${suffix}${ext}`)) suffixFile = raw;
}
if (directoryChild === null) {
const atRoot = file.startsWith(dirPrefix);
const atNested = file.includes(suffixDirPrefix);
if (atRoot || atNested) {
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
const after = file.slice(idx + dirPrefix.length);
if (after.length > 0 && !after.includes('/')) directoryChild = raw;
}
}
}
return suffixFile ?? directoryChild;
}
function findByProgressivePrefixStrip(
allFilePaths: ReadonlySet<string>,
pathLike: string,
): string | null {
const segments = pathLike.split('/').filter(Boolean);
for (let skip = 1; skip < segments.length; skip++) {
const found = findKotlinFile(allFilePaths, segments.slice(skip).join('/'));
if (found !== null) return found;
}
return null;
}

View file

@ -0,0 +1,12 @@
export { emitKotlinScopeCaptures } from './captures.js';
export { getKotlinCaptureCacheStats, resetKotlinCaptureCacheStats } from './cache-stats.js';
export { interpretKotlinImport, interpretKotlinTypeBinding } from './interpret.js';
export { kotlinArityCompatibility } from './arity.js';
export { resolveKotlinImportTarget, type KotlinResolveContext } from './import-target.js';
export { kotlinMergeBindings } from './merge-bindings.js';
export { populateKotlinOwners } from './owners.js';
export {
kotlinBindingScopeFor,
kotlinImportOwningScope,
kotlinReceiverBinding,
} from './simple-hooks.js';

View file

@ -0,0 +1,71 @@
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
export function interpretKotlinImport(captures: CaptureMatch): ParsedImport | null {
const kind = captures['@import.kind']?.text;
const source = captures['@import.source']?.text;
const name = captures['@import.name']?.text;
if (kind === undefined || source === undefined) return null;
switch (kind) {
case 'named':
return {
kind: 'named',
localName: name ?? source.split('.').pop() ?? source,
importedName: name ?? source.split('.').pop() ?? source,
targetRaw: source,
};
case 'alias': {
const alias = captures['@import.alias']?.text;
if (alias === undefined || name === undefined) return null;
return {
kind: 'alias',
localName: alias,
importedName: name,
alias,
targetRaw: source,
};
}
case 'wildcard':
return { kind: 'wildcard', targetRaw: source.endsWith('.*') ? source : `${source}.*` };
default:
return null;
}
}
export function interpretKotlinTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const nameCap = captures['@type-binding.name'];
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
let source: TypeRef['source'] = 'annotation';
if (captures['@type-binding.self'] !== undefined) source = 'self';
else if (captures['@type-binding.parameter'] !== undefined) source = 'parameter-annotation';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
return {
boundName: nameCap.text,
rawTypeName: normalizeKotlinType(typeCap.text),
source,
};
}
export function normalizeKotlinType(text: string): string {
let out = text.trim();
while (out.endsWith('?')) out = out.slice(0, -1).trim();
const lastDot = out.lastIndexOf('.');
if (lastDot >= 0) out = out.slice(lastDot + 1);
const collection = out.match(
/^(?:List|MutableList|ArrayList|Set|MutableSet|Collection|Iterable|Sequence|Array)<([^,<>]+)>$/,
);
if (collection !== null) return normalizeKotlinType(collection[1]!);
const map = out.match(/^(?:Map|MutableMap|HashMap|LinkedHashMap)<[^,<>]+,\s*([^,<>]+)>$/);
if (map !== null) return normalizeKotlinType(map[1]!);
const erased = out.match(/^([A-Za-z_][A-Za-z0-9_]*)<.+>$/s);
if (erased !== null) return erased[1]!;
return out;
}

View file

@ -0,0 +1,26 @@
import type { BindingRef } from 'gitnexus-shared';
function tierOf(binding: BindingRef): number {
switch (binding.origin) {
case 'local':
return 0;
case 'import':
case 'namespace':
case 'reexport':
return 1;
case 'wildcard':
return 2;
default:
return 3;
}
}
export function kotlinMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
if (bindings.length === 0) return bindings;
const best = Math.min(...bindings.map(tierOf));
const seen = new Map<string, BindingRef>();
for (const binding of bindings) {
if (tierOf(binding) === best) seen.set(binding.def.nodeId, binding);
}
return [...seen.values()];
}

View file

@ -0,0 +1,50 @@
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
export function populateKotlinOwners(parsed: ParsedFile): void {
populateClassOwnedMembers(parsed);
populateCompanionMembersOnEnclosingClass(parsed);
}
function populateCompanionMembersOnEnclosingClass(parsed: ParsedFile): void {
const scopesById = new Map<ScopeId, ParsedFile['scopes'][number]>();
for (const scope of parsed.scopes) scopesById.set(scope.id, scope);
for (const scope of parsed.scopes) {
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;
const enclosing = findEnclosingClassWithDef(parent.parent, scopesById);
if (enclosing === undefined) continue;
for (const def of scope.ownedDefs) {
if (def.ownerId !== undefined) continue;
(def as { ownerId?: string }).ownerId = enclosing.nodeId;
qualify(def, enclosing);
}
}
}
function findEnclosingClassWithDef(
start: ScopeId | null,
scopesById: ReadonlyMap<ScopeId, ParsedFile['scopes'][number]>,
): SymbolDefinition | undefined {
let current = start;
while (current !== null) {
const scope = scopesById.get(current);
if (scope === undefined) return undefined;
if (scope.kind === 'Class') {
const classDef = scope.ownedDefs.find((def) => isClassLike(def.type));
if (classDef !== undefined) return classDef;
}
current = scope.parent;
}
return undefined;
}
function qualify(def: SymbolDefinition, owner: SymbolDefinition): void {
if (def.qualifiedName === undefined || def.qualifiedName.includes('.')) return;
if (owner.qualifiedName === undefined || owner.qualifiedName.length === 0) return;
(def as { qualifiedName: string }).qualifiedName = `${owner.qualifiedName}.${def.qualifiedName}`;
}

View file

@ -0,0 +1,116 @@
import Parser from 'tree-sitter';
import Kotlin from 'tree-sitter-kotlin';
const KOTLIN_SCOPE_QUERY = `
;; Scopes
(source_file) @scope.module
(class_declaration) @scope.class
(object_declaration) @scope.class
(companion_object) @scope.class
(function_declaration) @scope.function
;; Declarations types
(class_declaration
"interface"
(type_identifier) @declaration.name) @declaration.interface
(class_declaration
"class"
(type_identifier) @declaration.name) @declaration.class
(object_declaration
(type_identifier) @declaration.name) @declaration.class
(companion_object
(type_identifier) @declaration.name) @declaration.class
(type_alias
(type_identifier) @declaration.name) @declaration.type_alias
;; Declarations functions / methods / properties
(function_declaration
(simple_identifier) @declaration.name) @declaration.function
(property_declaration
(variable_declaration
(simple_identifier) @declaration.name)) @declaration.property
(class_parameter
(binding_pattern_kind)
(simple_identifier) @declaration.name) @declaration.property
;; Imports
(import_header) @import.statement
;; Type bindings parameters
(parameter
(simple_identifier) @type-binding.name
[(user_type) (nullable_type) (function_type)] @type-binding.type) @type-binding.parameter
;; Type bindings property / local annotations
(property_declaration
(variable_declaration
(simple_identifier) @type-binding.name
[(user_type) (nullable_type) (function_type)] @type-binding.type)) @type-binding.annotation
(class_parameter
(binding_pattern_kind)
(simple_identifier) @type-binding.name
[(user_type) (nullable_type) (function_type)] @type-binding.type) @type-binding.annotation
;; Type bindings constructor-inferred val user = User(...)
(property_declaration
(variable_declaration
(simple_identifier) @type-binding.name)
(call_expression
(simple_identifier) @type-binding.type)) @type-binding.constructor
;; Type bindings return annotations after function parameters
(function_declaration
(simple_identifier) @type-binding.name
(function_value_parameters)
[(user_type) (nullable_type) (function_type)] @type-binding.type) @type-binding.return
;; References direct calls / constructor syntax
(call_expression
(simple_identifier) @reference.name) @reference.call.free
;; References member calls: obj.method()
(call_expression
(navigation_expression
(_) @reference.receiver
(navigation_suffix
(simple_identifier) @reference.name))) @reference.call.member
;; References property writes
(assignment
(directly_assignable_expression
(_) @reference.receiver
(navigation_suffix
(simple_identifier) @reference.name))
(_)) @reference.write.member
;; References property reads
(navigation_expression
(_) @reference.receiver
(navigation_suffix
(simple_identifier) @reference.name)) @reference.read.member
`;
let parser: Parser | null = null;
let query: Parser.Query | null = null;
export function getKotlinParser(): Parser {
if (parser === null) {
parser = new Parser();
parser.setLanguage(Kotlin as Parameters<Parser['setLanguage']>[0]);
}
return parser;
}
export function getKotlinScopeQuery(): Parser.Query {
if (query === null) {
query = new Parser.Query(Kotlin as Parameters<Parser['setLanguage']>[0], KOTLIN_SCOPE_QUERY);
}
return query;
}

View file

@ -0,0 +1,106 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { normalizeKotlinType } from './interpret.js';
const TYPE_DECL_NODE_TYPES = new Set([
'class_declaration',
'object_declaration',
'companion_object',
]);
export function synthesizeKotlinReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
if (fnNode.type !== 'function_declaration') return [];
const anchorNode = findFunctionBody(fnNode);
if (anchorNode === null) return [];
const extensionReceiver = extensionReceiverType(fnNode);
if (extensionReceiver !== null) {
return [buildReceiverMatch(anchorNode, 'this', extensionReceiver)];
}
const enclosingType = findEnclosingTypeDeclaration(fnNode);
if (enclosingType === null) return [];
const enclosingName = typeDeclarationName(enclosingType);
if (enclosingName === null) return [];
const out = [buildReceiverMatch(anchorNode, 'this', enclosingName)];
const superName = firstSuperclassText(enclosingType);
if (superName !== null) out.push(buildReceiverMatch(anchorNode, 'super', superName));
return out;
}
function findFunctionBody(fnNode: SyntaxNode): SyntaxNode | null {
for (let i = 0; i < fnNode.namedChildCount; i++) {
const child = fnNode.namedChild(i);
if (child?.type === 'function_body') return child;
}
return fnNode;
}
function extensionReceiverType(fnNode: SyntaxNode): string | null {
for (let i = 0; i < fnNode.namedChildCount; i++) {
const child = fnNode.namedChild(i);
if (child === null) continue;
if (child.type === 'simple_identifier') return null;
if (child.type === 'user_type' || child.type === 'nullable_type') {
return normalizeKotlinType(child.text);
}
}
return null;
}
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
let current = node.parent;
while (current !== null) {
if (TYPE_DECL_NODE_TYPES.has(current.type)) return current;
current = current.parent;
}
return null;
}
function typeDeclarationName(typeNode: SyntaxNode): string | null {
if (typeNode.type === 'companion_object') {
return (
typeNode.namedChildren.find((child) => child.type === 'type_identifier')?.text ??
enclosingNonCompanionTypeName(typeNode) ??
'Companion'
);
}
return typeNode.namedChildren.find((child) => child.type === 'type_identifier')?.text ?? null;
}
function enclosingNonCompanionTypeName(node: SyntaxNode): string | null {
let current = node.parent;
while (current !== null) {
if (current.type === 'class_declaration' || current.type === 'object_declaration') {
return current.namedChildren.find((child) => child.type === 'type_identifier')?.text ?? null;
}
current = current.parent;
}
return null;
}
function firstSuperclassText(typeNode: SyntaxNode): string | null {
if (typeNode.type !== 'class_declaration') return null;
for (const child of typeNode.namedChildren) {
if (child.type !== 'delegation_specifier') continue;
const ctor = child.namedChildren.find((n) => n.type === 'constructor_invocation');
const userType =
ctor?.namedChildren.find((n) => n.type === 'user_type') ??
child.namedChildren.find((n) => n.type === 'user_type');
const name = userType?.namedChildren.find((n) => n.type === 'type_identifier')?.text;
if (name !== undefined) return normalizeKotlinType(name);
}
return null;
}
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
const out: Record<string, Capture> = {
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
};
return out;
}

View file

@ -0,0 +1,56 @@
import { SupportedLanguages, type ParsedFile } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { kotlinProvider } from '../kotlin.js';
import {
kotlinArityCompatibility,
kotlinMergeBindings,
populateKotlinOwners,
resolveKotlinImportTarget,
type KotlinResolveContext,
} from './index.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 registry-primary parity reaches the
* RFC threshold. Forced mode currently passes 154/175 fixtures (88%),
* including core import, receiver, companion, default-param, vararg,
* constructor, local assignment-chain, and collection-iteration fixtures.
* Remaining gaps are advanced TypeEnv behaviors such as smart casts,
* cross-file iterable return propagation, method-chain fixpoint cases,
* overload target-id selection, virtual dispatch, and interface default
* method dispatch.
*/
export const kotlinScopeResolver: ScopeResolver = {
language: SupportedLanguages.Kotlin,
languageProvider: kotlinProvider,
importEdgeReason: 'kotlin-scope: import',
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
const ws: KotlinResolveContext = { fromFile, allFilePaths };
return resolveKotlinImportTarget(
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
ws,
);
},
mergeBindings: (existing, incoming) => [...kotlinMergeBindings([...existing, ...incoming])],
arityCompatibility: (callsite, def) => kotlinArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateKotlinOwners(parsed),
isSuperReceiver: (text) => text.trim() === 'super',
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: true,
collapseMemberCallsByCallerTarget: false,
hoistTypeBindingsToModule: true,
};

View file

@ -0,0 +1,36 @@
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
export function kotlinBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.return'] === undefined) return null;
let current: Scope | undefined = innermost;
while (current !== undefined && current.kind !== 'Module') {
if (current.parent === null) break;
current = tree.getScope(current.parent);
}
return current?.kind === 'Module' ? current.id : null;
}
export function kotlinImportOwningScope(
_imp: ParsedImport,
_innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
return null;
}
export function kotlinReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
return functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('super') ?? null;
}

View file

@ -20,6 +20,7 @@ import { cScopeResolver } from '../../languages/c/scope-resolver.js';
import { cppScopeResolver } from '../../languages/cpp/scope-resolver.js';
import { phpScopeResolver } from '../../languages/php/scope-resolver.js';
import { javascriptScopeResolver } from '../../languages/javascript/scope-resolver.js';
import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js';
/** Map of `SupportedLanguages` `ScopeResolver`. The phase iterates
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
@ -38,4 +39,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.CPlusPlus, cppScopeResolver],
[SupportedLanguages.PHP, phpScopeResolver],
[SupportedLanguages.JavaScript, javascriptScopeResolver],
[SupportedLanguages.Kotlin, kotlinScopeResolver],
]);

View file

@ -1,11 +1,12 @@
/**
* Kotlin: data class extends + implements interfaces + ambiguous import disambiguation
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
CROSS_FILE_FIXTURES,
createResolverParityIt,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
@ -14,6 +15,8 @@ import {
type PipelineResult,
} from './helpers.js';
const it = createResolverParityIt('kotlin');
// ---------------------------------------------------------------------------
// Heritage: data class extends + implements interfaces (delegation specifiers)
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js';
function captureTexts(source: string): Array<Record<string, string>> {
return emitKotlinScopeCaptures(source, 'fixture.kt').map((match) =>
Object.fromEntries(Object.entries(match).map(([tag, capture]) => [tag, capture.text])),
);
}
describe('Kotlin scope captures', () => {
it('counts trailing lambda call suffixes as call arguments', () => {
const captures = captureTexts(`
fun run(items: List<String>) {
items.forEach { println(it) }
}
`);
const forEach = captures.find(
(match) =>
match['@reference.call.member'] === 'items.forEach { println(it) }' &&
match['@reference.name'] === 'forEach',
);
expect(forEach?.['@reference.arity']).toBe('1');
});
it('does not synthesize extension free-call fallback for chained regular member calls', () => {
const captures = captureTexts(`
class Service {
fun current(): Service = this
fun save() {}
}
fun run(service: Service) {
service.current().save()
}
`);
const syntheticSaveFreeCalls = captures.filter(
(match) =>
match['@reference.call.free'] === 'service.current().save()' &&
match['@reference.name'] === 'save',
);
expect(syntheticSaveFreeCalls).toHaveLength(0);
});
it('keeps literal-receiver extension fallback for extension-call candidates', () => {
const captures = captureTexts(`
fun String.slug(): String = this
fun run() {
"hello".slug()
}
`);
const slugFreeCall = captures.find(
(match) =>
match['@reference.call.free'] === '"hello".slug()' && match['@reference.name'] === 'slug',
);
expect(slugFreeCall).toBeDefined();
});
it('does not emit a self type binding keyed by the extension function name', () => {
const captures = captureTexts(`
fun String.slug(): String = this
`);
const spuriousSelfBinding = captures.find(
(match) =>
match['@type-binding.self'] !== undefined &&
match['@type-binding.name'] === 'slug' &&
match['@type-binding.type'] === 'String',
);
expect(spuriousSelfBinding).toBeUndefined();
});
});