fix(go): resolve a package-qualified accessor chain on the factory's return type instead of a same-named method - #1640
Conversation
…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.
22f7e5c to
0abaef2
Compare
|
Force-pushed to 1. Validation on the three public GoFrame repos from the coverage playbook.
Node count identical on all three, #747's route edges untouched. hotgo shows the 2. An alias no longer loses package disambiguation. The first push matched 3. I withdraw the open question from the original description. I wrote that a Tests are 8 now; reverting the two source changes reds 6 of them. Full suite |
The shape
pkg.Factory().Method()reaches the resolver as a bareMethodref, so the namefallbacks 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:
service.X()is the accessorgf gen servicewrites for every service in everyGoFrame 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 statedinvariant 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 aselector_expression— thesame node type as an instance chain (
obj.Method().Other()), whose receivertype 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,
matchDottedCallChaintreated the dotted prefixas a receiver type and looked up
service::SysTable. Go package-level functionscarry a bare
qualifiedName(SysTable, notservice.SysTable), so thatlookup can never match.
The fix
src/extraction/tree-sitter.ts— re-encode a Goselector_expressioninner 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.
src/resolution/name-matcher.ts— a Go branch inmatchDottedCallChain: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 yieldno 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 -> implementationend to end.Validation on the three public GoFrame repos
Baseline vs. this build, same clone,
codegraph initeach way. Node count isidentical on all three, and the
routeedges #747 builds are untouched:controller -> service -> logic2-hop pathsThe 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:
service.SysTable().View(…)landedon
model.View, a struct;gjson.New(req).String()landed onan unrelated
apiItem::Stringinhggen/internal/cmd/genctrl;g.Redis().Do(ctx, "GET", k)landing on a project function namedDo.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 onphp-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 ctrlandgf gen servicemake the controller method, the service interface method and theimplementation share one method name by construction. Grouping every
callsedge out of its test tree by whether a same-named controller method exists:
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, adifferent 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,
explorewith the implementation file pinned: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).