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:
co-authored by
Claude Opus 4.8
parent
38eb4e688c
commit
38095aa95b
@@ -21,4 +21,4 @@
|
||||
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
|
||||
* in the product is load-bearing").
|
||||
*/
|
||||
export const EXTRACTION_VERSION = 20;
|
||||
export const EXTRACTION_VERSION = 21;
|
||||
|
||||
@@ -553,35 +553,66 @@ function normalizeSpecial(
|
||||
source: string
|
||||
): Array<{ name: string; node: SyntaxNode }> {
|
||||
switch (type) {
|
||||
// Java `Main::targetCb` / `this::run0` — last identifier child is the method.
|
||||
// Java method references. Receiver decides the resolution route (#808):
|
||||
// `this::run0` / `super::close` → `this.<m>` (class-scoped resolver;
|
||||
// super rides the inherited-member supertype pass)
|
||||
// `Type::method` (capitalized) → qualified `Type::method` (suffix-
|
||||
// matched against that type's members, cross-file capable)
|
||||
// `variable::method` → nothing (receiver type unknown statically —
|
||||
// the deferred obj.method class)
|
||||
case 'method_reference': {
|
||||
let last: SyntaxNode | null = null;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && child.type === 'identifier') last = child;
|
||||
}
|
||||
return last ? [{ name: getNodeText(last, source), node: last }] : [];
|
||||
}
|
||||
|
||||
// Kotlin `::targetCb` — the simple_identifier child.
|
||||
case 'callable_reference': {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && child.type === 'simple_identifier') {
|
||||
return [{ name: getNodeText(child, source), node: child }];
|
||||
}
|
||||
if (!last) return [];
|
||||
const m = getNodeText(last, source);
|
||||
const text = getNodeText(node, source);
|
||||
if (text.startsWith('this::') || text.startsWith('super::')) {
|
||||
return [{ name: `this.${m}`, node: last }];
|
||||
}
|
||||
const recv = text.match(/^([A-Z][A-Za-z0-9_]*)\s*::/);
|
||||
if (recv) {
|
||||
// `Type::method` — but `Type::new` (constructor ref) has no method
|
||||
// node to land on; let the stoplist drop it via the bare name.
|
||||
return m === 'new' ? [] : [{ name: `${recv[1]}::${m}`, node: last }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Kotlin `::targetCb` (one part) / `OtherClass::handle` (two parts —
|
||||
// receiver is a type_identifier; lowercase receivers are variables, the
|
||||
// deferred obj.method class).
|
||||
case 'callable_reference': {
|
||||
let receiver: SyntaxNode | null = null;
|
||||
let member: SyntaxNode | null = null;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (child.type === 'type_identifier') receiver = child;
|
||||
if (child.type === 'simple_identifier') member = child;
|
||||
}
|
||||
if (!member) return [];
|
||||
const m = getNodeText(member, source);
|
||||
if (!receiver) return [{ name: m, node: member }]; // ::topLevelFn
|
||||
const recvText = getNodeText(receiver, source);
|
||||
return /^[A-Z]/.test(recvText)
|
||||
? [{ name: `${recvText}::${m}`, node: member }]
|
||||
: []; // variable::method — unknown receiver type
|
||||
}
|
||||
|
||||
// Kotlin `this::fire` parses as navigation_expression with a `::fire`
|
||||
// navigation_suffix. Ordinary `a.b` navigation MUST yield nothing.
|
||||
// navigation_suffix — route through the class-scoped `this.` resolver.
|
||||
// Ordinary `a.b` navigation (and any non-`this` receiver) MUST yield
|
||||
// nothing.
|
||||
case 'navigation_expression': {
|
||||
if (!getNodeText(node, source).startsWith('this::')) return [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && child.type === 'navigation_suffix' && getNodeText(child, source).startsWith('::')) {
|
||||
const id = child.namedChild(child.namedChildCount - 1);
|
||||
if (id) return [{ name: getNodeText(id, source), node: id }];
|
||||
if (id) return [{ name: `this.${getNodeText(id, source)}`, node: id }];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -435,19 +435,34 @@ export class TreeSitterExtractor {
|
||||
if (isGeneratedFile(this.filePath)) return;
|
||||
|
||||
const definedHere = new Set<string>();
|
||||
const definedTypes = new Set<string>();
|
||||
for (const n of this.nodes) {
|
||||
if (n.kind === 'function' || n.kind === 'method') definedHere.add(n.name);
|
||||
if (
|
||||
n.kind === 'class' || n.kind === 'struct' || n.kind === 'interface' ||
|
||||
n.kind === 'enum' || n.kind === 'trait' || n.kind === 'protocol'
|
||||
) {
|
||||
definedTypes.add(n.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Import-binding names only (all binding emitters push kind 'imports').
|
||||
// Deliberately NOT 'references': those carry type-annotation and
|
||||
// interface-member names, which let local variables that share a type
|
||||
// member's name slip through the gate (excalidraw A/B finding).
|
||||
// member's name slip through the gate (excalidraw A/B finding). A dotted
|
||||
// import (JVM `import com.example.OtherClass`) also contributes its LAST
|
||||
// segment — the simple name Java/Kotlin code uses in `OtherClass::method`
|
||||
// references.
|
||||
const SIMPLE_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
const DOTTED_NAME = /^[A-Za-z_$][A-Za-z0-9_$.]*\.([A-Za-z_$][A-Za-z0-9_$]*)$/;
|
||||
const importedNames = new Set<string>();
|
||||
for (const r of this.unresolvedReferences) {
|
||||
if (r.referenceKind === 'imports' && SIMPLE_NAME.test(r.referenceName)) {
|
||||
if (r.referenceKind !== 'imports') continue;
|
||||
if (SIMPLE_NAME.test(r.referenceName)) {
|
||||
importedNames.add(r.referenceName);
|
||||
} else {
|
||||
const dotted = r.referenceName.match(DOTTED_NAME);
|
||||
if (dotted) importedNames.add(dotted[1]!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,21 +483,37 @@ export class TreeSitterExtractor {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// C-family file-scope initializers skip the gate (constant-expression
|
||||
// context — a bare identifier there is a function address, never a
|
||||
// variable; see FnRefSpec.ungatedModes). Local initializers and
|
||||
// everything else require a same-file/import match.
|
||||
const skipGate = ungated?.has(c.mode) === true && atFileScope;
|
||||
// Qualified C++ member-pointers (`Widget::on_click`) and TS/JS
|
||||
// `this.<member>` candidates gate on the member name; everything else
|
||||
// on the full name.
|
||||
const gateName = c.name.startsWith('this.')
|
||||
? c.name.slice(5)
|
||||
: c.name.includes('::')
|
||||
? c.name.slice(c.name.lastIndexOf('::') + 2)
|
||||
: c.name;
|
||||
if (!skipGate && !definedHere.has(gateName) && !importedNames.has(gateName)) {
|
||||
continue;
|
||||
// Gate policy by candidate shape:
|
||||
// - `this.<member>`: ALWAYS flush — the member may be inherited from a
|
||||
// class in another file (definedHere can't see it), volume is
|
||||
// naturally bounded by real `this.X` expressions, and resolution is
|
||||
// strictly class-scoped (own members or the validated supertype
|
||||
// pass), so nothing fuzzy can leak.
|
||||
// - `Scope::member` (C++ member-pointers, Java/Kotlin type-qualified
|
||||
// method refs): the SCOPE name must be a type defined here or an
|
||||
// imported name (covers `OtherClass::method` cross-file), or the
|
||||
// member matches the plain gate (back-compat for C++ same-file).
|
||||
// - C-family file-scope initializers skip the gate entirely
|
||||
// (constant-expression context — see FnRefSpec.ungatedModes).
|
||||
// - everything else: name ∈ same-file functions/methods ∪ imports.
|
||||
if (!c.name.startsWith('this.')) {
|
||||
const skipGate = ungated?.has(c.mode) === true && atFileScope;
|
||||
if (!skipGate) {
|
||||
if (c.name.includes('::')) {
|
||||
const scopeName = c.name.slice(0, c.name.indexOf('::'));
|
||||
const memberName = c.name.slice(c.name.lastIndexOf('::') + 2);
|
||||
if (
|
||||
!definedTypes.has(scopeName) &&
|
||||
!importedNames.has(scopeName) &&
|
||||
!definedHere.has(memberName) &&
|
||||
!importedNames.has(memberName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} else if (!definedHere.has(c.name) && !importedNames.has(c.name)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = `${c.fromNodeId}|${c.name}`;
|
||||
if (seen.has(key)) continue;
|
||||
|
||||
@@ -382,6 +382,9 @@ export class CodeGraph {
|
||||
// interface). Needs the implements/extends edges the main pass just
|
||||
// built, so it runs after resolution (#750).
|
||||
this.resolver.resolveChainedCallsViaConformance();
|
||||
// Same lifecycle for `this.<member>` callback registrations whose
|
||||
// member is inherited from a supertype (#808).
|
||||
this.resolver.resolveDeferredThisMemberRefs();
|
||||
}
|
||||
|
||||
// Refresh planner stats + checkpoint the WAL after bulk writes.
|
||||
@@ -503,6 +506,9 @@ export class CodeGraph {
|
||||
// receiver conforms to (protocol-extension / inherited). Needs the
|
||||
// implements/extends edges built above (#750).
|
||||
this.resolver.resolveChainedCallsViaConformance();
|
||||
// Same lifecycle for `this.<member>` callback registrations whose
|
||||
// member is inherited from a supertype (#808).
|
||||
this.resolver.resolveDeferredThisMemberRefs();
|
||||
}
|
||||
|
||||
// Refresh planner stats + checkpoint the WAL after bulk writes.
|
||||
|
||||
+90
-1
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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[];
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user