FINDING — go2cs assumes one toolchain; Go stopped guaranteeing that

Status: characterization complete, remedy staged. Lane L5, 2026-08-11, laptop. Origin: issue #37 — “Missing standard packages”, -recurse=nuget emitting PackageReferences that nuget.org cannot satisfy.

Headline. The reporter’s eight missing package ids are three unrelated defects wearing one costume, and only the third is what the issue title says. Six of the eight are Go 1.24 standard-library additions with no counterpart in the published Go 1.23.1 corpus — a version mismatch the converter never checks for. Two are a name the converter mints wrong. And underneath both sits a structural assumption that has quietly expired: go2cs resolves GOROOT from the AMBIENT toolchain while go/packages loads from the MODULE-SELECTED one, and since Go 1.21’s toolchain directive those are routinely different machines’ worth of standard library.

The converter already knows this can happen — loaderReleaseTags (directiveOperations.go:233) resolves release tags by asking go env GOVERSION from the loader’s directory, with a comment on getGoEnvFrom spelling out the GOTOOLCHAIN hazard verbatim. That insight was simply never applied to GOROOT. The inconsistency is the bug.


1. Root cause — exact

main.go:52-62 resolves GOROOT once, before the input path is even parsed, from the process environment or a bare go env GOROOT:

if goRoot = os.Getenv("GOROOT"); len(goRoot) == 0 {
    if goRoot, err = getGoEnv("GOROOT"); err != nil {   // <- no dir: the AMBIENT toolchain
        goRoot = runtime.GOROOT()
    }
    ...
}

getGoEnv is getGoEnvFrom("", name), and that empty dir is the whole defect. Compare the established module-aware form two files over:

// directiveOperations.go:249, inside loaderReleaseTags
if version, err := getGoEnvFrom(moduleRoot, "GOVERSION"); err == nil {

Meanwhile the loader is explicitly pointed at the module (moduleConverter.go:161, cfg.Dir = moduleDir). So when the target module’s go/toolchain directive requires a newer release, go list re-execs that toolchain and reports its packages from a GOROOT under $GOPATH/pkg/mod/golang.org/toolchain@…, while options.goRoot still names the ambient install.

Measured (2026-08-11, laptop, ambient Go 1.23.1)

A module whose go.mod says go 1.25.0:

Asked from go env GOROOT go env GOVERSION
the module dir C:\Users\Admin\go\pkg\mod\golang.org\toolchain@v0.0.1-go1.25.0.windows-amd64 go1.25.0
the module dir, with GOROOT env pinned to C:\Program Files\Go same toolchain path go1.25.0
a non-module dir C:\Program Files\Go go1.23.1

Two things this settles. The divergence is a pure function of which directory you ask from — so asking from the loader’s directory fixes it by construction. And setting GOROOT in the environment does not fight the toolchain switch (row 2), so main.go’s os.Setenv("GOROOT", …) is inert with respect to selection; it only makes the wrong value stickier.


2. What the divergence breaks

Every consumer of options.goRoot / build.Default.GOROOT silently inverts its answer:

Site Consumes Consequence when GOROOT is the wrong toolchain
moduleConverter.go:235 classify options.goRoot No package is classStdLib. The whole standard library is classified third-party, module std.
importOperations.go:375 getImportPackageInfo pkg.Goroot Stdlib routed to the third-party branch.
importOperations.go:455 getLocalModulePackageInfo options.goRoot The GOROOT branch never fires.
visitImportSpec.go:619 resolveGorootVendoredPath build.Default.GOROOT The vendor/ probe os.Stats a directory that does not exist, so no GOROOT-vendored path is ever resolved.
readmeValidationBadge.go options.goRoot Vendored module pins read from the wrong src/vendor/modules.txt.

Reproduction — the converter exits 0 and emits an unbuildable project

go2cs -recurse=nuget -go2cspath <repo>\src <module> <out>

against a module importing golang.org/x/net/http/httpproxy (v0.57.0 requires go >= 1.25, so go mod tidy writes go 1.25.0 and the switch engages). Result: exit 0, “Closure: … referencing 144 stdlib” — and every emitted csproj carries relative project references to files that were never generated, because -recurse=nuget does not convert the standard library:

<ProjectReference Include="../../../pkg/fmt/std.fmt.csproj" />
<ProjectReference Include="../../../../../net/netip/std.net.netip.csproj" />

out\pkg contains golang.org and nothing else. The std. prefix is the tell: it is the module name std, reached because classify fell through to the dependency-module branch.

This bypasses the guard that should have caught it. docs/README.md:264 documents the expected failure for a too-new module — package requires newer Go version go1.25 — but that error is raised by a 1.23.1 toolchain refusing to load. The auto-switch means no 1.23.1 toolchain is ever asked, so the load succeeds and the diagnosis never fires.


3. The published-corpus mismatch (the reporter’s headline six)

Independent of GOROOT, and the actual cause of the reported NU1101s. src/version.props pins GoStdLibVersion 1.23.1 / GoBuildNumber 5; the feed is a Go 1.23.1 conversion. All six of these were added to the standard library in Go 1.24, so no go.<pkg> was ever packed for them:

crypto/fips140 · crypto/hkdf · crypto/mlkem · crypto/pbkdf2 · crypto/sha3 · weak

Verified both directions. On nuget.org, go.crypto.sha3404, while go.lib, go.internal.weak and go.vendor.golang.org.x.net.http.httpproxy all resolve at 1.23.1.5. Against a local Go 1.23.1 go/build probe, crypto/sha3 and weak report “not in std” while internal/weak resolves under GOROOT — i.e. weak is the 1.24 promotion of internal/weak, not a packaging omission.

writeProjectFile emits a go.<pkg> reference for every import classified stdlib (projectFileWriter.go:386) with no check that such a package can exist. The user learns at restore time, in bulk. Both numbers needed to catch it are already in hand at emit time: goVersion() (readme.go:59) and the GoStdLibVersion the converter itself writes into the generated Directory.Build.props (moduleConverter.go:731).


4. The vendored-name defect (the reporter’s remaining two)

go.golang.org.x.net.http.httpproxy and go.golang.org.x.net.http2.hpack are not the published ids — the corpus names those vendor.golang.org.x.net.http.httpproxy / …http2.hpack, the published go.net.http nuspec depends on them under exactly those names, and both resolve at 1.23.1.5. So the un-prefixed form was minted by the reporter’s own conversion.

getLocalModulePackageInfo’s GOROOT branch (importOperations.go:455-473) derives targetDir from meta.Dir — the real …/src/vendor/golang.org/… location — but derives the name from the import path as written:

importPathParts := strings.Split(importPath, "/")   // golang.org/x/net/http/httpproxy
packageName := strings.Join(importPathParts, ".")   // golang.org.x.net.http.httpproxy  <- no vendor.

It never calls resolveGorootVendoredPath, which dependencyGraph.go:123, importAliasOperations.go:114 and importOperations.go:964 all do apply. The result is a reference whose directory is right and whose file/package name is wrong — unresolvable as a project reference, and unresolvable as a go.<id> NuGet package.

NOT reproduced — and one attempt came back NEGATIVE. Converting GOROOT’s own net/http standalone (go2cs <goroot>/src/net/http <out>, matched toolchain) emits all four vendored references correctly prefixed:

<ProjectReference Include="$(go2csPath)core/vendor/golang.org/x/net/http/httpproxy/vendor.golang.org.x.net.http.httpproxy.csproj" />
<ProjectReference Include="$(go2csPath)core/vendor/golang.org/x/net/http2/hpack/vendor.golang.org.x.net.http2.hpack.csproj" />

So the branch is not reached on the obvious path: visitImportSpec vendor-resolves the import BEFORE getImportPackageInfo sees it whenever the importing file lives under GOROOT, which is every stdlib package. Reaching the defective naming needs an importer outside GOROOT whose golang.org/x/… import nonetheless resolved into GOROOT’s vendor tree, and no input constructed so far produces that combination.

What is still solid: the published feed and the corpus both use the vendor.-prefixed names (the go.net.http nuspec depends on them under exactly those ids, and all resolve at 1.23.1.5), so the reporter’s un-prefixed ids did originate in their own conversion. What is not solid: the mechanism above being that origin. Treat §4 as an open question with a leading suspect, not a diagnosed defect — the fix must not land until an input reproduces it, or the real path is found. The issue-#37 reply asserted this more confidently than the evidence supports and is owed a correction.


5. Remedy — as implemented (A, B, D landed; C open)

A. Resolve GOROOT from the loader’s directory. toolchainResolution.go + main.go. After the input path is known, resolveLoaderGoRoot asks go env GOROOT from the input’s module root — the same move loaderReleaseTags makes for GOVERSION. Precedence is explicit: an operator-supplied GOROOT env or -goroot flag wins verbatim (goRootPinned); only a derived value is re-resolved. The value is deliberately not exported — §1 row 2 shows the environment does not steer selection, so exporting would only make a stale value stickier.

Cost. Re-resolution spends a go env subprocess (~300ms on Windows) and the converter runs once per package — 580 of them in a full CNR. toolchainSwitchPossible is the gate that keeps this at exactly zero for runs that cannot be affected: it reads the module’s go/toolchain directives and the GOTOOLCHAIN environment variable, all without a subprocess, and only pays when a switch is actually possible. Nothing in the corpus or the behavioral suite asks for a newer toolchain.

B. Make the reported release the LOADER’s, and refuse a mismatch. Two parts, both landed. pinGoVersion (readme.go) fixes what goVersion() reports, because that value becomes the emitted $(GoStdLibVersion) — the version every go.<pkg> reference restores at. Then checkNuGetStdLibCompatibility refuses a -recurse=nuget run whose standard library comes from a different Go language release than the one this converter publishes for, naming both and exiting before a single file is written. Patch drift (1.23.1 vs 1.23.7) is deliberately allowed: same standard library, and the floating revision already spans it.

Design note — where “the published release” comes from. publishedStdLibRelease() returns the release the binary itself was built with (runtime.Version()), on the standing invariant that version.propsGoStdLibVersion tracks the converter’s own go.mod. That is free, exact, and cannot go stale. It does not know which corpus versions are actually on nuget.org — if a release is built but never published, this check still passes. Closing that gap needs an embedded version.props value or a feed query; flagged for coordinator ruling, not decided here.

C. Vendor-resolve the name in getLocalModulePackageInfo. NOT DONE — still owed, and still unreproduced (§4). Apply resolveGorootVendoredPath to the import path before deriving packageName, so directory and name agree, with a unit guard.

D. Document the pin. docs/README.md: the -recurse=nuget option row now states the one-Go-release constraint and the refusal, and the walkthrough NOTE now explains that its GOTOOLCHAIN=local is what makes the too-new-module error appear at all — left unset, Go downloads and re-execs the newer toolchain and the failure moves to restore time.

6. Traps this arc uncovered

modfile.ParseLax silently drops the toolchain directive. Its non-strict gate (x/mod modfile/rule.go:338-345) keeps only go, module, retract and require; every other verb returns before reaching the semantic layer, so File.Toolchain is always nil under ParseLax no matter what the file says. Reading it there makes go 1.21 + toolchain go1.25.0 — the exact shape go mod tidy writes when it bumps a module — report a 1.21 request, which disables the whole GOROOT re-resolution in the commonest case that needs it. The directive is read from the retained syntax tree instead (toolchainDirective). Strict Parse is not the alternative: it rejects directives newer than the vendored x/mod, i.e. exactly the forward-compatible go.mod this check exists to read. A unit guard pins both halves.

The reporter’s symptom shape is reproducible. Converting the §2 module with the unfixed binary emits <GoStdLibVersion>1.23.1.*</GoStdLibVersion> while converting the 1.25 standard library. That is the reported failure exactly: every package present in both releases resolves, and only the ones a later release added — crypto/sha3, weak, the FIPS-140 family — 404. With A+B the same command refuses, naming both releases. (This does not by itself prove the reporter’s instance came from a toolchain switch rather than an ambient 1.24+ install; both produce this shape, and both are caught.)

7. Gates — run on the lane machine (laptop, 2026-08-11)

Gate Result
Converter go test ./... green, 149 s, with unit guards for A and B
check-no-regression.ps1 (full) NO REGRESSION — generated C# and .csproj byte-identical across all 580 behavioral packages; 147 advisory converter warnings, zero NOT MEASURED
solution-integrity + path-casing preflight OK — 582 registered projects, 4,185 tracked paths
§2 reproduction before: exit 0 with dangling std.*.csproj refs · after: refuses, names both releases, writes nothing
GOROOT-vendored naming (§4) attempted repro NEGATIVE — names emit correctly prefixed; C not landed

CNR is the claim that mattered: A and B must move no emitted byte on a matched toolchain. A 1.23.1 host resolves the GOROOT it always did and toolchainSwitchPossible never fires, so the corpus is untouched — confirmed byte-for-byte, which is equally the empirical proof that the subprocess gate costs the corpus path nothing.

Merge window: converter change — same constraint as L4, post-1.23.1.6.

Open for coordinator ruling. publishedStdLibRelease() uses the converter’s own BUILD release as the proxy for “which corpus versions exist”, on the standing invariant that version.propsGoStdLibVersion tracks the converter’s go.mod. Free, exact, cannot go stale — but blind to what is actually on nuget.org, so a release that is built and never published still passes the preflight. Closing that needs either an embedded version.props value or a feed query at conversion time; neither was invented here.