Skip to content

fix(go): resolve a package-qualified accessor chain on the factory's return type instead of a same-named method - #1640

Open
jiang-bx wants to merge 1 commit into
colbymchenry:mainfrom
jiang-bx:fix/go-package-qualified-accessor-chain
Open

fix(go): resolve a package-qualified accessor chain on the factory's return type instead of a same-named method#1640
jiang-bx wants to merge 1 commit into
colbymchenry:mainfrom
jiang-bx:fix/go-package-qualified-accessor-chain

Conversation

@jiang-bx

@jiang-bx jiang-bx commented Aug 28, 2026

Copy link
Copy Markdown

The shape

pkg.Factory().Method() reaches the resolver as a bare Method ref, so the name
fallbacks match it against any same-named method on an unrelated type — and,
when the enclosing method happens to share the name, against itself.

That is not hypothetical on a GoFrame app. In hotgo, one of the repos in the
coverage playbook:

func (c *cTable) List(ctx context.Context, req *sysin.TableListReq) (*sysin.TableListRes, error) {
	list, totalCount, err := service.SysTable().List(ctx, &req.TableListInp)
	//                                        ^ resolved to cTable::List — a self-edge
}

service.X() is the accessor gf gen service writes for every service in every
GoFrame project, so this is the normal shape of a call into business logic, not
an edge case. The same failure hits a concrete-returning factory —
including the Foo.factory().method() case #760 landed for Go, whose stated
invariant is that "a wrong inference produces no edge, never a wrong one"
(#750).

Root cause — two layers

Extraction. A chained receiver is re-encoded as <innerCallee>().<method>
so resolution can infer the type from what the inner call returns (#645/#608).
For Go this fired only when the inner callee was a bare identifier
(New().Method()). A package-qualified factory is a selector_expression — the
same node type as an instance chain (obj.Method().Other()), whose receiver
type isn't recoverable and which must stay bare — so both were dropped and the
ref went out as a bare name.

Resolution. Even re-encoded, matchDottedCallChain treated the dotted prefix
as a receiver type and looked up service::SysTable. Go package-level functions
carry a bare qualifiedName (SysTable, not service.SysTable), so that
lookup can never match.

The fix

  1. src/extraction/tree-sitter.ts — re-encode a Go selector_expression
    inner callee when its operand names a package the file imports (alias
    included). The import set is what separates a package qualifier from a
    variable receiver, so instance chains keep the behaviour they had.
  2. src/resolution/name-matcher.ts — a Go branch in matchDottedCallChain:
    look the factory up by name, map the call-site qualifier back through the
    file's imports (an alias doesn't name its directory), disambiguate by the
    import path's tail, and validate the method on the inferred type via
    resolveMethodOnType. Candidates that disagree on their return type yield
    no edge rather than a guess.

An interface return lands on the interface's method, which the
dynamic-dispatch pass already bridges to the implementation — closing
route -> controller -> service -> implementation end to end.

Validation on the three public GoFrame repos

Baseline vs. this build, same clone, codegraph init each way. Node count is
identical on all three
, and the route edges #747 builds are untouched:

repo files nodes edges controller -> service -> logic 2-hop paths route edges
gf-demo-user 38 248 → 248 474 → 474 0 → 7 7 → 7
gfast 184 1,875 → 1,875 4,260 → 4,236 0 → 82 65 → 65
hotgo/server 697 9,592 → 9,592 21,739 → 21,609 0 → 327 243 → 243

The chain from a controller into the logic it calls did not exist before, in any
of the three.

Every removed edge sampled was wrong, in four flavours:

  • the controller self-edge above;
  • a call matched to a same-named typeservice.SysTable().View(…) landed
    on model.View, a struct;
  • a chain through a dependency's accessor — gjson.New(req).String() landed on
    an unrelated apiItem::String in hggen/internal/cmd/genctrl;
  • likewise g.Redis().Do(ctx, "GET", k) landing on a project function named
    Do.

Those now resolve to nothing, which is the correct answer for a receiver whose
type lives in a dependency.

Tests

__tests__/go-package-accessor-chain.test.ts, modelled on
php-property-receiver-resolution.test.ts — 8 cases: interface accessor vs.
decoy, reaching the impl through the interface, concrete accessor vs. decoy,
import alias, picking the aliased package the import path names over another
exporting the same function, absent-method safety, a factory in a package
outside the index, and a guard that an instance chain stays on the bare-name
path.

Reverting the two source changes turns 6 of the 8 red. The two that stay
green are the instance-chain guard and absent-method safety — both pin behaviour
that must not change. Reverting only the alias mapping reds exactly the one
alias test.

Full suite: 179 passed | 16 skipped, 3,060 tests.

Also measured on a large private repo

A GoFrame monorepo, 3,328 files / 44,564 nodes, where gf gen ctrl and gf gen service make the controller method, the service interface method and the
implementation share one method name by construction. Grouping every calls
edge out of its test tree by whether a same-named controller method exists:

target name lands on the implementation lands on a controller method
no same-named controller method 1,950 0
has a same-named controller method 0 2,910

After: calls landing on a controller method fall 3,067 → 268 (the remainder are
ORM fluent chains — dao.X.Ctx(…).Update(…) — through an external type, a
different shape not addressed here), and two-hop paths from a test through the
service interface to the implementation go 0 → 5,619.

One concrete consequence, explore with the implementation file pinned:

before   implementation -> "no tests found within 3 caller hops"   (it has 16)
         controller     -> "20 callers; tests: create_test.go, ..."

after    implementation -> "tested via callers: create_test.go, index.go +1"
         controller     -> "2 callers; no tests found"

Related

#750 (tracking; the invariant), #760 / #469 (Go chained factories), #747 (the
GoFrame route resolver that builds the first half of this chain), #1496 (the
same self-edge shape in TypeScript), #1585 / #1545 (Rust / PHP).

…return type instead of a same-named method

`pkg.Factory().Method()` reached the resolver as a bare `Method` ref, so the name
fallbacks matched it against any same-named method on an unrelated type — and,
when the enclosing method shared the name, against itself (the colbymchenry#1496 shape).

On hotgo, `service.SysTable().List(ctx, …)` inside `func (c *cTable) List(…)`
resolved to `cTable::List`: a self-edge.

Extraction re-encoded a chained receiver for Go only when the inner callee was
a bare `identifier` (`New().Method()`). A package-qualified factory is a
`selector_expression` — the same node type as an instance chain
(`obj.Method().Other()`), whose receiver type is not recoverable and which must
stay bare — so both were dropped. The file's import set separates them: re-encode
only when the selector's operand names an imported package.

Resolution then has to read the factory's declared return type, but Go
package-level functions carry a bare qualifiedName (`Order`, not
`service.Order`), so the `Class::method` lookup used by the dot-notation
languages never matched. Look the factory up by name, map the call-site
qualifier back through the file's imports (an alias does not name its
directory), disambiguate by the import path's tail, and validate the method on
the inferred type through `resolveMethodOnType`. Candidates that disagree on
their return type yield no edge rather than a guess.

An interface return lands on the interface's method, which the dynamic-dispatch
pass already bridges to the implementation, closing
route -> controller -> service -> implementation.

Validated on the three public GoFrame repos from the coverage playbook, baseline
vs. this build. Node count is identical on all three and the `route` edges from
colbymchenry#747 are untouched:

  repo          files  nodes   edges          ctrl->svc->logic  route edges
  gf-demo-user     38    248  474  -> 474     0 -> 7            7   -> 7
  gfast           184  1,875  4,260 -> 4,236  0 -> 82           65  -> 65
  hotgo/server    697  9,592  21,739 -> 21,609  0 -> 327        243 -> 243

Every removed edge sampled was wrong: controller self-edges as above, calls
matched to a same-named TYPE (`service.SysTable().View(…)` -> `model.View`), and
chains through a dependency's accessor (`gjson.New(req).String()` ->
an unrelated `apiItem::String`, `g.Redis().Do(…)` -> a project function named
`Do`). Those now resolve to nothing, which is the correct answer.
@jiang-bx
jiang-bx force-pushed the fix/go-package-qualified-accessor-chain branch from 22f7e5c to 0abaef2 Compare August 28, 2026 09:58
@jiang-bx

Copy link
Copy Markdown
Author

Force-pushed to 0abaef2. Three changes since the first push, and I've rewritten
the description to match.

1. Validation on the three public GoFrame repos from the coverage playbook.
The original description only had numbers from a private repo, which isn't
reproducible for you. Baseline vs. this build, same clone:

repo files nodes edges controller -> service -> logic 2-hop paths route edges
gf-demo-user 38 248 → 248 474 → 474 0 → 7 7 → 7
gfast 184 1,875 → 1,875 4,260 → 4,236 0 → 82 65 → 65
hotgo/server 697 9,592 → 9,592 21,739 → 21,609 0 → 327 243 → 243

Node count identical on all three, #747's route edges untouched. hotgo shows the
sharpest form of the bug — service.SysTable().List(...) inside
func (c *cTable) List(...) resolved to cTable::List, a self-edge, so the
callers/impact/blast-radius answers for every GoFrame service method were
wrong. Every removed edge I sampled was wrong: that self-edge, calls matched to a
same-named type (service.SysTable().View(...)model.View), and chains
through a dependency's accessor (gjson.New(req).String() → an unrelated
apiItem::String; g.Redis().Do(...) → a project function named Do).

2. An alias no longer loses package disambiguation. The first push matched
the call-site qualifier against the candidate's directory, which an alias
detaches (ctrlcart "…/order/cart"). It now maps the qualifier back through the
file's imports and matches on the import path's tail, so aliased and
renamed-package imports disambiguate like any other. New test; reverting just
that mapping reds exactly that one test.

3. I withdraw the open question from the original description. I wrote that a
recovered receiver type lacking the method would still fall through to the
bare-name path and let a decoy win. It doesn't — the re-encoded ref is
inner().method, and the downstream fallbacks look that whole string up and find
nothing. So #750's invariant holds here as written; there's no design call left
for you to make on it. There's now a test pinning it (absent-method safety),
which stays green with the fix reverted, as it should.

Tests are 8 now; reverting the two source changes reds 6 of them. Full suite
179 passed | 16 skipped.

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.

1 participant