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
+1 -1
View File
@@ -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;
+44 -13
View File
@@ -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 [];
+48 -17
View File
@@ -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;