Skip to content

Go: receiver methods dropped from their type — generic receivers (*T[P]) and cross-file declarations lose the struct→method contains edge #583

@AriaShishegaran

Description

@AriaShishegaran

Summary

In Go a type's methods may (a) use a type-parameter receiver — func (s *Stack[T]) ... — and/or (b) be declared in a different file from the type declaration. Both are idiomatic and extremely common. In v0.9.7 (@colbymchenry/codegraph, source main @ b026e64) the extractor fails to attach such methods to their receiver type: no contains edge is created. The method nodes still exist, but they are orphaned from the struct.

Two independent root causes, one symptom (an incomplete method set on the type):

  1. Generic receivers — the receiver-type regex can't parse *Stack[T], so the method gets no receiver type at all.
  2. Cross-file methods — the struct→method owner lookup is scoped to the current file, so a method declared in another file of the same package never finds its type.

Because almost every downstream feature derives a type's method set from these contains edges — codegraph_node member outlines, callers/callees/impact, the gRPC Unimplemented*Server matcher (goGrpcStubImplEdges), and any future interface-satisfaction work — both bugs silently degrade Go navigation.

Environment

  • codegraph v0.9.7 (npm @colbymchenry/codegraph), source main @ b026e64
  • backend: node:sqlite; macOS arm64

Reproduction (minimal, self-contained)

go.mod

module example.com/repro
go 1.22

generic.go — generic-receiver case

package repro

type Stack[T any] struct{ items []T }

func (s *Stack[T]) Push(v T)       { s.items = append(s.items, v) }
func (s *Stack[T]) Pop() (T, bool) { var z T; if len(s.items)==0 { return z,false }; n:=len(s.items)-1; v:=s.items[n]; s.items=s.items[:n]; return v,true }
func (s *Stack[T]) Len() int       { return len(s.items) }

widget_a.go / widget_b.go — cross-file case (legal Go: methods on a type may live in any file of the package)

// widget_a.go
package repro
type Widget struct{ name string }
func (w *Widget) Foo() string { return "foo:" + w.name }
// widget_b.go
package repro
func (w *Widget) Bar() string { return "bar:" + w.name } // method in a DIFFERENT file than the struct

Control — same-file methods (e.g. a MemoryStore struct with Save/Load in the same file).

Index, then read the graph DB directly (bypasses all display/formatting layers):

codegraph init -i
sqlite3 .codegraph/codegraph.db \
  "SELECT s.name AS type, m.name AS method, m.file_path
   FROM edges e
   JOIN nodes s ON e.source = s.id
   JOIN nodes m ON e.target = m.id
   WHERE e.kind='contains' AND s.kind='struct' AND m.kind='method'
   ORDER BY s.name;"

Observed (ground truth from the edges table)

type contains→method edges missing
MemoryStore (control, same file) Save, Load
SQLiteStore (control, same file) Save, Load
Widget Foo only Bar (declared in widget_b.go)
Stack[T] none Push, Pop, Len

The qualified_name of the method nodes isolates the two distinct causes:

Push  qualified_name = "Push"          -- no receiver type extracted at all
Bar   qualified_name = "Widget::Bar"   -- receiver extracted fine; only the contains edge is missing
Foo   qualified_name = "Widget::Foo"   -- contains edge present (same file)

Root cause 1 — generic-receiver regex (src/extraction/languages/go.ts:60)

getReceiverType: (node, source) => {
  const receiver = getChildByField(node, 'receiver');
  if (!receiver) return undefined;
  const text = getNodeText(receiver, source);            // e.g. "(s *Stack[T])"
  const match = text.match(/\*?\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
  return match?.[1];
}

The pattern requires the captured identifier to be immediately followed by optional whitespace and ). For a type-parameter receiver (s *Stack[T]) the type name Stack is followed by [T]), so the regex never matches → getReceiverType returns undefined. The method is then treated as receiver-less: no Type:: qualified name and (via the code path in root cause 2) no contains edge. This affects every method on every generic type (Go 1.18+).

Suggested fix: strip the type-parameter list before matching, or anchor on the first type identifier rather than on ). For example, parsing the receiver as ( name? '*'? IDENT ('[' … ']')? ) and capturing IDENT; a regex such as

/\(\s*(?:[A-Za-z_]\w*\s+)?\*?\s*([A-Za-z_]\w*)/

captures Stack for (s *Stack[T]), (s Stack[K, V]), (*Stack[T]), and the existing non-generic forms (sl *scrapeLoop) / (Type).

Root cause 2 — same-file-only owner lookup (src/extraction/tree-sitter.ts:785-790)

if (receiverType && !this.isInsideClassLikeNode()) {
  const ownerNode = this.nodes.find(
    (n) =>
      n.name === receiverType &&
      n.filePath === this.filePath &&          // <-- restricts the owner to the CURRENT file
      (n.kind === 'struct' || n.kind === 'class' || n.kind === 'enum' || n.kind === 'trait')
  );
  if (ownerNode) {
    this.edges.push({ source: ownerNode.id, target: methodNode.id, kind: 'contains' });
  }
}

Extraction is per file, and this.nodes holds only the nodes of the file currently being parsed. A method whose receiver type is declared in another file of the same package (e.g. Widget in widget_a.go, Bar in widget_b.go) never finds an ownerNode, so no contains edge is emitted. The Type::method qualified name is still set, so it is purely the structural edge that is lost. This is idiomatic Go (large types routinely split methods across foo.go, foo_apply.go, …). It also affects Rust impl blocks placed in separate files.

Suggested fix: for methodsAreTopLevel / receiver-based languages, bind receiver-type → method as a resolution-phase join over the whole package rather than a same-file extraction-time lookup. After extraction, for each Go/Rust method node that has a receiver type but no contains parent, link it to the type node of matching name within the same package (same directory / module package). The package model added for cross-package call resolution (#388) already supplies what's needed.

Impact

  • Type member sets are incomplete in codegraph_node outlines and in every method-set computation.
  • callers/callees/impact on these methods lose the structural relationship to their type.
  • goGrpcStubImplEdges matches hand-written impls by comparing method-name sets built from contains edges; an impl whose methods span files — or any generic service — will not match.
  • Blocks correct Go interface-satisfaction (filed as a companion issue), which also needs complete method sets.

Notes

  • The generic struct node is indexed but its type_parameters column is empty — minor, same generics-coverage theme.
  • No existing issue covers this (searched open + closed for "generic receiver", "type parameter", "cross-file method").

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions