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. FIXED 2026-08-05 (issue #32) — see Conversion scope: the module without its dependency graph below.
  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). FIXED 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.

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.

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 path0 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 (convertToCSTypeNameconvertImportPathToNamespace). 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:

  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.