* feat(resolution): conformance-aware chained-method resolution (#750) A chained static-factory/fluent call whose method lives on a SUPERTYPE the receiver conforms to — a protocol-extension method (Swift), an interface default method, or an inherited superclass method — now resolves. resolveMethodOnType falls back to walking the return type's implements/extends edges (via the new context.getSupertypes) when the method isn't a direct member. Because those edges don't exist during the single-pass resolution, a second pass (resolveChainedCallsViaConformance) re-resolves the deferred chained refs after edges are built. Still validated, so a wrong inference yields no edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(changelog): conformance-aware chained-method resolution (#750) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
aa07dc59d4
commit
48d4654e8d
+87
-2
@@ -16,7 +16,7 @@ import {
|
||||
FrameworkResolver,
|
||||
ImportMapping,
|
||||
} from './types';
|
||||
import { matchReference, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
|
||||
import { matchReference, matchDottedCallChain, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
|
||||
import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef } from './import-resolver';
|
||||
import { detectFrameworks } from './frameworks';
|
||||
import { synthesizeCallbackEdges } from './callback-synthesizer';
|
||||
@@ -27,6 +27,17 @@ import { logDebug } from '../errors';
|
||||
import type { ReExport } from './types';
|
||||
import { LRUCache } from './lru-cache';
|
||||
|
||||
/** Node kinds that can declare supertypes (extends/implements). */
|
||||
const SUPERTYPE_BEARING_KINDS = new Set<Node['kind']>([
|
||||
'class', 'struct', 'interface', 'trait', 'protocol', 'enum',
|
||||
]);
|
||||
|
||||
/** Languages whose chained calls use the dotted `inner().method` encoding. */
|
||||
const DOT_CHAIN_LANGUAGES = new Set(['java', 'kotlin', 'csharp']);
|
||||
|
||||
/** The extractor's chained-receiver encoding: `<inner>().<method>`. */
|
||||
const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/;
|
||||
|
||||
/**
|
||||
* Cache size limits. Each per-resolver cache is bounded so memory
|
||||
* stays flat on large codebases (20k+ files). Sizes were chosen to
|
||||
@@ -185,6 +196,12 @@ export class ReferenceResolver {
|
||||
private queries: QueryBuilder;
|
||||
private context: ResolutionContext;
|
||||
private frameworks: FrameworkResolver[] = [];
|
||||
// Chained static-factory/fluent call refs the first pass couldn't resolve,
|
||||
// collected in-memory (the batched resolver deletes unresolved refs from the
|
||||
// DB, so they can't be re-read). Drained by resolveChainedCallsViaConformance
|
||||
// once implements/extends edges exist, to resolve methods on a supertype the
|
||||
// receiver conforms to (#750).
|
||||
private deferredChainRefs: UnresolvedRef[] = [];
|
||||
// Per-`.razor`/`.cshtml`-file `@using` namespace set (own directives + folder
|
||||
// `_Imports.razor`, cascading to the project root). Used to disambiguate a
|
||||
// markup type ref to the right C# namespace.
|
||||
@@ -400,6 +417,25 @@ export class ReferenceResolver {
|
||||
return result;
|
||||
},
|
||||
|
||||
getSupertypes: (typeName: string, language) => {
|
||||
// Union the `implements`/`extends` targets of every same-named type node.
|
||||
// Matching by simple name (not id) reconciles a type declared in one node
|
||||
// (`KF::Builder`) with conformance declared in a separate extension node
|
||||
// (`KF.Builder: KFOptionSetter`) — both have name `Builder`.
|
||||
const typeNodes = this.context
|
||||
.getNodesByName(typeName)
|
||||
.filter((n) => SUPERTYPE_BEARING_KINDS.has(n.kind) && n.language === language);
|
||||
if (typeNodes.length === 0) return [];
|
||||
const supertypes = new Set<string>();
|
||||
for (const tn of typeNodes) {
|
||||
for (const edge of this.queries.getOutgoingEdges(tn.id, ['implements', 'extends'])) {
|
||||
const target = this.queries.getNodeById(edge.target);
|
||||
if (target?.name && target.name !== typeName) supertypes.add(target.name);
|
||||
}
|
||||
}
|
||||
return [...supertypes];
|
||||
},
|
||||
|
||||
getImportMappings: (filePath: string, language) => {
|
||||
const cacheKey = filePath;
|
||||
const cached = this.importMappingCache.get(cacheKey);
|
||||
@@ -684,7 +720,19 @@ export class ReferenceResolver {
|
||||
candidates.push(nameResult);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 0) {
|
||||
// Defer a chained static-factory/fluent call the first pass couldn't
|
||||
// resolve — its method may live on a supertype the receiver conforms to,
|
||||
// resolvable once implements/extends edges exist (the conformance pass).
|
||||
if (
|
||||
ref.referenceKind === 'calls' &&
|
||||
DOT_CHAIN_LANGUAGES.has(ref.language) &&
|
||||
CHAIN_SHAPE.test(ref.referenceName)
|
||||
) {
|
||||
this.deferredChainRefs.push(ref);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return highest confidence candidate
|
||||
return candidates.reduce((best, curr) =>
|
||||
@@ -767,6 +815,43 @@ export class ReferenceResolver {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Second resolution pass for chained static-factory / fluent calls whose
|
||||
* chained method is defined on a SUPERTYPE the receiver's type conforms to —
|
||||
* a protocol-extension / inherited / default-interface method (#750). The
|
||||
* first pass can't resolve these because `implements`/`extends` edges aren't
|
||||
* built yet; this runs AFTER edges are persisted, so `context.getSupertypes`
|
||||
* (and the conformance fallback in resolveMethodOnType) can walk them.
|
||||
*
|
||||
* Operates only on the leftover unresolved refs that have the `inner().method`
|
||||
* chain shape, for the dotted-chain languages — a small set — and is idempotent
|
||||
* (re-resolving an already-resolved ref is a no-op since it's been deleted).
|
||||
* Returns the number of newly-created edges.
|
||||
*/
|
||||
resolveChainedCallsViaConformance(): number {
|
||||
const deferred = this.deferredChainRefs;
|
||||
this.deferredChainRefs = [];
|
||||
if (deferred.length === 0) return 0;
|
||||
|
||||
// Read fresh edges (the main pass built the implements/extends edges after
|
||||
// these refs were deferred). matchDottedCallChain now resolves a method on a
|
||||
// supertype via context.getSupertypes -> resolveMethodOnType's conformance walk.
|
||||
this.clearCaches();
|
||||
const resolved: ResolvedRef[] = [];
|
||||
for (const ref of deferred) {
|
||||
const match = this.gateLanguage(matchDottedCallChain(ref, this.context), ref);
|
||||
if (match) resolved.push(match);
|
||||
}
|
||||
if (resolved.length === 0) return 0;
|
||||
|
||||
const edges = this.createEdges(resolved);
|
||||
if (edges.length > 0) {
|
||||
this.queries.insertEdges(edges);
|
||||
this.clearCaches();
|
||||
}
|
||||
return edges.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and persist in batches to keep memory bounded.
|
||||
* Processes unresolved references in chunks, persisting edges and cleaning
|
||||
|
||||
@@ -267,6 +267,8 @@ function resolveMethodOnType(
|
||||
* signal Java imports carry but the call site doesn't (#314).
|
||||
*/
|
||||
preferredFqn?: string,
|
||||
/** Recursion guard for the supertype/conformance walk. */
|
||||
depth = 0,
|
||||
): ResolvedRef | null {
|
||||
// Look up methods by name and match by qualifiedName ending in
|
||||
// `<typeName>::<methodName>`. This works whether the method is defined
|
||||
@@ -284,7 +286,24 @@ function resolveMethodOnType(
|
||||
matches.push(m);
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length === 0) {
|
||||
// Conformance fallback: the method may be defined on a supertype `typeName`
|
||||
// extends, or on a protocol / trait it conforms to (e.g. a Swift protocol-
|
||||
// extension method, a C# default-interface or extension method, a Kotlin
|
||||
// extension on a supertype). Walk supertypes transitively (depth-capped) via
|
||||
// the resolved implements/extends edges — empty in the first resolution pass,
|
||||
// populated in the conformance pass. Still VALIDATED (the method must exist on
|
||||
// a supertype), so a wrong inference produces no edge.
|
||||
if (depth < 4 && context.getSupertypes) {
|
||||
for (const supertype of context.getSupertypes(typeName, ref.language)) {
|
||||
const via = resolveMethodOnType(
|
||||
supertype, methodName, ref, context, confidence, resolvedBy, preferredFqn, depth + 1,
|
||||
);
|
||||
if (via) return via;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (matches.length > 1 && preferredFqn) {
|
||||
const ext = ref.language === 'kotlin' ? '.kt' : '.java';
|
||||
|
||||
@@ -81,6 +81,16 @@ export interface ResolutionContext {
|
||||
getAllFiles(): string[];
|
||||
/** Get nodes by lowercase name (O(1) lookup for fuzzy matching) */
|
||||
getNodesByLowerName(lowerName: string): Node[];
|
||||
/**
|
||||
* Direct supertypes of the type named `typeName` (same language): the classes
|
||||
* it extends and the interfaces / protocols / traits it implements/conforms to,
|
||||
* by simple name. Backed by the resolved `implements`/`extends` edges, so it is
|
||||
* EMPTY during the first resolution pass (edges aren't built yet) and populated
|
||||
* afterward — the conformance pass uses it to resolve a chained method defined
|
||||
* on a supertype the receiver type conforms to (e.g. a protocol-extension
|
||||
* method). Optional so external/test contexts compile without it.
|
||||
*/
|
||||
getSupertypes?(typeName: string, language: Language): string[];
|
||||
/** Get cached import mappings for a file */
|
||||
getImportMappings(filePath: string, language: Language): ImportMapping[];
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user