Explorar o código

feat(resolution): conformance-aware chained-method resolution (#750) (#754)

* 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>
Colby Mchenry hai 2 semanas
pai
achega
48d4654e8d

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- Chained method calls now resolve when the chained method is **inherited from a superclass or declared on an interface/protocol** the receiver's type conforms to — for example a call on a sealed-subclass instance (`Either.Right(x).combine(...)`) that invokes a method defined on its parent type. Previously these chains found no caller edge even though the factory's type was known, so the call was invisible to callers, impact, and trace. CodeGraph now walks the type's supertypes (its `extends` / `implements` relationships) to find the method, creating the edge only when a supertype genuinely declares it (so a wrong inference still produces no edge). This makes Java, Kotlin, and C# factory and fluent chains more complete. Existing indexes should be re-indexed (`codegraph index -f`) to benefit. (#750)
 - C# method calls made through a static factory or fluent chain now resolve to the correct class. A call like `Foo.Create().Bar()` or `JObject.Parse(s).Property(...)` used to lose the receiver's type, so the chained method didn't resolve and the call was invisible to callers/impact/trace. CodeGraph now captures C# return types and infers the chained receiver's type from what the inner call returns, creating the edge only when that class genuinely has the method (so a wrong inference produces no edge). Existing C# indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (C#)
 - Kotlin method calls made through a companion-object factory or fluent chain now resolve to the correct class. A call like `Foo.getInstance().bar()` or `Config.create(opts).build()` used to drop the receiver entirely, so the chained method silently attached to a same-named method on an unrelated class — or didn't resolve at all — corrupting callers, impact, and trace. CodeGraph now captures Kotlin return types and infers the chained receiver's type from what the inner call returns, creating the edge only when that class genuinely has the method (so a wrong inference produces no edge instead of a misleading one). Existing Kotlin indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Kotlin)
 - Java method calls made through a static factory or fluent chain now resolve to the correct class. A call like `Foo.getInstance().bar()` or `Config.create(opts).build()` used to lose the receiver's type, so when two classes had a same-named method the call silently attached to whichever was indexed first — or didn't resolve at all — corrupting callers, impact, and trace. CodeGraph now captures Java return types and infers the chained receiver's type from what the inner call returns, creating the edge only when that class genuinely has the method (so a wrong inference produces no edge instead of a misleading one). Covers factories and fluent builders that take arguments (`hashKeys().arrayListValues()`), including builders that return a nested type. Existing Java indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Java)

+ 66 - 0
__tests__/resolution.test.ts

@@ -2402,4 +2402,70 @@ class Caller {
       expect(callerNamesOf('Other::OnlyOther')).toEqual([]);
     });
   });
+
+  describe('Chained call resolves a method on a supertype (conformance, #750)', () => {
+    function callerNamesOf(qualifiedName: string): string[] {
+      const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName);
+      if (!target) return [];
+      const names = cg
+        .getIncomingEdges(target.id)
+        .filter((e) => e.kind === 'calls')
+        .map((e) => cg.getNode(e.source)?.name)
+        .filter((n): n is string => !!n);
+      return [...new Set(names)].sort();
+    }
+
+    it('resolves a chained method defined only on a SUPERCLASS the return type extends', async () => {
+      // draw() lives on Base; Widget (the factory's return type) has no draw() of
+      // its own. Decoy.draw must never win. Needs the conformance second pass.
+      fs.writeFileSync(
+        path.join(tempDir, 'Main.java'),
+        `class Base { void draw() {} }
+class Widget extends Base {}
+class Decoy { void draw() {} }
+class Factory { static Widget create() { return new Widget(); } }
+class Caller {
+    void run() { Factory.create().draw(); }
+}
+`
+      );
+      cg = await CodeGraph.init(tempDir, { index: true });
+      expect(callerNamesOf('Base::draw')).toEqual(['run']);
+      expect(callerNamesOf('Decoy::draw')).toEqual([]);
+    });
+
+    it('resolves a chained method defined on an INTERFACE the return type implements (default method)', async () => {
+      fs.writeFileSync(
+        path.join(tempDir, 'Main.java'),
+        `interface Drawable { default void draw() {} }
+class Widget implements Drawable {}
+class Decoy { void draw() {} }
+class Factory { static Widget create() { return new Widget(); } }
+class Caller {
+    void run() { Factory.create().draw(); }
+}
+`
+      );
+      cg = await CodeGraph.init(tempDir, { index: true });
+      expect(callerNamesOf('Drawable::draw')).toEqual(['run']);
+      expect(callerNamesOf('Decoy::draw')).toEqual([]);
+    });
+
+    it('still creates NO edge when no supertype has the method (safety preserved)', async () => {
+      fs.writeFileSync(
+        path.join(tempDir, 'Main.java'),
+        `class Base {}
+class Widget extends Base {}
+class Other { void onlyOther() {} }
+class Factory { static Widget create() { return new Widget(); } }
+class Caller {
+    void run() { Factory.create().onlyOther(); }
+}
+`
+      );
+      cg = await CodeGraph.init(tempDir, { index: true });
+      // Neither Widget nor Base has onlyOther() — must not attach to Other::onlyOther.
+      expect(callerNamesOf('Other::onlyOther')).toEqual([]);
+    });
+  });
 });

+ 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 = 8;
+export const EXTRACTION_VERSION = 9;

+ 11 - 0
src/index.ts

@@ -376,6 +376,12 @@ export class CodeGraph {
               total,
             });
           });
+
+          // Second pass: chained calls whose method lives on a supertype the
+          // receiver conforms to (protocol-extension / inherited / default-
+          // interface). Needs the implements/extends edges the main pass just
+          // built, so it runs after resolution (#750).
+          this.resolver.resolveChainedCallsViaConformance();
         }
 
         // Refresh planner stats + checkpoint the WAL after bulk writes.
@@ -492,6 +498,11 @@ export class CodeGraph {
               });
             });
           }
+
+          // Second pass: chained calls whose method lives on a supertype the
+          // receiver conforms to (protocol-extension / inherited). Needs the
+          // implements/extends edges built above (#750).
+          this.resolver.resolveChainedCallsViaConformance();
         }
 
         // Refresh planner stats + checkpoint the WAL after bulk writes.

+ 87 - 2
src/resolution/index.ts

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

+ 20 - 1
src/resolution/name-matcher.ts

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

+ 10 - 0
src/resolution/types.ts

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