Skip to content

fix(go-lsp): type variables bound by cross-file multi-value and named results - #2703

Merged
DeusData merged 3 commits into
mainfrom
fix/go-lsp-multi-return
Oct 19, 2026
Merged

DeusData merged 3 commits into
mainfrom
fix/go-lsp-multi-return

Conversation

@DeusData

Copy link
Copy Markdown
Owner

Problem

The Go LSP types a variable from the call that binds it: e := enc.New() gives e the result type of enc.New, and e.Encode() then binds the right method. This works when the callee is in the same file, because the resolver reads the AST. For a callee in another file, it reads the result type from the cross-file registry. There, pass_lsp_cross.c handed over the raw result text. The Go registrar splits that text on | and parses each piece as one type (*T, []T, builtins, named types). A result list such as (*Encoder, error) or the named (e *Encoder) therefore parsed as nothing. As a result, every variable bound by a cross-file multi-value or named-result call was untyped:

e, err := enc.NewEncoder()   // (*Encoder, error)
h, _ := newHead()            // (*Head, error), then h.iso.LowWatermark()
_, d := diag.Run()           // (int, Diagnostics)
x, _ := enc.NewPair()        // (a, b *Encoder)

Calls on those variables fell back to name guesses (suffix_match, same_module, unique_name). Go code returns (T, error) everywhere, so this is a large share of Go method calls.

Fix

Go only, in pass_lsp_cross.c. A result list (...) is split at its top-level commas into its raw element types:

result registry text
(*T, error) *T|error
(t *T, err error) *T|error
(a, b int) int|int
  • A single result keeps its raw text, unchanged, so []T stays a slice.
  • chan, func, map, struct and interface are never taken for result names.
  • A list that does not split cleanly keeps its raw text, which is the old behaviour.
  • Other languages are untouched.

The extractor's return_types are deliberately not used. They are cleaned names for type-reference edges (clean_type_name strips [] and *), and []Sample read as Sample mistypes xs[0].T(). The corpus check of a first version of this change caught exactly that, and a fixture case now pins it.

The persisted LSP surface carries this string, so SURFACE_CODEC_VERSION goes from 2 to 3: existing caches re-extract Go surfaces once. The codec tests' literal v2 envelopes move to v3, so that they keep testing the rule they name rather than the version mismatch.

Tests

pipeline_go_multi_return_receiver_is_typed runs sequentially, and its _parallel twin runs with 52 filler files and CBM_WORKERS=4. It covers seven cross-file call sites:

  • tuple;
  • named result;
  • grouped named results;
  • a tuple-bound local reached through a field;
  • a named slice type;
  • a single slice result that is indexed;
  • a tuple with a slice element.

Every target method has two same-named decoys, so a name guess cannot pass. Each site must bind the right file, never a decoy, with an lsp_* strategy.

  • With the pass_lsp_cross.c change reverted, both tests are RED: UseTuple -> EncodeTuple: right=1 decoy=0 lsp=0, a name guess, not typed.

Evidence

All binaries are clean builds (build/ removed first).

  • Base: main 658639e.
  • 13 Go repos, parallel resolver ×2; caddy and prometheus also sequential ×2. Every run is identical to its repeat.
  • Precision and recall come from scip-go 0.2.7 as an independent oracle. Precision counts only the 6 repos where scip-go records external references.
precision recall
main 0.632 (88,080 / 139,301) 0.926
this PR 0.641 (89,270 / 139,356) 0.934

No repo got worse: precision and recall are equal or higher on all 13.

Changed strategies (edges, answerable call sites, correct):

strategy main this PR precision
lsp_type_dispatch 33,103 / 20,650 / 20,642 39,809 / 25,083 / 25,071 1.000 -> 1.000
lsp_embed_dispatch 125 / 83 / 83 150 / 108 / 108 1.000 -> 1.000
same_module 15,927 / 8,461 / 3,781 11,304 / 6,008 / 1,818 0.447 -> 0.303
suffix_match 56,780 / 36,876 / 3,092 54,511 / 35,425 / 2,203 0.084 -> 0.062
unique_name 15,838 / 10,696 / 2,417 15,314 / 10,284 / 2,006 0.226 -> 0.195
field_type_hint 7,551 / 3,944 / 17 7,282 / 3,863 / 16 0.004 -> 0.004
lsp_interface_dispatch 3,577 / 1 / 0 4,951 / 3 / 0 (oracle cannot answer)

The new typed edges are correct (4,429 of 4,433 answerable sites). The name-guess strategies keep their wrong edges and lose correct ones, because those calls now resolve by type instead.

lsp_interface_dispatch grows by 1,374 edges: values from cross-file (Iface, error) results now bind the interface method. Examples are prometheus app.Append -> storage.Appender.Append and vault inm.Put -> physical.Backend.Put. scip-go answers almost none of these call sites. A sample with source was 8 of 8 correct.

Other edge types:

  • USAGE -77 / CALL_REFERENCE +79: method values such as defer c.Close become typed references.
  • SEMANTICALLY_RELATED moves (+630/-820) because it is built from CALLS neighbours.
  • Parallel/sequential parity: CALLS gains +214 parallel and +224 sequential on caddy and prometheus, and the gap goes from 148 to 138.

Go only. On spring-petclinic-microservices (Java), composer (PHP) and moshi (Kotlin), the node and edge dumps are byte-identical to main.

Together with #1907 and #1913. Those PRs remove weak-guess edges, and this PR wins back many of the true calls among them. Measured on a local merge of this branch into their head:

  • scip-go recall 0.873 -> 0.910 and precision 0.944 -> 0.948;
  • 8,716 caller -> callee pairs recovered, 0 lost;
  • vault 0.792 -> 0.861, terraform 0.820 -> 0.868, prometheus 0.878 -> 0.944.

Not in this PR: embedded-struct promotion, package-qualified types read with the using file's imports, method-return chains, func-typed values, range variables and aliases. Each stays a separate change.

…ables

The cross-file pass handed every registrar the raw result text of a
def (pass_lsp_cross.c: `dst->return_types = src->return_type`). The Go
registrar splits that string on '|' (go_lsp.c split_pipe_types), so it
got `(*Encoder, error)` as one piece. That piece did not parse, and a
cross-file callee with more than one result, or with a named result
`(e *Encoder)`, had no usable return type. As a result,
`e, err := enc.NewEncoder()`, `h, _ := newHead()` and
`_, d := diag.Run()` all left the variable untyped, and calls on it fell
to name guesses. Same-file calls were fine, because they read the AST.

Go defs now pass the parsed result list (extract_go_multi_return),
'|'-joined: `*Encoder|error`, and `*Encoder` for the named result. Other
languages keep the single raw string.

The persisted LSP surface carries this string, so SURFACE_CODEC_VERSION
goes from 2 to 3: existing caches re-extract Go surfaces once. The
literal version-2 surfaces in the codec tests move to 3, so that they
keep testing the envelope rule they name rather than the version
mismatch.

Tests: pipeline_go_multi_return_receiver_is_typed (+_parallel). A tuple
result, a named result, a tuple-bound local reached through a field,
and a named slice type each must bind the right method with an lsp_*
strategy and none of its two same-named decoys.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The Python surface-envelope test (pipeline round 0) checks that the
persisted row is the versioned codec envelope by looking for the
literal "v":2. With SURFACE_CODEC_VERSION at 3, the envelope reads
"v":3. The assertion keeps its meaning (the row is the versioned
envelope and the def is present) and follows the version.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The first D1 change fed the Go cross-file registrar the extractor's
return_types. Those are cleaned names for type-reference edges:
clean_type_name strips `[]` and `*`. A cross-file single result `[]T`
therefore reached the registrar as `T`. `xs := pkg.Generate(); xs[0].M()`
lost its typing and fell back to a name guess. On prometheus the base
binds `sample[0].T()` to chunks.Sample.T, but the first D1 change bound
it to tsdb.T.

pass_lsp_cross.c now leaves a single result's raw text alone, as before
D1; the registrar already parses `*T` and `[]T`. Only a result LIST
`(...)` is split, at its top-level commas, into raw element types:
- `(*T, error)` -> `*T|error`;
- the named `(t *T, err error)` -> `*T|error`;
- the grouped `(a, b int)` -> `int|int`.
`chan`, `func`, `map`, `struct` and `interface` never count as result
names. A list that does not split cleanly keeps its raw text.

Tests: the D1 fixture gains three call sites, each with two same-named
decoys:
- a single slice result indexed (`xs[0].TS()`);
- a tuple with a slice element;
- grouped named results.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData
DeusData merged commit 684dcf5 into main Oct 19, 2026
29 checks passed
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