feat(resolution): inherited this.X, Java/Kotlin cross-file method refs, Swift type scoping (#810)

Three callback-registration shapes deferred from #756/#808, one arc:

1. INHERITED this.X (TS/JS + every this.-routed language): a `this.<member>`
   registration whose member isn't on the enclosing class defers to a second
   pass (resolveDeferredThisMemberRefs — in-memory like deferredChainRefs,
   runs after implements/extends edges persist, same lifecycle as the #750
   conformance pass) and resolves up the supertype chain, depth-capped BFS,
   validated targets only. `bus.on("submit", this.handleSubmit)` in a
   subclass links to FormBase::handleSubmit; same-named methods on unrelated
   classes never match. this.-prefixed candidates skip the extraction name
   gate (an inherited member can't be in definedHere).

2. JAVA/KOTLIN qualified method refs: `Handlers::onMessage` /
   `OtherClass::handle` emit QUALIFIED names resolved by the scoped
   suffix-matcher — cross-file capable, gated on the scope name being a
   same-file type or an imported name (dotted JVM imports now contribute
   their last segment). `this::m` and `super::m` route through the
   class-scoped resolver (super rides the supertype pass). References
   through a VARIABLE (`subscriber::onNext`) deliberately produce nothing —
   receiver type is unknowable; RxJava's baseline bare capture was resolving
   these to same-named same-file methods (a test method "registering" an
   anonymous class's onNext) — the rework drops 18 such wrong edges and
   keeps the 7 genuine Type::method refs RxJava's main tree actually has.

3. SWIFT enclosing-type scoping (implicit self): bare callback names match
   methods only of the from-symbol's own type (extension/nested scopes
   reconciled by suffix), and top-level code never matches methods.
   Alamofire: −44 wrong edges (parameters like `request`/`data`/`retrier`
   resolving to same-named methods on unrelated protocols), all verified;
   the same-class param collision (`task`) remains and is documented.

New ResolutionContext.getNodeById lets matchers derive the from-symbol's
class scope. Controls: redis/fmt fnref edges byte-identical; excalidraw
stable; typeorm +4 genuine inherited-getter dependencies; zero calls edges
changed on any of 7 A/B repos; nodes identical everywhere. Kotlin
companion-object members extract unqualified (pre-existing) so
`Type::companionFn` stays silent rather than guessing — documented.

Full suite 1389 passed. EXTRACTION_VERSION 20 → 21 (re-index to benefit).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-11 15:09:01 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 38eb4e688c
commit 38095aa95b
10 changed files with 369 additions and 45 deletions
+90 -1
View File
@@ -207,6 +207,11 @@ export class ReferenceResolver {
// once implements/extends edges exist, to resolve methods on a supertype the
// receiver conforms to (#750).
private deferredChainRefs: UnresolvedRef[] = [];
// `this.<member>` function-as-value refs whose member is NOT on the
// enclosing class itself — possibly inherited. Collected in-memory for the
// same reason as deferredChainRefs and drained by
// resolveDeferredThisMemberRefs once implements/extends edges exist (#808).
private deferredThisMemberRefs: 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.
@@ -422,6 +427,10 @@ export class ReferenceResolver {
return result;
},
getNodeById: (id: string) => {
return this.queries.getNodeById(id);
},
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
@@ -1214,7 +1223,13 @@ export class ReferenceResolver {
n.filePath === ref.filePath &&
n.id !== ref.fromNodeId
);
if (candidates.length === 0) return null;
if (candidates.length === 0) {
// Not on the class itself — possibly INHERITED. implements/extends
// edges don't exist yet in this pass, so retry in the supertype pass
// (resolveDeferredThisMemberRefs) instead of giving up.
this.deferredThisMemberRefs.push(ref);
return null;
}
const target = candidates.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
return {
original: ref,
@@ -1224,6 +1239,80 @@ export class ReferenceResolver {
};
}
/**
* Second pass for `this.<member>` refs whose member wasn't on the enclosing
* class itself (#808): once implements/extends edges exist, walk the
* class's supertypes (transitively, depth-capped) and resolve the member on
* the nearest one that declares it — `this.handleSubmit` registered in a
* subclass resolves to `FormBase::handleSubmit`. Validated targets only
* (function/method kind, same language family); no match → no edge.
* Mirrors resolveChainedCallsViaConformance's lifecycle. Returns the number
* of newly-created edges.
*/
resolveDeferredThisMemberRefs(): number {
const deferred = this.deferredThisMemberRefs;
this.deferredThisMemberRefs = [];
if (deferred.length === 0) return 0;
this.clearCaches();
const resolved: ResolvedRef[] = [];
for (const ref of deferred) {
const member = ref.referenceName.slice('this.'.length);
const fromNode = this.queries.getNodeById(ref.fromNodeId);
if (!fromNode || !member) continue;
const sep = fromNode.qualifiedName.lastIndexOf('::');
if (sep <= 0) continue;
const classPrefix = fromNode.qualifiedName.slice(0, sep);
const className = classPrefix.includes('::')
? classPrefix.slice(classPrefix.lastIndexOf('::') + 2)
: classPrefix;
// BFS up the supertype graph by simple name.
const seen = new Set<string>([className]);
let frontier = this.context.getSupertypes?.(className, ref.language) ?? [];
let target: Node | null = null;
for (let depth = 0; depth < 5 && frontier.length > 0 && !target; depth++) {
const next: string[] = [];
for (const superName of frontier) {
if (seen.has(superName)) continue;
seen.add(superName);
const members = this.context
.getNodesByName(member)
.filter(
(n) =>
(n.kind === 'function' || n.kind === 'method') &&
sameLanguageFamily(n.language, ref.language) &&
(n.qualifiedName === `${superName}::${member}` ||
n.qualifiedName.endsWith(`::${superName}::${member}`))
);
if (members.length > 0) {
target = members.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
break;
}
next.push(...(this.context.getSupertypes?.(superName, ref.language) ?? []));
}
frontier = next;
}
if (target) {
resolved.push({
original: ref,
targetNodeId: target.id,
confidence: 0.85,
resolvedBy: 'function-ref',
});
}
}
if (resolved.length === 0) return 0;
const edges = this.createEdges(resolved);
if (edges.length > 0) {
this.queries.insertEdges(edges);
this.clearCaches();
}
return edges.length;
}
private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
if (!result) return result;
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
+30 -1
View File
@@ -227,7 +227,7 @@ export function matchFunctionRef(
};
}
const candidates = context
let candidates = context
.getNodesByName(ref.referenceName)
.filter(
(n) =>
@@ -237,6 +237,35 @@ export function matchFunctionRef(
);
if (candidates.length === 0) return null;
// Swift implicit-self: a bare identifier can name a METHOD only of the
// ENCLOSING type (`Button(action: handleTap)` written inside that type) —
// a same-named method on any OTHER class is a parameter collision
// (Alamofire: a `request` parameter resolving to EventMonitor::request).
// Scope method candidates to the from-symbol's type; top-level code has no
// implicit self, so method targets are excluded there entirely. Free
// functions are unaffected.
if (ref.language === 'swift' && candidates.some((n) => n.kind === 'method')) {
const fromNode = context.getNodeById?.(ref.fromNodeId);
const sep = fromNode ? fromNode.qualifiedName.lastIndexOf('::') : -1;
const classPrefix = fromNode && sep > 0 ? fromNode.qualifiedName.slice(0, sep) : null;
candidates = candidates.filter((n) => {
if (n.kind !== 'method') return true;
if (!classPrefix) return false;
const mSep = n.qualifiedName.lastIndexOf('::');
if (mSep <= 0) return false;
const methodPrefix = n.qualifiedName.slice(0, mSep);
// Accept exact-scope matches plus suffix relationships either way, so
// extension-declared members (`Holder::m`) still match a nested
// from-scope (`Module::Holder::wire`) and vice versa.
return (
methodPrefix === classPrefix ||
methodPrefix.endsWith(`::${classPrefix}`) ||
classPrefix.endsWith(`::${methodPrefix}`)
);
});
if (candidates.length === 0) return null;
}
// Same-file definition wins — the extraction gate guarantees most survivors
// have one, and it's the dominant C pattern (static callback registered in
// a same-file ops struct).
+7
View File
@@ -91,6 +91,13 @@ export interface ResolutionContext {
* method). Optional so external/test contexts compile without it.
*/
getSupertypes?(typeName: string, language: Language): string[];
/**
* Look up a node by its id. Lets matchers derive the FROM-symbol's
* enclosing-class scope (Swift implicit-self method scoping, `this.X`
* member resolution). Optional so external/test contexts compile
* without it.
*/
getNodeById?(id: string): Node | null;
/** Get cached import mappings for a file */
getImportMappings(filePath: string, language: Language): ImportMapping[];
/**