Skip to content

fix(go): a struct links the types its fields are declared with - #2655

Merged
colbymchenry merged 2 commits into
mainfrom
claude/affectionate-borg-e6f54f
Oct 19, 2026
Merged

colbymchenry merged 2 commits into
mainfrom
claude/affectionate-borg-e6f54f

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

A Go struct referenced nothing through its fields. etcd's type PutResponse struct { Header *ResponseHeader } or gin's type Context struct { handlers HandlersChain } was a struct node with no references edge to the types its fields are declared with, so impact on ResponseHeader or HandlersChain missed every struct that holds one, and everything that uses those structs. On current main, gin, etcd, prometheus and kubernetes have 0 references edges out of a struct (kubernetes has 13,631 structs), and Go fields mint no nodes. Only embedded fields were read, as extends edges (#2397).

Fix

TS extractor and Rust kernel, mirrored:

  • goStructFieldTypeNames (src/extraction/languages/go.ts) and struct_field_type_names (codegraph-kernel/src/go.rs) walk each named field's type and push a references ref per type name, from the struct, on the name (where resolution reads a package qualifier back).
  • They reuse fix(go): a defined type links the types it is defined from #2441's walk, factored into collectGoTypeNames / collect_type_names plus goDeclaredNames / declared_names, so aliases, defined types and struct fields skip the same names: Go's predeclared types and, written bare, the declaration's own type parameters and its own name. A qualified name is never skipped.
  • An embedded field stays an extends edge and gets no reference for the type it embeds, but the type arguments of an embedded generic are references (see below).

Resolver: gateTargetKind's unknown-qualifier rule (#2417, aliases only) now covers every Go type declaration (GO_TYPE_KINDS), so a field written through a package the index doesn't know links to nothing.

Explore: a references edge between two type declarations no longer makes either end "connected to an entry" (see below).

Design points

References from the struct, no field nodes.

Embedded fields: the embedded type stays extends, its type arguments become references.

  • Kubernetes has 651 embedded generic fields naming 1,335 types in their type arguments: every client-go typed client (*gentype.ClientWithListAndApply[*v1.Pod, *v1.PodList, …]) and lister (listers.ResourceIndexer[*v1.Pod]). No edge covered these, so impact on Pod missed its lister.
  • gin, etcd and prometheus have none.

A struct naming itself is skipped at extraction. next *node, a tree's children []*Tree and etcd's ZapLoggerBuilder func(*Config) error inside Config all name the struct itself (gin 2, etcd 7, prometheus 7, kubernetes 262 such names). Skipping them leaves no self-loop and no failed unresolved row for dead code to read as a possible reference (the #2441 rationale). In Go, a bare name in a type's own declaration is always that type, so the skip is exact.

A generic struct's own type parameters are skipped (items []T): etcd 4, prometheus 10, kubernetes 245.

Resolution reads them as any Go type position (goTypePositionTarget, #2416): a bare name is its own package's type, pb.ResponseHeader the import's, and context.Context (outside the project) nothing.

  • The unknown-qualifier gate was needed. Without it, the indexed test's op clientv3.Op, written under an unaliased go.etcd.io/etcd/client/v3 import, bound to the same file's own Op, and kv clientv3.Client bound to the right type only by name luck.
  • After fix(go): an unaliased import is also known by the name goimports assumes for its package #2410, none of the 23,000+ new edges in the four repos is written through an unknown qualifier, so the gate changes nothing measured.

Explore: type-to-type references are structure, not flow.

  • With only the new edges, the codegraph_explore is token-heavy vs Instant Grep — dumps whole files and can conclude from thin context #1500 CG-10 gate failed: codegraph_explore on the payroll fixture lost runPayrollCycleAll's body (s.store.Upsert(ctx, slip)).
  • Cause: RunResult, an 8-line struct holding a []payroll.Payslip, became "connected to an entry" through its field type. It then took the 51-line method's place in cycle.go's shrunk cluster, which keeps equal-importance members smallest first.
  • Now a references edge whose two ends are both type declarations (a struct's field type, the type an alias or defined type names, a Dart class's field) doesn't count toward connectedness, in either direction. The struct still enters the subgraph; it just ranks as peripheral.
  • With the rule, all three allocation fixtures pass, and cycle.go delivers the same 5,274 chars as main.

Validation

Before/after full indexes (main de3eb71d, kernel path), diffed per reference site. Each new edge was judged against Go's scoping using each package's real package clause: a bare name is its own package's type, and q.Name is the type of the import whose package clause is q.

repo edges added bare → own package pkg.X → that package dropped / retargeted / metadata unresolved (outside the project) other unresolved
gin +41 39 ok 2 ok 0 / 0 / 0 57 0
etcd +1,006 585 ok 421 ok 0 / 0 / 0 1,096 1
prometheus +1,637 845 ok 792 ok 0 / 0 / 0 1,301 13
kubernetes +20,203 8,845 ok 11,358 ok 0 / 0 / 0 4,309 (+1 the judge misreads) 3
  • Every added edge is a references edge from a struct to a struct, interface or defined type in the right package.
  • One kubernetes bare name lands in another package through a dot import (. "k8s.io/kubernetes/pkg/volume"); it's right.
  • No node changes, no new self-loops, no Go field nodes. Kernel and wasm indexes are edge-for-edge identical on gin, etcd and prometheus.
  • The other unresolved rows are pre-existing resolver gaps that function parameters hit the same way on main (follow-ups below):
    • prometheus's memSeries and sample, and kubernetes's simpleCache and pruner, are unexported types used in another file of their own package, and another package declares the same name.
    • etcd's v3.Op sits in a file that imports both v3 "go.etcd.io/etcd/client/v3" and an unaliased github.com/cheggaaa/pb/v3.
    • kubernetes's pods.PodsServer is not in the index.
  • scripts/kernel-parity.mjs --lang go: gin 99/99, prometheus 736/736, etcd 1,065/1,105 with 0 diffs; etcd's 40 deferred files are the same 40 on main.

Impact at depth 3 and what codegraph_node / the viewer show, before → after:

symbol impact nodes files
etcd ResponseHeader 658 → 2,082 114 → 211 viewer callers 131 → 186, now including PutResponse, RangeResponse, DeleteRangeResponse
etcd PutResponse 142 → 177 61 → 69 codegraph_node trail gains Calls → KeyValue, ResponseHeader; viewer "uses types" chips: ResponseHeader, KeyValue
gin HandlersChain 175 → 764 19 → 38 viewer incoming now leads with Engine, Context, RouterGroup
kubernetes Container 1,546 → 7,821 421 → 1,733 reached through PodSpec and its users

codegraph_explore: fixed flow queries were run on before/after indexes (8 each on gin, etcd and prometheus, 5 on kubernetes), comparing the callables and type declarations each response renders.

  • gin renders the same symbols.
  • prometheus gains 3 interface/struct declarations and loses nothing.
  • etcd renders InternalRaftRequest (the struct the Put flow builds) instead of RequestHeader, which costs 9 incidental auth signatures in a backend.go skeleton.
  • kubernetes renders manageReplicas's body for a query that names it (main only named it) and SyncTerminatingPod's body for "how does the kubelet sync a pod". Every named body is still rendered.
  • The explore rule alone, on main's own indexes, changes no rendered symbol:
    • gin, etcd and kubernetes are byte-identical, and prometheus differs in text only on 3 of 8 queries;
    • on TypeScript and Dart (bulletproof, refine-examples, bloc), 27 of 30 generated queries are byte-identical and the rest only reorder.

Cost:

  • Index time, interleaved runs: prometheus median 13 s → 12 s and kubernetes 125 s → 122 s (3 runs per arm each), within noise.
  • DB size +0.4% to +1.4% (kubernetes 1,100 → 1,112 MB).
  • Impact on the biggest kubernetes hub, ObjectMeta, grows from 31,778 to 40,254 nodes and 2.7 s to 3.6 s.
  • The dead-code report is unchanged on gin, etcd and prometheus.

Tests

  • New __tests__/go-struct-field-refs.test.ts (46 tests; 38 fail on main, and the 8 that pass are the no-field-node guards):
    • extraction in both backends, LF and CRLF, covering every field-type shape: pointer, slice, map, channel, array, function, parenthesized, qualified, generic, an anonymous struct or interface in a field, a, b T, a struct literal behind =. Also the skipped names (predeclared, type parameters, a bare self-name, embedded types) and embedded generics' type arguments, each ref sitting on its name;
    • an indexed module under kernel and wasm, LF and CRLF, with namesakes in a package that sorts first: own-package and import resolution, context.Context linking nothing, a self-holding struct (no self-edge, no unresolved row), type parameters, an unknown qualifier (no link), a client-go lister, impact, and no field nodes;
    • incremental sync linking a field's type declared in a file added later.
  • __tests__/go-type-alias.test.ts and __tests__/go-defined-type-refs.test.ts: their fixtures' structs (WatchResponse, Context) now reference their fields' types.
  • __tests__/fixtures/kernel-parity/torture.go gains the struct-field shapes, so parity is checked in LF and CRLF.
  • __tests__/explore-allocation-1500.test.ts's CG-10 gate is the regression test for the explore rule: it fails with the new edges and without the rule.
  • Full suite on de3eb71 + this change (--maxWorkers=4 --minWorkers=1, kernel expected): 7,374 passed, 0 failed, 80 skipped. After merging fix(ts): index abstract methods as methods of their class #2653 (TypeScript abstract methods, which touched tree-sitter.ts and the kernel's TS walker) the kernel was rebuilt and the overlapping files rerun: this PR's three Go test files, ts-abstract-methods, kernel-tsjs-parity, explore-allocation-1500 and extraction, 928 tests, all passing.

Follow-ups found

  1. A bare unexported type used in another file of its package, like prometheus tsdb's memSeries or sample, fails to resolve whenever another package declares the same name. This happens from parameters and results as well as fields.
  2. An explicitly aliased import v3 "…/client/v3" loses its qualifier to an unaliased github.com/cheggaaa/pb/v3 in the same file.
  3. A Go interface's method signatures reference no types: KV.Put(…) (*PutResponse, error) has no edge to PutResponse, so impact on PutResponse misses the interface method and its callers.

🤖 Generated with Claude Code

colbymchenry and others added 2 commits October 19, 2026 04:20
A Go struct referenced nothing through its fields: etcd's `type
PutResponse struct { Header *ResponseHeader }` or gin's `type Context
struct { handlers HandlersChain }` had no `references` edge to those
types, so impact on `ResponseHeader` or `HandlersChain` missed every
struct holding one, and their users. Main had 0 such edges on gin, etcd,
prometheus and kubernetes; only embedded fields were read (`extends`).

TS extractor and Rust kernel, mirrored:
- goStructFieldTypeNames / struct_field_type_names walk each named
  field's type and push a `references` ref per type name from the
  struct, on the name. Fields mint no nodes: impact never climbs
  `contains` upward (#536), so a field node would stop impact short of
  the struct; Dart attributes field types to the class too (#2327).
- #2441's alias walk is factored into collectGoTypeNames /
  collect_type_names + goDeclaredNames / declared_names, so struct
  fields skip what aliases skip: predeclared types and, written bare,
  the declaration's own type parameters and its own name (no self-edge,
  no failed row for `next *node`).
- An embedded field stays `extends`; an embedded generic's type
  arguments are references (client-go's
  `listers.ResourceIndexer[*v1.Pod]`: 651 such fields in kubernetes).

Resolver: gateTargetKind's unknown-qualifier rule (#2417) now covers
every Go type declaration (GO_TYPE_KINDS). Without it, a field
`op clientv3.Op` under an unaliased go.etcd.io/etcd/client/v3 import
bound to the same file's own `Op`.

Explore: a `references` edge whose ends are both type declarations no
longer makes either end connected to an entry. With the new edges alone
the #1500 CG-10 gate failed: payroll's 8-line `RunResult` (holding a
`[]Payslip`) became connected and displaced the 51-line
runPayrollCycleAll body. The rule alone changes no rendered symbol on
gin, etcd, prometheus, kubernetes, bloc, bulletproof or refine-examples.

Validation (main de3eb71, site-keyed A/B, every added edge judged
against real package clauses): gin +41, etcd +1006, prometheus +1637,
kubernetes +20203, all correct; 0 dropped/retargeted/metadata-only; no
node changes or self-loops; kernel == wasm. Index time within noise,
DB +0.4-1.4%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit a944ec3 into main Oct 19, 2026
@colbymchenry
colbymchenry deleted the claude/affectionate-borg-e6f54f branch October 19, 2026 09:35
colbymchenry added a commit that referenced this pull request Oct 19, 2026
…2659)

A Go interface's methods are `method` nodes, but their signatures named
no types: etcd's `KV.Put(ctx context.Context, key, val string, opts
...OpOption) (*PutResponse, error)` had no edge to `OpOption` or
`PutResponse`. A call through the interface reaches that method, so
impact on those types missed the interface method and the callers that go
through it.

TS extractor and Rust kernel, mirrored:
- extractGoInterfaceMethods / extract_go_interface_methods give each
  method a `references` ref per type name in its parameters and results
  (goInterfaceMethodTypeNames / interface_method_type_names, on #2655's
  collectGoTypeNames).
- Skipped: the predeclared types and, written bare, the interface's own
  type parameters (goTypeParameterNames / type_parameter_names, split out
  of goDeclaredNames). The interface's own name is a type its method names
  (`Children() []Node`), so it is kept.

A/B on main 6692e05: gin +34, etcd +775, prometheus +294, kubernetes
+11079 edges; every one judged against the real package clause (bare →
own package, `pkg.T` → the import's package, kubernetes' one dot import
included); 0 dropped/retargeted/metadata-only. Kernel and wasm indexes
are identical. Explore keeps every named body on 29 flow queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@danusha2345

Copy link
Copy Markdown
Contributor

Since this merged, __tests__/explore-cross-call-dedup.test.ts fails on main — one case, on both the wasm and the kernel path:

× a second call against a real index > never re-sends a line it already sent, and points at every line it withholds
Error: call 2 re-sent internal/usecase/payroll/cycle.go lines 31,32,33,34,35

Checked commit by commit with CODEGRAPH_KERNEL=0 npx vitest run __tests__/explore-cross-call-dedup.test.ts: 26/26 at c7b5038 (the commit before this one), 1 failed from a944ec3 on, still at 383c2cd. The fixture is __tests__/fixtures/payroll-go, so the new struct-field references presumably bring a second call's answer back to cycle.go lines 31–35 that the first call already sent.

danusha2345 pushed a commit to danusha2345/codegraph that referenced this pull request Oct 21, 2026
…ack inside the block around it

Fixes colbymchenry#2756. `explore-cross-call-dedup`'s handler case failed on main
since colbymchenry#2655: call 1 now serves the 7-line `RunResult` struct of
cycle.go (the struct references `Payslip` through its field), and
call 2 re-sends those lines inside its render of the file's head.
That is rule 3 of the range algebra, not a bookkeeping gap — the
session record holds L31-37, and a held run shorter than
MIN_COVERED_LINES is re-emitted whole rather than replaced by a
pointer, so the block is not shredded. The test now fails only on a
repeated run at least that long.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit c000091)
colbymchenry added a commit that referenced this pull request Oct 22, 2026
…kouts alike (#2772)

explore-cross-call-dedup's "never re-sends a line it already sent" case
has failed on every LF checkout (Linux, macOS, CI) since PR #2655. It
passed on Windows only because core.autocrlf writes the fixture with CRLF.

Root cause. Since PR #2655 the payroll fixture's RunResult struct
(cycle.go lines 31-37) references Payslip through a field, so call 1
renders it. Call 2 renders the file's head, and that 7-line held run comes
back inside it: dedupeRange keeps a covered run shorter than
MIN_COVERED_LINES (8) in the emit set on purpose, rule 3 of the range
algebra, which its own unit test pins. The handler case counted any
repeated line as a duplicate. On a CRLF checkout explore's per-file
character budget also counts each line's carriage return, so cycle.go
rendered fewer lines and the case never met the short run.

Fix, test only:
- The case groups the repeated lines into consecutive runs and fails on a
  run of MIN_COVERED_LINES lines or more, read from EXPLORE_DEDUP. A
  shorter run is the algebra's deliberate re-send. What agents receive is
  unchanged.
- The suite gives its copy of the fixture LF line endings, so every
  checkout indexes and renders the same bytes.

Validation on Windows, with the kernel and with CODEGRAPH_KERNEL=0:
- main's test fails with an LF fixture ("call 2 re-sent
  internal/usecase/payroll/cycle.go lines 31,32,33,34,35") and passes with
  the CRLF one. The new test passes 26/26 with both.
- With the normalisation and main's assertion, a CRLF checkout fails the
  way an LF one does (lines 31-37): both checkouts now test one scenario.
- Teeth: an algebra mutated to re-send held runs of 8-23 lines fails the
  new case ("call 2 re-sent internal/usecase/payroll/cycle.go lines 42-51
  (10 lines)").
- tsc on the test file is clean.

Contributor PR #2758 by @danusha2345 reached the same diagnosis. This
change was written separately and also normalises the fixture.

Fixes #2756

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants