DESIGN — Recursive end-user conversion (app → third-party libs → stdlib)
Status: proposal for review (no converter code written yet). Scope chosen with the user (2026-07-10): build this as a real go2cs capability (not a throwaway script), and for the first test reference the already-converted stdlib rather than reconverting it per app. Companion: the NuGet stdlib discussion (this thread / to be filed in
Roadmap.md) — the stdlib reference this design points at is exactly what a NuGetPackageReferencelater replaces.Update (2026-07-11): the staging half is built and tested.
deploy-core.ps1(two modes; see the “Deploying the core” section of../CLAUDE.md) stages the referenced stdlib + thego2cs-genanalyzer at%GOPATH%\src\go2csand writes a rootDirectory.Build.propsthat pins$(go2csPath)to that root. That firms up the reference-resolution story below (§3.4/§3.5).Update (2026-07-11, later): the converter side is built —
-recurseand phases P1–P5 are all implemented, gated, and committed. The-recursefeature works end to end on a real internet DAG (fatih/color); the remaining gaps to a fully compiling real-world build are per-package converter defects (Phase-4 territory), catalogued in the §5 5b results.Update (2026-07-24): recursive output can now be isolated from the runtime root with
go2cs -recurse <input> <output>. App/dependency projects are written under that output and reference one another relatively;-go2cspathremains the independent stdlib/runtime root. The Tour uses this form aftergo mod tidy, so exercise imports such asgolang.org/x/tour/wcresolve, convert, build, and run automatically without writing generated projects into the checkout or deployed runtime.
1. What we’re validating
The normal end-user workflow the project targets (see the end goal: “use Go code in my C# project” / “extend a Go app in C#”) is:
- A developer downloads a Go application from GitHub (
git clone/go install). - It imports one or more third-party libraries plus the standard library.
- They point
go2csat it and get a buildable C# solution.
The goal of this exercise is process validation — recursively discovering the reference graph, converting in dependency order, and producing a solution that builds (compile success, not runtime correctness). It is explicitly OK if not everything compiles yet; Phase 4 (the Go test grind) will surface the residual per-package defects.
2. Current state — what exists, what’s missing
There are two disjoint drivers today:
| Driver | File | Handles | Dependency graph? |
|---|---|---|---|
-stdlib |
stdLibConverter.go |
The Go standard library only | Yes — full graph + topo sort |
| single project | processConversion (main.go:703) |
One input package (dir/file) | No — converts only the input |
What already works in our favor
processConversionbuilds the transitive import closure.captureImportDirs(main.go:792-805) walkspkg.Importsrecursively intoimportPackageDirs(import path →{Dir, Name}), module-aware viago/packages. It’s used today only for reference wiring and type-alias resolution, but it is exactly the closure a recursive converter needs.getProjectName(importOperations.go:26) is already module-aware — it reads the module path fromgo.modand produces a dotted namespace (github.com/fatih/color→ projectgithub.com.fatih.color, namespacego.github.com.fatih.color).- A two-root reference/output convention already exists in
getImportPackageInfo(importOperations.go:219-223):if isStdLib { targetDir = pathReplace(sourceDir, filepath.Join(goRoot, "src"), "$(go2csPath)core") } else { targetDir = pathReplace(sourceDir, filepath.Join(goPath, "pkg"), "$(go2csPath)pkg") }So the intended layout is: stdlib under
$(go2csPath)core/…, third-party under$(go2csPath)pkg/…— the recursive converter should honor exactly this. -stdlibalready emits from an app without converting stdlib. A singleprocessConversionrun emits$(go2csPath)core\…project references for the stdlib packages it imports without converting them — precisely the “reference, don’t reconvert” behavior we want for stdlib.- The referenced stdlib is now staged by
deploy-core(built + tested 2026-07-11).deploy-core stub(runnable baseline) ordeploy-core stdlib(compilable full stdlib) copies the runtime + stdlib + thego2cs-genanalyzer to%GOPATH%\src\go2cs, presenting the stdlib uniformly atcore\<pkg>, and writes a rootDirectory.Build.propspinning<go2csPath>$(MSBuildThisFileDirectory)</go2csPath>. That props file is the reference backbone for §3.4/§3.5: every project under the root — the staged stdlib and anything-recurselater writes there — resolves its$(go2csPath)core\… / gen\…references with no-p:go2csPathflag and no absolute paths. - The template already makes every library project NuGet-publishable (
csproj-template.xml:44-53:GeneratePackageOnBuild,PackageId=go.$(AssemblyName)) — relevant to the NuGet endgame.
What’s missing
- No recursive convert loop for a non-stdlib module.
processConversioniterates only the top-level matched packages (for _, pkg := range pkgs,main.go:737); third-party and app sub-packages in the closure are never converted. - The dependency graph / topological sort is hardwired to the stdlib.
scanStdLib(stdLibConverter.go:135) loads the"std"pattern withGO111MODULE=off;isStdLib(stdLibConverter.go:301) rejects any path containing.— sogithub.com/...modules can never enter the graph. - Module-cache dependencies would be “converted in place.” When
build.Importcan’t resolve a module-cache dep (the usual case — the module cache isn’t onGOPATH/src), resolution falls togetLocalModulePackageInfo(importOperations.go:251), which treats output as co-located with the Go source (TargetDir = meta.Dir, lines 285-292). For areplaced / co-located local module that’s correct, but for a read-only, versioned module-cache dep ($GOPATH/pkg/mod/<m>@<v>/…) it is wrong: the cache is read-only, and refs would point into it. Third-party deps must convert to a writable location ($(go2csPath)pkg/…) and be referenced there. - Solution generation is stdlib-only.
GenerateSolutionFile(solutionGenerator.go:36) walks thecore/output tree; it needs to also take in thepkg/(third-party) output and the app project.
3. Proposed design
3.1 Invocation
Add a -recurse flag (default off, so existing single-package behavior is unchanged). With it,
for a module/dir input, go2cs converts the input module’s packages and every third-party dependency
package in the transitive closure, in dependency order. The stdlib is not converted (chosen scope);
stdlib imports emit $(go2csPath)core\… references to a pre-converted stdlib (see §3.5).
(Alternative considered: auto-detect and always recurse. Rejected for now — it silently changes the behavior of today’s single-package invocation and can pull in a large graph unexpectedly. A flag is explicit and reversible; auto-detect can come later.)
3.2 Discover + partition the closure
Reuse the go/packages load already in processConversion, then partition the transitive closure
(captureImportDirs) into three sets by source location:
- stdlib —
pkg.Goroottrue (under$GOROOT/src) → reference only ($(go2csPath)core). - third-party — a non-GOROOT module (under the module cache or a
replace) → convert to$(go2csPath)pkg/<import-path>. - app — the input module’s own packages → convert to the user’s output dir.
Partitioning by pkg.Goroot / module identity is more robust than the current strings.Contains(".")
heuristic and should replace it in the shared graph code.
3.3 Dependency graph + topological order
Generalize the graph/topo-sort out of StdLibConverter into a shared component (e.g.
dependencyGraph.go) parameterized by the set of packages to convert and the edge predicate
(an edge counts only when the dependency is itself in the convert-set — the same rule
buildDependencyGraph already uses at stdLibConverter.go:261). StdLibConverter becomes one caller
(convert-set = std); the new ModuleConverter is the other (convert-set = app + third-party).
Both reuse the existing topologicalSort / visitPackage (stdLibConverter.go:313-418), which already
sorts from deterministic roots and tolerates Go’s import cycles with a warning. “Least dependencies
first” falls straight out of this — leaf third-party libs convert before the libs and app that import
them, so each importer sees its dependency’s finished package_info.cs (the source of imported
collision-rename aliases) before it is converted. Conversion stays sequential (the converter relies
on package-level global state — the reason the same file just removed -parallel).
3.4 Output layout and reference resolution
| Set | Output dir | Emitted reference |
|---|---|---|
| stdlib | (not written — pre-converted) | $(go2csPath)core\<dotted>\<name>.csproj |
| third-party | <output>/pkg/<import-path> |
relative path within <output> |
| app | <output>/src/<import-path> |
relative path within <output> |
The core change is decoupling source dir from converted-output dir for module-cache deps. Concretely:
- Give
ModuleConverteran explicit writable output root and route every app/dependency package through its version-free import path.getRecurseDependencyInforeturns the generated dependency project’s absolute path during emission;writeProjectFilerewrites it relative to the importer, keeping the converted graph portable and independent of the stdlib/runtime root. - Strip the module cache’s
@<version>segment from the output path (single-version assumption for now; see open decisions).
Without a second positional, -recurse retains the established layout under -go2cspath. With
go2cs -recurse <input> <output>, only the generated src\ and pkg\ trees move to <output>;
$(go2csPath)core\… and $(go2csPath)gen\… continue to resolve against the selected checkout or deployed
runtime. This separation is what lets disposable hosts such as the Tour isolate each conversion.
3.5 Referencing the pre-converted stdlib
Per the chosen scope, stdlib imports emit $(go2csPath)core\… references and are not converted. The
staging step (deploy-core stub|stdlib) has already placed a pre-converted stdlib at
%GOPATH%\src\go2cs\core\<pkg> and written a root Directory.Build.props pinning $(go2csPath) to that
root — so $(go2csPath)core\<pkg>\<pkg>.csproj resolves deterministically for every project beneath it,
independent of the template’s Debug/$(SolutionDir) vs. Release/$(USERPROFILE) fallback (the props value
wins, because the template’s go2csPath block is Condition="'$(go2csPath)'==''"). deploy-core stdlib
stages the full 302-package stdlib (compilable); deploy-core stub stages the runnable baseline subset —
pick per what the app imports. deploy-core also stages the go2cs-gen analyzer at gen\go2cs-gen, which
every converted csproj references at $(go2csPath)gen\go2cs-gen.
This is the seam the NuGet stdlib replaces — now implemented as -recurse=nuget (2026-07-14):
$(go2csPath)core\<pkg>\<pkg>.csproj ProjectReference → go.<pkg> PackageReference, and likewise golib
→ go.lib and the go2cs-gen analyzer → go.gen (PrivateAssets="all"), all versioned
$(GoStdLibVersion). Keeping the reference indirection through $(go2csPath) made the switch a
reference-rewrite, not a structural change: the app’s own converted packages (src\ + pkg\) stay
relative ProjectReferences, and the converter emits an output-root Directory.Build.props in this mode
that defaults GoStdLibVersion to the converter’s Go release (floating, e.g. 1.23.1.*), so a converted
app restores from nuget.org with no deploy-core staging.
3.6 Solution generation
Generalize GenerateSolutionFile to accept the app project + the pkg/ (third-party) output roots in
addition to (or instead of) core/, grouping into solution folders by module namespace (the folder-ID
hashing in solutionGenerator.go:246 already handles duplicate leaf names). The stdlib projects are
included as references via $(go2csPath)core (either listed for build, or assumed pre-built).
deploy-core already emits a flat go2cs-core.slnx over the staged core + analyzer; the recurse solution
follows the same shape, adding the app + pkg\… third-party projects.
4. Phased implementation plan
- P0 — stage the referenced stdlib (done).
deploy-core stub|stdlibputs the stdlib +go2cs-genanalyzer + a rootDirectory.Build.propsat%GOPATH%\src\go2cs; the phases below build on that root. Already implemented + tested (2026-07-11). - P1 — extract the shared dependency graph (done). Graph build + topological sort now live in a
reusable
DependencyGraph(src/go2cs/dependencyGraph.go) parameterized by the convert-set: the added nodes ARE the edge predicate (addImportEdgesrecords an edge only to a dependency that is itself a node, so a dependency outside the set — e.g. the stdlib, when converting a user module — never constrains the order).StdLibConverterbecame one caller (discovers thestdset + each package’s imports, delegates ordering);ModuleConverter(P2) will be the other. Pure refactor: the moved algorithm is verbatim, unit-tested for the exact topo order / edge predicate / GOROOT-vendored-key resolution / cycle tolerance (dependencyGraph_test.go), and gated green —check-no-regressionbyte-identical across 371 behavioral projects, and a filtered-stdlib fmt strconvrun scans + sorts the full 305-package std graph and converts strconv→fmt in dependency order. Implemented + tested (2026-07-11). (A drive-by fix updated the stalesolutionGenerator_test.gofolder assertions, which predated theId="…"folder attribute, sogo test ./...is a reliable gate for the phases below.) - P2 —
ModuleConverter+-recurse(done). Added the-recurseflag (default off — single-package behavior unchanged) andModuleConverter(src/go2cs/moduleConverter.go): it loads the input module + full dependency closure once (LoadAllSyntax | NeedModule), partitions every closure package byclassify— stdlib = under$GOROOT/src(covers GOROOT-vendored), app =pkg.Module.Main, third-party = any other dependency module (pkg.Module != nil) — adds the app + third-party set to the sharedDependencyGraph, topo-sorts, and converts each in dependency order viaprocessConversion(which re-loads each package with full syntax). Stdlib is referenced ($(go2csPath)core\…), never converted. Conversion stays sequential (package-level global state). Validated on a syntheticapp → lib(replace) → stdlibfixture: closure discovered + partitioned, converted lib before app, and the app csproj wired..\lib\example.com.lib.csproj(relativeProjectReference) +$(go2csPath)core\fmtwhile the lib wired$(go2csPath)core\strings— the cross-packagelib.Greetingcall resolved. Gate:check-no-regressionbyte-identical across 371 behavioral projects (the default path is untouched);classify+ convert-set-order unit-tested (moduleConverter_test.go). P2 converts in place (output co-located with source) — correct for the app andreplaced/co-located modules; read-only module-cache deps + the$(go2csPath)pkgoutput routing are P3. Implemented + tested (2026-07-11). - P3 — output/reference decoupling (done). Read-only, versioned module-cache dependencies
(
$GOPATH/pkg/mod/<m>@<v>/…) now convert to a writable$(go2csPath)pkg\<import-path>location and are referenced there, never in place at the read-only cache. Three changes: (a) a new module-cache branch ingetLocalModulePackageInfo(importOperations.go) emits$(go2csPath)pkg\<import-path>\…references (used as-is, like the stdlib$(go2csPath)corerefs —writeProjectFileonly relativizesIsAbsreferences) with the@versionstripped by deriving the path from the version-free import path; (b)ModuleConverter.outputDirForroutes a module-cache package’s OUTPUT to the matching$(go2csPath)pkg\<import-path>dir so reference and output agree (the app + co-locatedreplacemodules stay in place); (c)processConversionloads only the single target package under-recurse(never./..., which would re-convert sibling sub-packages). Validated live against a real cache dep (github.com/google/uuid@v1.6.0): it converted to…\pkg\github.com\google\uuid\(version stripped), the read-only cache was untouched, and the app csproj referenced$(go2csPath)pkg\github.com\google\uuid\github.com.google.uuid.csproj. Gate:outputDirForunit-tested;check-no-regressionbyte-identical across 371 behavioral projects (all changes are recurse-guarded). Implemented + tested (2026-07-11). - P4 — solution generation (done).
ModuleConverternow emits a flatgo2cs-recurse.slnxat the deploy root ($(go2csPath)) listing every converted app + third-party project (relative forward-slash paths — the app in place via a..\path, third-party underpkg\…). It mirrors the flat shape deploy-core.ps1 emits forgo2cs-core.slnx(newbuildFlatSolutionXML, no namespace-folder grouping — distinct from the stdlib’s folder-groupedbuildSolutionXML). Placing it at the deploy root makes$(SolutionDir)resolve there on build, so the pre-converted stdlib ($(go2csPath)core), golib, and the analyzer resolve and build transitively via the ProjectReferences — the stdlib is referenced, not listed. Validated: ago2cs -recurserun over the uuid cache-test emitted a two-project solution (..\cache-test\example.com.cachetest.csproj+pkg\github.com\google\uuid\…csproj). Gate:buildFlatSolutionXMLunit-tested;check-no-regressionbyte-identical across 371 behavioral projects. Implemented + tested (2026-07-11). - P5 — test harness (done). 5a synthetic:
TestRecurseSyntheticModule(moduleConverter_integration_test.go) runs the real-recurseconversion over an in-process two-module fixture (app → lib(replace) → stdlib) and asserts the loop end to end — topo order (lib before app), in-place output, the cross-packagelib.Greetingcall, the lib +$(go2csPath)core\fmtreferences, and the flat solution — deterministic, network-free, CI-able viago test. It caught agenerateSolutionFileedge case (an all-in-place conversion never created the deploy root), now fixed. 5b real internet: see §5 results —-recursehandled the realfatih/colorDAG (74-package closure, 4 third-party) flawlessly at the process level; the dotnet build is partial, surfacing per-package converter defects (Phase-4 territory), not recurse defects. Implemented + tested (2026-07-11).
Each phase is independently reviewable; P1 is a pure refactor with a strong existing regression gate. All five phases (P1–P5) are implemented, gated, and committed (2026-07-11).
5. Test plan
5a. Synthetic local test (deterministic, CI-able). A two-module fixture: an app main package
that imports a co-located lib module (via replace), which imports stdlib. Confirms the recurse loop,
topo order, and in-place vs. pkg output rules without network. Fits the existing crosspkglib test
pattern.
5b. Real internet test (the requested exercise).
- Fetch a small real app into a scratch module — proposed: a CLI using
github.com/fatih/color, which pullsgithub.com/mattn/go-colorable+github.com/mattn/go-isatty. That’s a genuine small DAG (app → color → {colorable, isatty} → stdlib) that actually exercises “least-dependencies-first.” (Simplest alternative: a zero-dep lib likegithub.com/spf13/pflagorgithub.com/google/uuidfor a one-hopapp → lib → stdlibgraph.) - Baseline with Go —
go build ./...to confirm it compiles as Go first. - Stage the stdlib —
deploy-core stdlibonce, putting the compilable stdlib + analyzer + rootDirectory.Build.propsat%GOPATH%\src\go2cs. - Convert —
go2cs -recurse <module-dir> -go2cspath %GOPATH%\src\go2cs, converting the app + third-party libs under that root (stdlib referenced, not converted). - Build —
dotnet buildthe generated solution. - Report — packages discovered / converted / compiled, and CS-error buckets for the rest. Success criterion is process completion + solution builds (partial compile is acceptable at this stage).
5b results (2026-07-11). Ran end to end against github.com/fatih/color (v1.16.0) — a genuine DAG
colordemo → color → {go-colorable, go-isatty, golang.org/x/sys/windows} → stdlib. go build ./...
baselined; deploy-core stdlib -NoBuild staged the 302-package compilable stdlib + analyzer + golib;
go2cs -recurse then dotnet build:
- Recurse process: 100%. 74-package closure discovered → 1 app + 4 third-party converted, 68 stdlib
referenced; topological order correct (
go-isatty&x/sys/windowsleaves →go-colorable→color→colordemo); all four third-party routed topkg\<import-path>; flat solution emitted. - dotnet build: partial (as expected — partial compile is the accepted criterion). The full staged
stdlib subset + golib + analyzer compiled (~1315 DLLs);
github.com/mattn/go-isatty(pure-stdlib deps) built; and a pure-Go control libgithub.com/google/uuidbuilt. The rest failed on three per-package CONVERTER defects (Phase-4 territory — not recurse-orchestration defects):uintptr→C#nuintcannot beconst(x/sys/windows, ~180 × CS0283/CS0133) — the syscall “raw-metal on non-native types” case flagged inBaseline-vs-FullConversion.md(archived undersrc/archived/).- A hyphen in a package’s import-path segment is emitted into C# identifiers unsanitized
(
go-colorable→go-colorable_package, ~8 × CS1002/CS0116) — invalid C# identifier. - A non-stdlib imported type-alias is namespace-qualified from the bare package name, not the dotted
import path (
getLocalModulePackageInfosetsPackageName = meta.Name), souuid’s app gotgo.uuid_package.…instead ofgo.github.com.google.uuid_package.…(4 × CS0234). The lib built; only the importer’spackage_info.csaliases misqualified.
All three are converter identifier/qualification/
constgaps in specific Go constructs, surfaced for the first time by converting real third-party code — precisely the residual per-package defects Phase 4 addresses. The-recursefeature itself (discovery, partition, dependency order, output/reference routing, solution generation) is validated correct on this real DAG.
Converter fixes landed (2026-07-11). All three defects above were fixed as focused, separately-gated
commits (check-no-regression byte-identical across 371 behavioral projects for each — none of these Go
constructs appears in the corpus):
- #3 (
729ce52df) —getLocalModulePackageInfonow setsPackageNameto the dotted namespace path (packageQualifiedName), using the Go package name (not the import-path segment) as the last part.github.com/google/uuidnow compiles fully end to end (app + lib, 0 errors) — a working pure-Go real-world example. - #1 (
2d3424085) —convertImportPathToNamespaceuses the Go package name (from the module-aware import graph, always a valid identifier) for a non-stdlib import’s class segment, clearing the hyphen parse errors. - #2 (
238c6db36) —visitValueSpecunaliases a const’s type before the*types.Namedtest, so a const typed through an alias to a named type (type Errno = syscall.Errno) emitsstatic readonly.golang.org/x/sys/windowsnow compiles — the syscall/Windows-API “raw-metal” package.
These took the fatih/color build from 188 → 2 errors. The remaining 2 were a fourth defect, this
one in go2cs-gen (the Roslyn generator, not the converter): a Go defined type named after a C# keyword
(type short int16 in go-colorable) is declared partial struct @short, but ImplicitConvGenerator built
the conversion operator’s host/return from the raw symbol name short, emitting partial struct short {
implicit operator short(dword) => new short(…) } — which parses the operator into the enclosing static
class (CS0715/CS0057) and, once the names were escaped, cast dword.Value (uint) straight to @short
(CS0030) because the numeric-conversion body was skipped (GetStructDeclaration matched Identifier.Text
"@short" against the symbol name "short"). Fixed (keyword-escape the type names via EscapeCsKeyword;
match GetStructDeclaration on the @-stripped ValueText), yielding partial struct @short { implicit
operator @short(dword src) => new @short((short)src.Value); }. fatih/color now builds fully — 0 errors,
all five projects (app + color + colorable + isatty + x/sys/windows). Gate: full behavioral suite green
(the generator change is byte-identical for the corpus — no keyword-named GoImplicitConv structs there).
Namespace-sanitization fix + adversarial review (2026-07-11). An adversarial review of the five fixes
above (14 subagents, review + verify) confirmed the fixes were correct for exactly what fatih/color hit but
incomplete for other real-world modules — the byte-identical corpus gate cannot catch out-of-corpus
identifier cases. The highest-impact class was fixed:
- Hyphen/tilde in an import-path segment — the converter never mapped a hyphen (or tilde) in an
import-path segment to a legal C# identifier, so
github.com/google/go-cmp/cmpproduced the illegal namespace…google.go-cmp(the hyphen is in a NON-leaf segment, so the earlier#1fix — which only substitutes the leaf segment’s package name — did not reach it). Fixed:replaceInvalidIdentifierChars(hyphen/tilde →_) applied in bothgetSanitizedImportandgetCoreSanitizedIdentifier, so both the dependency’s own namespace (getProjectName) and every importer’s reference (convertImportPathToNamespace) render…google.go_cmpconsistently. Unit-tested (sanitization_test.go);check-no-regressionbyte-identical (Go identifiers never contain hyphens, so only import-path-derived names change and the corpus has none). This covers the ubiquitousgo-*/*-gomodule families (go-cmp, go-isatty, go-sql-driver, golang-jwt, …). *(A first attempt also routedconvertImportPathToNamespace’s non-leaf parts throughgetCoreSanitizedIdentifierto escape the embedded keyword ingopkg.in— but that Δ-prefixed the_packageclass suffix and regressed 11 corpus goldens, so it was reverted; thegopkg.incase is backlog item 8.)
Output-layout redesign + flag/reference fixes (2026-07-11)
First real end-user run of the walkthrough surfaced two blocking defects and drove a layout redesign. All
four changes below are recurse-only (or transparent to flags-first callers); check-no-regression stayed
byte-identical across all 371 behavioral projects at every step.
- R1 — flag ordering (
main.go, all modes). Go’sflagpackage stops parsing at the first non-flag token, sogo2cs -recurse . -go2cspath <dir>silently DROPPED-go2cspathand wrote to the default%USERPROFILE%\go2cs— the converted deps and solution appeared “missing” at the expected path.parseArgsInterspersednow peels off one positional at a time and re-parses the remainder, so flags before OR after the module path are honored. Unit-tested (argParse_test.go). - R2 — parallel output tree + version-free references. Nothing is written in place any more (the original
Go source stays pure): the app’s own packages (the main module) convert to
$(go2csPath)src\<import-path>and every dependency — module-cache or a co-locatedreplace— to$(go2csPath)pkg\<import-path>. Because the app now lives UNDER the deploy root, it inherits the deploy-rootDirectory.Build.props, so a baredotnet buildin the app folder (or an IDE open) resolves$(go2csPath)with no solution needed. This also fixed a broken app→dependency reference: the app csproj had referenced a module-cache dep as$(go2csPath)pkg\mod\<module>@<version>\…(thebuild.Importsuccess path), which did not match where the dep converts ($(go2csPath)pkg\<import-path>), yieldingCS0246. App/third-party references now route through the module-aware go/packages metadata (getRecurseDependencyInfo) to the version-free src\/pkg
paths; stdlib still resolves to$(go2csPath)core. AddedOptions.mainModulePathto classify app vs. dependency consistently in both output routing and reference emission. - R3 — per-project solutions. A
.slnxis written next to every converted.csproj, over that project plus its transitive converted dependencies + golib + the analyzer (no stdlib), with the solution’s own anchor project marked the VS default startup (DefaultStartup="true"— a.slnxcapability the old.slnlacked). Building the app’s per-project solution builds the app and its whole dependency closure in one shot, without the ~300-project stdlib solution. (The separate flatgo2cs-recurse.slnxat the deploy root was removed as redundant — the app’s own per-project solution already lists its whole converted dependency closure, so it IS the build-everything solution for the app.) Projects are grouped into three top-level solution folders that mirror the%GOPATH%layout —srcfor the project(s) being converted (the app’s own main-module packages),pkgfor their converted dependency packages, andcorefor the go2cs runtime/generator projects (golib,go2cs-gen) — emitted in that enforcedsrc → pkg → coreorder (deliberately not alphabetic). Classification is by import path (isMainModulePackage, the same rule that routed each package’s output tosrc\/pkg\), so the folders agree with the on-disk tree. An empty folder is omitted (a dependency’s own per-project solution has nosrcpackage), mirroring how the stdlib solution drops its/tests/folder when empty. The three folder names are unique leaves, so — unlike the namespace-nested stdlib solution — no folderIdis needed. (buildRecurseSolutionXML,solutionGenerator.go; guarded byTestBuildRecurseSolutionXML+TestBuildRecurseSolutionXMLSkipsEmptyFoldersand the folder-order assertions inTestRecurseSyntheticModule.) - R4 — acceptance. With a current deploy root, the
fatih/colorexample (app +color+go-colorable+go-isatty+x/sys/windows+ golib + analyzer) compiles clean — 0 errors, 0 MSB reference errors. Two operational caveats learned here:- Stale deploy. The deploy root is a snapshot; building against an analyzer older than a converter fix
surfaces spurious errors (here
CS0715/CS0057ongo-colorable’s keyword-namedshort/dwordtypes, from a deploy predating theEscapeCsKeywordfix). Re-runningdeploy-corecleared them. - Toolchain skew.
fatih/colorv1.19 requires go 1.25 while go2cs’sgo.modpins go 1.23.1, sogo/packagesloads the dependency closure withpackage requires newer Go versionand degraded type info. Bumping go2cs’s toolchain is deferred — a different type-checker could shift the byte-identical behavioral corpus and needs its own re-baseline. Running the compiled example currently throws from a converted third-partyinit(a Phase-4 operational defect, plausibly downstream of the degraded x/sys conversion), but the milestone — a buildable, compiling solution — is met.
- Stale deploy. The deploy root is a snapshot; building against an analyzer older than a converter fix
surfaces spurious errors (here
Residual real-world-module hardening (Phase-4 backlog)
The review + the go-cmp/gopkg.in verification surfaced these known residual limitations — real, but each
out-of-corpus and narrower than the namespace fix. Left for Phase-4 hardening (none affects the stdlib,
fatih/color, uuid, or the behavioral corpus, all of which build):
- (medium, go2cs-gen) A C#-keyword-named Go type nested as a generic type argument (the
ж<short>pointer-box of anIndirect/self-box conversion) is emitted unescaped —GetFullTypeNamerebuilds generic names from the rawITypeSymbol.Name, and the single top-levelEscapeCsKeywordcannot reach nested arguments. Needs recursive keyword-escaping of generic arguments. - (medium, converter) A
consttyped through an alias touintptr(type Handle = uintptr; const X Handle = 42) still emitsconst— the#2unalias reached the*types.Namedgate but the downstreamcsTypeName == "uintptr"guard reads the alias name (Handle), not the unaliased underlying. - (medium, go2cs-gen)
EscapeCsKeywordmisses C# contextual keywords (file) that the converter DOES escape (os-styletype file …→@file), so a keyword-named-type conversion for such a type mismatches. - (low, converter) A dependency whose Go package name is a go2cs-reserved word (
package slice/array) or ends in_package: the imported-type-alias qualification (#3) Δ-prefixes the class segment (Δslice_package) while the producer emits it un-prefixed (slice_package) — dangling reference. - (low, converter) An alias-to-alias-to-named chain (
type A = Named; type B = A) emitsglobal using B = go.<pkg>.A;, referencing the intermediate aliasAas if a class member (CS0426). (robustness, recurse)FIXED 2026-08-05 (issue #32) — see Conversion scope: the module without its dependency graph below.processConversion’slog.Fatalfon a package load failure (e.g. a dependency whose transitive test-dep has a missinggo.sumentry —gopkg.in/yaml.v3→gopkg.in/check.v1) aborts the ENTIRE-recurserun rather than logging + skipping that one package.ModuleConverter.convertAllalready recovers panics; the load path should return an error instead of exiting.- (robustness, recurse) A deeply-nested module-cache subpackage (
github.com/google/go-cmp/cmp/internal/ flags) converted with a barenamespace go;instead of the full dotted namespace —getProjectName’s walk-up-to-go.modneeds to handle the@version-segmented cache path at depth. (low, converter) A C# keyword embedded after a dot in a single import-path segment (theFIXED 2026-08-07 (issue #33) — see One sanitizer knew the dots, the other did not below. It was not low: it is the whole reason the reporter’s converted app did not compile.inofgopkg.in) is escaped bygetProjectName(the dependency’s own namespace →gopkg.@in) but NOT byconvertImportPathToNamespace(an importer’s reference → baregopkg.in), so agopkg.in/*dependency’s own package compiles while its importers mis-reference it. Needs a dot-splitting sanitizer inconvertImportPathToNamespacethat escapes the embedded keyword WITHOUT Δ-prefixing the_packageclass suffix (the naivegetCoreSanitizedIdentifierswap does the latter — see the reverted attempt above).
Conversion scope: the module without its dependency graph (-recurse=module, 2026-08-05)
Issue #32 reported the gap from the outside: -recurse
converts the module and its third-party closure and dies on a dependency it cannot handle (there, a
Google API SDK), while a plain package conversion converts only the input directory’s own package — the
module’s other packages are never reached. There was nothing in between, and the thing the user wanted
(“convert my project, I’ll deal with the dependencies myself”) was exactly that in-between.
Two changes, one for each half of the report.
- A scope value on the existing flag.
-recursewas already an optional-value flag choosing a reference style (nuget); the values are now a comma-separated list so a scope composes with it (-recurse=module,nuget).modulekeeps app packages in the convert-set and leaves everyclassThirdPartypackage out of it, recording the import path instead (referencedThirdParty). Nothing else changes:getRecurseDependencyInforoutes a dependency topkg\<import-path>from the import path alone, never from anything converted, so the app’s emitted.csand.csprojare byte-identical to the full run’s (verified on the two-module fixture; only the per-project.slnxdiffers, by the/pkg/folder an unconverted dependency has no project to fill). Re-running the same conversion without=moduletherefore writes the dependencies at exactly the paths already referenced and resolves them, leaving the app’s own output untouched. The trade (the emitted solution does not build until they are there) is printed as a list at the end of the run, not left to be discovered at build time. The closure load also drops toNeedName|NeedFiles|NeedImports|NeedDeps|NeedModuleunder this scope: the closure load exists to DISCOVER and CLASSIFY (each package is re-loaded with full syntax and types when it is converted), so type-checking a graph that is not going to be converted is exactly the coupling the mode is meant to remove. The full-closure path keepsLoadAllSyntax— there, every package in the graph is about to be converted anyway. - A load failure stops being fatal (backlog item 6).
processConversionnow RETURNS the load error instead oflog.Fatalf-ing on it.ModuleConverter.convertAllalready survived a panic per package; it now survives an unloadable one the same way (recorded infailed, run continues),StdLibConverterinherits the same behavior through its existing error channel, andmain’s single-package call site re-raises it as fatal — the behavior that call site always had. Only the load path changed: everything after it exits on failure as before, because those faults are I/O on the output tree, not a property of the package being converted.
Guarded by TestRecurseModuleOnly (integration: the same two-module fixture as TestRecurseSyntheticModule,
asserting the app converts, the dependency does not, no pkg\ tree is written, and the app’s reference to it
is emitted anyway), TestModuleConverterPartitionScope (both scopes over one classified closure), and the
extended TestRecurseModeFlag (value composition in either order, false clearing the list, an invalid
value inside a valid list still an error).
Module-cache loads and the vestigial go.work (issue #32 follow-up, 2026-08-06)
The error log the issue-#32 reporter pasted back (the Renart project, 1,979-package closure) diagnosed the
abort their original report described — and it was neither a conversion defect nor a load-graph problem, but
an environment poisoning of the per-package reload. processConversion re-loads each convert-set package
with packages.Config.Dir set to the package’s own source directory, which for a dependency is its
read-only module-cache extraction (…\pkg\mod\cloud.google.com\go@v0.123.0\civil). A module zip includes
whatever sits at the module root, and the cloud.google.com/go monorepo keeps its development go.work
there — listing ~200 sibling modules (../accessapproval, ../bigquery, …) that are never in the cache,
because each is its own module with its own zip. The go command, walking up from the working directory,
finds that file, enters workspace mode, and fails the load with a wall of
cannot load module ../<sibling> listed in go.work file — so every package of the monorepo’s root module
was lost while its nested modules (auth, bigquery, whose zips do not contain the repo-root go.work)
converted fine. The reporter’s run died entirely because their binary predated the non-fatal load path
(backlog item 6 above); on current code the run survives but still loses those packages.
The fix (conversionDriver.go): when the reload’s input directory is under the module cache
(GOMODCACHE, resolved once — env, then go env, then the documented <GOPATH>/pkg/mod default), the
load runs with GOWORK=off. A go.work inside the module cache is unpacked zip content, never an
authoritative workspace, so disabling workspace mode there is categorically safe — and the gate is
deliberately that narrow: loads anywhere else keep the ambient GOWORK behavior, because an end-user
module may legitimately resolve its own packages through a real workspace. Guarded by
TestModuleCachePoisonedGoWorkLoad (integration, network-free: one poisoned fixture asserted from both
sides — outside the test-pinned cache root the load still fails through the go.work, under it the package
converts).
The same seam, one directive over: monorepo replace (issue #33 follow-up, 2026-08-06)
The GOWORK=off gate above closes the workspace flavor of “a module-cache directory is not a main module”.
Investigating issue #33 turned up the replace flavor, which that gate cannot reach — and this one is
measured against the reporter’s own dependency rather than a fixture.
go.opentelemetry.io/otel@v1.44.0/go.mod carries the monorepo’s own relative replaces:
replace go.opentelemetry.io/otel/trace => ./trace
replace go.opentelemetry.io/otel/metric => ./metric
Correct in the otel source repo, where those are sibling directories. The published module zip excludes
them (both are separate modules), so ./trace does not exist in the cache. A replace is honored only in
the main module — and loading a package with Dir inside the cache is precisely what promotes that
dependency’s go.mod to main-module status. The go command reports replacement directory ./trace does not
exist, go.opentelemetry.io/otel/trace never loads, its types.Package stays empty-named, and go/types
then reports could not import go.opentelemetry.io/otel/trace (invalid package name: "") at every use site.
189 of the 244 packages in the otel zip import trace or metric, so all 189 lose their types. This is
the common shape for a multi-module Go repository, not an otel quirk.
GOWORK=off does not help: replace is not a workspace feature. The remedy that does, validated by a
three-way packages.Load probe (A = cache dir standalone → the reporter’s error; B = app module dir,
pattern = import path → 0 errors; C = otel/trace’s own cache dir → 3 further failures from its own
vestigial replace … => ../), is to load a GOMODCACHE package from the main module’s directory by
import path. In that shape the go command never enters the dependency’s directory, so the vestigial
go.work is not read either and the GOWORK=off gate becomes redundant for third-party packages — it should
stay for the non-recurse paths, which have no main module to load from.
LANDED (2026-08-07). processConversion takes the main-module shape whenever all four conditions hold:
the input is under GOMODCACHE, the run is -recurse, and both the main module’s directory and the
package’s import path are known. ModuleConverter resolves the module directory absolute once in
ConvertModule (the load sets it as the go command’s working directory, so a relative path would resolve
against whatever the process’ cwd happens to be) and names each package in convertAll through a per-package
copy of Options. Everything else keeps the directory load, and GOWORK=off with it — a single-package
conversion has no main module to borrow a context from.
One defensive check rides along: an import path resolves through the main module’s version selection, so it
MUST land on the directory the convert-set names — the same selection produced both. If it ever does not, the
output would be written for one package under another’s path, silently; loadedPackageIsAt catches that and
falls back to the directory load with a warning. It has never been observed to fire.
Guarded by TestModuleCacheVestigialReplaceLoad (network-free, both sides from one fixture: the control
asserts the vestigial replace still reproduces, so the fix side cannot pass vacuously). Verified end to end
against the reporter’s own dependency: a -recurse run over the otel closure goes from 2 invalid package
name failures to 0, and with the converter itself rebuilt under Go 1.25 the whole closure converts
14/14 with no warning of any kind. Emission-neutral for the corpus by construction — no behavioral
package is under GOMODCACHE — and measured so: all 569 packages transpile byte-identically to the
converter that predates the change.
Deliberately NOT done, and left for a future pass: reusing the closure loadClosure already type-checked
instead of re-loading each package. Now that a dependency is loaded from the main module’s directory anyway,
the per-package reload is doing in 1,726 separate packages.Load invocations — the dominant cost of a recurse
run — what one pass already did correctly. That is a pipeline-shape decision rather than a bug fix, and it has
to respect -recurse=module, which deliberately skips the full-closure type-check so an unconvertible
dependency graph cannot block the app’s own code. Tracked on the Phase-4 board under the issue-#33 entry.
A second, independent finding from the same reproduction: the converter cannot type-check a module whose go
directive exceeds the Go release go2cs itself was built with (otel@v1.44.0 declares go 1.25.0; a
go2cs built with go1.24 reports package requires newer Go version go1.25 and everything downstream goes
untyped). The go command switches toolchains automatically; the go/types go2cs links in is whatever
release compiled it, and no toolchain switch reaches that. Build the converter with a toolchain at least as
new as the newest go directive in any closure it will be asked to convert.
The module path is a token, not the rest of the line (issue #33 follow-up, 2026-08-07)
The reporter’s next wall after the load fixes above was not a load at all — it was a file name:
Error while writing project file "…\pkg\gopkg.in\yaml.v3\"gopkg.in.yaml.v3".csproj":
The filename, directory name, or volume label syntax is incorrect.
The csproj name carried literal double quotes, so the open failed before a byte was written and
gopkg.in/yaml.v3 was lost from the run (as a per-package WARNING — the run itself continued and
reported success for everything else, which is why it reads as a mystery rather than a crash).
The quotes come from the dependency’s own go.mod. A go.mod directive is a sequence of tokens, and a
token may be written as a quoted string; gopkg.in/yaml.v3@v3.0.1 uses that form throughout:
module "gopkg.in/yaml.v3"
require (
"gopkg.in/check.v1" v0.0.0-20161208181325-20d25e280405
)
readModuleFromGoMod matched module\s+(.+) and returned the rest of the LINE, so the module path became
"gopkg.in/yaml.v3" — quotes and all. getProjectName then split it on / and joined with dots into the
project name, which is also the csproj file name, and Windows rejects " in a path. Nothing downstream was
wrong: the quotes were never part of the module path, they are syntax the reader was not reading.
That is also the discriminator against every dependency the recurse path had been exercised on. The
github.com/… convention is the bare form (module github.com/fatih/color), so the DAG this feature was
built against could not show it; the gopkg.in family writes the quoted form, and the old reader was equally
wrong about a // trailing comment and a backtick-quoted path.
The fix reads the token instead of the line: modfile.ModulePath (golang.org/x/mod, already in the
build graph) — the same tolerant reader cmd/go uses for exactly this job. It drops // comments, requires
module to begin the line, and unquotes both the interpreted and raw forms. Emission-neutral by
construction (a bare module path parses identically) and measured so: all 571 behavioral packages
transpile byte-identically.
Guarded twice, at the two altitudes the defect crossed: TestProjectNameFromModuleDirective
(importOperations_test.go — the four directive shapes, plus the standing invariant that a project name
never contains a character a file name cannot) and TestRecurseQuotedModulePath (integration, network-free:
a versioned dotted-host dependency resolved through a local replace, asserting the dependency’s .csproj
exists under the unquoted name and the app references it there). Neuter-proven — restoring the regex
reproduces the reporter’s error text verbatim in the integration guard.
What this unblocks, and what it does not. A gopkg.in/* dependency now converts end to end (verified on
a scratch module importing gopkg.in/yaml.v3@v3.0.1: 2/2 packages, project + solution files written, the
app’s ProjectReference resolving). It then meets backlog item 8 above, unchanged: the dependency’s own
namespace escapes the embedded C# keyword (namespace go.gopkg.@in;) while an importer’s reference does not
(using yaml = gopkg.in.yaml_package;), so the converted app does not yet compile. Item 8 is the next thing
a gopkg.in user hits and is now a demonstrated, reproducible case rather than a predicted one.
One sanitizer knew the dots, the other did not (issue #33 follow-up, 2026-08-07)
Backlog item 8, taken directly after the item above handed it a gopkg.in/yaml.v3 that converts. It was
filed low; it is in fact the whole remaining reason the reporter’s app does not build. A -recurse run
over a scratch module importing gopkg.in/yaml.v3@v3.0.1 converts 2/2 packages, and then:
main.cs(4,20): error CS1001: Identifier expected
main.cs(4,20): error CS1002: ; expected
main.cs(4,20): error CS1022: Type or namespace definition, or end-of-file expected
main.cs(4,23): error CS0116: A namespace cannot directly contain members …
main.cs(5,13): error CS1001 / CS1002 / CS1022
Eight errors, all of them the same two lines the importer emits:
using yaml = gopkg.in.yaml_package;
using gopkg.in;
in is a C# keyword. The dependency itself is fine — yaml.cs opens namespace go.gopkg.@in; and the
whole yaml.v3 package compiles clean — so the two sides of one namespace simply disagreed.
Root. A path ELEMENT may contain dots (gopkg.in, yaml.v3, example.com), and each dot is a
namespace separator in the emitted C#, so the parts either side are separate identifiers needing separate
escapes. The declaration side knew that: getProjectName sanitizes each element through
getCoreSanitizedIdentifier, which splits on dots. Every consumer emission renders through
convertImportPathToNamespace, which sanitized each element through getSanitizedImport — measuring it
whole, where gopkg.in is not a keyword and passes through bare.
Fix — one function, both sides. getSanitizedImport now splits on dots as well, escaping each level
independently. All four consumer emission sites are downstream of it and were fixed at once: the
using <alias> = <ns>; line, the enclosing-namespace using <ns>; an unaliased import adds
(requiredUsings), the packageChildNamespaces map that decides root qualification
(importAliasOperations), and the string-path type renderer (convertToCSTypeName →
convertImportPathToNamespace). The recursion deliberately stays inside getSanitizedImport rather than
switching to getCoreSanitizedIdentifier: callers append the _package class suffix to the FINAL element,
and the core sanitizer Δ-prefixes anything ending in _package, which is precisely why the naive swap was
reverted when item 8 was filed. Project/csproj FILE names are untouched — that is the previous section’s
territory, and a file name is not a C# identifier.
Emission-neutral by construction (the change is exactly “a dotted input with a keyword sub-token is now
escaped”; a dotted string could never equal a keyword, so the old test never fired for one) and measured
rather than assumed: the behavioral corpus has no dotted module path at all, the standard library’s
only dotted element is the GOROOT-vendored golang.org, whose parts are not keywords — CNR byte-identical
across 572 packages, and a seeded full stdlib reconvert byte-identical across 5,179
.cs/.csproj/README.md plus the generated go2cs-stdlib.slnx.
Guarded at both altitudes, both neuter-proven (restoring the whole-string measurement reproduces the
reporter’s emitted text verbatim): TestGetSanitizedImportKeywordSegments (unit — several keywords in
several positions, the two sanitizers asserted to AGREE on a segment, the _package suffix asserted not to
be Δ-prefixed, idempotence) and TestRecurseKeywordNamespaceSegment (integration, network-free — an
unaliased import of a gopkg.in-shaped dependency through a local replace, asserting the producer’s
namespace and both consumer emissions name the same thing, then sweeping every using in the file
against the converter’s own keywords set so a keyword the fixture never exercises is covered too).
What this unblocks, measured end to end. The scratch gopkg.in/yaml.v3 app now goes from 8 errors to
0 — the whole converted yaml.v3 package plus the app, dotnet build clean — and the built C# program
runs, decoding a nested document (structs, []string, map[string]string, ints, bools) to output
byte-identical to go run. That is the first third-party gopkg.in/* dependency working end to end.
What honestly remains, and why it is out of scope here. yaml.Marshal panics at runtime with
invalid memory address or nil pointer dereference, on input as small as yaml.Marshal(map[string]int{"a":
1}); yaml.Unmarshal is unaffected and correct on everything tried. So the ENCODE path of the converted
yaml.v3 carries a Phase-4 operational defect that has nothing to do with namespaces — both sides of this
fix compile and the decode path runs. Diagnosing it needs the -tests pipeline against yaml.v3’s own
suite, not another emission fix; tracked on the Phase-4 board under the issue-#33 entry alongside the
loadClosure reuse item.
One unrelated observation from the reconvert control, recorded so it is not re-discovered as drift: 24
committed src/core/*/README.md validation badges are stale (not_yet_validated where the package’s
proof page in docs/validation/current now says validated — hash 18/18, net/url 48/48, crypto/aes
13/13, …). The badge is composed from repository files rather than from the conversion, so the READMEs
simply predate those banks. Proven pre-existing: a converter built at this branch’s merge-base emits the
same green badges, byte for byte, as the fixed one. It levels on the next corpus regen.
Operational stdlib — native sync primitives (2026-07-11, Phase-4 start)
Running (not just compiling) the fatih/color sample exposed the Phase-3 → Phase-4 boundary: the full
conversion compiles but is not operational. The first blocker is sync: its //go:linkname
runtime concurrency primitives (Semacquire/Semrelease, notifyList, procPin, …) are emitted as
throwing stubs, so sync.init — reached by os/syscall and nearly every program — crashes at startup.
Emulating Go’s runtime sleeping semaphore on a .NET primitive is a dead end: it is co-designed with
the mutex state machine (starvation-mode ownership is handed to one specific waiter via an exact ticket),
and both a global-gate and a faithful per-address FIFO+handoff emulation deterministically trip
sync: inconsistent mutex state / unlock of unlocked mutex at sustained contention (2 goroutines × 10k
locks). So the runtime-dependent types are reimplemented natively (hand-owned files replacing the
converted output), on proven .NET primitives: Mutex → binary SemaphoreSlim; WaitGroup → guarded
counter + latch; RWMutex → writer-preferring monitor lock (keeps the RLocker/rlocker witness and the
syscall_hasWaitingReaders linkname); throw/fatal → native fatal hooks. Once/Map/Cond/Pool
ride on these unchanged (Cond still uses runtime_impl.cs’s notify-list; Pool sharding is best-effort).
Validated by sync-only converted stress tests (no os/fmt, so unaffected by the syscall gap):
Mutex+WaitGroup+Once at 100 goroutines × 1000 → 12/12 clean; RWMutex + Cond correct. Commit e36dea3aa.
These hand-owned files survive a reconvert via the existing [module: GoManualConversion] marker —
the converter’s containsManualConversionMarker scans each output .cs and skips re-converting the
matching .go when present (verified: -stdlib sync into a seeded dir leaves mutex.cs byte-identical,
still the native SemaphoreSlim impl). Remaining polish: RWMutex uses Monitor.PulseAll (correct but
O(n)/op under pathological contention).
Remaining to run color end-to-end: the package-level var initialization order crash is FIXED
(2026-07-11) — a general converter fix, not a syscall patch: an initializer whose Go dependency order C#’s
static-field-initializer order cannot reproduce (cross-file, same-file forward reference, or transitively
through package function bodies — syscall’s Stdin = getStdHandle(…) reading zsyscall’s
procGetStdHandle) is emitted as a bare field plus an initᴛ<name>() method in its home file, called in
types.Info.InitOrder by a generated package_init.cs static constructor. See
ConversionStrategies-Reference → Package-Level Variable Initialization Order;
guarded by the PackageVarInitOrder behavioral test.
Operational stdlib — syscall FFI + runtime bootstrap (2026-07-11, groundwork)
With init order fixed, the converted-program startup crash chain was cleared one layer at a time. Each
fix below is committed groundwork; the chain now reaches fmt.newPrinter, blocked on a distinct
go2cs-gen issue (see the end). All fixes live in src/go-src-converted unless noted, deployed via
deploy-core stdlib:
internal/godebugruntime//go:linknamehooks (setUpdate/registerMetric/setNewIncNonDefault) were throwingPartialStubGeneratorstubs →godebug_impl.cscompanion (no marker needed — supplying a bodyless partial’s implementing part auto-suppresses the stub). One-shotupdate()notify.- Windows syscall FFI (
loadlibrary/getprocaddress/Syscall…/SyscallN) → nativesyscall/dll_windows.cs(whole-fileGoManualConversion):LoadLibraryExW/GetProcAddressP/Invoke, plus an unmanaged function-pointercallidispatch (a switch on argument count — Windows x64 has a single calling convention).LazyDLL.Load/LazyProc.Finduse a plain double-checked lock (CLR reference writes are atomic — Go’satomic.LoadPointer((*unsafe.Pointer)(&d.dll))managed- referent round-trip cannot be emulated). runtime’s owninitfunctions divided by zero / hit stubs at class-initialization (arena sizing checks against a zerophysPageSize, etc.). In Go these run only after the assembly bootstrap (osinit/schedinit) populates such globals; converted code has no bootstrap — .NET is the runtime. Converter fix (visitFuncDecl.go): forpkg.Path()=="runtime", emitinitfuncs as commented-out/* [GoInit] runtime bootstrap init - not run */plain methods. Behavioral-neutral (CNR byte-identical — scoped toruntime, which no behavioral test is).efaceOfreinterpret panicked at class-init (several_typefield initializers walk it) → returns an inertefaceinruntime/runtime2.cs(descriptor walking is vestigial — go2cs replaces Go’s itab dispatch with C# interfaces + generators). Same file seedsgomaxprocs/ncpufromEnvironment.ProcessorCount(the bootstrap would; left 0,sync.Poolsizes a zero-length shard array and indexes out of range on the firstPrintln).sync.Poolper-P sharding presumes Go’s scheduler → nativesync/pool.cs(whole-fileGoManualConversion): oneConcurrentBagper Pool, lazily created through the Pool’s heap box (the next member of the native-syncfamily).runtime.SetFinalizerwalks type descriptors (os.newFilesets a finalizer on every opened file) →runtime/mfinal.csfrozen whole-fileGoManualConversion, its body replaced with aConditionalWeakTable+ sentinel-finalizer bridge (the rest is inert converted GC-queue machinery kept for compilation).
⚠ Open soundness constraint (documented in dll_windows.cs): golib’s ж<T>→uintptr conversion
returns an address captured inside a fixed block that exits before the return, so every uintptr the
converted zsyscall wrappers pass to the trampoline is a transient (GC-moveable) address. The
window is short and allocation-free in practice; the sound fix is to pin at the capture seam — a golib
decision to make with the project owner.
The final blocker for fmt.Println is a go2cs-gen gap, not FFI: fmt.newPrinter does
@new<pp>(), and pp has a plain field fmt fmt whose type carries a promoted embed (fmtFlags, a
ctor-initialized ж box). @new<T>() runs pp’s parameterless constructor, which leaves the fmt
field default — so its embed box (and its array-field backing) is null and clearflags NREs.
StructTypeTemplate.AppendPromotedBoxInitializers constructs a struct’s OWN embed boxes but not a
struct-typed FIELD whose type needs construction. The fix — construct such fields as new FieldType(nil)
in the zero-value constructors — is a general go2cs-gen change of comparable scope to the init-order fix
(large blast radius: every struct with a struct-typed field), so it is deferred to its own validated pass.
6. Open decisions (RESOLVED)
- Flag vs. auto-detect — explicit
-recurse(default off; single-package behavior unchanged). - Module version in the output path — strip
@<version>(single-version assumption; the$(go2csPath)pkg\<import-path>path is derived from the version-free import path). - Which pre-converted stdlib to reference — the
deploy-coremodes:stdlib(fullsrc/go-src-converted, compilable) orstub(runnable baseline); both stage to%GOPATH%\src\go2cs\core. 5b usedstdlib. - Test app —
fatih/color(the small DAG), withgithub.com/google/uuidas a pure-Go control.
7. Relationship to the NuGet stdlib decision
This design deliberately keeps the stdlib behind the $(go2csPath)core reference indirection so that the
switch to a NuGet stdlib is a ProjectReference → PackageReference rewrite. That switch is now
implemented as -recurse=nuget (2026-07-14): the go2cs stdlib (go.<pkg>), runtime (go.lib) and
analyzer (go.gen) become NuGet PackageReferences versioned $(GoStdLibVersion), while the app’s own
converted packages stay ProjectReferences and the converter emits an output-root Directory.Build.props
supplying the $(go2csPath) pin + a floating GoStdLibVersion default — so a converted app restores from
nuget.org with no deploy-core step. The same applies to third-party libs: because the template already
sets GeneratePackageOnBuild/PackageId, a converted third-party lib is itself NuGet-publishable — so
“convert once, reference everywhere” generalizes beyond the stdlib.