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

This commit is contained in:
henry201605 2026-05-22 17:57:42 +08:00 • committed by GitHub
commit cfc2b7d463
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 283 additions and 24 deletions

View file

@ -1,4 +1,4 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import type { Capture, CaptureMatch, Range } from 'gitnexus-shared';
import {
findNodeAtRange,
nodeToCapture,
@ -73,6 +73,30 @@ export function emitKotlinScopeCaptures(
if (navNode === null || !shouldEmitReadMember(navNode)) continue;
}
// Virtual dispatch via constructor type (#1762). When a property
// declaration carries BOTH an explicit type annotation AND a
// constructor-style call value (e.g. `val animal: Animal = Dog()`),
// suppress the annotation capture so the constructor-inferred
// binding wins. This matches Kotlin's virtual dispatch semantics:
// `animal.speak()` should resolve to the overriding `Dog.speak`
// (the dynamic type), not `Animal.speak` (the static annotation).
//
// The annotation source has higher precedence than constructor-
// inferred in the generic scope-extractor (see
// `typeBindingStrength` in scope-extractor.ts), so the only way to
// make the constructor type prevail is to drop the annotation at
// emission time.
if (
grouped['@type-binding.annotation'] !== undefined &&
grouped['@type-binding.name'] !== undefined &&
grouped['@type-binding.type'] !== undefined
) {
const annotation = grouped['@type-binding.annotation']!;
if (propertyDeclHasConstructorValue(tree.rootNode, annotation.range)) {
continue;
}
}
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const fnNode = findNodeAtRange(
@ -290,10 +314,11 @@ function synthesizeKotlinLocalAssignmentBindings(
returnTypes: ReadonlyMap<string, string>,
): CaptureMatch[] {
const out: CaptureMatch[] = [];
const classMembers = collectKotlinClassMembers(rootNode);
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);
const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes, classMembers);
if (inferred === null) continue;
localTypes.set(inferred.name.text, inferred.rawType);
if (inferred.synthetic) {
@ -316,6 +341,77 @@ function synthesizeKotlinLocalAssignmentBindings(
return out;
}
interface KotlinClassMembers {
/** className → fieldName → raw type text */
readonly fields: ReadonlyMap<string, ReadonlyMap<string, string>>;
/** className → methodName → raw return type text */
readonly methods: ReadonlyMap<string, ReadonlyMap<string, string>>;
}
/**
* Per-file class-member index — primary-constructor `val`/`var` params,
* body property declarations, and method return types. Used by
* `inferKotlinPropertyType` to walk single-level field and method chains
* like `val addr = user.address` and `val city = addr.getCity()` (#1760).
*
* Indexes by simple class name only. Multi-class collisions inside a
* single file will pick whichever class was visited last for that name
* — acceptable because Kotlin forbids same-name top-level classes in
* one file and per-file resolution is the design boundary here.
*/
function collectKotlinClassMembers(rootNode: SyntaxNode): KotlinClassMembers {
const fields = new Map<string, Map<string, string>>();
const methods = new Map<string, Map<string, string>>();
for (const cls of descendantsOfType(rootNode, 'class_declaration')) {
const className = cls.namedChildren.find((child) => child.type === 'type_identifier')?.text;
if (className === undefined) continue;
const fmap = fields.get(className) ?? new Map<string, string>();
const mmap = methods.get(className) ?? new Map<string, string>();
const primary = cls.namedChildren.find((child) => child.type === 'primary_constructor');
if (primary !== undefined) {
for (const param of primary.namedChildren) {
if (param.type !== 'class_parameter') continue;
// Constructor params are class fields ONLY when prefixed with
// `val`/`var` (binding_pattern_kind). Plain `fn(x: Int)`-style
// params remain locals to the constructor.
if (param.namedChildren.find((c) => c.type === 'binding_pattern_kind') === undefined) {
continue;
}
const fname = param.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
const ftype = param.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype);
}
}
const body = cls.namedChildren.find((child) => child.type === 'class_body');
if (body !== undefined) {
for (const member of body.namedChildren) {
if (member.type === 'property_declaration') {
const v = member.namedChildren.find((c) => c.type === 'variable_declaration');
const fname = v?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
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);
}
}
}
fields.set(className, fmap);
methods.set(className, mmap);
}
return { fields, methods };
}
function collectKotlinLocalTypeTexts(
fnNode: SyntaxNode,
returnTypes: ReadonlyMap<string, string>,
@ -357,6 +453,7 @@ function inferKotlinPropertyType(
prop: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
returnTypes: ReadonlyMap<string, string>,
classMembers?: KotlinClassMembers,
): { 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');
@ -375,18 +472,74 @@ function inferKotlinPropertyType(
return rawType === undefined ? null : { name, rawType, source: value, synthetic: true };
}
if (value?.type === 'navigation_expression') {
// `val addr = user.address` — receiver type → field on that class (#1760).
const chained = inferKotlinNavigationFieldType(value, localTypes, classMembers);
if (chained === null) return null;
return { name, rawType: chained, source: value, synthetic: true };
}
if (value?.type === 'call_expression') {
const callee = value.namedChildren.find((child) => child.type === 'simple_identifier');
const callee = value.namedChildren.find(
(child) => child.type === 'simple_identifier' || child.type === 'navigation_expression',
);
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 };
if (callee.type === 'simple_identifier') {
const rawType =
returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
if (rawType === null) return null;
return { name, rawType, source: callee, synthetic: true };
}
// `val city = addr.getCity()` — receiver type → method return on that class (#1760).
const chained = inferKotlinNavigationCallReturnType(callee, localTypes, classMembers);
if (chained === null) return null;
return { name, rawType: chained, source: callee, synthetic: true };
}
return null;
}
/** Resolve `receiver.field` → field's declared type, where `receiver`
* is a simple identifier whose type is in `localTypes` and `field`
* is declared on that type in `classMembers.fields`. Returns null
* when any link in the chain is unknown — safe over-conservative. */
function inferKotlinNavigationFieldType(
nav: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
classMembers: KotlinClassMembers | undefined,
): string | null {
if (classMembers === undefined) return null;
const receiver = nav.namedChild(0);
if (receiver === null || receiver.type !== 'simple_identifier') return null;
const member = nav.namedChildren
.find((c) => c.type === 'navigation_suffix')
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
if (member === undefined) return null;
const recvType = localTypes.get(receiver.text);
if (recvType === undefined) return null;
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`. */
function inferKotlinNavigationCallReturnType(
navCallee: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
classMembers: KotlinClassMembers | undefined,
): string | null {
if (classMembers === undefined) return null;
const receiver = navCallee.namedChild(0);
if (receiver === null || receiver.type !== 'simple_identifier') return null;
const methodName = navCallee.namedChildren
.find((c) => c.type === 'navigation_suffix')
?.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;
}
function inferKotlinIterableElementType(
iterable: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
@ -411,7 +564,16 @@ function inferKotlinIterableElementType(
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');
if (raw !== undefined) return kotlinContainerElementType(raw, 'values');
// Cross-file fallback (#1759): the callee's return type is unknown
// locally because the function lives in another file. Emit the
// callee name itself as the binding's rawName; `propagateImported
// ReturnTypes` will chain-follow `loopvar → callee → <ElementType>`
// once the imported module's `callee → ElementType` mirror lands at
// module scope. If `callee` isn't actually an imported callable
// (e.g. a local lambda or unrelated symbol), chain-follow fails
// safely and no edge is emitted.
return callee.text;
}
return null;
@ -508,6 +670,21 @@ function shouldEmitReadMember(navNode: SyntaxNode): boolean {
return true;
}
/** True when the property_declaration anchored at `range` has a
* `call_expression` value sibling (i.e. `val x: T = Foo()`). Used to
* suppress the explicit-annotation type-binding capture so the
* constructor-inferred binding wins (#1762). */
function propertyDeclHasConstructorValue(rootNode: SyntaxNode, range: Range): boolean {
const propNode = findNodeAtRange(rootNode, range, 'property_declaration');
if (propNode === null) return false;
const variable = propNode.namedChildren.find((c) => c.type === 'variable_declaration');
if (variable === undefined) return false;
const value = propNode.namedChildren.find(
(c) => c.id !== variable.id && c.type !== 'binding_pattern_kind',
);
return value?.type === 'call_expression';
}
function callArguments(callNode: SyntaxNode): SyntaxNode[] {
const suffix = callNode.namedChildren.find((child) => child.type === 'call_suffix');
if (suffix === undefined) return [];

View file

@ -8,7 +8,7 @@ export interface KotlinResolveContext {
export function resolveKotlinImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
): string | readonly string[] | null {
const ctx = workspaceIndex as KotlinResolveContext | undefined;
if (
ctx === undefined ||
@ -25,22 +25,49 @@ export function resolveKotlinImportTarget(
: parsedImport.targetRaw;
const pathLike = target.replace(/\./g, '/');
// Resolution tiers, most-specific first:
// 1. The full `pathLike` matches a `.kt`/`.kts` file directly
// (`import util.User` → `util/User.kt`).
// 2. Stripped (last-segment removed) `pathLike` matches a file
// directly (`import util.OneArg.writeAudit` → `util/OneArg.kt`,
// a class-or-object holding `writeAudit`).
// 3. Stripped `pathLike` matches a *package directory* — fan out to
// every `.kt`/`.kts` file inside it (`import models.getRepo` →
// `[models/User.kt, models/Repo.kt]`). The finalize pass walks
// each candidate and picks the one whose `localDefs` actually
// export the imported name (#1759).
// 4. Progressive prefix strip for deeper namespace aliases that
// don't map 1:1 to directories.
const stripped = pathLike.split('/').slice(0, -1).join('/');
return (
findKotlinFile(ctx.allFilePaths, pathLike) ??
findKotlinFile(ctx.allFilePaths, pathLike.split('/').slice(0, -1).join('/')) ??
findKotlinExactOrSuffix(ctx.allFilePaths, stripped) ??
findKotlinPackageFiles(ctx.allFilePaths, stripped) ??
findByProgressivePrefixStrip(ctx.allFilePaths, pathLike)
);
}
function findKotlinFile(allFilePaths: ReadonlySet<string>, pathLike: string): string | null {
return (
findKotlinExactOrSuffix(allFilePaths, pathLike) ??
findKotlinDirectoryChild(allFilePaths, pathLike)
);
}
/** Exact (`file === pathLike+ext`) or suffix (`file ends with /pathLike+ext`)
* match — does NOT fall back to picking an arbitrary file inside a
* `pathLike/` directory. Used by the stripped-path tier in
* `resolveKotlinImportTarget` so a package import like `models.getRepo`
* delegates to `findKotlinPackageFiles` (multi-file fan-out) instead of
* silently committing to the first directory child. */
function findKotlinExactOrSuffix(
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, '/');
@ -49,18 +76,73 @@ function findKotlinFile(allFilePaths: ReadonlySet<string>, pathLike: string): st
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;
return suffixFile;
}
/** First directory child of `pathLike/` — preserves the legacy single-
* file fallback for cases where `pathLike` itself is an unqualified
* package reference (rare in real Kotlin code; some fixtures rely on
* it). Multi-file package fan-out goes through
* `findKotlinPackageFiles` instead. */
function findKotlinDirectoryChild(
allFilePaths: ReadonlySet<string>,
pathLike: string,
): string | null {
if (pathLike === '') return null;
const extensions = ['.kt', '.kts'];
const dirPrefix = `${pathLike}/`;
const suffixDirPrefix = `/${dirPrefix}`;
for (const raw of allFilePaths) {
const file = raw.replace(/\\/g, '/');
if (!extensions.some((ext) => file.endsWith(ext))) continue;
const atRoot = file.startsWith(dirPrefix);
const atNested = file.includes(suffixDirPrefix);
if (!atRoot && !atNested) continue;
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
const after = file.slice(idx + dirPrefix.length);
if (after.length > 0 && !after.includes('/')) return raw;
}
return null;
}
/**
* Return every `.kt`/`.kts` file inside the package directory `dirPath`
* (e.g. `models` → `['models/User.kt', 'models/Repo.kt']`). Used as a
* fallback when an import like `models.getRepo` does not resolve to a
* file named after the symbol — in Kotlin the symbol can live in any
* file inside the package directory. The finalize pass walks each
* candidate and picks the one whose `localDefs` actually export the
* imported name (#1759).
*/
function findKotlinPackageFiles(
allFilePaths: ReadonlySet<string>,
dirPath: string,
): readonly string[] | null {
if (dirPath === '') return null;
const extensions = ['.kt', '.kts'];
const dirPrefix = `${dirPath}/`;
const suffixDirPrefix = `/${dirPrefix}`;
const out: string[] = [];
for (const raw of allFilePaths) {
const file = raw.replace(/\\/g, '/');
if (!extensions.some((ext) => file.endsWith(ext))) continue;
const atRoot = file.startsWith(dirPrefix);
const atNested = file.includes(suffixDirPrefix);
if (!atRoot && !atNested) continue;
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
const after = file.slice(idx + dirPrefix.length);
// Direct children only — `models/sub/Util.kt` is a different package
// (`models.sub`) and must not be merged with `models`.
if (after.length === 0 || after.includes('/')) continue;
out.push(raw);
}
return out.length === 0 ? null : out;
}
function findByProgressivePrefixStrip(