Baseline vs. Full Conversion — the separation contract

Companion to /CLAUDE.md. Defines what lives where, why, and the rules that keep the converter-improvement loop and the full-stdlib goal from colliding again.

The three things

  1. Baseline stdlib — src/core/<pkg> Small, hand-finished, compiling subset of the Go standard library. This is what the behavioral tests and converter-improvement loop build against. It must always stay green.

  2. Full auto-conversion — src/go-src-converted/ (target location) The entire Go standard library (302 packages, Go 1.23.1) auto-converted by go2cs -stdlib. The ultimate goal — and as of 2026-07-10 all 302 packages compile clean (commit 51ba5d9cf, tag stdlib-green-2026-07-10; the Phase-3 milestone). Compiling, not yet operational — running Go’s own package tests is Phase 4.

  3. Runtime — src/core/golib/ Hand-written C# runtime (slice, map, channel, @string, builtin, ж<T>, type aliases). Shared by both baseline and full conversion. Never auto-overwritten — some of it (builtin, unsafe helpers, assembly-backed routines) can never be produced by transpilation.

Why they must stay separate

Both baseline and full emit into namespace go with <pkg>_package static partial classes. Referencing both from one C# project produces duplicate-type collisions. So they are kept in separate directories and never referenced together by a single project.

How the collision happened (history)

Commit Date Event
9792eeea2 2020-07-09 Hand-converted stub created at src/gocore/<pkg> (Tour-of-Go support).
(many) 2020–2025 Stub maintained/refined for years; it was the working library.
ba6fef6c9 2025-03-08 src/gocore renamed → src/core (path change only).
3426298eb 2025-05-05 01:51 Last clean baseline. Stub compiles; tests green.
6ca1c45b7 2025-05-05 01:59 “Initial standard library conversion” — full stdlib written on top of src/core, overwriting the hand-finished packages (2,359 files, +508k lines).
cc14584c7 2025-05-11 Full-conversion work; tagged full-conversion-2025-05.
2026-06-25 2026-06-25 Separation restored: full conversion relocated to src/go-src-converted/; old stub restored into src/core; converter fixes; green baseline.

The mistake was writing the full conversion into the same directory as the baseline instead of a separate one. “All 305 packages converted successfully” meant the transpiler did not crash — not that the emitted C# compiles. The overwrite replaced compiling fmt/time/etc. with large machine-generated versions, which stalled the test loop.

The project was originally designed with this separation (gocore manual subset + go-src-converted full auto-output), so restoring it realigns with the original design.

How it was resolved (2026-06-25)

The contract (rules going forward)

  1. src/core/<pkg> is curated and must compile. Treat it as hand-owned source. Do not bulk-overwrite it with -stdlib output.
  2. src/go-src-converted/ is the full-conversion target. All go2cs -stdlib runs write here via -go2cspath. It may be regenerated wholesale; nothing hand-edited lives here long-term (fixes belong in the converter or, for out-of-band pieces, in golib).
  3. golib is shared and never auto-generated. Both trees reference src/core/golib/golib.csproj.
  4. Promotion go-src-converted → core is DEFERRED (strategy correction, 2026-07-01). Earlier work promoted packages into core as they went green (compiling). That was premature — compiling is not operating. Promotion should happen only once a package’s converted Go unit tests pass (Phase 4), and may not be needed at all (see The corrected end-state below). Until then, core stays the small bootstrap stub the behavioral tests build against (chicken-and-egg — the tests need a working library to run, and go-src-converted compiling doesn’t yet mean it works). sync/atomic already living in core is fine — it remains a useful stub. Do not promote further on the basis of a clean compile. The converter is never pointed at the baseline directory.
  5. The canonical MANUAL files live in core and are copied BACK into go-src-converted. Files marked [module: GoManualConversion] (the converter skips re-converting them) and hand-written *_impl.cs files are hand-owned in src/core/<pkg>. For a full-conversion milestone to be complete, these must be overlaid into their matching src/go-src-converted/<pkg> locations — that overlaid tree (auto-output
    • manual/asm stubs) is the real final state. overlay.sh already re-copies the src/core manual files after the cs/csproj copy; during these final compiling stages, do this religiously. The overlay must also copy the <name>.cs.auto review siblings the reconvert produces (a bare *.cs glob misses them) — and a reconvert only produces them when its output dir was seeded with the marked hand-owned files first, since the marker gate probes the destination .cs (see Hand-owning a package… below). Seeding is safe (2026-07-17 fix): a marked file is still analyzed and visited with its package — only its emission is redirected to the sibling — so every unmarked file of a seeded reconvert emits byte-identical to an unseeded run.

The corrected end-state (2026-07-01) — compile first, operate later

The milestone is a clean C# COMPILE of the whole overlaid go-src-converted (auto-output + the manual/*_impl.cs/asm stubs) — not an operational one. Operational correctness is Phase 4 (converting + passing the Go unit tests). Getting there, for runtime:

So the loop no longer stops at the S1/CS0030 “architectural wall” — it sorts: convert the native-type ops, apply the managed-referent model, and stub the genuine raw-metal dragons with GoManualConversion.

Once the whole stdlib compiles and the converted Go tests pass, a versioned build can ship to NuGet; at that point the chicken-and-egg is gone and core can be dropped (behavioral tests reference NuGet) or replaced with prior operational go-src-converted source — TBD.

Hand-owning a package to make it OPERATIONAL (Phase 4) — two patterns + the marker

Phase 3 stubbed the raw-metal dragons just to compile. Phase 4 (making packages run) needs the opposite in places: a faithful native reimplementation where the literal Go→C# conversion can compile but cannot work. The canonical case is sync (2026-07-11): its concurrency types are a state machine over the Go runtime sleeping semaphore (//go:linkname Semacquire/Semrelease/notifyList/…), which is co-designed with the mutex (starvation-mode ownership handed to one specific waiter via an exact ticket) and cannot be emulated on any .NET primitive — every emulation deterministically trips sync: inconsistent mutex state / unlock of unlocked mutex under sustained contention. The fix is to reimplement the types natively on proven .NET primitives (Mutex→binary SemaphoreSlim, WaitGroup→counter+latch, RWMutex→writer-preferring monitor lock). Expect more of this in Phase 4 (time, parts of os/syscall, …).

There are two ways a package carries hand-owned C#, and they are NOT interchangeable:

  1. *_impl.cs supplement — for SOME declarations in a file. The converter emits the file normally but, for types/funcs listed in manualConversionTypes / manualConversionFuncs (manualTypeOperations.go), replaces the body with a // … hand-converted … see the package's *_impl.cs comment and a bodyless partial. A hand-written <name>_impl.cs companion (no matching .go, so a reconvert never touches it) supplies the real bodies. Use when only part of a converted file needs managed semantics (e.g. sync/atomic, runtime/lock_sema). The *_impl.cs file typically also carries [module: GoManualConversion] for documentation, but does not need it (nothing regenerates it).

  2. Whole-file replacement — for an ENTIRE file, and it REQUIRES the marker. When the whole <name>.cs is hand-written (replacing the converted <name>.go output — e.g. sync’s mutex.cs/waitgroup.cs/ rwmutex.cs), it MUST carry [module: GoManualConversion], or a -stdlib reconvert regenerates the Go version straight over it. main.go’s conversion loop calls containsManualConversionMarker(<output>.cs) for each .go file and drops that file from the conversion set when the marker is present (directiveOperations.go). This is the ONLY thing that makes a whole-file native reimplementation durable across reconverts.

    Complete inventory of whole-file replacements (every non-*_impl file carrying a real module-level marker in src/go-src-converted; grep-verified 2026-07-16): sync mutex.cs / waitgroup.cs / rwmutex.cs / pool.cs; runtime runtime2.cs / mfinal.cs; syscall dll_windows.cs / exec_windows.cs (2026-07-19 — StartProcess only; see Child-process creation below); internal/godebug godebug.cs (2026-07-17, blocker R2 — parses $GODEBUG once on first use instead of the Go runtime’s update-hook cache; the literal conversion’s embedded *setting promotion faults at runtime because the generated promoted-field box treats its held nil pointer as a nil dereference even for the assignment that would populate it; subsumed and removed the older godebug_impl.cs hook companion) (these live in go-src-converted only); sync/atomic type.cs / value.cs and unsafe unsafe.cs (canonical in src/core, byte-identical copies in go-src-converted); math unsafe.cs (2026-07-16 — Float32/64 bits/frombits as direct BitConverter bit-cast intrinsics, replacing the literal conversion’s ж<T>/uintptr round-trip that compiles but cannot reinterpret bits at runtime; canonical in src/core/math, byte-identical copy in go-src-converted/math; guarded by the MathFloatBits behavioral test). Several *_impl.cs companions also carry the marker (documentation only, per pattern 1): internal/abi type_impl.cs, reflect value_impl.cs, runtime lock_sema_impl.cs / runtime2_impl.cs, sync runtime_impl.cs, internal/poll runtime_sema_impl.cs, syscall syscall_impl.cs (2026-07-19), and math/rand + math/rand/v2 rand_impl.cs (2026-07-17, blocker R3 — runtime.rand linkname bodies on Random.Shared: OS-entropy seeded, thread-safe, non-deterministic run to run exactly like Go’s runtime generator; the os / net / hash/maphash runtime_rand declarations still carry throwing stubs).

Marker mechanics: [AttributeTargets.Module, AllowMultiple = true] (golib GoManualConversionAttribute), so one per file across a package is fine. The scanner wants it before the first class, so place it after the usings and before the file-scoped namespace, written [module: go.GoManualConversion] (fully qualified so it resolves without a using go;). Verify a whole-file override survives by reconverting the package into a dir seeded with the hand-written file and confirming it stays byte-identical (go2cs -stdlib -go2cspath <seeded-root> <pkg> → the marked .cs is untouched).

Upgrade-time review — the <name>.cs.auto sibling (2026-07-16; emission model corrected 2026-07-17). A marker-skipped file would otherwise leave NO auto-converted output at all, so a Go-version upgrade would have nothing to diff the hand-owned C# against. The converter therefore emits a non-compiled <name>.cs.auto sibling beside every marked <name>.cs — the converter’s best-effort auto conversion of the same .go, for review only. It need not compile and is invisible to the build: generated csprojs compile <Compile Include="*.cs" /> only, which cannot match a name ending in .auto.

How it is emitted matters (2026-07-17 defect fix). A marked file is NOT dropped from the conversion pipeline: it stays in the convert set and is analyzed and visited with the package, in normal file order, so every piece of package-wide emission state its declarations feed — anonymous-struct lifts, package-var registrations, escape/addressed-global analysis, imports, init/temp-var numbering — reaches the package’s other files exactly as in an unseeded conversion; only the file’s WRITE target is redirected from <name>.cs to <name>.cs.auto (main.go’s file-visit loop). The original implementation instead skipped the marked file’s entire visit and emitted siblings in a separate last pass (emitAutoConversionSiblings), which corrupted every OTHER file of a seeded package: runtime’s proc.cs emitted raw Go struct{…} text where schedt’s lifted anonymous-struct type names belong (unparseable C#, a CS1513/CS1022 cascade) and re-declared the newprocs = 0 package-var assignment as a shadowing local var newprocs = 0;, because runtime2.go’s state contributions never registered. With the fix, a seeded reconvert is byte-identical to an unseeded one for every unmarked file — plus the marked .cs left untouched and the .cs.auto siblings added. Guarded by the ManualConversionSiblingState behavioral test (a marked state.cs whose skipped state.go declares an anonymous-struct-field type and package vars; the sibling main.cs consumes both). emitAutoConversionSiblings (src/go2cs/autoSiblingOperations.go) remains only for FULLY hand-owned packages, where the normal conversion path is skipped outright (no unmarked files, and no .csproj / package_info.cs / package_init.cs regeneration).

Siblings are committed in src/go-src-converted/<pkg> and refreshed by reconverts — the gate probes the DESTINATION .cs, so a reconvert only produces them when its output dir is seeded with the marked files first (see §5 above); with the fix that seeding is safe. *_impl.cs companions have no matching .go, so they get no sibling; unsafe is never queued by -stdlib (compiler-intrinsic, stdLibConverter.go), so unsafe/unsafe.cs has none either. Single-file conversion mode (go2cs example.go) emits no siblings — the marker gate is not effective there to begin with.

The rule from §5 still holds: canonical hand-owned files live under src/core/<pkg> and are overlaid into src/go-src-converted/<pkg> (overlay.sh) — with the marker, an overlaid whole-file override then survives the next reconvert instead of being clobbered.

Child-process creation (os/exec) — what had to be hand-owned, and what did not

Re-executing the current binary is how os, os/exec, runtime, flag, log and a large number of stdlib test suites exercise subprocess behavior, so this path gates a lot of Phase 4. Four distinct layers were broken; only one of them warranted hand-owning.

  1. golib — the reinterpret seam boxed a copy instead of aliasing the address, so os.Environ() walked the GC heap and freed it (STATUS_HEAP_CORRUPTION). Fixed generally in ж<T> (see A reinterpreted raw address ALIASES native memory in ConversionStrategies-Reference.md).
  2. Converterunsafe.Pointer(p) on a pointer parameter dereferenced it, so the nil out-pointer in StartProcess’s deferred DuplicateHandle panicked; and a switch default: clause could be emitted unchained, so every (*Process).wait returned “os: unexpected result from WaitForSingleObject”. Both fixed in the converter with behavioral guards.
  3. Bodyless runtime-provided partialssyscall.Exit / Getpagesize / runtimeSetenv / runtimeUnsetenv and os.runtime_beforeExit are provided by Go’s runtime, so go2cs emits throwing stubs. Supplied as *_impl.cs companions (pattern 1). Without them no converted program could exit deliberately, and a child reported the stub panic instead of its own status.
  4. syscall.StartProcess — the genuine hand-own (pattern 2). Go hands CreateProcessW a *_STARTUPINFOEXW whose fields are pointers into native memory; the converted struct holds them as golib ж<T> boxes — managed class references that are neither the right bytes at the right offsets nor marshalable at all (unsafe.Sizeof(*si) throws “cannot be marshaled as an unmanaged structure”). Same for _PROC_THREAD_ATTRIBUTE_LIST over an array<byte>. This is the memory-layout / raw-metal case, so StartProcess is transcribed against blittable [StructLayout(LayoutKind.Sequential)] mirrors and direct P/Invokes. Every other declaration in exec_windows.cs is the converted output verbatim — argument escaping, command-line building, environment-block building and path normalization are pure Go logic that converts faithfully — and the native code reuses those helpers plus the scalar-only converted wrappers (GetCurrentProcess, DuplicateHandle, CloseHandle).

Note what did not need hand-owning: SecurityAttributes is fully blittable (two uint32s and a uintptr), so CreatePipe — and therefore os.Pipe, which CombinedOutput relies on — works through the ordinary converted wrapper. The struct-passing seam only breaks for structs holding ж<T> fields.

The hand-owned implementation also copies every buffer handed to CreateProcessW (application name, command line, environment block, working directory, handle list, attribute list) into unmanaged memory for the duration of the call, freeing it in a finally. That closes the transient-pinned-address window documented in dll_windows.cs, where golib’s жuintptr conversion can only produce an address a compacting GC might invalidate mid-call.

Verified capability: a converted program re-executes itself, passes argv and a modified environment, has stdout and stderr captured through CombinedOutput, waits, and surfaces the child’s exit code — byte-identical to go run on the same program.

Known limits. Windows only (StartProcess is the Windows implementation; the POSIX fork/exec path is untouched). Go’s SysProcAttr surface is honored — HideWindow, CmdLine, CreationFlags, Token (via CreateProcessAsUserW), ProcessAttributes/ThreadAttributes, NoInheritHandles, AdditionalInheritedHandles, ParentProcess — but only the plain CreateProcessW/CreateProcessAsUserW paths are exercised by tests so far. syscall.Exec remains EWINDOWS, as in Go. Signals and process groups are unaddressed; Process.Kill/Signal route through the ordinary converted os code and were not exercised here.

Regenerating the full conversion

Current Go converter (authoritative flags in src/go2cs/main.go):

# Whole stdlib into the separate target:
go2cs -stdlib -comments -go2cspath <repo>/src/go-src-converted

# Specific packages only (used when greening a closure bottom-up):
go2cs -stdlib -comments -go2cspath <repo>/src/go-src-converted fmt strings io sort time

Always pass -comments for stdlib conversion. It defaults off, but the converted C# is a derivative work — the per-file // Copyright … The Go Authors … BSD-style license header must be preserved, and the Go doc-comments keep the output readable. Without the flag, headers and comments are stripped.

Package conversion is sequential (it relies on package-level converter state); output .csproj references are generated from detected imports.

Note on the <go2cspath>/core subdir: the stdlib converter writes packages to <go2cspath>/core/<pkg> (a hardcoded core subdir). To regenerate cleanly into src/go-src-converted you must either point -go2cspath so that subdir lands there, or convert to a temp dir and move. Don’t let it overwrite the baseline src/core packages.

The old stub as a fallback / reference

The last clean stub (3426298eb) is the source of today’s baseline. To inspect or recover individual files:

git worktree add ../go2cs-stub-ref 3426298eb      # browse the last clean baseline
git show 3426298eb:src/core/fmt/print.cs          # or per file

Stale tooling