فهرست منبع

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>
Colby Mchenry 1 هفته پیش
والد
کامیت
38095aa95b

+ 1 - 0
CHANGELOG.md

@@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - TypeScript and JavaScript **class fields are now reported as properties instead of methods**. A plain field like `public fonts: Fonts;` previously extracted as a method, misrepresenting class shape and letting calls to same-named functions resolve to data fields (a boolean field named `isArray` was soaking up `Array.isArray(...)` call edges). Fields holding arrow functions or function expressions (`onClick = () => {…}`, including wrapped ones like `onScroll = throttle(() => {…})`) correctly remain methods and their bodies are still analyzed. Field initializers are analyzed too, so `history = createHistory()` records its call — and JavaScript class fields, which previously produced no symbol at all, now appear in the graph. Re-index a project to benefit. (#808) (TypeScript, JavaScript)
 - Callback registration through `this` now resolves precisely in TypeScript and JavaScript: `window.addEventListener("online", this.onOfflineStatusToggle)` or an API object like `{ mutateElement: this.mutateElement }` produces a reference edge to the **enclosing class's own method** — never a same-named method on an unrelated class, and never a data field. Builds on the callback-registration support below. (#808) (TypeScript, JavaScript)
+- Callback-registration coverage deepened across four more shapes: a `this.<member>` registration whose method lives on a **base class** now resolves through the inheritance chain (`bus.on("submit", this.handleSubmit)` in a subclass links to the parent's `handleSubmit`); Java and Kotlin **method references to other classes** (`Handlers::onMessage`, `OtherClass::handle`) resolve across files, with `this::` and `super::` scoped to the defining class and references through a variable deliberately left out; and Swift bare callback names now match only the **enclosing type's** methods (implicit `self`), eliminating a class of wrong edges where a parameter like `request` linked to a same-named method on an unrelated type. (Java, Kotlin, Swift, TypeScript, JavaScript)
 - CodeGraph now sees where a function is **registered as a callback**, not just where it's called. A function name passed as an argument (`signal(SIGINT, handler)`, `qsort(…, compare)`, `addEventListener(…, onBlur)`), assigned to a function pointer or field (`ops->recv_cb = my_cb`, `OnClick := Handler`), or placed in a struct initializer or handler table (`{ .recv_cb = my_cb }`, `{ "get", getCommand }`) now produces a reference edge from the registration site to the function — so `codegraph_callers` and `codegraph_impact` surface callback wiring that previously looked like dead code. Works across all supported languages, including the language-specific forms: C/C++ `&fn`, Java `Class::method`, Kotlin `::fn`, Swift `#selector`, Objective-C `@selector`, Ruby `method(:fn)`, Scala eta-expansion, and Delphi/Pascal `@Handler` and `OnClick := Handler` event wiring. Callers output labels these "via callback registration". Resolution is deliberately conservative: an ambiguous name produces no edge rather than a wrong one. Re-index a project to benefit. Thanks @zmcrazy. (#756)
 - The `codegraph_node` MCP tool can now **read a whole source file like the built-in Read tool — only faster, served from the index**. Pass a file path with no symbol and it returns that file's current source with line numbers (the same `<n>⇥<line>` shape Read produces, so an assistant can edit straight from it), narrowable with `offset`/`limit` exactly like Read, plus a one-line note of which files depend on it (the file's blast radius). Use it anywhere you'd reach for Read on an indexed source file. Pass `symbolsOnly: true` for just the file's structure. Configuration/data files (`.yml` / `.properties`) are summarized by key only, never dumped, so secrets in them are never surfaced. The agent-facing guidance was also retuned so assistants reach for codegraph while *implementing* a change (not only when answering questions), since one codegraph call returns the same bytes plus the blast radius, faster than re-reading the file.
 - New `codegraph upgrade` command updates CodeGraph to the latest release in place — it detects how you installed (the standalone `install.sh` / `install.ps1` bundle, npm, or npx) and does the right thing for each, on macOS, Linux, and Windows. Use `codegraph upgrade --check` to see whether an update is available without installing, or `codegraph upgrade <version>` to move to a specific version. After upgrading it reminds you to re-index your projects so they pick up the newer engine's improvements. (#679)

+ 116 - 0
__tests__/function-ref.test.ts

@@ -457,6 +457,122 @@ describe('Function-as-value capture (#756)', () => {
     }
   });
 
+  it('INHERITED this.X: resolves on a supertype via the second pass, never on unrelated classes', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-inherit-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'base.ts'),
+      'export class FormBase { handleSubmit(): void {} }\n'
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'unrelated.ts'),
+      'export class Unrelated { handleSubmit(): void {} }\n'
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'login.ts'),
+      [
+        "import { FormBase } from './base';",
+        'declare const bus: { on(ev: string, cb: () => void): void };',
+        'export class LoginForm extends FormBase {',
+        '  wire(): void { bus.on("submit", this.handleSubmit); }',
+        '}',
+      ].join('\n')
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    try {
+      await cg.indexAll();
+      const handleSubmits = cg.getNodesByName('handleSubmit');
+      const baseM = handleSubmits.find((n) => n.qualifiedName.includes('FormBase'))!;
+      const unrelatedM = handleSubmits.find((n) => n.qualifiedName.includes('Unrelated'))!;
+
+      const intoBase = cg.getIncomingEdges(baseM.id).filter((e) => e.metadata?.fnRef === true);
+      expect(intoBase).toHaveLength(1);
+      expect(cg.getNode(intoBase[0]!.source)?.name).toBe('wire');
+      expect(
+        cg.getIncomingEdges(unrelatedM.id).filter((e) => e.metadata?.fnRef === true)
+      ).toHaveLength(0);
+    } finally {
+      cg.destroy();
+      tmpDir = undefined;
+    }
+  });
+
+  it('JAVA: Type::method cross-file, this::/super:: scoped, variable:: yields nothing', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-java-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'Handlers.java'),
+      [
+        'package com.example;',
+        'public class Handlers {',
+        '    public static void onMessage(int x) { System.out.println(x); }',
+        '}',
+      ].join('\n')
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'BaseForm.java'),
+      ['package com.example;', 'public class BaseForm {', '    void baseHandler(int x) {}', '}'].join('\n')
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'Main.java'),
+      [
+        'package com.example;',
+        'import com.example.Handlers;',
+        'import java.util.function.IntConsumer;',
+        'public class Main extends BaseForm {',
+        '    static void registerHandler(IntConsumer cb) { cb.accept(1); }',
+        '    void run0() {}',
+        '    void crossFile() { registerHandler(Handlers::onMessage); }',
+        '    void thisRef() { registerHandler(this::run0); }',
+        '    void superRef() { registerHandler(super::baseHandler); }',
+        '    void varRef(Main m) { registerHandler(m::run0); }',
+        '}',
+      ].join('\n')
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    try {
+      await cg.indexAll();
+
+      expect(sourceNames(cg, fnRefEdgesInto(cg, 'onMessage'))).toEqual(['crossFile']);
+      expect(sourceNames(cg, fnRefEdgesInto(cg, 'baseHandler'))).toEqual(['superRef']);
+      // this::run0 resolves class-scoped; m::run0 (variable receiver) must NOT
+      // add a second edge — exactly one source.
+      expect(sourceNames(cg, fnRefEdgesInto(cg, 'run0'))).toEqual(['thisRef']);
+    } finally {
+      cg.destroy();
+      tmpDir = undefined;
+    }
+  });
+
+  it('SWIFT SCOPING: bare ids hit only the enclosing type’s methods; top-level bare hits functions only', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-swiftscope-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'main.swift'),
+      [
+        'func register(_ cb: (Int) -> Void) { cb(1) }',
+        'class Monitor {',
+        '  func report(_ x: Int) {}',
+        '  func wire() { register(report) }', // implicit self → Monitor::report
+        '}',
+        'class Other {',
+        // `report` here is a PARAMETER; Monitor::report must not win.
+        '  func use(report: (Int) -> Void) { register(report) }',
+        '}',
+        'func topLevel() { register(report) }', // no implicit self → no method target
+      ].join('\n')
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    try {
+      await cg.indexAll();
+      const edges = fnRefEdgesInto(cg, 'report');
+      expect(sourceNames(cg, edges)).toEqual(['wire']);
+    } finally {
+      cg.destroy();
+      tmpDir = undefined;
+    }
+  });
+
   it('C UNGATED TABLES: a command table names handlers defined in OTHER files (redis pattern)', async () => {
     tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ctable-'));
     // Handler defined in its own file…

+ 26 - 12
docs/design/function-ref-capture.md

@@ -171,25 +171,39 @@ Index cost on redis: +6% time, +5% db size.
   without local-scope tracking — the data-flow frontier deliberately left
   uncovered. ~1-2 per 20 sampled edges on callback-heavy repos; the file-level
   dependency is real in every observed case.
-- **Swift single same-named method collisions** (`request(self, didFailTask:
-  task…)` where one `task` method exists): the overload-family rule only
-  refuses when ≥2 same-named methods share the file. Alamofire-style
-  API-mirrored param naming keeps a residual; needs same-type scoping (v2).
+- **Swift same-class param collisions** (`eventMonitor?.request(self,
+  didFailTask: task…)` where the enclosing type ALSO has a `task` method):
+  enclosing-type scoping (implicit self — methods match only the from-symbol's
+  own type, top-level bare ids never match methods) eliminated the CROSS-class
+  collision class on Alamofire (−44 wrong edges), but a parameter named after
+  a method of the SAME type is statically indistinguishable from an
+  implicit-self method value. Residual, documented.
 - **Pascal paren-less calls** (`Result := DoInitialize`): captured as
   references (Pascal can't distinguish a procedure VALUE from a paren-less
   CALL without types). The dependency direction is correct and these calls
   were previously invisible entirely (#791) — strictly more truth, imperfect
   label.
-- **Java/Kotlin cross-file method refs** (`OtherClass::method` without the
-  defining class imported as a simple name): gated away; same-file and
-  `this::m` forms work.
+- **Java/Kotlin method refs through a VARIABLE** (`subscriber::onNext`,
+  `m::run0`): receiver type unknown statically — deliberately no edge (the
+  obj.method class). RxJava's baseline bare capture was resolving these to
+  same-named same-file methods (a test method "registering" an anonymous
+  class's `onNext`); the qualified rework drops them. `Type::method` resolves
+  cross-file (scope gated on same-file types ∪ imported names, incl. the last
+  segment of dotted JVM imports); `this::m` / `super::m` ride the
+  class-scoped + supertype path.
+- **Kotlin companion-object members** extract UNQUALIFIED (node `handle`, not
+  `KtHandlers::Companion::handle` — pre-existing extraction shape), so
+  `KtHandlers::handle` refs to companion members stay silent rather than
+  guess. Fix belongs in kotlin companion extraction.
 - **Swift cross-file bare references**: Swift sees module-wide symbols without
-  imports, so cross-file bare callbacks only resolve when repo-unique.
+  imports, so cross-file bare callbacks only resolve when repo-unique
+  (functions; methods are enclosing-type-only). Cross-TYPE `#selector`
+  targets (rare — target-action is normally self) are scoped away too.
 - **PHP string callables**, **Ruby bare symbols** outside `method(:sym)`,
   **`obj.method` member values** where `obj` isn't `this`/`self`: deferred.
-- **TS/JS `this.X` to inherited members**: the class-scoped resolver matches
-  the enclosing class's OWN members only — `this.handleClick` defined on a
-  superclass yields no edge (would need the supertype walk; deliberate v1).
-  Reading a getter into a local (`const s = this.snapshot`) produces a
+- **`this.X` inherited members resolve through the supertype pass**
+  (`resolveDeferredThisMemberRefs`, depth-capped BFS over implements/extends,
+  runs after edges persist — same lifecycle as the #750 conformance pass).
+  Reading a getter into a local (`const s = this.snapshot`) still produces a
   references edge to the getter — a true dependency with an imperfect
   "registration" flavor.

+ 1 - 1
src/extraction/extraction-version.ts

@@ -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;

+ 40 - 9
src/extraction/function-ref.ts

@@ -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 }] : [];
+      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` — the simple_identifier child.
+    // 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 && child.type === 'simple_identifier') {
-          return [{ name: getNodeText(child, source), node: child }];
-        }
+        if (!child) continue;
+        if (child.type === 'type_identifier') receiver = child;
+        if (child.type === 'simple_identifier') member = child;
       }
-      return [];
+      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
src/extraction/tree-sitter.ts

@@ -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;

+ 6 - 0
src/index.ts

@@ -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
src/resolution/index.ts

@@ -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
src/resolution/name-matcher.ts

@@ -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 - 0
src/resolution/types.ts

@@ -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[];
   /**