Companion to /CLAUDE.md. The path from "loop stalled" to a converted Go standard
library that compiles, passes its upstream tests, and has working C# implementations for its
assembly-backed declarations. Sequenced green the loop first, then compile, validate, and complete
the full conversion.
Status (2026-06-27): Phases 0–2 done — baseline is green. Phase 3 is driving the full conversion to compile. Phase 4 will convert and run Go's own package tests; Phase 5 will use those tests to prove the C# implementations that replace assembly/cgo stubs.
Orientation docs so any task starts informed: /CLAUDE.md,
Architecture.md, Baseline-vs-FullConversion.md,
this file; plus a refresh of README.md.
Goal: src/core = compiling baseline; src/go-src-converted/ = full WIP; golib shared.
- Tagged the full conversion (
full-conversion-2025-05→cc14584c7). - Relocated the auto-generated stdlib out of
src/coreintosrc/go-src-converted/(2604-file rename); rewrote csproj/sln refs; added.gitignorerules for the Godebug/logpackage name collisions. - Scoped
src/go2cs.slnto the baseline; addedsrc/go-src-converted.slnfor the WIP. - Fixed
deploy-core.bat(gocore→core).convert-gosrc.*retarget still pending.
Outcome: rather than green the full fmt closure bottom-up (57 packages incl runtime/syscall/os),
we restored the old hand-finished stub (3426298eb) into src/core — it compiles cleanly against
today's golib, so the test loop went green immediately. Plus several converter fixes (below). The
57-package-closure analysis below is retained as context for Phase 3 (the full conversion).
The behavioral tests reference golib + the go2cs-gen analyzer + a few stdlib projects (fmt mostly,
plus time/unsafe/strings/sort/math/rand/io). The stub fmt is a minimal proxy with a tiny
closure (errors, io, strings, sync, math, …) — it avoids the runtime substrate, which is why restoring it
is the fast path to green. Restored 14 packages; excluded the stub testing (drifted, unused by tests).
The behavioral tests reference golib (all 59) + the go2cs-gen analyzer (all) + a few stdlib projects:
fmt (55 tests), time (5), unsafe (3), strings (1), sort (1), math/rand (1), io (1).
But these pull a transitive dependency closure. fmt alone references:
errors, internal/fmtsort, io, math, os, reflect, slices, strconv, sync, unicode/utf8 (and those pull
more). So "green the loop" means green fmt's closure, bottom-up.
- Compute closure + order. Reuse
stdLibConverter.go's topologicalsortedQueue, restricted to the baseline roots, to get a leaf-first build order (unsafe,internal/abi,unicode/utf8,math/bits, … up tofmt). - Green packages bottom-up. For each package in order:
dotnet buildagainst currentgolib; fix the converter (preferred) — or, for genuinely out-of-band pieces likebuiltin, the runtime — until it compiles. Crib from thego2cs-stub-refworktree when a hand-finished prior approach helps. - Lock it in with a regression test. Each greened package gets a behavioral/compile test so it stays
green (mirrors the "raw code vs target file" comparison mode noted in
src/go2cs/ToDo.md). - Exit criterion. Baseline
src/corebuilds clean and all 59src/Tests/Behavioralprojects pass. The converter-improvement loop is restored.
fmt's closure is 57 packages — the fullfmtpulls in the entire runtime substrate (runtime,syscall,os,reflect, and a deepinternal/*tree), unlike the old 36-line stubfmt.- Baseline build status: 18 / 57 compile clean out of the box (built in place in
go-src-convertedagainst currentgolib). The pipeline works; failures are concentrated converter defects, not intractability. - Prioritized converter-defect roadmap (own-errors across a probe of failing leaf/mid packages):
Count Code Meaning Status 48 CS0106 invalid modifier ( static readonlyon a function-local named-type const)FIXED — visitValueSpec.go, verified math/bits 16→020 CS1002 ;expectedopen (syntax cluster) 18 CS1519 invalid token in member declaration open (syntax cluster) 18 CS1026 )expectedopen (syntax cluster) 18 CS1003 syntax error, X expected open (syntax cluster) 18 CS0051 inconsistent accessibility (param type less accessible) open — CS0103 missing package-level lookup tables (e.g. ntz8tab/pop8tabin math/bits)open - Converter-improvement loop (proven end-to-end): edit
src/go2cs/*.go→go build(Go 1.23.1) → re-transpile →dotnet build. (For behavioral tests the harness runs this loop itself — see/CLAUDE.md"Test-harness mechanics".) - Retarget detail: the stdlib converter writes to
<go2cspath>/core/<pkg>(hardcodedcoresubdir). To regenerate cleanly intosrc/go-src-converted, point-go2cspathaccordingly or convert to a temp dir and move. Batch several converter fixes, then do one wholesale reconvert + re-measure.
Each verified by rebuild + reconvert + compile:
golib/UntypedIntmissing semicolon (CS1002) — blocked the whole runtime.static readonlyon a function-local named-type const → emit a plain local (CS0106) —visitValueSpec.go.- Multi-line type-constraint unions rendered with only the first line commented → collapse to one comment
line (
visitInterfaceType.go). ~[]Eslice constraint wrongly givenIEqualityOperators—*Slice/*Arraycases were backwards and an empty constraint-type set counted as a subset of every operator set (constraintOperations.go).- Switch fallthrough case with a single pattern value emitted an unbalanced
)(visitSwitchStmt.go). - Negative typed const (
int8 = -1) promoted toGoUntypedbecause the range check usedParseUint(rejects negatives) → also tryParseInt(visitValueSpec.go).
- Build-error roadmap. Convert +
dotnet buildsrc/go-src-converted/, capturebuild.log; bucket compile errors by frequency and by Go feature. This is the prioritized work queue (the README already frames this log as the road map). - Fix by error class, bottom-up the full DAG. Highest-frequency converter defects first (each fix clears many packages). Re-convert, re-bucket, repeat. Measure by packages-compiling and error-count, never by "conversion succeeded."
- Promote, don't fork. When a full package compiles cleanly and matches behavior, promote it toward the
baseline;
golib's hand-written core stays shared and never auto-overwritten. Track promotions here.
Measurement workflow used (no wholesale commit of go-src-converted; it stays regenerable):
reconvert the stdlib to a temp dir (go2cs -stdlib -comments -go2cspath <tmp> — always -comments so the
Go authors' BSD license header survives; output lands in <tmp>/core/<pkg>),
overlay the fresh .cs onto src/go-src-converted/<pkg> (keeping the relocated csprojs, or regenerating
them and rewriting $(go2csPath)core\ → $(go2csPath)go-src-converted\ except core\golib), then
dotnet build src/go-src-converted.sln and bucket. Single packages build with
-p:go2csPath=<repo>\src\ so the $(go2csPath) golib ref resolves outside the solution.
Four converter defects fixed (each verified by reconvert + compile of an affected package; behavioral suite stays green: 216/216):
| Defect | Symptom (top error class) | Fix |
|---|---|---|
Variadic of a type parameter (func Or[T any](v ...T)) emitted a namespace-level using ꓸꓸꓸT = Span<T> alias — T out of scope |
CS0246 'T' not found |
visitFuncDecl.go: when the variadic element is a *types.TypeParam, emit params Span<T> inline (C# 13 params-collections) instead of the alias. |
Go built-in comparable constraint emitted as bare comparable (golib's type is generic comparable<T>) |
CS0305 requires 1 type argument |
main.go getGenericDefinition: special-case comparable → comparable<T>. |
Bodyless (assembly/cgo) funcs emitted as accessibility-modified partial methods with no implementing half |
CS8795 (49) — biggest cluster | visitFuncDecl.go: emit a non-partial throwing stub (=> throw new NotImplementedException(...)) until an asm/cgo backend exists. |
Filename build-constraint over-exclusion: isFileNameCompatible treated any unknown _word suffix (e.g. bits_tables.go, bits_errors.go) as a failing platform tag, silently dropping the file → missing symbols |
CS0103 (pop8tab/ntz8tab/divideError…) |
directiveOperations.go: only a trailing recognized _GOOS, _GOARCH, or _GOOS_GOARCH constrains the build (full GOOS/GOARCH name tables added); descriptive suffixes impose no constraint. |
The filename fix is the highest-impact: a full reconvert went from 1472 → 1660 emitted .cs files (~188
previously-dropped stdlib source files now converted). That raises the raw error count (144 → 224) because
newly-included files surface their own latent defects — so track packages-compiling, not error count,
this phase.
internal/cpu/cpu_x86.cs owned ~140 of the syntax errors, all from &cpu.X86.HasADX (address of a field
of the anonymous-struct package global X86). Two stacked bugs, both fixed; behavioral suite stays green
216/216:
| Bug | Symptom | Fix |
|---|---|---|
The anonymous struct type of X86 is lifted to X86ᴛ1 while visiting cpu.go, but liftedTypeMap is per-file (each file gets its own concurrent Visitor), so cpu_x86.go couldn't resolve it — getExprTypeName fell back to the raw struct text, mangled to cpu.CacheLinePad} |
syntax errors ()/; expected …) |
New package-level shared registry (packageDynamicTypeNames, signature→C# name) populated by visitStructType for package-level lifts; convUnaryExpr emits a marker for unresolved anonymous structs, resolved after the file-visit barrier (dynamicTypeOperations.go, main.go). Race-free: resolution runs post-Wait(). |
With the name fixed, &X86.HasADX emitted ᏑX86.of(…) (identifier form), assuming a heap-boxed pointer companion — but a package-global value var has none |
CS0103 ᏑX86 does not exist |
convUnaryExpr: choose the address-of form by escape state — isHeapBoxedExpr (mirrors the existing escape check) → Ꮡvar.of(…) for escaping locals, else the constructor form Ꮡ(value).of(…) (consistent with the existing whole-value &global path). |
Result: internal/cpu went ~140 → 8 errors. Caveat: Ꮡ(value) heap-allocates a copy (golib
Ꮡ<T>(in T)), so &global.field currently points into a copy — a pre-existing whole-category limitation
(&global already did this on line 124); the proper fix is boxed companions for package-global vars whose
address is taken (future work).
The remaining 8 internal/cpu errors are fixed — internal/cpu is the first full-conversion stdlib
package to compile clean (was the ~140-error blocker). Three general fixes, all behavioral-green (228/228);
re-transpiling all 61 behavioral projects left every golden byte-identical (no converter-output regression),
and the whole solution rebuilds clean against the changed golib:
| Defect | Symptom | Fix |
|---|---|---|
Large untyped constant typed by value-range as (nint)…L even in an unsigned context (cpuid(0x80000000, 0)) |
CS1503 nint → uint (×6) |
convBasicLit: in the > int32 branch, if the literal's contextual type is unsigned (isUnsignedType via info.Types), emit an unsigned C# literal (2147483648U / …UL). |
Slicing an @string returned slice<byte>, so field[:4] != "cpu." was slice<byte> != string |
CS0019 | golib: @string this[Range] now returns @string (Go string slicing yields a string). Runtime-only — no .cs change. |
Empty-string literal in a tuple assignment emitted ""u8 (a ReadOnlySpan<byte> ref struct) — illegal as a ValueTuple element |
CS9244 | visitAssignStmt: suppress the u8 form for string literals in a multi-value (tuple) RHS (field, env = env, ""). |
Guarded by the StringSliceAndUnsignedConst behavioral test.
Ꮡ(value) heap-allocates a copy, so &global / &global.field pointed into a copy — mutations never
reached the global (e.g. internal/cpu.doinit set feature flags on a throwaway copy of X86). Fixed by
backing address-taken package-global vars with a heap box, so the pointer references the original:
- New
globalAddressOperations.go: a synchronous pre-pass (collectAddressedGlobals) scans all files for&g/&g.field/&g[i]rooted at a package-level var →packageAddressedGlobals(cross-file, since the global may be declared in one file and addressed in another). visitValueSpec: an addressed global is emitted as a box + ref-property —static ж<T> ᏑG = new(default(T)); static ref T G => ref ᏑG.val;— instead ofstatic T G;. Reads/writes ofGare unchanged (the ref-property forwards to the box). Only address-taken globals are boxed; everything else keeps the plain field, so the blast radius is tiny (onlyGlobalStructFieldPointersre-transpiled).convUnaryExpr/isHeapBoxedExpr: for an addressed global, emit the identifier formᏑG(the box) rather thanᏑ(G)(a copy).&X86.HasADX→ᏑX86.of(X86ᴛ1.ᏑHasADX).
internal/cpu still compiles clean and now mutates the real X86. Behavioral green; GlobalStructFieldPointers
strengthened to assert the global itself is mutated (would print false/0 before the fix).
Known limitation: cross-package &otherPkg.ExportedGlobal isn't boxed (only globals addressed within
their own package are detected).
A package-global var whose type is inferred from an anonymous-struct composite literal
(var S = struct{…}{…}) emitted the raw struct{…} text as its C# declaration type
(public static struct{A int; B int} S = new Δtype(…);) — invalid C#. The value was lifted to a named type
but the declaration wasn't (the lifting happened inside the composite literal, after the declaration type
name was resolved). Fix in visitValueSpec: for a package global with an inferred anonymous-struct type, lift
the struct with the var name before resolving the declaration type (mirroring the explicit-type path), so
both the declaration and the value share one lifted name (Sᴛ1). This also unblocks boxing such globals,
so addressed anonymous-struct globals (&S.field) now work too. Behavioral green; zero existing goldens
changed (no behavioral test had an anonymous-struct global). Guarded by an extension to the AnonymousStructs
test (a package-global anonymous-struct var, read and mutated through a field pointer).
A public struct embedding an unexported marker type as a blank field (_ noCopy, the
sync/atomic.Bool pattern) made the TypeGenerator (Roslyn) emit public Bool(noCopy _) —
a public constructor whose parameter type noCopy is internal → CS0051. Root cause:
GetScope("_") returns "public" (the firstChar == '_' rule), so the blank embedded field
was classified as a public member and drove the public ctor. Fix in StructTypeTemplate.PublicStructMembers:
exclude blank/underscore-prefixed fields (never exported in Go) from the public constructor. All CS0051
in sync/atomic cleared; behavioral green (232/232 + the new test). Guarded by UnexportedEmbeddedMarker.
Resolves the iteration-6 follow-up. Bodyless (asm/cgo) Go functions are once again emitted by the converter
as partial declarations (reverting iteration-1's non-partial throwing stub). A new PartialStubGenerator
(go2cs-gen) emits a throwing partial implementation for every bodyless partial method that has no
other implementing part in the compilation (IMethodSymbol.PartialImplementationPart is null). So:
- packages that ship a hand-written companion (
sync/atomic'sdoc_impl.cs, realInterlockedbodies) use those bodies — the generator detects the impl and skips them; and - companion-less packages (
crypto/internal/boring/sig,crypto/subtle, …) get a generated throwing stub, so they compile instead of CS8795/CS0111.
sync/atomic now compiles clean — the second full-conversion stdlib package to go green (after
internal/cpu). sig compiles too. Behavioral suite stays green (the generator is a no-op for the tests —
none contain asm functions; zero behavioral .cs changed). Not behaviorally testable (Go rejects a bodyless
function without an .s file), so verified via the full-conversion packages compiling.
A behavioral validation test (atomic ops, Go-vs-C# output, referencing go-src-converted/sync/atomic)
was written as the gate before promoting sync/atomic to the baseline. It failed, and that is the
point — "compiles" ≠ "correct". The package-level functions (atomic.AddInt32(&n, 3)) work, but the
typed atomic types are broken: var i atomic.Int32; i.Store(10); i.Add(5); i.Load() yields 0 in C#
instead of 15 (Go).
Root cause: a method like func (x *Int32) Store(v) { StoreInt32(&x.v, v) } converts &x.v (address of a
field of the pointer receiver) to Ꮡ(x.v), which boxes a copy (the ж(in T) ctor copies), so the
atomic op never touches the real field. Attempting the fix uncovered a deep stack of issues in the
receiver-capture mechanism, which is the only way to get a non-copying pointer to a receiver field:
&recv.fieldmust use the captured receiver box (Ꮡx.of(Type.ᏑField)), notᏑ(x.field)— and the detection has to run before the struct-field gate (a pointer receiver's selector type is a pointer).- The capture field name (
<Method>ꓸᏑx) collides across overloaded same-named methods on different receiver types (Int32.Add,Int64.Add, …) — needs the receiver type in the name (converter + generator). - The capture field is a static
ThreadLocalon the (non-generic) package class, so it cannot hold a generic receiver'sT(atomic.Pointer[T]). - Even fixed, the
ThreadLocalis only initialized when the method is called via theж(pointer) overload; a value-receiver-style call (i.Store(10)) routes through therefoverload and the capture is never initialized (runtime "Receiver target … is not initialized").
Conclusion: sync/atomic is not promotable — its primary API doesn't work, and the receiver-field
address / capture machinery needs a substantial rework (likely replacing the static-ThreadLocal capture
with something that works for value calls and generic receivers). internal/cpu likewise isn't promotable
(asm cpuid is a stub). Promotion stays pull/validation-driven; this gate correctly blocked it.
Making &recv.field reference the real receiver field (the sync/atomic typed-type unlock). Split into two
stages:
- Stage A — DONE (commit
36363ef16, behavioral green 236/236).convUnaryExpremits the captured-box field-ref form (<capturedName>.of(Type.ᏑField)) for&recv.fieldon a non-generic pointer receiver (the detection must run before the struct-field gate, since the receiver's selector type is a pointer). The captured-receiver field name is made unique per receiver type (<Method>_<RecvType>ꓸᏑx) so it doesn't collide across overloaded same-named methods (Int32.Add,Int64.Add, …) — coordinated in the converter (getCapturedReceiverName) and the generator (ReceiverMethodTemplate). - Stage B — DONE (commit
9aeaf29e2, behavioral green 236/236). Value-receiver calls of capture-mode methods now route through theжoverload so the captured form references the real field:captureModeOperations.go— a pre-pass (collectCaptureModeMethods) scans the package and its transitive imports (LoadAllSyntaxprovides dep ASTs) for non-generic pointer-receiver methods taking&recv.field, keyed by the interned*types.Funcso cross-package call sites match;escapeAnalysisOperations.go— a value var on which a capture-mode method is called is marked escaping (heap-boxed), so itsᏑnamecompanion exists;convSelectorExpr.go— the call is routed through theжoverload (Ꮡi.Store(10)). Also fixed an invertedCompareAndSwapin the hand-writtensync/atomiccompanion (doc_impl.cs):Interlocked.CompareExchangereturns the original, so a swap succeeded iff== old(was!= old) — found by the validation test. Result:sync/atomic's scalar typed types (Int32/64,Uint32/64/ptr,Bool) now work end to end —var i atomic.Int32; i.Store(10); i.Add(5); i.Load()→ 15, CAS/Swap/etc. all match Go. Guarded by the self-containedReceiverFieldAddressbehavioral test (nogo-src-converteddependency).
- Generic receivers (direct-
ж) — DONE. Generic capture-mode receivers (atomic.Pointer[T]) are now emitted with the heap box as the receiver (this ж<Box<T>> Ꮡx+ref var x = ref Ꮡx.val;), and&x.fieldfield-refs through the box parameter (Ꮡx.of(Box<T>.ᏑField)) —Tstays in scope, no static field needed.[GoRecv]is suppressed automatically (the signature isthis ж<…>, notthis ref …, soRecvGeneratorskips it and there is no duplicate overload). Value calls heap-box and route through the ж overload (the Stage-B escape/routing already handles this once the generic methods are included). Converter changes:captureModeOperations.go(newpackageDirectBoxReceiverMethods, keyed by*types.Func.Origin()so instantiated call sites match),visitFuncDecl.go(un-skip the receiver deref + emitж<T>receiver),convUnaryExpr.go(generic branch →Ꮡx.of(...)), andmain.gogetFullTypeName(append type args for instantiated cross-package generics, else a boxedatomic.Pointer[Config]emitsnew …Pointer()with no<Config>). Validated by theGenericReceiverFieldAddressbehavioral test (a genericBox[T]withSet/Gettaking&b.v, exercised forintandstring); behavioral suite green, zero churn. - Direct-
жextended to ALL field-address capture methods — fixes a concurrency bug. The non-generic capture path used a staticThreadLocal<ж<T>>field reassigned per call (new ThreadLocal<…>(() => Ꮡx)), which is a shared static — concurrent calls on distinct receivers race on that field, so e.g.u0.Load()can returnu6's value (proven by an 8-thread stress test: each thread Loads its ownatomic.Uint32, sees another's). Broken precisely for the concurrent types atomics exist for, and invisible to the single-threaded behavioral suite. Fix:captureModeOperations.gonow marks every&recv.fieldcapture method (generic and non-generic) direct-ж, so the box is the receiver parameter — no shared state, and alloc-free.convUnaryExpr.goalways emitsᏑx.of(...);visitReturnStmt.goreturnsᏑrecvfor a direct-box method (keeps a method that both takes&recv.fieldand returns the receiver consistent). Re-ran the stress test → "no race observed".core/sync/atomic/type.csscalars re-emitted to direct-ж(the file isGoManualConversion, so re-emitted by hand from a reconvert, keeping the managedPointer<T>). Guarded by theAtomicValuestest; only theReceiverFieldAddressgolden changed (intended, still correct). - Return-receiver capture converted too, and the ThreadLocal mechanism DELETED. The other capture trigger
—
func (t *T) Common() *T { return t }(internal/abi) — now also emits direct-ж(captureModeOperations.gobodyReturnsReceivermarks it direct-box;visitReturnStmt.goreturnsᏑrecv). With both triggers on direct-ж, nothing emits[GoRecv("capture")]anymore, so the whole racy mechanism is dead and was removed: converter (captureReceiverfield,getCapturedReceiverName, the[GoRecv("capture")]emission) and generator (ReceiverMethodTemplate'sCapturePointer/CaptureName/CaptureDeclarations/CaptureOperation- the
ThreadLocalusing, andRecvGenerator's now-unusedOptionsarg) — ~55 lines gone, generatedжoverloads are now a clean deref+delegate. Baselinecore/internal/abi/type.csCommonsurgically updated to direct-ж(promotion overloads regenerate correctly);StdLibInternalAbigolden updated. Full solution green; 32/32 capture-affected behavioral phases pass; stress test still "no race observed". There is now one way a pointer-receiver method captures its box (theж<T>parameter) — no thread-local, no shared state.
- the
sync/atomicPROMOTED tocore— first stdlib package migrated.src/core/sync/atomicis now in the green baseline (go2cs.slnx,/core/folder), referencingcore/unsafe+core/golib. Scalar typed types are the converter output as-is;Pointer[T]is hand-rewritten in the promoted (hand-owned) copy.- The
unsafe.Pointerfinding (why the rewrite, not a global fix).unsafe.Pointeris a type alias tonuint([assembly:GoTypeAlias("@unsafeꓸPointer","nuint")]) — a raw number — used across 171 files / ~1522ж↔uintptrsites in the full conversion, almost all legitimately numeric (pointer arithmetic, syscall, reflect). In .NET a managed reference cannot be stored as a number and survive a GC move (CLR rule), so golib'sж↔uintptroperators (ж.cs:449-458) can't round-trip a managedж<T>: the zero case NREs (*(T*)0) and a real case dangles (afixed-pin address read after the block closes). A globalunsafe.Pointerredesign would be reckless (huge blast radius, breaks the correct numeric uses), so the contained fix lives in the one type that stores a managed pointer. - The managed
Pointer<T>(core/sync/atomic/type.cs). Fieldvis a managedж<T>(not@unsafe.Pointer);Load/StoreuseVolatile.Read/Write,Swap/CompareAndSwapuseInterlocked.Exchange/CompareExchange<ж<T>>. AnilCanonhelper collapses an explicit nil-жtonullso reference-based CAS treats every nil pointer as equal (matching Go'snil == nil). Nounsafe.Pointer, GC-safe, nil-safe. - Validated end to end:
atomic.Pointer[int]/[Config]Load/Store/Swap/CAS + the scalar API all match Go. Guarded by theAtomicValuesbehavioral test (scalarInt32+ genericPointer[int], both paths). Solution builds green; behavioral suite green (zero churn). - csproj gotcha:
core/sync/sync.csprojhad no subfolder exclusion (it never had one); addingcore/sync/atomicmade it swallow atomic's generated files (CS0579). Fixed by mirroringmath.csproj's<Compile Remove="rand\**" />pattern →<Compile Remove="atomic\**" />.
- The
Promote— atomic gated on theinternal/cpuandsync/atomicunsafe.Pointer/жrepresentation above (scalars work,Pointer[T]runtime-broken); cpu gated on asmcpuid.- Confirm
internal/cpu/sync/atomicbuild within the fullgo-src-converted.slnalongside their dependents. - Re-bucket after a fresh full reconvert to find the next highest-frequency converter defect.
Fresh full reconvert (305 pkgs, 1659 .cs) + full go-src-converted.sln build, re-bucketed by CS####
frequency. Measurement gotcha caught: the reconvert-overlay must NOT blanket-delete .cs first — ~15
hand-written companion/pseudo-package files (unsafe/unsafe.cs, *_impl.cs, generator companions) are not
regenerated by the converter, and deleting them produces a phantom ~120-error unsafe_package CS0246 bucket.
Overlay by copying generated .cs over; git checkout -- any deletions.
After clearing the phantom bucket, the real own-error defects bucketed as: reflectlite (24, CS1537 dup
global using), container/list (6→25 post-fix) + container/ring (12) sharing one ж<T> defect, unicode (4),
internal/types/errors (4), plus runtime/unsafeheader-missing-ref cascades. Four fixes landed (behavioral
suite green throughout; zero existing goldens changed by any):
| Defect | Symptom | Fix |
|---|---|---|
Generator dup global using — GetFullyQualifiedUsingStatements (all 5 generators) copied global using alias directives from the source file as file-local using, colliding with the in-scope global one |
CS1537 (×24 in reflectlite, via PartialStubGenerator asm stubs) |
go2cs-gen/Common.cs: skip global using directives (already in scope everywhere, generated files included). reflectlite 24→0. Not behaviorally testable (asm-stub-only path); verified via the full conversion. Commit 9c9431b3f. |
Mixed-type for-init — for i, e := s.Len(), s.Front() (int + pointer) emitted two ;-separated decls inside the C# for-init clause (invalid: the ; ends the clause); the combined var (a,b) form is blocked by the int special-casing |
CS1002/CS1003 (container/list + ~20 files) | visitAssignStmt.go (+ forInit flag in FormattingContext/visitForStmt): emit a tuple-deconstruction declaration with per-element types — (nint i, var e) = (...). Gated on all-new, non-heap-boxed LHS. Guarded by ForInitMixedTypes. Commit 6d339a3d0. |
Variadic-of-pointer param — func In(r rune, ...*RangeTable) emitted an invalid using alias ꓸꓸꓸж<RangeTable> = Span<…> (alias identifier can't contain </>) |
CS1002/CS1022 (unicode) | visitFuncDecl.go: emit params Span<T> inline when the element type is generic/pointer (Contains "<"), extending iteration-1's type-parameter special case. |
Empty/spread variadic-of-pointer call — In(r) (no trailing args) panicked the converter (Args[i] indexed past end); f(slice...) emitted Ꮡslice (element address-of applied to the spread slice) |
converter panic → dropped file; CS0103 | convCallExpr.go: guard the element-pointer arg treatment with paramHasArg (empty call) and !(hasSpreadOperator && last param) (spread). |
The last two ship together with the VariadicPointerParam behavioral test (args/empty/single/spread calls).
A package declaring blank _ constants (skipping iota values) and a blank func _() (the stringer
compile-time-assertion idiom — e.g. internal/types/errors) emitted multiple internal static readonly … Δ_
fields that collided: CS0102 "already contains a definition for 'Δ_'". Root cause: performNameCollisionAnalysis
recorded _ in both the named-element set (the blank consts) and the method-name set (func _()), flagged it
as a const↔method collision, and getSanitizedIdentifier Δ-prefixed every _ to the same Δ_ — defeating
the value-spec visitor's per-blank unique naming (_ᴛNʗ). Fix (nameCollisionAnalysisOperations.go): exclude
the blank identifier from collision analysis (it is a discard, never referenced, and already gets unique names).
internal/types/errors CS0102 4→0 (remaining: a strconv project-ref, separate). Guarded by BlankIdentifierCollision;
behavioral suite green, zero existing goldens changed. Found-but-deferred: a bare discard _ = expr inside
a func _() emits _ = … which binds to the method group → CS1656 ("cannot assign to '_'") — a separate edge
case (real stringer asserts use _ = x[C-C]); not hit by internal/types/errors' actual body.
Measurement-methodology finding (important): the committed go-src-converted csprojs are stale and lack
inter-package ProjectReferences that the current converter emits correctly. Overlaying fresh .cs onto those
stale csprojs inflated the bucket with phantom CS0246 "package not found". Regenerating the csprojs from the fresh
conversion (with the documented core\→go-src-converted\ rewrite, golib excepted) dropped the total 95→79
and CS0246 23→5. So the measurement loop must regenerate csprojs, not keep the committed ones — there is no
converter csproj-emission defect. Reusable overlay script: scratchpad/overlay.sh.
True own-defect leaders after the rewrite: container/list (25) + ring (12) = the ж<T> model; internal/chacha8rand
(7, mostly the same ж<T> pattern — State.Init64/Refill on a value needing the box); math/bits (4, unsigned-
arithmetic/shift-count coercions); a handful of 1–2-error leaves.
The ж<T> model split into two sub-problems (converter derefs a pointer param/receiver to a value alias
ref var x = ref Ꮡx.val, losing pointer identity when Go uses it as a pointer):
- Sub-problem B — DONE (this iteration).
r := p/r = p(pointer copy of a *T parameter) emittedvar r = p(a copy of the pointed-to value); the converter already treats the walked target as a pointer (r.val/~r), so it miscompiled. Fix (visitAssignStmt.go): a plain pointer-typed identifier on an assignment RHS now gets the pointer (box) form —var r = Ꮡp— via a newrhsPointerCopyContext/appendRhsPtrContexthelper applied in both the declare/reassign branch and the per-variable (escaping-var) branch. A pointer local already holds the pointer directly, so it is unchanged → zero existing behavioral goldens changed; suite green 260/260. Guarded by thePointerCopyWalkbehavioral test (copy-then-walk, for-init copy, explicit local seed). - Sub-problem A — TODO (the harder half). Using the receiver as a pointer value —
func (n *node) selfLink() { n.next = n }— still emitsn.next = n(a value-ref receiverthis ref node nhas no box). Needs the receiver direct-жmechanism (extendcaptureModeOperationsso "receiver used as a bare pointer value" — assigned to a pointer field, stored, compared — triggers athis ж<T> Ꮡnreceiver, like the existing&recv.field/return recvtriggers). container/ring & list need both A and B to fully compile; B lands alone as a correctness fix.
Other queued leaf defects (from the 79-error bucket): plugin CS0553 (ImplicitConvGenerator emits illegal
object↔T conversions), runtime/internal/math CS0133/0266 (const MaxUintptr ulong→nuint not const), unicode
CS0051/52 (exported field of an unexported type), internal/runtime/atomic CS1526 (new without ()), and the
deferred CS1656 (bare _ = expr discard inside a func _()).
A method that uses its pointer receiver as a bare value — func (r *ring) initSelf() { r.next = r } (assign the
receiver to a pointer field) — emitted n.next = n: a value-ref receiver (this ref ring r) has no box, so a
ring value was assigned to a ж<ring> field (didn't compile; would point into a copy). Fix, two parts:
captureModeOperations.go: a third direct-ж triggerbodyUsesReceiverAsPointerValue(the receiver appears as a bare RHS identifier of an assignment) — alongside the existing&recv.fieldandreturn recvtriggers — so such methods get the box-as-receiver formthis ж<ring> Ꮡr.convIdent.go: when the receiver of a direct-ж method is used as a pointer value (its RHS context sets isPointer via iteration-10'srhsPointerCopyContext), emit the receiver boxᏑr(the value-ref path has no box).
Validated by ReceiverPointerValue (self-link, cross-link, pointer-walk; mutating through the self-link proves the
field points at the real receiver — 42/42/3/2). Suite green 264/264, zero existing goldens changed. Payoff:
container/ring 12→8 errors, container/list 25→~10 (and the remainder are now different, narrower defects).
Remaining ж<T> sub-problems (the still-failing ring/list errors, each a distinct mechanism — follow-up work):
- C — transitive direct-ж. A method calling a direct-ж method on its (value) receiver, e.g.
Next/Prev:return r.init()whereinitis direct-ж →r(aref Ringvalue) can't call theж<Ring>overload (CS1929). The caller must itself become direct-ж; needs a fixpoint overbodyCallsDirectBoxMethodOnReceiver. - D — reassign the receiver pointer.
Move'sr = r.prev/r = r.nextwalks the ring by repointing the receiver. With a direct-ж receiverrisref var r = ref Ꮡr.val(a value alias) → assigning aж<Ring>to it is CS0029. NeedsᏑr = r.prev; r = ref Ꮡr.val(repoint the box, re-alias) — the receiver-pointer-reassignment case. - A-variant — receiver in a comparison.
Len/Do'sfor (… p != r; …)compares aж<Ring>to the value receiverr(CS0019). The comparison must emitᏑr; extend the receiver-as-pointer-value detection/emission to binary (==/!=) operands, not just assignment RHS.
Finished the ж<T> self-referential-pointer model — container/ring and container/list both compile clean, and
(crucially) run correctly. Four converter pieces + one runtime fix; zero existing behavioral goldens changed:
- A-variant (
captureModeOperations.go):bodyUsesReceiverAsPointerValuealso fires when the receiver is an operand of a==/!=comparison (p != r).convBinaryExpralready sets isPointer on comparison operands, soconvIdentthen emitsᏑr. (ring 8→6) - C — transitive direct-ж (
captureModeOperations.go+convSelectorExpr.go): a method calling a direct-ж method on its receiver (return r.init()) must itself be direct-ж. New candidate list + fixpoint overbodyCallsDirectBoxMethodOnReceiver(repeat until stable — the callee may only be marked in a later pass). The call routes through the box:convSelectorExprnow boxes the receiver forexprIsCurrentDirectBoxReceiverand for a deref'd pointer parameter (exprIsDerefdPointerParam, e.g.Link'ss.Prev()). (ring 6→2) - D — reassign the receiver pointer (
visitAssignStmt.go):r = r.prevon a direct-ж receiver emits the box on the LHS (exprIsCurrentDirectBoxReceiver+ RHS-is-pointer), so the existing pointer-reassignment path producesᏑr = r.prev; r = ref Ꮡr.val;(repoint + re-alias). (ring 2→0 — container/ring compiles clean) - E — chained selector through a pointer field (
convSelectorExpr.go):e.list.root/p.next.prev(an intermediate pointer field) wasn't dereferenced — the receiver/param skip-deref check usedgetIdentifier(X), which digs to the root of the chain (e), so it wrongly skipped derefinge.list. Now it only skips whenXis directly the receiver/param ident. (list 10→0 — container/list compiles clean) - golib
ж<T>identity equality (ж.cs): the runtime correctness gate. The newRingPointerMethodstest compiled but stack-overflowed —ж<T>.Equalsdid pointed-to-value comparison, but Go pointer comparison is identity; value comparison infinitely recurses on a self-referential struct (Ring.next *Ring). RewroteEquals/GetHashCodeto compare by referenced storage (same box, same struct-field source+accessor, or same array+index), never the value. Correct Go semantics and recursion-free.
Guarded by the RingPointerMethods behavioral test (a full mini-ring exercising A/A-variant/B/C/D/E — build,
walk, Len, Move±, Prev — output matches Go). The golib change touches all pointer equality; validated by the full
suite. Both container packages now compile and run — closing out the general pointer-as-value problems.
Fresh full reconvert + regenerated-csproj overlay + go-src-converted.sln build re-bucketed the leaves: the
ж<T> work had cleared the big clusters, leaving 39 errors across 17 of 304 projects (down from last
session's 79). Five fixes landed (each its own commit; all behavioral-green, zero existing-golden churn except
the one intended re-baseline noted):
| Fix | Defect | Greens | Commit |
|---|---|---|---|
min/max built-ins (golib builtin.cs) |
Go 1.21 min/max emitted verbatim but golib had no such methods → CS0103. Added generic min/max constrained to IComparable<T> (numeric primitives + @string). No converter change. |
crypto/subtle (+ unblocks ~dozens that use the built-ins) | daddd953d |
unsafe.Pointer keyword sanitization (convIdent.go) |
The name.val deref form for an unsafe.Pointer ident used the raw Go name, so a param named new (C# keyword) emitted new.val → CS1526. Now sanitized to @new.val. |
(internal/runtime/atomic syntax; pkg has deeper latent ж issues) | 175e0dfd3 |
unsigned unary-minus + shift precedence + shift-assign count (convUnaryExpr/convBinaryExpr/visitAssignStmt) |
x & -x → CS0023 (now (T)0 - x); Go x>>4 + x / 1<<15 - 1 re-associated in C# (shift binds looser) → now shift exprs parenthesized; y <<= s cast count to RHS type → CS0019 (now (int)). |
math/bits 4→1; corrected a latent 1<<15-1→1<<14 miscompile in the StdLibInternalAbi golden (re-baselined) |
9089b9c4b |
builtin-shadowing local rename (variableAnalysisOperations.go) |
len := len(buf) — a local named like a called built-in shadows the using static method → CS0149/CS0841. Renames the local (lenΔ1) when that built-in is actually called in the function; built-in call stays len. |
hash/maphash | e87705650 |
comma-ok map access (convIndexExpr/convExpr/visitAssignStmt) |
v, ok := m[k] (+ blank, if-init, reassign forms) wasn't detected as a tuple result → indexed twice, value assigned to ok (CS0029). Now routed through golib's two-value indexer m[key, ꟷ] via a new IndexExprContext.isTupleResult. High-value correctness fix (every comma-ok map read was wrong; no behavioral test had covered it). |
internal/coverage/rtcov | 92d832204 |
Guarded by new behavioral tests: MinMaxBuiltin, UnsafePointerKeywordParam, ShiftPrecedenceUnsigned,
BuiltinShadowLocal, MapCommaOk. Leaf packages greened this iteration: crypto/subtle, hash/maphash,
internal/coverage/rtcov. Found-but-deferred: reading a nil map NREs instead of yielding zero/false (golib
nil-map representation gap — a chip was filed). math/bits' last error is a local untyped const (1<<32) typed
UntypedInt/BigInteger in uint64 arithmetic — context-typing untyped local consts is a larger follow-up.
Phase 3 — earlier note: the ж<T> self-referential-pointer-struct confusion in container/ring & container/list —
r.next = r (assign receiver to a pointer field → needs the box Ꮡr) and r = r.prev (reassign the Go
pointer variable, but the C# ref var r = ref Ꮡr.val deref aliases the value). The deeper one (CS0019/CS1061/
CS0029/CS1929 cluster), blocking both container packages.
Goal: validate each compiling converted package against the same _test.go suite used by Go before
assembly-backed implementations are attempted at scale. The detailed and authoritative design is
TestingInfrastructureRequirements.md; this section tracks the
delivery sequence and exit gates.
The behavioral suite proves individual converter/runtime constructs using hand-written fixtures. Phase 4
adds the complementary whole-package gate: load Go's internal and external test-package variants, convert
the eligible tests, compile them with the converted package sources, and compare their results with a clean
go test -json -count=1 run for the same source, build tags, GOOS, and GOARCH.
- Add an opt-in test conversion mode using
go/packageswithTests = true; do not burden normal production-only conversion. - Emit
x_test.goasx_test.csbeside the other converted files, plus<package>.tests.csproj,package_test_info.cs, a deterministic test manifest, and a generated package test host. - Keep the production project test-free. The test project recompiles the production
.cssources with the test sources instead of referencing the production DLL; this preserves same-package access to unexported declarations and joins the same partial package class. - Support both
package pand externalpackage p_testtests in one isolated package executable, including external self-import binding without a self-referencing project. - Add a hand-owned
go.testingcompatibility runtime for the initial bootstrap rather than depending on the full convertedtestingpackage. Implement the serial core: pass/fail,Error[f],Fatal[f],FailNow, logs, skips, helpers, cleanup, panic reporting,TempDir,Setenv, andTestMain/M.Run. - Run every package in a child process with a controlled environment, timeout, and isolated working tree
containing its relative fixtures and
testdata. - Add end-to-end behavioral fixtures for discovery, same-package unexported access, external tests,
TestMain, failure/skip/panic/defer paths, cleanup order, build constraints, fixtures, and unsupported capabilities.
Phase 4A exit: all harness fixtures pass and at least one already-compiling leaf standard-library package is validated end to end.
- Implement
T.Run, hierarchical names/filtering, duplicate subtest names, cleanup after child completion, andT.Parallelwith Go's parent/subtest barrier semantics. - Emit normalized package/test events and aggregate them into machine-readable output plus JUnit or TRX for CI. Compare eligible test identity and terminal status with Go; do not byte-compare timings, paths, stack formatting, or concurrent log interleaving.
- Record every discovered test as included, platform-excluded, unsupported, or failed-to-convert. No omitted test may silently count as a pass.
- Detect stale manifests from source inputs, target options, converter revision, and hand-written companion files; unchanged conversions must be byte-stable.
Phase 4B exit: representative leaf packages using external tests, subtests/parallelism, and file fixtures validate reproducibly on the matched target platform.
- Work bottom-up through the compiling package DAG, expanding the compatibility API from categorized real
failures rather than speculative reimplementation of all of
testing. - Track each target/package as validated, failing, conversion-blocked, infrastructure-blocked, or not-applicable.
- Add batching, CI sharding, package-level timeouts, failure artifacts, and explicit flake diagnostics.
- Keep benchmarks, active fuzzing, race-detector equivalence, and coverage-percentage equivalence outside the initial correctness gate. Add examples and deterministic fuzz seed corpora later without delaying Phase 5.
Phase 4 exit: one documented command converts, builds, and runs a selected package's tests; the harness semantics are guarded end to end; every compiling package attempted is honestly classified; and at least three representative leaf packages are validated (including external-package and subtest/parallel cases). Only validated satisfies the default Phase 5 behavior gate.
Goal: replace the PartialStubGenerator's throwing implementations for Go declarations backed by
assembler, cgo, runtime/compiler intrinsics, or platform services with maintainable C# implementations,
proved package by package by Phase 4.
- Produce a deterministic inventory of every bodyless partial declaration and generated throwing stub, including Go declaration/source, owning package, build constraints, target platforms, callers, and tests that cover it.
- Classify each member as: direct managed equivalent; .NET intrinsic/BCL primitive; platform interop; runtime service; intentionally unsupported target; or dead for the selected target.
- Prioritize bottom-up by dependency impact and test coverage. Start with leaf packages whose converted tests already run, not merely the declarations that are easiest to translate.
- Establish a per-package completion ledger linking declarations, implementation files, target support, test evidence, and any reviewed waiver.
- Keep the converter-owned bodyless declaration as a
partialmethod. Supply the implementation in a hand-owned companion such as*_impl.cs; when the Go source uses a declaration-oriented file, preserve the corresponding converted*_decl.csplus*_impl.cspairing. The establishedsync/atomicdoc.cs+doc_impl.cspattern is the model. - Never hand-edit regenerable declaration output. The
PartialStubGeneratorremains the safe compile-time fallback and automatically disappears for a member once its real implementing part is present. - Prefer behaviorally equivalent managed code (
Interlocked,Volatile,BitOperations, spans, BCL crypto, etc.) over literal instruction-by-instruction assembly translation. Preserve Go overflow, alignment, memory-ordering, pointer/GC, and platform semantics explicitly. - Keep platform implementations isolated by target and fail clearly for unsupported targets. Do not let a generic throwing stub masquerade as completed platform support.
- Add focused Go/C# behavioral fixtures when upstream tests do not directly exercise a declaration or an important boundary condition.
For each implemented package:
- reconvert production and tests from clean inputs;
- confirm the production and test projects compile without a generated stub for the implemented members;
- run the matched Go test baseline and converted C# suite;
- require all eligible relevant tests to pass, with no silent exclusions;
- run focused stress/edge tests for concurrency, atomics, unsafe pointers, cryptography, or platform calls where ordinary unit tests are insufficient; and
- promote/update the baseline only after the evidence is recorded in the package ledger.
Compilation alone is not completion. If a package's tests cannot yet run, it stays infrastructure-blocked or conversion-blocked; any platform-specific waiver must be explicit and reviewed.
Phase 5 exit: for every supported target, the external-declaration inventory has no unexplained throwing stubs; each implemented member has a real companion or a documented target exclusion; all applicable packages are Phase 4 validated; and the full converted standard-library test run passes for the supported package/target matrix.
| Metric | Source | Status |
|---|---|---|
| Baseline + tests build clean | dotnet build src/go2cs.sln |
✅ 79 / 79 |
| Behavioral suite passing | BehavioralTests (MSTest) |
✅ 216 tests |
| Full packages compiling | src/go-src-converted.sln |
◻ Phase 3 — iters 1–2: 5 converter fixes; internal/cpu ~140→8 errors |
| Full-conversion error count | build-error buckets | ◻ Phase 3 — next: address-of-global correctness; re-bucket after reconvert |
| Converted package tests | Per-package Phase 4 manifests/results | ◻ Phase 4 planned — requirements complete |
| Assembly-backed implementations | Phase 5 external-declaration ledger | ◻ Phase 5 planned — gated by Phase 4 validation |
visitMapType completion; remaining dynamic-struct implicit-cast checks across AssignStmt/CompositeLit/
IndexExpr/BinaryExpr/UnaryExpr/SelectorExpr/TypeSwitchStmt/ValueSpec; optional recursive
dependent-package conversion; map/channel GoType generator support (IMap/IChannel); comment
conversion; cgo + Go-assembler (.s) targets.