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 NuGet PackageReference later 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 + the go2cs-gen analyzer at %GOPATH%\src\go2cs and writes a root Directory.Build.props that 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-recurse and phases P1–P5 are all implemented, gated, and committed. The -recurse feature 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; -go2cspath remains the independent stdlib/runtime root. The Tour uses this form after go mod tidy, so exercise imports such as golang.org/x/tour/wc resolve, 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:

  1. A developer downloads a Go application from GitHub (git clone / go install).
  2. It imports one or more third-party libraries plus the standard library.
  3. They point go2cs at 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

What’s missing

  1. No recursive convert loop for a non-stdlib module. processConversion iterates 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.
  2. The dependency graph / topological sort is hardwired to the stdlib. scanStdLib (stdLibConverter.go:135) loads the "std" pattern with GO111MODULE=off; isStdLib (stdLibConverter.go:301) rejects any path containing . — so github.com/... modules can never enter the graph.
  3. Module-cache dependencies would be “converted in place.” When build.Import can’t resolve a module-cache dep (the usual case — the module cache isn’t on GOPATH/src), resolution falls to getLocalModulePackageInfo (importOperations.go:251), which treats output as co-located with the Go source (TargetDir = meta.Dir, lines 285-292). For a replaced / 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.
  4. Solution generation is stdlib-only. GenerateSolutionFile (solutionGenerator.go:36) walks the core/ output tree; it needs to also take in the pkg/ (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:

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:

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 ProjectReferencego.<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

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).

  1. Fetch a small real app into a scratch module — proposed: a CLI using github.com/fatih/color, which pulls github.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 like github.com/spf13/pflag or github.com/google/uuid for a one-hop app → lib → stdlib graph.)
  2. Baseline with Gogo build ./... to confirm it compiles as Go first.
  3. Stage the stdlibdeploy-core stdlib once, putting the compilable stdlib + analyzer + root Directory.Build.props at %GOPATH%\src\go2cs.
  4. Convertgo2cs -recurse <module-dir> -go2cspath %GOPATH%\src\go2cs, converting the app + third-party libs under that root (stdlib referenced, not converted).
  5. Builddotnet build the generated solution.
  6. 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:

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):

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:

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.

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):

  1. (medium, go2cs-gen) A C#-keyword-named Go type nested as a generic type argument (the ж<short> pointer-box of an Indirect/self-box conversion) is emitted unescaped — GetFullTypeName rebuilds generic names from the raw ITypeSymbol.Name, and the single top-level EscapeCsKeyword cannot reach nested arguments. Needs recursive keyword-escaping of generic arguments.
  2. (medium, converter) A const typed through an alias to uintptr (type Handle = uintptr; const X Handle = 42) still emits const — the #2 unalias reached the *types.Named gate but the downstream csTypeName == "uintptr" guard reads the alias name (Handle), not the unaliased underlying.
  3. (medium, go2cs-gen) EscapeCsKeyword misses C# contextual keywords (file) that the converter DOES escape (os-style type file …@file), so a keyword-named-type conversion for such a type mismatches.
  4. (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.
  5. (low, converter) An alias-to-alias-to-named chain (type A = Named; type B = A) emits global using B = go.<pkg>.A;, referencing the intermediate alias A as if a class member (CS0426).
  6. (robustness, recurse) processConversion’s log.Fatalf on a package load failure (e.g. a dependency whose transitive test-dep has a missing go.sum entry — gopkg.in/yaml.v3gopkg.in/check.v1) aborts the ENTIRE -recurse run rather than logging + skipping that one package. ModuleConverter.convertAll already recovers panics; the load path should return an error instead of exiting.
  7. (robustness, recurse) A deeply-nested module-cache subpackage (github.com/google/go-cmp/cmp/internal/ flags) converted with a bare namespace go; instead of the full dotted namespace — getProjectName’s walk-up-to-go.mod needs to handle the @version-segmented cache path at depth.
  8. (low, converter) A C# keyword embedded after a dot in a single import-path segment (the in of gopkg.in) is escaped by getProjectName (the dependency’s own namespace → gopkg.@in) but NOT by convertImportPathToNamespace (an importer’s reference → bare gopkg.in), so a gopkg.in/* dependency’s own package compiles while its importers mis-reference it. Needs a dot-splitting sanitizer in convertImportPathToNamespace that escapes the embedded keyword WITHOUT Δ-prefixing the _package class suffix (the naive getCoreSanitizedIdentifier swap does the latter — see the reverted attempt above).

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:

  1. internal/godebug runtime //go:linkname hooks (setUpdate/registerMetric/setNewIncNonDefault) were throwing PartialStubGenerator stubs → godebug_impl.cs companion (no marker needed — supplying a bodyless partial’s implementing part auto-suppresses the stub). One-shot update() notify.
  2. Windows syscall FFI (loadlibrary/getprocaddress/Syscall…/SyscallN) → native syscall/dll_windows.cs (whole-file GoManualConversion): LoadLibraryExW/GetProcAddress P/Invoke, plus an unmanaged function-pointer calli dispatch (a switch on argument count — Windows x64 has a single calling convention). LazyDLL.Load/LazyProc.Find use a plain double-checked lock (CLR reference writes are atomic — Go’s atomic.LoadPointer((*unsafe.Pointer)(&d.dll)) managed- referent round-trip cannot be emulated).
  3. runtime’s own init functions divided by zero / hit stubs at class-initialization (arena sizing checks against a zero physPageSize, 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): for pkg.Path()=="runtime", emit init funcs as commented-out /* [GoInit] runtime bootstrap init - not run */ plain methods. Behavioral-neutral (CNR byte-identical — scoped to runtime, which no behavioral test is).
  4. efaceOf reinterpret panicked at class-init (several _type field initializers walk it) → returns an inert eface in runtime/runtime2.cs (descriptor walking is vestigial — go2cs replaces Go’s itab dispatch with C# interfaces + generators). Same file seeds gomaxprocs/ncpu from Environment.ProcessorCount (the bootstrap would; left 0, sync.Pool sizes a zero-length shard array and indexes out of range on the first Println).
  5. sync.Pool per-P sharding presumes Go’s scheduler → native sync/pool.cs (whole-file GoManualConversion): one ConcurrentBag per Pool, lazily created through the Pool’s heap box (the next member of the native-sync family).
  6. runtime.SetFinalizer walks type descriptors (os.newFile sets a finalizer on every opened file) → runtime/mfinal.cs frozen whole-file GoManualConversion, its body replaced with a ConditionalWeakTable + 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)

  1. Flag vs. auto-detectexplicit -recurse (default off; single-package behavior unchanged).
  2. Module version in the output pathstrip @<version> (single-version assumption; the $(go2csPath)pkg\<import-path> path is derived from the version-free import path).
  3. Which pre-converted stdlib to reference — the deploy-core modes: stdlib (full src/go-src-converted, compilable) or stub (runnable baseline); both stage to %GOPATH%\src\go2cs\core. 5b used stdlib.
  4. Test appfatih/color (the small DAG), with github.com/google/uuid as 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 ProjectReferencePackageReference 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.