Conversion Strategies — Technical Reference
📖 This is the exhaustive technical reference. For a shorter, example-driven overview of how each Go construct maps to C#, start with
ConversionStrategies.md— every section there links back here for the full detail. Read this document when you need the why: the exact emitted form, the edge cases, the Phase-3 fixes, the behavioral-test guards, and the C#-vs-Go semantic reasoning behind a decision. It is the authoritative record; the summary is the front door.
Updated 2026-06-27 for the “go2cs2” generation of the converter. This is a living document; as more use cases are converted these strategies are refined. The current converter is written in Go (using the official
go/ast+go/typestoolchain, undersrc/go2cs/) and emits C# that leans on two things the visible code does not show in full: a hand-written runtime library,golib(src/core/golib/), and a set of Roslyn source generators (src/gen/go2cs-gen/) that synthesize the Go semantics which cannot be written directly in C#. Notes that previously referenced the retired ANTLR4/C# converter or the oldgocorelibrary have been updated to reflect this. See also:Architecture.md,Glossary.md,Roadmap.md, andCLAUDE.md.
The guiding goal: the generated C# should be both behaviorally and visually similar to the original Go, so that a Go developer can read the output and follow it. The runtime library and the generators exist to keep the visible converted code close to the Go original.
How this reference is organized. Each
##topic opens with the high-level rule (the same ground the summary covers) and is then followed by###subsections documenting specific conversion decisions, edge cases, and fixes — most keyed to the behavioral test that guards them. When updating the converter, add the deep detail here and the reader-facing example to the summary (see../CLAUDE.md, “Record the conversion decision”).
Topics
- Package Conversion
- Package-Level Variable Initialization Order
- Compiled Library versus Source Code
- Constant Values
- Native and Narrow Integer Types
- Named Numeric Types and Constant Contexts
- Floating-Point Formatting
- Nil and Zero Values
- Empty Interface (
any) - Multi-Assignment and Evaluation Order
- Short Variable Redeclaration (Shadowing)
- Multi-Result Values and Comma-Ok Forms
- Slices and Arrays
- Strings (
@stringandsstring) - Maps and Channels
- Generic Constraints
- Type Aliasing
- Delegates to Value Receiver Instances
- Defer / Panic / Recover
- Expression Switch Statements
- Type Switch Statements
- Struct Types
- Struct Type Embedding
- Interfaces
- Pointers
- Implicit Pointer Dereferencing
- Labeled Control Flow and Loop Variables
- The
go.golibsupport namespace - Source Generators
- Manually-Converted Declarations
- Comments
- Deterministic Output
Package Conversion
Although a Go package more traditionally parallels a C# namespace, Go includes referenceable functions directly from within a package root, for example, the Println function in the fmt package is called like: fmt.Println("Hello, world"). For C#, only type declarations, e.g., class, struct, enum, etc., are allowed in a namespace; functions exist as part of a class or struct. Described from a C# perspective, all Go functions are static, i.e., the functions exist separately from an instance of a type. Go supports the notion of a receiver function which allows a function to be targeted to an instance of a type (paralleling the operation of a C# extension function), but this is still a static function.
As such, the conversion strategy for a Go package is to convert it into a static C# partial class, e.g.: public static partial class fmt_package. Using a partial class allows all functions within separate files to be available with a single import, e.g.: using fmt = go.fmt_package;. The receiver functions are emitted as extension methods on that partial class (decorated with [GoRecv], see Source Generators).
So that Go packages are more readily usable in C# applications, all converted code is in a root go namespace. Package paths are simply converted to namespaces, so a Go import like import "unicode/utf8" becomes a C# using like using utf8 = go.unicode.utf8_package;. Each package also emits a package_info.cs carrying a [GoPackage] assembly attribute plus the package-wide global using aliases (Go’s built-in types, exported type aliases, etc.).
A consequence of converting a Go method to a C# extension method is that C# only discovers an extension method when its containing static class’s namespace is in scope (via a using <namespace>; directive or the enclosing namespace) — a class alias such as using atomic = go.@internal.runtime.atomic_package; resolves the type (atomic.Uint32) but does not bring the class’s extension methods into scope. This matters when a file calls a method on a value whose type comes from a multi-segment-path package (one that lands in a sub-namespace, e.g. internal/runtime/atomic → go.@internal.runtime): Go never requires importing a value’s package merely to call a method on it, so such a file may emit no import — and hence no using @internal.runtime; — leaving the extension method invisible and the call mis-binding to a wrong (e.g. embedding-promoted) overload (CS1929). The converter therefore registers the namespace of every cross-package method’s defining package as a file-local using at the call site, independent of the file’s explicit imports. (Packages in the root go namespace — most top-level stdlib packages — need nothing extra, since same-namespace extension methods are always visible. This is a stdlib-structural concern that only surfaces under multi-segment package paths, so it is guarded by the Phase-3 runtime build rather than a single-package behavioral test.)
Go projects that contain a main function are converted into a standard C# executable project, i.e., <OutputType>Exe</OutputType>. The conversion process can reference and convert needed external projects as library projects, i.e., <OutputType>Library</OutputType>, per any encountered import statements. In this manner an executable with packages compiled as project-referenced assemblies can be created. To create a single executable, like the original Go counterpart, a self-contained executable can be produced.
An executable’s <AssemblyName> is the last element of its import path, mirroring go build, which names a binary after the module/directory’s final segment — so module example.com/colordemo produces colordemo.exe, not example.com.colordemo.exe (the full dotted project name). Only the Exe assembly name is shortened; the .csproj filename keeps the full dotted path (its identity in the solution and in ProjectReferences), and library assemblies keep the full dotted <AssemblyName> — their DLL and NuGet PackageId (go.$(AssemblyName)) must stay unique across the package graph (e.g. github.com.fatih.color).
A project name is the package’s FULL import path — a go-file-free container directory does not truncate it
The dotted project name above is the package’s import path, every segment of it, joined with .
(getProjectName, importOperations.go). That is not a formatting preference: the name is the .csproj
filename, the library <AssemblyName>, the NuGet PackageId, and — minus its last segment — the C#
namespace. All four have to be unique across the package graph, and the import path is the only thing
about a package that is unique by construction.
For the standard library the path is read straight off GOROOT/src. For everything else the converter has
only a directory, so it recovers the import path the way the go command itself finds the main module:
walk up to the nearest go.mod, then join that module’s declared path with the package’s path relative
to it. The result is the import path by definition.
The walk used to stop early. Alongside go.mod and main.go it treated the first ancestor directory
holding no .go files of its own as a boundary and named the project after the leaf segment alone. Go
modules are made of such pure container directories — internal/, a proto grouping like xds/core/, the
datatransfer/ above an apiv1/, a service tree’s endpoints/ parent — so the truncation was routine
rather than exotic, and it cost two things:
-
Duplicate project names.
cloud.google.com/go/bigquery’sdatatransfer/apiv1andstorage/apiv1both emittedapiv1.csproj. Visual Studio refuses to open a solution containing two projects of the same name and reports no reason — silently doing nothing from a file dialog, offering to forget the entry from the recent list — so the entire generated.slnxfailed to load (issue #35). One reported-recurseconversion had 175 such projects across 49 colliding names out of 1,727. -
A collapsed namespace. The truncated arm joins its segments with
.before the separator split that builds the namespace, so the qualification never reached it: a truncatedinternal/errorslanded ongo.errors_package— the converted standard library’s own class.
A go-file-free ancestor is therefore a fallback, never a stop: the walk records the leaf-relative name it would have produced and keeps climbing. The fallback applies only when there is genuinely no module root anywhere above — a GOPATH-style tree, which is the one case it was ever needed for. Because both the declaring package and every importer derive the name from the same call, the two sides move together.
One consequence worth knowing when re-converting: recovering the full path renames every package that
was previously truncated (743 of the 1,727 above), so a re-conversion into an existing output root leaves
the old, wrongly-named .csproj files behind as orphans that a solution may still list. Convert into a
fresh output directory, or clear the old one first.
Guarded by TestProjectNameSurvivesGoFileFreeContainerDir and
TestProjectNameFallsBackWhenNoModuleRoot (the unit invariant, including the fallback arm) and
TestRecurseGoFileFreeContainerDirsKeepDistinctProjectNames (the whole -recurse conversion: distinct
.csproj files, a .slnx with no repeated project name, and matching qualification in the importer’s
converted code). That last one also exercises internal as a namespace segment, which only the
recovered path produces — go.example.com.app.@internal.web.api_package, keyword-escaped on both the
declaration and reference sides.
The repository’s own go2cs/ module marker is elided — on BOTH sides, by one rule
The behavioral corpus and the package-test fixtures declare module go2cs/<Name>, and that leading
segment is a repository marker rather than a namespace segment: module go2cs/<Name> and a bare
module <Name> emit the same namespace go; + <Name>_package, which is the only reason both
spellings can coexist in one tree (618 marked declarations against 46 bare ones). Eliding it is safe
by construction — a fetchable module path’s first element must contain a dot, because it is a host
name, and go2cs has none, so the marker cannot swallow a real dependency’s first segment.
A module path is nonetheless spelled twice by every conversion, and the elision has to reach both
spellings. getProjectName (importOperations.go) is the DECLARATION side: it decides the emitted
namespace, the project name, and the <ProjectReference> file name derived from it.
convertImportPathToNamespace (visitImportSpec.go) is the IMPORT side: it decides a
using <alias> = … target, a bare using <namespace>;, and the typeof of an init-forcing hook. The
marker was elided by the first and kept by the second, so every reference to a go2cs/… path named a
namespace that nothing emits, and such a package could not be imported at all:
// value.cs — the DECLARATION // external_test.cs — the IMPORT
namespace go; using harness = go2cs.convertedtestharness_package;
partial class convertedtestharness_package { using static go2cs.convertedtestharness_package;
error CS0234 ×2, which is why the end-to-end -tests fixture at
src/tests/PackageTests/ConvertedTestHarness never built from the day it was written — a module
go2cs/convertedtestharness whose own external test variant self-imports it. Both sides now route
through one function (trimGo2CSModulePrefix), the declaration side being the canonical one because
the committed corpus already rests on it and because the 46 bare declarations exist only to dodge
this asymmetry. A behavioral test that holds a nested sub-package is the measured case: under
module IoLike the parent imports IoLike/FsLike, which has no marker to disagree about, while the
same tree under module go2cs/IoLike would declare go.IoLike.FsLike_package and import
go2cs.IoLike.FsLike_package. Agreeing here retires that constraint rather than entrenching it —
it does not oblige any existing module to be respelled, and none is.
The elision is deliberately confined to a path a go.mod DECLARES. A path recovered from a directory
instead (getImportPackageInfo’s build.Import arm, whose canonical path is GOROOT/src- or
GOPATH-relative) is left alone: this is a module-path rule, and a directory that merely happens to
sit under a go2cs folder is not a module declaring that path.
Guarded by TestModulePathNamespaceAgreesAcrossSides
(src/go2cs/moduleNamespaceAgreement_test.go), which runs the marked single- and multi-segment cases
plus two controls — a bare module, which must reach the identical class, and an ordinary
example.com/foo/bar, which must keep every segment — through both derivations and requires one
answer. The guard pins the agreement rather than either side’s output, because either side alone can
be self-consistently wrong.
The emitted project PATH is budgeted — the file name compresses, the identity never does
Recovering the full import path made every project name correct, and on a deep dependency tree it made them long. The import path is then spelled twice on disk — once by the directory, which mirrors it, and once by the file name — so the deepest package of the conversion above emitted a 242-character project path before the user’s output root even counted. Visual Studio refused to load it, and the same solution failed all over again (issue #35, second report).
Three separate limits are in play, and only one of them is negotiable:
| Limit | Value | Liftable? |
|---|---|---|
| Visual Studio’s project loader | 260 characters (MAX_PATH) |
No. Measured on a machine with LongPathsEnabled=1: a 259-character .csproj loads and builds, a 265-character one fails with “The project file could not be loaded. Could not find a part of the path” — naming a file that is demonstrably present and that dotnet build reads without complaint. |
| A single filename component | 255 characters |
No, ever. No registry key, \\?\ prefix or manifest touches it (measured: a 265-character path writes fine on that same machine, a 256-character file name does not). |
| Total path, for long-path-aware tools | 32,767 with the key, 260 without | Yes — dotnet build and Visual Studio’s own MSBuild.exe both handled a 280-character artifact tree. |
So enabling long paths is not an answer, and neither is flattening the tree:
pkg/<import-path>/<dotted>.csproj and pkg/<dotted>/<dotted>.csproj measure identically, because a
separator costs exactly what a dot costs. The name cannot simply be shortened to the leaf either — that
is what collided in the first report, and .slnx has no display-name override to separate the file name
from the project name (measured: a DisplayName attribute does not help; Visual Studio still reports
“Project name ‘v3’ already exists in the ‘/pkg/’ solution folder”). Spelling the path twice is the
floor unless one of the two spellings is compressed.
The file name is what compresses; identity never does. projectFileBaseName bounds the emitted
project path — <tree-root>/<import-path>/<name>.csproj — at 200 characters, which leaves an output
root of up to 59 characters inside MAX_PATH. A name that fits is returned verbatim, which is every
package in the standard library (longest emitted path: 101) and every behavioral test (109), so the
committed corpus is untouched. A name that does not becomes head~tail.hash8: a readable head carrying
the module, a readable tail carrying the leaf package, and eight hex characters of SHA-256 over the full
canonical name. The C# namespace, the <AssemblyName> and therefore the NuGet PackageId all keep the
full import path — those are identity, and they stay globally unique and legible in a stack trace.
Replaying the reporter’s real 1,724-package conversion: 1,724 distinct names, 11 compressed, longest
emitted path exactly 200.
The compression is a pure function of the canonical name, so it is set-independent, and that is the point. The reference side derives a dependency’s file name from the import path alone, knowing nothing about the rest of the conversion. A shortest-unique-suffix scheme would have been shorter still, but under it adding one dependency can rename unrelated projects — the same coupling the first report’s fix removed, one level up.
Derived paths are redirected rather than budgeted. obj\, bin\ and the generator output
EmitCompilerGeneratedFiles writes multiply the import path instead of merely repeating it, so no name
budget reaches them: on the reporter’s tree, 1,570 of 1,725 projects produced a generator path over
MAX_PATH and 43 produced a file name over the unliftable 255. A -recurse conversion therefore
emits a Directory.Build.props and Directory.Build.targets at the output root that move all three to
<root>\.artifacts\{obj,bin,gen}\<token>\, where the token is 12 hex characters of
[MSBuild]::StableStringHash over the project’s root-relative directory. Two details are load-bearing:
- .NET’s built-in
UseArtifactsOutputwas evaluated first and does not fit — it lays artifacts out under$(ArtifactsPath)\obj\$(MSBuildProjectName)\, and the project name is the long thing here. Measured at a 13-character output root, the deepest package still landed a 309-character artifact. -
CompilerGeneratedFilesOutputPathis set from the.targets, not the.props— the generated.csprojsets it in the project body, which beats any.propsvalue — and with no trailing separator, because csc receives it as/generatedfilesout:"<path>"where a trailing backslash escapes the closing quote and the compiler is handed the directory as a file name (CS2021). Keeping this out of the csproj template is also what holds the standard library and behavioral corpus at zero movement.
Hint names are capped to 128 characters in the analyzer itself (Common.GetValidFileName), compressing to
<head>-<hash16>.g.cs. A hint name is only a label — Roslyn requires it to be unique within a generator
and nothing else — so the cap has no semantic consequence and the files it names are git-ignored. The
elision separator must come from the same allow-list IsValidHintNameChar enforces: a tilde there reaches
AddSource unscrubbed and throws, which surfaces as CS8785, a warning, leaving the generator to
contribute nothing and the build to fail on the type it should have emitted.
Guarded by TestProjectFileBaseNameLeavesCorpusNamesAlone, …BoundsTheEmittedPath,
…BoundaryIsExact, …IsDeterministicAndSetIndependent, …KeepsDistinctNamesDistinct, and
TestIssue35ReplayCorpus (which replays a real generated .slnx when GO2CS_ISSUE35_CORPUS points at
one), plus TestRecurseDeepImportPathStaysInsideMaxPath — a whole -recurse conversion over the
reporter’s actual shape, asserting the compressed file name, the untouched AssemblyName, the tree-wide
path bound and the reference side’s agreement.
The recurse output root records its runtime root: the $(go2csPath) default
A local-references -recurse conversion pins the runtime root it resolved against into the output
root’s generated Directory.Build.props (issue #36):
<PropertyGroup Condition="'$(go2csPath)' == ''">
<go2csPath>C:/Users/mason/go2cs-runtime/</go2csPath>
</PropertyGroup>
The -go2cspath command-line flag is a conversion-time input — it is where the converter reads each
imported package’s package_info.cs from and what it resolves $(go2csPath)core/… references
against — but before this pin its value never reached the emitted build files. The MSBuild property
of the same name then had only the csproj template’s fallback ($(USERPROFILE)/go2cs/, or
$(SolutionDir) under Debug), so converting into an isolated output root produced a solution whose
stdlib/runtime/analyzer references could not resolve unless the runtime happened to live at
~/go2cs or a deploy-core Directory.Build.props sat above the output. Three details:
- When the output root IS the runtime root (the one-positional form), the pin stays relative —
$(MSBuildThisFileDirectory), the deploy-core form — so the tree can move as a unit. An isolated output root pins the absolute resolved root (forward slashes and a trailing separator, per the section below); a re-conversion refreshes it. - The
Conditionkeeps it a default: ago2csPathenvironment variable, a-p:go2csPathbuild global, or a higherDirectory.Build.propsstill wins. -
-recurse=nugetdeliberately emits no pin — it has no$(go2csPath)references to resolve, and its props defaultsGoStdLibVersioninstead. A foreignDirectory.Build.propsat the output root (deploy-core’s, or user-authored) is never clobbered, per the generated-file marker rule.
Guarded by the go2csPath assertions in TestRecurseSyntheticModule (absolute pin, isolated
output root), TestRecurseBuildFilesRelativePinWhenRootsCoincide (relative form), and
TestRecurseNuGetReferences (no pin under NuGet references).
Path separators in emitted MSBuild files: forward slashes, on every host
Every path the converter writes into a .csproj, .slnx, .pubxml or Directory.Build.props uses /, on every host. There is no per-host emission and no host-conditional spelling: one converted corpus is correct on Windows, Linux and macOS.
<ProjectReference Include="$(go2csPath)core/fmt/fmt.csproj" />
<ProjectReference Include="$(go2csPath)gen/go2cs-gen/go2cs-gen.csproj" OutputItemType="Analyzer" … />
<OutDir>bin/$(Configuration)/$(TargetFramework)/</OutDir>
<GoValidationProofFile>$(go2csPath)../docs/validation/$(GoStdLibVersion).$(GoBuildNumber)/fmt.md</GoValidationProofFile>
MSBuild accepts / in every path context on Windows, and normalizes \ to / on Unix (FileUtilities.MaybeAdjustFilePath), so both spellings build on both hosts — the .NET SDK’s own targets depend on the Unix direction. The reason to pick one is that two other consumers are not MSBuild:
-
The converter’s own path arithmetic. The reference used to be composed by hand — replace every
/with\, thenfilepath.Joina backslash-prefixed file name. On Windowsfilepath.Cleanfolded that back into a well-formed path; on Unixfilepath.Jointreats\as an ordinary filename character, so a Linux-hosted conversion emitted the malformed$(go2csPath)core\fmt/\fmt.csprojfor every stdlib reference in every project — silent at emission, a restore failure later (F5,PLAN-linux-operation.md§A1.1). The composition is nowemittedProjectReference(importOperations.go):path.Join(slash-only, host-independent) over afilepath.ToSlash‘d directory.writeProjectFileandwriteTestProjectadditionallyToSlashat the emission point, because a sibling reference made relative byfilepath.Relarrives OS-native. -
The harnesses that READ an emitted reference.
BehavioralRunner.PreBuildSharedDepsandPerformanceRunnerparseProjectReference Include="…"out of the csproj and resolve it withPath.GetFullPath, which on Linux does not split on\either.
Consequence to expect when this changes: the sorted reference block can re-order. References are sort.Strings-sorted, and / (0x2F) sorts below alphanumerics while \ (0x5C) sorts above them, so a pair that differs at a separator boundary swaps. Across the whole 303-project stdlib the flip moved exactly one file’s ordering — net/http, where vendor/golang.org/x/net/http2/hpack had sorted before vendor/golang.org/x/net/http/httpguts (2 < \) and now sorts after it (/ < 2). Same set, different order; not a content change.
Seven hand-owned core files are never re-emitted (golib, testing, unsafe, internal/godebug, internal/concurrent, internal/weak, and core/Directory.Build.props), so they carry the form by hand. The one deliberate exception is the shared-project <Import Project="..\go2cs\go2cs.projitems" Label="Shared" /> in golib.csproj and go2cs-gen.csproj: that is Visual Studio’s own bookkeeping, VS round-trips its exact text, and MSBuild normalizes it on Unix regardless — so it stays backslashed, with a comment saying why.
Guarded by TestEmbeddedCsprojTemplatesUseForwardSlashesOnly and TestValidationPackBlockUsesForwardSlashesOnly (csprojTemplate_test.go — both templates are asserted to contain no backslash at all, so a future addition is covered without the guard enumerating it) and by TestEmittedProjectReferenceIsHostIndependent / TestEmittedProjectReferenceForModuleCachePath (importOperations_test.go).
The validation-proof block follows the OUTPUT LOCATION, not the invocation mode
A converted stdlib package’s .csproj carries a block that packs its versioned proof sheet as VALIDATION.md inside the nupkg, and .csproj files are regenerated on every transpile — so the block’s emission condition is what decides whether a package keeps shipping its proof. The rule is a structural test on where the project file is being WRITTEN:
emit the block for a
-stdlibconversion, or for any conversion re-emitting a.csprojunder the runtime root’score/tree.
It was originally scoped to the invocation MODE instead — -stdlib, later widened to -stdlib or -tests — and each narrowing left a door open that silently un-ships a package’s proof sheet at the next push-nuget. The -tests door was the first (the standing “0 8” restore family: every Phase-4 pipeline run stripped the block from the package under test). The single-package door was the second and is closed here (2026-08-19): go2cs <goroot-pkg-dir> <core-pkg-dir> — the form a lane uses to regenerate ONE corpus package after a converter change — is neither -stdlib nor -tests, so it stripped the block too. That one is the harder of the two to catch, because only the .csproj moves and a lone .csproj diff in a reconvert reads as ordinary emission drift rather than as a loss.
Keying on the output location closes both doors at once and cannot be re-opened by adding a mode: -recurse writes under its own src//pkg/ trees, a behavioral fixture writes under tests/Behavioral/, and an end-user module writes wherever it was pointed — none of them satisfy “under <go2csPath>/core/”, so all three keep their historical .csproj bytes. Guarded by TestValidationPackBlockSurvivesTestsRewriteOfCorePackage, which now pins the single-package form and its outside-core negative control alongside the -tests pair.
A GOROOT-vendored reference is named for the package’s ON-DISK path
A standard-library reference takes its project file name from the directory it resolved to, not from the import path as written. The two are the same string for every package in the standard library except one class — the GOROOT-vendored ones, imported as golang.org/x/… but existing on disk, and therefore as converted projects, only under vendor/golang.org/x/….
<!-- crypto/ecdh imports `golang.org/x/crypto/chacha20` -->
<ProjectReference Include="$(go2csPath)core/vendor/golang.org/x/crypto/chacha20/vendor.golang.org.x.crypto.chacha20.csproj" />
The directory half was always right — it is rewritten from the resolved source dir — while the file name was composed from the import path, so the emitted reference named a real directory and a file in it that exists nowhere: …/vendor/golang.org/x/crypto/chacha20/golang.org.x.crypto.chacha20.csproj. Deriving the name from the directory (stdLibImportPathFromTargetDir, applied in both stdlib arms of importOperations.go) is what makes the two halves agree structurally rather than coincidentally: getProjectName — the producer, which names the .csproj the vendored package actually emits — has always derived it from that same GOROOT/src-relative directory.
This is the third derivation in one family, and they must all resolve the vendored spelling or they disagree about which package is being named: the namespace (resolveGorootVendoredPath, under Cross-package imports), the dependency-graph key (stdLibConverter), and now the project file name.
Two consequences beyond the file name, because PackageName is not only a file name:
- It keys the embedded standard-library metadata, which records the vendored spelling (
##vendor.golang.org.x.crypto.chacha20), so the unvendored name matched no section at all (asserted directly by the guard). Wherever that record is the source — a dependency with nopackage_info.cson disk, i.e. a-recurse=nugetreference — the package’s exported aliases andGoImplementrecords would have come back empty and silently fallen through to the derive-from-declarations path. - It composes the imported-alias class path. The unvendored form yields
go.golang.org.x.crypto.chacha20_package, which names a class that exists nowhere — the CS0234 family the namespace arm above exists to prevent. It was latent rather than active only becauseloadImportedTypeAliasesdedupes on the dependency’spackage_info.cspath, which is the same file for both spellings, so whichever spelling was resolved first won and the other never applied its aliases.
Where it surfaced, and what it did NOT do. Only a -tests conversion could emit it: production emission resolves the vendored path upstream (visitImportSpec), while the test project’s dependency list is the raw import set, which carries both spellings — so crypto/ecdh’s test project named the package twice, once correctly and once not. It is worth being precise about the damage, because a missing <ProjectReference> sounds fatal and is not: MSBuild degrades it to warning MSB9008 and builds on (measured — the pre-fix crypto.ecdh.tests.csproj builds, 0 errors), and here the correct sibling reference supplied the assembly anyway. The real cost was downstream: the stale name was harvested into go2cs-stdlib.slnx as a phantom 308th project by the multi-platform merge’s solution-recovery path (fixed on the solution side by TestCollectConvertedProjectsIgnoresTestProjectReferences; this is the emission half).
Guarded by TestGorootVendoredReferenceNamesTheVendoredProject (the vendored spelling, the metadata key, and the leaf package name), TestStdLibImportPathFromTargetDir (the recovery, including the non-core-rooted no-match that leaves the caller on the import path) and TestStdLibReferenceUnchangedForUnvendoredPackage (the no-op half — the whole corpus bar the vendor/ tree, which is what makes a zero-movement CNR verdict meaningful rather than lucky), all in importOperations_test.go.
An importer spells the package class from the package NAME — the standard library included
The emitted class is <packageName>_package, so an importer’s spelling has to come from the Go package name, never from the last segment of the import path. The two agree for nearly every package, because a Go package is conventionally named for its directory — which is exactly why the places they disagree are so easy to miss.
// crypto/x509/internal/macos/security.go
package macOS // directory `macos`, package `macOS`
// the declaration side has always followed the package name
namespace go.crypto.x509.@internal;
public static partial class macOS_package { … }
// so the importer must too — crypto/x509/darwin/root_darwin.cs
using macOS = go.crypto.x509.@internal.macOS_package;
convertImportPathToNamespace substitutes the import graph’s authoritative package name for the path’s last segment. It used to do that only for non-stdlib imports, reasoning that a stdlib package is named for its directory so stdlib references would stay byte-identical. That premise is true for every standard-library package but one, and the exception could not surface until darwin was built at all: crypto/x509/internal/macos is darwin-exclusive, so its importers emitted macos_package against a declared macOS_package for as long as the corpus was Windows-only. C# is case-sensitive, so the result is CS0234 — and it reads like a missing project reference or an empty assembly, because the symbol genuinely exists nowhere.
Censused across windows, linux and darwin, the standard-library paths whose package name differs from their tail are exactly four:
| Import path | Package | Targets | Disposition |
|---|---|---|---|
crypto/x509/internal/macos |
macOS |
darwin | the one that moves |
math/rand/v2 |
rand |
all | already correct via the /vN branch |
internal/trace/internal/testgen/go122 |
testkit |
all | nothing in the corpus imports it |
runtime/internal/wasitest |
wasi |
all | nothing in the corpus imports it |
So trusting the import graph everywhere keeps the byte-identity the stdlib exclusion was asserting rather than merely asserting it — and CNR is what proves the claim instead of the comment.
The fix restructures rather than special-cases: when the graph knows a package’s name, that name is the class segment; the /vN directory convention remains the fallback for when it does not. A narrower “substitute only when the two differ” test would have looked equivalent and quietly broken the exotic case the convention branch was written for — a package literally named vN, which would then be rewritten to its parent. Preferring the authoritative name over the convention wherever both are available is what keeps the two rules from fighting.
This is the same family as the GOROOT-vendored reference above: several independent derivations name one package, and they are correct only when they agree structurally. Guarded by TestImportedPackageClassFollowsPackageName (the rule, over a stdlib name/directory mismatch, an ordinary stdlib package, a /vN directory and a module dependency) and TestMajorVersionFallbackAppliesWithoutGraphMetadata (the fallback half), in packageClassNaming_test.go.
Generated output path: $(OutDir) defers to $(BaseOutputPath)
Both project templates (src/go2cs/csproj-template.xml and the -tests host’s test-csproj-template.xml) give the generated project a stable default output path:
<!-- Build outputs are copied to $(OutDir), so this default must also defer to an explicit
$(BaseOutputPath); otherwise that redirection silently never takes effect. -->
<PropertyGroup Condition="'$(OutDir)'=='' AND '$(BaseOutputPath)'==''">
<OutDir>bin/$(Configuration)/$(TargetFramework)/</OutDir>
</PropertyGroup>
The AND '$(BaseOutputPath)'=='' half is load-bearing and was added 2026-07-26. MSBuild copies build outputs to $(OutDir), and $(OutDir) is what the SDK derives from $(BaseOutputPath) late in the import order — so a template that pins OutDir in the project body outranks any BaseOutputPath an outer Directory.Build.props sets, and that redirection is discarded without a warning. The generated project would keep writing to bin\<Config>\<tfm>\ while OutputPath reported the redirected location, which is exactly how the defect hides.
Three separate isolation intents were silently defeated by the unconditional pin, all found at once:
-
-testshost output —test-csproj-template.xmlsetsBaseOutputPath=bin/tests/(alongsideobj/tests/) precisely so the test project and the production project that shares its directory do not collide. Theobj/half worked; thebin/half never did, so every converted test host was writing into its production package’s output tree. -
The Native AOT perf publishes —
src/tests/Performance/Directory.Build.propssetsBaseOutputPath=bin\aot-build\under itsPerfAotgate. With the pin, the AOT publish’s build step wrote throughOutDirinto the JIT tree and overwrote the JIT binary with a self-contained,IsDynamicCodeSupported=falseone — which the runner then measured and published as the “JIT” column. Seedocs/phase4/DESIGN-iface-shell-caching.md§10. -
End-user layouts — any converted project consumed under an artifacts/CI convention that sets
BaseOutputPathwas ignored the same way.
Two guards pin this. TestCsprojTemplateEmitsWellFormedXml / TestTestCsprojTemplateEmitsWellFormedXml (src/go2cs/csprojTemplate_test.go) substitute each template exactly as its emitter does and stream the result through encoding/xml, which enforces the same comment rules MSBuild’s loader does — a malformed template breaks the entire corpus at compile time and is otherwise only visible ~450 s into a full behavioral run. On the measurement side, PerformanceRunner reads the JIT binary’s runtimeconfig.json before timing anything and fails the run if it is self-contained or has dynamic code disabled.
Per-GOOS sources: layout L3 and $(GoTargetOS)
Go selects its platform sources at build time — filename suffixes and //go:build constraints — so a
conversion does not merely target a platform, it is that platform. A converted package whose emission
varies across GOOS therefore keeps the varying files in per-GOOS subfolders, and the .csproj compiles
exactly one of them; files that are byte-identical on every platform stay flat. This is layout L3
(phase4/DESIGN-multiplatform-corpus.md §8, accepted 2026-08-08).
internal/goos is the first package to carry it:
src/core/internal/goos/goos.cs shared by windows, linux and darwin
src/core/internal/goos/package_info.cs shared
src/core/internal/goos/windows/nonunix.cs public const bool IsUnix = false;
src/core/internal/goos/windows/zgoos_windows.cs public static readonly @string GOOS = @"windows"u8;
src/core/internal/goos/linux/unix.cs public const bool IsUnix = true;
src/core/internal/goos/linux/zgoos_linux.cs public static readonly @string GOOS = @"linux"u8;
src/core/internal/goos/darwin/…
Such a package’s .csproj gains exactly two blocks — the selector, and the include that reads it:
<PropertyGroup Condition="'$(GoTargetOS)'==''">
<GoTargetOS>windows</GoTargetOS>
</PropertyGroup>
…
<Compile Remove="**/*.cs" />
<Compile Include="*.cs" />
<Compile Include="$(GoTargetOS)/*.cs" />
The include must follow <Compile Remove="**/*.cs" />, which would otherwise remove it — MSBuild evaluates
items in document order. The property may sit anywhere (properties are evaluated in an earlier pass) but is
declared above the item that reads it. windows is the default because the corpus that exists today is the
Windows emission, so a plain dotnet build reproduces the single-platform package this layout replaced:
verified byte-identical, and -p:GoTargetOS=windows produces the same assembly as the property absent.
Only a package that varies gets the blocks. The design measures the varying set at 37 of 304; the rest emit the same C# on every platform and keep exactly the project file they always had.
Which files are per-GOOS cannot be decided by one conversion. The platform axis is a comparison of
several targets’ emissions, and it is emphatically not derivable from Go file sets: four packages emit
different C# from identical Go source (constant folding, escape analysis, cross-file collision renaming,
dead-branch folding), and three emit identical C# from differing Go source. So the layout is produced by a
multi-target run — go2cs -stdlib -comments -platforms windows/amd64,linux/amd64,darwin/amd64 — which
converts once per target into a seeded staging root, classifies every emitted artifact (shared / variant /
partial / exclusive), and merges the result into one tree.
A single-target conversion instead honors a layout the output tree already carries: if the package
directory holds <goos>/<name>.cs, that is where <name>.cs is written, and a package directory holding
any per-GOOS source folder gets the two blocks. That is what makes an ordinary seeded reconvert reproduce
an L3 package file for file, instead of laying a flat duplicate beside the copy the project is already
compiling — a duplicate-member break that would otherwise arrive silently. A per-GOOS folder is
distinguished from a nested package by the project file every converted package directory holds and a
source folder never does: internal/syscall/windows is a real package whose own directory name is a GOOS.
Guarded by platformLayout_test.go (src/go2cs), that negative case included.
A HAND-OWNED file has a platform too, and the classifier cannot see it. L3 decides placement by
comparing emissions — and a hand-owned file is never emitted, so it is never classified, so it silently
keeps whatever placement it had while the file it belongs to moves per-GOOS. That is invisible on Windows,
where the compile item set is identical either way, and it took the entire Linux corpus down at increment 3:
runtime/lock_sema_impl.cs supplements lock_sema.cs, which Go selects on Windows and macOS but never on
Linux, so the Linux build compiled the flat companion against a principal that was not in its build.
The rule is stated as a platform set, not as a folder: a hand-owned file belongs in exactly the platform
builds its principal takes part in, after which L3’s ordinary placement rule applies unchanged — every
platform ⟹ flat, a subset ⟹ one copy per platform in the subset. Read literally as “inherit the principal’s
folder”, os/proc_impl.cs and syscall/syscall_impl.cs would be triplicated: their principals are per-GOOS
variants present on all three platforms, so three copies of one hand-written file would have to be
maintained in lockstep for no compile benefit. L3 duplicates only what cannot be shared.
A principal comes in two shapes, and both are already recorded in the tree by the emission itself:
| Hand-own | Principal | Why |
|---|---|---|
<name>_impl.cs |
<pkg>/<name>.cs |
a companion with no Go counterpart of its own; it supplements the converted file |
<name>.cs carrying [module: GoManualConversion]
|
<pkg>/<name>.cs.auto |
the conversion still runs and only its EMISSION diverts, to the review sibling — which is therefore emitted by exactly the platforms that compile the Go file this hand-own replaces |
The second binding is the one that catches a whole-file hand-own of a platform-exclusive Go file:
syscall/dll_windows.cs and syscall/exec_windows.cs replace Windows-only sources and now live in
syscall/windows/. The .cs.auto sibling moves with its .cs so the pair cannot separate — which is also
what a single-target reconvert already does, since conversionDriver routes both through
platformLayoutPath. A hand-own whose principal no target emitted (runtime/managed_impl.cs,
internal/poll/runtime_sema_impl.cs — go2cs machinery with no Go file behind it) has no placement evidence
and is left alone. Two existing copies of one hand-own that disagree are an error, never a first-wins
choice: a duplicated hand-own is hand-maintained in each folder, so propagating one over the other is how a
fix applied to a single flavor would disappear.
⚠ A hand-owned FUNCTION is a different problem with no layout answer. manualConversionFuncs is keyed by
NAME and is platform-blind, so an entry turns its Go declaration into a placeholder on every platform while
the implementation exists only where one was written — and notetsleep_internal is even four arguments in
lock_sema.go against two in lock_futex.go. Census and remedy in
phase4/DESIGN-multiplatform-corpus.md §7.
Guarded by platformHandOwn_test.go, which walks the real src/core rather than a synthetic tree —
the next offender will be a file somebody adds by hand. Three structural rules: an *_impl.cs whose
principal is in some but not all of its package’s per-GOOS folders must be in exactly those; a .cs.auto
lives beside the .cs it reviews; and a source carrying Go’s own GOOS filename constraint (*_windows.cs,
*_linux.cs, *_darwin.cs, with an _impl suffix stripped first) is never flat in an L3 package. That
third rule exists because a static walk cannot find a marked hand-own’s principal — it is what would have
caught dll_windows.cs/exec_windows.cs a whole increment earlier.
References are conditioned by the same rule, one level up. A package’s direct imports can differ by
GOOS too — measured at 21 packages, os being the clearest: it imports internal/syscall/windows on
Windows and internal/syscall/unix on Linux and macOS. One flat reference list cannot say that, so the
references common to every platform stay unconditioned and only the differences are selected:
<ItemGroup>
<ProjectReference Include="$(go2csPath)core/golib/golib.csproj" />
…the 16 references os has on every platform…
</ItemGroup>
<ItemGroup Condition="'$(GoTargetOS)'=='darwin'">
<ProjectReference Include="$(go2csPath)core/internal/syscall/unix/internal.syscall.unix.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(GoTargetOS)'=='windows'">
<ProjectReference Include="$(go2csPath)core/internal/godebug/internal.godebug.csproj" />
<ProjectReference Include="$(go2csPath)core/internal/syscall/windows/internal.syscall.windows.csproj" />
</ItemGroup>
A platform whose delta is empty still gets a group, written self-closing
(<ItemGroup Condition="'$(GoTargetOS)'=='linux'" />). That is not noise, and it is the one detail the whole
mechanism rests on: the shared list is an intersection, and a single-target reconvert recovers the other
platforms’ sets from the file it is about to overwrite. Forget that linux takes part and the next reconvert
computes the intersection over two platforms instead of three — promoting a Windows-only reference into the
shared list, where it would land in the Linux build. The empty group records membership so the axis survives.
Both producers — the multi-target merge and a single-target reconvert — go through one renderer, which is what makes the reconvert reproduce the merge’s bytes rather than something merely equivalent. When every platform’s imports agree again the block disappears and the project file returns to its plain form.
Two facts that are NOT references still have to be reconciled, because one .csproj serves every platform:
<AllowUnsafeBlocks> is emitted as the union across targets (it differs in os/user and syscall; the
property grants a capability rather than using one, so raising it is inert where unused), and every other
companion artifact — README, icons, .cs.auto — is taken from the first target in -platforms order,
the reference flavor, so the choice is deterministic instead of depending on which target happened to have
something to rewrite.
Guarded by platformProject_test.go, whose central test is the invariant itself: a single-target reconvert
of every platform must reproduce the merged project file byte for byte.
Build-warning suppression: what the emitted .csproj silences, and what it deliberately does not
Both templates carry one suppression policy, and it is a policy rather than an accretion: every entry is a
diagnostic that is structural to the Go emission model, and every diagnostic that is not is left
visible on purpose. The full census, code by code, is docs/phase4/DESIGN-warning-suppression.md.
<Nullable>annotations</Nullable>
<NoWarn>CS0162;CS0164;CS0282;CS0660;CS0661;CS1717;CS1718;CS8618;CS8860;CS8974;CS8981;IDE0060;IDE1006;CA2255</NoWarn>
The NoWarn entries each name an emission the converter re-creates on the very next conversion: Go type
names are lower-cased ASCII (CS8981, and CS8860 for a Go type literally named record); a struct’s
fields are split between its type file and package_info.cs (CS0282); Go comparison operators are emitted
on value types without Equals/GetHashCode (CS0660/CS0661); Go zero values leave non-nullable fields
uninitialized (CS8618); if (raceenabled) bodies and the break; appended after a case that already
throw panic(…)s are unreachable (CS0162), as are the synthetic continue_<label>:/break_<label>:
pairs emitted for every labeled Go statement (CS0164); the named-return store on the goto ᒐdone path
through a defer frame is a self-assignment (CS1717); return f != f; is Go’s NaN idiom verbatim
(CS1718); a function value in a map[string]any looks like a forgotten call (CS8974); and Go init()
is modeled as a [ModuleInitializer], which the library-hygiene analyzer objects to by design (CA2255).
IDE0060/IDE1006 never fire at the command line — they exist for Visual Studio’s live analysis, where
every _-shaped parameter and every Go identifier would otherwise be flagged.
Nullable is annotations, not enable. Go has no non-nullable pointer, interface, map, slice,
channel or func — every one of them is nil-able by construction — so C#’s nullable flow analysis is
asking a question the source language cannot pose, and the only way to satisfy it would be to annotate the
whole emitted corpus ?, burying the Go shape the project exists to preserve. Nor is it protecting a
semantic go2cs wants: a converted program that dereferences a nil Go value should fault, because that is
Go’s nil-pointer panic. annotations keeps ? meaningful (golib’s ж<T>?, PanicException?) and keeps
default! legal while turning the analysis off; disable would be wrong, because it makes every ? in
the emitted code a fresh CS8632. One consequence is load-bearing: CS8618 stays in NoWarn even under
annotations, because go2cs-gen emits #nullable enable at the top of each generated .g.cs and a
file-level directive beats the project property.
The publish properties are scoped off Library. PublishReadyToRun/PublishTrimmed/
IncludeNativeLibrariesForSelfExtract/EnableCompressionInSingleFile sit under
Condition="'$(OutputType)'!='Library'", because the SDK turns PublishTrimmed into
EnableTrimAnalyzer=true at build time — so on a library, which is never published, that one line was
the sole source of every IL#### warning in the corpus while buying nothing. A converted main package
still gets the analysis, and the trimmer re-runs over the whole closure at app publish, where it is
actionable. AllowUnsafeBlocks shares that group’s history but not its condition: it is a compile
setting, and the converted stdlib is full of library packages that do not compile without it.
Codes deliberately left visible are the other half of the policy — CS0219 (dead named-return locals,
the one static signal for a genuinely dropped assignment to a named result), CS8778 (a live 32-bit
truncation in nint-typed int64 constants), CS0675, CS8500 (the managed-referent hazard the S1 fork
ruling is about — golib suppresses it locally, the corpus must not inherit it), CS8826, CS0252,
CS0649, CS1522. Each is a converter or golib defect wearing a warning’s clothes; suppressing them would
delete the signal rather than fix the emission.
Six .csproj are hand-owned and carry the policy by hand rather than by emission — core/unsafe and
core/testing (skip-listed packages), core/internal/godebug, core/internal/weak and
core/internal/concurrent (whose only Go file is fully hand-owned, so unmarkedFileCount == 0 makes the
driver continue before writeProjectFile), and core/golib. golib keeps a shorter, deliberately
different list: it is hand-written, it is the reflection/unsafe core, and its trim and nullable warnings
are a real to-do list with an owner rather than emission noise.
Three guards pin all of this in src/go2cs/csprojTemplate_test.go:
TestBothCsprojTemplatesCarryTheSameSuppressionPolicy asserts the two templates agree on Nullable and on
the NoWarn set exactly (so adding a code forces the design-doc update, and dropping one fails), and
TestPublishPropertiesAreScopedOffLibrariesButAllowUnsafeBlocksIsNot parses the rendered project and pins
both halves of the publish-group split — a regression that moved AllowUnsafeBlocks under the condition
would break the corpus in a way no warning count would reveal.
A doc-comment link resolves to a fully-qualified, version-pinned URL
A converted package’s README.md is its package-level Go doc comment rendered to Markdown, and a Go doc
comment can link. Left to go/doc/comment’s defaults, those links come out site-root-relative:
[io.Reader] renders as [io.Reader](/io#Reader), because Printer.DocLinkBaseURL defaults to empty and
DocLink.DefaultURL then composes a path from the site root. That is exactly right for pkg.go.dev, which
serves the documentation at its own root, and exactly wrong everywhere this README is actually read:
GitHub resolves /io#Reader against github.com, Pages/Jekyll against the site root, and nuget.org
against nuget.org. The link is dead in all three.
The emitter therefore installs its own Printer.DocLinkURL (renderPackageDoc in readme.go, resolver in
readmeDocLinks.go). A standard-library target pins the Go release that produced the conversion —
https://pkg.go.dev/io@go1.23.1#Reader — which is the same rule, and the same honesty doctrine, the Docs
badge beside it already follows.
Completeness is structural here, not a judgement call, because the grammar is closed.
go/doc/comment’s Text interface has exactly four implementations — Plain, Italic, *Link,
*DocLink — and only two carry a URL:
-
*LinkURLs are absolute by construction. Both of the parser’s two link sources require a scheme:parseLinkrejects a[text]: urldefinition whose url has noisScheme(...)://, andautoURLrejects inline text on the same test (the accepted schemes arefile,ftp,gopher,http,https,mailto,nntp). A*Linktherefore cannot reach the emitter with a relative URL, and passes through untouched — which is also what the “already-absolute URLs are left alone” rule asks for. -
*DocLinkis the sole relative-URL producer, and its own documentation enumerates the exhaustive set of five field combinations.resolveDocLinkURLanswers all five.
DocLink fields |
Emitted URL |
|---|---|
ImportPath |
https://pkg.go.dev/io@go1.23.1 |
ImportPath, Name
|
https://pkg.go.dev/io@go1.23.1#Reader |
ImportPath, Recv, Name
|
https://pkg.go.dev/io@go1.23.1#Writer.Write |
Name |
https://pkg.go.dev/<current>@go1.23.1#Name |
Recv, Name
|
https://pkg.go.dev/<current>@go1.23.1#Recv.Name |
The two same-package forms cannot occur today — the converter leaves Parser.LookupSym nil, so [NewInt]
stays literal text rather than becoming a link (which is why the corpus is full of escaped \[Int],
\[Encoder], \[Decode]: those are not dead links, they are not links at all, and pkg.go.dev shows an
unresolvable name the same way). Answering them anyway is what makes the resolver total against the
grammar rather than against today’s census, so enabling LookupSym later needs no second pass here.
An external module path is pinned only when the distribution actually pinned it. A path whose first
element carries a dot is a module, not a std package, and cannot be pinned to a Go release — it is not a Go
release artifact. When GOROOT vendors that exact package, src/vendor/modules.txt records the snapshot the
conversion read and the URL states it (golang.org/x/sys@v0.22.0/cpu#X86). When it does not —
golang.org/x/sys/windows is referenced by std doc comments but is not among the x/sys packages GOROOT
vendors — the URL is emitted fully qualified but unversioned rather than borrowing the pin from the
module’s other vendored packages. A fabricated pin is worse than an unpinned link: the unpinned one still
resolves on all three surfaces, which is the entire defect being fixed. Same degradation the Source·Go
badge makes for the same reason — an unresolvable pin costs precision, never correctness.
Corpus census at the change: 99 relative link occurrences across 38 of 307 emitted READMEs (40
package-only /pkg, 57 /pkg#Name, 2 /pkg#Recv.Name, 0 bare-fragment #Name — exactly the distribution
the grammar predicts with LookupSym nil), against 1,899 already-absolute targets that pass through
unchanged.
Guarded by readmeDocLinks_test.go, which enumerates the five combinations rather than sampling them and
fails on any target that still begins at the site root, plus an end-to-end case over real godoc markup that
asserts both halves of the contract — every doc link qualified, every absolute link untouched.
One thing that looks like this defect and is not. src/core/image/README.md renders
\[Go Security Policy]([https://go.dev/security/policy](https://go.dev/security/policy)). That is upstream
Go writing Markdown link syntax inside a doc comment (image/image.go:37), which go/doc/comment does
not support; pkg.go.dev renders it identically. Faithful conversion of an upstream quirk, not an emitter
defect.
The README has TWO emission points, because its Tests badge reads a page the run writes LAST
A converted package’s README carries the four-badge line, and the Tests badge is composed at CONVERSION
time from docs/validation/current/<dot-id>.md. The run that WRITES that page is the compare at the END
of a -tests pipeline (emitValidationProofPage). Those two facts are an ordering, and the ordering is
the whole problem: within a single -test-action all, the README is always built from the proof page as
it stood BEFORE the run. A package whose counts are unchanged is fine; a package whose counts CHANGE —
every fresh bank, and every rebank that moves a number — emitted one run behind, so a fresh bank shipped
a README reading Tests-not_yet_validated-orange beside its own green proof page, and a bank owed one
extra conversion of its own package as paperwork.
Two formulations were on the table and they are not equivalent. Widening the emission GATE (which
package gets a README at all) is what emitsPackageReadme does, and it closes the unchanged-counts case;
it cannot reach an ordering. Closing the ordering needs a SECOND emission point:
refreshPackageReadmeAfterProof re-emits the README immediately after the compare writes the page.
The hazard, and why the refresh is sourced from a record. -test-action build|run|compare do not
convert: the converter’s package globals (packageDoc, packageSourceDir) are empty on those paths. A
refresh that re-read them would render a DOC-LESS README over a corpus package — a destructive,
corpus-wide rewrite that reads as ordinary reconvert drift. So the refresh does not read them. The
conversion-time write records what it composed the README from (packageReadmeEmission, readme.go),
and the refresh runs from that record or not at all: no conversion, no record, no write. The hazard is
unrepresentable rather than avoided, and the record is taken INSIDE the emitsPackageReadme gate, so the
one decision about whether a package gets a README also decides whether one can be refreshed.
writeReadmeFile is idempotent (needToWriteFile), so a package whose counts did not move rewrites
nothing and a sweep stays byte-clean — this opens no standing-restore family. The corollary is worth
carrying: a README.md moving during an operational sweep now means that package’s validation counts
moved. That is a finding to read, not dirt to classify.
Measured on hash/adler32 with its proof page moved aside to simulate a fresh bank: one
-test-action all validates 2 of 2, writes the page, and emits the green 2/2 badge in the same run
— byte-identical to the committed README. A -test-action compare over a deliberately staled README leaves it
byte-for-byte untouched, and creates none where the conversion wrote none. (Guarded by
TestPackageReadmeRefreshFollowsInProcessConversionNotRunMode, the sibling of
TestPackageReadmeEmissionFollowsPackageProvenanceNotRunMode that pins the gate half.)
Assembly metadata is one derivation chain, and the framework is hoisted to props
Every project in the tree — the two converter templates and the hand-written golib and go2cs-gen —
carries the same metadata block, in the same order, derived from the same two roots:
<Product>go2cs</Product>
<Description>$(AssemblyName) ($(TargetFramework) - $(Configuration))</Description>
<AssemblyTitle>$(Description)</AssemblyTitle>
<Authors>$(Product) Authors</Authors>
<Company>The $(Authors)</Company>
<Copyright>Copyright © 2018-2026 $(Company)</Copyright>
<RepositoryUrl>https://github.com/ritchiecarroll/go2cs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<ApplicationIcon>go2cs.ico</ApplicationIcon>
The order is the contract: the block reads top-down as a chain, so Product names the project, the two
description properties fall out of it, Authors falls out of Product, Company out of Authors, and
Copyright out of Company. There is exactly one place to edit a name, and no way for two projects to
disagree about one. A literal that happens to expand to the same string is still a defect — it is the copy
that goes stale.
The block had drifted in three directions at once before the guards existed. The two hand projects spelled
Company and Copyright as literals while the template spelled the year with a printf verb over
time.Now().Year(); the test-host template omitted Authors and Copyright entirely; and
go2cs-gen.csproj carried a second, empty <Description> below its real one — MSBuild keeps the last,
so the published go.gen package shipped with an empty description and an empty AssemblyTitle, with
nothing warning and nothing failing to build.
The Copyright year is now a literal range, not a verb. The verb made every emitted .csproj a
function of the wall clock: the same converter over the same sources with the same flags produced different
bytes on either side of New Year’s Eve, so the first regeneration of each year reported the whole corpus as
drifted. Determinism is worth more here than an automatically-current year, which is a once-a-year edit.
<TargetFramework> is owned by src/Directory.Build.props, so a framework hop is one edit rather than
one per csproj family plus a whole-corpus regeneration. It survives in each project only as a conditioned
fallback:
<TargetFramework Condition="'$(TargetFramework)'==''">net10.0</TargetFramework>
Both halves are load-bearing. Directory.Build.props is imported above the project body, so where the
file is in scope it wins and the project’s own line is inert; where it is not, the project still names a
framework and still builds. And it is genuinely not always in scope: deploy-core.ps1 stages the corpus
under a root that deliberately excludes core’s props and writes its own, a -recurse conversion
writes generated code under an arbitrary output root, and a single-package conversion can land anywhere.
Unconditional in the project would mean the hop silently skips every emitted project; absent entirely would
mean those trees do not build at all. go2cs-gen keeps its netstandard2.0 unconditional on purpose —
a Roslyn analyzer must not follow the hop — and its own value therefore wins over the props default.
MSBuild stops at the first Directory.Build.props found walking up, so the two nested ones
(src/core, src/tests/Performance) explicitly import the root via GetPathOfFileAbove. Any new nested
props file owes the same import or it silently shadows the root for everything beneath it.
Guarded by csprojMetadata_test.go, which states the contract over all four projects at once — the two
rendered templates and the two hand-written files read from disk — pinning the order as a subsequence, the
derived values exactly, the absence of a format verb, the conditioned framework, and (the regression guard
for the shipped defect) that no metadata property is set twice unconditionally.
Cross-package imports (importing another package / assembly)
When a package imports another and uses its exported surface, the converter must agree, on both the producer side (converting the imported package) and the consumer side (resolving the import), on the imported package’s C# (namespace, class) and emit a ProjectReference to its generated .csproj. The package class is <packageName>_package and the namespace is the root go plus the import path’s leading segments, so the two sides line up when the Go package name equals the import path’s last segment (the usual layout: import "x/y/barlib" → package barlib → go.x.y.barlib_package). The consumer emits using barlib = …barlib_package; and references members as barlib.Thing.
Resolving where an imported package lives is module-aware: the standard library is found under GOROOT and mapped to $(go2csPath)core/<pkg>, but a local/user module (reached via a go.mod replace, or simply co-located in the same module tree) is invisible to the legacy go/build GOPATH resolver. The converter therefore falls back to the dependency directory captured from the module-aware go/packages load, treats that package’s converted output as in-place (co-located with its Go source), and emits a ProjectReference relative to the referencing project (e.g. ../barlib/barlib.csproj) so the generated .csproj is portable. This is what makes “import a sibling package and use it” compile as separate assemblies.
Exported type aliases cross packages. A package-level Go type alias that is exported — type Temperature = Celsius — is recorded in that package’s package_info.cs as an assembly attribute in its <ExportedTypeAliases> block:
// <ExportedTypeAliases>
[assembly: GoTypeAlias("Temperature", "go.CrossPkgLib_package.Celsius")]
// </ExportedTypeAliases>
A consumer that imports the package and names CrossPkgLib.Temperature cannot use a C# member-access for it (C# has no namespace-level type alias). Instead, the converter parses the imported package’s package_info.cs, reads its [GoTypeAlias] attributes, and emits a corresponding global using into the consumer’s own <ImportedTypeAliases> block — keyed by a package-qualified name whose . separator is the extended-Unicode dot ꓸ (ꓸ, a valid C# identifier character), since CrossPkgLib.Temperature is not a legal C# identifier:
// <ImportedTypeAliases>
global using CrossPkgLibꓸTemperature = go.CrossPkgLib_package.Celsius;
// </ImportedTypeAliases>
The consumer’s converted code then refers to the alias as CrossPkgLibꓸTemperature. (This round-trip depends on the module-aware resolution above to locate the imported package’s package_info.cs; a stdlib dependency is found under the core output tree, a local module via its go/packages directory.) Guarded by the CrossPkgLib/CrossPkgUser cross-package behavioral test pair.
A collision-renamed alias chain resolves to its concrete target. When an exported type’s name collides with a method name it is Δ-renamed (see Type-vs-Method Name Collisions); and when that type is also an empty interface — type Token any colliding with a Token() method, encoding/json’s shape — the producer’s package_info.cs carries a two-hop chain. The collision analysis records Token → ΔToken, and visitTypeSpec (which renders an empty-interface target as object) records the renamed declaration ΔToken → object:
[assembly: GoTypeAlias("Token", "ΔToken")]
[assembly: GoTypeAlias("ΔToken", "object")]
A consumer that resolves only the FIRST hop and then qualifies the intermediate Δ-name as a package member emits global using jsonꓸToken = go.encoding.json_package.ΔToken; — but ΔToken is an assembly-scoped global using, not a namespace member of json_package, so it is CS0426 (encoding/json’s Token consumed by html/template, internal/coverage/cfile, expvar, log/slog, internal/fuzz, …). The imported-alias loader (loadImportedTypeAliases) therefore follows the chain within the producer’s OWN exported aliases to its concrete target, emitting global using jsonꓸToken = object;. A chain whose final target is a real Δ-renamed member (a delegate/struct such as ΔFilter, which is not itself an exported alias) stops there and stays package-qualified, unchanged. Guarded by the CrossPkgLib/CrossPkgUser pair — an empty-interface Token colliding with a Sensor.Token() method, named as a var type in the consumer and its boxed value read back, output-compared vs Go.
Imported stdlib alias metadata loads from the tree the assembly is COMPILED from — which since 2026-08-01 is the only tree there is. (Resolved; kept because the failure mode is instructive.) The alias round-trip locates the imported package’s package_info.cs under the core output tree. While the converted standard library lived in a SECOND tree, a -tests build compiled its stdlib dependencies from go-src-converted while loadImportedTypeAliases still read package_info.cs from the baseline core stub — and most stubs had no package_info.cs at all (runtime was impl-stubs only), so the alias map came back empty and a test’s cross-package reference to a collision-renamed stdlib type rendered the RAW, undefined qualified name: err.(runtime.Error) → runtime.Error (CS0426) instead of runtimeꓸError → runtime_package.ΔError (math/bits’ Div overflow/divide-zero panic asserts). The fix at the time derived the alias-load directory from the project-reference remap itself, so the two authorities could not drift. Both the remap and that derivation are now deleted: the converted stdlib lives at $(go2csPath)core/<pkg>, exactly where every resolver already pointed, so the alias-load path and the compile path are the same path by construction. The lesson outlives the machinery — metadata must be read from the tree the code is compiled from, never from a parallel one that merely looks like it.
A same-named cross-package alias target is fully qualified. Two different packages can share a Go package name — html/template and text/template are both package template. When such a package aliases the other’s type — html/template’s type FuncMap = template.FuncMap, whose target lives in text/template — the alias RHS must name the target’s OWN (namespace, class): go.text.template_package.FuncMap. getFullyQualifiedTypeName had gated its cross-package branch on the package name (pkg.Name() != packageName), so a same-named foreign type read as same-package and fell through to the t.String() path, whose cross-package slash-strip drops BOTH the text path segment AND the _package class — emitting global using FuncMap = go.template.FuncMap; (CS0234; template is not a namespace of go). The check now compares package identity (pkg != v.pkg), matching getAliasQualifiedTypeName and collectCrossPackagePaths, so the branch fires and the target fully qualifies. (A code-body reference already rendered correctly — getAliasQualifiedTypeName keyed on identity — so only the global using alias RHS was wrong.) Guarded by the CrossPkgSameNameAlias behavioral test: a package atomic that aliases the same-named sync/atomic’s Int32 (type Int32 = atomic.Int32), whose global using RHS must render go.sync.atomic_package.Int32, not the dropped-segment go.atomic.Int32.
A //go:linkname VARIABLE pull becomes a forwarding property to the (publicized) remote. Go’s //go:linkname local pkgpath.remote on a bodyless package var aliases local to another package’s remote — SAME storage, resolved by the linker. math/bits’ //go:linkname overflowError runtime.overflowError (its Div panics with overflowError, a runtime.Error) emitted a null field, so the converted Div panicked with null. Go 1.23 requires the definition side to authorize the pull with a one-argument handle (runtime/linkname.go’s bare //go:linkname overflowError); the authorization is puller-AGNOSTIC, so the faithful C# emission of a handle-marked var is public (a puller in a separate assembly must reach it) — a purely local decision each package makes from its own directives, no cross-package coordination. The pulling var then emits as a forwarding property to the fully-qualified remote (resolves in namespace go; without a using), and the remote’s package is queued for a project reference:
// runtime (definition side, one-arg handle):
public static error overflowError = ((error)((errorString)(@string)"integer overflow"u8));
// math/bits (two-arg pull):
internal static error overflowError { get => go.runtime_package.overflowError; set => go.runtime_package.overflowError = value; }
Three safety gates keep the emission compilable, each narrowing forwarding to what C# can express (the rest keep the pre-feature null-field/heap-box form): (1) a handle var is publicized only when its type is itself publicly accessible — runtime’s sched (schedt), writeBarrier (anon struct), lastmoduledatap (*moduledata) have unexported types and stay internal, since a public member cannot expose a less-accessible type (CS0052/CS0053) and such a var could not be pulled cross-assembly anyway; (2) a pull whose forwarding reference would form a project-reference cycle — runtime pulling internal/syscall/windows.CanUseLongPaths, where the target transitively depends on runtime — keeps its plain field (Go’s link-time linkname has no package cycle; a C# project reference cannot be circular); (3) an address-taken pull — reflect’s //go:linkname zeroVal runtime.zeroVal with &zeroVal[0] — keeps its addressed-global heap box, because a property has no address (ᏑzeroVal would be CS0103). The definition-side handle collection (collectLinknameHandles, a package-wide pre-pass like collectPublicizedTypes) and the pull recognition live in linknameOperations.go. (Guarded by the LinknameVarPull/LinknameVarPullLib behavioral test pair — a consumer package pulling an unexported handle var from a separate provider assembly, its value printed and output-compared vs go run; the full corpus compiles with the runtime handle vars publicized and the acyclic pulls forwarded. This is what unblocks math/bits’ Div panic tests from null.)
Gate (2) asks its question three ways, because the cheapest oracle is not always available. It used to read the -stdlib convert-set graph alone and answer “no cycle” whenever there was no graph — which is every single-package and every -tests conversion. That shortcut was W1: converting runtime under -stdlib suppressed the CanUseLongPaths pull, converting the SAME package under -tests emitted it, and the resulting runtime -> internal/syscall/windows reference closed six project cycles (MSB4006) through Go’s own internal/syscall/windows -> syscall -> runtime. One variable, two answers, no diagnostic. The assumption behind the shortcut — that one package alone cannot form a cross-package cycle — is true of every reference the converter emits except this one: all the others descend from an import, and Go’s import graph is acyclic by construction, whereas a linkname edge is the one reference the converter emits that Go’s own graph does not contain. linknamePullWouldCycle now answers from, in cost order, the convert-set graph when a batch driver built one; the current package’s own transitive import closure (if this package already reaches the target, the target cannot reach back); otherwise a memoized packages.Load of the pull TARGET, walked for the current package. A question that cannot be answered refuses the pull and says so on stderr — an unanswerable cycle question must not be answered “no”, because “no” emits a reference that may not compile at all while “yes” emits the plain field the converter emitted before the feature existed.
An UPWARD //go:linkname var alias INVERTS its storage instead of giving up
Gate (2) above keeps a cyclic pull compilable, but “compilable” is the whole of what it achieves: the two declarations become two unrelated fields, which is silently not what Go’s directive says. A //go:linkname var alias is a link-time identity — runtime.canUseLongPaths and internal/syscall/windows.CanUseLongPaths are one word of memory, arranged with no import in either direction. C# has no link-time identity, so one assembly must hold the field and the other must reach it through a member reference, which is a compile-time edge and must be acyclic. Every aliased pair therefore forces one question:
Which side holds the storage?
The project graph answers it: storage goes in whichever package the other one already depends on. varLinknamePull always puts it on the right of the two-argument directive, which is correct for a DOWNWARD pull (math/bits → runtime) and forms a cycle for an upward one. For the upward case the converter inverts rather than degrades — runtime keeps the storage (where Go’s own write already is) and the isw declaration becomes the forwarding property:
// runtime (storage side — publicized by packageVarAccess's alias arm, not by a handle):
public static bool canUseLongPaths;
// internal/syscall/windows (forwarding side, under Go's one-arg handle):
public static bool CanUseLongPaths { get => go.runtime_package.canUseLongPaths; set => go.runtime_package.canUseLongPaths = value; }
Inverting costs zero new project references here — isw → runtime already exists — where the un-inverted direction costs six cycles. That asymmetry is not luck: it is the same fact stated twice, since the side that is already depended upon is by definition the side no new edge is needed to reach.
It needs a curated registry, linknameVarAliasTargets, for the identical reason linknamePushTargets does: converting internal/syscall/windows, the converter cannot see runtime’s directive. A package is converted from its own syntax, and its dependencies contribute types, not comments — so from isw’s side a var under a one-arg handle is indistinguishable from any other opened var, and nothing in it names runtime. The row records the missing half as a judgment; linknameVarAliasStorage is derived from it (the linknamePushSources pattern) so the publicize arm and the registry cannot drift. Go’s authorization is still required — the forwarding side must carry its one-arg handle, so a row that outlives Go’s directive fails closed to a plain field rather than inventing an alias — and gate (3) is inherited on the target side, because an address-taken forwarding property would name a Ꮡ box that does not exist.
Forwarding and populating are one change — the GetSystemDirectory rule again. canUseLongPaths is written only by initLongPathSupport, called only from osinit, which the converter emits already marked not-run and whose body bottoms out in asmstdcall; so the alias alone would have faithfully forwarded a permanent false. A naive “set it true” would be worse than the gap: os.fixLongPath would stop adding the \\?\ prefix on a host where the PEB IsLongPathAwareProcess bit was not actually set, producing paths that silently fail. The flag is therefore tied to the outcome: golib’s InitializeWindowsLongPaths reads the PEB bit back after writing it and records that observation in WindowsLongPathsEnabled, and the hand-owned runtime/windows/os_windows_impl.cs copies it into canUseLongPaths from a [ModuleInitializer] — the same slot, file and pattern as that file’s existing ᴛInitSysDirectory. (Guarded by TestRecurseLinknameVarAlias for the four emission arms, TestLinknameVarAliasRegistryMatchesGoSource for both halves of each row against GOROOT, and the LongPathRoundTrip behavioral test for the semantics — a >MAX_PATH path round-tripped through os and output-compared vs go run. Design and the corrected root: docs/phase4/DESIGN-linkname-push-cycles.md.)
Exported structs and interfaces cross packages. An exported struct’s fields and methods are reachable on the consumer side exactly as the producer emits them — CrossPkgLib.Sensor{Name: …, Temp: …} lowers to a C# constructor call and s.Name / s.Hot() to field/method access on the imported struct — because the struct and its [GoRecv] extension methods live in the (referenced) library assembly.
A cross-package interface satisfaction is subtler. Go is structurally typed, so a consumer may assign any value with the right method set to an interface; C# requires the nominal partial struct T : I implementation glue, which the ImplementGenerator can only add to T in T’s own assembly (isLocalImplType). The converter records a [assembly: GoImplement<T, I>] for each concrete→interface conversion it witnesses while converting T’s package — so for a consumer to use Sensor as Labeled across the assembly boundary, the satisfaction must be witnessed in the library that declares Sensor. The idiomatic Go interface-satisfaction assertion does exactly this:
var _ Labeled = Sensor{} // in CrossPkgLib — records GoImplement<Sensor, Labeled> in this assembly
With that, the library emits [assembly: GoImplement<Sensor, Labeled>], Sensor : Labeled is realized in the library assembly, and a consumer’s var l CrossPkgLib.Labeled = s / CrossPkgLib.Describe(s) compile as ordinary upcasts. (A library that returns the interface from a constructor — func New(...) Labeled { return Sensor{…} } — witnesses it the same way.) A type that satisfies an interface but is never used as it within its own package gets no nominal glue — proactively recording every local concrete→local interface structural match WAS tried (a declaration-site scan, 2026-07) and was RETIRED on 2026-07-25, because it could never be complete (it cannot see a dynamic type in a later-converted assembly) and paid for its incompleteness in speculative glue. Such a satisfaction is resolved at run time by the interface’s duck-typing shells instead; a declared conversion still takes the nominal fast path. Also guarded by the CrossPkgLib/CrossPkgUser pair (Phase 3: struct field access + interface satisfaction).
A sub-package import whose leading segment is a package alias root-qualifies
When a package imports both a parent package and its sub-package — testing/fstest importing io and io/fs — the converter emits using io = io_package; (a type alias for the io package class) and, for io/fs, a relative namespace target io.fs_package. In C# the leading io segment of io.fs_package binds to that type alias, so io.fs_package[.FS] resolves to the nonexistent nested type io_package.fs_package[.FS] — CS0426 (the using fs = … alias line, the embedded fs.FS getter, and the generated TypeGenerator copies). The converter records every direct-import using-alias identifier bound in the package and prefixes go. onto any multi-segment relative namespace/type whose leading segment is one of them, so the segment resolves as the child namespace it names:
import (
"io"
"io/fs"
)
type fsOnly struct{ fs.FS }
using fs = go.io.fs_package;
public go.io.fs_package.FS FS;
The unqualified io.fs_package.FS is retained as the promotedInterfaceImplementations map key (which feeds alias-less generator files where the relative form resolves). This complements the existing alias-vs-child-namespace Δ-rename, which only catches <currentNS>.<alias> collisions. (Recurs for any parent+sub-package import pair; a behavioral guard is owed — the io/fs embedded-interface pattern needs a parent+sub-package pair absent from the core baseline stdlib.)
The same collision detection must see GOROOT-VENDORED namespaces. visitImportSpec resolves a GOROOT package’s golang.org/x/… import to its on-disk vendor/… path (and namespace) when the importing file lives under GOROOT, but computeImportAliasRenames built packageChildNamespaces from the raw imp.Path() — so a vendored sub-namespace like go.vendor.golang.org.x.text.unicode was absent from the map. rootQualifyIfAmbiguous then could not see that a stdlib alias’s leading segment collides with it: bidirule (at vendor/golang.org/x/text/secure/bidirule, importing both unicode/utf8 and the vendored golang.org/x/text/unicode/bidi) emitted using utf8 = unicode.utf8_package;, whose unicode bound to the in-scope vendored unicode namespace rather than stdlib go.unicode (CS0234). computeImportAliasRenames now applies resolveGorootVendoredPath to each closure path when the package lives under GOROOT — matching the emission — so the vendored namespaces populate the map and the alias root-qualifies to go.unicode.utf8_package. Gated on GOROOT so a user module’s own golang.org/x dependency is untouched; guard owed (the fix fires only for a GOROOT-vendored package, which the behavioral harness — never under GOROOT — cannot express; validated by the bidirule reconvert A/B).
A DOTTED build tag (goexperiment.X, amd64.vN) is matched against the host toolchain’s tool tags. The converter re-checks each file’s //go:build constraint after go/packages has already loaded it (to drop files for the wrong GOOS/GOARCH when converting cross-platform). Its evaluator only handled bare identifiers (linux, amd64), so a dotted tag parsed as a selector and fell through to false. That is wrong for an experiment enabled BY DEFAULT: coverageredesign, regabiwrappers, and regabiargs are in the host’s go/build ToolTags, so go/packages loaded their //go:build goexperiment.X _on.go files — but the re-check then re-EXCLUDED them (the selector → false), dropping the package-level consts (testing’s goexperiment.CoverageRedesign, CS0117 ×4). The evaluator now resolves a dotted tag by membership in build.Default.ToolTags — so an enabled experiment’s _on.go survives and a disabled one’s !goexperiment.X _off.go survives, exactly the one go/packages chose. Blast radius is only internal/goexperiment (the sole stdlib package whose file selection flipped). Guard owed — the fix depends on the host toolchain’s active tool tags, which the go2cs/* behavioral harness cannot express portably; validated by the reconvert A/B (only internal/goexperiment changes) and the census (the consts appear, testing’s CS0117 clear). (That tag lookup now lives in the single matchTag callback described next; it was a *ast.SelectorExpr case while the converter still parsed constraints itself.)
Build constraints are parsed and evaluated by go/build/constraint
The re-check above needs a constraint parser, and for a long time the converter hand-rolled one: it lowercased the expression text and handed it to parser.ParseExpr, then walked the resulting ast.Expr itself. That parser is for Go expressions, and a build constraint is not one. A Go release tag is the case that breaks it — go1.21 reads as the identifier go1 followed by an illegal .21 selector, so every constraint mentioning a release failed with failed to parse build constraint: 1:4: expected 'EOF', found .21. conversionDriver warns on that error and falls through to including the file, so the failure hid behind an accidentally-correct outcome for as long as the only constraints that hit it were bare go1.N gates (five go-logr files, surfaced by a -recurse probe of go.opentelemetry.io/otel). It stops being correct the moment a constraint mixes a release tag with a platform: //go:build go1.21 && windows converted for linux lost its platform half along with the rest of the expression.
Constraints are now parsed and evaluated by go/build/constraint, the package the toolchain itself uses, which retires the whole custom parse/eval layer rather than special-casing the dot:
-
Recognition —
constraint.IsGoBuild/constraint.IsPlusBuilddecide what is a constraint line. Both require the comment at column zero, and only the file header is scanned (everything above the package clause, line comments only). The regex that preceded them matched any//go:-prefixed line anywhere in the file, so a//go:buildquoted in documentation below the package clause gated the file it was describing. -
Precedence — a
//go:buildline wins outright; the legacy// +buildlines apply only in its absence, ANDed across lines, with,meaning AND and a space meaning OR inside one. The old regex could not see// +buildat all (no//go:prefix), so a legacy-only file — the norm in third-party modules predating Go 1.17, exactly what-recursemeets — converted as unconstrained. -
Evaluation —
Expr.Evaldrives onematchTagcallback, so the boolean structure (&&,||,!, parentheses) is the stdlib’s problem and go2cs owns only “is this one tag satisfied”. Tags resolve in three layers: theallowedPlatformsmap (GOOS, GOARCH, the derivedunix/posix, the compiler tags, and every-tagsvalue), then the Go release tags, thenbuild.Default.ToolTagsfor dotted tags. Matching is case-sensitive, as the toolchain matches; the old evaluator lowercased the whole expression first, which quietly made a mixed-case-tags MyTagunsatisfiable —SetTagstored it verbatim but the lookup folded it.
Release tags are asked of the go command, not of the compiled-in list. This is the one place where “use build.Default” — right for ToolTags — is wrong. Under GOTOOLCHAIN=auto the go command re-execs a newer toolchain when the main module asks for one, so go/packages can be selecting files under Go 1.25 while the build.Default.ReleaseTags linked into a go2cs built with Go 1.23 stops at go1.23. The re-check would then call go1.24 false where the loader called it true and drop the file — and the !go1.24 sibling was already dropped upstream by the loader, leaving the package with neither half of a fallback pair. Over-exclusion is this pass’s recurring failure mode (the purego seeding and the goexperiment branch above each exist to undo one) and it is the dangerous direction, because the loader has already applied the full constraint for the target platform — anything this pass subtracts is real code. So release tags come from go env GOVERSION run in the same directory packages.Load uses, expanded to go1.1…go1.N. An unreachable or unparseable toolchain falls back to the compiled-in list — never to an empty one, which would make every go1.N false.
That subprocess costs ~300 ms on Windows, so it is contained twice. Resolution is lazy — matchTag tests the go1.N shape before resolving anything, so a constraint naming no release tag never pays it; and the answer is then cached per module root, because GOTOOLCHAIN keys on the module. Both halves earn their place: the behavioral corpus is 569 separate modules, so the cache alone would still spend 569 lookups answering a question not one of those packages asks (no behavioral constraint mentions a release tag), while -stdlib reaches the lookup exactly once — sort is the only standard-library package with a go1.N gate — and GOROOT/src carries one go.mod above all 302 packages anyway. Note this does not help the type checker go2cs links in, which is still whatever release compiled it; a module whose go directive exceeds that still needs go2cs rebuilt on a newer toolchain.
Guarded by src/go2cs/buildConstraints_test.go (release tags bare/negated/compound, the legacy +build grammar, extraction precedence, loader-toolchain resolution), each assertion verified to fail against the pre-fix converter.
An import forces the imported package’s init to run
Go guarantees an imported package is fully initialized before the importing package’s own
initialization, for every form of import. A converted Go init becomes [GoInit], which
csproj-template.xml aliases to .NET’s [ModuleInitializer] — the right shape and a weaker
guarantee: a module constructor runs at first access to something in its own module, so an
assembly nothing in the program has touched yet has not initialized. The converter closes that gap by
emitting a hook that forces the imported package’s module constructor.
Blank imports were forced first, and for years they were all that was. import _ "image/png"
imports a package purely for the side effects of its init, so it is by definition the case that
names nothing, and the observable form is a registry that stays empty. image/gif’s writer_test.go
blank-imports image/png so that png’s init calls
image.RegisterFormat (image/png/reader.cs); with the import emitted as a comment alone that never
ran, image.Decode had no PNG entry, and TestWriter failed with
../testdata/video-001.png image: unknown format — the package’s only failure, at 27 of 28. The same
shape gates every registration-by-blank-import consumer: database/sql drivers (sql.Register),
net/http/pprof (its init installs the /debug/pprof handlers), image/png and image/jpeg as
decoders for anything calling image.Decode, and time/tzdata.
That reading was true of blank imports and said nothing about the others (2026-08-26). A NAMED
import whose package is referenced only from a function body is equally untouched at
module-initialization time, and Go orders it identically. log/slog is the case that made the
difference observable: slog’s init captures log/internal.DefaultOutput, which log’s own
init installs. Whichever of the two is touched first wins, so a test host that touches slog
first captured nil into defaultHandler.output — a value that is captured, never re-read, so
log’s later initialization could not repair it. handler.cs:120 then dereferenced a nil func: on
the test’s own thread that surfaced as one ordinary failure, and on a goroutine it escaped and killed
the host outright, costing every ordinally later test its verdict. It is a correctness fix rather
than a verdict fix — before it, any converted program that touched log/slog before log crashed.
The trigger is “the imported package initializes something, transitively.” Two honest trigger
sets were available. Forcing every import matches Go exactly and costs a hook per import per
package, corpus-wide. Forcing every import whose module constructor is non-empty transitively is
observationally equivalent — running an empty module constructor is a guaranteed no-op, the same
reasoning the pseudo-package skip below already applies to unsafe/builtin/C — at a fraction of
the emission. The second is what the converter does.
The fact behind it is computed in process from the loaded package graph (packageInitFacts.go)
rather than published as metadata, and that is the one design decision in the change worth stating
plainly. A package initializes when it declares a func init(), or when go/types’
Info.InitOrder carries a package-level variable whose initialization expression is not a
compile-time constant — Go’s own definition of what runs at init time, read off Go’s own analysis
rather than re-derived. That answer is then closed transitively over the import graph, which is a
DAG, so it terminates; and only reachability matters, because forcing a package runs the forcing
hooks it carries in turn, so the runtime walks the graph one link at a time.
Computing it in process is possible because go/packages is loaded with LoadAllSyntax, which
carries syntax and type information for every dependency, not only for the package being
converted (measured on log/slog: 66 transitive dependencies, 66 with TypesInfo, 65 with Syntax
— the one without is unsafe, which the pseudo-package fence already answers). Three shapes make
that materially better than publishing the fact as a package_info.cs record: the three
hand-owned-by-consequence packages (internal/concurrent, internal/godebug, internal/weak)
never re-emit a package_info.cs at all, so a published record could never appear for them — and
internal/godebug has an init; a hand-owned file inside a converted package is not visited, so
its init would be invisible to a record scraped from emitted output, while Go’s own graph still
sees it; and layout L3 would need the record routed per GOOS, whereas the loader answers per
target for free, since which files a package is built from is exactly what decides whether it has an
init. The fact also stays deterministic from the Go sources alone, so the emission cannot vary with
the state of the output tree — the failure mode the -go2cspath empty-<ImportedTypeAliases> trap
is the standing example of.
An import path this conversion has no loaded handle for answers yes. Forcing a module constructor that turns out to be empty is a guaranteed no-op; skipping one that is not loses Go’s ordering silently, and silently is how this defect lived in the corpus for months. The trigger fails toward fidelity, never toward the smaller emission.
⚠ A read-set heuristic cannot substitute. The tempting narrow rule — “force only the imports whose
symbols the importer’s own init references” — MISSES this exact case: slog’s init reads
log/internal.DefaultOutput, but the package whose init WRITES it is log. The dependency that
must be forced is not the one the init statement names.
A blank import still emits no using — using _ = <ns>; would hijack C#’s _ discard for the
whole file (CS0118 + CS0029 on any deconstruction discard) — so it stays a comment, and every import
form, blank included, gets the same generated module-initializer hook at the top of the importing
file’s class body:
import (
_ "BlankImportSideEffects/jpeglike"
_ "BlankImportSideEffects/pnglike"
"BlankImportSideEffects/registry"
)
// blank import: BlankImportSideEffects.jpeglike_package (side effects only; no using emitted — a `using _` alias hijacks C# discards)
// blank import: BlankImportSideEffects.pnglike_package (side effects only; no using emitted — a `using _` alias hijacks C# discards)
using registry = BlankImportSideEffects.registry_package;
partial class main_package {
// Go runs an imported package's `init` before this package's own; .NET would never load
// an assembly nothing has touched yet, so that initialization is forced here.
[GoInit] internal static void initᴛᴛimportꓸBlankImportSideEffectsꓸjpeglike() {
builtin.initPackage(typeof(BlankImportSideEffects.jpeglike_package));
}
// …and the same hook for the NAMED import beside them, which Go orders identically.
[GoInit] internal static void initᴛᴛimportꓸBlankImportSideEffectsꓸregistry() {
builtin.initPackage(typeof(BlankImportSideEffects.registry_package));
}
Four decisions make that emission what it is.
The mechanism is RuntimeHelpers.RunModuleConstructor, wrapped as golib’s builtin.initPackage(Type)
so the emitted line stays readable and the mechanism lives in exactly one place. It is the explicit,
spec-defined way to run a module constructor, and the runtime guarantees a module constructor runs at
most once — so several importers of one package, or a package forced after it has already
loaded, are no-ops rather than repeated init calls. Measured under Native AOT as well as the JIT:
the call is AOT-safe, and under AOT the gap does not even arise (a single native image has no lazy
assembly load, so every linked module’s initializers run at startup regardless) — the forced call is
simply redundant there. typeof also roots the package class for the trimmer, so a trimmed publish
keeps the assembly the program otherwise never names.
The hook leads the class body, ahead of the file’s own init functions, because Go orders an
imported package’s initialization before the importer’s. Roslyn emits an assembly’s module-initializer
calls in compilation file order and then declaration order within a file, so leading the file’s
declarations is what that ordering buys — within one file it is exact. Across files of one package the
order is Roslyn’s, and the conversion does not state it: an init in file B that depends on an import
only file A names is ordered by the compiler rather than by go2cs. That residual is the one part of
Go’s rule the emission still approximates, and closing it would mean a single per-assembly driver that
forces every import in dependency order — a strictly larger change, and one no measured case needs.
Exactly one hook per (assembly, imported package). Go initializes a package once per program
however many files import it, and a .NET module constructor likewise runs once per assembly, so the
hook belongs to the first file that names the import; later files’ imports of the same package
emit nothing. Since the hook covers named imports too, that overlap is now the rule rather than the
exception — most files of a package re-import what a sibling already forced. The hook’s name is
derived from the import path (image/png →
initᴛᴛimportꓸimageꓸpng), which makes it unique by construction — two imports in one file
are two methods, and a shared generated name would be CS0111 — and stable across runs without a
counter or a file-name mangle. Path segments are reduced to C# identifier characters, so a module
path’s dots and hyphens (github.com/mattn/go-isatty) cannot break the identifier. The doubled temp
marker keeps the name clear of the relocated-package-var method space (initᴛ<varname>, see
Package-Level Variable Initialization Order) and the
import word clear of the -tests package-init hook (initᴛᴛtests).
The forcing typeof is root-qualified when the enclosing class shadows its leading segment.
The hook body is the ONE place the converter spells a namespace-qualified path where C# class-member
lookup applies. Every other cross-package reference is emitted through a file-scoped using alias, and
a using directive resolves at namespace scope, where class members are not in play; the hook sits
inside the class body, and C# resolves the leading identifier of a namespace-or-type-name by searching
the enclosing type declarations outward first. A nested type sharing that identifier therefore
occludes the namespace for the whole class body — while the alias a few lines above keeps working, which
is what makes the failure read like a converter regression somewhere else entirely:
using palette = image.color.palette_package; // namespace scope — resolves
partial class image_internal_test_package {
[GoInit] internal static void initᴛᴛimportꓸimageꓸcolorꓸpalette() {
builtin.initPackage(typeof(image.color.palette_package)); // CS0426
}
[GoType] internal partial interface image : Image { … } // ← occludes `image`
Go’s own image_test.go declares a test-local type image interface{…}, and package image is free to:
importing image/color/palette binds palette, not image. The same shape is available to production
code — type sync struct{} beside import "sync/atomic" breaks identically — so the remedy covers the
class, not the instance: writeImportInit root-qualifies the target with global:: (which restarts
lookup at the global namespace and so cannot be occluded at all) whenever forcingTargetShadowed finds a
package-level type named for the target’s leading segment that emits into this class.
Both halves of that gate are narrow on purpose, and both directions cost something. Only a type
occludes: typeof(a.b) is a namespace-or-type-name, and that lookup considers types and namespaces
alone, so a same-named func or var is irrelevant. And only a type emitted into the same class occludes,
which is what keeps a production hook bare when the shadow lives in the test-variant class — the two are
sibling partial classes, and a using static import of one loses to the namespace’s own members at
namespace scope — so the -stdlib and -tests emissions of the same production file stay identical.
Over-qualifying would be valid C# but would churn every hook in the corpus (2,147 sites across 691 files
at the time of the fix, 606 behavioral goldens among them); under-qualifying is a hard CS0426. An ordinal
census of the converted standard library finds exactly one shadow site — image’s — which is why the
guard costs the corpus nothing: every site it changes is a site that did not compile. Guarded by
tests/Behavioral/ImportSegmentTypeShadow (production scope, compiled and output-compared) and by
importSegmentShadow_test.go (the test-local half, the gate’s four quadrants, and the rooting shapes).
Go’s pseudo-packages are skipped. unsafe and builtin are compiler-provided and have no
initialization at all, and C is cgo. import _ "unsafe" is the //go:linkname ritual — 67 files of
the converted standard library — so forcing it would be a guaranteed no-op emitted 67 times. They are
answered before the loaded-handle lookup, which matters for unsafe specifically: it loads with type
information but no syntax, otherwise indistinguishable from a package the loader failed to give
this conversion, which fails open and would force it.
Only the module constructor is forced — that is exactly the package’s init functions. A package’s
own package-level variable initializers are C# static field initializers on the package class, which
the CLR still runs lazily at first access to that class, unchanged from every other import (and the
package’s own init touching them is what triggers them). The residual case is a package whose
registration is a package-level var _ = pkg.Register(…) rather than an init; closing it means
additionally forcing the package class’s type initializer, which is deliberately not done here
because it would eagerly run runtime’s 291 package-level initializers on behalf of
runtime/metrics’s linkname-only import for no measured benefit. No import in the converted
standard library registers that way.
Note the deliberate asymmetry with the trigger above, which counts a non-constant package-level
initializer as initialization. The FACT states what Go runs at init time; the HOOK runs what a .NET
module constructor can run. Making the fact narrower — “only a func init() counts” — would tighten
the emission today at the cost of encoding one property of the current emission model into a
Go-level question, so a later change that moved relocated initializers into the module constructor
would silently under-force. The looser fact costs a no-op hook for a package whose only
initialization is variables; it cannot cost a missed one.
The -tests emission carries all of this unchanged — the hook is written by the same import visitor,
so an import in a _test.go file (which is where image/gif’s blank import is) forces from the test
assembly. Under the recompile model the external variant’s import of the package under test
binds a class compiled into that same assembly, so there is no separate module constructor and no
hook; under the two production-reference models there genuinely is one, and the ordinary
per-import hook forces it (iter’s pull_test.cs carries a real initᴛᴛimportꓸiter()). What that
does not cover is the subject of the next subsection.
Guarded by the NamedImportInitOrder behavioral test — four packages in the reduced log/slog shape,
where store holds a value and initializes nothing, writer’s init writes it, reader’s init
CAPTURES it, and main touches only reader, from a function body. Before the fix Go printed
written-by-writer-init and the converted C# printed the empty string. It also pins the
selectivity in the same run: store gets no hook, because it initializes nothing transitively. And by
the BlankImportSideEffects behavioral test — a registry package that two blank-imported
sibling packages fill from their inits, read back by an importer that never names either registrant,
with the importer’s own init recording the count to prove the ordering and an unregistered name as
the negative control (without the hooks the program prints count at init: 0 and three missing:
lines; with them it matches go run exactly) — plus four converter unit tests:
TestImportInitName and TestNoInitPseudoPackages lock the generated name’s uniqueness and the
pseudo-package skip, while TestPackageInitializesTransitively and
TestPackageInitializesTransitivelyFailsOpen lock the fact itself against a loaded fixture module
covering each shape — no init at all, constant-only initializers, a func init(), a non-constant
variable initializer, and reaching an initializing package one and two hops away — plus the
unknown-path direction.
A -tests production-reference project forces the package under test’s own init
Go’s contract for a test binary is stronger than “imports first”: every init in the package
under test — the production files’ included — has run before the first test does. The subsection
above closes the import half. This one closes the half the import machinery structurally cannot
reach.
Under the two production-reference test models (reference and whitebox-reference) the test
assembly is a separate module that references the production assembly of the same Go package.
[GoInit] is [ModuleInitializer], which fires at first access to something in its own module,
so the production init ran at the first touch of a production symbol — which may be the second
test, or the tenth, or never. A test that only observes an init side effect therefore sees
nothing at all when it happens to run first.
An external test file (package foo_test) writes import "foo", so the per-import hook already
forces it. An internal one (package foo) is in the package and imports nothing of it: there
is no import spec for writeImportInit to hang a hook on, and nothing else was forcing the module.
net/http/pprof is the shape that made it observable (2026-08-29). Its whole mux surface is
installed by its init (http.HandleFunc("/debug/pprof/", Index), …), its test file is package
pprof, and TestDeltaProfile is the only test that goes through a real httptest server rather
than calling handlers directly. It got 404 whenever it ran before any test that touched a
production symbol — measured over eight shuffled runs that split four/four exactly on order, plus
the unshuffled pipeline (where TestDeltaProfile sorts first) making nine observations. Every
banked -tests row that observes an init side effect was order-lucky rather than proven safe,
which is precisely why the class stayed invisible: most suites happen to touch something first.
The remedy is one hook, seeded into package_test_info.cs’s anchor class by
referenceModelTestPackageInfoSeed, using the same RunModuleConstructor mechanism as the import
hooks:
[GoPackage("pprof")]
public static partial class pprof_internal_test_package
{
// <TypeAccessibility> … </TypeAccessibility>
// Go runs every `init` in the package under test - the production files' included -
// before the first test. The production package is a REFERENCED assembly here, whose
// module constructor .NET would not run until something in it is touched, so that
// initialization is forced before anything else in this test module runs.
[GoInit] internal static void initᴛᴛproduction() {
builtin.initPackage(typeof(global::go.net.http.pprof_package));
}
}
Four decisions, each mirroring one the import hooks already made.
It is emitted unconditionally, with no “does the production package initialize anything” gate. The import hooks pay for that gate because they emit one hook per import per package corpus-wide; this is one hook per test project, an empty module constructor is a guaranteed no-op, and the runtime runs one at most once — so a gate could buy nothing and could only mis-answer.
The recompile model gets nothing, and structurally rather than by a check: it never seeds this
file. There the production sources are compile items of the test assembly, so their [GoInit]s
are already that module’s own.
The typeof target is global::-rooted unconditionally, where the import hooks root only on a
detected shadow. The import hooks are conditional because over-qualifying would churn thousands of
corpus sites; here there is exactly one site per test project and it is new, so the collision-proof
spelling costs nothing and needs no forcingTargetShadowed analysis to stay correct.
The residual ordering nuance is stated, not engineered around. Roslyn orders a module’s
initializers lexically and the tests csproj sorts its compile items by name, so a test file sorting
before package_test_info.cs has its own init run before this hook. That is the same guarantee the
converter already declines to make across files of one package, and it does not touch the property
the hook exists for: every module initializer runs before Main, hence before the first test.
What it does and does not buy on the row that found it. With init forced, pprof’s
TestDeltaProfile goes skip → infrastructure-error, landing on the same
pprof_mutexProfileInternal stub that already fails TestHandlers//debug/pprof/mutex. The deferred
init was masking a capability gap; removing the mask changes the shape of the divergence, never
its existence. The row reads 6 of 15 either way. That is the general lesson as much as the specific
one — an ordering defect that hides a stub reads as a skip, which is the least alarming verdict
there is.
Guarded by TestReferenceModelSeedForcesProductionInit, which asserts the two properties that make
the hook work rather than its mere presence — that it carries [GoInit] (a plain method would never
run) and that its typeof is global::-rooted — over both the flat and the nested-namespace
whitebox shape.
A NuGet-referenced standard library carries its exported metadata IN THE CONVERTER
Everything above — the imported-type-alias global using round-trip, and the foreign GoImplement
records that tell a consumer its dependency’s own assembly already implements an interface — is learned
by opening the referenced package’s package_info.cs and scraping the [assembly: …] lines out of it
(loadImportedTypeAliases / loadPackageImplements, importOperations.go). That file is converted
source, found at $(go2csPath)core/<pkg>, i.e. wherever deploy-core staged the standard library.
-recurse=nuget removes exactly that file. In NuGet mode the standard library is consumed as
pre-compiled go.<pkg> assemblies, so there is no converted-source tree on disk — and there never will
be, since skipping the source deployment is the whole point of the mode. The scrape then finds nothing
and the converter falls through to foreignDerivedTypeAliases, which recovers some aliases from the
dependency’s own Go declarations and no implement records at all. Two things go wrong, both in the
CONSUMING package:
- its
<ImportedTypeAliases>block loses the dependency’s exported aliases (global using syscallꓸHandle = go.syscall_package.ΔHandle,netꓸAddr,timeꓸLocation, …); and - not knowing that
syscall’s assembly already implementserroronsyscall.Errno, it re-declares the pair locally —[assembly: GoImplement<syscall_package.Errno, error>]— and wraps the value at the cast site in a locally-generated adapter (new syscall_Errnoᴠerror(e)) instead of converting implicitly.
golang.org/x/sys/windows shows both at once: it Go-aliases type Errno = syscall.Errno, so the two
spellings Errno and syscall_package.Errno were each recorded, resolved to the same type, and made
ImplementGenerator emit the syscall_Errnoᴠerror adapter twice — CS0102 / CS0111 ×5 / CS8646.
That single duplicated adapter is what made the README’s fatih/color walkthrough unbuildable under
-recurse=nuget.
Note what is NOT the cause. The go2cs-gen generators are not reference-kind sensitive: a real
MSBuild build hands a <ProjectReference> to the compiler as a PortableExecutableReference exactly
like a <PackageReference>, and the generators read foreign type shape from symbols either way (see
FindTypeSymbol). Both modes ran the same generator over the same kind of reference; the emitted C#
they were given differed, because the converter’s cross-package knowledge differed.
The conversion. The exported metadata is a static, per-package property of the published assemblies,
so it travels with the converter. internal/genstdlibmeta captures the <ExportedTypeAliases> section and
every GoImplement record of all 302 src/core/**/package_info.cs into
src/go2cs/stdlib-metadata.txt (~128 KB), which stdlibMetadata.go embeds via //go:embed. When a
stdlib dependency’s package_info.cs is absent, the converter reads its recorded lines through the very
same parsers (parseExportedTypeAliasLines / parseExportedPointerImplementLines /
parseExportedValueImplementLines, refactored off the file read for this) and proceeds identically. The
asset is generated from the same tree push-nuget.ps1 packs, so the embedded record and the published
assemblies are always one commit’s output.
Reading the record is gated on PackageInfo.PublishedStdLib — isStdLib && nugetRefs && !convertStdLib —
and this gate is a soundness precondition, not conservatism. The record describes the converted
standard library at src/core; substituting it is only correct when that is what the build actually
references. Under -recurse=nuget that is guaranteed. A $(go2csPath) deploy root may hold an older or
partial staging whose exported surface differs, and the stdlib
self-conversion is building the assemblies being published. Both keep the derive-from-declarations
fallback. Because -recurse=nuget is off by default, no other conversion path changes — CNR is
byte-identical across the behavioral corpus.
The result is exact rather than approximate: converting the README’s colordemo with -recurse=nuget
against an empty $(go2csPath) now emits byte-identical C# to the same app converted with
-recurse against a full deploy-core stdlib root, and the NuGet-referencing solution builds with 0
errors and runs with output matching go run. syscall.Errno is not a special case — a probe returning
six foreign concrete types as foreign interfaces recovered 14 imported aliases and dropped three spurious
re-declarations spanning both record forms (io/fs’s PathError→error pointer adapter, sort’s
IntSlice→Interface value implement, and syscall.Errno), leaving only the one record the
consumer legitimately owns (os.File → io.Reader), exactly as the source-referencing conversion does.
Five guards (stdlibMetadata_test.go). TestStdLibMetadataAssetFileName pins the generator’s output name
against the //go:embed target (and its package_info.cs constant against the converter’s), so the two
halves can never write and read different files. TestStdLibMetadataInSync regenerates the asset in-process from
src/core and fails on drift — a stale asset would hand -recurse=nuget the previous
standard library’s records while the published assemblies carry the current ones, surfacing only as
downstream C# errors. TestStdLibExportedMetadataReadsThroughPackageInfoParsers pins that the embedded
lines feed the shared parsers (and that a Pointer-form record does not leak into the value implements).
TestPublishedStdLibScope pins the three-way gate above. TestRecurseNuGetResolvesForeignImplements
converts a module returning a syscall.Errno as an error with no converted stdlib on disk and asserts
the consumer records nothing and emits no local adapter — it fails on both assertions with the record
disabled.
Residual limitation. The asset is regenerated by go generate . from src/go2cs, so a change to the
converted standard library must be re-banked into src/core before it reaches
-recurse=nuget consumers. TestStdLibMetadataInSync makes that a test failure rather than a silent
mismatch, but it can only compare against the committed tree — it cannot detect that the committed tree
is itself older than the published packages.
A foreign implement record is keyed in ONE spelling, and a VALUE one is trusted only for a partial struct
The record scraped above answers one question at a cast site: does the dependency’s own assembly
already implement this pair? If it does, the bare value converts implicitly and a local
<pkg>_<T>ᴠ<Iface> value adapter is dead machinery. Getting the answer wrong in either direction is
expensive, so both halves — the KEY and the TRUST — are stated precisely here.
The key. implementRecordKey composes <declaring package>|<C# simple type>|<pkg>_package.<Iface>
and is called by BOTH sides of BOTH record sets: loadPackageImplementLines, over records parsed from a
dependency’s package_info.cs, and the value arms and the foreign-pointer arm of
convertToInterfaceType, over a cast being converted. That it is one function is the whole point — the
two sides used to compose it independently, over different alphabets, and agreed only when the
dependency’s import path was a single segment:
| dependency | load side | use side | |
|---|---|---|---|
io |
io\|noBody\|io_package.ReadCloser |
io\|noBody\|io_package.ReadCloser |
match |
encoding/binary |
binary\|bigEndian\|binary_package.ByteOrder |
binary\|bigEndian\|encoding.binary_package.ByteOrder |
miss |
image/color |
color\|ΔRGBA\|color_package.Color |
color\|RGBA\|image.color_package.Color |
miss |
text/template/parse (ptr) |
parse\|ListNode\|parse_package.Node |
parse\|ListNode\|text.template.parse_package.Node |
miss |
go/types (ptr) |
types\|TypeName\|go.types_package.Object |
types\|TypeName\|types_package.Object |
miss |
image (ptr) |
image\|ΔRGBA\|image_package.Image |
image\|RGBA\|image_package.Image |
miss |
Two divergences, and the second is easy to miss because it only shows on a collision-renamed type.
(1) The INTERFACE side: a parsed record names the recording package’s own interface BARE and a foreign
one whole (go.image.color_package.Color), while a cast site always renders the full namespace chain.
canonicalImplementRecordIfaceName drops everything ahead of the <pkg>_package segment, so both reduce
to color_package.Color; a member path under the class (y_package.Outer.Inner) survives intact. The
package CLASS must stay — the simple name alone collides, and image’s Paletted→image.Image record
must not satisfy a Paletted→draw.Image cast. Note that neither side is reliably the longer one, so
a “strip the chain” heuristic would not do: go/types records its OWN Object fully qualified where the
cast site renders it short, while text/template/parse records its own Node bare where the cast site
renders it whole. Which spelling a file produces depends on its own using/alias context — which is
exactly why a canonical form, and not either side’s raw text, is the key. (2) The TYPE side: a record
carries the EMITTED C# name, so image/color’s RGBA (collision-renamed against its own RGBA() method)
is ΔRGBA there, while the use side was naming the GO type. Both sides now reduce the emitted name.
The DECLARING-package component is what keeps a record honest. A package may record a value pair for a
type declared in a THIRD assembly — image re-declared all of image/color’s models — and go2cs-gen
realizes that as a local adapter class, not as the type implementing the interface. The use side names
the TARGET’s package, so such a record can never satisfy a cast (image|Alpha|… against
color|Alpha|…).
The trust. A record says the declaring assembly implements the pair; it does not say HOW.
ImplementGenerator makes every named Go type a partial struct T : Iface that really does implement
it — struct, slice ([GoType("[]Color")] partial struct Palette), map, channel, numeric
([GoType("num:nint")] partial struct ΔSignal) — with exactly one exception: a named FUNC type arrives
as a C# delegate, which cannot be a partial struct, so its TypeKind.Delegate arm emits an adapter
CLASS in the declaring assembly instead. valueRecordRealizesAsPartialStruct gates on the target’s Go
underlying being a non-*types.Signature, at the use site where go/types can still see it. Without
that gate the fix hands a bare delegate to an interface slot — CS0029 for net/http’s
HandlerFunc → ΔHandler in expvar, net/http/cgi and three more.
The POINTER set shares the key and needs no trust gate. [assembly: GoImplement<T, Iface>(Pointer =
true)] is not “the declaring assembly implements this somehow” — it is exactly the shape
ImplementGenerator realizes as the public adapter class <T>ж<Iface>, so the record’s existence is
the answer and there is nothing further to ask. (The delegate hazard that forces
valueRecordRealizesAsPartialStruct on the value side cannot arise: a pointer record already means the
adapter route was taken.) An earlier ruling kept this set’s key un-collapsed on the reasoning that
matching a foreign record suppresses a LOCAL record the consumer needs; that hazard is real but it is a
realization question, not a key question, and on the pointer side it does not exist at all.
One consequence had to be fixed with the key, and only the collision-renamed types reach it: a foreign
type whose name is Δ-renamed resolves through a whole-TYPE global using alias (imageꓸRGBA =
go.image_package.ΔRGBA), which is a single identifier rather than a path — and the adapter is a MEMBER
of the declaring package’s class, so composing onto the alias names nothing (imageꓸRGBAжImage, CS0246
×11 across five packages). The foreign-adapter arm therefore rebuilds a dotless base as the file’s
package qualifier plus the type’s EMITTED simple name — image.ΔRGBAжImage, which is exactly what the
declaring assembly’s generator composed (image/image.cs reads new ΔRGBAжImage(…) for its own casts).
Pointer footprint, from a whole-stdlib A/B with both roots seeded (304/304 converted per side):
31 files, 66 constructions, every one the same edit — new <pkg>_<T>ж<Iface>(x) becomes
new <pkg>.<T>ж<Iface>(x), the declaring package’s own adapter — plus the 37 (Pointer = true) records
that existed only to generate those local classes. Zero additions anywhere and the total adapter-
construction census is unchanged at 4348, so it is a one-for-one redirection, not a removal. By
declaring package: text/template/parse→Node 33, go/types→Object/ΔType 20, image→Image 4,
net/http→RoundTripper/ΔHandler 4, net/url→error 2, net/textproto→error 1,
go/internal/srcimporter→types.Importer 1, go/build/constraint→Expr 1. go2cs-stdlib.slnx
builds 0 errors on the overlaid tree.
The pointer form’s symptom is milder than the value form’s and worth stating precisely, because it is
what makes this an increment rather than a bug fix. The generated adapter’s Equals compares
IжAdapter.Box by reference, so a redundant local adapter and the declaring assembly’s own one still
compare equal and still alias the same object — no observable divergence was reproduced. What is wrong
is duplication plus a non-deterministic dynamic type: each adapter’s module initializer calls
AdapterRegistry.Register(typeof(ж<T>), typeof(Iface), …), which is first-wins, so which assembly’s
class a type-assert re-wraps into depends on assembly load order. The value form’s second-identity
failure (image/png’s %v, below) is the same defect one degree worse.
Footprint, from a whole-stdlib A/B with both roots seeded (302/302 converted per side): 13 files,
every changed line the same edit — new <pkg>_<T>ᴠ<Iface>(x) becomes x — plus the 16
[assembly: GoImplement] records that existed only to generate those adapters. 497 constructions
go away (472 of them in image/color/palette’s two palette literals); the rest of the corpus adapter
census is identical count for count, HandlerFuncᴠΔHandler included. Two survivors are instructive
because they are NOT this defect: color.Palette→color.Model (5) and encoding/binary’s
bigEndian/littleEndian→ByteOrder (79) have no record to match at all — neither package ever
converts that pair itself, so nothing writes the record and the local adapter is the only realization.
A pair a package satisfies but never records is a separate root, closed by the section below.
This is not merely a wasted allocation. The adapter is a second identity for one Go value: reflect
and fmt see the wrapper where the Value’s own type says the wrapped struct, which is how it surfaced —
image/png’s diff printing %v of a color.Color died with System.ArgumentException: Field 'R' … is
not a field on the target object which is of type 'go.image_package+color_NRGBAᴠColor'. (Guarded by the
ForeignValueImplementSuppression behavioral test — a sibling package at a multi-segment path that
converts its own values, a collision-renamed implementer, a second implementer, and a named FUNC type as
the live negative; the pre-fix converter emits five adapters where the fixed one emits the func’s alone.
ValueAdapterDynamicType was its byte-identical complement — its sibling never converts, so its four
adapters were real — until the declaring side began recording pairs it merely satisfies (next section),
which is exactly that sibling’s shape; its assertions now prove the bare value instead. The pointer form
is guarded by ForeignPointerImplementSuppression, whose sibling tone self-converts a
collision-renamed *Tone and an ordinary *Plain — both must reference tone’s own adapters — against
two live negatives that must keep minting their own: *Lone, a pair tone satisfies but never records,
and shade.Level, an interface with the same SIMPLE name as tone.Level. The pre-fix converter emits
four local adapters there where the fixed one emits the two negatives’ alone. Unit-guarded by
TestImplementRecordKeyBothCompositionsAgree,
TestImplementRecordKeyKeepsPackageClassDiscrimination and TestValueRecordRealizesAsPartialStruct.)
A package records the pairs it SATISFIES, not only the ones it witnesses
Every [assembly: GoImplement<T, Iface>] the converter writes comes from a cast it converted —
convertToInterfaceType records the pair it just emitted. Go satisfies an interface structurally,
so a package can implement one of its own interfaces completely and never write a conversion:
encoding/binary declares type bigEndian struct{} with the whole ByteOrder method set and exports
var BigEndian bigEndian, with no var _ ByteOrder = BigEndian anywhere. No cast, no record — so
binary_package.bigEndian was emitted as a partial struct that does not implement ByteOrder, and
every consumer minted its own binary_bigEndianᴠByteOrder adapter. This is the one place where the
declaring assembly implements this pair is TRUE in Go and FALSE in the emitted C#, and it is the root
the section above measured but did not close.
recordSamePackageImplements (samePackageImplements.go, called from processConversion after
the file visits and before writePackageInfoFile) walks the package scope and records the VALUE-form
pairs the package satisfies. encoding/binary’s metadata gains:
// <InterfaceImplementations>
[assembly: GoImplement<bigEndian, AppendByteOrder>]
[assembly: GoImplement<bigEndian, ByteOrder>]
[assembly: GoImplement<littleEndian, AppendByteOrder>]
[assembly: GoImplement<littleEndian, ByteOrder>]
[assembly: GoImplement<nativeEndian, AppendByteOrder>]
[assembly: GoImplement<nativeEndian, ByteOrder>]
// </InterfaceImplementations>
and every consumer hands over the bare value, its own record and adapter gone with it — debug/dwarf’s
d.Value.order = new binary_bigEndianᴠByteOrder(binary.BigEndian) becomes
d.Value.order = binary.BigEndian, and crypto/x509’s
crypto.SignerOpts signerOpts = new crypto_HashᴠSignerOpts(hashFunc) becomes
crypto.SignerOpts signerOpts = hashFunc.
It records through convertToInterfaceType with an EMPTY expression — the record-only probe path
convCompositeLit / convTypeAssertExpr / visitValueSpec already use, since every emission arm is
gated on exprResult != "". That is the whole design: a synthesized pair is composed, keyed and pruned
exactly as a real cast would compose, key and prune it, so no second naming path can drift from the cast
site’s — the divergence that made the FOREIGN lookup miss for six weeks. Scope names arrive sorted, so
the record order is deterministic across runs.
Five gates bound it, and each one is load-bearing.
- The interface is EXPORTED. A record is a CROSS-ASSEMBLY contract — it exists so another assembly’s cast can drop its local adapter — and no other assembly can name an unexported interface, so a record for one could never be consulted. The package’s own casts already record what it needs internally.
-
The target’s underlying is NOT a
*types.Signature. A named FUNC type is a C# delegate, which cannot be a partial struct, soImplementGeneratoremits an adapter CLASS for it; a consumer trusting THAT record hands a bare delegate to an interface slot (CS0029 — net/http’sHandlerFunc→ΔHandler). This is the declaring-side half ofvalueRecordRealizesAsPartialStructabove. -
Neither side is GENERIC. A type argument cannot appear in an assembly-attribute type argument
(CS0246) — the same exclusion
convertToInterfaceType’stargetIsOpenGenericmakes. - Both sides are declared in a file this run CONVERTS. A package scope holds every file’s declarations, including build-constraint-excluded ones, and a record naming a type no emitted file declares is CS0246.
-
Every interface method is REALIZABLE by the generator — it resolves on the type itself or
through at most ONE embedded field (
types.LookupFieldOrMethodindex length ≤ 2).ImplementGeneratorforwards a promoted member through a single embed hop and says so (“Go’s promotion ambiguity rules make multi-embed satisfaction rare; extend when needed”), so a deeper promotion emits a forwarder through the WRONG hop:CrossPkgUser’srigembedsCrossPkgLib.Device, which embedsSensor, whereLabellives, andCrossPkgLib_package.Label(this.Device)is CS1503 for want ofthis.Device.Sensor. Promotion through an embedded INTERFACE is the common shape this still admits (sort’sreverseembedsInterface;debug/macho’s segment types embedLoadBytes). The bound is deliberately CONSERVATIVE rather than a model of the generator’s exact reach — it costs exactly two stdlib records (net’stcpConnWithoutReadFrom/tcpConnWithoutWriteTo→Conn, whose*TCPConnhop the generator’sembedHopDeepPathsarm can in fact follow), neither of which has a consumer, and withholding a speculative record is always safe: the consumer keeps the adapter it had before.
The gates bind only the SPECULATIVE recorder. A pair the source actually casts is DEMANDED and still records at its cast site — promotion depth and all — so none of this narrows existing behavior.
The POINTER method set was deliberately out of scope here — types.Implements(*T, Iface) is the far
larger set, its records are adapter-class existence signals with a different trust rule, and it was owed
its own increment with its own measured footprint. That increment has since landed; see the next section.
Footprint, from a whole-stdlib A/B with both roots seeded (302/302 converted per side, 3690 files
compared CRLF-normalized): 68 files, split evenly between metadata and code. 33 records appear
across sixteen declaring packages; 31 go away — three dropped by the existing interface-inheritance
prune because a newly recorded pair subsumes one a cast had recorded (flag’s textValue→Value under
Getter, and net/runtime’s errorString→error under their own ΔError, which embeds it), and
twenty-eight consumer-local foreign records that existed only to generate an adapter. 89 adapter
constructions disappear across 34 files — exactly the census the previous section predicted, pair for
pair: binary_bigEndianᴠByteOrder 43, binary_littleEndianᴠByteOrder 36, color_PaletteᴠModel 5,
crypto_HashᴠSignerOpts 5. Every changed consumer line is the same edit, the adapter construction
unwrapped to its argument; the full stdlib solution builds with 0 errors.
Most of the 33 new records have no consumer today — they are the rule stating what Go already says
(sort’s reverse→Interface, image’s Rectangle→RGBA64Image, debug/macho’s five Load
implementers, io’s discard→StringWriter), and they cost one assembly attribute each. Notably
ABSENT is net/http’s HandlerFunc→ΔHandler: the delegate gate holds on the corpus instance that
motivated it.
Guarded by the SamePackageImplementNoWitness behavioral test — a sibling ledger package that
declares an exported interface and value implementers and never converts one to the other, with its
negatives live rather than asserted (a named FUNC type, an unexported interface, a generic; the
pointer-only implementer was a fourth until the next section made it a positive). The pre-fix converter
records nothing and mints four adapters where the fixed one mints one; the delegate negative
(ledger_MeterᴠMetric) is byte-identical across the fix. CrossPkgLib/CrossPkgUser and
ValueAdapterDynamicType carry the same shape and re-baselined to the bare value. The realizability gate
is guarded by COMPILE rather than by a golden, and by the corpus case that found it: CrossPkgUser’s
rig is the depth-2 promotion, so dropping the gate puts a GoImplement<rig, Labeled> back and the suite
goes red on CS1503 in the generated forwarder.
The POINTER method set records the same way, for a different contract
The section above closed the VALUE half of “the declaring assembly implements this pair is TRUE in Go and FALSE in the emitted C#” and named the POINTER half as owed. This is that increment.
Why it is not just “the same rule with a bigger set.” A value record and a pointer record are consumed
differently, and the difference decides both the gates and the failure mode. A VALUE record licenses an
IMPLICIT conversion: the declaring assembly’s partial struct T : Iface means a consumer hands over the
bare value and names nothing. A POINTER record is an adapter-class EXISTENCE signal — Pointer = true is
exactly the shape ImplementGenerator realizes as <T>ж<Iface> — and the consumer CONSUMES it by NAME,
emitting new pkg.TжIface(x) where it would otherwise mint its own pkg_TжIface.
Why cast-site sourcing is not good enough, stated as the bug it caused. Every record the converter
writes comes from a cast it converted, so a pair’s record lives or dies with the ONE body that happens to
witness it. syscall’s three Sockaddr{Inet4,Inet6,Unix} → Sockaddr pairs are witnessed by exactly one
method body, (*RawSockaddrAny).Sockaddr, and hand-owning that single function — which the blittable-
mirror work has every reason to want — silently dropped all three (Pointer = true) records, after which a
reconvert of net minted syscall_SockaddrInet4жΔSockaddr beside syscall’s own. Nothing failed to
compile; the L10 lane found it only because it re-converted a dependent and diffed. The pointer form’s
symptom is milder than the value form’s %v-over-the-wrapper crash — both adapters wrap the same box and
compare equal — but it is a NON-DETERMINISTIC dynamic type: each adapter’s module initializer calls
AdapterRegistry.Register first-wins, so which class a type assert re-wraps into follows assembly load
order. Sourcing the record from the METHOD SET makes the pair independent of which bodies a run converts,
which is the root fix rather than a rule about what may be hand-owned.
recordSamePackageImplements (the renamed recordSamePackageValueImplements) therefore asks BOTH
questions of every candidate and records both forms. The pointer set is a SUPERSET of the value set, so a
value-satisfied pair is recorded twice, and that is deliberate: Go’s T and *T are two dynamic types,
realized as the partial struct and the adapter respectively, and dropping the pointer record for such a
pair leaves a consumer’s var i Iface = &t with nothing to reference.
The gates are the same five, plus one. The added gate is the trust rule made mechanical:
-
BOTH sides are EXPORTED (
pointerRecordIsPubliclyRealizable).ImplementGeneratorscopes the adapter classpubliconly when the struct and the interface are each public,internalotherwise. A record is a cross-assembly contract, and this form’s contract is “this class exists and you may name it” — so a record naming an unexported participant advertises a class no consumer can reference (CS0122), an existence signal that is a lie. The value form needs only the interface gate because its contract is realized by a conversion that names nothing, which is why the two rules differ here and only here.
The realizability gate is not merely re-asked of *T — it is TIGHTENED, and that is the second place
the two forms genuinely differ. generatorCanForwardPointerMethodSet requires every interface method to
resolve DIRECTLY on the type (index length 1), admitting no promotion at all, where the value bound
admits one embed hop. A partial struct’s explicit implementation resolves a promoted member the way the
converter’s own call sites do; the ж adapter does not. Its promoted-member arms are keyed on embedded
POINTER fields (GetEmbeddedPointerHopNames), and with exactly one such field the single-hop arm
takes every unbound member unconditionally — which the generator says outright, and is right to, because
for a DEMANDED record that member’s promotion is what type-checked the cast. For a SPECULATIVE record it
is not: the member’s true source may be a different embed entirely.
StructPointerPromotionWithInterface’s MyCustomError is the corpus instance, and the go2cs.slnx build
found it rather than reasoning did. It embeds BOTH the Abser interface and *MyError; Abs is promoted
from the INTERFACE, but the adapter’s lone pointer embed is *MyError, so the generated forwarder bound
Abs against MyError — where the only candidate in scope was time.Abs(Duration): CS1929, in a
generated file, naming time from a test about struct promotion. Depth is not the discriminator (that
promotion is index length 2, which the value bound admits); the KIND of hop is, and modelling the
generator’s exact hop selection inside the converter would duplicate its internals in a second place —
the very drift this recorder’s design exists to prevent. So the bound is conservative in the same spirit
as the value one and safe in the same way: withholding a speculative record leaves the consumer with the
local adapter it already had. A pair the source actually CASTS is untouched and keeps the full promotion
support the generator was built for, which is what that behavioral test guards. A named FUNC type is
excluded before either question is asked, as before.
Footprint, from a whole-stdlib A/B with both roots seeded (304/304 converted per side; marker gate
54 marked files / 43 *_impl.cs companions / 0 violations on both roots): 75 files — 35
package_info.cs and 40 code — with 0 .csproj and 0 README.md moved. 184 records appear
across 22 declaring packages (go/ast 96, image 17, io 14, image/color 12, database/sql 8,
net 7, math/rand/v2 4, sort 3, …) and 117 go away, every one a consumer-local duplicate the
declaring assembly now owns: go/parser 49, go/types 28, go/doc 8, go/printer 5 (all of them
go/ast node types), net/http 4, the five debug/* + internal/xcoff readers’ io.SectionReader
pairs, net/http/httputil’s io.Pipe{Reader,Writer}, and one each for sync.Mutex→Locker,
image/color.RGBA64→Color and parse.BranchNode→Node. Net corpus movement is +67 pointer
records (1,071 → 1,138), not the 548 the deferral’s raw pair count suggested — most of that set was
already recorded from cast sites, and the gates take the rest.
318 adapter constructions are repointed across 40 files, every changed line the same edit —
new pkg_TжIface(x) becomes new pkg.TжIface(x) — which is the second-identity elimination measured:
318 sites that used to name a locally minted duplicate now name the declaring assembly’s one adapter.
There is no third family; a classifier over the whole diff reports zero unclassified added lines.
Guarded by SamePackageImplementNoWitness, whose *Tally → Metric pair moved from negative to positive
(the consumer now references ledger.TallyжMetric instead of minting ledger_TallyжMetric) and which
gained the negative this gate needs — tick, an UNEXPORTED target whose pointer set implements the
exported interface, kept live through ledger.Count and absent from ledger’s metadata. Also guarded by
ForeignPointerImplementSuppression, where Lone — a pair tone satisfies and never casts — flipped the
same way and is now that test’s proof that a record needs no witnessing cast, while its shade.Level
negative (a same-SIMPLE-named interface in another package, which must keep its local adapter) is
byte-identical across the change. Unit-guarded by TestPointerRecordIsPubliclyRealizable,
TestGeneratorCanForwardMethodSetDepthBound and TestPointerMethodSetSubsumesValueMethodSet.
Acceptance witness — the L10 probe, re-run to prove the absence of what it once measured. With
RawSockaddrAny.Sockaddr suppressed through manualConversionFuncs (a scratch build on each side, so
the only variable is the recorder), syscall and net were reconverted into seeded roots:
syscall’s (Pointer = true) Sockaddr records |
net’s six construction sites |
|
|---|---|---|
| pre-increment converter + suppression | absent (all three) |
new syscall_SockaddrInet4жΔSockaddr(…) — locally minted duplicates |
| post-increment converter + suppression | all three present |
new syscall.SockaddrInet4жΔSockaddr(…) — syscall’s own adapter |
The pre-increment row is the regression exactly as L10 measured it; the post-increment row is the same probe finding nothing to report. That is what makes the hand-own safe rather than merely discouraged.
Standard-library solution file (.slnx)
A whole-standard-library run (go2cs -stdlib) also emits a Visual Studio solution — go2cs-stdlib.slnx — at the output root (-go2cspath), so the freshly converted stdlib is openable / buildable as one unit immediately after a run, rather than depending on a hand-maintained solution that drifts. It is the auto-generated counterpart of the committed src/go2cs-stdlib.slnx, and its XML mirrors the format of src/go2cs.slnx (a <Configurations> block plus <Folder>/<Project> entries, CRLF line endings, no BOM). It references:
- every converted stdlib project it emitted (
core/<pkg>/<projectName>.csproj, grouped under a/core/folder), - any per-package test projects (
*_test.csproj, grouped under a/tests/folder — inert until Phase 4 emits them, and the folder is omitted entirely when there are none), - the shared
golibruntime (core/golib/golib.csproj), and - the
go2cs-gensource-generator/analyzer project (gen/go2cs-gen/go2cs-gen.csproj, under a/generators/folder).
The stdlib project list is gathered by walking the emitted core/ output tree (so it also picks up future test projects with no code change), and every project is emitted in stable alphabetical order for deterministic output. All paths are solution-relative (forward slashes) so the generated solution is portable — no absolute, machine-specific paths. The golib and go2cs-gen references use the same core\golib / gen\go2cs-gen layout the converted .csproj files already assume via $(go2csPath) (which resolves to $(SolutionDir)), so the solution locates them wherever those csproj references already resolve. The file is only rewritten when its content changes, so repeated runs are a no-op.
Recurse per-project solution file (.slnx)
A recursive end-user run (go2cs -recurse) instead emits one .slnx next to every converted .csproj (ModuleConverter.generatePerProjectSolutions), each over that anchor project plus its transitive converted dependencies + golib + the analyzer — no stdlib listed (the stdlib is referenced via $(go2csPath)core, pre-staged by deploy-core). Building the app’s own per-project solution thus builds the app and its whole converted dependency closure in one dotnet build, without the ~300-project stdlib solution. The anchor project is marked the Visual Studio default startup project (DefaultStartup="true").
Projects are grouped into three top-level solution folders that mirror the %GOPATH% layout, emitted in an enforced, deliberately non-alphabetic order:
-
/src/— the project(s) being converted (the app’s own main-module packages), -
/pkg/— their converted dependency packages (module-cache orreplacethird-party), then -
/core/— the go2cs runtime/generator projects (golib,go2cs-gen).
Each member is placed by import path (isMainModulePackage — the same rule that routes a package’s output to src\ vs. pkg\), so the solution folders agree with the on-disk parallel tree regardless of the .slnx-relative path shape. An empty folder is omitted — a dependency’s own per-project solution has no src package — mirroring how the stdlib solution drops its /tests/ folder when empty. Because the three folder names are unique leaves, no folder Id attribute is emitted (unlike the namespace-nested stdlib solution, whose duplicate leaves like crypto/internal require the hashed folderID). Paths are solution-relative forward slashes, CRLF line endings, no BOM; the file is rewritten only when its content changes. Rendered by buildRecurseSolutionXML (solutionGenerator.go), guarded by TestBuildRecurseSolutionXML, TestBuildRecurseSolutionXMLSkipsEmptyFolders, and the folder-order assertions in the TestRecurseSyntheticModule integration test.
Package-Level Variable Initialization Order
Go initializes package-level variables in dependency order (spec: “within a package, package-level variable initialization proceeds stepwise, each step selecting the earliest variable … that has no dependencies on uninitialized variables”), where dependencies are resolved through function calls and function literals referenced by the initializer. The default conversion emits a package var as a C# static field with an initializer — but C# executes static field initializers in textual order within one file of the partial package class and in an undefined order across files. Three dependency shapes therefore break at runtime while compiling cleanly (the Phase-3 → Phase-4 distinction in miniature):
-
Cross-file: syscall’s
var procSetFilePointerEx = modkernel32.NewProc("SetFilePointerEx")(syscall_windows.go) readsmodkernel32declared in zsyscall_windows.go — with the wrong file order the receiver box is nil (NullReference in the type initializer, the first Phase-4 crash of any program importingos). -
Cross-file through a function: syscall’s
var Stdin = getStdHandle(STD_INPUT_HANDLE)— the initializer calls a package function whose body reads zsyscall_windows.go’sprocGetStdHandle. Same failure, invisible to a direct-reference scan; dependencies must be resolved transitively through same-package function bodies (and func-literal bodies — an IIFE initializer executes at init time), exactly as Go’s own analysis does. -
Same-file forward reference:
var first = base + 1declared abovevar base = 41— C# readsbase’s zero value (silently wrong value, no crash).
The conversion: collectMovedInitVars (converter: initOrderOperations.go) walks types.Info.InitOrder — Go’s authoritative dependency-sorted initializer list — resolving each initializer’s same-package var dependencies transitively through package function/method bodies. An initializer moves when a dependency is cross-file, a same-file forward reference, or itself moved (a moved var is only assigned in the ctor, so its dependents can no longer stay field initializers regardless of layout). A moved var is emitted as a bare field plus a tiny init method beside it in its home file (so the rendered expression keeps that file’s using aliases), and a generated package_init.cs supplies the package class’s static constructor, calling the methods in InitOrder:
// syscall_windows.go
var procSetFilePointerEx = modkernel32.NewProc("SetFilePointerEx")
// syscall_windows.cs
internal static ж<LazyProc> procSetFilePointerEx;
internal static void initᴛprocSetFilePointerEx() { procSetFilePointerEx = modkernel32.NewProc("SetFilePointerEx"u8); }
// package_init.cs (generated)
partial class syscall_package {
static syscall_package() {
initᴛprocSetFilePointerEx();
// … every relocated initializer, in types.Info.InitOrder …
}
}
This is correct by C#’s own initialization guarantees: all static field initializers (every partial-class file) run before the static-constructor body, so every non-relocated dependency is already initialized when the ctor runs; the ctor then applies the relocated initializers in Go’s order. Vars with no order hazard (the overwhelming majority — only 25 of the 302 stdlib packages relocate anything) keep their readable inline form. Cross-package order needs no handling: accessing another package’s static field triggers that type’s initialization first (.NET guarantees), matching Go’s imported-packages-first rule. Adding an explicit static ctor also removes beforefieldinit from the package class, giving it precise initialization semantics.
Notes: a blank (_) initializer never relocates (its value is unreadable, so its order is immaterial — it still runs as a field initializer for its side effect); the initᴛ method name composes the TempVarMarker so it cannot collide with any Go identifier; an addressed global relocates as a default-valued heap box whose ctor assignment writes through the ref property into the same box; a tuple-deconstructing spec relocates as one unit (see the next subsection). The one remaining warn-and-stay-inline fallback is a moved PLAIN var whose initializer carries a multi-value hoisted inner call (globalDeclHoist — the template.Must(template.New(…).Parse(…)) spread shape); the whole-corpus census found zero flagged occurrences of it. Guarded by the PackageVarInitOrder behavioral test (all three hazard shapes plus IIFE and moved-dependency closure, output-compared vs Go).
A TUPLE-deconstructing package var relocates as ONE unit
A package-level var a, b = f() — one multi-value call deconstructed across the names — is a single initialization step in Go: types.Info.InitOrder carries one entry for the whole spec with every name in its Lhs, so collectMovedInitVars flags all of a spec’s non-blank names together under one shared ordinal, and the emission registers one initᴛ method per spec at that ordinal — writeOrderedInitCalls needs no new bookkeeping. Until 2026-08-11 this path refused to relocate (“unsupported for tuple specs”, warn and leave inline), on a “no stdlib occurrence” premise the census falsified: crypto/internal/edwards25519’s var identity, _ = new(Point).SetBytes(…) and var generator, _ = … reach feOne and d — declared later in the same file — through the package’s own (*Point).SetBytes, so the inline field initializers ran first, identity read a null feOne, field.Subtract null-dereferenced, and the package cctor threw before any test ran (the corpus’s only whole-package casualty: 0 of 55 verdicts, restored to 52 of 55 by the relocation — the three residuals are separate pre-existing roots). Census: exactly two production occurrences on Windows (both edwards25519) and two latent on darwin (os’s executable_darwin.go); full detail in docs/phase4/FINDING-init-order-tuple-specs.md.
Two emission sub-shapes (writeMovedPackageTupleVarSpec, visitValueSpec.go), both turning every name into a bare field:
One non-blank name (the edwards25519 shape): the method assigns its component directly from the once-run call; blank siblings keep their uninitialized _ᴛNʗ fields — the call now runs in the ctor, so no blank ever carries it (from the InitOrderTupleSpecs golden, var single, _ = makeGreeting()):
internal static @string single;
internal static error _ᴛ1ʗ;
internal static void initᴛsingle() { single = makeGreeting().Item1; }
Two or more non-blank names (darwin os’s var initCwd, initCwdErr = Getwd() shape; golden: var cwd, cwdErr = fakeGetwd()): the method evaluates the call once into a method-local and assigns each non-blank component from it — the inline path’s hidden static tuple holder is unnecessary, because the method body itself sequences the call before its reads. The local reuses the holder’s minted tupleᴛNʗ name shape so it cannot collide with anything the rendered call expression references:
internal static @string cwd;
internal static error cwdErr;
internal static void initᴛcwd() { var tupleᴛ1ʗ = fakeGetwd(); cwd = tupleᴛ1ʗ.Item1; cwdErr = tupleᴛ1ʗ.Item2; }
An all-blank spec (var _, _ = f()) never relocates — blanks are excluded from the moved set, so it keeps the inline emission where the first blank’s field initializer carries the call for its side effect. An addressed name relocates as the default-valued heap box with the method assignment writing through the ref property, exactly like the plain path. A blank in the middle of a spec simply drops out of the assignment list (the golden’s var head, _, tail = makeTrio() assigns .Item1 and .Item3). (Guarded by the InitOrderTupleSpecs behavioral test — both sub-shapes, the mid-spec blank, a plain var chained onto a moved tuple var, an addressed moved tuple var, and an order-safe inline control, output-compared vs Go — and by the converter unit test TestPackageTupleVarSpecInitOrderRelocation, which additionally pins that the refusal warning no longer fires and that an order-safe spec keeps the inline holder emission.)
Test-variant (-tests) initializers relocate too — through the erasable static-ctor hook
The same pass runs in the -tests conversion driver (the three-drivers rule): a _test.go package-level var whose initializer reads a var declared later in the file, in another file, or in the production package cross-file has exactly the same hazard — internal/fmtsort’s compareTests reads chans/ints declared 170 lines below it, so every test died in the class initializer on the default slice; encoding/gob’s basicTypes table reads production type.go vars cross-file. The relocated emission is unchanged (bare field + initᴛ<name> method beside the declaration); only the ordered-constructor site differs by variant, because a C# class gets ONE static constructor across all partial parts:
-
External variant (
<pkg>_test— its own<pkg>_test_packageclass): a generatedpackage_init_external_test.cssupplies the class’s static constructor directly, exactly likepackage_init.cs. -
Internal variant (test files join the production
<pkg>_packageclass): when the productionpackage_init.csexists it already owns the single static-ctor slot, so under-testsits constructor ends with a call to an erasable classic partial method —static partial void initᴛᴛtests();— and the test conversion emitspackage_init_internal_test.csimplementing it with the test-side relocations. Unimplemented (the production compile set excludes*_test.cs), C# erases both the declaration and the call, so the production assembly is untouched at runtime. Test relocations therefore run after every production relocation — semantically safe, since a production initializer can never depend on a test var. When the production package relocated nothing, the internal test variant claims the static ctor itself.
Two hard-won rules ride along: the hook name doubles the TempVarMarker (init + ᴛᴛ + tests, Symbols.PackageTestInitHookMethod) so no relocated var’s initᴛ<name> method can collide; and go2cs-gen’s PartialStubGenerator must never stub the hook — its whole design is C# erasure when unimplemented, and the generator’s NotImplementedException stub for “asm/cgo” bodyless partials detonated encoding/gob’s static ctor for every consumer of the production assembly (go/token’s serialize path) until the generator learned to skip it (guarded by PartialStubGeneratorTests.StubsAsmPartialsButNeverTheTestInitHook). The hook emission is -tests-gated, so a plain -stdlib/behavioral conversion is byte-identical — like the IP-4 csproj test-artifact exclusions, the production-file difference is intended -tests output, not drift. The relocated test files themselves ride the banked suites (fmtsort’s cctor is the operational guard). The hoisted-literal interplay keeps its conservative setting (initOrderRelocated=false for the real test emission run): suppressing test-file hoists in initializer-reachable functions is a pure allocation pessimization, never a correctness risk, and flipping it would drift every banked *_test.cs.
A CONSTANT emitted as an initialized FIELD is an initialization dependency too
Go constants are compile-time values with no initialization order at all, so types.Info.InitOrder never mentions them — and for almost every constant that stays true in C#, because a constant C# cannot declare const is emitted as a get-only property rather than a field (see Constant Values). A property re-evaluates at each read, so it can never be observed at its zero value, whatever the declaration order.
Two forms cannot be properties, because re-evaluating them would rebuild an allocation on every read: a string constant (@string, or a [GoType("@string")] wrapper such as const labelPipe label = "pipe", whose u8 literal the string-literal arc deliberately hoists to a single allocation) and a GoBigConst constant (a BigInteger.Parse). Those two stay static readonly fields with initializers, and therefore carry exactly the cross-part field-initializer ordering hazard a package var has. The relocation analysis has to supply that dependency edge itself, because Go’s own analysis has no reason to model it:
// server.go — a plain string const, so a `static readonly @string` FIELD
const TimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
// header.go — sorts BEFORE server.go in the compile set
var timeFormats = []string{TimeFormat, time.RFC850, time.ANSIC}
Read before its initializer runs, TimeFormat is the empty @string, and net/http’s date parsing silently loses its primary format. collectRefs records package-scope *types.Const references whose emission is an initialized field (constEmittedAsInitializedField, keyed off the constant’s value kind — string always; int, float and complex only where the magnitude escapes to GoBigConst), resolved transitively through function bodies on the same memoized graph the var closure uses. A const dependency forces the move under the same two rules a var dependency does: declared in a different file, or later in the same file. A const is of course never itself relocatable, so only its declaration site matters.
The emission side needed the matching half. Relocation lived only in the non-constant initializer arm (tv.Value == nil), yet a Go-constant-valued initializer is just as order-sensitive in C#: Go folds the value at compile time, but the conversion deliberately keeps the source expression for readability, and that expression still reads the field:
// registry.go
type label string
const labelPipe label = "pipe"
// entries.go — compiled first; the initializer folds to the constant "pipe!" in Go
var pipeLabel = string(labelPipe) + "!"
// entries.cs — relocated, because the rendered expression READS the labelPipe field
internal static @string pipeLabel;
internal static void initᴛpipeLabel() { pipeLabel = ((@string)labelPipe) + "!"u8; }
Where the edge came from, and what it kept. The defect that exposed it was regexp/syntax’s opNames — a 19-entry table index-keyed by the Op constants declared in a later file, every key read as zero, the whole table collapsing to one slot so Parse("a").Dump() printed the numeric fallback op3{a} where Go prints lit{a}. Op is a named uint8, so the property rule now removes that hazard at the source, and an over-broad predicate naming every non-const form would draw an edge for a shape that no longer has one. Scoped to the residue, the edge’s corpus footprint is two packages: internal/buildcfg relocates five initializers (GOARCH, GOOS, GO386, GO_LDSO, Version — each envOr("…", default…) over a string const in zbootstrap.go) and net/http relocates timeFormats. Both were latent zero-value const reads that compiled cleanly.
Guarded by the PackageVarInitOrder behavioral test, which carries both halves: neutering the predicate collapses pipeLabel onto an empty @string (stdout mismatch), and neutering the property rule allocates a fixed-array field at length 0 and panics on the first indexed write (exit code 2). Neither is redundant.
Compiled Library versus Source Code
One big difference between Go and many other languages is the notion of source availability. Traditionally programming languages have depended on using a pre-compiled library — both to avoid recompiling the library and to protect source as intellectual property. Go was born in an era of faster computing and prolific open source; it relies on having access to all source at compile time, including library code. Go takes advantage of this to make interesting optimizations, especially around when a structure escapes the stack to the heap. Keeping structures off the heap means they do not need to be tracked for garbage collection, and the Go compiler manages this automatically. The interesting consequence is that, for a given use of a library as source, an application structure may or may not escape to the heap depending on how it flows through the code — an optimization only possible when all source is compiled together.
Because this is a complex optimization, the converter currently assumes structures can escape to the heap except in the simplest-to-detect cases (see Pointers). A future option could distinguish optimizations targeted at a compiled library (very safe escape analysis) versus a standalone application (more aggressive). Already-converted packages are referenced as compiled libraries — the consumption model most C# developers are accustomed to — which takes two pieces, both in place: -recurse=nuget maps each imported Go package to its published go.<pkg> package reference, and the exported metadata a consumer would otherwise scrape out of the dependency’s converted source travels with the converter (see A NuGet-referenced standard library carries its exported metadata IN THE CONVERTER).
Constant Values
Go constants hold arbitrary-precision literals with expression support, and assignment of a constant to a variable happens at compile time. The converter preserves the constant value (and, in a comment, the original expression). A typed Go constant is emitted with its concrete C# type, e.g.:
public const nint MaxRetries = 3;
An untyped Go constant is emitted using a golib “untyped” wrapper type — UntypedInt, UntypedFloat, or UntypedComplex — so it can hold a value that does not fit a single primitive and can implicitly adapt to whatever numeric type its use site requires (mirroring how an untyped Go constant takes its type from context). A [GoType] struct is not a legal C# constant type, so the declaration is a get-only property rather than a field (see the next subsection for why):
internal static UntypedInt win => 100;
public static UntypedInt N => /* 11 + 1 */ 12;
A constant C# cannot declare const is a get-only PROPERTY, not a static readonly field
A Go constant has no initialization: it is a compile-time value usable from anywhere in the
package regardless of declaration order. Whenever C# can say const (a primitive-typed const) that
property is preserved for free. It cannot for the wrapper types above, nor for a named type, a
uintptr, or a complex — and a static readonly field reintroduces exactly the initialization
Go does not have. C# runs static field initializers in class-textual order (across a partial
class, in <Compile>-item order), so a package-level variable declared ahead of the constant read
it as the type’s DEFAULT — silently, with no diagnostic:
var fixedHuffmanDecoder huffmanDecoder // compress/flate/inflate.go — declared FIRST
...
const huffmanNumChunks = 1 << huffmanChunkBits // …the const it transitively needs comes LATER
type huffmanDecoder struct { chunks [huffmanNumChunks]uint32 }
As a field, huffmanNumChunks was still 0 when that variable’s new huffmanDecoder() ran its
chunks = new(huffmanNumChunks) field initializer, so the decode table was allocated at length 0
where Go says 512: init filled nothing (its for off < len(h.chunks) loop never ran) and every
later chunks[i] read panicked with index out of range [281] with length 0. The same trap zeroed
maxNumLit for fixedLiteralEncoding’s initializer across files, emptying the fixed Huffman code
table. Both are silent-correctness defects that compiled clean, and both took down every dependent
package (compress/gzip, compress/zlib).
A get-only property carries no initialization at all, so declaration order cannot be observed and the JIT folds the literal at each use — Go’s semantics exactly:
internal static UntypedInt huffmanNumChunks => /* 1 << huffmanChunkBits */ 512;
public static ΔKind Uintptr => 12; // named-type const
internal static uintptr MaxUintptr => unchecked((uintptr)18446744073709551615);
RESIDUE — the two ALLOCATING const forms stay static readonly fields: @string (whose u8
literal the string-literal arc deliberately hoists to a single allocation) and GoBigConst (a
BigInteger.Parse). A property would rebuild their value on every read. Neither can serve as an
array length, so neither reproduces the failure class above — but a package-level variable
initializer that reads one before its declaration point still would, so the residue is handed to
the initialization-order pass,
which draws a relocation edge for exactly these two forms and nothing else.
The two rules divide the problem cleanly and neither is redundant: the property form takes every
numeric, named-numeric, uintptr, complex and untyped constant out of the ordering problem, and
the relocation edge orders the string/GoBigConst residue that has to stay in it. Guarded together
by the PackageVarInitOrder behavioral test (cross-file constants consumed by earlier-declared
vars — directly, through a struct’s fixed-array field initializer, and through a constant-folded
initializer over a named-string const).
Wrapper conversions are VALUE conversions in every direction. UntypedInt stores its payload as
int64 bits (so a ulong-range literal like 9223372036854775808 round-trips through the same 8 bytes),
and its float/complex operators originally bit-reinterpreted that payload — var fl float64 = m (m an
untyped 3) produced 1.5e-323, the denormal double whose bit pattern is 3, instead of 3. The
float32/float64/complex64/complex128 operators now convert by value, like every integer-direction
operator always did. Because the payload can be unsigned-flavored (a beyond-int64 ulong literal such as
1 << 63 uses the uint64 constructor — bits identical to a signed -9223372036854775808), the wrapper
carries a payload-kind discriminator so a float conversion keeps the uint64 magnitude and ToString()
prints it unsigned. (Guarded by the UntypedIntFloatContexts behavioral test — one local const used in
both int and float contexts, the 1<<63 unsigned payload, complex contexts via real/imag, and a
negative payload, values verified vs Go.)
COMPARISON is a value comparison too, across payload kinds. The (payload, unsigned-flavour) pair
denotes a mathematical integer, and </<=/>/>=/equality must order over that VALUE — but they
compared the raw int64 bits, so a uint64 at or above 2^63 (which reads as a NEGATIVE int64)
answered u < someSmallConst TRUE. That is a silent wrong answer for the whole
if fastSmalls && i < nSmalls idiom the standard library uses: strconv.FormatUint(9223372036854775808, 10)
took the small-integer fast path, small() truncated the negative index, and the result was "0"
(TestUitoa); the varlen sibling threw on the truncated slice bound (TestFormatUintVarlen). The
operators now route through one Compare helper: same flavour compares at that flavour, and mixed
flavours put a NEGATIVE signed payload below every unsigned payload (otherwise both are non-negative
and the unsigned reading of each is exact). Only a payload at or above 2^63 changes answer — every
signed-only or in-int64-range comparison is bit-for-bit what it was. (Guarded by the
BigUntypedConstComparison behavioral test’s wrapper arm — 0/999/1000/1001/1<<62/1<<63/
1<<64-1 against a wrapper const across all six relations, the signed counter-cases, and the inlined
FormatUint fast-path predicate, output-compared vs Go; the test FAILS [Output] without the fix.)
The wrapper carries 64-bit shift operators so a still-wrapped untyped shift keeps Go’s width. UntypedInt defines << and >> (an int count) returning UntypedInt, shifting the int64 payload. Without them, a wrapper-typed untyped constant shifted by a non-constant count bound through the implicit UntypedInt → int conversion — a 32-bit int shift, which both masks the count to its low 5 bits and truncates the payload to 32 bits — whereas Go shifts at the type the untyped operand assumes from context (typically the enclosing uint64). math.Frexp corrupted on exactly this: x |= uint64((-1 + bias) << shift) (bias a package-level UntypedInt const, so the compound -1 + bias stays UntypedInt; shift a runtime int, up to 52) emitted ((-1 + bias) << (int)(shift)) and computed 1022 << (52 & 31) = 1022 << 20 instead of 1022 << 52, scrambling the exponent field of the assembled float64. The operators keep the shift a 64-bit long << int returning UntypedInt, so it composes with the surrounding (uint64) conversion and reproduces Go’s value. Note this fires only for a compound untyped operand (bias - 1): a bare-identifier operand (bias << n) is cast to the shift’s context result type at the use site first, so it never reaches the wrapper operator. (Guarded by the UntypedIntWideShift behavioral test — package-level UntypedInt consts bias/bits, the compound bias - 1 left-shifted and bits - 1 right-shifted by runtime counts 52/40/33 (all > 31, so a 32-bit narrowing would mask them), values verified vs Go.)
Float constant values are emitted exactly. The emitted value of a float-kind constant declaration is never go/constant’s Value.String() — that is a shortened human-readable form (~6 significant digits), and using it silently truncated the compiled value while the exact literal survived only in the /* … */ comment (math cbrt’s C = /* 5.42857142857142815906e-01 */ 0.542857). The emission prefers the Go source literal verbatim when it is also valid C# syntax — decimal floats including exponents and _ digit separators overlap C# exactly, and a unary-minus form (-7.05306122448979611050e-01) carries its sign — which also elides the now-redundant original-expression comment (the emitted value is the original). When no single valid literal exists — a folded constant expression (19.0 / 35.0), or a Go-only literal form (hex float 0x1p-2, trailing-dot 5.) — the value emits as the shortest round-trip form (strconv.FormatFloat 'g'/-1) of the constant converted at the declaration’s width (bitSize 32 for a float32-typed const, so the f-suffixed single parses with the same one rounding Go applies; 64 otherwise):
const (
C = 5.42857142857142815906e-01 // 19/35 = 0x3FE15F15F15F15F1
D = -7.05306122448979611050e-01
folded = 19.0 / 35.0
)
internal static readonly UntypedFloat C = 5.42857142857142815906e-01;
internal static readonly UntypedFloat D = -7.05306122448979611050e-01;
internal static readonly UntypedFloat folded = /* 19.0 / 35.0 */ 0.5428571428571428;
A beyond-float64 value still routes to the GoBigConst (BigInteger) overflow path unchanged. (Guarded by the UntypedConstDefine behavioral test — package-level and function-local high-precision consts printed and compared against Go, which fails with the truncated 0.542857 emission; SortArrayType additionally locks the verbatim forms 1.0f/3.14e100.)
A FUNCTION-BODY float const referencing a named untyped-float const folds the same way (2026-07-18). The exact-emission above is a declaration rule; the identical double-rounding hazard exists for a compile-time float constant computed in a FUNCTION body that references a named untyped-float const (math.Pi, math.Ln10 — each emitted as a golib UntypedFloat wrapper already rounded to float64). Left as runtime C# arithmetic, float64(100000 * Pi) becomes (float64)(100000D * Pi) and rounds a SECOND time — 314159.2653589793, a ULP below Go’s single arbitrary-precision fold 314159.26535897935 (math’s TestLargeCos was fed a −1-ULP argument; the trig algorithm itself is bit-exact), and 1 / Ln10 in Log(x) * (1/Ln10) the same. foldedNamedFloatConstLiteral emits the Go-folded value as a /* <expr> */ <literal> at the RESOLVED float width, at two sites: a float64(…)/float32(…) type CONVERSION (convCallExpr, restricted to a basic float target so a named float type keeps its [GoType] wrapper path) and a computed-const OPERAND being cast to a concrete float in a binary expression (convBinaryExprCore, reusing the sibling’s resolved type). The width is honored exactly — a float32 target rounds the exact constant STRAIGHT to float32 (exactFloatText’s constant.Float32Val), never through a float64 intermediate, which would double-round differently than Go’s single round-to-float32. Gated to a COMPUTED float const that references a named untyped const: a bare named-const reference already renders as a single-rounded wrapper, and a pure-literal float const (1.5 * 2.0, no named ref) computes exactly in C# double and keeps its readable operator form. Cleared math’s TestLargeCos/Sin/Tan/Sincos + TestLog10 (68 → 73 / 77) by folding 34 sites across the sin/tan/atan/erf/jN/lgamma/pow families; the emission is a no-op on the behavioral corpus (nothing there exercises the pattern). (Guarded by the NamedConstFloatFold behavioral test — a float64(100000*myPi) conversion, its float32 counterpart, and a 1/myLn10 typed-float64 operand, output-compared vs Go.)
Function-local untyped constants TIGHTEN to their single concrete use type. A per-function analysis pass (performUntypedConstAnalysis) resolves every use of each function-local untyped numeric constant through go/types: when ALL uses record the SAME concrete basic type — and none participates in constant folding — the declaration emits at that type (with C#’s const keyword where legal: the primitive aliases; native-int/uintptr values fall to the existing static readonly/unchecked demotions), and every cast the wrapper made necessary (the bitwise/arith operand casts, the append/deferred-call element casts, the 32-bit-and-wider shift retype) is skipped as redundant. One cast is kept because it is value-changing, not wrapper-driven: a tightened const of a sub-int32 type (int8/int16/uint8/uint16) as the LEFT operand of a non-constant shift keeps the width retype — C# promotes a narrow shifted operand to int, so without (byte)(cb << (int)(k)) Go’s wraparound at the declared width is lost (const cb = 200; b + cb<<k → Go wraps 200<<1 to 144, byte width, result 145; the promoted C# shift computes 400 → 401). The emitted code otherwise reads like the Go source — math cbrt:
const (
C = 5.42857142857142815906e-01 // 19/35 = 0x3FE15F15F15F15F1
G = 3.57142857142857150787e-01 // 5/14 = 0x3FD6DB6DB6DB6DB7
)
s := C + r*t
t *= G + F/(s+E+D/s)
const float64 C = 5.42857142857142815906e-01; // 19/35 = 0x3FE15F15F15F15F1
const float64 G = 3.57142857142857150787e-01; // 5/14 = 0x3FD6DB6DB6DB6DB7
var s = C + r * t;
t *= G + F / (s + E + D / s);
The guards are deliberately conservative — any doubt keeps today’s Untyped* wrapper form:
- Function-local only (package-level constants keep the wrapper: they are visible across functions whose contexts may differ, including from other files of the package).
-
Untyped integer/rune/float/COMPLEX, excluding any value on the
GoBigConst(BigInteger) path. (Untyped complex was excluded whilecomplex128had no non-constemission; a representable complex const now emitsstatic readonlylikeuintptr, and the wrapper it replaces is actively wrong for it — see A complex constant emits a complex VALUE below. A complex use type tightens only an untyped-COMPLEX const: the converter never widens an untyped integer const tocomplex128because one use happens to be complex-typed.) -
Every use must record a concrete numeric basic type in go/types’
Info.Types, and all uses must agree on ONE basic kind. go/types records the implicit-conversion target for an untyped operand (float64forC + xwithx float64), so a use that stays untyped (another constant’s initializer), resolves to a NAMED type or type parameter, or records a non-numeric type (string(c)) disqualifies — as do mixed concrete types (float64andfloat32uses of one constant). -
No use may participate in constant folding: an ancestor expression carrying a folded constant value (
uint64(B1) << 32— cbrt’s B1/B2 stayUntypedInt) disqualifies. Go folds untyped constant expressions at arbitrary precision; re-expressing an operand at a concrete C# type could change the folded result (or re-fold it in C#’s checked int32 arithmetic). - The exact value is re-checked representable in the resolved type (belt-and-braces — go/types already validated each use’s conversion).
A tightened constant composes with the exact-float emission above (the cbrt literals round-trip to their documented bit patterns, e.g. C ↔ 0x3FE15F15F15F15F1), with the iota initializer (a position-0 = iota tightened to nint emits golib’s constant bare — see the bare-iota rule below; any other tightened type keeps the folded value with the /* iota */ comment), and with a float-KIND value under an INTEGER tightened type — const infinity = 1e6 (go/printer; 1e6 lexes as a float literal) used only in int contexts emits the integer form const nint infinity = 1000000;, since a C# 1e6 double literal has no implicit conversion to nint and the tightening pass guaranteed integral representability. (Guarded by the UntypedConstDefine behavioral test’s tightenGuards — single-type/append/defer/shift-operand uses tighten, mixed-type/const-feeding/folding uses keep the wrapper, and the narrow byte/int16/uint16 shifted consts keep the width retype (145, not 401), all output-compared vs Go — and by BitwiseUntypedConst, whose local signBit = 1 << 63 now emits const uint64 with the (uint64) operand casts dropped; ConstShadowsParam locks the shadow-rename interplay, its folded int64(ns) uses staying untightened.)
A complex constant emits a complex VALUE, rendered from its two exact halves
A COMPLEX-kind constant is emitted as a real complex value built from its real and imaginary parts, each rendered by the same exact-float machinery a float const uses (exactFloatText) and recombined in the postfix .i() form convBasicLit already emits for written imaginary literals. Because complex128 is System.Numerics.Complex and complex64 is a golib struct — and C# forbids const of a library struct (CS0283) — the declaration is static readonly, the same demotion uintptr takes:
const (
cRational = 5.5 + 1.5i
cNegImag = 2.25 - 0.75i
cPureImag = 3i
cWideEnough = 1.5e308 + 1.0e307i
cFolded = (1 + 2i) * (3 + 4i)
)
const c64 complex64 = 1.5 + 2.5i
internal static readonly UntypedComplex cRational = /* 5.5 + 1.5i */ 5.5D + 1.5D.i();
internal static readonly UntypedComplex cNegImag = /* 2.25 - 0.75i */ 2.25D + -0.75D.i();
internal static readonly UntypedComplex cPureImag = /* 3i */ 3D.i();
internal static readonly UntypedComplex cWideEnough = /* 1.5e308 + 1.0e307i */ 1.5e+308D + 1e+307D.i();
internal static readonly UntypedComplex cFolded = /* (1 + 2i) * (3 + 4i) */ -5D + 10D.i();
internal static readonly complex64 c64 = /* 1.5 + 2.5i */ 1.5F + 2.5F.i();
The receiver’s F/D suffix selects the golib i() overload (i(this float) → complex64, i(this double) → complex128) exactly as a written literal’s does, and each half’s implicit float→complex conversion closes the +. A ZERO real part renders as the bare imaginary literal (3D.i() — the Go source form of 3i); a NEGATIVE imaginary part composes as written, because member invocation binds tighter than unary minus, so 2.25D + -0.75D.i() is 2.25 + −(0.75·i).
Why the halves are tested individually. Representability was previously decided by handing go/constant’s Value.ExactString() to strconv.ParseComplex. That text is the parenthesized RATIONAL form — 5.5+1.5i is (11/2 + 3/2i) — which is neither C# syntax nor a form ParseComplex accepts (its grammar is Go literal syntax: no parentheses, no spaces around the sign, no p/q). The test could therefore never succeed: every complex constant, however ordinary, was classified as beyond-complex128 and emitted through the GoBigConst arm — whose BigInteger.Parse cannot represent a complex at all. strconv’s atoc_test.go (const want = 1.5e308 + 1.0e307i, a value that fits complex128 with room to spare) failed to compile on c != want (CS0019, Complex vs GoBigConst). Each half is now rendered and range-tested on its own at the declaration’s element width; only a genuinely unrepresentable value keeps the GoBigConst arm, and that emission now warns, because it is knowingly lossy.
A FUNCTION-LOCAL untyped complex const additionally tightens to its single concrete use type (the tightening pass above), which the wrapper form cannot substitute for: UntypedComplex converts implicitly to and from complex128, so comparing a wrapper-typed const against a complex128 is AMBIGUOUS (CS0034) — atoc_test’s TestParseComplexIncorrectBitSize is exactly that shape, and it emits complex128 want = …. (Guarded by the ComplexConstContext behavioral test — rational halves, a negative imaginary part, a pure imaginary, the beyond-1e308-real strconv shape, a folded complex expression, a complex64-typed const, and a function-local const, all output-compared vs Go.)
A const initialized by exactly the builtin iota emits golib’s constant bare when it can express the value. golib’s builtin declares public const nint iota = 0 (golib/builtin.cs), so the initializer emits as bare iota — instead of the folded comment form /* iota */ 0 — only when BOTH halves of that declaration match: the folded group-position value is 0 (position 0 of the Go const group) AND the emitted C# type accepts golib’s nint constant, i.e. the emitted type is nint (an explicit Go int type, or a function-local untyped const tightened to it) or the UntypedInt wrapper (implicit from nint). Everything else keeps the folded form: a LATER group position folds to a value golib’s constant cannot express (x = iota at position 1 emits /* iota */ 1 — on the UntypedInt path a bare iota there would even compile, silently at the WRONG value), and any other emitted type — named wrappers (ΔKind Invalid = /* iota */ 0), other widths (int64) — keeps /* iota */ N rather than casting golib’s nint. (No current emission path casts an in-range position-0 const, so no (T)iota form exists; should one ever require a cast anyway, the cast would wrap iota rather than the folded value.) The identifier must resolve to the universe iota — a user-shadowed iota keeps the folded value. From compress/flate’s huffmanBlock states:
const (
stateInit = iota // Zero value must be stateInit
stateDict
)
const nint stateInit = iota; // Zero value must be stateInit
const nint stateDict = 1;
(Guarded by the IotaEnum behavioral test — position-0 bare iota at explicit int and via local tightening, the int64 mismatch both explicit and tightened, and later positions on both the nint and UntypedInt paths; the untyped later-position rawOne = iota case also locks the value fix — the prior emission referenced golib’s iota (0) for a position-1 constant (1) — and the named-wrapper enum stays folded; all output-compared vs Go.)
A Go untyped float constant defaults to float64, so its C# literal carries the double suffix D — not F — regardless of whether the value happens to fit in float32. (Emitting F whenever the value fit would make z := 1.0 a float, breaking later float64 arithmetic with CS0266.) A literal in an explicit float32 context keeps F:
z := 1.0 // untyped float -> float64
var f float32 = 2.5 // float32 context
var z = 1.0D;
float32 f = 2.5F;
The F-vs-D decision needs one more step inside a constant expression: go/types resolves the contextual type on the outermost constant expression only (its updateExprType deliberately never descends into constant operands — they never materialize at runtime in Go), so the inner literals of var b float32 = -3.5, of complex(2.5, -3.5) in a complex64 context, or of -(1.5 + 2.0) stay recorded untyped float and would fall back to the D default — emitting -3.5D where C# needs -3.5F (no implicit double→float32/complex64 conversion: CS0266/CS0019). Since the emitted C# preserves the operand structure, the converter re-propagates the resolved type down the constant shapes go/types dropped it from — parens, unary +/-, arithmetic binary operands, and the complex/real/imag/min/max builtin arguments, each mapping the context appropriately (a complex64 result makes complex(…)’s arguments float32; a float32 result makes real(…)’s argument complex64) — via markUntypedConstContexts (untypedConstOperations.go), which the literal emitter consults when the literal’s own recorded type is untyped:
var c64 complex64 = complex(2.5, -3.5)
var c64b complex64 = 2.5 - 3.5i
var a, b float32 = 2.5, -3.5
complex64 c64 = complex(2.5F, -3.5F);
complex64 c64b = 2.5F - 3.5F.i();
float32 a = 2.5F;
float32 b = -3.5F;
An imaginary literal is emitted in POSTFIX form — Go 3.5i becomes 3.5D.i(), the closest C# rendering of the Go literal — via golib extension methods on the suffixed real literal. The receiver suffix drives the overload choice: …F.i() (i(this float)) returns a complex64 and …D.i() (i(this double)) a complex128, so the suffix follows the literal’s resolved complex type per the same propagated context — replacing the earlier fits-in-float32 heuristic, which routed 0.1i in a complex128 context through complex64 and silently lost precision ((double)(0.1f) != 0.1). The postfix form exists because a bare i(…) call is poisoned by Go’s single most common identifier: a local or parameter named i in scope binds the bare call instead of the using-static golib helper (encoding/gob encComplex’s i *encInstr parameter, c != 0+0i → CS0149; C# block-scope rules make even a later-declared local poison an earlier bare call, CS0135/CS0844). Member access cannot bind a local, needs zero scope analysis, and — since the F/D suffix is emitted unconditionally — the receiver always lexes as a real literal (0D.i(), .25D.i(), 1e2D.i() all parse; a suffixless 3.i() would not). Member invocation also binds tighter than unary minus, so -3.5D.i() is -(3.5i) exactly as in Go, down to the negative-zero real part Go prints as (-0-3.5i). The prior solution was the class-qualified builtin.i(3.5D) — equally shadow-immune, replaced for readability; the static call form remains valid on the this-modified overloads. (Guarded by the UntypedConstFloatContext behavioral test — the shapes above plus nested parens, quotient operands, min/max, real/imag round-trips, and a named-float32 context, values verified vs Go — by ComplexImaginaryShadow — the gob-shaped shadowing parameter — and by ComplexFormat — complex printing round-trips.)
A golib companion to the above: UntypedFloat’s conversions to a complex type are EXPLICIT, not implicit. An untyped float constant emits as a golib UntypedFloat (see the untyped-wrapper section), and multiplying it into complex arithmetic — Go’s 1i * math.Pi, which the imaginary rule renders 1D.i() * math.Pi (Complex * UntypedFloat) — bound ambiguously while UntypedFloat converted implicitly to both float64 and complex128: the complex operand could bind as Complex * double (untyped → double) OR as UntypedFloat * UntypedFloat (the complex → UntypedFloat implicit conversion), and C# prefers neither (CS0034). The fix keeps UntypedFloat’s float↔complex relationship explicit in both directions (UntypedFloat.cs), so such arithmetic resolves cleanly to Complex * double — mathematically identical, since the untyped operand is a real value. This is compile-time only (an untyped float already converts implicitly to its natural float64; the widening to complex is the rarer direction, where the converter emits an explicit cast). Adding Complex-typed operators to UntypedFloat instead was rejected — it reintroduces ambiguity (UntypedFloat / int then matches both UntypedFloat op UntypedFloat and Complex op UntypedFloat via int’s dual conversions). This unblocked math/cmplx’s example build. (Guarded by the same UntypedConstFloatContext test’s 1i * gPi / gPi * 2i / 1i + gPi lines — a package-level untyped const, so the operand emits as UntypedFloat; the pre-fix golib fails these at compile with CS0034.)
An untyped INT literal resolved to a floating type takes the same F/D suffix (2026-07-18). The propagation above marks the integer argument 0 of complex(0, gHalfPi) with the complex128 element context (float64), but convBasicLit’s int-literal arm formerly ignored that mark and emitted a bare 0 (a C# int). golib’s complex builtin has both a complex(float32, float32) → complex64 and a complex(float64, float64) → complex128 overload, and C# rates the int → float argument conversion better than int → double — so complex(0, gHalfPi) (where gHalfPi is a package-level untyped float, implicitly convertible to either width) bound the complex64 overload and recomputed gHalfPi at float32: math/cmplx’s Atanh of an infinite input returned float32(π/2) = 1.5707963705062866 where Go’s complex(0, math.Pi/2) is float64 π/2 = 1.5707963267948966. The int-literal arm now renders a context-resolved literal at its float type (complex(0D, gHalfPi) → the float64 overload), exactly as the float and imaginary arms above already do; an int has no fractional part, so its exact digits plus the F/D suffix suffice. A complex64 context is unaffected — the float32 overload is the intended one there. The directly-typed float case (a []float64{74, …} element) is the companion rule immediately below. One operand kind is deliberately excluded: an integer-kind operand of a / is left unmarked, because Go integer-divides untyped-int operands even in a float context (7 / 2 is 3, then 3.0 — not 3.5), so pushing a float type onto it would silently switch C# to float division. A float-kind operand of the same / (math.Pi / 2, already a float division) keeps the context and its D suffix; the exclusion is per-operand (isIntegerKindConstExpr in the QUO arm of propagateUntypedConstContext), matching the exact-rational reasoning that already bars an integer context from crossing /. This cleared math/cmplx’s TestAtanh. (Guarded by the ComplexConstContext behavioral test — complex(0, gHalfPi)’s imaginary part as float64 π/2, plus 7 / 2 == 3 and complex(7/2, 0)’s integer-quotient real part, vs Go.)
The mirror case: an IMAGINARY literal resolved to a REAL float type emits its real part, NOT .i() (2026-07-24). Go permits an untyped complex constant whose imaginary part is ZERO (0i, value 0) to convert to a float parameter: complex(math.NaN(), 0i) — internal/fmtsort’s sort_test.go complex128-key map — where complex()’s second parameter is float64. go/types records the literal’s type as that float64 (the converted type), and its REAL part (0) is what must be emitted. convBasicLit’s token.IMAG arm formerly emitted .i() unconditionally, producing 0D.i() — a System.Numerics.Complex where the golib complex(double, double) overload wants a double, so C# reported CS1503 on BOTH arguments (double→float on arg 1, Complex→float on arg 2, as it fell back to the complex(float, float) candidate). The arm now checks the RESOLVED type: when it is a float (types.IsFloat), the real part is rendered as a plain D/F-suffixed literal (0D) exactly as the float and int arms do; only a complex resolved type takes the .i() form. Only 0i can reach this arm — a nonzero imaginary constant is not representable as a real float, so go/types never records one with a float type — but the emission is driven off the resolved type, not that fact. This was the sole compile blocker for internal/fmtsort. (Guarded by the resolveBuildTags/convBasicLit converter unit coverage and the ComplexConstContext behavioral test’s complex(_, 0i) real-context line, values vs Go.)
A DIRECTLY float-typed int literal takes the suffix too — and an overflowing one MUST (2026-07-20). The rule above keys off the propagated context (untypedConstContexts), which the propagation walk records only for the constant shapes it descends — parens, unary sign, arithmetic/shift operands, and the complex/min/max/real/imag builtin arguments. A great many int literals reach a float type by a route the walk never touches: go/types types them directly as a non-untyped float — a float64/float32 composite-literal element, a typed const, a function argument, a return value, or an assignment RHS. For those, info.Types[lit].Type is already float64/float32 (not untyped int), so no context mark exists and the int-literal arm formerly fell through to the ordinary integer emitter — a bare C# integer literal. For a small value that merely bound an int-typed overload where a float one was meant (the same latent hazard as the propagated case); for a large value it was a hard compile error. strconv’s ftoa_test.go puts 123456789123456789123456789 in a float float64 struct field; the bare 27-digit literal overflows every C# integral type — error CS1021 “Integral constant is too large” — which was the sole blocker stopping the whole strconv test host from compiling in Phase 4. intLiteralFloatKind (convBasicLit.go) now consults both routes: the literal’s directly-resolved non-untyped float/complex type first (a named type over float64 resolves through Underlying to its float64 kind, matching the FLOAT arm), then the propagated context. Either way the int-literal arm emits the same F/D-suffixed literal. The Go source digits are preserved verbatim whenever they also form a valid C# real literal (the visually-similar goal) — 123456789123456789123456789D is a valid C# double literal that rounds to the same float64 the bare form overflowed on, so the emitted digits still read like the source. A radix-prefixed or legacy-leading-zero form cannot survive a pasted suffix (0x10D is the C# hex integer 269), so those re-render as the folded constant’s exact decimal digits — the reuse of isValidCSharpRealLiteral (which already rejects those forms for the FLOAT arm) gates the choice. This also makes the previously-inconsistent element pair consistent: []float64{74, -784} now emits 74D, -784D (the negated sibling already rendered -784D via the propagated route). Value-identical throughout; the behavioral A/B footprint was 15 projects, every changed line an integer-form literal in a float/complex slot gaining a D/F. (Guarded by the IntFormFloatConst behavioral test — an overflowing 123456789123456789123456789 and a small 33909 in a float64 field/var/return, a float32 field, and a decimal-form control that stays byte-identical, output-compared vs go run; unfixed, the overflowing literal fails to compile with CS1021.)
Go-only float literal forms re-render as decimal. The suffix decisions above choose what type a literal is; independently, its text must be a form C# can parse. Both literal arms emit the Go source text verbatim whenever C# shares the form — the same visually-similar goal that keeps 0x4000 from flattening to 16384, so 1.5e-3 stays 1.5e-3D — but two Go float forms have no C# spelling and must re-render as the shortest round-trip decimal (strconv.FormatFloat 'g'/-1) of the go/types-folded constant:
| Go | Was emitted | Now |
|---|---|---|
0x1p-2 |
0x1p-2D — CS1002, C# has no hex-float syntax |
0.25D |
2. / 1.e2
|
2.D / 1.e2D — C# requires digits after the point |
2D / 100D
|
0x10i |
builtin.i(0x10D) — silently 269
|
builtin.i(16D) |
The imaginary row is the dangerous one: 0x10D is a valid C# hex integer literal, so the pasted-on suffix changed the value with no diagnostic rather than failing to compile. An imaginary literal’s mantissa is matched against constant.Imag of its folded (complex) value, never the whole constant.
The re-render rounds the exact constant straight to the literal’s resolved width (constant.Float32Val for a float32/complex64 context), never float64-then-narrow: var j float32 = 0x1.0000010000000000001p0 is 1 + 2⁻²⁴ + a residue, so double rounding lands exactly halfway and ties-to-even down to 1, while Go’s single rounding sees the residue and rounds up to 1.0000001. These forms are absent from the non-test stdlib corpus but routine in Go’s own _test.go files (the math tests) and in user code. The shared predicate (isValidCSharpRealLiteral) is the same one the constant-declaration path above uses; it also rejects the Go-only integer-mantissa radix forms an imaginary literal can carry — octal 0o123i, binary 0b101i, and legacy leading-zero 0123i/0_123i (octal-flavored source C# would re-read as decimal) — which re-render as their exact decimal value (83D.i(), 5D.i(), 123D.i(); guarded by the exotic-mantissa cases in ComplexFormat). (Guarded by the GoOnlyFloatLiteralForms behavioral test — hex floats, trailing-dot and 1.e2 forms in float64/float32 contexts, hex-float/hex-integer/trailing-dot imaginary literals in complex128/complex64 contexts, the double-rounding case above, and decimal controls proving verbatim round-trip; values verified vs Go.)
A native-sized integer constant (nint/nuint, including the uintptr alias) whose value does not fit a C# constant of that type — e.g. const MaxUintptr = ^uintptr(0) (= 0xFFFFFFFFFFFFFFFF), a ulong literal that needs a non-constant nuint conversion — cannot be a C# const (CS0133/CS0266). It is emitted as static readonly with an unchecked cast instead (small native-int consts like const nint iota = 0 stay const):
public static readonly uintptr MaxUintptr = /* ^uintptr(0) */ unchecked((uintptr)18446744073709551615);
The same unchecked cast is emitted for a named constant declared over a wide unsigned underlying whose folded value overflows int32 — const unknownClass = ^Class(0) (x/text/unicode/bidi, type Class uint) and const _m = ^Word(0) (go/constant via math/big, type Word uintptr) both fold to the all-ones literal 18446744073709551615 (a C# ulong), which has no implicit conversion to the [GoType] wrapper struct (CS0266). The native-int-const detection, which previously fired only for a uintptr underlying, now also fires for uint/uint64 underlyings, so the const emits unchecked((Class)18446744073709551615). A small named const stays uncast (const c = Class(5) → Class(5), an ordinary in-range constant conversion) — the cast is added only when the value is out of int32 range, so no other named-const emission churns. (Guarded by the NamedNumericConstCast behavioral test — a beyond-int32 ^Named(0) over uint and over uint64 plus a small in-range control, values verified vs Go; shared root, cleared go/constant and bidi one error each.)
uintptr is a DISTINCT golib struct (golib/uintptr.cs), not an alias of System.UIntPtr: Go’s uint and uintptr are distinct types (both may appear in one type switch; %T reports them differently; conversion between them is explicit), and the historical alias erased that identity — type switches collided (CS8120), %T lied, and overloads could not distinguish them. The struct holds a single public mutable nuint Value field (PascalCase — it is public so Interlocked/Volatile seams can target the inner storage; the intrinsics cannot take a ref to a user struct) and carries the full operator surface so uintptr-typed expressions KEEP the type. The conversion matrix is empirically tuned to C#’s user-defined-conversion candidate rules (encompassing counts only STANDARD conversions, so nothing ever chains two user-defined operators; a PARTIAL outbound operator set is unstable — undeclared targets see multiple viable std-hop candidates, CS0457): implicit both ways with nuint plus implicit from smaller unsigned/char/UntypedInt; explicit inbound from signed types and uint64; the FULL exact outbound matrix (all integer widths + float32/float64 + unsafe void*). Knock-ons handled with it: const uintptr is illegal C# (user struct) so every uintptr const emits static readonly; a uintptr-typed switch tag/label can never be a constant/relational pattern (CS9135) so those switches use the if-else == form; wrappers over uintptr ([GoType("num:uintptr")]) gain generated nuint/UntypedInt bridges; generic-math-constrained golib helpers (unsafe.Add/Slice/String) gain non-generic uintptr overloads; and the manual managed-referent types declare direct uintptr bridges (token out, panic-on-nonzero in).
Numeric literal formatting is preserved wherever Go and C# syntax overlap: hex (0x4000), binary (0b1011), and decimal literals — including _ digit separators — emit with their original source text (0x4000 never flattens to 16384), keeping bit masks and addresses recognizable; required U/UL/L suffixes and casts compose with the preserved text (0xFFFFFFFFU). Go-only forms re-render as decimal: 0o… octal has no C# syntax, and a legacy leading-zero octal (0755) would silently re-bind as decimal 755 in C#.
A beyond-MaxInt64 integer literal in a uint64 context emits a plain UL literal. The emitter classifies an INT literal by parsed range, and a value above int64 (representable only unsigned — the -Inf bit pattern 0xFFF0000000000000, ^uint64(0)) previously always emitted (nuint)0x…UL. That prefix is the bridge needed when the literal’s resolved type is Go uint/uintptr (C# nuint — a bare ulong literal has no implicit conversion to it, CS0266, while the non-constant unchecked (nuint) conversion compiles), but in a uint64 context it is spurious: semantically wrong for a 64-bit target type and value-truncating on a 32-bit platform — math.Float64frombits(0xFFF0000000000000) emitted Δmath.Float64frombits((nuint)0xFFF0000000000000UL) while the int64-range 0x7FF0000000000000 emitted clean (the signed branch already consulted the resolved type). The emitter now checks the literal’s resolved underlying type: uint64 — including a named type over uint64, whose [GoType] wrapper converts implicitly from ulong — takes the plain 0xFFF0000000000000UL; native-width unsigned targets keep the (nuint) cast. This also cleans the same pattern from stdlib constant tables on the next regen (crypto/sha512’s K, crypto/des masks, nistec field elements). (Guarded by the MathFloatBits behavioral test — ±Inf bit patterns as uint64 arguments plus var-decl, comparison-operand, and binary-mask contexts, values verified vs Go; the BitwiseUntypedConst/NamedIntSignednessConv/ShiftPrecedenceUnsigned goldens re-baselined to the cast-free form, and LargeUintptrConst pins the native-width path unchanged.)
A constant expression whose SUBexpression overflows the target type narrows once at the whole expression. Go evaluates constant arithmetic in arbitrary precision and requires only the FINAL value to be representable in the target type — a subexpression is free to overflow it, so []int32{1<<31 - 1} is legal Go even though the inner shift is 2147483648. C# has no such rule: it would compute the operators in int and overflow at compile time (CS0220), which is why an out-of-int32-range constant subexpression FOLDS to a C# long literal (2147483648L) in the first place. That fold widens the WHOLE element rendering to long, and long converts implicitly to none of the narrower integer targets — so the emission must narrow back exactly once:
| Go | Was emitted | Now |
|---|---|---|
[]int32{1<<31 - 2} |
2147483648L - 2 — CS0266 |
(int32)(2147483648L - 2) |
[]uint32{1<<32 - 1} |
4294967296L - 1 — CS0266 |
(uint32)(4294967296L - 1) |
[]uintptr{1<<40 + 1} |
1099511627776L + 1 — CS0266 |
(uintptr)(1099511627776L + 1) |
bits & (1<<52 - 1) (uint64) |
bits & (4503599627370496L - 1) — CS0019 |
bits & ((uint64)(4503599627370496L - 1)) |
The narrowing applies to every integer target EXCEPT int64, whose C# long already is the widened width, and it fires at the emission itself — in the parenthesized (type)(…) form wholeExprIsCastOfType recognizes — so it reaches composite elements, arguments, and comparison operands, not only the assignment position the sibling nativeIntConstCastType covers (which keeps handling the out-of-int32-range values folded whole; the cast strings match, so neither re-wraps the other). Two scope restrictions carry over from that sibling: at least one OPERAND must itself fold to a long literal — a bare 1 << 40 (both operands small) emits as a 32-bit 1 << (int)(40) whose count C# MASKS to 8, and casting that would convert a loud error into a silently wrong value — and the whole value must be int64-exact, so a uintptr past int64 range keeps its visible error rather than being masked. Requiring a folded operand also guarantees the non-folded operands compute exactly, since a shift is only left unfolded when its value fits int32, which bounds its count below 32. A subexpression that stays inside int32 therefore keeps its readable operator form (1<<20 + 1 → (1 << (int)(20)) + 1), and a whole value already past int32 still folds outright (1<<63 - 1 → 9223372036854775807L).
Corpus effect: this repaired latent ulong-versus-long mismatches across crypto/aes, crypto/cipher, database/sql/driver, math/big, net/http, runtime, strconv, sync, and vendored chacha20poly1305, and made math/rand’s Int31n compute in uint32 exactly as Go does (it previously computed the same value in long). The 1<<31 - 1 / 1<<63 - 1 idiom is pervasive in Go’s own _test.go files, where the shape is a hard compile blocker. (Guarded by the ConstSubexprOverflow behavioral test — int32/int16/uint32/uint64/uintptr/int elements, the int64 no-cast case, in-range controls, and assignment/explicit-conversion/argument positions, values verified vs Go.)
The narrowing root can be a UNARY node, and the widening fold can be arbitrarily deep (2026-07-25). Two shapes escaped the rule above because it only ever looked at a *ast.BinaryExpr and only at that node’s two direct operands.
-
A negated widened constant roots at a unary node. go/types types only the ROOT of a constant operator expression and leaves its operands untyped (
updateExprTypestops descending once the node it is retyping is itself constant), so[]int32{-(1<<31 - 1)}recordsuntyped inton the inner1<<31 - 1andint32only on the negation. There is no typed binary anywhere in the tree to hang the cast on, so nothing narrowed and the element emitted a bare-(2147483648L - 1). strconv’satoi_testparseInt32 table —{"-2147483647", -(1<<31 - 1), nil}against anint32struct field — is exactly this (CS1503).widenedConstExprCastTypenow accepts a unary root too andconvUnaryExprapplies the cast at its own emission, mirroringconvBinaryExpr:(int32)(-(2147483648L - 1)).^takes the same treatment ((int32)(~(2147483648L - 1))) and aninttarget narrows tonint; the non-constant unary operators (&x,<-ch,!b) are excluded by the existing constant-value and integer-kind guards. -
The fold need not be a DIRECT operand. The operator form is emitted over the operand renderings, so a subtree that does not fold itself still renders
longwhen one of its operands does: in[]int32{(1<<31 - 1) - 1}the root’s operands are(1<<31 - 1)(value in range, unfolded) and1, and only the grandchild shift folds — likewise[]int32{1<<40>>20 - 1}.operandRendersWidenedFoldnow descends the whole constant subtree instead of testing one level. Descent cannot over-report: because go/types leaves the operands of a constant operator expression untyped, no interior node ever carries a narrowing cast of its own, so exactly one cast is emitted at the root — the guard test pins(int32)((2147483648L - 1) - 1), not a doubled form.
The unary root is also taught to the explicit-conversion path (int32(-(1<<31 - 1))), which returns the operand’s own cast rather than doubling it — the same wholeExprIsCastOfType check that path already applied to a binary operand. (Guarded by the ConstSubexprOverflow extension — negated/^/deep-fold elements for int32, int, and an in-range int16 control, plus a typed assignment, an explicit conversion, a struct-field table entry and two call arguments, values vs Go; counter-proven against the pre-fix converter, whose emission fails to compile with ten CS0266/CS1503 on exactly those positions.)
A subexpression past INT64 under a NATIVE-WIDTH unsigned target folds with its own (nuint) cast (2026-07-20). The narrowing above needs the whole value to be int64-exact, and the fold that produces its widened operand originally ran only under a plain uint64 target — a native-width target (uint/uintptr, and any named type over them) was left with its visible error, since nuint has no implicit conversion from ulong and the fold could not name the target. Go’s arbitrary-precision rule makes this shape ordinary in numeric code: math/big’s nat{0, 0, 1 + 1<<(_W-1), _M ^ (1 << (_W - 1))} (int_test.go’s TestQuoStepD6, where Word is a named type over uintptr and _W is 64) has an inner 1 << 63 of 9223372036854775808 — past int64 entirely, so no signed long fold can carry it — while each element’s own value is representable in Word. Left alone, C# computed the element in int32: 1 + (1 << (int)(63)) against a Word element (CS0029), and (nuint)_M ^ (1 << (int)(63)) mixing nuint with int (CS0019).
The fold now covers uint64 and both native-width spellings — Go uint renders as nuint, Go uintptr as golib’s distinct uintptr struct — and carries the narrowing itself for the native-width pair, in the same parenthesized form wholeExprIsCastOfType recognizes as the (nint)(…) arm on the signed side:
| Go | Was emitted | Now |
|---|---|---|
[]Word{1 + 1<<63} (Word uintptr) |
1 + (1 << (int)(63)) — CS0029 |
(nuint)(9223372036854775809UL) |
[]uintptr{_M ^ (1 << 63)} |
(uintptr)_M ^ ((1 << (int)(63))) — CS0019 |
(nuint)(9223372036854775807UL) |
[]uint{1 + 1<<63} |
1 + (1 << (int)(63)) — CS0029 |
(nuint)(9223372036854775809UL) |
[]uint64{1 + 1<<63} |
9223372036854775809UL |
unchanged |
The cast is spelled nuint for both native-width targets rather than naming the target: it is the primitive C# native unsigned type, and it converts implicitly to golib’s uintptr struct and to a [GoType] wrapper over uintptr alike — so one spelling covers uint, uintptr, and named types over either, with no target-name synthesis. The uint64 emission is untouched (it already had an implicit conversion from ulong), so this is zero-churn on the existing corpus — CNR is byte-identical across all 434 behavioral projects. (Guarded by the same ConstSubexprOverflow behavioral test, extended with a named-uintptr Word type plus plain uintptr/uint/uint64 elements of the beyond-int64 shape, values verified vs Go.)
See Named Numeric Types and Constant Contexts for how these interact with native-int and named numeric types. See also example.
Native and Narrow Integer Types
In Go the int and uint types are sized according to the platform build target, i.e., 32-bit or 64-bit. C#’s int/uint are always 32-bit and long/ulong are always 64-bit. As of C# 9.0, native-sized integer types exist that behave exactly like their Go counterparts: nint and nuint. The converter maps Go int → nint and Go uint → nuint; uintptr also maps to nuint. The fixed-width Go types (int8/16/32/64, uint8/16/32/64, byte, rune) are kept as readable C# aliases of the same name (e.g. global using uint16 = System.UInt16;).
Narrow-integer arithmetic. A subtle semantic gap: Go evaluates arithmetic on a sub-int-width integer (int8/uint8/int16/uint16) at that operand’s own width, with overflow wrapping — var a, b uint8 = 200, 100; a + b is 44 (300 mod 256). C#, however, promotes arithmetic on byte/sbyte/short/ushort to int, so a + b is 300 and is not implicitly assignable back to the narrow type. Where a narrow-arithmetic result is used in a context that requires the narrow type — e.g. passed to a narrow-typed parameter — the converter emits an explicit cast back to that type, which both compiles (the implicit int→narrow conversion is rejected, CS1503) and restores Go’s wrapping:
takeU8((uint8)(a + b)); // Go take(a + b), a/b uint8 → 44 (wraps), not 300
takeU8((uint8)(~a)); // Go take(^a) → 55
The same cast applies in the assignment context — a narrow-arithmetic value assigned to a narrow variable, array/slice element, or struct field (y := a + b; y = y + 1; arr[0] = a + b; bx.b = a + b) — and in the declaration context — a typed-var initializer (var z uint8 = a + b). All emit (uint8)(a + b) for the same two reasons. (A double cast is avoided when another path already narrowed the RHS, e.g. a bitwise op with an untyped constant emits its own (byte)(b | 128).)
The cast is applied only when the value’s Go type already matches the target (parameter / LHS / declared type), so Go accepts it without a conversion, and only for an arithmetic (binary/unary) expression — a bare identifier is already the narrow type. (Guarded by the NarrowArithmeticArg behavioral test, which verifies the wrapped values match Go across all four contexts. Wider integer types — int32/uint32 and up — are not promoted by C# and need no cast.)
A redundant-cast guard on this decision — skip the cast when the converted RHS is already a full narrowing ((byte)(b | 128)) — must distinguish a WHOLE-expression cast from one that only converts the FIRST operand. buf[i] = byte(e/100) + '0' (runtime print.go) emits the RHS (byte)(e / 100) + (rune)'0', which starts with (byte)( but only casts e/100; the binary result is still int (the (rune)'0' promotes it), so the narrowing cast is still required (CS0266). The guard therefore checks that the cast-paren’s matching close is at the very end of the RHS (a parenthesis-balance walk that skips (/) inside char/string literals), not merely that the RHS begins with (byte)(. (Guarded by the NarrowByteArithFirstOperandCast behavioral test — including a wrapping case; cleared 3 runtime CS0266 in print.go’s exponent formatting.)
The per-argument cast path (convExprList, which applies castArgToType — e.g. an append element cast to the slice’s element type) carries the same redundant-cast guard and shares the same whole-expression test. It previously used a bare strings.HasPrefix check, which the narrow-shift result cast above newly exposed: append(s, uint16(v[0])<<8 + uint16(v[1])) (vendor/golang.org/x/text’s BMP-string decoding, crypto/x509) emits (uint16)(…<<8) + (uint16)v[1], whose leading (uint16)( covers only the shifted first operand while the sum still promotes to int — so the prefix test wrongly skipped the element cast that the whole expression still needs. Both sites now call the same balance-walk helper, so a first-operand-only cast is never mistaken for a whole-expression narrowing.
The same narrowing applies to a return of narrow-integer arithmetic. func lowerASCII(c byte) byte { return c + ('a'-'A') } (runtime env_posix) returns byte + int (the untyped char constant promotes to int) → CS0266 against the byte result type. The cast was applied on the assignment and value-spec paths but not the return path; it is now applied in visitReturnStmt when the function’s result type at that position is narrow and the returned expression is binary/unary arithmetic (reusing the same gate — a bare identifier, a call, or an already-whole-expr-narrowed return is untouched, and a non-narrow result type is unaffected). (Guarded by the NarrowByteArithReturn behavioral test — a per-branch return plus a wrapping case; cleared the env_posix.lowerASCII CS0266.)
The same narrowing applies to a narrow-arithmetic comparison operand (2026-07-18). A narrow (int8/uint8/int16/uint16) NON-constant arithmetic/complement result compared directly — with no narrow destination to force the cast — keeps its C# int-promoted value and so compares WRONG: int8(MaxInt8) + 1 != MinInt8 evaluates 128 != -128 (true) where Go wraps at int8 width to -128 != -128 (false). convBinaryExprCore now wraps each comparison operand (== != < <= > >=) that is a narrow non-constant arithmetic expression at its own width — (int8)(v + 1) != MinInt8 — through the same narrowArithmeticCastTypeFor helper (narrowComparisonOperand), gated to NON-constant operands: a Go constant cannot overflow its type, and a wrap cast on a C# compile-time constant expression is CS0221 (the shift-retype path guards identically). Unlike the destination contexts, this had no compile symptom — the code compiled and only the value of the comparison was wrong — so it surfaced only when math’s TestMaxInt/TestMaxUint ran. (Guarded by the NarrowArithmeticArg behavioral test’s comparison cases a+b == 44 / c+d == -56 / e+f != 4464 / ^a > 50, output-compared vs Go.)
A related wide case: a computed constant arithmetic expression assigned to a native-width integer (uintptr/uint/int → C# nuint/nint) whose folded value overflows int32. pattern = 1<<maxBits - 1 (runtime mbitmap, maxBits = 57) is a uintptr constant, but the converter folds the untyped sub-shift 1<<maxBits to a signed C# long literal (144115188075855872L, since it exceeds int32 and the untyped operand is treated as signed), so the whole RHS is long — which has no implicit conversion to the native target (CS0266). A UL/(nuint) suffix would not help (ulong→nuint is also an explicit conversion). The converter wraps the whole RHS in the native target’s cast: pattern = (uintptr)(144115188075855872L - 1). This fires only when the constant fits int64 but is out of int32 range — exactly the signed-long fold range. A value that overflows int64 (a large unsigned uintptr like 1<<63 + 1<<62) is deliberately left alone: its sub-shift already mis-emits (a 1<<63 int-shift), so casting it would convert a visible compile error into a silent wrong value — that is a separate defect to fix on its own, not to mask. (Guarded by the NativeIntWideConstAssign behavioral test — uintptr/uint/int targets with int64-range constants, values verified vs Go; cleared the mbitmap CS0266, the last one in runtime.)
A runtime shift count that can reach the operand width uses Go-semantics helpers (2026-07-18). Go zeroes a shift whose count reaches or exceeds the operand’s bit-width — x >> n / x << n with n >= width is 0 (a SIGNED right shift sign-extends: 0 for a non-negative value, -1 for a negative one). C#’s native >>/<< instead mask the count (n & 63 for a 64-bit operand, n & 31 for 32-bit, and sub-int operands promote to int and mask by 31), so a native shift by a runtime count silently yields the wrong value once the count can reach the width — math.FMA’s double-word funnel shifts (u2 >> (64 - n) at n == 0: Go >>64=0, C# >>0=u2) and math.RoundToEven’s >> e (e up to 1024 for a NaN) both corrupted. The converter keeps the native shift ONLY when the count is PROVABLY in [0, width): R1 a constant in range (the majority of shifts — every x >> 5; a constant >= width routes to the guard, which returns 0); R2 a syntactic mask y & M with constant M <= width-1; R3 a modulo y % M with constant M <= width. Everything else — a bare variable count (x >> s, which genuinely can exceed width, as RoundToEven’s e proves, so it cannot be trusted) or an arithmetic count (64 - n, shift - e) — routes through golib’s GoShift.Rsh/Lsh extension methods, x.Rsh(n) / x.Lsh(n) (the naming echoing Go’s math/big.Int.Rsh/Lsh): one guarded shift per operand width returning 0 (or sign-extending) at n >= width. The count is taken as a wide uint64 so a computed count like 64 - n that unsigned-wraps to a huge value is compared at FULL magnitude BEFORE narrowing to the int shift amount — truncating to int first would defeat the guard. A named-[GoType]-wrapper left operand keeps its generated-operator path, as does a compound untyped-const UntypedInt left operand (its own operator<<, the UntypedIntWideShift subject) — but the WRAPPER OPERATOR now carries the guard itself (2026-08-09), because leaving it native left that entire family with the masked answer and no other layer covers it. NumericTypeTemplate’s operator << / operator >> emit value.m_value.Lsh((uint64)shift) / .Rsh(…) in place of the native value.m_value << shift, so a named integer type shifts with Go’s semantics wherever the converter’s own provability analysis cannot reach. What motivated it was not a wrong number but a hang: math/big’s lehmerSimulate reads a2 = B.abs[n-2] >> (_W - h) on Word (type Word uint), and for a normalized operand h == 0, so the count is exactly 64 — Go yields 0, C# yielded the word itself. The corrupted Lehmer cosequences make GCD’s for len(B.abs) > 1 loop stop converging: an INFINITE LOOP inside math/big, reached from crypto/elliptic’s generic CurveParams path, so elliptic.P256().Params().Double(Gx, Gy) never returned. That is crypto/ecdsa’s TestINDCCA/P256/Generic, carried on the board as a 20-minute timeout open between “performance gap or hang”; the fixed path runs in 0.31 s against Go’s 0.66 s, so it was never slowness. It is value-dependent, which is why it hid so long — a garbage a1/a2 that fails Collins’ stopping condition immediately costs only a Euclidean step, so equal-width operand pairs pass and only pairs that make the condition iterate corrupt anything. operator >>> stays native: Go has no unsigned-right-shift operator, so nothing converted ever calls it. Measured footprint: of ~3,556 corpus shifts, ~80% (constant, named-const, and masked/modulo counts) stay native and byte-identical; only the ~20% of unprovable variable/arithmetic counts become .Rsh/.Lsh — the SOUND floor without value-range analysis (a lightweight loop-bound/range recognition could shrink it later). This replaces the narrow-shift result retype for a runtime count ((byte)(cb << (int)(k)) → cb.Lsh(k), the Lsh(byte) overload doing both the width wrap and the k>=8→0). (Guarded by the GoShiftSemantics behavioral test — 64/32-bit unsigned shifts by runtime counts 0/1/63/64/65/200, signed sign-extension, and R2/R3 masked/modulo counts staying native, output-compared vs Go; cleared math’s TestFMA + TestRoundToEven, whose funnel shifts now emit guarded automatically. Extended 2026-08-09 with the NAMED forms — type word uint / halfword uint32 / signedword int64 shifted by the same runtime counts, including the w >> (W - h) shape lehmerSimulate writes, which is the wrapper-operator route rather than the guard route.)
The signed integer minima sign-fold at the unary level. Go folds -literal into one constant, but the emitter classifies the POSITIVE operand literal alone, and both signed minima’s magnitudes overflow their own type: []int32{-2147483648} (internal/fuzz mutator’s interesting32) saw 2147483648 > MaxInt32 and emitted -(nint)2147483648L, which has no implicit conversion back to an int32 slot (CS0266); the int64 minimum’s operand 9223372036854775808 does not even parse as int64, routing through the unsigned branch to -(nuint)9223372036854775808UL — and C# defines no unary minus on nuint at all (CS0023). convUnaryExpr’s token.SUB handling now mirrors its FLOAT arm: for an INT literal operand it classifies the range on the unary expression’s resolved (sign-folded) constant. The exact int32 minimum in an int32-typed context emits the plain negated literal -2147483648 — C# special-cases the negated decimal int-min as an int constant, by value, so _ digit separators survive (-2_147_483_648 compiles, proven by the guard) — and the exact int64 minimum emits -9223372036854775808L (the matching long special case), wrapped as ((nint)(-9223372036854775808L)) in a Go-int context where long has no implicit conversion. Decimal source formatting is preserved per the literal-formatting rule; hex/binary re-render as decimal (C# has no signed special case for those forms — -0x80000000 binds as a long-typed expression). Everything else keeps the default path: in-int32 operands never had a problem, and a folded int32-min in a WIDER context (var x int64 = -2147483648, or boxed to any where Go-int must stay nint) keeps the implicitly-convertible -(nint)…L form — the full-stdlib A/B footprint was exactly the one mutator.cs line. (Guarded by the IntMinLiterals behavioral test — int32-min plain and underscored in []int32, int64-min in []int64, the nint-min := form, between-minima and non-minimal negative controls, and min-value comparisons, values vs Go; the pre-fix converter fails it CS0266 ×2 + CS0023 ×2.)
Constant-literal return inside a lambda with an unsigned result (delegate-type inference, CS8917). A Go closure assigned to a local — casePC := func(casi int) uintptr { if pcs == nil { return 0 }; return pcs[casi] } (runtime select.go) — is emitted as var casePC = (nint casi) => { … };, whose delegate type C# must infer from the return-expression types. The literal return 0 is typed int; return pcs[casi] is typed nuint (uintptr). C#’s best-common-type algorithm uses the expression types (not constant convertibility), and int has no common type with nuint/uint/ulong (there is no implicit int→unsigned conversion for a non-constant), so the var assignment fails with CS8917 (“no best type found for the lambda”). The converter casts the literal to the result type so both returns share it: return (uintptr)(0). Gated tightly to avoid churn and new errors: only inside a lambda body (conversionInLambda — a named func’s return 0 to a nuint result compiles as an ordinary constant conversion and needs no cast), only for a bare integer literal (the sole shape that trips the int-vs-unsigned inference gap — byte/uint16 widen to int, and the signed/nint/long kinds share a common type with int, so those never hit CS8917), and only when the result is a basic uint/uint32/uint64/uintptr (a named type over an unsigned kind is left alone — (gclinkptr)(0) would only compile if that type defined an int conversion, so casting it could introduce a new error). Runs after the narrow-arithmetic return cast, with which it is disjoint (that handles binary/unary arithmetic on sub-int types; this handles a bare literal to a wide unsigned type). (Guarded by the ClosureMixedReturnUnsigned behavioral test — uintptr/uint64/uint32/uint mixed-return closures plus a signed control that stays uncast, values verified vs Go; cleared the select.go casePC CS8917.)
A beyond-int32 integer constant takes the width of the type it RESOLVED to, not the untyped default (2026-08-08). A Go integer constant outside the C# int32 range cannot be written bare in a native-width slot — long has no implicit conversion to nint — so the converter wrapped every one of them in (nint)…L. That is right only when the constant really is a Go int. When it resolved to int64 the cast is the wrong type: int64 is C# long, so the digits alone denote it exactly, and the (nint) both truncates on a 32-bit target and reads nothing like the Go source. The compiler says so — CS8778, “constant value may overflow nint at runtime” — and 607 of the corpus’s 620 such warnings were one table, math/rand’s rngCooked [607]int64:
// before — every element carries the untyped-int DEFAULT type
internal static array<int64> rngCooked = new int64[]{
-(nint)4181792142133755926L, -(nint)4576982950128230565L, (nint)1395769623340756751L, …
// after — the element type the Go source declares, and the Go source's own digits
internal static array<int64> rngCooked = new int64[]{
-4181792142133755926L, -4576982950128230565L, 1395769623340756751L, …
The cause is a deliberate go/types behavior that stays invisible until you look for it: updateExprType0 short-circuits with “if x is a constant, the operands were constants” and does not descend into the operands of a constant expression, because in Go they never materialize at runtime. So in [...]int64{-4181792142133755926, 1395769623340756751} the NEGATED element records untyped int while its positive sibling records int64 — purely because one is wrapped in a unary minus. Every element of rngCooked is negative, which is why that whole table lost its element type while positive-only tables elsewhere kept theirs. convBasicLit therefore resolves the literal’s integer type from two routes, exactly as it already does for the float F/D suffix: the type go/types recorded directly, and failing that the contextual type markUntypedConstContexts propagated (which already pushes an integer context through unary +/-/^ and arithmetic operands). An int64 resolution emits the bare …L; everything else keeps nint — it must, because an any slot has to box a Go int as nint so a later x.(int) succeeds — but as unchecked((nint)…L), which is what makes a beyond-int32 constant conversion legal without the warning (nint is 64-bit on every platform go2cs targets, so the value is exact). The same unchecked covers the other two emitters of a native-width constant: convBinaryExpr’s constant FOLD (unchecked((nint)(4611686018427387903L)) — bufio’s maxInt/2) and csNintLiteral’s array LENGTH (unchecked((nint)140737488355327) — runtime’s (*[maxAlloc/2 - 1]byte) casts). The fold keeps its parenthesized (T)(…) body: wholeExprIsCastOfType, the redundancy guard 17 call sites share, now peels an unchecked( wrapper first, so enclosing paths still recognize the cast and do not re-wrap it into (nint)(unchecked((nint)(…))). (Guarded by nativeIntConstWidth_test.go — the negated int64 element and its positive sibling, the int var and the any slot both taking unchecked, the fold NOT double-wrapped, an in-range constant untouched, plus a unit test pinning the recognizer’s peel. Corpus effect: CS8778 620 → 0.)
One sticking point: not all C# indexing constructs accept a
nint. Explicit indexers supportnint, but implicit index support (theIndex/Rangesyntax) currently only works withint, so range-operation indices are cast tointwhere needed. (The earlier strategy of compiling tolong/ulong, or of custom@int/@uintstructs selected by aTARGET32BITdirective, has been superseded bynint/nuint.)
Named Numeric Types and Constant Contexts
General untyped constant representation is covered in Constant Values. This section
records the places where constants and operators become difficult because a numeric context is already
known: named numeric wrappers, native-width targets, typed-element contexts such as append, shift and
bit-mask operands, and the casts needed to keep C# overload resolution aligned with Go.
This area is where Go’s flexible numeric model meets C#’s stricter one, and it has a few moving parts worth calling out.
Untyped constants. As noted under Constant Values, an untyped Go constant becomes a golib UntypedInt/UntypedFloat/UntypedComplex. These wrappers define implicit conversions to and from every numeric type so the value can slot into whatever context uses it, just like an untyped Go constant. The trade-off is that mixing an UntypedInt directly into heavily-typed arithmetic (e.g. someUint64 * untypedConst) can become ambiguous to C#’s overload resolution, since the wrapper is convertible in either direction. A function-local untyped constant whose every use resolves to one concrete basic type sidesteps the wrapper entirely — it is declared AT that type with the per-use casts dropped (see Constant Values, function-local untyped constants tighten); the wrapper-cast machinery below applies to the remaining wrapper-emitted constants (package-level, mixed-context, and folding-participating locals).
One resolved instance: an argument to the min/max builtins that is a named untyped constant renders as its UntypedInt static, which golib’s min<T>(T, params ReadOnlySpan<T>) overloads reject (CS1503 — params-span element binding does not apply the user-defined implicit conversion): runtime min(n, maxObletBytes) (mgcmark.go, uintptr sibling) and min(debug.profstackdepth, maxProfStackDepth) (runtime1.go, int32). The converter casts such an argument to the call’s Go-resolved result type — min(n, (uintptr)(maxObletBytes)) — and, once one argument is cast, every constant-valued sibling too (min(big, limit, 500) → …, (uintptr)(limit), (uintptr)(500)) — a bare literal is a C# int and would break T inference against the cast type). Typed arguments and literal-only calls are unchanged. (Guarded by the MinMaxBuiltin extension — untyped consts typed by uintptr/int32 siblings plus the mixed literal case, values vs Go.)
Named numeric types. A Go type definition over a numeric base — type Celsius float64, type level int, type Flags uint — is emitted as a partial struct carrying a num: [GoType] attribute, and the TypeGenerator source generator fills in the body:
[GoType("num:nint")] partial struct level; // type level int
[GoType("num:nuint")] partial struct Flags; // type Flags uint
[GoType("num:float64")] partial struct Celsius; // type Celsius float64
The generated struct wraps the underlying value and implements the comparison and arithmetic operators plus implicit conversions to/from the underlying type, so the named type is a distinct C# type that still behaves like its base.
Increment / decrement. Go allows c++ / c-- on a named integer (e.g. a for c := chunkIdx(0); …; c++ loop counter). The generator therefore emits operator ++/operator -- returning the named type — operator ++(T value) => (T)(value.m_value + (U)1) (U the underlying). Without a dedicated operator, C# c++ falls back to the implicit conversion to the underlying and re-assigns the (promoted) result, which for a native-int-backed named type (num:nuint/num:nint) promotes to ulong/long and then cannot implicitly convert back to the named type (CS0266). The dedicated operators keep the result in the named type. (Guarded by the NamedNumericIncDec behavioral test — ++/-- on uint- and int-backed named types in loop counters; runtime uses this for chunkIdx/arenaIdx/statDep loop counters, ~7 CS0266.)
Unsigned underlying types and unary minus. Go permits unary minus on an unsigned value (it wraps: -x == 0 - x). C# does not allow the unary - operator on unsigned operands. So the generator’s IsUnsignedType check omits the unary negation operator for unsigned underlying types (uint8/16/32/64, byte, uintptr, and the native nuint/uint). Go’s unary minus on such a value is instead lowered by the converter to the equivalent subtraction-from-zero form:
var b Flags = 2
_ = -b // Go: unsigned unary minus (wraps)
Flags b = 2;
_ = ((Flags)0 - b); // C#: lowered to (T)0 - x
This keeps the generated numeric struct compilable (a (T)(-value.m_value) body over nuint is a CS0023 error) while preserving Go’s wrap-around semantics. The same (T)0 - x lowering is used for unsigned unary minus on built-in unsigned values.
Converting to a named numeric type. The generated struct’s implicit conversions are only between the named type and its exact underlying basic (traceArg ↔ uint64, arenaIdx ↔ nuint). So a Go conversion traceArg(procs) where procs is int32, or arenaIdx(1 << b) where the shift is int, has no matching operator — a plain (traceArg)procs is CS0030. The converter coerces the argument through the underlying type first, which is exactly Go’s numeric-conversion semantics:
var procs int32 = 5
a := traceArg(procs) // type traceArg uint64
b := arenaIdx(1 << 4) // type arenaIdx uint
int32 procs = 5;
var a = ((traceArg)(uint64)procs); // through the underlying uint64
var b = ((arenaIdx)(nuint)(1 << 4)); // through the underlying nuint
When the argument is already the underlying basic (traceArg(u) with u uint64), the existing single cast already binds, so no extra cast is inserted (no churn). (Guarded by the NamedNumericConversion behavioral test; runtime exercises this pervasively for traceArg, arenaIdx, traceTime, the abi offset types, etc.)
Converting from a named numeric type. The mirror direction has the same root: because the wrapper only converts between the named type and its exact underlying, a Go conversion from a named numeric to a different basic numeric — uint64(nameOff) where type NameOff int32, or int(idx) where type idx uint — has no matching operator ((ulong)NameOff / (nint)idx is CS0030). The converter routes it through the named type’s underlying basic first — the named→underlying [GoType] operator followed by an ordinary numeric C# cast:
var s NameOff = 7 // type NameOff int32
e := uint64(s) // NameOff -> uint64
var i idx = 9 // type idx uint
f := int(i) // idx -> int
NameOff s = 7;
var e = ((uint64)(int32)s); // through the underlying int32
idx i = 9;
nint f = ((nint)(nuint)i); // through the underlying nuint
When the target basic is the named type’s exact underlying (int32(s) for NameOff), the single operator already binds, so no extra cast is inserted (no churn). (Same NamedNumericConversion behavioral test; runtime hits this on the abi offset types NameOff/TypeOff/TextOff → uint64/uintptr, taggedPointer/traceTime → int64, etc.)
Cast parenthesization (visual fidelity). A Go conversion T(x) reads as a function call; the C# cast (T)x is the closest equivalent, so the converter keeps it minimally parenthesized to stay close to the source:
-
Basic-target conversions omit the outer wrap. A conversion whose target is a basic C# type —
uint64(a),int(k),float64(b), and evenunsafe.Pointer(p)(go/types models it as a*types.Basic) — emits(uint64)a, not((uint64)a). The result of a basic-typed cast can never be the receiver of a postfix./[]/invocation (Go basic types expose no callable members and the converter emits none on them), and the C# cast operator outranks every binary operator, so the bare form binds correctly in any surrounding context:f((uint64)a),return (uint64)a;,(uint64)a << n,(nint)(uint8)k < len(s), and(nint)x.Load() + 5(which parses as((nint)(x.Load())) + 5— postfix.binds before the cast). A named-type target keeps the defensive outer parens((Named)x)— its result can be member-accessed (Named(x).Method()), which is parent-context-dependent and not decidable at the conversion site.stringis the exception among basic types: its C# representation is the member-accessible golib@stringstruct, so astring(x)conversion is a valid postfix receiver — the variadic-string spreadstring(r)...→((@string)(rune)r).ꓸꓸꓸ, an indexstring(b)[i]→((@string)b)[i], orlen(string(b)). Dropping the wrap there reparses the postfix against the cast’s inner operand ((@string)(rune)r.ꓸꓸꓸbinds.ꓸꓸꓸtor, CS1061), so astringtarget retains the outer parens like a named type. (unsafe.Pointerstays in the no-wrap set: although its C#@unsafe.Pointeris a struct, Go exposes no members onunsafe.Pointerand the converter never emits a postfix on such a conversion result.) -
Identity conversions are not double-cast — EXCEPT a plain constant argument.
arenaIdx(x)wherexis alreadyarenaIdxis a Go no-op. This arises for an untyped-constant shift that adopts the target type from context (arenaIdx(1 << bits), whose operand go/types already types asarenaIdx, so the inner conversion has already emitted(arenaIdx)((nuint)1 << bits)), and for a plainarenaIdx(yArenaIdx). Wrapping the already-typed expression in a second(arenaIdx)cast just doubles it, so the converted argument is returned as-is. The exception: a plain constant argument (Word(1)) — go/types types the constant AS the target (identity), but the render is the bare literal, which under a binary operator resolves asintand degrades the whole expression (math/big’smask := Word(1)<<s - 1, CS0029). The named cast is re-imposed at the conversion site —((Word)1) << (int)(s)) - 1— which also made the older:=-declaration-only patch in visitAssignStmt redundant (it now sees the cast already present).
Named-numeric wrappers carry the full INTEGER operator surface. The generated [GoType("num:…")] wrapper defines + - * / % ++ -- returning the wrapper; integer underlyings additionally define ~, the shifts << >> (int count), and the binary bitwise & | ^ — all returning the WRAPPER type, exactly Go’s typing (Word >> ŝ IS a Word). Without them C# resolved compound expressions through the implicit-to-underlying conversion and the whole expression degraded to the raw numeric (math/big’s Word arithmetic, CS0266 ×45). Floats/complex omit the integer-only operators.
Named SLICE types keep the named type when sliced. The generated slice wrapper’s Range indexer and Slice() overloads return the wrapper (nat[a:b] IS a nat — a fresh wrapper sharing the same backing window), so a method call directly on a slice expression binds the named type’s extensions (u[s:].norm() bound the raw slice<Word> instead, math/big CS1929 ×21). The explicit ISlice<T> implementations keep the raw slice type.
A conversion between two named slice types sharing an identical underlying (tar’s sparseElem(s[i*24:]), both []byte) hops through the shared underlying slice — ((sparseElem)(slice<byte>)(…)) — since the wrapper-returning slicing makes the argument the NAMED wrapper and a direct cast would chain two user-defined operators (CS0030). (Guarded by SortArrayType’s Roster(byAge[0:2]).) The same hop covers the map and array underlyings (net/mail’s textproto.MIMEHeader(h), where Header and MIMEHeader are both written over map[string][]string).
…but NOT when one of the two was written directly over the other. Go’s Underlying() resolves through the whole declaration chain, so type shuffledFS MapFS — testing/fstest’s own suite, over a MapFS that is itself map[string]*MapFile — passes the shared-underlying test above while being a completely different shape. The wrapper for a non-basic underlying keeps the NAMED base ([GoType("global::go.testing.fstest_package.MapFS")], see visitIdent/visitTypeSpec), so shuffledFS declares exactly ONE conversion operator and it targets MapFS. Hopping through the raw map therefore creates the two-operator chain the hop exists to prevent — shuffledFS→MapFS→map, CS0030 — where the plain cast Go actually wrote binds in one step:
f, err := MapFS(fsys).Open(name) // fsys is shuffledFS
var (f, err) = ((global::go.testing.fstest_package.MapFS)fsys).Open(name);
The written right-hand side is recovered from packageTypeSpecRHS (writtenUnderlyingOperations.go), the same per-package pre-pass the named-numeric hop already consults for its own version of this exception — with one difference: the numeric exception is gated to a CROSS-package base, because a same-package numeric chain resolves to the basic underlying ([GoType("num:uintptr")], no named-base operator to bind). A composite underlying keeps the named base either way, so the composite exception needs no package gate. Both directions are covered (the arg written over the target, and the target written over the arg), and all three composite arms — map, slice and array. Unrecorded or cross-package declarations miss the lookup and keep the pre-existing route.
Measured on testing/fstest, whose whole 7-verdict suite sat behind this one CS0030: it now runs at 6 of 7, the residual being TestShuffledFS’s runtime assertion that the returned *shuffledFile satisfies fs.ReadDirFile — the pointer-adapter identity class, unrelated. (Guarded by the DefinedOverNamedComposite behavioral test: a child package owning the named map/slice/array, defined types over each in both directions plus a same-package one, and the net/mail two-raw-map control in the same program so the hop is proven to still fire.)
A defined type over a named COMPOSITE gets an inherited wrapper that does not expose the golib sequence surface, so
len(x)directly on one is CS0315 againstbuiltin.len<TSeq>. That is a separate, pre-existing gap — no corpus site asks for it, and the guard above deliberately measures through the base type instead.
(Guarded by NamedNumericConversion, NamedNumericShiftConv, NamedTypeBitwiseConst, IotaEnum, FuncTypeParam, and CrossPkgUser; the string-target exception is guarded by StringConvPostfix and UnsafeOperations; verified by the full behavioral suite — output comparisons confirm the precedence is unchanged.)
Generated conversion operators between named numerics of different assemblies. The two paragraphs above are the converter’s inline casts. Separately, when the converter sees a conversion between two named numeric types it records a [assembly: GoImplicitConv<…>] and the ImplicitConvGenerator emits a user-defined implicit operator for it. The emitted body constructs one named type from the other’s underlying value: new Target((ValueType)src.Value).
ValueType is a CAST TARGET, and it names the constructed type’s BACKING PRIMITIVE (corrected
2026-08-08). The template applies it to src.Value and feeds the result to the constructed type’s
constructor, and that constructor takes the primitive — so ValueType must be uint32, nint,
int64, not the wrapper. It named the constructed type itself until this was rooted, making the
body a round-trip through that type’s own conversion operators — new WaitStatus((WaitStatus)src.Value)
— which compiles only while a standard EXPLICIT conversion exists between the two primitives, because
a user-defined conversion admits just one standard conversion on its input. syscall’s unix flavors are
where it finally bit: WaitStatus is backed by uint32 and Signal by int (nint), and
uint32→nint is not a standard IMPLICIT conversion (a 32-bit unsigned value does not fit a 32-bit
native int), so its reverse is not a standard explicit one, the operator is not applicable, and the
cast is CS0030. Windows declares WaitStatus a struct, so the pair is never registered there and
no corpus build reached it. All 49 of the corpus’s ValueType records carried the constructed type’s
own name, so the form was never right — only never yet fatal, because the two compensating generator
overrides below cover most of the gap. One consumer had to move with it: the uintptr hop read
ValueType as the type to CONSTRUCT (new {valueType}(…)) and now constructs the LH type and casts
to ValueType, exactly like the default body. (Guarded by implicitConvValueType_test.go — an
end-to-end conversion of two named numerics with different underlyings, plus a unit sweep over every
basic kind a named numeric can carry.)
When both named types live in the same assembly the default body is fine (e.g. runtime’s
muintptr ↔ Δhex), but when the operator must construct a foreign named numeric — one declared
in another C# assembly — two problems appear that only manifest cross-assembly:
- A direct cast to the foreign named type has no route.
(NameOff)src.Valuewheresrc.ValueisulongandNameOff(internal/abi) is a different assembly is CS0030 — C# does not select the foreign type’sint32-based user conversion for aulongsource across the assembly boundary (the same cast to a local named type compiles). It must go through the foreign type’s underlying basic:new …NameOff((int)src.Value). - The default host can be a phantom. The operator is hosted in
partial struct {sourceType}; if that source is the foreign type (reached here via a local alias, e.g. runtime’sglobal using nameOff = abi.NameOff, so the cross-package dot is hidden and the conversion records asInverted), thepartial struct NameOffdeclares a new empty local type rather than extending the foreign one — CS1729 (no constructor). The operator is relocated into the local type instead.
So for a foreign constructed type the generator emits, fully-qualified and hosted in the local type:
// runtime, dur↔hex style: foreign abi.NameOff constructed from local Δhex
partial struct Δhex {
public static implicit operator global::go.@internal.abi_package.NameOff(global::go.runtime_package.Δhex src)
=> new global::go.@internal.abi_package.NameOff((int)src.Value); // through the underlying int32
}
The override fires only when the new-constructed side (the LH type: the source when the conversion is Inverted, else the target) is foreign; same-assembly operators are emitted byte-identically as before (no churn). Because the trigger is inherently cross-assembly, the behavioral-test harness (single-assembly, and unable to import a foreign named numeric — internal/* types are un-importable from a test module and the baseline stubs expose none) cannot host it; the guard is the core/runtime build, where NameOff/TypeOff/TextOff ↔ Δhex naturally occur (this fix cleared 3×CS0030 + 3×CS1729 there).
A same-assembly pair also needs the through-underlying routing when the two named numerics have incompatible underlyings — internal/trace’s public type Time int64 ↔ unexported type timestamp uint64, converted both ways (Time(ev.Ts) / timestamp(ts)). The default new Time((ΔTime)src.Value) casts src.Value (a ulong, since timestamp is uint64-backed) straight to the wrapper, which routes through the wrapper’s long-based user conversion — but ulong→long is not an implicit C# conversion, so the cast is CS0030. (This is the mixed-accessibility case: Time is exported and timestamp is not, so the operator is already relocated into the less-accessible timestamp struct — orthogonal to the underlying.) The generator now, for a local numeric pair, casts through the constructed type’s underlying C# keyword when the source underlying does not implicitly convert to it: new Time((long)src.Value), new timestamp((ulong)src.Value). The source/constructed underlyings are read from each side’s [GoType("num:X")] tag (a sibling generator cannot see the generated Value property), and the implicit-convertibility test is the fixed C# numeric-conversion table over the fixed-width integer/float basics. Crucially this fires only on pairs the default cast could not compile (the default (Wrapper)src.Value succeeds iff that same source→underlying conversion is implicit), so every already-compiling conversion stays byte-identical — the full behavioral suite’s goldens are unchanged. uintptr-backed pairs keep the existing nuint-hop override; int/uint native-width wrappers are deliberately left to the default (their classification is version-sensitive and the failing corpus cases are fixed-width). (Guarded by the NamedIntSignednessConv behavioral test — a public int64 ↔ unexported uint64 named pair converted both ways, including a ^uint64(0)→int64 case whose -1 result verifies the cast preserves the bit pattern exactly, output-compared vs Go; internal/trace’s timestamp→Time inverse operator relies on it.)
A cross-assembly mixed-accessibility pair has no legal form at all, and is skipped. The relocation
above is the only remedy for a mixed pair — a C# user-defined conversion operator is necessarily
public and must be declared in one of its two operand types — and a foreign type cannot host
anything. Hosting in the local, more accessible side then exposes a type less accessible than the
operator: CS0056 when the foreign side is the return type, CS0057 when it is the parameter, so
neither direction is expressible. The shape is reachable only under the -tests white-box model, where
a package’s own _test.go declares an EXPORTED defined type over an UNEXPORTED production one — time’s
export_test.go has type RuleKind int beside zoneinfo.go’s type ruleKind int, which become
public RuleKind in the test assembly and internal ruleKind in the referenced production assembly.
Nothing is lost by skipping: the converter renders such a conversion site as an explicit
through-underlying cast ((RuleKind)(nint)r.kind), which needs no operator at all. The local side’s
accessibility comes from the GO export rule, as in the relocation above (at analysis time the [GoType]
partials are modifier-less); the FOREIGN side is read from metadata, where it is already final.
The recorded GoImplicitConv must also be able to name the foreign type. The recorded type name carries the foreign package’s import qualifier — the DOT form driver.IsolationLevel for an unrenamed type, or a ꓸ global-using alias (CrossPkgLibꓸGrade) for a Δ-renamed one — but the attribute sits in package_info.cs at file scope and the generated operator lands in a .g.cs, neither of which carries the body files’ import usings. A Δ-renamed foreign numeric resolves through its own ꓸ global using, but the dot form needs a resolving using driver = go.database.sql.driver_package; in package_info.cs’s ImportedTypeAliases block. The STRUCT-conversion branch of checkForImplicitConversion already drives that using by calling recordConversionPackageUsing(argType)/(funcType), but the aliased-NUMERIC branch omitted it — so a cross-package named-numeric conversion (database/sql’s driver.IsolationLevel(opts.Isolation), where sql.IsolationLevel and driver.IsolationLevel are distinct named ints) left driver unresolved in both the attribute and the generated operator (CS0246). The numeric branch now records the same package usings. (Guarded by an extension to the CrossPkgUser cross-assembly test — a local float64-based named numeric converted to the unrenamed CrossPkgLib.Celsius, which renders in dot form and so needs the registered using; a Δ-renamed target like CrossPkgLib.Grade would have resolved via its alias and would not have caught the gap.)
The same underlying routing applies when an untyped-constant shift is re-typed to a named numeric. An untyped shift 1 << k is re-typed to the type it assumes from context (so it can combine with typed operands); when that resolved type is a named numeric, the re-type must go through the underlying — (arenaIdx)((nuint)1 << k), not a bare (arenaIdx)(1 << k) (CS0030). The shift’s width is likewise decided by the underlying (a nuint/uint64-backed named type shifts the left operand in that width to avoid the int-overflow seen for 1 << 63). Non-named shifts are unchanged. (Guarded by the NamedNumericShiftConv behavioral test — wide uint/uint64-backed and narrow uint8-backed named types; runtime hits this on arenaIdx(1 << arenaBits).)
The unsigned named-numeric path above gets a width-cast operand, but a signed constant operator expression whose target is a plain builtin int64 has no such cast, so C# would compute it in int32 and overflow at compile time in checked mode (CS0220): int64(1<<63 - 1), var d int64 = 1<<40 + 7, or 12345 * 1000000000 + 54321 passed to an int64 parameter. Go evaluates each as a constant in its int64 type. For a signed constant binary/shift expression whose folded value is outside the C# int32 range, the converter emits the folded 64-bit literal (9223372036854775807L, 1099511627783L, 12345000054321L) instead of the operator form — correct, and self-contained. In-range constants are unchanged (they keep the readable 1 << k form). (Guarded by the UntypedConstArithmetic behavioral test; runtime hits this in mgcmark/netpoll/runtime1.)
A signed fold whose resolved type is Go int (C# nint) additionally carries its own cast (2026-07-17): C# has no implicit long→nint conversion, so the bare L fold failed loudly at every non-assignment use — strings’ SplitN test table puts math.MaxInt / 4 in an n int struct field, and the composite-literal element emitted 2305843009213693951L against the nint field (CS1503; the Phase-4 blocker-map row B7a, one site each in strings and bytes). The fold now emits (nint)(2305843009213693951L) — the parenthesized cast form the assignment path’s nativeIntConstCastType already recognizes (wholeExprIsCastOfType), so assignments that previously received the whole-RHS wrap render byte-identically (the cast simply moves into the fold; NativeIntWideConstAssign’s n = (nint)(144115188075855772L) is unchanged). The value always fits — nint is 64-bit on all supported platforms — and the conversion is a runtime unchecked narrowing, never a C# constant expression, so no checked-context overflow arises. An untyped-int subtree keeps the bare L form (its enclosing context supplies the conversion), as does an int64 target (long is already exact). (Guarded by the NativeIntWideConstElement behavioral test — composite-literal elements, call arguments, and a var initializer, values verified vs Go.)
The in-range widened sibling (2026-07-17; sort’s test-suite conversion): a typed-int constant operator expression whose value fits int32 but whose emitted arithmetic an operand fold has widened to long — maxswap: 1<<31 - 1 (sort_test countOps): the whole value (2147483647) is in range, so no whole-expression fold applies, but the untyped inner shift folds to a bare 2147483648L, making the rendering 2147483648L - 1 — a C# long with no implicit conversion to the nint composite field (CS1503; assignments were equally unprotected, since nativeIntConstCastType also requires the whole value out of int32 range). convBinaryExpr now wraps such an expression in the same parenthesized cast at its own emission: (nint)(2147483648L - 1), position-independent. The trigger is shape-restricted (operandRendersWidenedFold): an operand must be an operator subtree whose overflowingConstLiteral fold is non-empty — a named untyped-const reference of the same value renders as its Untyped* wrapper, which narrows itself at the use site (maxInt - maxInt stays unwrapped; wrapping it would churn green emissions). (Guarded by the NativeIntWideConstElement extension — the 1<<31 - 1 element, call argument, and assignment, plus the wrapper-operand control, values vs Go.)
FLOAT literals in INTEGER contexts render their integer form (2026-07-17; sort’s test-suite conversion). A float literal directly typed integer by go/types has always folded (math.Inf(1.0) → 1 — the convBasicLit integer-form rule), but inside a constant operator expression the literal stays untyped float (go/types resolves the context on the outermost node only): search_test’s tests table writes {"descending 7", 1e9, …, 1e9 - 7} against n, i int fields, and the element rendered 1e9D - 7 — a C# double against the nint field (CS1503). Integer contexts now propagate through markUntypedConstContexts exactly like float/complex ones, and a float literal whose propagated context is integer emits its exact integer form: 1000000000 - 7 — the arithmetic stays exact C# int, implicitly convertible everywhere. Two soundness gates: a non-integral literal (1.5) keeps its loud D form (constant.ToInt exactness), and division does not propagate an integer context — Go evaluates an untyped-float constant / in exact rational arithmetic, so a nested quotient may be transiently non-integral (3.0 / 2 * 2 = 3) where folded operands would int-divide (3/2*2 = 2), a silently wrong value; those trees keep the loud double rendering. (Guarded by the NativeIntWideConstElement extension — 1e9 - 7 and 5e8 * 2 elements and a 2e9 - 8 call argument, values vs Go.)
UNSIGNED constant expressions fold under a much narrower trigger (2026-07-03): every other unsigned shape already has a working mechanism — a typed unsigned shift gets the width-cast operand ((uint64)1 << 40), an int64-range untyped subtree is folded by the signed arm when recursion reaches it ((281474976710655L) + arenaBaseOffset in runtime mranges), and a named-const reference renders via its Untyped* wrapper ((uintptr)m5 ^ 4 in runtime hash64). The one unfixable shape is an untyped constant operator subtree (a BinaryExpr) whose value exceeds int64 entirely: 1<<63 nested inside (1 << 63) - 1 — go/types lands the uint64 conversion on the outermost constant node, so the inner shift stays untyped, no width cast reaches it, and C# computes it in int32. int64((1 << 63) - 1 - (1<<63)%uint64(n)) (math/rand Int63n, CS0220) emits as (int64)(9223372036854775807UL - (((uint64)1 << (int)(63))) % (uint64)n): the constant subtree folds to UL, the standalone typed shift keeps its readable width-cast form. Gated to plain-uint64 underlying targets (constExprHasBeyondInt64UntypedOperatorSubexpr) — a native-width uintptr target would need a further cast the fold cannot safely synthesize, so that pre-existing caveat keeps its visible error. A first broader cut (any untyped subtree beyond int32, any unsigned target) regressed runtime’s hash64/mranges by stealing exactly those already-working shapes — the narrow trigger is load-bearing. (Guarded by the UntypedConstArithmetic extension — the Int63n shape, value-compared vs Go.)
uintptr was missing from the width-cast retype the paragraph above relies on (2026-08-09). Everything there is conditioned on “a typed unsigned shift gets the width-cast operand ((uint64)1 << 40)”, which is emitted by the shift retype in convBinaryExpr when isWideShiftType says the target does not promote to int. That predicate listed uint32/uint64/int64/nuint — and uintptr is the one wide unsigned type that does NOT render as a C# primitive: Go’s uint becomes nuint, but Go’s uintptr becomes golib’s uintptr STRUCT. So it fell to the narrow arm, which casts the result — precisely the thing the function’s own comment says does not help, because the shift has already happened in int32. 1 << (4 * goarch.PtrSize) emitted (uintptr)(1 << (int)(32)), C# masked the count to five bits, and the value was 1. golib’s uintptr carries a native nuint and declares operator <<(uintptr, int) over it, so a cast OPERAND (((uintptr)1 << (int)(32))) shifts at 64 bits exactly as nuint does; uintptr simply joins the list. Whole-corpus A/B: eight files, one mechanical family, six of them already-correct sub-int32 values reshaped (16 << 10, 512 << 20, 1 << 16, 1 << 20) and two live wrong answers — runtime/internal/math’s MulUintptr overflow fast path, whose guard read 1 instead of 2³² so every uintptr below MaxUint32 “overflowed”, and runtime/mpagealloc_64bit.go’s 1 << heapAddrBits, which computed 2¹⁶ where Go computes 2⁴⁸. The neighbouring 1<<(UintptrSize/2) - 1 was always right, which is what hid this: there the shift is an INNER node still typed untyped int, so the signed fold above takes it whole. CNR is byte-identical across all 576 behavioral packages — no behavioral project had the shape until now. (Guarded by the extended LargeUintptrConst behavioral test, already the MaxUintptr pattern’s home: a context-typed 1 << (4 * ptrSize), a literal-count 1 << 40, and the composite-literal table row beside its always-correct - 1 sibling, values vs go run; and by runtime/internal/math’s banked suite.)
FLOAT contexts need the same fold, and there the damage is silent rather than a compile error (2026-07-17). C# masks a shift count to the left operand’s width (5 bits for int), so an integer-literal constant in a float context — where no arm above applies, because the constant’s type is not an integer — evaluates in int32 and quietly yields the wrong number: var hf float64 = 1 << 63 emitted (1 << (int)(63)), i.e. 63 & 31 = 31 → int.MinValue, and hf / (1 << 60) divided by 2^28 (60 & 31 = 28) instead of 2^60, printing 34359738368 where Go prints 8. Go evaluates the constant in exact arithmetic and converts the result to the float type, so the converter emits the Go-evaluated value as a float literal — float64 hf = 9223372036854775808D, hf / (1152921504606846976D), float32 sf = 1099511627776F — which also carries the values 1<<63 puts beyond int64, where no L/UL fold could reach. Two gates keep the readable operator form everywhere it is already correct: the operands must be all integer literals (that is what makes C# evaluate in int32 — a float-literal operand like 1e18 * 10.0 already computes in double, and a named-const operand renders via its Untyped* wrapper), and the value must be outside int32 (1 << 10 computes identically in C# and is left alone). Unlike the int64 case, an inner shift is not rescued by recursion: Go promotes the operands of 1<<40 * 1.5 to a common kind, so the shift is recorded untyped float — invisible to the signed arm’s integer test — and folds from its propagated context instead (see markUntypedConstContexts under Constant Values); left bare it masks to 256 and silently yields 0.375. The full-stdlib A/B footprint was exactly six lines, every one a live wrong-value bug: math’s normalize (x * (1<<52) off by 2^32), cbrt (2^54), ldexp’s denormal factor (1.0/(1<<53)), pow’s 1<<53/1<<63 branch guards, and both math/rand Float64s — v1 divided by int.MinValue and so returned negative numbers, v2 divided by 2^21 instead of 2^53. (floatContextConstLiteral, convBinaryExpr.go; guarded by the UntypedConstArithmetic extension — the 1<<63/1<<60 float64 and 1<<40 float32 folds, the untyped float nested shift, plus in-range and float-literal controls that must keep their operator form, values vs Go.)
The same fold covers a complex128 context (2026-07-18). complex128 is float64-backed (System.Numerics.Complex), so an all-integer-literal shift whose result type is complex128 — a slice/array element like []complex128{1 << 35, 1 << 240} (math/cmplx’s hugeIn test inputs) — carries the identical int32-masking hazard: 1 << 35 emitted (1 << (int)(35)), which C# masks to 35 & 31 = 3 → 8 instead of 2^35, silently corrupting the complex value’s real part (its imaginary part is 0, so it is not int-literal arithmetic). floatContextConstLiteral takes the constant’s real part and folds it to a D-suffixed literal — 34359738368D, and the 73-digit exact form of 1<<240 — which C# parses to the same float64 the Go constant rounds to (a power of two lands exactly; a mixed value like 1234567891234567 << 40 round-trips to the nearest double, matching Go). complex64 is deliberately excluded: its float32 real part would overflow to a C# compile error for the beyond-float32 magnitudes this fold targets, and such constants do not arise. This cleared math/cmplx’s TestTanHuge, whose huge Tan inputs were being reduced to tiny masked values (8, 65536, 4096) — Tan then computed correctly on the wrong arguments. (Guarded by the ComplexConstContext behavioral test — 1<<35/1<<240/-1<<120/1234567891234567<<40 complex128 real parts, values vs Go.)
The same coercion is needed where the converter itself inserts a C# (int) cast on a named-numeric value — a slice bound (summary[sc+1:ec] with sc/ec of type chunkIdx), a shift count (1 << (d % 64) with d of type statDep), or the length of an unsafe.Pointer-to-array slice ((*[N]T)(ptr)[:n] → new slice<T>(new ReadOnlySpan<T>(ptr, (int)n)), since the ReadOnlySpan<T> constructor takes a C# int — see Slicing a pointer-to-array). A bare (int)(sc + 1) is CS0030 for the same reason, so the converter emits (int)(nuint)(sc + 1) / (int)(nint)(d % 64) — through the named type’s underlying basic; a plain nint/nuint length is narrowed (int)(n). Plain basic operands keep the bare (int)(x) form. (Guarded by the NamedNumericIntCast behavioral test; the Span length by StdLibInternalAbi.)
Defined types over a struct — forwarded fields. A Go type definition over a struct — type winlibcall libcall — makes the underlying struct’s fields accessible on the named type (w.fn), without promoting its methods. The named type is emitted as [GoType("libcall")] partial struct winlibcall; and the TypeGenerator wraps the underlying value (private libcall m_value;). For the underlying’s fields to be reachable, the generator forwards each as a ref-returning property over m_value:
private libcall m_value; // NOT readonly — see below
[UnscopedRef] public ref nuint fn => ref m_value.fn;
[UnscopedRef] public ref nuint n => ref m_value.n;
// … args, r1, r2, err
The underlying struct is resolved with GetStructDeclaration (same package, or a source-referenced package), and its members come from GetStructMembers. Crucially m_value is mutable (not the wrapper’s usual readonly), so a write through a pointer — c.Value.fn = fn, where c is a ж<winlibcall> and c.Value is ref winlibcall — reaches the real storage and persists. (The readonly→mutable choice is decoupled from the nullable-m_value form that only the lazily-allocated array backing needs.) Forwarding is skipped for a non-struct underlying (a named type over an interface or another named type) and for an underlying that contributes no fields, so those wrappers are unchanged. Composite-literal construction of such a type (winlibcall{fn: x}) is a separate, not-yet-handled case (the runtime accesses these only by field). (Guarded by the NamedTypeOverStruct behavioral test — write-through and read-back of forwarded fields through a pointer; runtime hits this on winlibcall over libcall, syscall_windows.go.)
The forwarded member must be a VARIABLE, and the underlying may be METADATA-ONLY (2026-07-31). Two independent defects in the paragraph above, both surfaced by index/suffixarray’s suffixarray_test.go — type index Index, where Index has an ints-typed field sa with len/get methods — and both fixed generally:
-
A get/set property is not a variable. In Go the selection is the underlying field, so
x.sa.len()binds a receiver the converter emitsthis ref(every value-receiver method is a ref extension) and&x.sa/x.sa.Push(…)take its address. A get/set property yields a value, so all of those were CS0206 (“a non ref-returning property or indexer may not be used as an out or ref value”). The forward is now a ref-returning property, which is a strict superset —w.fn = vstill assigns (through the ref), and the variable-requiring uses now bind.[UnscopedRef]is what makes it legal at all: a struct member returning a ref to instance state is CS8170 by default (the receiver could be a temporary), and the attribute states the ref’s lifetime is the receiver’s — exactly Go’s guarantee, since the selection aliases the wrapper’s own storage. C#’s ref-safety rules then reject at the call site precisely the cases Go also rejects (addressing a non-variable). Note the neighbouring array-view case below keeps its ensure-then-share-copy shape: its accessor must materialize a lazily-allocated backing first, which is a different problem than aliasing an existing field. -
A metadata-only underlying resolved to nothing.
GetStructDeclarationcan only see a struct whose SOURCE is in this compilation or in aCompilationReference; a real MSBuild build hands a<ProjectReference>to the compiler as compiled metadata, so a defined type over a struct in another package forwarded no members at all and every selection on it was CS1061.FindUnderlyingStructSymbolnow resolves the[GoType("…")]definition to itsINamedTypeSymbolwhen the syntax walk misses — trying the name as written (global::go.index.suffixarray_package.Index, the fully-rooted form the-testswhite-box bridge emits) and thengo.-rooted (time_package.Duration, the package-alias-qualified form ordinary cross-package emission uses, is not a CLR name) — andGetForeignStructMembersenumerates it. Membership mirrorsStructTypeTemplate’s metadata field scan: instance FIELDS plus the ref-returning, non-indexer PROPERTIES a referenced assembly’s generated wrapper exposes for its embedded and promoted members. Visibility is decided byCompilation.IsSymbolAccessibleWithinrather than a public-only test, which is Go’s own rule projected into C#: an exported field ispublicand always forwards, while an unexported one isinternaland forwards only where C# can reach it — i.e. the friend (InternalsVisibleTo) test assembly, which is precisely the same-Go-package case where Go permits the selection. Ordinary cross-package wrappers over foreign structs whose fields are unexported (type timeTime time.Time) therefore forward nothing, exactly as Go allows nothing.
Both fixes were needed for one package: with only (2), the CS1061 wall collapsed to the board’s originally-reported CS0206 at two sites — a worked example of charter §9’s root-cause layering (the first diagnostic moved rather than cleared). (Guarded by the DefinedTypeOverForeignStruct behavioral test — a ptlike sub-library supplies Outer{Name string; In Inner}, the parent declares type alias ptlike.Outer and reads a forwarded field, writes one, calls Inner’s value- and pointer-receiver methods through the forwarded field with the mutations observed afterwards, writes a nested element, converts back to the underlying, and reads the zero value — output-compared vs go run. It is the cross-assembly sibling of NamedTypeOverStruct, which covers the same-package case, and of DefinedTypeOverPkgType, which covers a cross-package defined type reached only by conversion, never by field.)
Defined types over an array-backed defined type — the IArray view. A second-level definition — type pallocBits pageBits, where type pageBits [8]uint64 is itself an array-backed [GoType] wrapper — is len()‘d and indexed directly in Go (runtime mpallocbits.go), which requires IArray on the outer wrapper (golib len(IArray); CS1503 otherwise, and the named-over-array indexing sites in mgcscavenge/proc/traceback fail the same way). The generator detects this in the bare-name branch — the resolved underlying struct contributes no declared members but its own [GoType] definition is an array form ([N]elem) — and implements IArray<elem> on the wrapper as a view (IArrayViewTypeTemplate). Every member delegates through a private view accessor that first touches m_value.Value on the mutable field — materializing the underlying’s lazily-allocated backing in the wrapper’s own storage — and then returns a value copy sharing that heap T[], so element refs land in the real storage. (Going through the plain copying Value property instead silently dropped writes on a zero-valued wrapper — the backing allocated on the copy — which is the historical pallocBits lost-writes trap, reproduced and pinned before the fix. A struct member cannot ref-return its own field — CS8170 — so the ensure-then-share-copy shape is the correct one; the (pageBits)(b) reinterpret conversions keep compiling and, once the backing exists, write through shared storage.) (Guarded by the NamedArrayWrapper behavioral test — len, index read/write, and a write via the (*pageBits)(b) reinterpret observed through the original, values vs Go; cleared runtime’s 5 pallocBits → IArray CS1503 plus a −3 CS0021 cascade of named-over-array indexing, 86 → 74 with the copy overload below.)
copy from a defined slice type. copy(dst, src) where src is a named slice type — type pMask []uint32, runtime proc.go’s copy(nidlepMask, idlepMask) — cannot bind the generic copy<T1,T2>(in slice<T1>, in slice<T2>): the wrapper implements ISlice<uint32> but is not a slice<T2>, and generic inference does not see user-defined conversions, so resolution fell onto copy(slice<byte>, @string) (CS1503 ×2 per call). golib adds copy<T1, T2>(in slice<T1> dst, ISlice<T2> src) — T2 infers from the implemented interface — copying element-wise through the interface indexer with the same min-length/convert semantics; a genuine slice<T> source still binds the more-specific slice/slice overload, so existing calls are unchanged. (Guarded by the same NamedArrayWrapper test — copy count/values plus post-copy independence of source and destination, vs Go.)
The wrapper also forwards the underlying’s field-box accessors. Taking the address of a wrapper’s field — &p.x on a *pinnerBits, where type pinnerBits gcBits (runtime pinner.go) — emits the box-accessor form Δp.of(pinnerBits.Ꮡx), whose owning type is the wrapper; without a forwarded accessor the static exists only on gcBits (CS0117). For every forwarded field (properties cannot be ref‘d and get none, matching the plain-struct template) the generator emits the accessor as a true ref through m_value into the underlying struct’s field: public static ref uint8 Ꮡx(ref pinnerBits instance) => ref instance.m_value.x; — a genuine ref chain into the wrapper’s own storage, so a write through the resulting box persists (a copy here would silently drop writes — the trap that sank an earlier pallocBits forwarding attempt). Emitted only when members are forwarded, which is exactly when m_value is mutable. (Guarded by the NamedTypeOverStruct extension — bump(&c.a) writes through the wrapper’s field address and the original observes it; cleared runtime pinner.go’s 3 CS0117, 89 → 86.)
Untyped constants in a typed-element context (append). Because an untyped constant renders as a bare C# int/double literal or an Untyped* wrapper, passing one as an append element to a typed slice trips C#’s overload resolution: append<T>(ISlice, params T[]) infers T from the element while the slice<T> overloads infer T from the slice, so append([]uint16, replacementChar) (or append(buf, 7, 8)) would pick slice<int> and fail (CS0121 / CS0029). The converter therefore casts an untyped numeric-constant append element to the slice’s element type, matching Go’s implicit conversion:
var a []uint16
a = append(a, replacementChar) // replacementChar is an untyped const
a = append(a, 7, 8)
slice<uint16> a = default!;
a = append(a, (uint16)(replacementChar));
a = append(a, (uint16)(7), (uint16)(8));
The same cast reaches an untyped numeric constant referenced through a cross-package SELECTOR.
isUntypedNumericConstArg had matched only a bare *ast.Ident, so append([]byte, tabwriter.Escape)
(go/printer’s block builder — tabwriter.Escape is const Escape = '\xff', rendered as a golib
UntypedInt) kept the ambiguity (CS0121 ×6). The gate now also inspects an *ast.SelectorExpr’s Sel
constant object, casting the element to the slice’s element type: append(block, (byte)(tabwriter.Escape)).
That same selector gate also feeds the deferred method-value arg cast — defer(Δsyscall.Seek,
Ꮡfd.Value.Sysfd, curoffset, (nint)(io.SeekStart), ref ᒐ) (internal/poll fd_windows.cs) casts the
const to the parameter type rather than the default-type wrap — and the regexp/syntax
unicode.MaxRune append; both are equal-or-better and compile.
A same-package untyped const (a bare ident) is unchanged. (Guarded by the CrossPkgUser extension —
append([]byte, CrossPkgLib.Sep) (rune ':') and append([]rune, CrossPkgLib.Precision) (int 2), both
cross-package untyped consts reached through a selector, output-compared vs Go; without the fix the appends
are CS0121.)
The parameter-type cast also reaches the lambda form of a go/defer call, not just the method-value
form. When the callee returns a value (or is a value-receiver method), visitGoStmt/visitDeferStmt
force the temp-param lambda goǃ(ᴛ1 => f(ᴛ1), arg) (see A value-returning goroutine callee is wrapped
in a discarding lambda); there the arg’s C# type drives ᴛ1’s inference, and the lambda body’s f(ᴛ1)
then needs ᴛ1 to be f’s parameter type. An untyped numeric const otherwise took convExprList’s
DEFAULT-Go-type cast, so ᴛ1 inferred the default (nint) and f(ᴛ1) failed — hash/crc32’s
go MakeTable(Castagnoli) (Castagnoli an untyped uint32 poly) emitted goǃ(ᴛ1 => MakeTable(ᴛ1),
(nint)Castagnoli), CS1503. convCallExpr now applies the parameter-type cast in the lambda form too, but
ONLY when the parameter differs from the const’s default type (untypedNumericConstArgDefaultType vs the
param’s underlying basic) — when they match, the existing default-cast path already yields the right type,
so overriding would only churn the golden ((nint)x→(nint)(x)). Proven zero-drift on the behavioral
corpus and the full stdlib reconvert (the fix fires only where a wider/other parameter demands it).
(Guarded by the GoUntypedConstArg behavioral test — go compute(poly) with a value-returning callee and
an untyped uint32-poly const, output-compared vs Go; without the fix the goǃ arg is (nint)poly and
the lambda body is CS1503.)
A string-literal spread — append(b, "runtime error: "...) (runtime error.go’s message builder) — renders the literal as a "…"u8 ReadOnlySpan<byte>, which has no spread property (.ꓸꓸꓸ → CS1061). The spread emission wraps a direct string-literal source in the member-accessible @string — append(b, ((@string)"runtime error: "u8).ꓸꓸꓸ) — whose ꓸꓸꓸ returns the Span<byte> the append<T>(slice<T>, params Span<T>) overload binds; this is the same wrap the string(r)... conversion spread uses (above). A non-literal spread source (a slice, a @string variable) is unchanged. (Guarded by the StringConvPostfix extension — two literal spreads appended and value-compared vs Go.)
A string-literal CONCAT as an object/interface vararg argument — runtime stack.go’s newline+tab join in print’s diagnostics — needs the same u8 suppression the direct literal argument already gets, propagated INTO the BinaryExpr’s operands: both halves otherwise render as "…"u8 spans, and a ReadOnlySpan<byte> cannot box to object (CS1503) nor be +-concatenated. The binary-expression conversion now honors an incoming BasicLitContext.u8StringOK=false, so the operands render as plain C# strings whose + and boxing are fine; the default context leaves every other path unchanged. (Guarded by the StringConvPostfix extension — a concat with an escape into an fmt.Println vararg plus a nested three-way concat, values vs Go.)
A []byte("literal") over a plain-text string literal feeds the zero-allocation u8 ROM span straight into the slice — []byte("hi") → slice<byte>("hi"u8) — via golib’s slice<T>(ReadOnlySpan<T>) factory (which copies the span into the slice’s backing array), rather than routing the literal through a heap @string first (the older slice<byte>((@string)"hi") allocated an @string and then converted it to byte[]). Both the general []byte/[]rune conversion path and the u8 literal keep their existing forms elsewhere; only the specific plain-[]byte-literal case is retargeted, gated to exactly what convBasicLit renders as a u8 span: a []rune literal keeps @string (it needs @string’s rune decoding, not raw UTF-8 bytes); a high-\xHH-byte []byte literal keeps the byte-array-backed @string (its bytes do not round-trip through u8); a NAMED byte-slice type (type htmlSig []byte) keeps its wrapper cast; and a string variable is already an @string. Not ambiguous with the array slice<T>(T[]) builtin — a u8 literal is a ReadOnlySpan<byte> (an exact match for the new overload), while an @string converts to byte[] but not to a span. (Guarded by the StringLiteralSliceConversion extension — plain-text, raw-backtick, and high-\xHH-byte []byte literals plus []rune and string-variable controls, output-compared vs Go; and confirmed across ~144 stdlib sites by the full reconvert.)
Typed arguments and already-explicitly-converted elements (uint16(r)) are left as-is.
Relatedly, when the shifted (left) operand of a shift is an untyped constant — 1 << k — Go gives the whole shift the type it assumes from context (e.g. uintptr when compared with a uintptr), but the bare C# literal makes the result int, which then cannot compare or combine with the typed operand (CS0034). The shift result is cast to its resolved type:
var u uintptr = 7
_ = u < 1<<8 // 1<<8 takes type uintptr
uintptr u = 7;
_ = u < (uintptr)(1 << (int)(8));
The narrow-width flavor of the same shift-retype is a behavioral requirement, not just a compile fix: a sub-int-width left operand (int8/uint8/int16/uint16) promotes to int in a C# shift, so the shift computes at 32-bit width with no wraparound at the type’s own width — where Go computes a shift in the operand’s type. byte(200) << 1 is 144 in Go (wraps at byte width) but 400 in the promoted C# int shift. The shift result is cast back to the shift expression’s resolved Go type, so a var, typed-const, and untyped-const left operand all wrap alike (the untyped-const flavor was historically correct only via the wrapper retype above; typed left operands got no cast at all and produced the unwrapped value):
var cb byte = 200
var k uint = 1
fmt.Println(cb << k) // 144: wraps at byte width
byte cb = 200;
nuint k = 1;
fmt.Println((byte)(cb << (int)(k)));
A whole-expression Go constant shift is skipped (Go constant arithmetic cannot overflow its type, and the wrap cast on a C# compile-time constant would even be rejected, CS0221), and right shifts take no cast (a narrow operand zero-/sign-extends into the int-width shift, so the result always fits the narrow width). A named narrow type routes through its underlying — (nb)(byte)(n << (int)(k)) — since a [GoType] conversion accepts only its exact underlying, never C# int. int32-and-wider left operands already shift at their Go width in C# and keep their existing forms. (Guarded by the NarrowShiftVarCount behavioral test — byte/uint16/int8/int16 left shifts by variable counts that overflow the narrow width, across var, typed-const, untyped-const, and named-type left operands, plus right-shift controls, output-compared vs Go.)
A C# compound shift-assignment (<<=/>>=) requires the shift count to be int; the count’s own (possibly unsigned/native-width) type is rejected — s.allocCache >>= (nuint)x is CS0019. So the count is cast to int (s.allocCache >>= (int)x). This applies whether the assignment target is a simple variable or a selector/pointer-field LHS (s.allocCache, a field reached through a *mspan) — both paths emit the same (int) count cast. (Guarded by the ShiftPrecedenceUnsigned behavioral test — simple-variable and struct-field shift-assigns with an unsigned count; runtime hits the field form in malloc/mbitmap’s allocCache bit walks.)
A related case is a computed constant mask under a native-int bitwise operator. i & ((1 << shift) - 1) or i &^ (blockSize - 1), where i is a uintptr/uint (C# nuint/uintptr) and shift/blockSize are native-int constants: the mask is a Go compile-time constant, but because the native const is emitted as a get-only property (package scope) or a plain local (function scope), not a C# const, the expression is not a C# constant, so it renders as a bare int — and nuint & int is CS0019 (no common type, and no implicit constant conversion since the operand is non-constant). The converter casts such a computed-constant operand to the native result type — (uintptr)i & (uintptr)((1 << (int)shift) - 1). A small bare literal (x & 7) is left alone (C#’s constant conversion fits it), but a large literal whose value exceeds the C# int32 range (uintptrMask & 0x00ffffffffff) is emitted by convBasicLit with its own (nint)/unsigned cast — so it is no longer a bare int and nuint & nint is CS0019 too; such a literal operand is cast to the native result type the same way (& (uintptr)(nint)1099511627775L). A named untyped-const reference is handled by the wrapper cast below. There is also a &^ (AND-NOT) twist: it is rendered & ~y, and ~ promotes its operand to int, so even a small constant operand (p &^ 15 → nuint & ~15 = nuint & (int)-16) is CS0019 — a negative int cannot convert to an unsigned native type, even as a constant. So a constant right operand of &^ with a native-int result is also cast to the native type, & ~(uintptr)15, performing the complement in that width (a non-constant native operand, p &^ mask, already complements correctly and is left alone). (All guarded by the NativeIntConstMask behavioral test — computed mask, large-literal mask, and small-literal &^; runtime exercises this in arena/page mask arithmetic such as arenaIndex/alignDown, mallocinit’s uintptrMask &, and os_windows’s ptr &^ 15 16-byte align.)
Similarly, when a named untyped numeric constant (emitted as the UntypedInt/UntypedFloat wrapper) is an operand of arithmetic with a concrete numeric type, the wrapper’s bidirectional implicit conversions can make the result resolve to the wrong type (a * two32, uint64 * UntypedInt, yields int — CS0029). The named-const operand is cast to the concrete operand’s type (comparisons resolve through the implicit conversion, so only arithmetic is cast):
const two32 = 1 << 32
var a uint64 = 100
_ = a*two32 + 3
UntypedInt two32 = /* 1 << 32 */ 4294967296;
uint64 a = 100;
_ = a * (uint64)two32 + 3;
A constant too large for int64/uint64 (or float64) is emitted as GoBigConst (=
System.Numerics.BigInteger), which has no implicit operator with the built-in numeric types.
Unlike an UntypedInt/UntypedFloat wrapper, that makes a bare reference a hard error in every
concrete numeric context, not merely a resolution hazard in arithmetic — so the cast belongs to the
reference itself (bigIntegerConstMaterialization), applied wherever go/types records a
concrete numeric type on it:
const below1e23 = 99999999999999974834176
var ftoatests = []ftoaTest{{below1e23, 'e', 17, "9.99999999999999748e+22"}}
_ = x > Two129 // Two129 = 1<<129
internal static readonly GoBigConst below1e23 = /* 99999999999999974834176 */
GoBigConst.Parse("99999999999999974834176");
internal static slice<ftoaTest> ftoatests = new ftoaTest[]{
new((float64)below1e23, (rune)'e', 17, "9.99999999999999748e+22"u8)}.slice();
_ = x > (float64)Two129;
Comparison was originally the only casting consumer (in convBinaryExpr), which left composite-literal
elements, call arguments, typed var initializers, assignments, returns, and channel sends emitting
bare — twelve BigInteger→double CS1503s in strconv’s ftoa_test.cs alone. Moving the cast to the
reference serves all of them at once, and the comparison arm was dropped so it no longer double-casts
((float64)(float64)Two129). Two properties make the context type reliable: go/types records the
converted type on the reference (inside []float64{…} the recorded type is float64, not
untyped), and Go only admits a constant where its value is representable — so a BigInteger-backed
value’s concrete context is necessarily float/complex, never a 64-bit integer that would overflow.
The reference is kept readable rather than folded to a literal, the same call
foldedNamedFloatConstLiteral makes for a bare reference. (Guarded by BigUntypedConstComparison,
extended from comparison-only to every position, with an in-range UntypedInt const as the
must-stay-uncast counter-control.)
An INTEGER expression over a GoBigConst constant folds — it has no 64-bit form
The (float64)Two129 cast above works because BigInteger converts to double. An integer target has
no such luck: (uint64)mask on a 128-bit BigInteger throws System.OverflowException at run time. That is
not a corner case — it is the shape of the Go standard library’s whole-width byte-classification bitmap
idiom (go/doc/comment’s isHost/isPath/isIdentASCII/importPathOK, net/textproto’s
validHeaderFieldByte/validHeaderValueByte), where a 128-bit untyped mask is legal precisely because
Go requires only the FINAL value of a constant expression to be representable:
const mask = 0 | (1<<26-1)<<'A' | (1<<26-1)<<'a' | (1<<10-1)<<'0' | 1<<'_' | /* … */ 1<<':'
return ((uint64(1)<<c)&(mask&(1<<64-1)) |
(uint64(1)<<(c-64))&(mask>>64)) != 0
Both halves are uint64-valued constants, so both must emit as the go/types-recorded folded value.
mask&(1<<64-1) already did — its 1<<64-1 operand subtree exceeds int64, which
constExprHasBeyondInt64UntypedOperatorSubexpr recognizes. The sibling mask>>64 has no such subtree: its
only unrepresentable operand is the reference, which the shift path retyped to the shift’s resolved width
(((uint64)mask).Rsh(64)) and threw. overflowingConstLiteral therefore also folds on
constExprHasBeyondUint64UntypedConstRef — any PROPER subexpression that is a named untyped-const
reference fitting neither int64 nor uint64 (the GoBigConst emission, isBigIntegerBackedConstRef):
GoBigConst mask = /* 0 | (1<<26-1)<<'A' | … */ GoBigConst.Parse("10633823862292363665388054147449749504");
return ((uint64)((uint64)((((uint64)1).Lsh((uint64)(c))) & (576284830442979328UL)) |
(uint64)((((uint64)1).Lsh((uint64)((c - 64)))) & (576460746666278911UL)))) != 0;
Unlike the sibling overflow folds this one is magnitude-independent: the operator form does not merely
compute in the wrong width, it throws, so a folded value that fits int32 (mask>>64 of 1<<70 | 1<<3 is
64) folds too. Scope: the unsigned arm covers uint64/nuint/uintptr; the signed arm is confined to the
64-bit-wide targets its …L / (nint)(…L) contract already covers (a narrower signed target keeps the
operator form, where the wrapper cast fails LOUDLY rather than silently computing the wrong value — no such
site exists in the stdlib corpus). The mask local itself stays emitted, unused, carrying the gofmt’d Go
constant as its comment: it is what makes the folded magic numbers readable back to the Go source (its
BigInteger.Parse hoists to a static field — see the next subsection).
Corpus footprint of the fold: exactly two files across the 302-package stdlib conversion
(go/doc/comment/parse.cs, net/textproto/reader.cs), both still compiling clean.
(Guarded by the UntypedConstWideMask behavioral test — the isHost mask, the &^-inverted
validHeaderValueByte mask, a small-valued high half, and a uintptr-target native-width mask, all
output-compared vs Go. Without the fold the uintptr arm is a hard CS0030 and the uint64 arms throw.)
A function-LOCAL GoBigConst hoists its parse to a static readonly field
A Go constant has no runtime existence — its value lives in the instruction stream — and GoBigConst
is the one C# constant projection with a real per-evaluation cost: BigInteger.Parse allocates its
bits array on every run. Emitted as a plain local, that parse re-ran on every call of the enclosing
function; net/textproto’s validHeaderFieldByte paid it 14 times per canonicalMIMEHeaderKey call
(560 B against Go’s 0) inside TestCommonHeaders’ want-ZERO testing.AllocsPerRun assert — and the
local was not even referenced, every use having been folded by the subsection above. An int-kind
function-local big constant therefore hoists its parse to one private static readonly field above the
function (the hoisted-string-literal pattern), and the local initializes from the field — a BigInteger
struct copy, which allocates nothing:
func validHeaderFieldByte(c byte) bool {
const mask = 0 | (1<<(10)-1)<<'0' | /* … */ 1<<'~'
…
}
// Hoisted Go big-integer constant (single parse; Go folds constants at compile time)
private static readonly GoBigConst maskᶜ = GoBigConst.Parse("116972063611741436228934278030836105216");
internal static bool validHeaderFieldByte(byte c) {
GoBigConst mask = /* 0 | (1<<(10)-1)<<'0' | … */
maskᶜ;
return …;
}
Field names are claimed package-wide (<name> + HoistedConstMarker ᶜ + ordinal on collision —
reader.cs declares maskᶜ and maskᶜ1 for its two functions’ masks), deterministic because files
convert sequentially; a -tests internal variant seeds from the production conversion’s claims exactly
as lifted type names do (productionHoistedConstOrdinals). Float/complex OVERFLOW constants keep the
per-call parse: their exact string may be a rational ("1/3") whose Parse throws, and a field
initializer would turn that per-call throw into a package-class TypeInitializationException.
Package-level big consts were already static readonly fields and are unchanged. (Guarded by
UntypedConstWideMask — four functions with local big-const masks, exercising the ordinal chain — and
by net/textproto’s validated TestCommonHeaders, whose want-zero assert is what surfaced the cost;
L11.)
The &^= (bit-clear) compound assignment on a narrow type
C# has no &^ (AND-NOT) operator, so Go’s a &^= b expands to a &= ~b. The ~ complement always promotes its operand to int, and int is not implicitly convertible to a narrower or unsigned LHS type (byte/ushort/uint/ulong/uintptr/nuint) — so flags &= ~b is CS0266. The complemented value is therefore cast back to the LHS type, inside unchecked because for a constant operand ~b folds to a negative int constant whose checked narrowing would overflow (CS0221):
h.flags &^= hashWriting // h.flags is uint8
h.Value.flags &= unchecked((uint8)~hashWriting);
An LHS type that int widens to implicitly (int/int32/int64) needs no cast and stays a &= ~b. (Guarded by the AndNotAssignNarrow behavioral test, which exercises both an ident LHS and a struct-field LHS — they route through different assignment-emission paths.)
A standalone ^x on uint8/uint16 truncates back to the operand’s type
The same int promotion has a silent-value face, not just a CS0266 face. Go’s ^x has x’s own
type, so on a sub-int UNSIGNED type the complement wraps to that width — ^uint16(5) is 65530. C#
promotes byte/ushort to int first, so bare ~x is -6: identical in the low 16 bits, but every
widening use then carries the sign bits, and no cast is required to make it compile:
w.writeBits(int32(^uint16(length)), 16) // compress/flate, stored-block header
w.writeBits((int32)((uint16)(~(uint16)length)), 16);
Without the inner truncation this wrote -6, and writeBits’ bits |= uint64(b) << nbits
sign-extended it across the whole 64-bit accumulator — so every level-0 (NoCompression) DEFLATE
stream was garbage and the decoder rejected its own encoder’s output with flate: corrupt input
before offset 59. It compiled clean and only compress/zlib’s TestWriter, which round-trips at
every level, caught it.
Only unsigned uint8/uint16 need this. A signed narrow type is already value-correct (C#’s ~
of a sign-extended operand equals the sign-extended Go result: int32(^int16(5)) is -6 in both
languages), and every type at least 32 bits wide (uint, uint32, uint64, uintptr, all signed
widths) keeps its own type under C#’s ~. A NAMED type routes through its golib wrapper’s operator.
A CONSTANT operand needs one more word. The truncation is then a C# constant conversion, and
those are checked at compile time no matter what the enclosing context says: ~(ushort)0 promotes to
the int -1, and (ushort)(-1) is a hard CS0221 — however correct the runtime truncation would
be. The all-ones idiom is exactly that shape, and it is the bound x/net/dnsmessage compares each
section count against, seven times in one file:
if len(m.Questions) > int(^uint16(0)) { // vendor/golang.org/x/net/dns/dnsmessage
if (len(m.Questions) > (nint)(unchecked((uint16)(~(uint16)0)))) {
So a Go-constant operand states unchecked; a variable operand does not, since C#’s default context
is already unchecked and the keyword would only add noise at flate’s ^uint16(length) sites.
Where the result is immediately narrowed back by the surrounding narrow-arithmetic cast the inner
truncation is redundant — takeU8(^a) renders takeU8((uint8)((uint8)(~a))). That is accepted
cosmetic noise: the two forms are value-identical, and the alternative (deciding at the unary site
whether the enclosing context widens) trades a silent-corruption hole for readability. (Guarded by
the AndNotAssignNarrow behavioral test’s widening cases — int32(^uint16(x)), uint64(^uint8(x)),
the uint32/int16 no-op controls, the narrow round-trip, and the constant cases int(^uint16(0)),
int(^uint8(0)) and uint64(^seed) over a typed uint16 const.)
Logical operators on a named boolean type cast through bool
A Go defined type whose underlying type is bool (type boolVal bool) is modeled as a [GoType("bool")] struct with an implicit bool conversion but no logical operators. Go’s !, &&, and || on such a value yield that same named type, so return !y / return x && y in a function returning an interface the type implements (go/constant’s UnaryOp/BinaryOp, returning the Value interface) still satisfies the interface. A bare !y / x && y in C# collapses to a plain bool — which cannot implicitly convert to the interface (CS0029), and ! has no operator on the struct (CS0023). The converter casts each operand through bool, applies the operator, then casts the result back to the named type so it keeps satisfying the interface:
case boolVal:
return !y // y is boolVal, result must be the Value interface
case boolVal y: {
return ((boolVal)(!(bool)y));
}
Binary &&/|| take the parallel form ((boolVal)((bool)x && (bool)y)). A predeclared-bool operand keeps the bare !x / x && y form (no golden churn). (Guarded by the NamedBooleanLogic behavioral test.)
Casting a negative value to a non-keyword type parenthesizes the operand
C# parses (T)-value as a cast only when T is a keyword primitive (int, long, nint, byte, …). For a using-alias (int64=long, uint64=ulong, rune=int, …) or a [GoType] named type (level), (int64)-1 / (level)-1 is instead parsed as type MINUS value — CS0075 (“to cast a negative value, you must enclose the value in parentheses”) and CS0119 (“‘long’ is a type, not valid in the given context”). So a cast whose operand leads with a unary +/- and whose target is not a C# keyword type parenthesizes the operand:
lvl := level(-1) // named conversion
mask := -1 << uint(bits) // int64-typed wide shift
var lvl = ((level)(-1));
var mask = ((int64)(-1) << (int)((nuint)bits));
Two emission sites carry it: the type-conversion cast (convCallExpr, castOperandNeedsParens) covers level(-1)/int64(-1), and the wide-shift left-operand cast (convBinaryExpr) covers -1 << bits (a wide shift type does not promote to int, so its left operand is cast to that type). A keyword target ((int)-1, (nint)-1) and a non-negative operand keep the bare form (no golden churn). (Guarded by the CastNegativeNamedType and ShiftNegativeWideConst behavioral tests.)
A cast’s operand asks TWO questions: parse ambiguity AND precedence
The section above answers the parse-ambiguity question — does (T)-1 read as a cast or as a subtraction? A cast’s operand poses a second, independent question that castOperandNeedsParens (a leading-sign TEXT test) cannot see: a C# cast binds tighter than every binary operator, so an operand that renders as a top-level binary expression has the cast claim its left operand alone.
The named-numeric identity-constant arm (convCallExpr — reached when go/types gives a constant operand the target type, so the conversion looks like an identity) asked only the first question. Both symptoms below are the same emission:
rf(3 / 2) // type rf float64 -- Go folds 3/2 as untyped INTEGER division: 1
renamedComplex64(3 + 4i) // type renamedComplex64 complex64
((rf)(3 / 2)) // was: ((rf)3 / 2)
((renamedComplex64)(3F + 4F.i())) // was: ((renamedComplex64)3F + 4F.i())
The first was silently value-changing and compiled cleanly: Go folds the constant expression in exact arbitrary precision before converting (untyped integer division gives 1), whereas ((rf)3 / 2) converts first and divides in the target’s own float arithmetic (1.5). Every named int/float type hides the defect this way, because its [GoType] wrapper supplies an operator for the mis-bound first leg. A named complex type has no float→named-complex conversion at all, so there the same emission is a hard CS0030 — which is how the class was found, holding fmt’s own test suite (fmt_test.go/scan_test.go’s renamedComplex64/renamedComplex128 entries).
Keyed on the AST, not the rendered text: the operand’s emission may be a call, a literal or a folded constant, and only the written expression says whether a binary operator is left exposed. A ParenExpr operand already renders wrapped, so the direct type test suffices. Unary operands are deliberately excluded — a cast and a unary operator share precedence and associate right, so (T)~0 already means (T)(~0); their only hazard is the sign ambiguity the section above covers. (Guarded by the NamedConstConversionPrecedence behavioral test, which is output-compared so the silent value divergence is caught, not merely the CS0030.)
A named complex type emits only Go’s complex operator set
The generated named-numeric wrapper (go2cs-gen InheritedTypeTemplate/NumericTypeTemplate) emits the operator surface of the underlying kind, and Go’s complex kinds define only ==/!=, +/-/*//, unary -, and ++/-- — no ordered comparisons and no % (the Go spec limits </<=/>/>= to ordered types and % to integers; C#’s System.Numerics.Complex and golib complex64 have neither operator either). A type C complex128 therefore gets no </<=/>/>=/% operators and no IComparisonOperators interface declaration — emitting them was CS0019 ×5 per type (first hit: testing/quick’s TestComplex64Alias/TestComplex128Alias, which compile-blocked the whole quick test host). Integer named types keep the full set including %/bitwise/shifts, and float named types keep ordering (and C#’s native float %, inert for converted Go, stays). Same kind-gate shape as the pre-existing complement/shift gate (GetComplementOperator). Guarded by the NamedNumericIncDec behavioral test’s named-complex block (++/--/arithmetic/equality on a type cx complex128).
Floating-Point Formatting
Go’s default rendering of a float — %v, %g, and the bare Println/Print/Sprint paths — is
strconv.FormatFloat(f, 'g', -1, bitSize): the shortest decimal digits that round-trip back to the same
float, laid out in 'e' form when the decimal exponent is below -4 or at/above 6, and 'f' form
otherwise. The exponent is lowercase, always signed, and always at least two digits.
.NET’s default/"R" formatting also produces shortest-round-trip digits, but presents them differently:
it flips to exponent form on its own thresholds and writes an unpadded, uppercase exponent. The two agree
far more often than they disagree, which is what makes the disagreement easy to miss — the gap only opens
at the ends:
fmt.Println(1000000.0) // Go: 1e+06 .NET default: 1000000
fmt.Println(1e-5) // Go: 1e-05 .NET default: 1E-05
fmt.Println(2.2250738585072014e-308) // Go: …e-308 .NET default: …E-308
fmt.Println(999999.0) // Go: 999999 .NET default: 999999 (agree)
The 6 is the whole story for %v: it is why 1000000.0 prints as 1e+06 while 999999.0 prints in full,
and it comes from strconv’s formatDigits, which pins the exponent threshold to a flat 6 whenever the
digits were the shortest round-trip (if shortest { eprec = 6 }) rather than to the requested precision.
The threshold is not 21 — that figure appears in an older reading of the rule and matches JavaScript’s
Number.toString, not Go’s.
The conversion: the baseline stub fmt (src/core/fmt/format.cs) reproduces strconv’s layout rather
than delegating to .NET’s presentation. It leans on an empirical equivalence, verified by differential
fuzzing (below): .NET and Go produce the same digits. .NET’s "R" is the same shortest round-trip, and
its "E<n>" rounds the exact binary value to n+1 significant digits with the same round-half-to-even
that strconv’s shouldRoundUp applies — including denormals, and exactly (not zero-padded) well past 17
digits, so %.40e of 0.1 agrees digit-for-digit. Only the presentation differs. So FormatFloat takes
.NET’s digits, reduces them to strconv’s decimalSlice shape (DecomposeDigits: significant digits, sign
and trailing zeros stripped, scaled so the value is 0.<digits> × 10^dp), and lays them out through direct
ports of strconv’s fmtE and fmtF. Fixed-point (%f with a precision) is the one case handed to .NET
whole — "F<n>" already matches fmtF exactly, and it wants digits at a decimal place rather than a
significant-digit count.
Verb defaults follow Go’s fmt: %v renders as %g, %F as %f; %e/%f default to a precision of 6
and %v/%g to the shortest round-trip; an explicit precision overrules either. float32 resolves its
digits as a single (((float)value).ToString(…), never widened to double first), so float32(1.0/3.0)
prints Go’s 0.33333334 and not the double’s 0.3333333333333333. Two Go quirks that a straight reading
of the rule misses, both caught by the fuzz:
-
%gpromotes a zero precision to one significant digit — and that promotion feeds the exponent-form decision too. strconv mutatesprecitself (case 'g', 'G': if prec == 0 { prec = 1 }), not merely the digit count, so%.0gof0.7is0.7. Applying the promotion only to the digit count leaves the threshold test comparing against 0, which rounds the value away to0. -
±Inf carries an inherent sign (
+Inf, never∞), the space flag demotes its+to a space, and Inf/NaN space-pad under the0flag because they do not look like numbers.
Verification. Beyond the fixed cases, a differential fuzz compared the stub against go run over
random float64/float32 bit patterns (so NaNs, infinities and denormals arise naturally), precisions 0–20
across %g/%e/%f/%G/%E, and values clustered on the threshold where the form flips: ~50,000 formatted
values over six seeds, all byte-identical. The %.0g promotion above was found this way — the hand-picked
cases all passed without it.
Scope. This is the hand-written baseline stub fmt, the proxy the behavioral corpus builds against.
The full conversion’s fmt calls the converted strconv, which is Go’s own digit code and needs none of
this. (Guarded by the FloatFormatExponent behavioral test — both thresholds from either side, 1e20/1e21,
1000000.0, the math.MaxFloat64/SmallestNonzeroFloat64 values, float32 counterparts, negative zero, and
every verb with and without precision, byte-compared against go run; ±Inf/NaN flag interactions live in
PrintfWidthFlags. The extreme values are spelled as literals because the converted math package’s
constants are themselves rendered lossily — MaxFloat64 as 1.79769e+308 — which is a separate converter
defect, deliberately not conflated with this one.)
A folded constant of a NAMED type carries its type in the fold
overflowingConstLiteral materializes a compile-time integer constant whose value falls outside the C#
int32 range, because C# would otherwise evaluate the operator expression in int32 and overflow
(CS0220). It read the constant’s type through Underlying(), so a constant of a defined type folded
to a bare basic literal and the Go type was simply lost:
d := 8 * time.Hour
secondsEastOfUTC := int((8 * time.Hour).Seconds())
var d = 28800000000000L; // a C# long, not a Duration
nint secondsEastOfUTC = (nint)(28800000000000L).Seconds(); // CS1929 — long has no Seconds
The compile error is the loud half; the silent half is d, which is now a long and prints as its
digit count where a Duration prints 8h0m0s. The fold now carries the named type in the same
parenthesized (T)(…) shape the native-int arm uses — (time.Duration)(28800000000000L) — which
wholeExprIsCastOfType already recognizes, so enclosing paths do not re-wrap it. The [GoType] wrapper
converts implicitly from its underlying, so the cast is always legal, and Go’s own parentheses around a
method-call receiver keep the postfix .M() binding to the cast rather than to the literal. Only
constants outside int32 reach this arm at all, so the corpus footprint is the handful of computed
time.Duration-class constants above that magnitude. (Guarded by the PackageNameShadowing behavioral
test, case 4.)
Nil and Zero Values
In Go, nil is the equivalent of C# null. Where possible, converted code uses the golib NilType with a default instance called nil (defined in go.builtin). NilType provides comparison operators so x == nil / x != nil work across the runtime types (slices, maps, channels, pointers, interfaces), each of which defines what “nil” means for it (e.g. a map<K,V> whose backing dictionary is null is the nil map: reads return the zero value, len is 0, ranging yields nothing, and a write panics — matching Go).
The same null-safe-zero-value principle applies to value types whose backing store is a reference. A zero-value string converts to @string s = default!, which runs no constructor, so the backing byte[] is null. Rather than NRE on the first read, @string treats a null backing as Go’s empty string "" for every read — length 0, no bytes to index/range, == "" is true, prints empty, and concatenation yields the other operand (var s string; s += "x" → "x"). Constructors still allocate, so only the default(@string) zero value relies on this. (Guarded by the StringZeroValueConcat behavioral test.)
A NARROW-UNSIGNED target folds a constant only when nothing else can make it compile
uint32(1<<32 - 1), *_C_pw_uidp(&sp) = 1<<32 - 2. Go evaluates an untyped constant expression
at arbitrary precision and requires only the RESULT to fit the target; C# evaluates the operands
themselves, and an operand past uint32 forces the literal path to emit a bare long — which has
no implicit conversion to uint/ushort/byte, so the assignment fails CS0266 even though
the value fits exactly. The fix folds the whole expression and casts the result:
// Go: *_C_pw_uidp(&sp) = 1<<32 - 2 (_C_uid_t = uint32)
_C_pw_uidp(Ꮡsp).Value = unchecked((uint32)(4294967294UL));
The FIVE conditions, and why each exists. This arm is deliberately the narrowest in the fold family, because every widening of it damaged readable emission somewhere else in the corpus. It applies only when ALL of:
-
the target is unsigned and narrower than
uint64— the wider arms already handle their own; -
the target is a plain basic type or an ALIAS to one, never a NAMED type —
basicat that point is the UNDERLYING type, soio/fs.FileMode(a nameduint32) arrives looking plain, and folding it erased the type on every mode expression in the corpus ((fs.FileMode)(ModeDevice | ModeCharDevice)→ a bareunchecked((uint32)(69206016UL))). A named target keeps the arm below, which carries its type in the fold; an alias has no distinct C# type to lose; -
no NAMED CONSTANT is referenced — those render through their
Untyped*wrappers, which is how every wider arm preserves them, and folding replacedmath/bits’x>>1 & (m0 & m)withunchecked((uint32)(1431655765UL)): arithmetic no reader can trace back tom0; -
an UNTYPED subexpression exceeds
uint32— a typed conversion carries its own width and emits correctly unaided, soruntime’s^uint32(0)/8 + 1never needed the fold, and counting it flattened the entireclass_to_divmagictable into 68 casts; -
the threshold is
uint32, not the target’s own width —(1<<16) - 1exceeds auint16but the emission already carries an explicit(ushort)cast and compiles (measured), soregexp/syntax’sRange16keeps its source form; only a barelonghas nothing to rescue it.
How the conditions were found — the method, not just the result. Each was exposed by a three-target corpus regeneration, never by the two packages the fix was aimed at: the local darwin build was green at every step. The site count fell 754 → 46 → 12 → 10 → 2 across six regenerations, and the two survivors are exactly the expressions that cannot compile otherwise. The lesson generalizes past this arm: a converter change is measured against the corpus, not against the file that motivated it — a fold that looks obviously correct at its motivating site can rewrite hundreds of unrelated ones, and only a full regeneration shows it.
Guarded by the ConstSubexprOverflow behavioral test, which already covered the construct
(u32 := []uint32{1<<32 - 1}); its golden re-baselined to the folded form with the Output phase
passing unchanged — the value never moved, only the spelling.
The THREE deref accessors of ж<T> — when each is needed, and how the converter picks
Establishing a local ref over a heap box (ref var p = ref Ꮡp.<accessor>) looks like one
operation but encodes different answers to one question: is this access the Go DEREFERENCE, and
what does Go say happens on nil at exactly this point? Consolidated here because the members
landed across separate arcs (their individual sections, linked below, carry the full derivations);
this is the map.
| Accessor | On nil | The Go semantics it encodes | How the converter KNOWS |
|---|---|---|---|
.Value |
panics immediately (Go’s message, even on bind) | this access IS the deref, and Go panics here — the ordinary pointer USE site (*p, ~Ꮡp, a read through the box) |
the DEFAULT everywhere except a pointer’s ENTRY alias; no special case applies |
.ValueSlot |
no check — the slot as-is | a read of the HELD value, never a deref: when the pointee is itself reference-like, *p legally yields nil (*(&err) of a nil error panics in neither language), so .Value’s null check would fire SPURIOUSLY on a legally-held null. Identical to .Value’s slot in every non-throwing case. Also where nil is structurally impossible (a freshly make-allocated box, heap(out …)) and in the reflection bridge’s field paths. |
by the POINTEE’S TYPE or by CONSTRUCTION — a box-of-pointer LOCAL, a named-result box, the bridge’s field walk. NOT at a pointer’s entry alias (see below) |
.DerefOrNull() |
defers — binds Unsafe.NullRef<T>, faults with Go’s panic on first USE |
Go defers the panic to the body’s own deref point: passing a nil *T to a function, or calling a method through one, is legal; the body RUNS, a side effect before the deref must happen, and the panic lands where Go’s would — after it, or never (delegated checkValid-style guards). |
STRUCTURALLY — EVERY direct-ж pointer ENTRY alias, RECEIVER and PARAMETER alike, unconditionally (no analysis, because the accessor is faithful whether or not the body guards), plus the pointer-reassignment re-alias and go2cs-gen’s ReceiverMethodTemplate bridge; see A nil RECEIVER is nil-deferring, not nil-safe and A pointer PARAMETER is nil-deferring for exactly the reason a receiver is
|
Why three and not one: the ENTRY alias and the USE site are different questions, and .Value
answers the second. .ValueSlot is different in KIND rather than in timing — it marks accesses
that were never dereferences in Go’s semantics at all, which no nil-policy accessor can express —
but it is not selected at an entry alias, where nothing can know whether the body will dereference
and the nil-policy question is the only one being asked.
There used to be a fourth, .DerefOrNil() — a nil-SAFE accessor handing back a shared
default(T) slot — and its retirement (2026-08-02) is what collapsed the set. It was admitted
by a body ANALYSIS: a pointer param the body nil-compares, one passed the untyped nil at a
same-package call site, or one whose first mentioning statement re-points it without dereferencing
(l = l.get() normalization). Wherever that analysis was RIGHT the silent zero was unobservable;
wherever it was wrong — and it could never be complete, because a body’s guard may be DELEGATED to
a callee it merely hands the pointer to — a deref Go says must panic instead read a silent zero.
Unifying every pointer entry alias on .DerefOrNull() made the analysis unnecessary in the first
place, so the accessor, the three analyses that fed it (collectNilSafePtrParams,
reassignedBeforeDerefParamName, and the package-wide nil-argument pre-pass) and their vestigial
receiver arms were deleted together — 382 net lines of converter. The golib method survives with
its own unit coverage, but converted code no longer emits it.
Canonical typed-nil pointer boxing
Go’s typed nil is a real value: any((*T)(nil)) is a non-nil interface carrying dynamic type
*T, %T prints *T, and the pervasive descriptor idiom reflect.TypeOf((*T)(nil)).Elem()
resolves the pointee type. A bare C# null erases all of that, so a nil→pointer conversion
renders in pointer context and yields the type’s canonical typed nil instance — one shared,
write-protected instance per pointer type (ж<T>.NilBox; a generated named-pointer wrapper’s
NilInstance), which the NilType implicit conversion returns:
var errorType = reflectlite.TypeOf((*error)(nil)).Elem()
var x any = (*int)(nil) // x != nil; %T prints *int
internal static reflectliteꓸType errorType = reflectlite.TypeOf(((ж<error>)nil)).Elem();
any x = ((ж<nint>)nil);
Supporting semantics, all structural (m_isNull — never the value-peeking IsNull, which remains
the dereference guard):
-
Pointer identity: a null reference and a nil box are the same Go nil pointer and compare
equal; a heap box holding a nil value (
ж<ж<T>>captured local) is a non-nil pointer holding nil — two such distinct addresses are unequal and neither equals nil (&p1 != &p2thoughp1 == p2 == nil), including when both slots share the canonical singleton. -
Interface adapters null-coalesce their receiver box to the canonical instance, so an
interface holding a nil
*Tkeeps its dynamic type, typed nils compare correctly across theany/interface boundary ((*A)(nil)-error ≠(*B)(nil)-error), andcase *T:matches with a nil pointee. - The non-generic
INilPointersurface exposes the structural predicate to runtime machinery holding a pointer only asobject(equality tails, the reflection bridge’sIsNil/Elem).
Every consumer that asks “is this THE nil pointer” must ask the structural predicate. The
managed-slot atomic.Pointer<T> (core/sync/atomic/type.cs) canonicalizes the nil pointer to a null
slot so a reference CompareAndSwap treats all nil *T values as equal — and its nilCanon helper
asked the value-peeking IsNull, so it collapsed a pointer to a nil value to nil as well. sync.Map
is built out of exactly that shape and lost both halves of it: e.p.Store(&i) with a nil any value
dropped the entry outright (load()’s p == nil then reported not-ok, so Range skipped it and
CompareAndSwap failed against it), and the expunged = new(any) sentinel — a real address holding a
nil interface — became indistinguishable from nil, so a deleted entry could not be told from an
expunged one and the whole dirty/expunge protocol degenerated. The predicate is now
ж<T>.IsNilPointer. The same conflation applied to atomic.Pointer[error], atomic.Pointer[func()]
and any **T slot (atomic.Pointer[*T]), all present in the corpus. (Guarded by
AtomicPointerToNil: Load/Store/Swap/CompareAndSwap over a pointer to a nil any, two
distinct new(any) sentinels, a pointer to a nil *int, and the genuinely-nil slot, output-compared
vs go run. Before the fix the guard panics with a nil-pointer dereference on its second line.)
(*T)(nil) conversion expressions are where the canonical instance is minted, and pointer
locals, parameters and fields keep plain null — their statically-typed world never needs the
type carried. The type is carried where it becomes observable instead: at the boundary into
interface space (below).
A target written as a pointer to a composite type literal — (*[]byte)(nil),
(*map[string]int)(nil), (*struct{ r int })(nil) — reaches the same rendering by a different
route. isTypeConversion resolves a star target through its types.Object, which a type literal
does not have, so these shapes fell through to the regular call path and emitted a bare cast of
default! ((ж<slice<byte>>)(default!)) — a null reference, type erased. They are now claimed
as conversions, and the typed-nil interception renders the target through convStarExpr rather than
the resolved name, because an anonymous struct/interface element must be LIFTED to a named C#
type and convStarExpr is the site that performs that lift (the plain name path emits an
unresolvable raw struct{…} signature). encoding/gob’s bootstrapType table is the whole idiom
in one place, and it is a package-init NRE without this — every reflect.TypeOf(…).Elem() in it
saw a null descriptor:
tBytes = bootstrapType("bytes", (*[]byte)(nil))
tReserved7 = bootstrapType("_reserved1", (*struct{ r7 int })(nil))
internal static typeId tBytes = bootstrapType("bytes"u8, ((ж<slice<byte>>)nil));
[GoType("dyn")] partial struct Δtype {
internal nint r7;
}
internal static typeId tReserved7 = bootstrapType("_reserved1"u8, ((ж<Δtype>)nil));
Guarded by TypedNilInterface (extended with the slice/map/anonymous-struct type-literal targets),
PointerToNilPointerIdentity, and NamedPointerReinterpret (the canonical singleton keeps its
object-reference nil compare working); part of the reflection-bridge Phase-3 chip (see
docs/phase4/DESIGN-reflection-bridge.md).
A nil converted to an unnamed MAP type is a cast, not an invocation. The star form above is one
half of the type-literal problem; map[string]int(nil) — no pointer, the map type literal written
directly — is the other, and it did not merely erase a type, it failed to compile. The
composite-literal arm of isTypeConversion claims a target only when target and argument share an
underlying type, and untyped nil’s underlying is itself, so the shape was never claimed as a
conversion and fell through to the regular CALL path. What that path emits for a call whose callee
is a type is the type followed by an argument list — map<@string, nint>(default!) — which is
CS1955, “non-invocable member map<TKey, TValue> cannot be used like a method”, and the whole
package fails. An untyped-nil operand against a map-underlying target is therefore claimed
explicitly, and the ordinary conversion renderer emits the cast:
fmt.Println(reflect.TypeOf(map[string]int(nil)))
fmt.Println(reflect.TypeOf(((map<@string, nint>)default!)));
The NAMED twin myMap(nil) was always correct — types.ConvertibleTo holds for it, so the general
named path claimed it and cast — which is why only the type-LITERAL spelling ever broke. The two
sibling nil-able type literals are deliberately left on the routes they are already on, having no
defect: []byte(nil) emits slice<byte>(default!), which is not an invocation of a type but a call
to golib’s real builtin.slice<T>(T[]) conversion helper — the same helper []byte("…") is emitted
against — and yields the nil slice; and (chan T)(nil) already renders as a cast. Claiming either
alongside the map would rewrite roughly twenty-five corpus sites to no effect.
The spelling matters, and it is why the corpus never showed this. The BARE map[K]V(nil) — the
broken one — appears in no standard-library production source; all thirteen of its GOROOT sites
are in _test.go files, and Go’s own suites lean on it heavily (fmt’s
{"%#v", map[int]byte(nil), …} table row, one of that package’s censused conversion roots;
reflect’s TypeOf/DeepEqual tables; encoding/json’s encode table; internal/reflectlite). The
PARENTHESIZED (map[K]V)(nil) reaches the conversion fork through convParenExpr instead and was
already emitting a cast, so its one production site — reflect/type.go’s
var imap any = (map[unsafe.Pointer]unsafe.Pointer)(nil) — compiled all along. Claiming the shape
in isTypeConversion now routes that site through the ordinary renderer too, which changes its
parentheses ((map<…>)(default!) → ((map<…>)default!)) and nothing else. So this is a
converted-test and end-user fix whose only corpus footprint is one line of re-parenthesization.
(Guarded by the UnnamedMapNilConversion behavioral test — four unnamed-map nil conversions
including composite and struct-keyed element types, a nilness/length/absent-key read proving the
converted nil IS nil rather than merely typed, and the named-map, named-slice, []byte, chan and
*int controls, output-compared vs Go.)
A HAND-OWN’s pointer parameter sees NilBox, never null — and the doctrine alone did not hold it
The rule above — “every consumer that asks ‘is this THE nil pointer’ must ask the structural predicate” — was written, correct, and violated 24 times on one platform before anything measured it. That is worth recording, because the reason is a gap between two true sentences in this same section rather than an author ignoring either of them.
Sentence one: (*T)(nil) conversion expressions mint the canonical instance, and “pointer locals,
parameters and fields keep plain null”. Sentence two: nil reaches a ж<T> through
implicit operator ж<T>(NilType) => NilBox. Both hold. What follows from them together is the part
neither states: a hand-own’s ж<T> PARAMETER is on the receiving end of a caller’s nil, so it
sees NilBox — a real StandardBox<T> whose .Value throws — and Ꮡx is null is FALSE for it.
A guard written that way takes the wrong branch and the dereference behind it faults.
Neither half of the predicate is sufficient alone, which is why the corpus form is a pair:
if (Ꮡrusage is not null && !Ꮡrusage.IsNilPointer) { ... } // and its inverse
uintptr addr = Ꮡrusage is null || Ꮡrusage.IsNilPointer ? (uintptr)0 : (uintptr)(nint)(&native);
A C# null is reachable at the same sites (an uninitialised ж<T>?) and .IsNilPointer on a
genuine null would itself throw. The ADDRESS arm needs the same predicate as the dereference: with
only the deref fixed, a syscall wrapper hands the kernel a non-zero pointer where the caller meant
nil — a quietly wrong call rather than a crash.
Measured 2026-09-02, corpus-wide over every tracked .cs under src/core, and the split was total:
syscall/linux/structclass_linux_impl.cs 17 sites and
syscall/linux/zsyscall_linux_amd64_impl.cs 7 sites carried the one-sided form with zero using
the predicate, while all four syscall/windows/* sites used it — one hand-own family written
twice with the check correct on one platform only, invisible on Windows because those functions do
not exist there. Every one of the 24 was a PARAMETER (Select, seedNativeFdSet, copyNativeFdSet,
FcntlFlock, Statfs, Fstatfs, Sysinfo, Adjtimex, Fstat, fstatat, wait4, Uname). The
crash that surfaced it was syscall.Wait4(pid, &status, 0, nil) — Go’s own os/exec wait shape.
The remedy that makes it stick is a guard, not more prose. corpusNilPointerGuard_test.go walks
every .cs under src/core in the converter’s own go test and fails on a Ꮡ-prefixed identifier
tested with is null/is not null alone. It is corpus-wide rather than hand-own-only because the
converter never emits the form (generated code compares with == nil), so the walk needs no
exception list and catches the next hand-own wherever it lands; comment lines are skipped, and an
empty walk is a FAILURE rather than a pass so it cannot go green over a hole.
reflect.Value.Interface() is a boundary into interface space, so it packs the typed nil too
The rule above says the canonical instance is minted where the type becomes observable — at the
boundary into interface space — and pointer slots themselves keep plain null, because their
statically-typed world never needs the type carried. reflect.Value.Interface() is one of those
boundaries, and it is the one a slot read arrives at: the Value’s data came out of a slice
element, an array element, a struct field, a map value or a reflect.New(...).Elem(), all of which
hold null for a nil *T. Handing that null straight out erases the type at the one call whose
entire job is to preserve it.
Go’s own form makes the obligation explicit. packEface builds an interface from a type and a
data word, so a pointer-kinded Value with a nil data word packs as a non-nil interface holding
(type=*T, value=nil). Managed storage has no data word to keep the type beside, so the bridge
reconstructs it from the Value’s static type — which makeTypedValue recorded when the Value was
built — and re-encodes a null pointer-kinded read as that type’s canonical typed nil:
in := make([]*Int, 1) // one zero-filled *Int element
v := reflect.ValueOf(in).Index(0)
i := v.Interface() // (*Int)(nil), NOT nil
data, err := i.(GobEncoder).GobEncode() // assertion SUCCEEDS; nil receiver dispatches
The consumer that proves it is encoding/gob: encodeGobEncoder is literally
v.Interface().(GobEncoder).GobEncode(), and big.Int.GobEncode opens with if x == nil because
Go guarantees it will be reached that way. With the type erased, i == nil is true, %T prints
<nil>, the assertion takes its failure arm, and the nil-receiver method never runs — which is the
whole of math/big’s TestGobEncodingNilIntInSlice / TestGobEncodingNilRatInSlice.
Two boundaries of the rule, both load-bearing:
-
Pointer kinds only. An interface- or func-typed slot holding
nullis the nil interface / nil func, and Go packs that as the nil eface. Re-encoding those would invert the bug. -
No new representation. The value handed out is
ж<T>.NilBox— the same singletonreflect.Zeroof a pointer kind already yields (GoReflect.ZeroValueOf) and every emittednil→*Tconversion already mints. So the packed value compares equal to a language-level(*T)(nil)and asserts through the ordinary witness machinery. A slot whose static type resolves to no canonical nil keeps itsnull, so the rule can only add type information, never substitute a wrong one.
The fabrication path (reflect.Zero) and the write path were already on this encoding; this is the
read path joining them, so there is one nil encoding system-wide rather than two.
Guarded by ReflectTypedNilInterface, which runs typed nil → Interface() → == nil → type assert
→ nil-receiver dispatch across every slot kind that funnels through makeTypedValue, each paired
with a non-nil sibling so a blanket substitution fails as loudly as the erasure did, and pins
Elem() of a typed nil as still invalid (how a walker tells a typed nil from a pointer to a zero
value).
⚠ The emission side is a separate, open question. A typed nil crossing into an interface in
ordinary converted code — not through reflection — still collapses, because the pointer slot really
does hold null and the conversion site is not always able to see that it needs the box. Closing
that changes what == nil means for every converted interface and is a design decision, not a fix;
encoding/gob’s own TestNilPointerInsideInterface is its standing witness.
A pointer crossing into an interface carries its static type, however the pointer was produced
The rule the boxing above is a special case of:
A Go POINTER entering INTERFACE space is represented by its pointer BOX, carrying its static pointee type — however the pointer was produced.
Go’s interface value is a (dynamic type, value) pair, so a pointer in one is never merely an
address: any((*T)(nil)) is a non-nil interface whose %T prints *T and whose x.(*T)
assert succeeds with a nil result. Two managed renderings break that, and both silently:
- a bare
nullboxes as nothing at all — the type is gone,x == nilanswers Go’sfalsewithtrue, the assert fails, and the reflection bridge finds no descriptor; and - a deref-aliased pointer (
ref var p = ref Ꮡp.DerefOrNull()) rendered by its value alias boxes a copy of the pointee — dynamic typeTwhere Go says*T, pointer identity gone, and a nil one panics at the box instead of crossing intact.
A non-empty interface target already satisfies the rule and takes nothing from this: its
conversion goes through a generated adapter that holds the box and null-coalesces it to the same
canonical instance (AdapterImplTemplate). The empty interface (any) has no adapter — C#
boxes the value directly — so the treatment is emitted, as the golib ж<T> extension
OrTypedNil() (box ?? ж<T>.NilBox; TypedNilBoxAccessor):
var ip *int
var st struct{ P *AErr }
sl := make([]*BErr, 1)
fmt.Printf("%T %T %T\n", ip, st.P, sl[0]) // *int *main.AErr *main.BErr
ж<nint> ip = default!;
main_st st = default!;
var sl = new slice<ж<BErr>>(1);
fmt.Printf("%T %T %T\n"u8, ip.OrTypedNil(), st.P.OrTypedNil(), sl[0].OrTypedNil());
Why the BOUNDARY and not the producers. Emitting the canonical instance for a pointer’s zero
value instead would be incomplete by construction: var p *T is an emission the converter owns,
but a struct’s zero value, a make([]*T, n) element and a map miss are not — they are C# default,
which no emission intercepts. The boundary is finite and enumerable; the producers are not. It is
the same set of slots an untyped constant boxed as any
already routes through, for the same reason (a value’s Go dynamic type must survive being boxed):
call arguments including the variadic ...any of the fmt family, composite-literal elements, keyed
and positional struct fields, map keys and values, var initializers, assignments, returns,
channel sends, and an explicit any(p) conversion. (The positional form is the one encoding/gob’s
TestNilPointerPanics table is written in — []struct{ value any; mustPanic bool }{{nilStringPtr,
true}, …} — where every nil row has to arrive as a typed nil for gob to panic on it as Go does.)
Three BUILT-INs take an any slot without ever passing a declared parameter, so each applies the
boundary at its own emission arm rather than through the parameter loop: panic’s value (a
recovered typed nil must still answer r.(*T)), an []any append element (the existing
element-to-any cast then wraps the result), and an any-keyed map’s delete key — which has to
box to the same value the store did, or it matches no entry. None of the three has a corpus site
today (every panic argument in the standard library is an address-of composite, so the census is
zero), but they are the same boundary and the guard exercises all three.
A FORWARDED multi-value return is one more slot in that enumeration, and it was the one the
enumeration missed. return f() where f returns (*T, error) and the function’s own results are
(any, error) cannot convert in place — C# tuple conversions do not consult user conversions
element-wise — so the call is deconstructed into temporaries and each element converts on its own.
Routing the any element through the ordinary interface-conversion machinery is wrong precisely
because any has no adapter to hold the box: with no arm to take, that route falls through to
its pointer-DEREF prefix and boxes a copy of the pointee. The element already is the box, so it
takes the boundary treatment directly:
func parsePublicKey(…) (any, error) {
…
return ecdh.X25519().NewPublicKey(der) // (*ecdh.PublicKey, error) into (any, error)
}
var (ᴛ1, ᴛ2) = ecdh.X25519().NewPublicKey(der);
return (ᴛ1.OrTypedNil(), ᴛ2); // NOT (~ᴛ1, ᴛ2) — that boxes a PublicKey VALUE
crypto/x509’s parsePublicKey and parsePKCS8PrivateKey are the corpus sites — the only two, and
the deref is what made %T, reflect.TypeOf and every case *ecdh.PublicKey type-switch arm on the
result disagree with Go, since case ж<ecdhꓸPublicKey> can never match a boxed value. The
ASSIGNMENT sibling of this arm (c, err = sd.dialTCP(…)) is not affected: its target list is
screened for non-empty interfaces only, so an any target never reaches the conversion machinery
there at all. (Guarded by the MultiValueReturnOrder behavioral test’s identity half — a
(*thing, error) call forwarded into (any, error), then read back through a type switch and both
assertions; before the fix the switch takes the case thing arm and the *thing assertion panics.)
The scope is a genuine *T (a types.Pointer). unsafe.Pointer is a Basic and renders as a
struct, and a NAMED pointer type renders as its generated wrapper struct — neither can be a null
reference, so neither has anything to carry. An address-of (&x), a new(T), a (*T)(nil) and a
pointer CONVERSION of any of those ((*Buffer)(&b), log/slog’s buffer pool) render non-null by
construction and are left bare.
Corpus footprint: 465 sites across 145 files (A/B of two seeded whole-stdlib reconverts), plus
one site where the deref-alias preamble became dead because the pointer now renders as its box —
net/http’s http2h1ServerKeepAlivesDisabled(hs *Server), whose var x any = hs had been boxing a
Server VALUE and then interface-asserting it, so its doKeepAlives probe could never match the
pointer method set. Guarded by the extended TypedNilInterface (declared/field/element/map-miss
nils through every one of those slots, %T, ==, and a type assert, output-compared vs go run;
before the fix it reports dpi==nil true where Go says false and panics dereferencing a nil box
on the return path).
Pointer-to-interface assignment through selector fields
A selector assignment whose LHS field is an interface (h.d = s) uses the type of the whole selector expression, not just the selected identifier name, when deciding whether to wrap the RHS in an interface adapter. If the RHS is a pointer-typed identifier, the adapter receives the pointer box so a dereferenced value alias is not copied into a pointer-only implementation. The generated form matches other pointer-to-interface conversion sites:
func assignDescriber(h *holder, s *Setting) {
h.d = s
}
internal static void assignDescriber(ж<holder> Ꮡh, ж<Setting> Ꮡs) {
ref var h = ref Ꮡh.Value;
ref var s = ref Ꮡs.Value;
h.d = new SettingжDescriber(Ꮡs);
}
This is intentionally keyed on selector/index expression type instead of the root identifier, so struct fields such as go/types’ operand.expr ast.Expr and ordinary behavioral fields both take the same path. Guarded by PointerInterfaceStructField, including the assignment case after the struct-literal cases.
Empty Interface (any)
In Go, every type satisfies the method-less interface interface{}, now spelled any. This operates fundamentally like .NET’s System.Object, so the converter maps the Go empty interface to any (a global alias for object). For example, a Go func(i interface{}) becomes void f(any i), and a map[any]string becomes map<any, @string>.
A string literal in an any slot boxes through @string — as (@string)"…"u8
A Go string literal normally emits as a "…"u8 ReadOnlySpan<byte> (which converts implicitly to @string). But a ReadOnlySpan<byte> has no conversion to object, so a string literal RETURNED (or returned as a tuple element) where the result type is the empty interface fails with CS0029 — testing’s func (f *chattyFlag) Get() any { return "test2json" }. Such a result must box a golib @string (preserving Go string identity for a later x.(string) assertion), so visitReturnStmt renders the literal as (@string)"…"u8 for an empty-interface result element:
[GoRecv] internal static any Get(this ref chattyFlag f) {
if (f.json) {
return (@string)"test2json"u8; // NOT a BARE "test2json"u8 (CS0029)
}
return f.on;
}
The cast and the u8 suffix are independent decisions. The cast is what the slot requires — it turns
the span into an @string, which boxes with Go’s string dynamic type. The suffix then just decides where
the literal’s bytes come from: u8 makes them a compile-time constant in the assembly’s data section,
while a bare C# "…" is a UTF-16 constant that Encoding.UTF8.GetBytes has to transcode on every
evaluation of the site (measured 2.1–2.4× for ASCII, 4.2× for non-ASCII). The two were coupled for a
while — the emitter derived “no u8” from “needs the cast” — and every any position paid the transcode.
They are now separate flags (castToGoString, u8StringOK), and every any slot takes the combined form.
The coupling had a second, load-bearing consumer: convBinaryExpr suppressed u8 inside a string
concatenation whenever the enclosing slot had u8 off, because two u8 operands fold (C# folds UTF-8
literal constants) into a single ReadOnlySpan<byte> that then has no boxing conversion — print("\n" +
"\t") in runtime’s newstack diagnostics is CS1503. Splitting the flags would have silently re-enabled
u8 there, a compile break that CNR cannot surface (the emitted text is legal-looking; only the corpus
build fails). Concat suppression therefore has its own signal, BasicLitContext.spanTargetUnsupported,
which says “this slot cannot hold a bare span” and is set by every span-hostile site — any positions,
ValueTuple elements, attribute (struct-tag) arguments, panic’s object parameter, a deferred call’s
generic type-parameter slot — independently of how a STANDALONE literal renders there. It propagates into
nested operands, so "a" + "b" + c stays suppressed all the way down.
resultParamIsInterface excludes the empty interface (andNotEmptyInterface), so the interface-conversion arm never fires for any; the per-element context sets castToGoString on (and leaves u8StringOK on) instead. Only string basic-literals consult those flags, so a non-string any result is unaffected. Also corrects a latent semantic bug in the multi-result form (return "<no value>", true from a (any, bool) result rendered a raw C# string, which would fail a Go x.(string) assertion). Guarded by InterfaceCasting.
The same boxing applies to an assignment whose target’s static type is the empty interface — a plain
local (arg = "<nil>", go/types format.go’s sprintf over an any range variable, CS0029), a
selector/index target (h.value = "field"), and a mixed-statement reassignment all render the literal
(@string)"…"u8. visitAssignStmt threads the same castToGoString-on literal context
into each RHS conversion site when lhsIsEmptyInterface reports the target is any (the NON-empty
interface wrap stays with convertExprToInterfaceType, which the empty interface deliberately bypasses).
(Guarded by the AnyStringLitAssign behavioral test — an any local, an any-typed range variable, and
an any struct field each assigned a string literal, then type-switched on string, output-compared vs Go.)
The same boxing applies to every composite-literal position whose declared slot type is the empty
interface — the interface-wrap machinery deliberately bypasses any there too, so a string-literal
element otherwise renders either as the u8 span (no conversion to the generated object slot —
CS1503/CS0029) or as a bare C# string (compiles, but boxes a System.String, so a later Go
x.(string) assertion or case string: fails at runtime):
type pair struct { label string; value any }
p := pair{"tag", "val"} // positional field
n := &node{inner: "hi"} // keyed field (typed, elided, and pointer-elided forms alike)
m := map[string]any{"k": "mv"} // map value
mk := map[any]int{"ky": 7} // map key
s := []any{"a", "b"} // slice/array element
sp := [3]any{1: "sp"} // sparse-array element
var p = new pair("tag"u8, (@string)"val"u8); // NOT bare "val" (wrong boxed identity)
var n = Ꮡ(new node(inner: (@string)"hi"u8)); // NOT a BARE "hi"u8 (CS1503)
var m = new map<@string, any>{["k"u8] = (@string)"mv"u8};
var mk = new map<any, nint>{[(@string)"ky"u8] = 7};
var s = new any[]{(@string)"a", (@string)"b"}.slice();
var sp = new array<any>(3){[1] = (@string)"sp"u8};
(p’s FIRST element is the plain string field — a positional struct element in a string slot now
renders u8 too, matching what the keyed and elided forms already emitted; the slice/array any
ELEMENT form is the one position still on the bare-cast rendering.)
Keyed elements resolve their target slot in convKeyValueExpr (struct field via info.Uses; map/sparse
element and map key via the threaded composite type) and take the same castToGoString-on literal
context; positional struct fields and slice/array elements flip the per-element flags
(useGoStringArg on, u8StringArgOK left on) that convExprList feeds each element’s literal context. A TYPE-PARAMETER slot is excluded even though its underlying constraint is an
interface (isEmptyInterfaceTarget) — a ~string-constrained field takes the literal directly. Only
string basic-literals are affected; every non-any slot keeps its exact prior form. (Guarded by the
AnyStringLitComposite behavioral test — all the shapes above, each read back through a string
type-switch to prove runtime identity, output-compared vs Go.)
The same boxing applies to a channel send whose element type is the empty interface — both the
statement form and the select-case registration form. The send value previously converted with no
target-type context at all, so the literal’s default "…"u8 span failed against the channel’s
in object send parameter (CS1503):
ch := make(chan any, 1)
ch <- "text" // statement send
select { case ch <- "sel": … } // select-case send (registration form)
ch.ᐸꟷ((@string)"text"); // NOT "text"u8 (CS1503)
switch (select(ch.ᐸꟷ((@string)"sel", ꓸꓸꓸ))) { … } // registration form takes the same box
Both send positions route through a shared convSendValueExpr (visitSendStmt.go), which resolves
the channel’s ELEMENT type and applies the same isEmptyInterfaceTarget/isStringBasicLit gate as
the assignment and composite-literal positions (a type-parameter element is excluded; only string
basic-literals are affected). The same helper also activates the NON-empty interface element wrap —
see Maps and Channels. (Guarded by the AnyStringLitChanSend behavioral test —
statement and select-case sends read back through a string type-switch and an x.(string)
assertion to prove runtime identity, output-compared vs Go.)
An untyped constant boxed as any boxes at Go’s DEFAULT TYPE
The numeric twin of the @string boxing above. Go materializes an untyped constant into an interface
at its default type: untyped int → int (go2cs nint, an IntPtr), untyped rune → rune
(int32), untyped float → float64. The boxed CLR type must match, because every observation of an
interface value dispatches on it: x.(int) (emitted x._<nint>()) panics on a boxed Int32
(interface conversion: interface {} is int, not int — both sides print “int”, but one is Int32,
one is nint); a case int: type switch falls through; golib AreEqual bails early on
leftType != right.GetType(); and fmt’s printArg type-switch drops to its reflection fallback.
Two renderings need a cast, for different reasons:
- A bare int literal (or literal-only arithmetic) in the int32 range:
convBasicLitrenders it as a plain C# integer literal, which isSystem.Int32. (An int constant outside int32 range already renders(nint)…L, and a rune literal renders(rune)'A'/ a float literal2.5D— all already the default CLR type, so those need nothing.) - Any expression referencing a named untyped constant (
const fsize = 5), whichvisitValueSpecemits as a golibUntypedInt/UntypedFloatwrapper struct, never a CLR number —fsize + 1evaluates through the wrapper’s operator overloads and boxes the STRUCT, which matches no Go type at all. This holds at every magnitude and for every wrapped kind, which is why the int32-range test applies to the literal rendering only.
The cast applies at every empty-interface position — call argument (variadic ...any included),
var-spec, assignment, return, channel send, slice/array element, keyed struct-field, map value, map
KEY (composite and index alike), and an explicit any(...) conversion:
fmt.Sprintf("%s.v%d.%d", GOARCH, 8, i) // variadic ...any argument
fmt.Sprintf("%s%c%03d", d, os.PathSeparator, seq) // named untyped RUNE const under %c
v.Store(42) // non-variadic any argument (atomic.Value.Store)
var a any = 7 // var-spec
b = 8 // reassignment
func r() any { return 42 } // return
ch <- 3 // channel send (chan any)
_ = []any{5} // slice/array element
_ = map[string]any{"k": 9} // map value
_ = map[any]string{12: "x"} // map key (and the matching m[12] lookup)
_ = holder{v: 3} // keyed struct field
_ = any(7).(int) // explicit conversion to any
fmt.Sprintf("%s.v%d.%d"u8, GOARCH, (nint)(8), i);
fmt.Sprintf("%s%c%03d"u8, d, (int32)(os.PathSeparator), seq);
Ꮡv.Store((nint)(42));
any a = (nint)(7);
b = (nint)(8);
internal static any r() { return (nint)(42); }
ch.ᐸꟷ((nint)(3));
_ = new any[]{(nint)(5)}.slice();
_ = new map<@string, any>{["k"u8] = (nint)(9)};
_ = new map<any, @string>{[(nint)(12)] = "x"u8}; // and m[(nint)(12)]
_ = new holder(v: (nint)(3));
_ = ((any)(nint)(7))._<nint>();
untypedConstBoxCast (convCallExpr.go) drives the decision and returns the C# cast type (or none).
The constant’s kind comes from info.Types[arg] — the type go/types has already DEFAULTED for the
interface slot — so a literal (42), a unary (-5), a binary (1 + 2), and a named untyped const are
all classified by one rule. Whether the rendering is a wrapper struct comes from
exprRendersUntypedConstWrapper, which walks the expression for an *ast.Ident resolving (via
Info.Uses) to a *types.Const whose OWN declared type is UntypedInt/UntypedRune/
UntypedFloat — info.Types[arg] cannot answer this, since it reports plain int for a literal and a
named untyped const alike. A defined-type-over-int constant (type MyInt int) is excluded (its box is
the [GoType] wrapper, asserted as MyInt). Call arguments reuse the per-argument castArgToType
plumbing; the other positions wrap through boxUntypedConstAsDefaultType.
Deliberate exclusions and known residues:
- A type-parameter parameter constrained by
any(func f[T any](v T)) reads as an empty interface here too, but its instantiation binds the argument to the concreteT(int → thenintparameter), where a bare int literal already converts implicitly. Every gate usesisEmptyInterfaceTarget(which excludes type parameters), unlike the u8-span→@stringcase, where aK=stringparameter genuinely needs the cast to bind. - An untyped COMPLEX constant is out of scope: a named one renders as golib
GoBigConst(aBigInteger.Parseof the literal text —visitValueSpec’swriteUntypedConstpath, with its own standing TODO), a separate pre-existing gap that a(complex128)cast would not close. A complex literal renders1D + 2D.i()and already boxes ascomplex128. - Untyped bool constants need nothing:
true/falserender as C#bool, already the Go type. Untyped string constants DO need the cast, but under the mirror-image rule — see below. - A cast onto an expression that already renders at the default type — a typed constant
(
const seqFirst int = iota→const nint), or a local untyped const thatvisitValueSpectightened to a concrete C# type (const float64 derived = 7) — is a harmless no-op the predicate does not attempt to suppress. It cannot know the declaration’s chosen C# spelling from the argument alone, and an extra cast is cosmetic noise where a missing one is a runtime divergence.
A variadic ...any slot used to be carved out for literals, on the theory that a boxed Int32
formats identically to nint under %d/%v so the cast was redundant noise on the most common call
pattern. That is wrong wherever the boxed value is compared rather than printed:
encoding/base32’s testEqual("Read after EOF, n = %d, expected %d", n, 0) boxed n as nint and 0
as Int32, AreEqual compared the dynamic types first, and the assert fired with a message that reads
as an equality (n = 0, expected 0). It is also wrong for the %!-verb path, where the full-conversion
fmt names the argument via reflect.TypeOf(arg).String() and a boxed Int32 reports "int32"
instead of Go’s "int". The carve-out is gone; the corpus-wide footprint of removing it is two
lines in two files (go/token/position.cs, internal/buildcfg/cfg.cs — Go’s own stdlib almost never
passes a bare int literal into a ...any slot), plus two (int32)(os.PathSeparator) lines in
testing/testing.cs from the rune-kind arm, which is a genuine %c fix for the Phase-4 test host.
The any map KEY used to be excluded too, because golib’s map uses the default Dictionary
comparer (no numeric normalization — nint(6) != Int32(6)) and leaving both the composite key and a
literal index uncast kept map[any]int{6:1}[6] round-tripping. That self-consistency only held for
literal-vs-literal: a lookup by a real int VALUE (m[n], necessarily boxed nint — the only form Go
can even distinguish) MISSED. convIndexExpr now applies the same cast to an untyped-constant index of
an any-keyed map, so store and lookup agree on nint and both forms hit — while m[int32(6)]
correctly misses, as Go requires.
(Guarded by the UntypedIntInterfaceBox behavioral test — each position read back through an
x.(int) assertion or an int/int32 type switch, output-compared vs Go — and by
AnyBoxedUntypedConst, which pins the whole default-type class in one program: variadic and
non-variadic slots, named/literal/rune/float/beyond-int32 constants, []any/map[any]/map[K]any/
struct-field/chan-send/return/explicit-conversion positions, the map[any] store↔lookup round-trip
plus its int32 miss, and the dynamic types a type switch reports — output-compared vs go run. The
pre-fix converter diverges on 13 of its lines.)
An untyped nil in a VARIADIC slot states its element type for a different reason — arity, not dynamic type. The constants above are cast so the box matches Go’s; a bare nil is cast so the argument exists at all. C# prefers a call’s normal form over its expanded one whenever the argument converts to the params ARRAY, and a typeless default! converts to any[] exactly as readily as to any — so exec(t, db, "INSERT|t|id=10,name=?", nil) (database/sql’s sql_test.go) emitted exec(…, insertTId10Nameˢ, default!) against params ꓸꓸꓸany argsʗp and bound it as a null array. The callee saw len(args) == 0 where Go passes one nil element (a bare nil is always ONE variadic element; passing the slice itself requires nil...), and the fake driver answered sql: expected 1 arguments, got 0. That is the significant part: it is a silent behavioral divergence, not a compile error — the emission is perfectly valid C# that means something else — which is why the variadic position needs the cast while an ordinary parameter does not (a non-variadic slot has only one form to bind, so f(nil) there is already unambiguous and stays bare). The fix reuses the same castArgToType plumbing: every trailing argument of an expanded variadic call that is the predeclared nil renders (any)(default!) — at the parameter’s ELEMENT type, which getParameterType already yields for the variadic slot — and the nil need not be first, so the whole tail is checked. A SPREAD call (f(args...)) is excluded: it passes the slice whole, so there is no expansion to disambiguate and describe(none...) correctly yields length 0. (Guarded by the VariadicSlotInterfaces extension — arity read back for nil alone, leading, trailing and repeated, in both an ...any and a named-interface ...Shape slot, against no-argument, typed-value and nil...-spread controls, output-compared vs go run.)
A SLICE or ARRAY of the ELEMENT type, passed as the SOLE argument of a variadic slot, states its element type for the SAME arity reason — and it is a C# 14 regression, not a standing defect (2026-08-24). Go spreads a variadic argument only on an explicit a...; a bare a is one value, even when its type is exactly []E. jsValEscaper(a) with a []any against func jsValEscaper(args ...any) (html/template js_test.go, whose whole table nests each case as []any{x} and expects one more level of array wrapping) therefore means a pack of length one. The emission is jsValEscaper(a) with a a slice<any> against params ꓸꓸꓸany argsʗp, and using ꓸꓸꓸany = Span<any> — so whether Go’s meaning survives depends entirely on whether C# finds the callee applicable in its NORMAL form. Under C# 13 it did not: reaching Span<any> from slice<any> needed golib’s implicit operator T[] (slice.cs) followed by array→span, which was itself a user-defined operator on Span<T>, and C# never composes two user-defined conversions. Only the expanded form was applicable, so the slice arrived as one element and the corpus was correct by accident. C# 14 made array→span and string→span standard implicit conversions; a user-defined conversion admits one standard conversion on each side, so slice<any> → any[] → Span<any> became implicit, the normal form became applicable, and C# prefers the normal form — the slice silently became the entire argument list. Exactly one level of nesting disappears, never zero and never two: "[42]" renders as " 42 ", "[[42,\"foo\",null]]" as "[42,\"foo\",null]". The remedy is the sibling rule’s cast at the same castArgToType plumbing — jsValEscaper((any)(a)) — which removes the normal form from consideration (nothing converts any to Span<any>) and restores the expanded one. Three deliberate narrowings keep the footprint at the mechanism: a SPREAD call is excluded (it passes the slice whole, and anys... really is the pack); a tail of two or more arguments is excluded (the normal form is only applicable at arity one); and a NAMED slice/array type is excluded, because its only route to a span is wrapper→slice<T>→T[]→span — two user-defined conversions, which C# 14 still does not compose. The cast is always legal where the call already compiled: binding the expanded form at all required an implicit conversion from the argument to the element type, so making it explicit cannot fail where the implicit one succeeded. (Guarded by the VariadicSlotInterfaces extension — a []any and a [2]any passed whole, their ... spread controls, a nil-slice pair separating “passed whole” from “spread”, a two-argument tail control, and a named-slice control pinning the exclusion; read back both as ARITY and as rendered OUTPUT, output-compared vs go run. The pre-fix converter diverges on 6 of its lines under C# 14 and none under C# 13.)
An untyped STRING constant takes the same treatment, under the MIRROR-IMAGE shape rule (2026-07-25).
Go’s default type for an untyped string constant is string — golib @string — and here it is the
LITERAL that boxes wrong: convBasicLit renders a string literal as a plain C# "seed"
(a System.String) or, where the position allows it, a "seed"u8 ReadOnlySpan<byte> (a ref
struct, which cannot box at all — CS0029). A NAMED string constant needs nothing whether it is typed
or untyped, because it is emitted as an @string member — there is no UntypedString wrapper struct —
and its concatenations evaluate through @string’s own operators. So the string arm of
untypedConstBoxCast keys off the literal-only SHAPE (constExprIsStringLiteralConcat: string
BasicLits joined by + through parens), exactly INVERTING the exprRendersUntypedConstWrapper test
the numeric arms apply.
The defect was that the coverage was partial and therefore self-inconsistent: the positions that
already boxed through @string did so via a BasicLitContext flag (castToGoString, set by
anyBoxedStringLitContext and its siblings, which also suppresses the u8 form) — struct field, slice
element, map value, channel send, return, reassignment — while a call argument, a var-spec,
an any-keyed map index lookup, and an explicit any("…") conversion left the literal bare. So
box{v: "seed"} stored an @string while eq(b.v, "seed") passed a System.String, and Go’s
true/true came back C# false/false — silently, with %T still printing string on both sides.
A literal CONCATENATION ("se" + "ed") was uncovered at every position, because the flag only reaches
a BasicLit, and where the u8 form survived (new box(v: "se"u8 + "ed"u8)) the emission did not even
compile.
Both mechanisms are kept, each doing what it is good at. The literal context still produces the tighter
(@string)"seed" at the positions that carry it, and the four missing positions were given it
(convExprList via a new isStringBasicLit branch beside markAnyFieldLits’ identical either/or,
visitValueSpec’s isAnyType context, and convIndexExpr’s any-key context). untypedConstBoxCast
is the general net underneath — it catches the concatenations and any position that lacks the flag — and
all its application sites now route through applyUntypedConstBoxCast, which skips a rendering that
already leads with the cast so the two can never double up:
box{v: "seed"}; eq(b.v, "seed") // Go: true var v any = "x" m[any] lookup by "seed"
new box(v: (@string)"seed"); eq(b.v, (@string)"seed"); // now true
any v = (@string)"x"; m[(@string)"seed"];
new any[]{(@string)("se" + "ed")}; // the concat shape, previously bare
The cost is real and accepted, on the same reasoning that removed the variadic int carve-out: every
string literal in a ...any slot now carries the cast, so fmt.Println("x") emits
fmt.Println((@string)"x"). That is 866 lines across 150 behavioral projects — uniform, mechanical, and
individually inspected. The alternative is a carve-out that leaves x.(string), case string:, ==,
and %T silently wrong on exactly the values a Go program is most likely to compare.
(Guarded by the AnyBoxedUntypedConst extension — literal, concatenation, named-untyped and named-typed
string constants at the variadic, non-variadic, var-spec, []any, map[any] key store and lookup,
map[K]any value, keyed and positional struct-field, channel-send, return, explicit-conversion and
type-assertion positions, plus the dynamic type a case string: switch reports — output-compared vs
go run. The pre-fix converter leaves twelve of those renderings bare and emits four that do not compile
at all — CS0029/CS0030/CS1503 on the "…"u8 span reaching an object slot.)
The same cast applies to an interface ==/!= comparison against an untyped int constant. Go
compares an interface against a concrete value by its dynamic type and value, which the converter
lowers to golib’s reflective AreEqual (convBinaryExpr’s interface-comparison branch — the
iface == concrete / iface == iface / iface == ptr cases). AreEqual(object, object) bails early
on leftType != right.GetType(), so a comparison operand’s boxed runtime type must match, exactly as
a stored-then-asserted value’s does. A bare C# int literal boxes as System.Int32, so e.Value != 1
against an any field holding a boxed Go int (nint/IntPtr) — container/list’s
TestIssue6349, emitted !AreEqual((~e).Value, 1) — saw IntPtr != Int32, reported the values
UNEQUAL, and fired the test’s error even though the value round-tripped as 1. The interface-comparison
branch now casts the concrete constant operand to its default type (!AreEqual((~e).Value, (nint)(1))),
reusing the same untypedConstBoxCast predicate the boxing positions above key off — so the literal
boxes as Go’s int dynamic type and the runtime-type guard passes. The cast is confined to the
AreEqual lowering (interface-vs-concrete / interface / pointer), so a bare int compared against a
concrete int — which lowers to C#’s native ==, never AreEqual — stays bare (no noise). The
predicate yields nothing for the interface operand itself and for any non-constant operand, so only the
genuine boxed-constant-mismatch site is touched. (Guarded by the
InterfaceUntypedIntCompare behavioral test — an any-field-holding boxed int compared ==/!=
against an int literal, negative-literal and literal-on-the-left forms, output-compared vs Go; the
pre-fix converter emits the bare literal and mis-reports every comparison unequal.)
Comparing two interfaces of one UNCOMPARABLE dynamic type panics, as Go does
Go decides an interface == in three steps, and only the third can panic:
- a nil operand makes it a nil test —
m == nilis false for an interface holding a map, and never panics; - a dynamic-type mismatch answers false —
m == 5never panics either; - only once both operands carry the same dynamic type does the runtime run that type’s equal
algorithm — and an uncomparable type has none, so it panics
runtime error: comparing uncomparable type T.
builtin.AreEqual(object?, object?) implemented the first two and answered step 3 quietly with a
bool, for every shape but one: a map, slice or func held in an interface, and a
struct or array that transitively contains one. The single covered shape — two adapters over a
nil named-func delegate — was a special case of exactly this rule, and now routes through the same
mint (RuntimeErrorPanic.ComparingUncomparableType) rather than restating the message.
The gate sits after both nil legs and the dynamic-type check, which is what makes it safe against
the ~1,300 emitted AreEqual call sites: it is reached only where Go itself would have run the equal
algorithm and found none, and the converter emits AreEqual only for a comparison Go’s own type
checker admitted. The panic is a recoverable runtime error, so recover() observes it exactly as in
Go.
The message spells the dynamic type as Go spells it, which takes two things the managed type alone cannot supply:
Go value in an any
|
reported type |
|---|---|
map[string]int{} |
map[string]int |
[]int{1} |
[]int |
func(){} |
func() |
withSlice{1, []int{2}} |
main.withSlice — the STRUCT, not its field |
myMap{} (type myMap map[string]int) |
main.myMap — its OWN name |
[1][]int{{1}} |
[1][]int — the LENGTH is part of the type |
The length comes off the live operand (GoReflect.ArrayDimsOfValue), because a managed array<T>
does not carry it in its Type — only the value knows. Comparability itself is not restated
here: it delegates to GoReflect.IsComparable, already the signal the reflection bridge populates
abi.Type.Equal from, so == and reflect.Type.Comparable answer from one definition. The verdict
is immutable per type and cached in a ConcurrentDictionary<Type, bool>, since == is a hot path
(roughly every err == io.EOF in the corpus) and an uncached walk would re-reflect over a struct’s
fields on every comparison.
A struct’s interface-typed FIELD recurses correctly — TypeGenerator already compares such
fields through AreEqual (see the interface field an emitted Equals must route through
AreEqual), so struct{ V any } holding a map panics naming map[string]int, the inner type, just
as Go does.
Stated residual — an ARRAY that reaches an uncomparable value through an interface. Measured
against go1.23.12: [1]any{map…}, [1]any{[1]any{map…}} and [1]withAny{…} (a struct with an any
field) all panic in Go and all answer quietly here. Two distinct mechanisms sit behind that, and
neither is the gate above:
- a SELF-comparison never reaches an element at all —
array<T>’s structural equality short-circuits on backing-store reference identity, soa == ais true before any element is examined. This is visible in the[1]withAny{…}row, whose per-element comparer would have panicked (the struct routes itsanyfield throughAreEqual), and did not; - for genuinely distinct arrays,
array<T>compares elements withEqualityComparer<T>.Defaultrather than Go’s relation, so an[N]anynever consultsAreEqualfor its elements.
Closing either means changing array<T>’s equality, which also decides GetHashCode, array-typed
map keys and DeepEqual — and the element-comparer half is the same hole
GoEqualityComparer.ForKeys<T>() closed for map keys, whose doc records a deliberate decision to
leave float-containing arrays on the BCL rule. Left as a measured residual with no known consumer
rather than covered speculatively (the r39d rule).
(Guarded by the UncomparableEquality behavioral test — every panicking shape with its message
asserted verbatim, every nil and type-mismatch non-trigger, and the comparable positive controls,
output-compared vs go run.)
Multi-Assignment and Evaluation Order
All right-hand operands in assignment expressions in Go are evaluated before assignment to the left-hand operands. C# can operate equivalently using tuple deconstruction (thanks to Eugene Bekker for the suggestion). For the following Go code:
x, y = y, x+y
the equivalent C# code operates as follows:
(x, y) = (y, x + y);
The simultaneous deconstruction is mandatory whenever the targets alias — a swap s[i], s[j] = s[j], s[i] shattered into s[i] = s[j]; s[j] = s[i]; loses the first target’s original value (the second read sees the already-overwritten slot). The converter routes a multi-target assignment to the deconstruction form when every target is a reassignment to existing storage, counted per element; an index, star-deref, or selector LHS is always such a write. This recognition keyed off getIdentifier, which resolves a target’s root identifier by unwrapping index/star/selector/chan/array/map nodes but not ParenExpr — so an index whose base is a parenthesized pointer deref, (*p)[i] (the shape a pointer-receiver method uses to write its own named-slice element, e.g. a heap’s func (h *myHeap) Swap(i, j int) { (*h)[i], (*h)[j] = (*h)[j], (*h)[i] }), resolved to a nil root and was not counted as a reassignment. The parallel assignment then fell through to sequential statements and the swap silently corrupted the slice — one element lost, the other duplicated:
func (h *myHeap) Swap(i, j int) { (*h)[i], (*h)[j] = (*h)[j], (*h)[i] }
// before: two sequential stores drop the temporary — (h)[i] and (h)[j] both end up as the old (h)[j]
[GoRecv] internal static void Swap(this ref myHeap h, nint i, nint j) {
((h)[i], (h)[j]) = ((h)[j], (h)[i]); // simultaneous deconstruction, correct swap
}
Such a paren-deref index LHS is now counted as a reassignment directly (a single-element (*h)[i] = v write emits identically on either path, so nothing else drifts). The bug was invisible to compilation — the broken form compiled cleanly — and only surfaced when a converted test ran: it silently miscompiled container/heap’s test heap and the internal/trace/internal/oldtrace order heap (three swap sites). (Guarded by the PointerReceiverSliceSwap behavioral test — a pointer-receiver swap and a full slice reversal by repeated swaps, output-compared vs go run; the pre-fix converter loses elements and diverges. It is also what makes container/heap’s Go test suite validate — see Phase 4.)
The swap recognition above routes a pure-reassignment parallel assignment (every target already exists) to the deconstruction form. A mixed parallel := — some targets reassigned, some newly declared, with rhsLen == lhsLen — was not covered: it satisfies neither lhsLen == reassignedCount nor the all-declared arm, and (unlike the call-deconstruction mixed cases below) has no single-call RHS to trigger tupleResult, so it fell through to sequential statements. When a reassigned target is read by a later right-hand expression, that read must see the target’s ORIGINAL value (Go evaluates every RHS before any store); sequential emission reads the already-updated value. strconv’s Ryū shortest-float rounding does exactly this — dc, fracc := dc>>extra, dc&extraMask, where fracc must read the pre-shift dc:
dc, fracc := dc>>extra, dc&extraMask // fracc must read the ORIGINAL dc
(dc, var fracc) = (dc.Rsh(extra), (uint64)(dc & extraMask)); // whole tuple evaluated, then deconstructed
The converter now detects this read-after-write hazard (lhsReusedInLaterRhs: a written target’s types.Object appears in a strictly later RHS element) and routes the mixed assignment through the same deconstruction path, where C# evaluates the entire right-hand tuple before deconstructing. It is scoped to the actual hazard, so a hazard-free mixed := (m, n := m+1, 100) keeps its minimal sequential form. A newly-declared int/uint element takes its explicit type rather than var — (a, b, nint c) = (b, a, a + b) — because var would infer C# 32-bit int from a literal/int32 RHS instead of the Go-int-target nint; string (an @string’s u8 span cannot sit on a value-tuple LHS) and unsafe.Pointer hazards are excluded and keep the sequential form, a documented limitation with no stdlib occurrence. Like the swap bug this was invisible to compilation and surfaced only at runtime: it made strconv’s shortest-float formatting round down, failing math’s TestFloatMinMax (4e-324 vs Go’s 5e-324). (Guarded by the ParallelAssignmentHazard behavioral test — reassigned + newly-declared parallel forms whose later RHS re-reads a written target, output-compared vs go run; the pre-fix converter diverges. It is also what makes math’s Go test suite validate — see Phase 4.)
Go’s partial redeclaration — a, b := f() where a already exists in the same scope — reuses a (assigns it) and declares only the new names. A blanket var (a, b) would re-declare the reused variable, so the converter emits var per newly-declared element only:
frac, e := normalize(frac) // frac is the existing parameter; e is new
(frac, var e) = normalize(frac);
The same per-element mechanism handles a destructured element whose address is taken (list, delta := netpoll(0); injectglist(&list)). Such a local must be heap-boxed so its Ꮡlist companion exists, but the combined var (list, delta) = … deconstruction cannot declare it as a ref var … = ref heap(…). The converter emits the escaping element’s heap declaration first, then a mixed deconstruction-assignment in which the escaping element is the pre-declared box ref-local and the rest declare with var:
list, delta := netpoll(0)
injectglist(&list)
ref var list = ref heap<gList>(out var Ꮡlist);
(list, var delta) = netpoll(0); // list is the box ref-local; delta is newly declared
injectglist(Ꮡlist); // Ꮡlist now exists
Without this, &list emits Ꮡlist with no box (CS0103), and the Ꮡ(value) copy fallback would silently lose writes made through the pointer. (Guarded by the TupleDestructureEscapingLocal behavioral test — a mutate-through-pointer proves the real local is updated; runtime exercises it in the netpoll poll loops.)
A subtler case: a newly-declared tuple element can be flagged escaping by analysis yet need no heap box — typically an already-pointer local that is merely returned (pp, now := pidleget(now), where pp is a *p that the function returns). The heap-decl path above only owns elements that produce an actual ref var … = ref heap(…); an escaping element with no such declaration must still be counted as newly-declared so it receives its var, or the deconstruction emits (pp, now) = … with pp declared nowhere (CS0103). Both the mixed ((var pp, now) = …, reusing the value parameter now) and the all-shadowing (var (ppΔ1, gpΔ1) = …) forms are handled. (Guarded by the TupleMixedDeclareReassign behavioral test; runtime hits it in pidlegetSpinning and findRunnable.)
A tuple deconstruction into INTERFACE variables hoists the call when a component needs converting. Reassigning a multi-value call into pre-declared interface locals (c, err = sd.dialTCP(…) with var c Conn) can require a per-component interface conversion C#’s tuple assignment cannot perform implicitly — a ж<TCPConn> component satisfies Conn only through its generated pointer adapter, an explicit conversion (CS0266 ×11 in net’s dial.go). Mirroring the return-statement tuple arm, the call is hoisted into temp markers and each component converts in a tuple literal:
var (ᴛ1, ᴛ2) = Ꮡsd.dialTCP(ctx, laΔ1, raΔ1);
(c, err) = (new TCPConnжConn(ᴛ1), ᴛ2);
The arm fires only for a statement-position deconstruction (one call RHS, several LHS) where some non-empty-interface target’s tuple component is a non-identical, non-interface type; all other deconstructions keep the direct form. (Guarded by the InterfaceCasting extension makeCounter — a (*Counter, error) call deconstructed into an Incrementer — runtime-verified against Go.)
A multi-value RETURN reads its plain operands AFTER its calls
The read-after-write hazard above has a return-statement sibling, and it arrives from the opposite direction: there Go’s ordering is fixed and C#’s sequential emission breaks it; here Go’s ordering is free and C#’s tuple literal fixes it the other way.
Go’s spec orders only a return statement’s calls — “all function calls, method calls, receive operations, and binary logical operations are evaluated in lexical left-to-right order” — and leaves its plain operands deliberately unordered against them. gc resolves that freedom the same way every time, because its order pass rewrites the statement: each call is spilled to a temporary first, and the result list is then assembled from those temporaries and whatever plain operands remain. So under gc every plain operand is read after every call. A C# tuple literal has no such freedom — it evaluates strictly left to right — so a plain operand written before a mutating call is copied before the mutation:
func ParseOID(oid string) (OID, error) {
var o OID
return o, o.unmarshalOIDText(oid) // gc: the call runs, THEN o is read
}
public static (OID, error) ParseOID(@string oid) {
OID o = default!;
var ᴛ1 = o.unmarshalOIDText(oid); // gc's own rewrite, emitted
return (o, ᴛ1);
}
Without the spill this emits return (o, o.unmarshalOIDText(oid));, which copies the empty o and only then fills the original — so crypto/x509’s ParseOID returned a zero-length OID beside a nil error, and every parse “succeeded” with no bytes. Nothing about it is visible at compile time: both sides compile, both return two values, and only the content of the first differs.
The spill fires only where the ordering is observable — where a later operand’s call receives an earlier operand’s storage by address, the only way it can write what that operand reads. Three shapes hand a call that address:
| Shape | Example | Storage the call can write |
|---|---|---|
| pointer-receiver method on a value | return o, o.fill() |
o itself — the call takes &o
|
| explicit address argument | return n, raise(&n) |
n itself |
| a pointer operand handed over |
return c.n, c.bump(), c a *counter
|
*c, so a read through c observes it |
Whether the read and the write actually meet is decided by comparing access paths — a root variable (types.Object, exactly as in lhsReusedInLaterRhs, so a same-named but distinct variable is never a false positive) plus the hops from it to the storage in question, where a field or element hop stays inside the previous location and a deref leaves it. Two locations conflict when they share a root, agree on every hop they both have, and the longer path’s extra hops contain no deref.
A root plus a single “indirect” flag is not enough, and the corpus says so. net/http’s return n.handler, n.pattern.String(), n.pattern, matches reads storage inside *n while the call writes *(n.pattern); the flag model reads both as one location — “something behind n” — and spills a call that provably cannot touch what the first operand reads. The paths diverge at .handler versus .pattern, so they do not conflict and nothing spills. The model holds the case one hop over just as firmly: return nd.pat.n, nd.pat.bump() reads through the very pointer the call writes through, the write path is a prefix of the read path with no deref between them, and it spills. Where a hop cannot be spelled exactly — a field promoted through embedding — the path is truncated rather than abandoned, naming the larger enclosing location, so imprecision can only ever over-report a conflict and never miss one.
Every call-bearing operand at or below the hazardous index spills, not merely the hazardous one: the spec does fix the calls’ order among themselves, so spilling b.bump() out of return b.n, side(), b.bump() while leaving side() in the tuple would run bump first. A channel receive spills with them, since the spec’s sentence orders receives alongside calls. A type conversion and a pure builtin (len(o.der)) do not: both are calls syntactically and reads semantically, gc spills neither, and leaving them in the tuple is what puts their reads after the spilled calls — which is exactly where gc puts them. The temporaries are numbered from the file-monotonic tupleTempIndex the converter’s other multi-value expansions already share, so two spills in one scope cannot collide.
The scope is as much of the rule as the spill is: a rule that spilled every call-bearing operand would also be correct, and would rewrite most of the corpus for no correctness gain. Four controls therefore keep the call in the tuple — an operand that is the pointer (return c, c.bump(); both orders yield the same pointer, and the emitted pointer is the box rather than a copy), a call on unrelated storage (return a.n, b.bump()), a value-receiver method (return c.n, c.peek(); the receiver is a copy, so the caller can observe nothing), and the net/http pointer-field shape above. Deliberately not covered, each because deciding it needs more than the statement itself: an operand that contains a call of its own (return o.f + g(), o.mutate() — gc spills g() too and reads o.f last, so spilling the whole operand would re-create the problem rather than fix it; no corpus site), a pointer whose pointee no path can name (f(getPtr()) — the interprocedural question one step removed), aliasing through a slice or map’s backing store (return s[0], fill(s), where no address is taken at the call site at all), and a call that reaches the operand through a package-level or captured variable, which is interprocedural outright.
Corpus footprint: 2 production files (A/B of two seeded whole-stdlib reconverts, control binary versus fixed, 10,260 files emitted per side) — crypto/x509/oid.cs, where the spill is the bug fix, and runtime/symtabinl.cs’s return u, u.resolveInternal(pc), where it is emission-only (the method reads its receiver and writes nothing, so gc’s order and C#’s agree on the value; the spill simply stops relying on that). Their two package_info.cs position maps move with them, and nothing else in the corpus does.
(Guarded by the MultiValueReturnOrder behavioral test — all five hazard shapes, a three-operand call-order case, and the four controls, output-compared vs go run; before the fix every hazard reports the pre-mutation value. returnOperandOrder.go owns the analysis and returnOperandOrder_test.go pins the emission against three neuters: the rule forced off, the rule forced on everywhere, and pathsConflict reduced to the root-plus-indirect model — the last reporting exactly the two controls the access path exists to separate.)
A SELECTOR left-hand side counts as a reassignment — a field swap must stay simultaneous
The paren-deref fix above closed the index form of the target-classification gap; the selector form (x.f, y.g = …) had the same hole. The classifier deliberately drops a selector LHS’s root identifier (getIdentifier would return the base — a package name, or a struct local — which, not itself being reassigned, would wrongly be counted as a new declaration and take a var prefix), and then counted it as neither reassigned nor declared. A parallel assignment whose targets are all selectors therefore satisfied no tuple-path gate — not lhsLen == reassignedCount, not lhsLen == declaredCount, and (with no single-call RHS) not tupleResult — and shattered into sequential stores, losing the swap’s implicit temporary:
// regexp/onepass.go — makeOnePass: put the empty-match leg in inst.Out
inst.Out, inst.Arg = inst.Arg, inst.Out
// before — both fields end up holding the ORIGINAL Arg
inst.Value.Out = inst.Value.Arg;
inst.Value.Arg = inst.Value.Out;
// after — the simultaneous deconstruction C# gives for free
(inst.Value.Out, inst.Value.Arg) = (inst.Value.Arg, inst.Value.Out);
Note the tell in the “before”: the very next statement of the same Go function swaps two plain locals (matchOut, matchArg = matchArg, matchOut) and was already emitted correctly as (matchOut, matchArg) = (matchArg, matchOut); — the divergence was purely the target shape. The consequence was silent: every InstAlt whose empty-match leg needed swapping got a corrupted dispatch, so regexp quietly lost its one-pass engine for ^[a-c]*$, ^(?:a*)$, ^.bc(d|e)*$ and friends, and ^[a-c]+$ stopped matching "abc" at all. A field write is a write to existing storage exactly as the index and star-deref forms are, so it is now counted as reassigned like them — which also aligns single-selector assignments (h.flags &= ^writing) with the narrowing-cast rendering a plain-ident target already got. (Guarded by the ParallelAssignmentHazard extension — a pointer-receiver field swap, a cross-struct rotate reading pre-assignment values, and a package-var swap; and by the RangeVarReassign / AndNotAssignNarrow goldens, whose re-baselines are this routing change.)
A STAR-DEREF of a CALL result counts as a reassignment — the last shape of the classification gap
The paren-deref index form and the selector form above each closed one hole in the target
classifier. The third and last is a star-deref whose operand has no ident root at all: getIdentifier
unwraps index/star/selector/chan/array/map nodes but has no CallExpr arm, so *l.Ptr(i) — the deref of
a method that returns a pointer into a backing store — resolved to a nil root. It then reached neither
the selector arm nor the index arm of the ident == nil branch, and the plain-ident star arm
(*v = …) lives in the ident != nil branch it never entered. The target was counted as neither
reassigned nor declared, no tuple-path gate was satisfied, and the parallel assignment shattered:
// internal/trace/internal/oldtrace/parser.go — Events.Swap, the only mutator sort.Stable calls
func (l *Events) Swap(i, j int) { *l.Ptr(i), *l.Ptr(j) = *l.Ptr(j), *l.Ptr(i) }
// before — the second store re-reads the slot the first just overwrote, so the
// swap is a NO-OP that duplicates element j over element i
l.Ptr(i).Value = l.Ptr(j).Value.ΔClone();
l.Ptr(j).Value = l.Ptr(i).Value.ΔClone();
// after — the simultaneous deconstruction
(l.Ptr(i).Value, l.Ptr(j).Value) = (l.Ptr(j).Value.ΔClone(), l.Ptr(i).Value.ΔClone());
This is the same package the paren-deref fix repaired one layer down (that one fixed the oldtrace order
heap; this is the event list the parser sorts at the end of parse), and it was the last converted-code
divergence in internal/trace. The failure mode is worth noting because it is not a sorting complaint:
a corrupted Swap makes sort.Stable duplicate and lose events, and the damage is reported much later by
the old-trace parser’s own post-pass consistency checks — p 3 is running before start, previous sweeping
is not ended before a new one — which read like trace-semantics bugs and point nowhere near the assignment.
It is also why the divergence hid for so long: sort.Stable performs no swaps on an already-ordered input,
and the event stream only acquires inversions from the EvGoSysExit timestamp rewrite that runs immediately
before the sort. Only traces with enough syscall traffic to reorder anything ever exercised the broken
Swap, so 10 of the 12 old-trace fixtures passed and the two stress fixtures failed.
Counting such a deref as a reassignment is scoped to multi-target assignments: simultaneity is the only
property at stake, and a single-element *(*T)(p) = v has no hazard to fix. That single-element form is also
the overwhelmingly common one — 213 sites in the converted corpus at Go 1.23.12, 60 in reflect/value.go
alone, nearly all the *(*T)(p) = v unsafe-write idiom — so leaving them on their existing path holds the
change’s emission footprint to the one site in the Go tree that is genuinely a parallel deref assignment.
(The single-element form was measured to emit identically on either path, so the scoping buys footprint,
not correctness.)
(Guarded by the PointerReceiverSliceSwap extension — a cell/cells pair mirroring oldtrace’s
Event/Events, exercised by disjoint swaps, a full reversal, and a selection sort driven entirely by the
call-deref swap, output-compared vs go run; the pre-fix converter leaves all three visibly uncorrected.)
An address-taken reference-typed local heap-boxes too — Ꮡ(value) copies are only for reads
An INHERENTLY heap-allocated local (interface/pointer/slice/map/chan/func) is already a
reference, so escape analysis blanket-marks it and the box machinery historically skipped it —
&local fell back to the Ꮡ(value) copy constructor. That is only sound when nothing
writes through the pointer: dwarf’s zeroArray(&typ) (with typ Type, an interface local)
writes *t = &tt in the callee, and the copy-box silently dropped the write (C# printed the
un-replaced value — a behavioral divergence, not a compile error). The box predicate
(identHasHeapBox) now boxes such a local when its address is genuinely taken — by a
capturing closure (the pre-existing box-ref-var case) or anywhere in the current function
(memoized &ident scan) — so &swapped references a real aliasing box:
var swapped Animal = Dog{}
replaceAnimal(&swapped) // callee: *a = &Cat{}
ref var swapped = ref heap<Animal>(out var Ꮡswapped);
swapped = new Dog(nil);
replaceAnimal(Ꮡswapped); // callee writes through the SAME box — "Meow!"
Details: the box declaration always uses the parameterless heap<T>(out …) form for these
(new Animal() on an interface is CS0144, and the reference-like zero value is exactly what
the box provides); a []T slice local routes to heap<slice<T>> (the array-branch prefix
test mistook [] for an array and emitted a mismatching heap<array<T>>); and the
pointer-form ident render in convIdent deliberately keeps the PLAIN value render for these
locals (new Middle(Inner: inner) wants the held pointer; only an explicit &inner wants
the ж<ж<T>> box, via convUnaryExpr). Non-escaping and never-addressed reference locals are
unchanged (no churn). (Guarded by InterfaceCasting’s replaceAnimal — the swap is visible
through the original variable; the churned goldens PointerToPointer,
UnsafePointerReinterpret, DerefPointerToField, PointerCastSliceRange,
EscapedLoopVarSiblingIndex all re-verified against Go.)
An address-taken NAMED RESULT heap-boxes too
A named result is declared in the function signature, not by any body statement, so the escape
analysis’ define-walk never reached it — it was analyzed only when it also happened to sit on a
:= LHS somewhere. A named result whose address is taken but which is never :=-reassigned
(text/tabwriter’s func (b *Writer) flush() (err error) { defer b.handlePanic(&err, "Flush"); … },
where the deferred handler writes *err = nerr.err) was therefore left unboxed: &err fell back to
the Ꮡ(err) copy box, so the handler wrote a copy while return err read the original —
silently dropping the error (Go promotes an address-taken named result to the heap). The escape
analysis now walks every named result and marks it escaping when its address is genuinely taken
(&err, &err.field, &err[i]), so the existing heap-box machinery boxes it at entry — the box
Ꮡerr, the deferred handler’s write through the pointer, and the final return err all reference
one slot:
func (b *Writer) flush() (err error) { defer b.handlePanic(&err, "Flush"); … } // handlePanic: *err = e
internal static error /*err*/ flush(this ж<Writer> Ꮡb) {
heap<error>(out var Ꮡerr); // box declared before the try
GoFrame ᒐ = default;
try {
ref var b = ref Ꮡb.DerefOrNull();
ref var err = ref Ꮡerr.ValueSlot; // value alias inside
defer(Ꮡb.handlePanic, Ꮡerr, flushˢ, ref ᒐ); // the BOX is passed, not Ꮡ(copy)
b.flushNoDefers();
err = default!;
}
catch (Exception ᒐex) when (GoFrame.IsPanic(ᒐex, out PanicException? ᒐp)) { GoFrame.Capture(ᒐp); }
finally { ᒐ.Run(); }
return Ꮡerr.ValueSlot; // reads the SAME slot the handler wrote
}
The trigger is address-taken specifically — a named result merely referenced or written inside a
closure is not boxed (a C# closure already captures the outer local by reference), so a common
defer func(){ err = wrap(err) }() result keeps its plain declaration (no churn). The verdict is
only ever SET true, so a result whose address is never taken is byte-unchanged. Function literals
take the same treatment. (Guarded by NamedResultAddressEscape — an error result written through
&err by a deferred handler and a value int result mutated through &n, output-compared vs Go;
PointerToInterfaceParamDeref re-baselined to the box form, its output unchanged since its handler
only reads *err.)
An address-taken VALUE PARAMETER heap-boxes too
A value parameter, like a named result, is declared in the signature — not by any body statement —
so the escape analysis’ define-walk never reached it either. Parameters were additionally kept out of
the full escape analysis on purpose, which left exactly one parameter trigger in the pass: the
capture-mode-method check (markCaptureModeBoxedParams, A capture-mode method called on a value
PARAMETER below). A plain ¶m was not a trigger, so &r fell back to the call-site Ꮡ(r)
copy box — it compiles, and silently drops every write the callee makes through the pointer:
func DrawMask(dst Image, r image.Rectangle, src Image, sp image.Point, …) { // image/draw
clip(dst, &r, src, &sp, mask, &mp) // clip narrows r (and sp/mp) IN PLACE
if r.Empty() { return } // …but the narrowed r was never seen
The escape pass now marks a value parameter whose address is genuinely taken — &r, &r.field…
(a value-field chain rooted at the parameter), or &r[i] — via objectAddressTaken, the same
generic per-object scan the named-result arm above uses (renamed from namedResultAddressTaken now
that it serves both signature-declared categories). The existing entry-time box machinery then boxes
the parameter at entry, exactly as the capture-mode trigger does:
func clipParam(r Rect) Rect { clip(&r, 5, 5); return r }
internal static Rect clipParam(Rect rʗp) {
ref var r = ref heap(rʗp, out var Ꮡr); // ENTRY-time box, never a call-site copy
clip(Ꮡr, 5, 5); // the callee writes THIS storage…
return r; // …and the body reads it back
}
Entry-time boxing is what preserves Go’s semantics on both sides: the callee’s writes are visible to
the rest of the body, and the caller’s argument stays untouched (the parameter is still by-value —
the box is initialized from the incoming ʗp copy). The ¶m.field form renders through the box
accessor (Ꮡb.of(Box.ᏑR).of(Rect.ᏑMin)) and ¶m[i] through Ꮡa.at<E>(i) — the latter now
superseding, at its parameter sites, the array-parameter fallback that copy-boxed the array<T>
wrapper (Ꮡ(value).at<byte>(0), kept for any array base that still owns no box) and was correct
only because the wrapper shares its T[]. An ARRAY param folds its Go by-value clone into the box
init: ref var a = ref heap(aʗp.Clone(), out var Ꮡa);.
An INHERENTLY-HEAP parameter takes only the BARE &p form (paramAddressTakenNeedsBox). A
slice/map/chan/interface/func — and a type parameter, whose underlying is its constraint interface —
is already a reference, so only the address of the reference variable itself needs a box; &p[i]
addresses the shared backing array, which the emitted element form Ꮡ(p, i) already aliases
correctly, and Go likewise does not heap-promote a slice header for an element address. This mirrors
identHasHeapBox’s own box gate, which is what makes the restriction load-bearing rather than an
optimization: marking a verdict that gate then refuses would leave identEscapesHeap set with no
box, and that map is read raw by the capture analysis and several emitters. Measured on the first cut
(which did not restrict), 48 of 149 newly-boxed parameters were &s[i]-only slices — including
unicode.is16/is32, slices.Equal/Index, subtle.XORBytes, crypto/internal/alias, and the
Windows syscall buffer paths — every one allocating a box per call for no semantic gain.
The analysis trigger and the emission gate must move together. markCaptureModeBoxedParams
records the reason and paramBoxReasonHolds (read by visitFuncDecl’s parameter preamble, its
processPotentialCapture box-ref arm, and convFuncLit’s literal prologue) re-verifies it against the
declaring ident; a reason recorded by analysis but missing from the gate leaves body uses referencing
a box that was never declared (CS0103), and the reverse declares a box nothing references. Both gained
the address-taken trigger in the same change. The gate stays narrow in the other direction: a param
that leaks into identEscapesHeap some other way — a mixed data, pc, line := … define re-uses the
param object, so the define walker escape-analyzes it (debug/gosym’s slice) — still keeps its
historical unboxed emission. Function-literal params take the identical treatment
(funcLitHeapBoxParamIdents), and a boxed param referenced from a nested closure is box-ref’d rather
than snapshot-copied (see CaptureModeParamClosure below), so the closure, the body, and the callee
all share the one parameter variable Go gives them.
This was the fifth path in the Ꮡ(value) copy-box family, after the pointer-to-array element,
the slice/array field of a receiver, the field-addressed value local, and the named result — and the
value RECEIVER, below, is the sixth and last. The corpus consumers the parameter arm corrects are
all silent-wrong-answer bugs, not compile errors:
| Package | Site | Before → after |
|---|---|---|
crypto/x509 parser.cs
|
parseValidity(der cryptobyte.String) calls parseTime(&der) twice
|
two independent Ꮡ(der) copies → one Ꮡder: the DER cursor now advances, so notAfter is parsed from the bytes after notBefore instead of re-parsing the same ones. parseName, forEachSAN, parseBasicConstraintsExtension, parseExtKeyUsageExtension and parseCertificatePoliciesExtension have the same non-advancing-cursor shape (der.ReadASN1(&der, …)). |
crypto/x509 verify.cs
|
Verify(opts VerifyOptions) → systemVerify(&opts), isValid(…, &opts), buildChains(…, &opts)
|
three separate copies → one shared Ꮡopts. |
net/http server.cs
|
Serve(l net.Listener) registers trackListener(&l, true) and defers trackListener(&l, false)
|
the register and deregister boxed different copies, so the deregister’s delete(srv.listeners, ln) could never match the registered key — every served listener leaked. Now both pass Ꮡl. |
database/sql sql.cs
|
(*Rows).close(err error) → fn(rs, &err) plus a withLock closure that assigns err
|
the hook’s *err = … wrote a copy while return err read the original. Now the closure (Ꮡerr.ValueSlot = …), the hook, and the return share one slot. |
testing/slogtest slogtest.cs
|
wrapper.Handle(…, r slog.Record) → h.mod(&r) then h.Handler.Handle(ctx, r)
|
mod mutated a copy, so the wrapped handler received the unmodified record — the whole wrapper mechanism was a no-op. |
(Guarded by the AddressOfParamWrite behavioral test — a value parameter clipped in place through
&r by a callee that writes, a ¶m.field bump, an ¶m[i] bump on an array parameter, and
three controls that must not change: a read-only ¶m, an address-taken local, and a parameter
whose address is never taken; the caller’s own argument is printed after the call to prove it stayed
untouched. Output-compared vs go run — under the pre-fix emission the three write cases all printed
the unmodified input.)
The value RECEIVER is the same category — and it closes the family. A method’s receiver is the
third thing declared in a signature rather than by a body statement, so it failed for exactly the
reason the named result and the value parameter did: it arrives on funcDecl.Recv, which neither the
define-walk nor markCaptureModeBoxedParams (which walks funcType.Params) ever reaches. Two
distinct symptoms, both closed by markAddressTakenBoxedReceiver:
Go (receiver starts Box{Rect{0,16}, 6} / Trio{7,8,9}) |
Pre-fix C# | Go says | Pre-fix C# said |
|---|---|---|---|
func (b Box) Bumped() int { bumpBox(&b); return b.Tag } |
bumpBox(Ꮡ(b)); |
16 |
6 |
func (b Box) Clipped() … { clip(&b.R, 5, 5) … } |
clip(Ꮡ(b).of(Box.ᏑR), 5, 5); |
5 5 |
0 16 |
func (a Trio) Elem() … { bump(&a[1]) … } (array receiver) |
bump(Ꮡa.at<nint>(1)); |
7 18 9 |
CS0103 — Ꮡa never declared |
The array-receiver row is not a silent wrong answer but a hard compile error: convUnaryExpr’s
array-base copy-box fallback (Ꮡ(value).at<E>(i), kept for an array base that owns no box) is keyed on
identIsParameter, and the receiver is deliberately not a parameter in that model — so the naive
identity-box form was emitted for a box nothing declared. Giving the receiver a real box fixes both
symptoms with one mechanism.
The convention is the parameter’s, reused verbatim rather than invented: the incoming value takes the
ʗp name and the entry preamble re-declares the Go name as the boxed ref alias, with an ARRAY
receiver folding its Go by-value clone into the box init exactly as an array parameter does.
func (b Box) Bumped() int { bumpBox(&b); return b.Tag }
func (a Trio) Elem() int { bump(&a[1]); return a[1] }
public static nint Bumped(this Box bʗp) {
ref var b = ref heap(bʗp, out var Ꮡb);
bumpBox(Ꮡb);
return b.Tag;
}
public static nint Elem(this Trio aʗp) {
ref var a = ref heap(aʗp.Clone(), out var Ꮡa); // the by-value clone folds into the box init
bump(Ꮡa.at<nint>(1));
return a[1];
}
The public surface is unchanged, which is what makes the rename safe: only the receiver’s name
moves, never its C# type, so the method stays the value-receiver extension Go’s method set requires —
still callable on a value, still callable through a pointer (which copies into the receiver, so the
caller’s own variable is untouched, matching Go), and still satisfying an interface it implements by
value. RecvGenerator is unaffected because it is gated on IsRefRecv (this ref T), and a value
receiver never carries ref; [GoRecv] is likewise emitted only for a this ref signature. The box
is an implementation detail of the body, exactly as the parameter ʗp + heap preamble is.
Analysis and emission move together here too — markAddressTakenBoxedReceiver records and
recvBoxReasonHolds (read by paramNeedsHeapBox, which now consults funcDecl.Recv before the
params walk) re-verifies. The receiver’s reason set is deliberately narrower than a parameter’s:
just the address-taken predicate. A capture-mode (direct-ж) receiver is already served by
packageDirectBoxReceiverMethods, which emits the box as the receiver instead of renaming it, and a
receiver the capture analysis routed to box-ref storage must never take the ʗp form at all — so
neither bodyCallsCaptureModeMethodOn nor isLambdaBoxRefVar joins the receiver gate.
The receiver reuses paramAddressTakenNeedsBox, so an inherently-heap receiver still boxes only for
the bare &r. Measured, that restriction rejects zero corpus sites today — unlike the parameter
arm’s 48 of 149 — and the reason is worth recording: the parameter over-boxings came from the first
cut also recording packageCaptureModeBoxIdents, which forces identHasHeapBox to grant a box. The
receiver arm never records it, so for a &r[i]-only slice receiver identHasHeapBox’s own gate
refuses the box independently and the emission is byte-identical either way. The restriction is kept
because it keeps the analysis verdict and that gate in agreement: marking a verdict the gate then
refuses leaves identEscapesHeap set with no box, and that map is read raw by the capture analysis and
several emitters.
Corpus footprint of the fix, from a two-seeded-root A/B (master converter vs fixed, both reconverting
all 305 projects into their own temp root): 3 receiver sites across 2 files — encoding/base64’s
WithPadding and Strict and encoding/base32’s WithPadding, each func (enc Encoding) … *Encoding
returning &enc. All three are correct-by-luck under the old emission: &enc is the last operation,
after every mutation, so the Ꮡ(enc) copy carried the mutated value out. They are now one storage
identity rather than two, at the same single allocation. That there is no live victim is the point —
this path was closed at its root rather than after a sixth package was found broken by it.
The receiver decision is MODE-STABLE, structurally — -stdlib and -tests cannot disagree about
it, and that is worth stating because the sibling category can. A package-level var’s address may be
taken by the package’s own _test.go, which a production go/packages load never sees, so the storage
shape has to be reconciled deliberately (A global addressed only by the package’s own _test.go is
still heap-boxed, below). A receiver is function-scoped: its address can only be taken inside its
own method body, and a production method’s body is production source that no _test.go can add a
statement to. markAddressTakenBoxedReceiver reads funcDecl.Recv against funcDecl.Body and nothing
else, so admitting test files to the analysis universe cannot change its answer. Measured against the
claim rather than assumed: a whole-stdlib -stdlib reconvert and the -tests pipeline’s regenerated
production .cs for encoding/base32 and encoding/base64 are byte-identical. (A board row read the
opposite from a go2cs.exe built before this fix, and filed the resulting sweep drift as an open
mode-instability that must never be banked; the retraction — and the bank — are in
phase4/BOARD-next-validation-candidates.md.)
(Guarded by the same AddressOfParamWrite behavioral test, extended: a value receiver bumped in place
through &b, a &recv.field clip, a &recv[i] bump on an ARRAY receiver, and four controls that must
not change — a &recv[i] on an inherently-heap slice receiver, a read-only &recv, a pointer
receiver, and a receiver whose address is never taken — plus the two public-surface cases, the boxed
value-receiver method called through a pointer and through an interface. Output-compared vs go run.)
A field-addressed value local heap-boxes — Ꮡ(x).of(…) copy-boxes orphan writes
Escape analysis’s address-of walk marked &x (direct) and &x[k] (element) but had no
selector arm, so a value-struct local whose FIELD address was taken in plain assignment
(or composite-literal / return) position stayed unboxed, and convUnaryExpr fell back to the
Ꮡ(x).of(T.Ꮡval) copy-box — writes through the pointer landed in the copy and were
silently lost (Go reads the write back through x; C# printed the original value — a
behavioral divergence, not a compile error). The walk now peels a value-field selector
chain (x.f1.…fn, every hop a direct FieldVal selection with no pointer indirection) to
its root ident and marks the root escaping, so the emission routes through the identity box:
x := Thing{val: 7}
p := &x.val
*p = 99
return x.val // Go: 99
ref var x = ref heap<Thing>(out var Ꮡx);
x = new Thing(val: 7);
var p = Ꮡx.of(Thing.Ꮡval);
p.Value = 99;
return x.val; // 99 — the pointer aliases x's box
Multi-hop chains chain the accessors (&w.inner.val → Ꮡw.of(Wrap.Ꮡinner).of(Thing.Ꮡval)),
and a field promoted through a VALUE embed roots at the local too (&o.ev →
Ꮡo.of(Outer.Ꮡev)). A hop that crosses a POINTER — an explicit w.ptr.val deref or a field
promoted through an embedded pointer (both are Selection.Indirect()) — aliases the
POINTEE’s storage instead, so the root deliberately stays unboxed: w.ptr.of(Thing.Ꮡval)
already writes through the held box. (Guarded by LocalStructFieldAddr — plain, nested,
method-body, value-embed-promoted, composite-literal, and return positions plus the
pointer-hop negative control, all output-compared vs Go; the one churned golden
UnsafePointerParamPin — &h.v under unsafe.Pointer — re-verified.)
A capture-mode method called on a FIELD CHAIN of a local is an address-of too
The selector arm above sees the explicit &x.field; Go also takes that address implicitly
when a pointer-receiver method is called on the field — x.i.Add(delta) is (&x.i).Add(delta).
For a method whose receiver binds this ref T the emission needs nothing: C# binds the extension
on x.i itself, a genuine ref into the local. A capture-mode (direct-ж) method takes ж<T>
instead, so the call site must materialize a real pointer — and the escape trigger that boxes the
local recognized only the method called on the var ITSELF (i.Store(10)), never on a field chain
rooted at it. Unboxed, emission fell to the Ꮡ(x).of(…) copy-box: every atomic write landed in a
fresh copy per occurrence, and every read minted another (sync/atomic’s entire 43-divergence
Phase-4 residual — x.i.Add(delta) returned the right value while x.i read back zero).
bodyCallsCaptureModeMethodOnObject now accepts a value-field chain rooted at the target
(selectorChainRootsAtIdent, the same root walk the explicit-& arm uses, whose
Selection.Indirect() gate keeps a pointer-crossing chain excluded), so the local heap-boxes and
the call routes through its identity box:
var x struct{ i atomic.Int32 }
v := x.i.Add(5) // Go: v=5, and x.i reads 5
ref var x = ref heap(new struct_x(), out var Ꮡx);
var v = Ꮡx.of(struct_x.Ꮡi).Add(5); // aliases x's box — x.i reads the write back
The analysis trigger and the emission-side re-verification (paramBoxReasonHolds) read the SAME
predicate, so value parameters take the widening in the same motion (func f(x holder) calling
x.i.Store(3) boxes x at entry, ref var x = ref heap(xʗp, out var Ꮡx)). A pointer-receiver
method that is NOT capture-mode stays untouched — w.c.inc() binds ref w.c in place, and
promoting for it would heap-box every local that calls any pointer-receiver method. (Guarded by
CaptureModeFieldAddress — local, value parameter, type-switch binding and lifted anonymous
struct, plus the non-capture-mode control, all output-compared vs Go.)
The same chain one level up: &recv.f1.f2 on a POINTER RECEIVER
The two sections above fix the LOCAL. The identical shape rooted at a pointer receiver went unfixed until 2026-08-23, and it is the more dangerous of the two because the receiver is already a pointer in Go, so the address is unambiguously real and a lost write is unambiguously a bug.
The converter recognised only the ONE-hop form &recv.field, which emits the field box
Ꮡrecv.of(T.Ꮡfield) and marks the method direct-ж so that box exists. A DEEPER chain matched no arm
and fell through to Ꮡ(recv.f1).of(T.Ꮡf2) — the Ꮡ(value) copy-box — so every write through the
returned pointer went into a temporary. It compiled, it ran, and it printed wrong numbers.
func (b *Builder) incrementSectionCount() error { // vendor/golang.org/x/net/dns/dnsmessage
var count *uint16
switch b.section {
case sectionQuestions:
count = &b.header.questions // header is a VALUE struct field
...
}
*count++ // ... and this increment was LOST
}
// before — count points into a heap copy of b.header; b.header.questions never moves
count = Ꮡ(b.header).of(dnsmessage_package.Δheader.Ꮡquestions);
// after — chained from the receiver box, so the write lands in the real field
count = Ꮡb.of(Builder.Ꮡheader).of(dnsmessage_package.Δheader.Ꮡquestions);
Consequence in the corpus: the DNS message Builder’s header counts stayed at zero, so every message
it produced carried a question section with QDCOUNT=0. Any conformant parser answers
ErrSectionDone to that, which is why the symptom surfaced three levels away as an unexplained
resolver timeout rather than as anything resembling a lost write. The census over all 5,565
address-of sites found the hazard at exactly four write-context sites, all in that one function.
Both halves moved together, and that is the part worth remembering:
-
convUnaryExpr’s receiver arm walks the chain (receiverValueFieldChain) and folds one.of(…)per hop. A single-hop chain reproduces the previous string byte for byte, so no existing site moved. -
bodyTakesReceiverFieldAddress— the scan that MARKS a method direct-ж — walks the same chain, so the box the emission reaches for actually exists.
Every intermediate hop must be a VALUE struct field, and the walk is type-aware to enforce it. A
pointer-typed hop is already its own box and the pointer-variable arm field-refs through it
correctly (o.ptr.of(inner.Ꮡb)); routing it through the receiver would address the pointer’s own
storage instead of the pointee’s field — the mirror-image defect.
A deep chain additionally requires the enclosing method to actually BE direct-ж, and skipping that
test is a compile error waiting to happen. Marking is driven by scanning for an explicit
&recv.f1.f2; an implicit address is invisible to that scan, because there is no ast.UnaryExpr
in the tree at all:
func (h *MAC) Sum(b []byte) []byte { // vendor/golang.org/x/crypto/internal/poly1305
h.mac.Sum(&mac) // Sum is promoted from an embedded field:
} // Go takes &h.mac.macGeneric implicitly
Emitting the box form there names a receiver the method does not have — CS0103: The name 'Ꮡh' does
not exist in the current context, measured on the full-corpus build. Those sites decline and keep
their value-chain form, which is already correct for them: the receiver binds this ref T, so
h.mac.macGeneric reaches the real storage and the write lands. Guarded by
ReceiverNestedFieldAddress (one hop, two hops, switch-selected pointer written after the switch,
read-back, and the pointer-hop negative control, all output-compared vs Go). Against the un-fixed
converter that guard compiles clean and prints 0 for every value-chain write while the pointer-hop
control still prints 3 — the defect’s exact scope, and the reason a guard here had to be behavioral
rather than a golden.
A TYPE-SWITCH BINDING is escape-analyzed like any other local
Every rule above reached a variable through info.Defs — and a type-switch guard has no object
there (go/types: “symbolic variables t in t := x.(type) … the corresponding objects are nil”;
the real binding is one implicit *types.Var PER CASE CLAUSE, in info.Implicits). So no
address form on a type-switch binding was ever seen: d.translate(&t1.Name, true) handed a
ж
The per-case objects now join both analyses: performEscapeAnalysisForObject runs the standard
walk for each case clause’s implicit var (body uses resolve to it through info.Uses, so every
arm matches by object identity), and the ref-lowering locals census tracks the same category, so
a binding whose every address-connected use feeds a lowered position still REVERTS to a plain
stack local — the fixture shapes that already aliased correctly through a lowered ref Name
parameter emit byte-identically. The analysis is deliberately narrowed to non-inherently-heap
bound types: a binding bound at an interface (multi-type and default arms always are) is
already a reference, and its no-entry state is load-bearing for the capture analysis.
On the emission side a C# pattern variable cannot be a ref local, so an escaping binding binds the pattern to a uniquely-numbered temp and opens the clause with the entry-time box pattern proven by the escaping-parameter preamble and the select comm-clause binding:
switch t1 := tok.(type) {
case StartElement:
d.translate(&t1.Name, true) // write must land in t1
t = t1 // …because Go reads it back out
case StartElement t1ᴛ1: {
ref var t1 = ref heap(t1ᴛ1, out var Ꮡt1);
d.translate(Ꮡt1.of(StartElement.ᏑName), true);
t = t1;
The gate is identHasHeapBox — the exact predicate the body’s &name emission consults — so the
box is declared iff it is referenced, and a binding with no escaping use keeps today’s direct
pattern binding byte for byte. (Guarded by TypeSwitchBindingAddress — the xml shape through a
ж-parameter method, a held p := &t1.n pointer, the direct &t1 form, and the already-correct
slice-element control &t1.attr[i], all output-compared vs Go.)
A PACKAGE-LEVEL function literal’s own locals are analyzed too
Every heap-box rule above is decided by the escape-analysis pass, and that pass reached a variable
only through its declaring function declaration: the driver walked *ast.FuncDecl bodies and ran
the define-walk (:=, var, range/for/if/switch/type-switch init defines) against each body. A
function literal that is not inside any declaration — a package-level var initializer — hit a
separate arm that marked its parameters and named results but never ran that walk, so none of its
own locals were ever analyzed and every one of them stayed unboxed, whatever the body did with it.
That is the shape of every Go test table (var tests = []struct{ name string; f func() }{{"…",
func(){ … }}}) and of the sync.OnceFunc/OnceValue package-level initializers, and it produced
both failure modes at once:
var InitWSA = sync.OnceFunc(func() { // internal/poll fd_windows.go
var d syscall.WSAData
e := syscall.WSAStartup(uint32(0x202), &d) // fills d
…
})
// before — Ꮡ(d) COPY-boxes: WSAStartup filled a copy and the Winsock data was lost (silent)
Δsyscall.WSAData d = new();
var e = Δsyscall.WSAStartup((uint32)0x202, Ꮡ(d));
// after — the identity box, exactly as an in-declaration local has always emitted
ref var d = ref heap(new Δsyscall.WSAData(), out var Ꮡd);
var e = Δsyscall.WSAStartup((uint32)0x202, Ꮡd);
The compile-visible half is a capture-mode receiver: sync mutex_test.go’s misuseTests table does
var mu sync.Mutex; mu.Unlock() inside such a literal, and an unboxed mu emits the VALUE receiver
form, which binds no ж<Mutex> extension overload at all (CS1929 ×16). The define-walk is now a
shared helper both arms call, so a package-level literal gets byte-identical treatment to a
declaration body. Literals nested inside a declaration were already walked against the enclosing
body (a superset), and the analysis short-circuits per object, so the overlap is a no-op — the
behavioral corpus is byte-identical and the full-stdlib footprint is exactly the three package-level
initializers that needed it (internal/poll, internal/syscall/windows, internal/sysinfo).
(Guarded by PkgLevelFuncLitLocals — every declaration form inside a package-level literal (var,
:=, for/if/switch init, range value, a nested closure) plus a standalone package-level literal and
an in-declaration control, output-compared vs Go.)
A pointer-receiver METHOD VALUE heap-boxes its receiver — the implicit (&x).M
Go’s spec makes c.split shorthand for (&c).split when c is addressable and split has a
pointer receiver: the method value binds a pointer into c’s own storage, so every write it
makes through its receiver is visible in c afterwards. That is the same escape condition as an
explicit &c — just written without the &, which is precisely why the address-of walk above could
not see it. The local stayed unpromoted and emission fell back to the copy box Ꮡ(c).split: it
compiled and ran, but the method mutated a copy and the caller’s writes were silently dropped.
bufio’s s.Split(c.split) is the real site — the scan counter never decremented, so the reader
“stopped with 10000 left to process”.
Escape analysis now recognizes a method value that selects a pointer-receiver method on the local’s
own storage — the bare ident, or a non-indirect value-field chain rooted at it, reusing the same
root walk the explicit-& arm uses — and marks it escaping, so the emission becomes the aliasing
box:
c := counter{n: 100}
sum := applyInt(c.dec, 5, 7) // (&c).dec — Go: c.n is 88 afterwards
ref var c = ref heap<counter>(out var Ꮡc);
c = new counter(n: 100);
nint sum = applyInt(Ꮡc.dec, 5, 7); // Ꮡc aliases c — was Ꮡ(c), a copy
The rule is position-general (argument, assignment, composite element, return) and covers value
parameters and named results as well as locals: a parameter takes the entry-time box
(ref var c = ref heap(cʗp, out var Ꮡc);) rather than a call-site copy, exactly as the capture-mode
call form already did — the three emitters that materialize a parameter box now share one predicate
(paramBoxReasonHolds), so an analysis reason can no longer be recorded without its box being
declared (CS0103). An inherently-heap named slice/map/chan receiver takes the box too (Ꮡ(l)
would clone the slice header, orphaning *l = append(*l, v)).
Three boundaries stay deliberately outside the rule. A direct call c.dec() is not a method
value: it binds C#’s this ref counter c extension receiver against the variable and is already
correct, and promoting for it would heap-box every local that calls a pointer-receiver method. A
pointer-typed base (p.M where p is *counter) passes the pointer value and takes no address
of p. A value-receiver method value (c.peek) copies the receiver at evaluation time in Go —
which is what the existing lambda-snapshot emission (var cʗ1 = c; … cʗ1.peek()) already does.
(Guarded by MethodValueReceiverEscape — local, value parameter, named result, value-field chain,
named-slice, and closure-formed method values, plus all three negative controls, output-compared vs
Go. Note the still-open residue: a method value bound to a variable — f := c.dec — takes the
lambda-snapshot path, which copies the receiver even when the local is promoted.)
A blank-identifier element in a split multi-assign is a C# discard, never a declaration. Go’s _, _, _, _ = a, b, c, d (a common “mark these used” idiom) is emitted as one bare discard per element with no var — the per-element discard test keys off each LHS ident, not just the single-LHS case, so every blank stays a discard:
_, _, _, _ = fi, fn, gi, gn
_ = fi;
_ = fn;
_ = gi;
_ = gn;
A blanket var _ on each would declare _ once and then collide on every later element (CS0128 “a local named _ is already defined”). (Guarded by the BlankIdentifierCollision behavioral test; runtime hits it in softfloat64’s fdiv64.)
Short Variable Redeclaration (Shadowing)
When using Go’s short variable declaration syntax, e.g., x := 2, a variable can be redeclared in a lesser (nested) scope. The inner declaration “shadows” the outer one: the inner instance is manipulated while the outer value is preserved, and once the inner scope ends the outer variable still holds its original value.
C# forbids a local (or a lambda parameter) from shadowing an enclosing local of the same name (CS0136). So rather than the older save/restore approach, the converter renames the shadowing inner variable with a Δ disambiguation suffix (x → xΔ1, xΔ2, …) and rewrites all references within that scope to the renamed identifier. The outer variable is untouched, so its value is naturally preserved. For example:
func sumWithLenLocal(buf []int) int {
total := 0
len := len(buf) // a local named like the built-in, shadowing it
for i := 0; i < len; i++ {
total += i
}
return total + len
}
converts to:
internal static nint sumWithLenLocal(slice<nint> buf) {
nint total = 0;
nint lenΔ1 = len(buf); // renamed; the built-in call stays `len(...)`
for (nint i = 0; i < lenΔ1; i++) {
total += i;
}
return total + lenΔ1;
}
The same Δ mechanism handles a local shadowing a called built-in (as above), a nested-block variable shadowing a function-level one, an IIFE/closure parameter colliding with an outer local, and a type-switch guard (switch x := x.(type)) whose variable shadows an enclosing one — the guard is renamed within the switch (case T xΔ1:) while references after the switch still resolve to the enclosing variable, matching Go’s scoping.
The nested-block detection holds across a closed sibling block: a declaration that follows a nested block inside the same enclosing block (runtime procresize’s second trace := traceAcquire() after an inner if {…} that declared its own trace) is still checked against enclosing scopes. The shadow tracker’s processing flag is shared across the nesting levels of a block tracker, so an inner block’s cleanup must restore it for the still-open enclosing block rather than clear it — clearing it made the follow-on declaration skip the check and collide with the function-level local (both emitted Δtrace — the LAST runtime compile error, CS0136). Composition with the collision rename is suffix-based: the function-level local keeps the base name (Δtrace after its collision prefix), the shadows number independently of the prefix (traceΔ1, traceΔ2 — a shadow name no longer collides, so it takes no Δ prefix). (Guarded by the GlobalShadowedByLocal extension nestedBlockShadow — the three bindings verified by value vs Go.)
A local shadowing a same-named package function it calls in its own initializer — signame := signame(gp.sig) (runtime panic.go) — renames the same way. Go starts the shadow after the initializer, so the call resolves to the function; C# scopes the local over its own initializer, so an unrenamed call would bind the (non-invocable) string local (CS0149). Detection is object-accurate: any identifier go/types resolves to the function while a same-named local exists means Go bound it where the local was not yet in scope — the old position guard (“call before the declaration”) excluded exactly the own-initializer case. (Guarded by the BuiltinShadowLocal extension — a package signame shadowed in its own initializer, values vs Go.)
A block-scoped const that shadows an enclosing parameter or variable is renamed the same way. func f(ns int64) { …; const ns = 10e6; use(ns) } (runtime notetsleep_internal) is legal in Go but the inner const ns and the param ns both emit as ns in C# (CS0136). A const is tracked separately from variables — its go/types object is a *types.Const, not the *types.Var the scope stack records — so the shadow-rename pass had ignored it; it now records a shadowing const (detected by the same by-name enclosing-scope check) and rewrites its declaration and every use to nsΔ1, leaving the enclosing ns untouched. Only a shadowing const is renamed (a plain block const keeps its name, no churn). (Guarded by the ConstShadowsParam behavioral test — the inner uses bind the const value, the outer uses bind the param.)
Renaming depends on correctly identifying which declarations are function-level — the set a nested variable of the same name must avoid (C# forbids the nested one even when the function-level one is declared later). A for init; … loop’s := variable, and a range := key/value, are scoped to their own statement, not the function body, so they are deliberately excluded from that set. Recording a for-loop variable as function-level (it is encountered first, in source order) would mask the real function-level variable of the same name declared afterward — for b := …{} for b := …{} … b := newBucket(…) — leaving all three emitted as b and colliding (CS0136). With the for-loop variables correctly treated as inner scopes, they are renamed bΔ1/bΔ2 while the function-level b keeps its name. (Guarded by the ForVarMasksFuncLevel behavioral test; runtime hit this in stkbucket.)
The same forward-collision rule applies at every block level, not just the function body. C# CS0136 fires whenever a name is declared in two scopes where one encloses the other, regardless of declaration order — so a nested variable must be renamed if the same name is declared anywhere in an enclosing block, whether that declaration appears before or after it in source. The scope-stack walk only records declarations already seen (backward), and the function-level forward set covers only the function body; a variable declared later in an intermediate enclosing block would otherwise be missed. To close that gap, each block scope (function body, if/for/range/switch/select bodies, bare blocks, and case/comm-clause bodies) is pre-scanned for its directly-declared names (:= and var, excluding a control statement’s own init :=, which is scoped to that statement) when the scope is pushed, so forward declarations are visible to the shadow check. For example, the runtime’s runGCProg has two for off := … loops followed by off := n - nbits in the same enclosing for {} body — the block-level off encloses both loops, so the loop variables are renamed offΔ1/offΔ2 while the block-level off keeps its name. (Guarded by the ForVarMasksBlockLevel behavioral test — distinct from ForVarMasksFuncLevel, where the later same-named variable is function-level; this cleared 5 runtime CS0136 in runGCProg/mprof/runtime1/time.)
The mirror image — a local shadowing a package-level GLOBAL — is resolved the other way: the global reference is qualified rather than the local renamed. C# locals are function-scoped, so a local trace := traceAcquire() shadows a same-named global var trace throughout the function, and an earlier read of the global binds to the not-yet-declared local (CS0841; the wrong variable regardless). Renaming the local is the fragile, entangled path (it interacts with collision renames and the shadow-rename counter); instead a use whose ident resolves to a package-level var of this package — while a same-named function-level local is declared — is emitted qualified with the package static class: runtime_package.Δtrace.minPageHeapAddr, which a local can never shadow. This is the same package-class qualifier the box-field accessor uses for a shadowed owning type (below). Runtime’s traceallocfree.traceSnapshotMemory reads the global trace.minPageHeapAddr before its local trace := traceAcquire() (both collision-renamed Δtrace); the qualifier is gated so an ordinary global (no shadowing local) and the local’s own uses (which resolve to the local, not the package scope) keep their bare, Go-like form — no churn. (Guarded by the GlobalShadowedByLocal behavioral test — a collision-renamed global and a plain global each read before a same-named local; cleared runtime’s last CS0841.)
That qualifier names the class that DECLARES the global, which under the white-box test model is not always the production one. In a whitebox-reference internal variant, the emission unit is the bridge class (md5_internal_test_package), and a package-level declaration contributed by an internal _test.go lives there — the production class holds only the production declarations. Qualifying unconditionally with <pkg>_package therefore names nothing whenever the shadowed global comes from a test file (CS0117): crypto/md5’s benchmarkSize opens with the idiomatic buf := buf, whose package-level buf is declared in md5_test.go, and it emitted md5_package.buf. The qualifier is now chosen by where the object is declared — the bridge class for a _test.go declaration, the production class otherwise — so both halves of a white-box compilation stay addressable from the same function. Production emission is untouched (the override is empty outside the test variant), so this is zero-churn for the corpus; crypto/md5’s banked suite is its operational guard.
A package-level CONST is shadowed the same way, and had neither half of the defence. q := big.NewInt(q) — crypto/internal/mlkem768’s TestZetas/TestGammas over const q = 3329 — is legal Go because a short variable declaration’s scope begins after its own ValueSpec, so the initializer still reads the constant; C# scopes the local to the whole block and the initializer binds to the local it is declaring (CS0841 with an inferred var, CS0165 “use of unassigned local variable” when the declaration states its type). The local-rename half above cannot reach it: the pre-scan that drives it records only objects that are *types.Var and live in the package’s global var map, so a const-shadowing local is never renamed. The const arm therefore qualifies, exactly as the global-var arm does — but it consults the WIDER local set. funcLevelDecls holds only declarations made directly in the function body, while the same shape inside an if/for init is not function-level; the const arm asks instead whether the name is declared anywhere in the function (funcScopeVarNames — nested blocks and func literals included), so both depths are covered. Qualifying a reference that no local actually shadows costs verbosity and never changes meaning, which is what makes the wider set the safe side to err on; a function that merely reads the const, declaring no such local, keeps the bare q. Two CS0841 were all that stood between crypto/internal/mlkem768’s converted suite and a build. (Guarded by extensions to the same GlobalShadowedByLocal behavioral test — a self-referencing initializer at function level, the same shape in an if init, the var-inferred form that reproduces the exact CS0841, and an unshadowed control that must keep the bare name.)
Two subtleties complete this for loops whose variable’s box hoists before the loop. A hoisted loop-variable box is block-scoped in C#, one per name per container. A loop variable that escapes to the heap and whose box is emitted before the loop — today that is a string/int/chan/func range variable, or the legacy fallback of a for i := … clause variable referenced by a clause func literal; every other case boxes per-iteration inside the body (slice/array/map ranges via the deferred range-var box, and for clause variables via the per-iteration carrier rewrite — Go 1.22 semantics, see Labeled Control Flow and Loop Variables) — is emitted as a ref var i = ref heap<…>(out var Ꮡi) declaration hoisted into the enclosing container (function body, block, or switch/select clause) — see Pointers — so other loops in that container that reuse i genuinely collide with it, unlike the ordinary all-loop-scoped case above. Loop variables are therefore grouped per container and name: the first whose box actually claims a container-level name is the keeper and keeps its name; every other direct-child loop variable with that name in the same container is force-shadow-renamed. The claim test mirrors the emission exactly — the var escapes AND is not inherently heap-allocated (a pointer/slice/map/chan/interface/func var is already a reference and gets no box) AND the box actually hoists (per the split above). A group with no claiming var is untouched, so ordinary same-named sibling loops keep their Go names — a claiming sibling would otherwise emit a duplicate hoisted box in the same scope (CS0128), and a non-claiming sibling’s loop-scoped variable (or deferred in-body box) nests inside the block that owns the box name (CS0136). (The historical motivating cases — runtime typesEqual’s for i := 0 pair inside one switch case and runqputslow’s three for i := … loops — now box per-iteration inside their bodies and no longer claim container names at all; EscapedLoopVarSiblingIndex keeps guarding the sibling grouping and was re-baselined to the per-iteration shape.) A function-body-level keeper is additionally recorded as function-level (so non-loop uses elsewhere shadow-rename as before), but never masks a real function-level declaration — preserving the ForVarMasks… invariant above. A name group with no escaped variable is untouched (loop-scoped in C# too — no churn).
Escape analysis marks only the arg’s storage ROOT, not every identifier in a pointer argument. Passing an expression to a pointer parameter escapes the storage the pointer refers to — the peeled root of a literal &expr (through parens, field selectors, index expressions, and derefs), or the bare identifier itself. An identifier appearing merely in a subexpression of the argument contributes a value, not its own address: in xs[i].link(&xs[i+1]) or typesEqual(tin[i], vin[i], seen) the container (xs/tin’s elements) escapes but the index i does not. The old contains-anywhere check heap-boxed every such loop index — a spurious allocation on a hot path (Go keeps these in registers), gratuitous Ꮡi machinery in the emitted code, and the very duplicate-hoist collisions the grouping above then had to resolve (typesEqual’s pair now emits two plain for (nint i = 0; …) loops, no boxes, no renames). A direct &i anywhere — including nested inside a larger argument — is still caught independently by the address-of analysis. And a renamed variable used as an LHS index/map key is rewritten there too. An assignment a[i] = … / m[ns] = … / p.f[k] = … reassigns the root (a/m/p); the index/key expression is a separate value, so a shadow-renamed variable used there (a[iΔ1], m[nsΔ1]) must be rewritten by descending the target’s index/selector/deref chain and renaming each index. Missing this is a silent bug — the LHS key kept the enclosing variable’s name, so m[ns] = nsΔ1*100 wrote to the wrong key with no compile error — as well as a CS0136/CS0165 once the loop variable itself is renamed. (Both guarded by the EscapedLoopVarSiblingIndex behavioral test — the array case would not compile and the map case would silently return the wrong value without the pair, its boxedSiblings extension covers two genuinely-escaping siblings in one switch case (both take &i; first keeps the name, second renames), and its caseSiblings extension proves the index-only pair stays UNBOXED; cleared the 2 runqputslow CS0136, a CS0841, and the 2 typesEqual CS0128.) The target-chain descent also visits a method-call receiver in the chain — x.ptr().Value.next = … (runtime stackpoolalloc, where the loop x is renamed xΔ1 because a func-body x is declared after the loop). The x is buried inside the x.ptr() call, past the selector/index steps, so without visiting the call the use kept the raw x — read before its (later) declaration → CS0841, or a silent wrong bind. Visiting the whole call renames its receiver and argument identifiers (the call’s result is the navigated base, so the descent stops there). (Guarded by the ShadowedVarMethodCallLHS behavioral test — write-through through the method verified vs Go; cleared the stack.cs CS0841.) A TYPE ASSERTION in that chain is a navigation step too, and its absence stopped the descent dead. n.Values[0].(*ast.CompositeLit).Type.(*ast.ArrayType).Len = nil — go/types’ generate_test.go, inside ast.Inspect(f, func(n ast.Node) bool { switch n := n.(type) { case *ast.ValueSpec: … } }), where the case variable shadows the literal’s n parameter and is renamed nΔ1. The assertion was in no arm of the descent switch, so everything below it went unvisited: the chain’s root ident kept its raw name and bound the enclosing ast.Node parameter, emitting (~n).Values[0]… — ~ applied to an interface, CS0023 — while the if condition one line above, an ordinary expression, renamed correctly. getIdentifier does not see through an assertion either, so unlike a paren-rooted target there is no reassignVar fallback to catch the root. The descent now visits the assertion’s operand subtree whole (the treatment the method-call arm already uses): it carries every ident that needs the rename — the root and any index below it — while the asserted type carries none. Where the raw name still type-checks this was a silent wrong bind rather than a compile error, which is why the guard compares values against Go rather than merely compiling. (Guarded by the AssignThroughTypeAssert behavioral test — the go/types witness, a renamed loop index below the assertion in the same chain, the compound-assign form, an assertion at the very root of the target in a default arm, and a chain-read control that already worked; the pre-fix converter regresses five emission sites, four of them CS0023.)
The reverse collision — a package method named like a built-in — needs the opposite treatment. In Go a method func (b *pageBits) clear() and the universe clear built-in coexist: the method is only ever reached as b.clear(), while a free clear(s) is always the built-in. But the method is emitted as a clear(this ref pageBits) extension on the package’s static class, and C# member lookup binds that same-class member for an unqualified free clear(s) call — shadowing the using-static go.builtin.clear and failing (CS1620/CS1503). So a built-in call whose name the package also declares as a method/function is emitted qualified — builtin.clear(s) — which resolves to the golib built-in regardless of the same-class shadow; the method call stays b.clear(). (This also required golib to gain the Go 1.21 clear built-in itself, in slice/span/map forms — plus an IMap<TKey, TValue> overload for a named map type’s value: the generated wrapper implements IMap<K,V> and forwards to the shared underlying map, so clear(h) on an http.Header-style named map empties the caller’s storage, and a nil named map stays a no-op (net/http/httputil’s clear(h), CS0411 without it). Guarded by the ClearBuiltinShadow behavioral test — including a named-map value cleared through an alias and a nil named map; runtime hit the original shadowing on pageBits.clear/sweepClass.clear, ~11 errors.)
For a function-literal parameter that shadows an enclosing local, the rename must reach the parameter declaration itself, not just the body: run(func(n int){ … n … }) where an outer n is in scope emits run((nint nΔ1) => { … nΔ1 … }). The body’s uses already resolve to nΔ1; if the signature still declared the bare n (the raw name), the body’s nΔ1 would be undeclared (CS0103). The parameter name in the emitted lambda signature therefore comes from the same shadow-aware identifier mapping as the body (the raw name when nothing is shadowed, so plain function types and non-shadowing parameters are unchanged). (Guarded by the ClosureParamShadow behavioral test; the runtime hit this pervasively on mcall/systemstack(func(gp *g){…}) where the closure’s gp shadows an outer gp, ~40 CS0103.)
Conversely, a local that shadows a pointer parameter must not inherit the parameter’s special emission. A deref-aliased pointer parameter is ж<T> Ꮡp with ref var p = ref Ꮡp.Value, so passing it whole to a *T-expecting function emits its box Ꮡp. But a local t shadowing a t *T parameter (func mapKeyError2(t *_type, …){ … var t *_type; … }) is a plain pointer local — passing it should stay use(tΔ2), not use(ᏑtΔ2) (the spurious & references an undefined ᏑtΔ2 box → CS0103). The bug was that the “is this a parameter?” check matched by name, so the shadowing local was misclassified; it now verifies the resolved object is genuinely one of the function’s parameter objects, not just a name match. (Guarded by the ShadowedPointerParam behavioral test; runtime hit this on mapKeyError2/interhash’s inner var t *_type, ~11 CS0103.)
Type-vs-Method Name Collisions
Go keeps types and methods in separate namespaces, so a package may legally declare both a type foo and a method foo on some receiver. In C# both land in the same package class — the nested type and the [GoRecv] extension method — where a type and a method cannot share a name (CS0102). The converter resolves this by Δ-prefixing the type (Δfoo) while the method keeps its core-sanitized name (foo), so they no longer collide.
This needs an extra step when the colliding name is also a golib reserved word (slice, array, channel, map, …). Such a name is Δ-prefixed anyway — to avoid the golib runtime type (slice<T> etc.) — so the method too becomes Δslice, and the plain Δ no longer separates type from method. In that case the converter appends the type marker ᴛ to the type only, giving it a name distinct from the method:
[GoType] partial struct Δsliceᴛ { … } // Go `type slice struct{…}`
[GoRecv] internal static Δsliceᴛ Δslice(this ref builder b, …) // Go `func (*builder) slice(…)`
Only the type side is renamed; the method (and every call site and go2cs-gen-generated pointer-receiver overload) stays Δslice. This is deliberate: the go2cs-gen generators compute method names independently, so renaming the method would desync them — renaming the type keeps the converter and generators in agreement (the generators read the type name from the emitted C# syntax/attributes). This mirrors the Go runtime’s type slice struct{…} (the GC slice header) versus func (*userArena) slice(…).
A struct field named like a colliding package-level identifier is not renamed: a field is struct-scoped (g.trace does not collide with a package type/method trace in C#), so the field declaration keeps its core-sanitized name (trace). The box-field accessor static the TypeGenerator emits for it is therefore g.Ꮡtrace (the Ꮡ-prefixed declared member name). The converter’s &g.field address form (Ꮡg.of(g.Ꮡtrace)) must use that declared field name — it derives the accessor member from getCoreSanitizedIdentifier plus the type-colliding rename, not from the general identifier path that applies the package-level collision Δ-rename. Using the latter would emit g.ᏑΔtrace, which has no matching generated static (CS0117). Reserved-word fields keep their Δ (the field really is declared Δarray for a field named array), so the accessor is Ꮡ+the declared name in every case. (Guarded by the CollisionFieldBoxAccessor behavioral test; runtime hit this on g/m/p’s trace/stack/p fields, ~20 CS0117.) The generated accessor’s accessibility matches the field’s (its exportedness), not the field type’s name — an exported field Fun [1]uintptr (C# array<nuint> Fun) yields a public ᏑFun, so another package’s other.of(ITab.ᏑFun) can reach it; deriving the scope from the type’s simple name (array → lowercase → internal) would make the cross-package accessor unreachable (CS0117 in runtime’s iface.go walking abi.ITab.Fun).
One case does rename the field: when its name equals its enclosing type’s name and that type is itself Δ-renamed for a type-vs-method collision. internal/trace’s type Label struct{ Label string } sits alongside func (e Event) Label() Label, so the type becomes ΔLabel; the field, whose name equals the type, is renamed to differ (CS0542 — a member cannot share its type’s name). The existing rename prefixed a single Δ, but that yields ΔLabel — equal to the renamed type, so the collision persisted. typeCollidingFieldName now doubles the marker (ΔΔLabel) when the name is a package-level collision, exactly as it already did for the keyword-family case (a reserved-word type is Δ-renamed too). Deterministic from the name, so the field declaration, the keyed composite-literal key, and every access site all agree:
[GoType] partial struct ΔLabel { // Go `type Label struct{ Label string }`
public @string ΔΔLabel; // field name == type name, doubled to differ
}
… new ΔLabel(ΔΔLabel: e.label, …) // composite key
… l.ΔΔLabel // access
(Guarded by FieldNameTypeMethodCollision — a Label field in a Label struct with a colliding Label() method, read/written through a value, the method result, and a composite literal.)
The double must also apply across packages. typeCollidingFieldName keys the double on the current package’s nameCollisions map, which is populated only for the package being converted — so a cross-package access of such a field (internal/trace/testtrace reading a Label’s field) emitted the SINGLE-marker l.ΔLabel against the declaration’s double ΔΔLabel — CS1061. The access site now consults the FIELD’S OWN package: fieldTypeIsRenamed derives the enclosing named type from the selector and asks packageHasMethodNamed(type.pkg, type.name) (a cached per-package scan of every func/method name — a type-vs-method collision Δ-renames the type), threading the result through a new fieldTypeIsRenamed ident context so convIdent upgrades the single marker to the double for the foreign case (the in-package case already doubled via nameCollisions, and its result is left untouched). CNR byte-identical (the pattern is absent from the single-package corpus except the guard). This is the FIELD-access counterpart to the cross-package renamed type-reference substitution (getCSharpTypeName / getScopeCheckedTypeName, further below) — that one covers naming the renamed type, this covers accessing its field; internal/trace/testtrace needs both. (The type-reference half — trace.Time/Event/Stack in a func signature, or *time.Location as a box element — is also resolved: a fresh full reconvert renders traceꓸTime/ж<timeꓸLocation> correctly through the convertToCSFullTypeName→getAliasedTypeName path described in Foreign renamed types reference the recorded imported-type alias below. It was mis-diagnosed as a still-open root off a stale overlay whose importedTypeAliases were not populated.) (Guarded by the CrossPkgUser/CrossPkgLib extension — CrossPkgLib.Marker, a Marker field in a Marker struct alongside a Sensor.Marker() method, its field read across the assembly boundary through an inferred-type value; vs Go.)
The same struct-scoped rule applies to a keyed composite-literal field name. Frame{funcInfo: f}, where the field funcInfo is named like a colliding package type/method (declared unrenamed as funcInfo), must emit the C# initializer key funcInfo: — the package-level Δ-rename that convExpr would apply yields ΔfuncInfo:, which is not a parameter name of the generated constructor (CS1739). convKeyValueExpr therefore emits a struct-field key whose name collides at package level via getCoreSanitizedIdentifier (the declared name), not the general identifier path. (Same CollisionFieldBoxAccessor test; runtime hit this on Frame{funcInfo: …} in symtab.)
The type half of the same accessor (receiver.of(Type.Ꮡfield)) needs care too. Go code routinely names a local after its own type — m := getg().m, where m is a *m — so taking the address of one of its fields (&m.park) emits m.of(m.Ꮡpark), in which the bare type reference m binds to the variable m (a ж<m>, which has no Ꮡpark) instead of the type (CS1061). Because a converted struct is nested in its package’s static class, the converter qualifies the type with that class — m.of(runtime_package.m.Ꮡpark) — which a same-named local cannot shadow. A bare m (binds the variable) and a go.m (the struct is not a direct member of the go namespace) both fail; the package-class qualifier is the correct form. This is applied only on a collision (the .of() receiver variable’s name equals the type’s simple name), so every other box accessor keeps its un-namespaced, Go-like form — no golden churn. (Guarded by the VarNamedAsType behavioral test; runtime hit this on m/Δp locals taking field addresses, ~9 CS1061.)
The same collision fires when the receiver is that variable’s lambda capture. Inside a closure the captured variable renames to its capture copy (mʗ1), so the receiver-equality check alone misses it — but the enclosing local m is still visible to the C# lambda, so the accessor’s bare owning-type reference binds to it all the same: runtime rwmutex.lockSlow’s systemstack(func() { …; notesleep(&m.park) }) emitted mʗ1.of(m.Ꮡpark) → CS1061. boxAccessorType therefore also qualifies when the receiver is the type name plus the capture marker (typeName + ʗ…), yielding mʗ1.of(runtime_package.m.Ꮡpark). (Guarded by a further extension to CollisionFieldBoxAccessor — capturedLocalNamedAfterType, a type-named local field-addressed inside a capturing closure, write-through verified vs Go; cleared runtime rwmutex’s 2 CS1061, 91 → 89.)
The type half also needs the type-vs-method collision rename (above). When the accessor’s owning type is itself a colliding name — type funcInfo versus a method func (f *Func) funcInfo(), so the type is declared ΔfuncInfo — taking the address of one of its fields must use the renamed type (Ꮡ(f).of(ΔfuncInfo.Ꮡnfuncdata)); a bare funcInfo.Ꮡnfuncdata binds to the package’s static funcInfo method group (CS0119). The boxAccessorType helper applies the Δ-rename to a bare same-package collision name before its receiver-shadow check (the renamed name no longer matches a raw-named local, so the two disambiguations compose). (Guarded by an extension to CollisionFieldBoxAccessor — a global whose type is the collision type; runtime hit this in symtab’s pcdatastart/funcdata.)
A collision-renamed owning type is qualified unconditionally, not just when it equals the .of() receiver — because a Go local named after its type is renamed to the same Δ-name, so such a local anywhere in the function shadows a bare Δp.Ꮡfield (C# locals are function-scoped). Runtime’s malloc persistentalloc1 does persistent = &mp.p.ptr().palloc and then declares a local p further down (renamed Δp); the accessor (~mp).p.ptr().of(Δp.Ꮡpalloc) bound its bare Δp to that later local — CS0841 (use-before-declaration), and CS1061 regardless (the local’s type has no Ꮡpalloc). The receiver ((~mp).p.ptr()) is not the colliding local, so the receiver-name check missed it. boxAccessorType now qualifies whenever the type name is Δ-prefixed (a type is never shadow-renamed — types are package-level — so a Δ-prefixed accessor type is always a collision rename), emitting (~mp).p.ptr().of(runtime_package.Δp.Ꮡpalloc). Qualifying is value-identical to the bare form when nothing shadows, so it is safe to apply to every collision-type accessor. (Guarded by a further extension to CollisionFieldBoxAccessor — localShadowsCollisionType, a local named after the collision type declared after the accessor; cleared runtime malloc’s CS0841 plus two mheap Δp.Ꮡgcw CS1061 of the same shape, 148 → 145.)
The three receiver-shadow arms above (.of() receiver equals the type, its capture, its box) and the collision-rename arm are all special cases of one general rule: a box accessor’s bare owning-type spelling Type.Ꮡfield binds to any same-named variable that C# has in scope, and C# scopes a local to its whole enclosing block regardless of where it is declared or whether it participates in this accessor at all. So boxAccessorType also qualifies whenever a variable of the type’s name is declared anywhere in the current function — receiver, parameters, results, or a local at any nesting depth (func literals included), collected into funcScopeVarNames during variable analysis. The general case, unrelated to any type-vs-method collision, is vendored poly1305 under -tags purego: mac_noasm.go declares type mac struct{ macGeneric }, and func (h *MAC) Sum(b []byte) declares var mac [TagSize]byte, so reaching the promoted-embed method h.mac.Sum(&mac) spelled Ꮡ(h.mac).of(mac.ᏑmacGeneric) in which mac bound to the array<byte> local (CS1061 ×2). It errored precisely in Sum/Verify (which declare that local) and not in Write (which does not) — confirming the diagnosis. Qualifying is always value-correct: Go guarantees the reference is unambiguous, and inside a scope that shadows the type the type is simply unreachable by that bare name, so every bare occurrence the emitter produces is meant as the type. Emitted forms (from the LocalShadowsEmbedHopType guard):
Ꮡ(h.acc).of(acc.Ꮡinner).Add(p); // Write: no local named `acc` — stays bare
Ꮡ(h.acc).of(main_package.acc.Ꮡinner).Store(Ꮡacc); // Sum: `var acc [4]byte` shadows the type — qualified
Ꮡ(d.deep).of(main_package.deep.Ꮡacc).of(main_package.acc.Ꮡinner).Store(Ꮡdeep); // Verify: both hop types shadowed (nested-block locals)
This is a pre-existing shadow class the converter always had latent; adopting -tags purego for the standard-library conversion (see The standard-library conversion applies -tags purego below) is what first reached the poly1305 code that exercises it — unblocking poly1305 and its 17 dependents (chacha20poly1305 → crypto/tls → net/http, net/rpc, net/smtp, expvar). (Guarded by LocalShadowsEmbedHopType, whose Write/Sum/Verify discriminate qualified-vs-bare by whether the method declares the shadowing local; CollisionFieldBoxAccessor’s boxRefCapturedValueNamedAfterType golden was re-baselined — it previously compiled Ꮡw.of(w.Ꮡpark) only because C#’s identical-simple-name rule happened to bind the type; it now qualifies uniformly like every other shadowed accessor, a strict improvement with identical runtime output.)
A related case is the box name of a shadow-renamed receiver/parameter. A deref-aliased pointer (a receiver or a *T parameter) is emitted as ref var <name> = ref Ꮡ<raw>.Value — the Ꮡ companion always keeps the raw Go name, even when the value alias is shadow-renamed for a collision (func (p *cpuProfile) add() where p collides with the type p → ref var Δp = ref Ꮡp.Value). When a pointer-receiver (capture-mode) method is then called on that receiver/parameter, the call routes through the box, and that box reference must use the raw name Ꮡp — the value alias Δp would yield ᏑΔp, which is not in scope (CS0103). The converter builds the box from the raw identifier name (not the shadow-renamed value form), but only when they differ — so non-renamed receivers are unaffected (no churn).
The same raw-box-name rule applies when such a shadow-renamed pointer is captured by a closure (where the value alias is referenced through its box, since the ref-local can’t be captured — see the box-ref section below). A value use inside the closure becomes Ꮡp.Value.n and a field-address use Ꮡp.of(T.Ꮡn) — both rooted at the raw box name Ꮡp, never the renamed ᏑΔp. The field-address form (&p.field) routes through the box-ref address path rather than the generic pointer-variable path: that generic path would prepend Ꮡ onto the closure’s box-deref read (Ꮡp.Value), yielding a double-boxed ᏑᏑp.Value (CS0103). Because the captured pointer’s box Ꮡp is the ж<T>, the field address is simply Ꮡp.of(T.Ꮡfield) — the same form as a captured value struct. (Guarded by the RenamedReceiverBox behavioral test, which exercises a shadow-renamed receiver calling a capture-mode method, plus a shadow-renamed pointer parameter both read through and field-addressed inside a closure; runtime hit this on p/Δp receivers calling methods like p.addExtra() and on closures capturing such pointers, ~12+ CS0103.)
A closure that captures an outer variable is emitted with a snapshot copy declared before the lambda — var sʗ1 = s; — and uses of the captured variable inside the lambda are rewritten to that capture name sʗ1. The capture-name mapping is keyed by name, which breaks on a self-shadowing initializer inside the closure: runtime mgcsweep’s systemstack(func() { s := spanOf(uintptr(unsafe.Pointer(s.largeType))); … }) declares an inner s whose initializer reads the outer captured s. Both the captured use (the RHS s.largeType) and the distinct inner binding were mapped to the same sʗ3, so the inner declaration emitted var sʗ3 = …(~sʗ3)… — its RHS binding to the not-yet-initialized inner variable (CS0841). The fix records the captured object alongside the name, and applies the capture name only when an ident resolves to that exact outer object; the inner binding falls through to its own (shadow-renamed) name. The emission is var sΔ1 = spanOf(…(~sʗ3)…) — the inner s shadow-renamed to sΔ1 (distinct from the capture sʗ3), its RHS correctly reading the captured sʗ3, and later uses of the inner s using sΔ1. Because the object check passes for every non-shadowing capture (the ident is the captured variable), it changes nothing outside this self-shadow case (zero golden churn). (Guarded by the ClosureSelfShadowCapture behavioral test — a captured pointer with an inner s := f(s) in a systemstack-shaped call-argument closure, output verified vs Go; cleared runtime mgcsweep’s CS0841.)
The same rule applies to an escaping local whose address is taken — var p _panic; … preprintpanics(&p) in runtime’s gopanic, where p collides with the type p. The heap allocation is ref var Δp = ref heap(new _panic(), out var Ꮡp), so the box is Ꮡp (raw) and &p must emit Ꮡp, not ᏑΔp. Crucially, the box-name rule is keyed to the rename kind, because the two kinds name their boxes differently: a type-collision rename prepends the marker (p → Δp) but keeps the raw box (Ꮡp), whereas a nested-scope shadow rename appends the marker plus a counter (i → iΔ1, iΔ2) and keeps the shadow box (ref var iΔ1 = ref heap<nint>(out var ᏑiΔ1), so &i correctly emits ᏑiΔ1). The converter therefore rewrites to the raw name only when the alias is exactly Δ+rawname (the collision form); a shadow-renamed or non-renamed var keeps its existing box name. (Guarded by the CollisionRenamedLocalBox behavioral test, with ForVariants/NestedVarShadow covering the shadow-rename form left unchanged.)
A nested closure must not clobber the enclosing closure’s capture state
The per-lambda conversion state — conversionInLambda (are we inside a closure body?) plus the capture-name maps (currentLambdaVars/currentLambdaVarObjs) — is what makes closure-body emission rewrite captured references to their box/copy forms: a captured local s reads as sʗ1, and the current method’s direct-ж receiver (func (s *Stmt) … emitted this ж<Stmt> Ꮡs, whose body alias ref var s = ref Ꮡs.Value is a ref-local that cannot be captured by a C# closure) reads through its box as Ꮡs.Value. That state was set on entering a closure but reset to false/nil on exit, not restored — so a closure that contains an inner closure had its state wiped the moment the inner one finished, and every reference in the outer closure body after the inner one fell back to the bare, un-rewritten name. For a receiver field-read that is a bare ref-local capture — database/sql (*Stmt).QueryContext’s s.db.retry(func(){ …; rows.releaseConn = func(err){…}; if s.cg != nil { … } }), where s.cg sits after the inner releaseConn closure — the emission was s.cg (CS8175, “cannot use ref local s inside an anonymous method/lambda”); the equivalent captured-local case silently split a variable between its bare form and its ʗ1 copy within one closure. The fix makes enterLambdaConversion/exitLambdaConversion a proper LIFO save/restore stack (conversionStack): entering pushes the current state and installs fresh state; exiting restores the enclosing closure’s state instead of resetting. A closure at top level still restores to false/empty (unchanged), so the change is inert except where a closure body continues after a nested closure — there the receiver box-read (Ꮡs.Value.cg, Ꮡs.Value.cg.txCtx()) and the captured-local copy name are now applied consistently across the whole body. (Guarded by the NestedLambdaReceiverField behavioral test — a direct-ж receiver method whose closure holds a nested closure followed by a non-call receiver field read, a field-method call, and another field read, all verified to render Ꮡs.Value.<field> and output-compared vs Go; cleared database/sql’s 2×CS8175 and re-baselined DeferValueFieldPtrReceiver whose defer-then-body sequence exercises the same restore.)
The capture snapshot is a STATEMENT, so every position that can hold a func literal owes it a hoist target
var sʗ1 = s; is a declaration statement, and C# has no statement slot inside an argument list — so a
capturing literal in expression position must send its snapshot to a hoist sink the enclosing
statement flushes ahead of itself. convFuncLit consults two, in order: the explicit
LambdaContext.deferredDecls builder that go/defer/return thread through the expression
contexts, then the ambient v.hoistedDecls that the assignment, expression-statement, if, for,
range and var-spec forms install. With neither, the decls emit inline and the file stops
parsing — CS1003 ',' expected + CS1026 ')' expected + CS1002 ';' expected + CS1513 '}' expected,
per site, the first of which reads as a defect in whatever token happens to follow.
Two positions had no sink, and between them they were the entire parse wall that kept net/http’s
1,352-verdict suite from ever running (28 diagnostics, 7 clusters, 2 of 35 converted test files):
-
A CONVERSION is transparent to the hoist.
HandlerFunc(func(rw, req){ … conn … })handed togo Serve(ls, …)(serve_test) is a type conversion whose operand is the literal. The conversion fork ofconvCallExprrendered that operand with no expression contexts at all, so the wrapper made the literal invisible to the sink thegostatement had already provided, and the snapshot landed inside the delegate-creation argument listnew Δhttp.HandlerFunc(var connʗ1 = conn; …). The fix adopts the ambient target for the conversion operand exactly as the&compositeand composite-literal arms ofconvExpralready do — gated on a non-nil sink, so every other conversion keeps rendering with the nil contexts it always had. It matters only where a statement supplies the explicit builder and no ambient one, i.e. thego,deferandreturnforms; the statement forms that installv.hoistedDeclswere already served by the second lookup. -
A channel SEND supplied no sink of either kind.
handlerc <- HandlerFunc(func(w, r){ … ts … })(client_test) broke for the same reason with the wrapper, and a bare capturing literal sent to a channel broke without one.visitSendStmtnow installsv.hoistedDeclsand writes it before the send, the same shapevisitExprStmtuses — under the sameuseNewLinetest, because aSendStmtis aSimpleStmtand can also be afor/ifinit-or-post clause, where there is no statement slot to hoist into and the enclosing statement’s own sink must stand.
The general rule the two share: a conversion, an adapter wrap, or any other expression wrapper must
not be able to hide a func literal from the enclosing statement’s hoist. (Guarded by the
CaptureHoistThroughConversion behavioral test — ten shapes covering go, defer, channel send,
interface-element send, plain call, return and assignment positions, wrapped and bare, all
output-compared against go run; the seven affected shapes reproduce the CS1003/CS1026/CS1002
cluster with the fix reverted.)
Test-variant name coherence: production names are pinned, test-side method declarators Δ-rename
The -tests pipeline re-analyzes the package over the whole variant universe (production files + _test.go files) but only emits the test files — the production .cs on disk were converted from the production-only universe and recompile into the test assembly as-is. Production symbol names are therefore immutable in a test-variant analysis: any collision a test file introduces must resolve by Δ-renaming the test-side declarator, never the production element. Two shapes (strings/sort blockers B2/B9), both resolved in performNameCollisionAnalysis:
-
A test-file METHOD over a production element’s name (B2). strings’ export_test.go declares
func (r *Replacer) Replacer() any— the ordinary type-vs-method resolution (above) would Δ-rename the type, but the productionreplace.cson disk keepsReplacer, so the assembly split into two disagreeing halves (CS0102strings_packagealready containsReplacer+ CS0246ΔReplacer). When the colliding method declarators are all test-declared and the element is production-declared, the element keeps its bare name (and no exported alias is registered) and the method Δ-renames instead; a FuncDecl colliding with a same-package element is necessarily a method (Go keeps method names in a separate namespace — any other same-scope reuse is a Go compile error). When a production method also carries the name, the production universe had the same collision and already renamed the element on disk, so the normal path stays consistent. -
A test-file METHOD shadowing a dot-imported function the variant calls unqualified (B9). Go keeps method names and dot-imported function names in separate namespaces, but both land in the C# package class’s member-lookup scope, and an enclosing class’s method group always wins over
using staticimports — sort_test.go’s dot-importedSort(data)bound example_keys_test.go’sBy.Sortextension (CS1501 ×14, plus 5 downstream method-group CS1503s the wave-2 probe had attributed to B10). A test-declared method whose name matches a foreign function the variant references unqualified (only unqualified sites conflict — SelectorExpr Sels are excluded, so a qualifiedsort.Sort(ps)never triggers; an unqualified foreign-function reference can only come from a dot-import) Δ-renames, and the dot-imported call keeps its bare emission, now binding throughusing static. -
A test-file FREE FUNCTION whose emitted signature matches a production METHOD’s receiver (2026-07-20). A method emits as a C# extension method, so its receiver becomes the leading
thisparameter — andthisdoes not participate in C# signature identity. math/big’sfunc (z nat) norm() nat(nat.go) andfunc norm(x nat) nat(int_test.go) are legal Go in separate namespaces, but both emit asnorm(nat)inbig_package: CS0111.resolveReceiverParameterCollisionscompares each same-named pair’s emitted parameter list — the method’s receiver type followed by its parameters, against the free function’s parameters — and Δ-renames the test-side declarator. Discrimination is exact: an extra parameter (func trim(x nat, n int)) or a different first parameter (func keep(n int)) emits distinctly and keeps its plain name, as do generic declarations (type parameters keep the C# signatures distinct) and a variadic/non-variadic mismatch. Two methods can never collide this way (Go forbids redeclaring one method on one type) and two free functions cannot share a package scope, so a method/free-function pair is the only shape. When both sides are test-declared the FREE FUNCTION is the one renamed, so the outcome does not depend on declaration order and two colliding declarators never both becomeΔ-prefixed; a collision between two production declarators is deliberately left alone, since it would equally break the production-only conversion and is a different fix than test-variant coherence (the 302-package corpus compiles clean, so no instance exists).
The rename registry is object-keyed (testMethodRenames map[types.Object]bool) — the same-named production type/function keeps its plain emission at every other site — and session-scoped, initialized once per -tests conversion rather than per variant: both variants come from one go/packages load, so the external variant’s references to an internal-variant method (the export_test pattern) resolve by object identity to entries registered during the internal pass. The declaration renames in visitFuncDecl, and every reference follows through convIdent — a METHOD name through its isMethod arm (all selector emissions funnel there), and a package-level free function referenced as a call target or function value through the trailing identifier path, which the receiver/first-parameter case above made reachable (without it the declarator renamed while its call sites still emitted the bare name: CS0103). The go2cs-gen RecvGenerator reads the emitted name, so generated ж-receiver overloads follow automatically. Real emissions from the probes:
public static any ΔReplacer(this ж<Replacer> Ꮡr) { … } // strings export_test.go `func (r *Replacer) Replacer() any` — type stays bare
@string got = fmt.Sprintf("%T"u8, tc.r.ΔReplacer()); // EXTERNAL-variant call site (replace_test.cs) follows the internal rename
public static void ΔSort(this By by, slice<Planet> planets) { … } // sort example_keys_test.go `func (by By) Sort(planets []Planet)`
new By(mass).ΔSort(planets); // test-method call sites follow
Sort(data); // dot-imported production call keeps its bare emission
Production conversions have no _test.go files in their universe, so the analysis is inert there (CNR byte-identical ×402). (Guarded by TestTestVariantPinsProductionTypeAgainstTestMethodCollision — declaration, internal call site, external call site, and the pinned type — and TestTestVariantRenamesTestMethodShadowingDotImportedFunction — rename + bare dot-imported call, with never-referenced and qualified-only same-named methods as discrimination controls.)
A NESTED package’s production GoImplement record anchors to the production metadata file
An EXTERNAL test variant’s collected GoImplement records are split across two anchor files, because the go2cs-gen ImplementGenerator hosts its output in the first class of the attribute-bearing file: a record whose generated adapter must be a member of the test package class goes to package_info_external_test.cs (whose first class is <pkg>_test_package), while a record that generates a partial/adapter on the production class stays with the production-anchored package_test_info.cs. isTestAnchoredImplementRecord decides which, by testing the implementer name against the production class’s qualifier.
The anchor is a file-level property — GetFirstClassName reads the first ClassDeclarationSyntax in the compilation unit — so two required anchors mean two files; this is a workaround for the generator’s positional contract, not an intrinsic need. The external unit is written only when the variant actually records test-anchored attributes, so utf8-class packages keep their single-file shape. It carries no [GoPackage] (the attribute-bearing partial stays in package_test_info.cs, CS0579) and no global using aliases (they must be declared once per compilation, CS1537).
⚠ The _test.cs suffix on package_info_external_test.cs is load-bearing. It is what excludes the file from the production project, via the shared csproj-template.xml <Compile Remove="*_test.cs;…"> glob and productionCSFiles. package_test_info.cs does not match that glob, which is why it needed its own explicit entry in the template — and why the external unit was originally named package_info_test.cs (2026-07-18) to ride the glob for free. That name was a near-anagram of package_test_info.cs, and the two sorted adjacent to package_info.cs in every converted package directory; it was renamed to package_info_external_test.cs on 2026-07-21 (“external test package” is Go’s own term for package <name>_test). Any future rename must keep the _test.cs suffix, or add an entry to the shared template — which re-emits, and so churns, every behavioral .csproj.
The live records qualify the implementer namespace-relative — WITHOUT the go. root: math/rand’s external rand_test casting *rand.Rand to io.Reader records ж<math.rand_package.Rand>. The test formerly compared only the BARE (rand_package.) and fully-rooted (go.math.rand_package.) forms, so a top-level package matched by accident — its relative qualifier is the bare form (sort_package.IntSlice) — while every nested package (math/*, text/*, net/*, encoding/*, container/*, hash/*, crypto/*) matched neither and mis-anchored to the test file. The generator then emitted a SHORT StructName (non-foreign structs are emitted unqualified) inside rand_test_package, producing ж<Rand> where no Rand is in scope — while the converter’s own cast site already assumed production anchoring (new rand.RandжReader(r) against using rand = go.math.rand_package;), so the pair failed as two CS0246s in the generated .g.cs. The relative qualifier is now recognized alongside the other two, matching the function’s stated contract.
⚠ package_info_external_test.cs and package_test_info.cs are MERGE-PRESERVING. After any change to this routing, DELETE both files and the package’s Generated/ directory before re-running the pipeline — otherwise a stale record persists in both anchors and masks the result.
Test suites REFERENCE the production project instead of recompiling it
The original -tests model — recompile the production .cs into the test assembly — duplicates the production types. That is harmless until another referenced assembly surfaces one of those types in its API: strings.ToLowerSpecial(unicode.SpecialCase, …) and hash.Hash : io.Writer name the type in the production assembly, while the test source binds a distinct recompiled copy. No compile-set adjustment repairs that identity split.
-tests therefore selects among three test-project models (selectTestProjectModel, recorded as testProjectModel in the manifest):
-
reference— a black-box-only suite references the colocated production project and emits only the external test package into the test assembly. -
whitebox-reference— a suite with an internal variant still references production, but emits the internal_test.godeclarations into a separate friend-assembly bridge. This keeps the production assembly as the sole identity while preserving access to Go-unexported members. -
recompile— the original same-assembly shape, retained only as the deterministic fallback when test-contributed metadata genuinely has to mutate a closed production type.
Both reference models bind the package under test as an ordinary imported package: exported aliases and implementation metadata load from the colocated package_info.cs, its types render package-qualified, and isSameAssemblyPkg is false. Their package_test_info.cs is a test-class-only metadata anchor; it never declares a local production partial.
The white-box extension has five coupled parts:
- The normal production scan already reads build-selected same-package
_test.gofiles to stabilize alias-shadow spelling. That same cheap scan now reports whether an internal test file exists. Only then does the production.csprojemit<InternalsVisibleTo Include="$(AssemblyName).tests" />; packages with no internal tests remain byte-stable. - Internal test files emit into
<name>_internal_test_package, withusing static <namespace>.<name>_package. Production declarations remain untouched. Test-host registrations retain the Go package name in the manifest but target this bridge class. -
go/packagesloads production, internal and external test variants together. An external test reference is routed to the bridge only when itsgo/types.Objectbelongs to the production import path and its declaration position is in_test.go; production objects and same-spelled unrelated declarations keep their ordinary route. This is howio_testreachesErrInvalidWritefromexport_test.gowithout source rewriting or a generated alias contract. - Test-contributed implementation adapters are owned by test metadata anchors — a MIXED suite has two. The generators host output in the FIRST class of the attribute-bearing file, and a mixed white-box assembly has two classes generated code must merge into, so the B4/B5 two-file split returns in mirror image: records whose generated partial must merge with a bridge-declared type (a BARE record name in the internal variant’s declared-name set —
splitWhiteboxVariantRecords) anchor inpackage_info_internal_test.cs, whose first — and only — class is the bridge (also the bridge’s singlestaticdeclaration and its[GoPackage]carrier); every other record — production-qualified, foreign, or external-declared — stays inpackage_test_info.csunder the external test class. Anchoring a bridge implementer in the external class would generate a phantom empty type there instead of merging with the real declaration. Deferred adapter markers are redirected only when the exact(struct, interface)pair appears in a test anchor (emittedAdapterPair); the anchored reference is composed<anchor>.<member>where the member comes from the record’s spelling (anchoredAdapterMemberName—adapterStructKeynormalizes a qualified production struct to the generator’s foreign<pkg>_<Simple>form and leaves a variant-local name bare, exactly the generator’s local-vs-foreign naming split; composing from the cast site’s spelling instead emittedParseErrorжerrorwhere the generator wrotecsv_ParseErrorжerror), and each pair remembers which anchor file recorded it. Imported production adapters keep pointing to their defining assembly. The generator also recognizes a collision-renamed embedded value property (ΔBufferfor embeddedbytes.Buffer) as the same promotion hop, and scans the current compilation for friend-bridge box-receiver extensions by simple name when — and only when — the struct has no local declaration (the bridge spells its box parameter through the imported alias,this ж<Replacer>, and the metadata-only case is precisely the one whose discovery compilation is null). - The metadata seed imports the production, bridge and external-test classes as needed, but its first and only declaration remains the selected test anchor. An internal-only suite’s bridge is both the test class and the bridge, so the seed imports it exactly once (a second, global import of the same class is CS8933). This keeps go2cs-gen’s positional anchor contract deterministic for mixed and internal-only suites.
A MIXED suite’s
package_info_internal_test.csis therefore written UNCONDITIONALLY, records or not (2026-08-14). The file is not only a metadata anchor: it is the bridge class’s ONLYpublic static partialdeclaration. Every converted SOURCE file opens its package class bare —partial class registry_internal_test_package {— exactly as production and external-test sources do, with the modifier living in the metadata file; seepackage_info.cs’sTypeAccessibilitysection for the same division of labour applied to types. Writing the unit only when the variant contributed bridge-anchoredGoImplement/GoImplicitConvrecords therefore left a record-less bridge with nostaticdeclaration anywhere, and an internal test file declaring a method on a production type — which converts to an EXTENSION method — is then CS1106.internal/syscall/windows/registry’s whole 6-verdict suite sat behind one such line,func (k Key) SetValue(name string, valtype uint32, data []byte) errorin itsexport_test.go. Mixed suites that appear to escape it do so incidentally:sort,bytesandstringseach happen to have a go2cs-genRecvGeneratorfile that re-declares the classpublic static partial— a generator supplying a modifier the emitter owes. A record-less bridge writes an anchor whose sections are all empty, which is what the production and external-test seeds already do in the same situation. Measured: registry moves from build-blocked to 4 of 6 (residualsTestValues, a raw-address array reinterpret materializing a zero-lengtharray<T>, andTestGetMUIStringValue); guarded byTestWhiteboxBridgeUnitIsWrittenWithoutBridgeRecords. - The friend grant is inserted after template rendering, never as a template verb: a user-supplied
-csprojtemplate keeps its historical verb count and renders exactly as before (insertFriendAssemblyAccess, anchored on the first closingPropertyGroup). And the reference models’ anchored metadata writes treat the anchor class as the local type scope (metadataAnchorLocalTypes), while the recompile model’s anchored writes keep the historical production-local qualification — there the production class genuinely is local to the assembly.
A production ALIAS whose right-hand side is ANONYMOUS is carried across with its global using (2026-08-14). Go’s type CorpusEntry = struct{Parent string; Path string; Data []byte; …} (internal/fuzz’s fuzz.go) has no C# spelling of its own, so the production conversion LIFTS the anonymous struct to a real nested type and reaches it through a compilation-scoped alias — global using CorpusEntry = go.@internal.fuzz_package.CorpusEntryᴛ1; at the top of fuzz.cs. global using is scoped to ONE compilation, and a reference-model test project is a second one that does not recompile the production sources, so neither half crossed: nothing visited the declaration, nothing claimed the lift, and every test-side reference fell through to t.String() and emitted raw Go syntax into a C# file —
internal Func<struct{Parent string; Path string; Data []byte; Values []any; Generation int; IsSeed bool}, error> fn;
— CS1031/CS1525/CS1003 cascades in minimize_test.cs and worker_test.cs, with all 52 of the package’s verdicts behind them. seedProductionAliasLifts now reads the production package’s own package_info.cs (which the test conversion already opens for its GoImplement pairs) and seeds both halves together: the alias into importedTypeAliases, so the test metadata file re-emits the global using, and the anonymous TYPE into productionAliasLiftedTypes, so every renderer spells CorpusEntry (liftedNameFor, consulted wherever liftedTypeMap was). Keying by go/types identity is exact here — production and test variants are type-checked in one go/packages load, so the alias’s right-hand side and every test-side reference are the same *types.Struct.
Narrow on both axes, deliberately — but on the SECOND axis only. Publication is the standing precondition: only an alias the production package_info.cs publishes is seeded, so a type is never rendered under a name the test compilation cannot resolve, and an unexported alias to an anonymous struct publishes nothing and keeps the pre-existing route.
The anonymous-RHS restriction was WRONG and is retired (2026-08-19). Its reasoning — “a named RHS already renders through its own qualified name” — does not hold wherever the production conversion declares the alias as a compilation-scoped global using in the FILE that declares it, because the renderer then spells the BARE alias name in production and test alike. html/template’s type FuncMap = template.FuncMap is exactly that shape:
// template.cs, line 4 — the production conversion's own declaration
global using FuncMap = go.text.template_package.FuncMap;
A reference-model test project compiles *_test.cs ONLY, so template.cs is not in that compilation and the bare name resolves nowhere — new FuncMap(new map<@string, any>{…}) in clone_test.cs, escape_test.cs and exec_test.cs is CS0246 ×6, with all 243 of the package’s verdicts behind it. The test metadata file did already declare the CROSS-package two-hop spelling (global using templateꓸFuncMap = go.text.template_package.FuncMap;), which is what makes this a name-resolution gap rather than a missing import.
So a NAMED right-hand side is seeded too — and only the name half of it. The type half stays anonymous-RHS-only, because a named RHS has its own qualified spelling and is already rendered through it, so recording it in productionAliasLiftedTypes would re-spell references that already compile. The two kinds therefore need different halves of “reachable”, and recordType in seedProductionAliasLifts is that distinction. The set this widens to is small and exact: across Go 1.23’s converted standard library, an EXPORTED type X = <named> exists in four packages only — html/template (FuncMap), os (DirEntry/PathError/FileInfo/FileMode), internal/reflectlite (Kind) and debug/buildinfo (BuildInfo) — and a _test.go cannot redeclare such a name in the package’s own scope, which is where the recorded collision concern was. Guarded by TestSeedProductionAliasLiftsCarriesLiftAndAliasTogether, which now pins BOTH halves of the named case (the alias seeded, the type not) alongside the anonymous case and the unpublished-alias control.
internal/fuzz builds clean afterwards (0 errors, from four parse-error families) and then stops one layer further out, on an infrastructure root that is not this one and is worth recording precisely: its worker_test.go TestMain calls flag.Parse(), and the converted flag.CommandLine has never been told about the host’s own --json / --result / --junit / -timeout arguments, so the run dies with flag provided but not defined: -json before any test executes. In Go, testing.M registers those flags on flag.CommandLine before TestMain runs, which is what makes the same flag.Parse() legal there. This also corrects the board’s attribution of the identical symptom on go/internal/srcimporter (“the process the host launches is not the go2cs test host”): the process IS the host — what it lacks is the flag registration.
That second layer is now closed, host-only (2026-08-14) — src/core/testing/TestFlagBridge.cs, and internal/fuzz banks 52/52 behind it. The host declares its OWN command line on the converted flag.CommandLine immediately before it invokes a converted TestMain, which is exactly what testing.Init() does for -test.* in Go and exactly what was missing: nothing is consumed or hidden from the parse, the names are simply defined, so flag.Parse() recognizes them. The full -test.* set is registered — with this run’s real values for test.run/test.parallel/test.v/test.short/test.count/test.timeout/test.shuffle — rather than only the spellings the host was handed, because converted tests READ those flags back (os/exec gates on flag.Lookup("test.run").Value.String(), runtime on flag.Lookup("test.parallel").Value.(flag.Getter).Get().(int)); registering less would trade a parse error for a nil dereference, and it is why the typed registrars are used and not flag.Func, whose funcValue has an empty String() and is no Getter. The flag package is bound late, by name, not by project reference: Go’s testing imports flag, but the generated test csproj sets DisableTransitiveProjectReferences=true (load-bearing against CS0576), so a testing → flag reference does not deploy flag.dll beside the 124 of 141 test hosts whose package does not import flag — measured, along with a +33% build cost on every test project — and it is not needed, because the converted flag package is present in a test compilation iff that package imports it, which is precisely when flag.CommandLine is observable at all. A name the test package already defined is skipped, since the converted FlagSet.Var panics on redefinition. Go’s M.Run also parses when flag.Parsed() is false; that is deliberately NOT mirrored, because no member of the class needs it and an unconditional parse would newly reach ExitOnError for packages that merely reference flag.
A THIRD layer of the same concern — the host now STOPS parsing at the first non-flag argument, which is Go’s rule (2026-08-14, lane claude/host-argv-stop; src/core/testing/TestOptions.cs). The bridge above made a converted flag.Parse() recognize the host’s arguments; this makes the host recognize the PROGRAM’s. flag.(*FlagSet).parseOne stops at the first token that is not at least two characters long and starting with -, leaving it and everything after it for the program — and a Go test binary is a program: its TestMain may take arguments, and os/exec drives its entire helper protocol that way, re-executing the test binary as exec.Command(exePath(t), "cat") and dispatching on flag.Args()[0]. The converted host’s TestOptions.Parse had no stopping rule at all; its default: arm threw unsupported converted test option: cat, so every helper child died at startup with exit 2 before TestMain was entered, and the PARENT then reported the downstream symptom (echo: want "foo bar baz\n", got "", ExitCode got 2, want 42) — one host defect reading as twenty unrelated failures across 26 of os/exec’s comparison rows. Nothing else was needed to make the remainder visible: the converted os package fills os.Args from the real command line independently of this parser (on Windows through syscall.GetCommandLine + commandLineToArgv, on unix through runtime.argslice/goargs_impl.cs), so the host’s whole obligation is to stop, and to leave the trailing tokens untouched on the way past. Stopping is not ignoring, and the difference is load-bearing in both directions: exe cat -n must leave -n to the child rather than parse it as a host flag, while an unrecognized -flag appearing BEFORE any non-flag is the host’s own command line being wrong — Go errors there too, so it still exits 2 (with Go’s own wording, flag provided but not defined: -x, since this host stands in for a Go test binary and its stderr is read beside one). The rest of parseOne is mirrored for the same reason: a lone - is a non-flag by the length test, -- terminates the flags and is itself consumed, ---x/-=x are bad flag syntax, a non-boolean flag takes the NEXT token as its value even when that token looks like a flag (-run -v filters on -v), and one or two leading dashes name the same flag — the --json ≡ -json equivalence TestFlagBridge already assumed when it republishes these options under their undashed names. (Guarded by TestingRuntimeTests.FlagParsingStopsAtTheFirstNonFlagAndLeavesTheRestToTheProgram, which pins all of it through the host’s public surface: recognized flags in both dash spellings, the stop, a trailing -v that must neither be consumed nor rejected, -/--, and the unknown-flag and bad-syntax errors before a stop.) os/exec’s root A is closed by this — 23 of the 26 rows joined the agreeing set — but the package does NOT bank: 27 rows remain on the declared relocatable single-file test executable host limit, and the 3 residual TestWaitInterrupt rows revealed a THIRD root underneath, os/signal’s runtime primitives being unimplemented partial stubs. See the board.
A FOURTH layer, which CORRECTS one sentence of the third — the package’s OWN flags participate, because the package is now initialized before the host decides (2026-08-17, lane claude/tls-finish; src/core/testing/TestOptions.cs, TestHost.cs, TestFlagBridge.cs). The layer above says “an unrecognized -flag appearing BEFORE any non-flag is the host’s own command line being wrong”. That is right only when nobody else could own the name, and there is a whole class where somebody does: a package that declares its own flag.Bool/flag.String at package level. crypto/tls is the corpus’s example — BoGo re-executes the test binary as its TLS shim (-shim-path=os.Args[0] -shim-extra-flags=-bogo-mode), -bogo-mode is a flag.Bool in handshake_test.go, and all 3,242 BoGo cases died at startup on flag provided but not defined: -bogo-mode before TestMain ran. The mechanism is ORDER, not tolerance. Go’s test binary reaches exactly ONE flag.Parse(), and by then testing.Init() has defined the -test.* set and the package’s own package-level variable initializers have run, so both vocabularies live in a single flag set — an unknown name is still an error there, just a later and better-informed one. The converted host’s parse necessarily runs earlier than the package’s initialization, so it was answering a question it could not yet answer. Three changes restore Go’s order: (1) TestOptions.Parse’s default: arm records the name in UnrecognizedFlag and STOPS, exactly as a non-flag token stops it — stopping rather than skipping is forced, because nothing at that point knows a foreign flag’s ARITY and -port 5000’s value is indistinguishable from a program argument, so everything from the unrecognized name onward is the program’s (which does mean a HOST flag placed after a package flag is left to flag.Parse() rather than read here; the pipeline places the host’s own flags first, and Go’s single parse makes the ordering irrelevant on its side); (2) TestHost.Run runs the package’s own initialization the way Go runs it before main — RuntimeHelpers.RunClassConstructor over the declaring types of the registry’s delegates, which ARE the converted package’s classes — but only when the parse actually met an unrecognized name, so every other run keeps initialization exactly where it was; (3) the verdict is then taken against the converted flag.CommandLine (TestFlagBridge.IsDefined), after the package’s flags and the host’s bridged flags are both declared, and an undefined name is still flag provided but not defined: -x with flag’s own ExitOnError code. The rejection moved; it did not go away — and it did not move at all for the 124 of 141 test projects whose package does not import flag, where IsDefined answers false because no flag can exist rather than as a fallback. Ordering the package’s initialization BEFORE TestFlagBridge.Register also makes that registrar’s redefinition guard real for the first time (it skips a name the package already defined; previously the package had not yet run, so the guard could never fire). ⚠ Creating a delegate over a static method does NOT run its declaring type’s static constructor — ldftn+newobj is neither a static-field access nor an invocation — so the generated host’s registry.Add("TestX", pkg_test_package.TestX, …) lines leave the package uninitialized until the first test BODY runs; that measured fact is what makes step (2) load-bearing rather than defensive. Still deliberately NOT mirrored: M.Run’s if !flag.Parsed() { flag.Parse() }, for the reason the second layer already gives. (Guarded by TestingRuntimeTests.APackageRegisteredFlagParticipatesAndAnUndefinedOneIsStillRejected, which stands a class whose static constructor declares a flag in for the package under test — written with an explicit static ctor so the CLR’s precise non-beforefieldinit rules apply — and pins all four claims: the package’s flag participates, a flag before it is still the host’s, a flag after it belongs to the program, and an undefined name is still exit 2. It is the one test needing the converted flag package present, so BehavioralTests.csproj references it; testing.csproj still must not, and does not.) The payoff is measured at value level: the converted host answers -bogo-mode -is-handshaker-supported with No byte-identically to Go’s test binary, and a filtered BoGo case (-bogo-filter Client-Verify-ECDSA-TLS1) passes — the converted TLS stack completing a handshake against BoringSSL’s own runner. See the board for why crypto/tls still does not bank.
Fallback is based on mutation, not merely on a production-qualified record. Pointer/value adapters and the shared T → ж<T> boxing route are relocatable. A structural conversion involving a production type, or a numeric conversion whose two operands are both production types, would require an operator on a closed referenced type; recordsRequireProductionMutation returns errProductionAnchoredRecords, and the already-loaded variants are re-emitted once under recompile. The older black-box recordsRequireProductionAnchor gate remains conservative for the ordinary reference model.
The model is abstract, not an io patch. io is the first mixed-suite proof: its project now builds with one io_package.Writer identity, export_test.go lives in io_internal_test_package, external references bind the bridge by Go object identity, and test-owned adapters coexist with imported io adapters. Focused guards cover model selection, conditional internal-test detection, host targeting, mutation fallback, emitted-pair adapter ownership, alias-shadow stability, and collision-renamed embedded-value promotion.
What the bridge owes the compiler once production is a REFERENCE. A 62-package regeneration sweep found five defects that share one cause: the bridge is the same GO package as production, so every same-package test in the converter reads a production declaration as LOCAL — while its C# now lives in a closed referenced assembly. Each was fixed at the layer that made the wrong assumption.
-
A bridge member HIDES the production
using static. The bridge binds production throughusing static <pkg>_package, and C# member lookup stops at the first enclosing type carrying the name — so any member the bridge declares hides every same-named production member, overload resolution included. container/heap’sfunc (h *myHeap) Pop() anyhidheap_package.Pop(Interface), and the suite’s ownPop(h)/Push(h, i)bound the extension by value (CS1620 ×8). Such a reference is emitted production-class-qualified (go.container.heap_package.Pop(…)), the remedypackageBuiltinShadowsalready applies to a shadowedusing static go.builtin. The shadow set is the internal variant’s package-level declarations plus its methods — those emit as static extension members of the same class — keyed on raw Go names, and consulted only for a production-declared package-level object. -
A production type is FOREIGN for a VALUE implement. “Local” is what selects go2cs-gen’s partial-struct realization, which folds the interface into the type’s own declaration; a closed referenced type instead gets a per-interface
ᴠVALUE ADAPTER. Both consequences matter and only together: the cast site must CONSTRUCT the adapter, and the record must be EXEMPT from the interface-inheritance prune (sound only for the one-type-one-interface-list partial shape). encoding/binary’sTestByteOrdercastsBigEndianto a function-localbyteOrderthat EMBEDS the productionByteOrder, so the subsumedbigEndian → ByteOrderpair was pruned as covered — true while a merged partial carried it, false against a referenced assembly, and everyRead(r, BigEndian, data)was CS1503. The arm mirrors the existing both-foreign one,importedValueImplementscheck included, so a pair production already records stays a bare implicit conversion. -
A value adapter must name the anchor its record lands in. A mixed white-box suite has two metadata anchors, and the value-adapter reference qualified through the external test class unconditionally — right for a production↔production pair, wrong for one whose BRIDGE-declared interface anchors it at the bridge (CS0426). The reference now applies
splitWhiteboxVariantRecords’ own bridge-declared-name predicate to the record’s two participants, folding in the live LIFTED claims while the bridge is the variant under conversion. (The pointer form already reached the same answer through its deferred marker’semittedAdapterPairAnchors; a value adapter’s name is composed inline and has no marker to resolve.) -
A bridge-declared LIFTED name must reach the record split. That split keys on the record’s EMITTED name against a set built from go/types
TypeNamedefs, which carry the GO-SOURCE name — so encoding/hex’stype r struct{ io.Reader }insideTestEncoderDecoderwas collected asrwhile its record namedTestEncoderDecoder_r. The record anchored in the external class and the generator declared a PHANTOM empty type there; every symptom followed from that one type being empty (CS0103 on the promoted embed it does not declare, CS0034 because a phantom carries no[GoType]and so no TypeGenerator(T,T)==to bind exactly, CS1503 at the cast site). The internal variant’s live lift claims are unioned in right after it converts, while they still stand. -
A BARE record name resolves in the variant that RECORDED it, never across variants. The bridge’s declared-name set is a set of SIMPLE names, and the two
-testsvariants are separate Go packages, free to declare the same one: encoding/gob declaresPointincodec_test.go(package gob) and again inexample_interface_test.go(package gob_test— the one whoseHypotenuseimplementsPythagoras). Each variant’s records are split as that variant converts, and every cross-variant reference is routed bygo/types.Objectidentity to a CLASS-QUALIFIED spelling —whiteboxBridgeNamedTyperenders an internal-test type the external suite names asglobal::<ns>.<pkg>_internal_test_package.T, andwhiteboxProductionObjectdoes the mirror while the bridge converts — so a bare name recorded by the external suite is external-declared by construction, whatever the bridge spells the same way. Matching it against the bridge’s set regardless anchored the EXTERNAL pairPoint → Pythagorasinpackage_info_internal_test.cs, wherePythagorasis not in scope:CS0246, no test host built, and all 106 of gob’s verdicts read empty — a missing host masquerading as mass runtime failure. The set is therefore consulted only while splitting the BRIDGE variant’s own records (splitWhiteboxVariantRecords’bridgeVariant), and the emission mirror that names an adapter through the anchor its record will land in carries the identical gate (whiteboxBridgeDeclaredType), so the two cannot disagree about a pair. Write-time qualification is not a substitute:qualifyAmbiguousTestTypeRefsroots an ambiguous bare name at the file it is ALREADY being written into, so a mis-anchored record comes out merely qualified to the wrong variant’s class (gob_internal_test_package.Point). Guarded byTestSplitWhiteboxVariantRecordsResolvesBareNamesInTheRecordingVariant, over a fixture module that declaresPointin both variants and asserts the collision itself through the realgo/typesscan (collectWhiteboxBridgeTypeNames) before exercising either split. -
The friend bridge’s box receivers survive into the pointer adapter. sync’s
export_test.godeclaresPushHead/PopTailon the production*poolDequeue/*poolChain; the converter emits them as direct-ж primaries, sopoolDequeueжPoolDequeuemust forwardm_box.PushHead(val). Two generator defects stacked: the bridge scan matched the receiver’s parameter TEXT against one composed spelling (ж<poolDequeue>) when the bridge qualifies it however its own file needs —this ж<global::go.sync_package.poolChain>once ago/*package in the closure shadows the root namespace — and the foreign-struct arm then re-derived every member’s receiver from the referenced assembly’s METADATA, where a bridge-contributed method does not exist, clobbering the binding back tom_box.Value(CS1929 ×5). The scan is now on the ж argument’s last dotted segment (well-defined because it runs only where the struct has no local declaration), and a bridge-bound member is left alone.
A sixth, in golib, was masked behind those compile failures: reflect reads a package class’s [GoPackage] stamp, not its NAME. GoReflect reconstructed a type’s Go package by trimming _package off the declaring class name — a heuristic the bridge breaks by design, since binary_internal_test_package hosts declarations Go-declared in package binary. Both readers (GoTypeName’s qualifier and PkgPath) now prefer the stamp, with the name-trim kept only for a hand-written class carrying none; the two agree for every ordinary converted package. encoding/binary catches it through Go’s own asserts twice: TestNoFixedSize compares the error text … not fixed-sized in type *binary.Person, and TestSizeAllocs NAMES its subtests from reflect.TypeOf(v), so a whole subtest set appeared under invented names with no Go counterpart.
Reference closure (the declaration-edge rule). The test project’s references are the direct-import set, plus the alias scan (B2c), plus — because binding any referenced type in C# requires the assemblies that type’s own declaration names — the declaration closure of that set (declarationClosureImports, both project models). Two declaration edges carry it: an interface’s base interfaces, and a struct’s field types. Interface bases: Go interfaces satisfy structurally and compose by embedding; C# interfaces are nominal, so the converter carries both shapes as C# inheritance at each interface declaration (getStructuralInterfaceBases): hash.Hash embeds io.Writer, io/fs’s fs.File lists Read/Close explicitly and does not embed io, and both emit a converted declaration that NAMES an io base. Such a base edge belongs to the declaring package’s import graph, so it appears in no test import and no alias using; DisableTransitiveProjectReferences (B2b) then hides the declaring assembly’s own io reference, and every site that names the interface fails CS0012:
- the emitted conversion record — hash/maphash’s
[assembly: GoImplement<Hash, hash_package.Hash64>(Pointer = true)]and crypto/hmac’sGoImplement<hmac, hash_package.Hash>, whose closures reachhashbut neverio('io_package.Writer' is defined in an assembly that is not referenced); - the go2cs-gen adapter realizing that record, whose class declaration lists the interface; and
- every converted production/test source that names it (
(fs.File, error) Open(...), hmac’sjustHash).
Struct fields at a composite literal: the converter renders a Go composite literal as new T(Field: …) — a call to the fieldwise constructor go2cs-gen generates for a [GoType] struct, whose parameter list spells out every field’s type — so binding that call needs every field type’s assembly. testing/quick’s Config holds a Rand *rand.Rand, so image/draw’s quick.CheckEqual(orig, sqDiff, &quick.Config{MaxCountScale: 10}) fails CS0012: The type 'rand_package.Rand' is defined in an assembly that is not referenced at the new quick.Config(…) expression, with math/rand in no import list on either side. No interface closure can ever reach it — Rand is a struct, so the shape is invisible to a base-interface walk; it is the same missing-declaration-edge defect one type-kind over.
The alias scan cannot reach either class: the named type does bind by name — its own package is referenced — and what is missing is a package named inside that type’s own C# declaration. Three gates keep the closure minimal, because over-inclusion is its own defect — every extra reference is churn across the banked corpus plus a chance at a duplicate-type conflict:
-
Seed only from the files the test assembly COMPILES (
referencedTypeSeedswalks the retained syntax rather than iterating theTypesInfomaps, which also makes seed order deterministic). A Phase-4D compile-excluded Example/Benchmark-only file is analyzed — so its declarations still reach the manifest — but no C# is emitted for it, so it names nothing the compilation must bind. Seeding from one handedcompress/gzipfive references (context,crypto/tls,mime/multipart,net/http,net/url) reached throughhttp.Request’s fields, from anexample_test.gothat is not compiled at all, and handedgo/tokenago/astreference the same way. -
Start the interface walk from named types, never from whole packages, and follow bases transitively (
b.B : a.A : io.Writerneeds bothaandio, because a base’s own declaration must bind in turn). C# needs a base’s assembly only when the derived interface is BOUND, and walking every exported interface of every referenced package would handioto nearly the whole corpus throughfmt.State’s structural io.Writer base — a reference no project that merely callsfmt.Sprintfrequires. -
Fire the struct-field edge only where a composite literal CONSTRUCTS the struct — that, not mere value use, is what demands the field types, and one level suffices (the generated constructor’s parameters default unless supplied, and a nested literal is itself a seed). Measured against the corpus: eleven banked packages hold
sync.Once,sync.Mapandreflect.Valuevalues (strconv’s package-levelatofOnce, encoding/binary’sreflect.ValueOf) and compile clean today with no reference tosync/atomicorinternal/abi— so a “named by value” rule would add eleven references that nothing needs.os.File’s single*filefield likewise never drags internal/poll, syscall and the rest of os’s private graph in. An EMPTY literal carries the edge only for a struct declared in a ROOT package, and the boundary is ACCESSIBILITY rather than a package list:T{}rendersnew T(nil)— go2cs-gen’s dedicated nil constructor, which names no field — but the FIELDWISE overload stays a resolution candidate wherever it is visible, and binding a candidate means binding its parameter types. That constructor isinternalfor any struct with an unexported field, so outside its assembly and friends it is not a candidate at all (which is exactly why mime’sonce = sync.Once{}and testing/quick’sreturn reflect.Value{}, falseneed nothing), while a root package’s struct IS visible that way — recompiled into the test assembly, or reached through the white-boxInternalsVisibleTogrant. math/rand/v2’s*p = ChaCha8{}failedCS0012 … 'chacha8rand_package.State' … assembly that is not referencedat thenew ChaCha8(nil)expression, with internal/chacha8rand named in no import list on either side.
A root’s own package is never an addition, and the external variant makes that load-bearing rather than theoretical: go/packages names it <pkg>_test, which resolves to no importable package at all, so a bytes_test struct literal whose field type is declared beside it fails the whole conversion with F14b’s loud resolve test project dependency "bytes_test": package bytes_test is not in std. Every root’s PkgPath therefore seeds the already-referenced set (its types compile into the test assembly, or bind through the production project reference the template already carries).
testing is skipped as a walk source (closureWalkable): it binds to the hand-owned core/testing shim per F15b, whose C# declarations are authored by hand and share only names with Go’s — Go’s testing.T embeds a common holding io.Writer, time.Time, sync.RWMutex and a dozen more, none of which the shim’s two-field T declares, so inferring C# edges from the Go declaration there is simply invalid. Every -tests compilation names testing.T, making this the widest over-inclusion the struct rule could possibly cause; nothing is lost, since the shim’s reference is fixed in the project template.
Each interface step runs the same types.Implements candidate match the converter uses at the declaration site (identical exported / non-alias / non-generic / method-set / strictly-fewer-methods gates) — deliberately taken before that function’s covered-by-embed skip and minimal-covering-set prune, so the result is a superset of the emitted base list and no emitted base’s assembly can be missing. Only the declaring package’s own imports are scanned, so a same-package base needs no separate visit (an interface implements its base’s bases too, so those candidates are found directly), and the output is a sorted set, so the map-ordered walk stays deterministic. It is a pure project-reference concern (the manifest dependency list stays import-derived), empty for a package whose named types carry no foreign declaration edge (unicode/utf8/path/cmp/itoa → unchanged), and {io} for io/fs, hash/maphash and crypto/hmac. Measured minimality: regenerating every banked package’s .tests.csproj and diffing is the instrument, and it is what rejected each looser rule in turn — an un-file-scoped seed drifted compress/gzip (context, crypto/tls, mime/multipart, net/http, net/url) and go/token (go/ast); a “named by value” struct edge drifted eleven more (encoding/binary, errors, hash/crc64, internal/fmtsort, math/rand, math/rand/v2, mime, os/signal, strconv, strings, testing/quick); an “any composite literal” edge — the empty form unscoped — still drifted three (encoding/binary, mime, testing/quick). Every one of those gates remains a zero-drift rule. The root-scoped empty-literal edge is measured the same way, and it is the one edge that is deliberately not zero: at the 63-package roster (2026-07-31), converting every package twice on one binary with that edge neutered and restored, it changes exactly one project — math/rand/v2, by exactly one line, <ProjectReference Include="$(go2csPath)core/internal/chacha8rand/internal.chacha8rand.csproj" />. That is the root set itself and nothing else: the three foreign-struct negatives (mime, testing/quick, encoding/binary) that rejected the unscoped form stay byte-identical, as does every other banked package. (Run that probe with the converter’s exit status checked — 63/63 conversions must succeed on both sides: a conversion that fails writes no csproj, so an ignored failure reads exactly like “no drift”, and that false-clean is how the <pkg>_test self-reference below first hid.) Guarded by the TestSelectTestProjectModel, TestRecordsRequireProductionAnchorGatesReferenceModel, TestWriteTestProjectReferenceModelBindsProductionProject, TestReferenceModelSeedAnchorsTestClassOnly, and TestDeclarationClosureImportsSurfacesForeignDeclarationEdges (foreign-package interface base, own-package structural base, transitive b → a → io, the fmt.State narrowing with its positive control, the empty-closure case, the *rand.Rand struct field at a composite literal with its by-value-only negative, a func-typed field’s signature, the one-level field boundary, the compile-excluded-file negative with its own positive control, the external-variant self-reference negative, and the testing-is-never-walked negative) converter unit tests; unicode (28 tests, incl. TestSpecialCaseNoMapping and the testing.Benchmark-driven TestCalibrate) is the first package it validated under the reference model, and crypto/hmac (172 tests) the first the generalized foreign-package rule unblocked. (io/fs compiles clean under this closure but does not yet validate: its TestCVE202230630 globs a 10 001-separator pattern whose faithful globWithLimit recursion overflows .NET’s fixed thread stack before Go’s depth > 10000 guard fires — a Go-growable-stack vs .NET-fixed-stack divergence in the test host, orthogonal to the reference closure.)
The third closure edge — a MEMBER ACCESS (2026-08-03, r38-gob). The two edges above are edges of a named
TYPE’s declaration; the third is the edge of an access: resolving x.M requires BINDING x’s type, and
when x is declared in another package that type is spelled nowhere in this compilation — not in an import,
not in an alias using. unique is the witness: handle.go declares var cleanupMu sync.Mutex, the
white-box suite calls cleanupMu.Lock(), and the test project referenced no sync — handle_test.cs failed
CS0012 … 'sync_package.Mutex' twice, no host linked, and the package’s suite had never been measured. Adding
the production package’s whole import list instead is the looser rule the reference model exists to avoid (it
would have added internal/stringslite and unsafe here, and far more elsewhere). The seed is every
*ast.SelectorExpr’s BASE type, namedTypesIn-expanded, then reach()ed and enqueue()d like a field edge;
a package-QUALIFIED selector (sync.Mutex, lib.F) is not this shape at all — its base is a PkgName, which
has no type — and the import that spells it already carries the reference.
Measured minimality — two restrictions, each of which the roster REJECTED a looser form of. The probe is
the one this section prescribes (regenerate every banked package’s .tests.csproj and diff, with the
converter’s exit status checked on both sides: 73/73 conversions must succeed, since a failed conversion
writes no csproj and reads exactly like “no drift”). (1) The receiver, not every named declaration. “The
type of every var/const/func the compilation NAMES” is equally true of C#’s binding rules in the abstract, and
drifts 23 of 73 — bufio into compress/bzip2 and image/gif, internal/abi + internal/reflectlite into
errors, three references into hash/crc32 — all of which compile clean today with none of it. Naming a
declaration does not force its signature to be materialized; accessing a member of it forces the receiver’s.
(2) _test.go files only, scoped per FILE. Under the reference model the production sources are not in this
compilation — they are in the referenced assembly, which carries its own references — and seeding from them
too still drifts 13 (crc32’s castagnoliOnce.Do, math’s cpu.X86, …). Per-file rather than per-package
because go/packages loads the INTERNAL test variant with the production files alongside its own, so a
package-level gate lets every production receiver back in. With both restrictions the roster is zero-drift:
unique’s single <ProjectReference Include="$(go2csPath)core/sync/sync.csproj" /> is the only line that
changes across all 73. Under the recompile model the edge is a no-op by construction — that model adds
production.Imports wholesale, so a production receiver can never be an addition. Guarded by
TestDeclarationClosureImportsSurfacesMemberAccessEdges (the unique shape through real test variants, plus
the named-but-not-accessed, production-source-only and package-qualified negatives). One more rule fell out
of the family’s own testing-never-walked negative: testing must never be an ADDITION either, for the same
reason it is never a walk SOURCE — its reference is fixed in the project template, which is why the caller
strips "testing" from the import-derived set rather than passing it through as already-referenced, so
reach() now honours closureWalkable too.
The fourth and fifth closure edges — a ZERO-VALUE DECLARATION and a RECORDED INTERFACE BASE (2026-08-07, r43f).
log and go/scanner sat on the build-blocked list with one CS0012 family each, and each named a demand the
first three edges structurally cannot see.
log — the zero-value declaration. TestNonNewLogger writes var l Logger. There is no composite literal
anywhere in it, so the struct-field edge’s *ast.CompositeLit seed never fires — but Go’s zero value of a
struct is not default(T) in the emission. The converter renders the declaration as a constructor call:
ref var l = ref heap(new Logger(), out var Ꮡl) for the address-taken shape (escapeAnalysisOperations),
new Logger() otherwise (visitValueSpec). C# overload resolution must materialize every accessible
constructor’s signature before it can choose one, and under the white-box InternalsVisibleTo grant the
package-under-test’s internal fieldwise overload is accessible — so log_test.cs failed
CS0012 … 'atomic_package.Pointer<>' … assembly 'sync.atomic' at new Logger(), Logger’s three atomic.*
fields being spelled in no import list on either side. This is the empty-literal edge’s exact demand by
another route, so it feeds the same seed (constructedEmpty) under the same ROOT/accessibility gate, scoped
to _test.go files for the member-access edge’s reason. math/rand/v2’s *p = ChaCha8{} and log’s
var l Logger are now one rule.
go/scanner — the recorded interface base. A converted concrete type names no interface in its own
emitted declaration — [GoType("[]ж<ΔError>")] partial struct ErrorList; names none at all. Its bases arrive
as the VALUE-form [assembly: GoImplement<T, I>] records its package emits, which the go2cs-gen
ImplementGenerator realizes as partial struct ErrorList : global::go.sort_package.Interface inside the
declaring assembly. The metadata type therefore declares that base, and binding any member on it makes
the compiler resolve the base list: list.Sort(), len(list), Ꮡlist.RemoveMultiples() and the generated
ErrorList→error value adapter’s own m_value.Equals(…) all failed
CS0012 … 'sort_package.Interface', thirteen times across the suite, with sort in no test import and no
alias using. The edge hangs off the member-access seed the third edge already computes — it is what
binding that receiver additionally costs — and interfaces are excluded from it because the base walk covers
them already.
Measured minimality — and here the instrument rejected the rule C#’s binding rules appear to justify.
“The interfaces the receiver’s type implements, taken from the declaring package’s imports” is the natural
go/types statement of the edge, mirroring interfaceBaseCandidates one type-kind over. It drifts 16 of
the 96 banked projects. The reason is that a GoImplement record exists only where the converter converted
a cast, so Go satisfaction wildly over-approximates the emitted base list: os.File satisfies
syscall.Conn and handed syscall to thirteen projects (compress/{flate,gzip,lzw,zlib}, image and four
image codecs, io, math/rand/v2, regexp, strconv) — while os records File only against io/fs.File and
io.Writer, and both POINTER-form, which generates an adapter class (FileжWriter) rather than a base
on the type and so demands nothing of a member binding; bytes.Buffer satisfies most of io and handed io
to sort and unicode/utf8 though bytes emits no records at all; internal/buildcfg’s Stringer
handed it fmt from an equally empty set. All sixteen compile clean today with none of it. So the records
are the gate and satisfaction merely supplies the candidate universe: packageImplementBases parses the
package’s own freshly-written package_info.cs (value form only — parseExportedValueImplementLines already
drops the pointer form), keys the interfaces by the implementing type, and the walk fires only where the
receiver’s type and the candidate’s package both match a record. Keyed per type, because os’s one
genuine syscall record is for rawConn, not File. With that gate the roster is zero-drift: converting
all 96 banked packages on both binaries changes not one line of one .tests.csproj. The lookup is scoped to
the package under test, whose package_info.cs this run has just written; a foreign type’s base list is its
own package’s record set in its own package_info.cs, and no measured case has ever demanded one — widening
is the same lookup pointed at that file.
go/types — the widening, measured. That last sentence held until go/types’ own suite, whose host build
produced exactly one error: check_test.cs(78,66): CS0012 … 'sort_package.Interface', at len(list) in
if list, _ := err.(scanner.ErrorList); len(list) > 0. The type is go/scanner’s ErrorList — the very
type the same-package edge was built for, one package over. go/scanner is referenced (the suite imports
it) so ErrorList binds; what does not resolve is the base list go/scanner’s own record set realizes on
it, and sort sits in go/types’ PRODUCTION project references (the package imports it) and in no test
import. So the records are now read per declaring package: a root type’s from the package_info.cs this
run just wrote, a foreign type’s from that package’s, resolved through the same getImportPackageInfo route
the <ImportedTypeAliases> block already reads a dependency’s metadata by (layout L3’s per-GOOS placement
included) and memoized per import path. An unreadable or absent file yields no edge, exactly as it does for
the package under test. The gate itself is untouched — same value-form records, same per-type key, same
candidate match — which is why the widening does not reopen the 16-of-96 question: it points one lookup at a
different file.
The other half: the member bindings that spell no selector. The same measurement moved the edge’s SEED as
well. check_test.go never writes list.Sort() — it binds ErrorList only through len(list) and
range list — so the member-access seed (x.M, the third edge’s) never saw it. Each of those lowers to a
member on the value’s type all the same: golib’s generic len, the emitted enumeration, an indexer. The seed
therefore also carries the types a compiled _test.go binds a member on through a builtin call, a range, or
an index/slice (memberBound), and that set feeds the implemented-interface edge only — never
reach/enqueue, because the value is spelled by a package the suite already imports. The boundary the
member-access edge measured stays exactly where it was, and is pinned by the same negatives: a test that
merely NAMES a value of such a type, or PASSES IT ALONG to a function with an exact parameter type
(var r Rows; Order(r)), binds nothing on it and surfaces nothing. Seeding this edge from every named type
instead would cross that line — the existing negative fails immediately — which is why the shape is the
member binding rather than the mention. go/types’ reference set gains exactly one line, core/sort, and the
host builds clean. (Guarded by TestDeclarationClosureImportsSurfacesForeignImplementedBases — the foreign
positive plus four negatives (no resolver, empty record set, a record keyed to another type, and the
name-and-pass boundary) and the range/index forms — and by
TestForeignImplementBasesResolverReadsDeclaringPackageInfo, which pins the value-form-only parse, the
memoization, and the silent-nothing on an absent file. Both fail against the pre-fix converter, and each half
of the fix fails it independently.)
Guarded by the TestDeclarationClosureImportsSurfacesZeroValueVarDeclarations (the log shape through real
test variants, plus the foreign-struct accessibility negative and the production-source scoping negative) and
TestDeclarationClosureImportsSurfacesImplementedInterfaceBases (the scanner shape, plus the three negatives
that pin the gate: no records ⇒ no edge even where types.Implements says yes, a record keyed to another
type, and the receiver restriction) converter unit tests. go/scanner validates 11/11 on the fix.
log builds and runs for the first time — seven of its nine test functions agree with go test — but does
not bank: two roots stand behind the closure one, runtime.Caller’s unimplemented getcallersp stub
(TestAll, the same row testing/slogtest carries) and TestDiscard’s exact allocation-count assert
(the established alloc-profile class). ⚠ The Caller root closed on 2026-08-07 and log still does
not bank — TestAll asserts the GO source file’s own extension and line numbers, which the converted
program does not carry; see runtime.Caller works by severing the FUNNEL below.
Under the RECOMPILE model the test half CONTINUES the production emission (the productionSeed)
The recompile model is the only one where the converted _test.go files land in the same C# class
as production sources this run does not rewrite. Everything the converter numbers or claims per package
is therefore a shared, immutable name supply, and every counter that restarts for the test emission pass
re-mints a name already on disk. Two such supplies were already pinned — lifted type names
(productionLiftedTypeNames) and hoisted big-constant ordinals (productionHoistedConstOrdinals) — and
the same rule turned out to be owed by three more. All five now travel in one productionSeed struct,
captured in convertTestVariants from the production run’s live state (it ran moments earlier in the
same process) before the first variant’s resetPackageState, and installed by convertTestVariant
for the INTERNAL variant under the recompile model alone:
| Supply | Emitted name | The collision |
|---|---|---|
| lifted type names | Δtypeᴛ1 |
two lifts of differently-shaped anonymous structs (encoding/gob) |
| hoisted big-constant ordinals | maskᶜ1 |
two const mask = <big> hoists |
| import force hooks | initᴛᴛimportꓸcryptoꓸsha256 |
production and test files repeating one import |
| the blank-identifier counter | _ᴛ1ʗ |
a blank package-level _ in each half |
func init() ordinals |
init / initΔ1
|
production and test files each declaring func init()
|
The last three are crypto/x509’s, and they are ordinary Go, not exotica. x509.go and x509_test.go
both import _ "crypto/sha256" and _ "crypto/sha512" — a test repeating a production blank import is
what a test that exercises those registrations does — and each half emitted the same
[GoInit] internal static void initᴛᴛimportꓸcryptoꓸsha256() into x509_package: CS0111. The
x509_package class likewise already held _ᴛ1ʗ for pem_decrypt.go’s blank const heading an iota
block when oid_test.go’s var _ encoding.BinaryMarshaler = OID{} re-minted it (CS0102), and
root_windows.go’s func init() when x509_test.go’s own init claimed the bare name again (CS0111).
The import hook is the one whose OWNERSHIP is worth stating rather than merely its uniqueness:
exactly one hook per (assembly, imported package) — Go initializes an imported package once per program
and a .NET module constructor runs once per assembly — and the production half owns it whenever its
file is in the compilation, because that file is the one a -tests run cannot rewrite. The seed is
skipped for the EXTERNAL variant and for both reference models for one reason, stated once: there the
names land in a different class (<pkg>_test_package, the friend bridge) or a different assembly
(production, referenced), so they may be reused freely and seeding would only churn banked emissions.
Guarded by TestTestVariantPinsProductionBlankImportForces,
TestTestVariantContinuesProductionBlankIdentifierCounter and
TestTestVariantContinuesProductionInitOrdinals, each pinning both directions — unseeded the test half
legitimately takes the first name, seeded it must step past the production one.
A recompile-model test project compiles the production sources — so it owes their references and their per-GOOS half
Two more crypto/x509 roots, both of the same shape: the recompile model makes the production .cs
compile items of the test project (writeTestProject), and two places that enumerate or probe those
files did not describe what actually compiles.
The B2c alias scan read only the test-emitted files. The tests csproj sets
DisableTransitiveProjectReferences, so every assembly the compilation names must be a DIRECT reference,
and the alias scan is what finds the ones no import list mentions (see the reference-closure rule above).
Under the recompile model a production file’s using aliases are references the TEST project owns — and
they were never scanned. The omission hides in the ordinary case, because a production file’s aliases are
usually its own package’s direct imports, which the import-derived set already carries; it bites where the
alias names a package reached only transitively. x509.cs and pem_decrypt.cs emit
using hash = hash_package; because crypto.Hash.New() RETURNS hash.Hash — hash is in no import list
of crypto/x509 and in no reference of its own production csproj, which compiles anyway precisely because
it does not disable transitive references. The test build failed CS0246 ×2 inside the production
files. testProjectAliasScanFiles now names the scan set as “what the test project compiles”, with the
production half included under the recompile model and excluded under the reference models (there those
sources compile in their own project and their aliases are that project’s concern). Guarded by
TestAliasScanCoversRecompiledProductionSources, which pins the model gate as well as the find.
The enumeration and the static-ctor probe were both flat-only, and layout L3 is not flat. An L3
package keeps its platform-varying sources in <goos>/ and its production csproj compiles one folder via
$(GoTargetOS)/*.cs; a test project lists compile items explicitly, so the same selection has to be made
when enumerating them (productionCSFiles) and when asking whether a production package_init.cs exists
(platformLayoutPath, the probe that decides whether the test side implements the erasable
initᴛᴛtests() hook or declares a static constructor of its own). crypto/x509 is the corpus’s only L3
package on the recompile model — every other L3 suite takes a reference model, where the production
ASSEMBLY carries its per-GOOS half — so neither gap had ever been exercised. Together they cost 187
errors reported against the TEST files rather than the missing folder (Verify, VerifyOptions’ fields,
loadSystemRoots, domainToReverseLabels, every error type’s Error()), plus a second
static x509_package() beside the real one. Guarded by
TestProductionCSFilesTakeTheTargetPlatformFolder (target folder taken, non-target folder not, per-GOOS
package_init.cs included, flat package unchanged) and TestProductionInitProbeFollowsPlatformLayout.
An Example/Benchmark-ONLY test file is dropped from the compile set (Phase-4D file exclusion)
Example and Benchmark declarations are uniformly Phase-4D-deferred — discoverTestDeclarations records them in the manifest with status unsupported (“… execution is deferred to Phase 4D”) and the differential oracle filters them from both sides (eligibleTerminalTestResults admits only included test-kind declarations). The option-a ruling (2026-07-24) extends that deferral from the declaration to the file: a _test.go file is dropped from the -tests conversion/compile set (selectCompileExcludedTestFiles) iff both
-
every RUNNABLE declaration it contributes is a Phase-4D-deferred
func Example*/func Benchmark*— imports do not count as declarations, and (since 2026-08-15) neither do puretypedeclarations and methods; any top-levelvar/const, or any other plain func (aTest/TestMain/Fuzzfunc, aninit, or a mis-signatured Example/Benchmark), disqualifies the whole file (conservative by design;TestMain/Fuzzare deliberately out of scope). The Example/Benchmark classification is the exactisPhase4DExcludedTestFuncpredicatediscoverTestDeclarationsuses (no receiver, no results, no type params, and either a zero-parameterExample*or a single-*testing.B-parameterBenchmark*), so a file qualifies only when it truly contributes nothing to the run registry; and -
no RETAINED test file references any object the file declares, resolved by go/types object identity across the loaded variant set (never filename or text) — a promotion fixpoint over both variants, so an Example a retained test takes by value (
[]func(){ExampleWired}) keeps its file compiled, and a candidate promoted back to retained can in turn pull further candidates in.
The predicate is pure go/ast+go/types: go/token’s example_test.go declares only func Example_retrievePositionInfo() at top level (its type p = token.Pos / const bad / func ok live inside a raw-string literal fed to parser.ParseFile), so a text scan would wrongly disqualify it while the AST predicate correctly qualifies it. Excluding it is the demonstrated consumer: that external package token_test file, recompiled into go/token’s mixed whitebox+blackbox test assembly, names token.FileSet/ΔPos/Token — types the referenced go.parser/go.ast assemblies surface from the production go.token assembly while the recompile makes a second, local copy — CS0012. With the file excluded, go/token compiles.
Metadata representation — discovery stays intact; only emission + compile-membership are dropped. The excluded file’s Example/Benchmark declarations still appear in the manifest under their existing disclosed-unsupported status, and its source is listed in testSources with a distinct example-benchmark-only status. This is required for oracle exactness, not cosmetic: go test runs an Example that carries an // Output: comment (go/token’s does), so it appears in the raw go test -json stream, and the F6 census gate (manifestCensusGaps, computed over the unfiltered Go results) fails the comparison for any run declaration the manifest does not account for. Dropping the declaration would trip the census; keeping discovery — and dropping only the file’s .cs emission and its .tests.csproj compile item — keeps every already-filtered Example filtered and the differential oracle exact. The predicate lives in convertTestVariants (both project models honor it): discovery runs over every test file, emission over the non-excluded subset.
Blast radius (banked packages). Because the policy changes which files a banked package compiles, a GOROOT scan of every committed test suite (with go/token as the positive control) found 18 packages with a qualifying file — each re-validated with identical Test counts and rebanked to remove the excluded *_test.cs, its .tests.csproj compile item, the project references + using aliases the excluded file exclusively pulled in (verified by import analysis, e.g. math/cmplx’s fmt, encoding/hex’s os/io.fs/log), and — where the excluded external file was the sole contributor to the external anchor — the orphaned package_info_external_test.cs. One subtlety is guarded operationally: the metadata writer merges with the committed anchor, so a GoImplement/GoImplicitConv record contributed only by the excluded file (math/rand’s GoImplement<text.tabwriter.Writer, io.Writer>, from an Example that casts a *tabwriter.Writer) survives the merge as a stale record referencing a now-unreferenced assembly (CS0234); regenerating the anchor from a clean state (as a whole-corpus reconvert would) drops it. The 17 packages with no qualifying file stay byte-identical.
Condition (1) admits pure TYPE declarations and METHODS (2026-08-15, the crypto/tls lane). The original wording — every top-level declaration is an Example/Benchmark — is the shape go/token’s example_test.go happens to have, and crypto/tls’s is the same file in every way that matters: the package’s ONLY black-box file, every runnable thing in it an Example. It differs in one respect — its Examples need an io.Reader to hand Config.Rand, so it declares type zeroSource struct{} and one Read method — and that single helper kept the whole file compiled, producing precisely the failure this ruling exists to prevent: http.Transport’s TLSClientConfig field names tls_package.Config in the PRODUCTION assembly while the recompile makes a second local copy, so the field is unnameable — CS0012 ×3 at example_test.cs 88/99/198, three of crypto/tls’s four build errors. Adding the production reference cannot fix that (the two Configs stay distinct types and CS0012 merely becomes CS0029); the file must not be compiled. A type declaration and its methods are admissible because they have no RUN-TIME behavior of their own — nothing executes at package init — and any use by a retained file is a reference condition (2) already resolves. That last clause is load-bearing and is why the type and method objects are now recorded in declared: widening condition (1) without it would have silently disarmed condition (2) for exactly the declarations it just admitted. Everything else stays disqualifying, deliberately: a var/const initializer can carry side effects and a plain helper func can be init(), neither of which any reference edge would reveal.
Guarded by the TestSelectCompileExcludedTestFilesDropsExampleAndBenchmarkOnly (positive: external Example-only + internal Benchmark-only), TestSelectCompileExcludedTestFilesDropsExampleWithHelperType (the crypto/tls widened-arm positive), TestSelectCompileExcludedTestFilesKeepsHelperTypeUsedByRetainedTest (condition 2 over the widened arm — the disarm this change had to avoid), TestSelectCompileExcludedTestFilesKeepsExampleWithTopLevelVar (condition 1 negative), TestSelectCompileExcludedTestFilesKeepsReferencedExample (condition 2 fixpoint), and TestSelectCompileExcludedTestFilesKeepsTestMainAndFuzzOnly converter unit tests.
A package-qualifier using in a converted TEST SOURCE contributes a project reference
Test projects set DisableTransitiveProjectReferences=true, so an assembly the package reaches only transitively is invisible to the test compile (CS0234). aliasReferenceImports covers this by scanning using ALIASES for namespace tokens of packages in the transitive import closure and adding a direct project reference for each. It formerly scanned only the two metadata files, despite its contract covering a file-local package-qualifier using; it now scans the converted *_test.cs outputs as well.
The shape this misses otherwise has no textual import at all: math/rand’s default_test.go does not import os/exec, but testenv.Command(…) returns *exec.Cmd, so the emitted default_test.cs binds cmd.Value.Env and cmd.CombinedOutput() through the os/exec assembly while exec. never appears in any import list. Scanning the emitted source finds the qualifier using and emits $(go2csPath)core/os/exec/os.exec.csproj. The manifest’s dependency list stays import-derived — alias targets are purely a project-reference concern — and the scan is additive, so a package whose sources introduce no new qualifier is unchanged.
Shadowing the names go2cs itself spells (nil, golib names, emitter-spelled type names, C# keywords)
A census (2026-07-16) of every non-function predeclared Go identifier, the golib public top-level type surface, and the C# keyword list — checked against the three name-protection mechanisms (keywords @-escape, reserved Δ-rename, and the shadow analyses) with a minimal transpile-and-run repro per candidate — found five real gaps, each fixed and guarded by the ReservedNameShadows behavioral test:
-
A local named
nil(nil := 5, legal Go) was emitted as the nil-literal rendering —nint default! = 5;, a syntax error — because the ident conversion matched on the name. It now checks the resolved object (mirroring thetrue/falsehandling): only a use resolving to the universe*types.Nilrenders as the literal (default!, or golibnilin pointer contexts); a shadowing object falls through to normal rendering (nint nil = 5;—nilis not a C# keyword, and Go’s own scoping guarantees no nil-literal use while shadowed). -
User types named
builtinorsstringshadowed golib names the emitter references even when the Go source never spells them — the qualifiedbuiltin.len(…)calls (emitted when a package method shadows a built-in) bound the nested user struct (CS1501), and the string([]byte) elision’ssstringviews bound the user type (CS0030). Both names joined thereservedset: the user types decline toΔbuiltin/Δsstring. -
User types named
any,rune,nint, ornuintshadow spellings the emitter itself produces:interface{}rendersany(slice<any>bound the user struct, CS0029), an untyped rune-constant default spellsrune(c := 'x'emitsrune c = 'x';, CS0030), and Goint/uintmap to the C# native-int contextual keywords (partial struct nint { internal nint d; }is a CS0523 layout cycle, and@cannot fix a name-identity problem). These names must never enter the string-basedreservedset — legitimate emissions re-enter the same sanitizers, so corpus-wideslice<rune>(…)would corrupt toslice<Δrune>and re-fed delegate compositions corruptFunc<…, nint, nint>toΔnint. InsteadperformNameCollisionAnalysisregisters a package-level TYPE bearing one of these names (emitterSpelledTypeNames) in the package-scopednameCollisionsmap: every ident with that name in that package isΔ-renamed — exactly mirroring Go’s package-scoped shadowing — with zero effect on any other package (CNR byte-identical). -
requiredandscopedare C# 11 contextual keywords banned as type names (CS9029/CS9062, surfacing in both the converted declaration and the TypeGenerator’s output). Both joined thekeywords@-escape set likefile(partial struct @required— the@escape is valid in every position and the generator carries it through).record,partial, and the other contextual keywords compile clean as type names on C# 13/net9 (verified empirically) and stay unescaped. - The keyword set carried the typo
__argslist, which covered nothing: a Go local named__arglisthit the real (undocumented) Roslyn keyword and failed to parse (CS1002). Corrected — the local now emitsnint @__arglist = 5;.
Census rows verified fine with no action needed (each proven by a transpile-run-compare repro): locals named any predeclared identifier (nil, iota, error, any, comparable, rune, the numeric type names — Go-consistent shadowing carries over); user types named error or a predeclared type name in the common self-consistent cases; embedded predeclared fields (struct{ float64; rune; any; int; string } — the color-color form internal rune rune; compiles, with keyword-mapped embeds escaping only the field NAME: internal nint @int;); the golib Defer/Recover delegates (never spelled in emitted code — the defer machinery’s lambda parameters are inferred); and a local named heap alongside heap-boxing machinery (heap<nint>(out var Ꮡx) is a generic invocation, which a non-generic local simple name cannot shadow) — half right, corrected 2026-08-28: the generic form is genuinely immune for exactly the reason given, but the ARGUMENT-CARRYING form heap(new T(), out var Ꮡx) carries no type argument and does collide, which this row’s repro never reached (see A declaration named heap qualifies the boxing intrinsic below). Known residuals, documented rather than fixed (unreachable under default flags in any constructed repro, or pathological): a user TYPE named a numeric alias name (float64, int32, uintptr, complex64, …) in a package where an emission would be forced to spell that predeclared name through inference; and type string struct{} (the golib @string spelling is escape-identical to the keyword-escaped user name).
A parameter that shadows an imported package is renamed at its declaration too
A function parameter whose name equals an imported package the function references — crypto/rsa’s func emsaPSSEncode(…, hash hash.Hash), where hash shadows the hash package named in the signature type hash.Hash — is shadow-renamed by the variable analysis (hash → hashΔ1) so it does not bind the using hash = hash_package; alias. Every usage already rendered the renamed name (convIdent reads v.varNames), but the parameter declaration was emitted from the raw param.Name(), so the signature kept hash.Hash hash while its uses were hashΔ1 — CS0103 at every use (40 sites in crypto/rsa, 27 in testing/quick’s rand). The declaration now resolves through the same v.varNames map, so it matches the usages:
func emsaPSSEncode(mHash []byte, emBits int, salt []byte, hash hash.Hash) { … hash.Size() … }
internal static (…) emsaPSSEncode(slice<byte> mHash, nint emBits, slice<byte> salt, hash.Hash hashΔ1) { … hashΔ1.Size() … }
A non-shadowed parameter maps to its own raw name (no churn). (Guarded by the PackageShadowParam behavioral test.)
A shadow-renamed pointer parameter completes the same rule on two more paths. A *T parameter is deref-aliased as ref var <value> = ref Ꮡ<raw>.Value, so its box companion Ꮡ<raw> always keeps the raw Go name even when the value alias is shadow-renamed — func decrypt(rand io.Reader, …) where rand shadows the math/rand-style alias becomes ref var randΔ1 = ref Ꮡrand.Value. (A) An address-of or by-pointer pass of that parameter must therefore use the raw box name Ꮡrand, not Ꮡ+value-alias ᏑrandΔ1 (which is not in scope, CS0103) — boxBaseName returns the raw name for a pointer parameter specifically (unlike an escaping shadow-renamed local, whose box is the shadow form ᏑiΔ1). (B) When a function has both a pointer parameter and a shadow-renamed value parameter, its signature is rebuilt through a separate updatedSignature path (not the generateParametersSignature path fixed above), which had kept emitting the value param’s raw name — so EncryptOAEP(hash.Hash hash, …) diverged from its hashΔ1 uses again. That path now resolves value-param names through v.varNames too, matching the primary fix. Together these cleared 50 errors (crypto/rsa 23 + testing/quick 27). (Guarded by the PackageShadowPointerParam behavioral test.)
A declaration shadowing a BUILT-IN makes the call an ordinary call
Go permits shadowing a universe built-in at any scope, after which a call through that name is an
ordinary call to the declaration, not the built-in — math/big’s own tests declare
make := func(z *Int) *Int { … } as a function-local and then call make(test.z). The converter’s
built-in handling is keyed on the identifier’s name, so such a call was emitted with built-in
semantics. Every built-in arm is now gated on the identifier actually resolving to the universe
built-in (identIsUniverseBuiltin — go/types records a genuine built-in as a *types.Builtin
object; anything else is a shadowing declaration), and a shadowed call falls through to the ordinary
call path:
make := func(n int) int { return n * 2 }
fmt.Println(make(21))
var make = (nint n) => n * 2;
fmt.Println(make(21)); // was: fmt.Println(new nint()) — the argument dropped entirely
Seven built-ins had a name-keyed emission arm and so were affected: make (→ new nint()), new
(→ @new<nint>() — both drop the argument, CS1503/CS1929), panic (→ the statement throw
panic(x) in expression position, CS8115), print/println (a spurious variadic interface{}
cast), and len/cap when the argument is a pointer-to-named-array (a spurious .Value deref
from the auto-deref arm). close, min/max and recover already carried the *types.Builtin
check; append’s arm self-bails on a non-slice argument; the remaining built-ins (copy, delete,
clear, complex, real, imag) have no dedicated arm and already fell through. Two analysis
paths shared the hole and were closed the same way: isTerminatingStmt treated a shadowed
panic(…) as terminating (mis-deciding a switch case’s break), and the capture-mode scan treated
a shadowed recover(…) as forcing the function’s defer frame.
Note this is the opposite direction from packageBuiltinShadows (see Type-vs-Method Name
Collisions): there the call genuinely is the built-in and a same-named package method shadows the
C# using static go.builtin, so the call is emitted qualified as builtin.<name>(…). Here the
call is not a built-in at all. (Guarded by the BuiltinShadowLocal behavioral test.)
A declaration named heap qualifies the boxing intrinsic
Every collision above is between two things the Go source names. This one the converter invents:
heap is not a Go built-in, it is go2cs’s own boxing helper (golib’s
heap(value, out var Ꮡname) / heap<T>(out var Ꮡname), in scope in every converted file through
using static go.builtin), so a Go program may legally name anything heap with nothing in its source
hinting at a conflict.
The failure is the same CS0149 a shadowed built-in produces — a C# local or parameter wins simple-name
lookup outright over a using static member — but it lands at a line the Go source did not write: the
boxing prologue the converter emits for an address-taken local. It therefore reads as an emitter defect
at the box site rather than as a name collision. internal/trace’s
func heapDebugString(heap []*batchCursor) string, whose strings.Builder local needs a box, was the
whole of that package’s 92-verdict build wall:
ref var sb = ref heap(new strings.Builder(), out var Ꮡsb); // CS0149: Method name expected
The remedy is the opposite of the shadow-renames elsewhere in this section: heap is the name the
Go program chose and nothing about it is ambiguous in Go, so the identifier is preserved and the
INTRINSIC is qualified instead — builtin.heap(…) — and only where a heap declaration is actually in
scope, so the corpus stays byte-identical everywhere else. heapIntrinsicName supplies the spelling at
all fourteen emission sites; declaresHeapIntrinsicIdent answers per function declaration (walking
nested function literals, and OR-ed with a literal’s own declarations in convFuncLit), and
packageDeclaresHeapIntrinsicIdent covers the one package-level shape that can also collide.
What does and does not shadow is decided by C#’s invocable-member rule, and every boundary below was
measured rather than reasoned into place. A simple name used as the target of an invocation ignores
type members that are not invocable, so only a genuine method group — or a nearer local declaration
space — can displace the using static import:
-
A local or parameter named
heapDOES shadow. Both argument-carrying shapes collide: an address-taken struct local, and an address-taken value parameter itself namedheap(which addsCS0841: cannot use local variable 'heap' before it is declared). The invocable-member filter applies to type members, not to the local declaration space. -
A type-argument-carrying call is immune.
heap<nint>(out var Ꮡx)is a generic invocation, and a simple name followed by a type-argument list considers only generic methods — a local is never a candidate. This is why the 2026-07-16 census cleared the case, and why a guard built on a scalar local (which takes exactly this form) proves nothing. -
A package-level TYPE or VAR named
heapdoes NOT shadow, because neither emits an invocable member. The first version of this check tested “any non-PkgNameobject” and was falsified by its own A/B:GlobalCapturedInClosuredeclarestype heapat package level, and with the fix reverted it still compiled — the broad form was only over-qualifying, changing a golden no defect required. The check is narrowed to*types.Func, the one package-level shape that is a real method group. Nothing in the corpus declares that, so the positive case is reasoned from the lookup rule rather than reproduced; the negative side is guarded. -
An import ALIAS named
heapdoes NOT shadow.import "container/heap"renders asusing heap = go.container.heap_package;, and a using-alias does not displace ausing staticmethod group in an invocation — proven bycontainer/heap’s own bankedexample_pq_test.cs, which carries the alias and two heap-box emissions and compiles.*types.PkgNameis excluded for that reason.
(Guarded from both directions: BuiltinShadowLocal carries the two colliding shapes plus a
non-shadowing function that must keep the bare heap<arr>(…); GlobalCapturedInClosure carries the
package-level-type control that must keep the bare heap(new heap(), …).)
A local that shadows a PACKAGE name is not a package qualifier
Go lets a variable, parameter or receiver take the name of an imported package; from its declaration
onward the identifier denotes the variable, and the package is simply unreachable in that scope. The
standard library’s own test code does this freely — format_test.go has both
func checkTime(time Time, …) and time := Unix(0, 1233810057012345600) inside TestFormat.
Every emission out of convSelectorExpr used to be passed through getAliasedTypeName, the
QUALIFIED-NAME resolver. That function reads its argument as <package>.<member> and rewrites either
half — a collision-renamed foreign member (time.Second → time.ΔSecond), a Δ-shadowed import
qualifier (color.RGBA → Δcolor.RGBA), or a type alias (color.RGBA → colorꓸRGBA). Applied to a
rendered expression, it fired on any base that merely shared a name with an imported package, so
one function produced three different wrong answers depending only on which rewrite matched the member:
func checkTime(time Time, test *ParseTest, t *testing.T) {
if time.Year() != 2010 { … } // Year is not renamed
if time.Month() != February { … } // Month is a renamed TYPE
if time.Hour() != 21 { … } // Hour is a renamed CONST
Δtime.Year() // the import alias — the variable vanished
timeꓸMonth() // the type alias — a type used as a method
time.ΔHour() // the const rename applied to the METHOD name
The resolver is now gated on the selector’s base actually denoting a package, asked through
go/types (selectorBaseIsPackage, consulted by aliasResolvedSelector), so a shadowing binding is
excluded by construction rather than by name. A non-package base could never resolve through the alias
maps anyway — they are keyed <package>.<member> — so the gate states the property once instead of
per emission site. This cleared 33 errors across five codes (CS7036, CS1061, CS1955, CS8130, CS1501) in
time’s converted test suite alone. (Guarded by the PackageNameShadowing behavioral test, whose
describe(time time.Time) calls all three member kinds on the shadowing parameter.)
A collision-renamed member keeps the file’s RENAMED qualifier
A package that collision-renames an exported const or var publishes it with the const: marker, which
tells a consumer to keep the reference qualified through the package (time.ΔSecond) rather than
alias it to a type. That arm carried the qualifier through verbatim — the raw Go package name — while
the file’s actual using may be Δ-renamed because a same-named child namespace is visible (see
importAliasOperations.go). Both halves have to move together:
import (
"time"
_ "time/tzdata" // puts the `go.time` CHILD NAMESPACE in the assembly
)
… time.Nanosecond …
using Δtime = time_package; // `time` alone would bind the go.time namespace
…
time.ΔNanosecond // WRONG — CS0234, `go.time` has no ΔNanosecond
Δtime.ΔNanosecond // emitted now
The qualifier is run through importQualifier when — and only when — it is a single segment, so an
already _package- or global::-qualified spelling is untouched.
A DOT-imported collision-renamed member has no selector to carry the rename
import . "time" makes every exported member a bare identifier, which is what time’s external test
files use. A foreign type still resolves correctly in that form, because foreignAliasedTypeName
works from go/types rather than from the source spelling; a const or var had no equivalent, so
Second, Minute, Hour, Nanosecond, UTC and Local — every one Δ-renamed in time because a
Time method shares its name — emitted raw and bound nothing (CS0103 ×176 across five files).
convIdent now resolves such a reference through the same recorded GoTypeAlias entries the qualified
path uses (dotImportedRenamedMember), and emits the renamed member bare: a dot import renders as
using static <pkg>_package, which exposes it under exactly that name. Only const:-marked entries are
honored — a type entry resolves to a pkgꓸName global-using alias, which is the type layer’s business.
Multi-Result Values and Comma-Ok Forms
Many Go functions return either a single value or a “value, ok”/”value, error” tuple, where only the declared return arity selects the behavior. You cannot differentiate C# overloads by return type alone, so the runtime types expose a second overload distinguished by an extra discard argument. For map access, the “comma-ok” read routes through a two-value indexer using the discard sentinel ꟷ:
var v1 = m["Answer"]; // single value: zero value if the key is absent
var (v2, ok) = m["Answer", ꟷ]; // comma-ok: (value, present?)
These two forms can behave differently — case in point, type assertions: the single-value form panics on failure, while the comma-ok form returns safely with a boolean success result. Type assertions convert similarly, through a generated _<T>() accessor:
var t = i._<MyType>(); // panics on failure
var (t, ok) = i._<MyType>(ᐧ); // comma-ok, safe
The asserted type is a type position, so an assertion to a pointer type renders the pointer type ж<T>, not a value dereference: i.(*box) → i._<ж<box>>(). (The starred-operand-is-a-type case previously emitted the type form only inside a (*T)(p) cast; a non-parenthesized *type fell through to the value-deref path and emitted box.Value — CS0426, since Value is not a member type of box.) (Guarded by the TypeAssert behavioral test’s *box assertion; runtime hit this in netpoll.go’s arg.(*pollDesc).)
An assertion to a NAMED interface must record the concrete implementation, even from a non-empty interface source. At run time _<T>() resolves a NAMED target interface only through a compile-time GoImplement adapter (golib TryTypeAssert’s structural duck-typing path exists only for anonymous interfaces — a named interface with no generated ᴛAs method is treated as a miss, not converted). So h.(encoding.BinaryMarshaler) — where h is a hash.Hash32 whose dynamic type is *digest — needs [assembly: GoImplement<digest, encoding.BinaryMarshaler>(Pointer = true)] or it panics at run time (interface conversion: … not encoding.BinaryMarshaler), after compiling cleanly. The converter recorded such implementations only for an empty-interface source; a non-empty interface source (the common hash.Hash32/sort.Interface/… case) recorded nothing, because getUnderlyingType on an interface-typed expression yields the interface, not the concrete dynamic type. convTypeAssertExpr now, for a non-empty interface source asserting to a named target interface, enumerates the package’s concrete types that implement both the source and target interfaces — exactly the dynamic types the assertion can succeed on — and records a GoImplement for each (probing the value then the pointer form, so a pointer-receiver MarshalBinary records against *digest). The both-interface filter keeps the set tight (adler32 → just digest); an anonymous target interface is excluded (it resolves through its ᴛAs method and needs no adapter, so recording one would be dead machinery). Known limitation: only the current package’s scope is scanned, so an external p_test assertion whose concrete type lives in the package under test, or a dynamic type imported from a third package, is not yet covered. This unblocked the hash/* marshal round-trips (TestGoldenMarshal). (Guarded by the InterfaceToInterfaceAssertion behavioral test — a Stringish-typed value asserted to a named Marshaler it implements, plus a comma-ok miss on a type that does not, output-compared vs go run; the pre-fix converter panics at the assertion.)
A named interface’s DECLARATION site also records its same-package structural implementers. The assertion-site recorder above fires only where a package itself performs the assertion, so it generates an adapter only when the ASSERTING package can NAME the concrete dynamic type. When it cannot, the code compiles yet the assertion misses at run time: math/bits asserts err.(runtime.Error) on runtime’s overflowError/divideError panic values, whose dynamic type is the unexported errorString. runtime never itself casts errorString to runtime.Error — its plain error casts recorded only errorString → error, never errorString → runtime.Error, which errorString satisfies STRUCTURALLY through its value-receiver RuntimeError() method — and math/bits cannot name the unexported errorString, so no site ever recorded the pair and err.(runtime.Error) returned ok = false, NRE-ing on e.Error(). visitInterfaceType now, at each non-lifted non-constraint named-interface DECLARATION, records a GoImplement for every SAME-PACKAGE concrete type whose method set structurally satisfies the interface (recordLocalConcreteImplementers), sharing the value-then-pointer recordIfImplements probe with the assertion-site recorder. The producer runs in the concrete’s HOME assembly — exactly where the go2cs-gen adapter must be generated — so the fix needs zero generator/golib change; errorString records against ΔError in runtime’s own package_info.cs and math/bits resolves through it. The scan is deliberately EAGER: same-package, non-interface, non-generic concrete types, structural (no cast required), with NO gate on the assertion actually occurring and NO gate on the concrete being exported (errorString is unexported yet escapes via the exported panic value). It COMPLEMENTS the assertion-site recorder (commit fcfe4a948) — which still covers cross-package concrete dynamic types the declaration-site scan cannot see (an external p_test assertion, or an imported dynamic type).
Measurement (an isolated A/B full-stdlib reconvert) put the blast radius at just 102 net-new GoImplement records, 7.1 % over a 1442-record base — no VOLUME gate was needed. Most of the gross additions are a CLEANUP, not new surface: a concrete→interface pair a downstream consumer used to record redundantly at its own cast site (go/parser held 49 *ast.Ident/… → ast.Expr/Stmt/Decl records) now lives once in the producing package (go/ast, +96), and consumers drop the duplicate through the existing importedValueImplements dedup — the record migrates to the assembly where the adapter belongs.
One narrow SHAPE gate WAS forced, by the build rather than by volume: recordIfImplements skips a pair whenever the go2cs-gen adapter could not FORWARD one of the interface’s methods (adapterCannotForward). The generator names a forwarding target in exactly one step — this.M() for a method whose receiver is the type itself, or this.Field.M() for one reachable through a SINGLE embedded field (its receiver is that field’s own type). Two shapes fall outside that and compile to an adapter referencing a non-existent member:
- a method PROMOTED FROM AN EMBEDDED INTERFACE field (go/types gives it an interface receiver) — CS1929,
context.afterFuncCtx, whoseDeadlinepromotes fromcancelCtx’s embeddedContextinterface whileDone/Err/Valueare concretecancelCtxmethods; - a method reached through TWO OR MORE embedded structs (its receiver is neither the type nor any direct-field type; the generator hops to the first field only, one level too shallow) — CS1503, a
rig{Device{Sensor}}whoseLabellives on the twice-embeddedSensor(guarded directly by theCrossPkgUserbehavioral test).
The gate lives in the shared helper so both recorders honour it, and it only ever SHRINKS the recorded set — it drops zero pre-existing records (the compiling corpus is green without any of these shapes), so the corpus stays a strict subset of what already compiled: no new CS1929 or CS1503. This unblocked math/bits’ TestDiv*Panic{Overflow,Zero} assertions (5 of 6 validate end-to-end through the reconstructed adapter; the 6th, TestDiv32PanicZero, is gated by an unrelated golib gap — a hardware DivideByZeroException is not re-presented as a Go runtime.Error, and Div32 alone relies on the implicit division panic rather than an explicit panic(divideError)). (Guarded by the OptionalInterfaceStructuralAssertion behavioral test — a widget that structurally implements a narrower Tagger interface, never cast to it, held as any through a []any and asserted to Tagger; the any-typed operand makes the assertion-site recorder record nothing, so the declaration-site producer is the SOLE source of the widget → Tagger adapter, output-compared vs go run.)
A second gate is a NAME collision, resolved at write time. The adapterCannotForward shape gate was measured against a corpus build that reported only the long-standing reflect/value.cs .Clone blocker — but MSBuild SKIPS a failed project’s dependents, and flag and compress/zlib both reach reflect through fmt, so neither was ever built. With .Clone fixed, compress/zlib surfaced CS0102 + CS0111 ×5: go2cs-gen composes an adapter class name from the LAST DOT SEGMENT of each side (the same naming adapterTypeRef/valueAdapterTypeRef emit at cast sites), so zlib’s OWN Resetter and the compress/flate Resetter that its *reader also implements both compose readerжResetter in zlib_package — two .g.cs files declaring one class. The FORM is part of the name (the ж pointer prefix vs the ᴠ value infix), so only same-form pairs collide.
The loser must be chosen by ORIGIN, not by record order: writePackageInfoFile now tracks, per recorded pair, whether EVERY producer was the declaration-site structural recorder (structuralOnlyImplementations; a DEMANDED record from any emitted cast/assertion site wins permanently), and its collision prune drops a structural-only pair whose adapter name a demanded pair already owns. That is the safe direction: no emitted C# names a structural-only pair’s adapter — it exists solely so a run-time assertion can resolve — whereas dropping a DEMANDED pair strands a real cast site on a class the generator never emits (CS0246). Two colliding structural-only pairs are broken by keeping the lexicographically first, so the outcome is deterministic regardless of map iteration order. A pair the ALIAS dedup will skip (the qualified duplicate of a record already carried under a package type alias) is excluded from ownership — CrossPkgUser’s type Tagged = CrossPkgLib.Labeled records badge under both names, and the qualified one would otherwise evict a local Labeled pair while itself emitting badgeᴠTagged. Corpus-wide the prune removes exactly one record. (Guarded by CrossPkgUser: *dial satisfies both the local Labeled and the foreign CrossPkgLib.Labeled and is cast to the foreign one, so the two pairs compose one dialжLabeled; without the prune the build fails CS0102: 'main_package' already contains a definition for 'dialжLabeled'.)
RETIRED (2026-07-25) — the two recorders above, plus the test-package scan, are GONE; a named-interface assert now resolves at RUN TIME. Everything from “An assertion to a NAMED interface must record…” through the ORIGIN paragraph is HISTORY, kept because it explains records that still exist. Its founding premise — that TryTypeAssert can only resolve a named target through a compile-time GoImplement adapter — stopped being true when the tiered interface shells landed (see Every eligible interface carries runtime duck-typing shells): golib’s AdapterBinder now constructs a wrapper for a structurally-matching dynamic value from the interface’s OWN assembly, so the three STRUCTURAL producers — recordAssertConcreteImplementers (assertion site), recordLocalConcreteImplementers (declaration site) and recordTestPackageImplementers (-tests type-side scan) — were deleted along with recordIfImplements, adapterCannotForward and the structuralOnlyImplementations collision prune they existed to arbitrate (~495 lines). They were never sound, only useful: each GUESSED an assertion’s dynamic type by enumerating types it could name, and was blind by construction to a dynamic type in a later-converted assembly (io/fs recorded subFS, never os.dirFS). What REMAINS is every DEMANDED record — an explicit conversion, a var _ I = T{} witness, a resolved-concrete empty-source assert — plus the Promoted and ConstraintProxy records; ImplementGenerator is untouched, so a declared conversion still gets its nominal adapter, still the ~1.1 ns fast path. The corpus effect, measured on a seeded 305-package reconvert: 1535 → 1324 records, −335 / +124 across 53 packages. Of the 335 dropped, 73 RELOCATE — the pair moves from the provider package to each consumer that demands it, so the emitted reference changes shape (io.SectionReaderжReader → io_SectionReaderжReader, and (Scored)(Verdict)4 → new CrossPkgLib_VerdictᴠScored(…)) — and 262 are simply gone; a scan of all 1919 corpus .cs finds 261 of those 262 named by no emitted C# whatsoever, which is the direct measurement of how speculative they were. Of the 124 added, 117 are the consumers’ half of those relocations and 7 are base-interface records the interface-inheritance prune had been suppressing under a now-deleted derived-interface record (flag.textValue → Value, net.UnknownNetworkError → error, runtime.errorString → error, …). (Guarded by five EXISTING behavioral tests that now carry ZERO nominal records for the pairs they assert — DerivedInterfaceStructuralProbe, OptionalInterfaceStructuralAssertion, InterfaceToInterfaceAssertion, AnonIfaceThroughPointerAdapter, IfaceToIfaceNarrow — so their output comparison IS the shell-resolution proof; and operationally by the banked suites, testing/quick 8/8 being the direct one, since its myStruct → Generator record was the test-package scan’s sole consumer.)
A local named-FUNC value record is exempt from the interface-inheritance prune. Independently, flag failed CS0246 on boolFuncValueᴠValue — a PRE-EXISTING defect the same masking hid, reproducible with the structural recorder reverted. A C# delegate cannot be a partial struct, so a GoImplement pair whose concrete is a named func type generates a per-interface ᴠ ADAPTER CLASS rather than an entry folded into the type’s own base list. flag’s boolFuncValue is recorded against both boolFlag and Value, and boolFlag EMBEDS Value, so the subsumption prune dropped the Value pair as “covered by inheritance” — true for a partial struct, false for an adapter class, which is per-exact-interface. The ж-pointer form and the foreign-value form were already exempt (adapterClassImplementations); the local named-func value form now registers there too, so flag.cs’s new boolFuncValueᴠValue(…) keeps its record. This is the same reasoning that exempts new net_ConnᴠWriter(…), applied to the one adapter-class shape the list had missed.
The run-time structural match for an ANONYMOUS interface is SIGNATURE-aware, not name-only. (Ladder detail SUPERSEDED — ᴛAs was retired 2026-07-25 in favour of the duck-typing shells, and the two memoized tiers were folded into one per-interface itab cache on 2026-07-26; both are described under Every eligible interface carries runtime duck-typing shells. The signature-aware matching rule below is unchanged and current — only the tier it gates has moved.) An assertion to an anonymous (dynamically declared) interface — x.(interface{ Unwrap() []error }) — does not resolve through a compile-time GoImplement adapter; it resolves at run time in golib, where builtin.TryTypeAssert gates on Cache<TInterface>.Implements before invoking the generated ᴛAs conversion. That gate used to compare only method names, so two anonymous interfaces that share a name but differ in signature — errors.Is’s emitted is_typeᴛ1 { error Unwrap(); } and is_typeᴛ2 { slice<error> Unwrap(); } — were CONFLATED: a value whose Unwrap returns []error matched both, and constructing the wrong adapter (error Unwrap() bound to a []error-returning method) threw NotImplementedException from the adapter’s static constructor. The check now matches each interface method by NAME and SIGNATURE (parameter and return types), scoped to the value’s actual Go method set: TypeExtensions.StructurallyImplements resolves the value’s receiver element (a pointer box ж<X> exposes X’s value- and pointer-receiver methods; a plain value X exposes only its value-receiver methods) and requires, for every interface method, an extension method with the same name whose signature matches (the candidate’s first parameter is the receiver, which the interface method lacks). This also fixes a second defect on the SAME path: GetExtensionMethodNames collapses a closed ж<X> to the open ж<> generic definition (correct for MinBy-precedence single dispatch, wrong for a name-set membership test), so the old check admitted the pointer-receiver methods of every type — e.g. ж<errorString>, whose errorString has no Unwrap, matched is_typeᴛ1 because fmt’s *wrapError.Unwrap was in the collapsed set. Open-generic receiver methods (methods on a Go generic type, whose signature carries type parameters that cannot be compared against a concrete interface signature) keep the prior name-only match. On the same fix, error._<T>() (the single-value assertion err.(T) on an error) now routes an INTERFACE target T — including a dyn anonymous interface — through this general machinery instead of casting the carrier to error<T> (which threw InvalidCastException when the dynamic value was a generated pointer/interface adapter rather than an error<T>), first unwrapping a pointer-sourced IжAdapter to its receiver box so the structural probe sees the dynamic *T. This unblocked errors.Join/TestJoin end-to-end and made errors.Is route wrapped/multi errors to the correct Unwrap arm. (Guarded by the AnonInterfaceSignatureAssert behavioral test — a value whose Unwrap returns []error and one whose Unwrap returns error, each asserted against BOTH interface{ Unwrap() error } and interface{ Unwrap() []error }; the correct shape matches and dispatches, the wrong one misses. Output-compared vs go run; the pre-fix golib crashes the C# process at the conflated assertion.)
An interface that EMBEDS another interface must collect only the base’s INSTANCE, ORDINARY members — reflection’s default BindingFlags leak the base’s STATICS. Go’s interface{ error; Temporary() bool } (net.Error’s shape) converts to a [GoType("dyn")] partial interface classify_type : error, so both halves of the duck-typing machinery have to walk the C# base interface to see Error(). Both walks collected too much. golib’s TypeExtensions.GetInterfaceMethods called baseInterface.GetMethods() with default flags (Public | Instance | Static) where the direct-member call above it correctly passes BindingFlags.Public | BindingFlags.Instance — and golib’s hand-written core interfaces expose static duck-typing conversion helpers (error.As<T>, fmt.Stringer.As<T>), while TypeGenerator stamps ᴛAs onto every dyn interface. So StructurallyImplements demanded a static As from the dynamic value’s Go method set, which no Go type can ever satisfy: every embedding interface’s structural probe answered FALSE, and an assert against a value that plainly has both methods returned a MISS. Symmetrically, go2cs-gen’s InterfaceDeclarationSyntaxExtensions.GetInterfaceMethods walked AllInterfaces members with no IsStatic / MethodKind filter, so the Δ wrapper FORWARDED those statics — the two surviving As overloads emitted duplicate AsByPtr/AsByVal delegate types and s_AsByPtr/s_AsByVal fields (CS0102 ×6, so the project did not even compile). Both walks now filter to instance, ordinary members; property/event accessors and constructors are likewise never Go interface methods. Note the base walk must stay TRANSITIVE — .NET’s Type.GetInterfaces() already flattens an inheritance chain, so a 3-deep interface{ named; Depth() int } → named : stringish collects all three levels. (Guarded by the DerivedInterfaceStructuralProbe behavioral test: an anonymous interface embedding error asserted against a *tempErr that has Error+Temporary and a *plainErr that has only Error, plus a 3-deep embedding chain with a deepest-level negative control, output-compared vs go run. Pre-fix the project fails to compile; with only the gen half fixed it compiles and prints not temporary where Go prints temporary=true msg=boom.)
A typed-error assert (err.(*T) / err.(T)) resolves through the SAME machinery — the error<T> carrier is only a special case. errorExtensions._<T>(this error) is the overload C# picks whenever the assert operand is statically an error (builtin._<T>(this object) is less specific), and its body used to be a direct cast, ((error<T>)target).Target. That only ever matched golib’s own reflective carrier — the object error.As builds — which converted code never constructs: an error produced from a Go pointer is a generated IжAdapter (new fs.PathErrorжerror(Ꮡ(new PathError{…}))), and one produced from a value is the implementing struct itself. So every single-value typed-error assert against real converted code threw InvalidCastException (“PathErrorжerror to error<ж<fs.PathError>>”) — and because that is a raw CLR fault rather than a PanicException, Go’s recover could not even see it. os’s dirFS.Open path-fixup (err.(*PathError).Path = name) died on it, taking io/fs’s TestGlob, TestReadDirPath and TestReadFilePath down as infrastructure errors. The comma-ok form was never affected: err._<T>(ᐧ) has no errorExtensions overload and already bound to builtin’s adapter-aware TryTypeAssert. _<T> now unwraps the error<T> carrier when the dynamic value actually IS one, and otherwise defers to ((object)target)._<T>() — the one type-assertion machinery — so a statically-error operand and an any-typed one can never disagree about the same assert. Commit cb0f58078 (above) closed the INTERFACE half of this defect; this closes the CONCRETE-type half, and the two runtime-Type overloads (err._(type), err._(type, out result)) were routed through builtin.TryTypeAssert(object, Type, out object) for the same reason, replacing a reflection lookup of error<>’s explicit conversion operator that had the identical carrier-only blind spot. (Guarded by the TypedErrorAssertThroughAdapter behavioral test — pointer- and value-sourced errors asserted back to their concrete types through an error boundary, a write through the asserted *T observed on the interface value, comma-ok misses in both directions, and a failed single-value assert proven to be a recoverable panic; output-compared vs go run, and the pre-fix golib crashes the C# process with exit code 2.)
Constructing the run-time duck-typing wrapper is FAIL-SOFT — a structural false positive must MISS, not crash. golib’s structural probe is deliberately imprecise in one place: for an open-generic receiver method it matches by NAME ONLY, because the candidate’s signature carries the receiver’s type parameters and cannot be compared against the interface’s concrete signature (see StructurallyImplements’ remarks). So a Go generic’s Get() T matches interface{ Get() string } for every instantiation — box[int] included, where Go plainly says no. The probe therefore said yes, builtin.TryTypeAssert closed the generated ᴛAs over box<int> and invoked it, the wrapper’s static initializer found no bindable extension overload and threw NotImplementedException, and reflection re-wrapped that as TargetInvocationException — which escaped the assertion as a process crash (Exception has been thrown by the target of an invocation., exit 2) where go run prints a clean comma-ok miss. A MISS is normal control flow at every emitted assertion and type-switch site (the method’s own remarks say so), so wrapper construction now runs through one fail-soft helper (TryConstructInterfaceWrapper) covering both close-over paths — by-value and by-pointer over a receiver box — and any construction failure answers ok=false: a MakeGenericMethod constraint violation, an already-faulted type initializer, a conversion whose result is not the asserted interface (the hard (T) cast became an is T pattern), or missing dynamic-code support. This is the same discipline golib’s CreateInterfaceHandler already applies. It narrows nothing: a genuine duck-typed assertion still resolves and dispatches. (Guarded by the StructuralAssertFailSoftMiss behavioral test — a real match then a false-positive probe, on each close-over path; pre-fix the C# process dies on the second line.) Superseded 2026-07-25 — the ᴛAs route this helper closed, TryConstructInterfaceWrapper itself, and CreateInterfaceHandler were all retired when anonymous interfaces moved onto the runtime duck-typing shells (see Every eligible interface carries runtime duck-typing shells…). The fail-soft rule is unchanged and now lives in one place, AdapterBinder.TryCreate; the guard still holds.
CLOSED 2026-07-25 (kept as the measured statement of the problem) — a NAMED interface had no run-time wrapper, so a cross-package structural satisfaction missed. Every named interface now carries runtime duck-typing shells (see Every eligible interface carries runtime duck-typing shells… below), io/fs validates 18/18, and the ᴛAs machinery described here no longer exists — anonymous interfaces use the same shells. The two recorders above (assertion-site and declaration-site) can only see the converting package’s scope, so an assert whose dynamic type comes from a package converted later — or from one the asserting package does not import at all — records nothing, and the run-time fallback cannot cover for it: builtin.TryTypeAssert’s structural path needs the generated ᴛAs/Δ<Iface><T> duck-typing wrapper, which TypeGenerator emits only for [GoType("dyn")] (anonymous) interfaces — 33 of them corpus-wide, against 267 named ones. Measured ground truth (io/fs, 2026-07-24): os.dirFS structurally satisfies fs.ReadDirFS/fs.StatFS and ж<os.File> satisfies fs.ReadDirFile, and golib’s structural PROBE agrees — builtin.Implements<fs.ReadDirFS>(dirFS) returns true for all three — yet each assert returns a MISS, because GetInterfaceConversionMethod finds no ᴛAs on the named target and bails. io/fs’s ReadDir therefore falls back to fsys.Open + file.(ReadDirFile), misses again, and returns readdir .: not implemented with zero entries; fs.Glob swallows that error (TestGlob sees an empty match set) and fs.WalkDir hands it to the callback (TestIssue51617 sees only .). Nominal recording can never be complete here — an interface’s dynamic type may live in any assembly, including one built after the asserting package — so the durable fix is to give NAMED interfaces the same run-time duck-typing wrapper dyn ones already get, with method collection made TRANSITIVE (a named interface inherits members through C# interface inheritance: ReadDirFS : FS needs Open as well as ReadDir). That is a corpus-wide go2cs-gen change and is not yet done; io/fs validates 16/18 with these two tests open.
The types that support these tuple-returns are defined in the golib library; ordinary user-code tuple returns convert as normal C# tuples without special handling.
A package-level var a, b = f() reads ValueTuple components. C# static field initializers cannot deconstruct a tuple, so the per-name field emission assigned the WHOLE result tuple to the first field (CS0029 — edwards25519’s var identity, _ = new(Point).SetBytes(…)). With exactly one non-blank name the component read is appended to the inline call (internal static ж<Point> identity = …SetBytes(…).Item1; — blank names keep their uninitialized _ᴛNʗ fields, and the call still runs once). With two or more non-blank names the call is evaluated ONCE into a hidden tuple field and each name reads its component (internal static (nint, @string) tupleᴛ1ʗ = pair(); internal static nint n = tupleᴛ1ʗ.Item1; — C# static initializers run in textual order, so the reads follow the temp). Gated to package scope, no explicit type, one call initializer typed as a tuple; in-function var x, y = f() keeps the existing path. (Guarded by the GlobalTupleVarDecl behavioral test — both shapes plus a call-count probe proving single evaluation, output-compared vs Go.)
Every trailing argument of a variadic pointer parameter gets the box treatment. The per-parameter argument loop visits declared parameters only, so checkInitialized(p, q) binding two deref-aliased pointer parameters to ...*Point boxed only the first (checkInitialized(Ꮡp, q) — CS1503). The pointer-argument box treatment now fans out from the variadic parameter’s index to every trailing argument, mirroring the type-parameter @string fan-out; the spread form (f(s…)) is excluded as before, and non-variadic calls are byte-identical. (Guarded by the VariadicPointerParam extension pairTotal — three deref-aliased pointer params forwarded to the variadic, value vs Go.)
A call-result delegate of a NAMED func type must resolve its signature through Underlying(). All the per-argument treatments above (pointer boxing, interface conversion, u8 suppression) are driven by getFunctionSignature, which for a callee that is itself a call — valueEncoder(v)(e, v, opts), encoding/json — read info.TypeOf(fun).(*types.Signature). When the inner function returns a named methodless func type (valueEncoder returns encoderFunc), info.TypeOf is a *types.Named, so that assertion failed and the signature came back nil — the per-argument loop never ran, and the pointer receiver e (a deref’d ref var e = ref Ꮡe.Value) passed its value alias where the ж<encodeState> slot wanted the box Ꮡe (CS1503). The *ast.CallExpr arm now asserts on Underlying(), looking through the named func type to its signature (a no-op when the result is already an unnamed signature). Byte-identical across the behavioral corpus and across an A/B of encoding/json+gob+text/template+net/http+reflect — a single line moves (json’s valueEncoder(v)(e,…) → (Ꮡe,…)). (Guarded by the NamedFuncResultPointerArg behavioral test — adder() returning a named addFunc called immediately with a *State receiver that must box, mutation through the box observed vs Go.)
A variadic closure rebinds its params array to a slice at the top of its body. A variadic parameter a ...T arrives in C# as a params ꓸꓸꓸT array named <name>ʗp (a distinct name, so it doesn’t collide with the slice the body expects); the body then references the bare <name> as a slice<T>. A top-level function emits a var <name> = <name>ʗp.slice(); prologue as its first block statement, but a function literal emitted no such prologue, so any closure that referenced its variadic parameter used an undefined bare name (CS0103 — internal/dag’s errorf := func(format string, a ...any) { … fmt.Sprintf(format, a...) } spread a… against a name that was never declared). A function literal now emits the same rebinding prologue. Because the prologue is prepended before the single-return→expression-body collapse, a variadic closure whose body is a lone return f(a...) keeps its block form (the rebound name is a statement-scoped local) rather than collapsing to an undefined expression. An IIFE literal is excluded — it emits parameter names only (the raw a, with the delegate cast supplying the params type), so there is no <name>ʗp array to .slice(). (Guarded by the VariadicClosureSpread behavioral test — a single-return closure spreading a... into fmt.Sprintf, a closure ranging its variadic slice, and a single-return closure forwarding a... to another variadic; output-compared vs Go.)
An UNNAMED or BLANK variadic parameter emits no rebinding at all. Go permits a variadic parameter with no name (func cmdPipeTest(...string)) or with the blank name (func f(_ ...int)); either spelling leaves the parameter unreferenceable from the body, so the rebound slice<T> local is dead by construction. Emitting it anyway was broken rather than merely redundant. An unnamed parameter named that local with the EMPTY string — var = ʗp.slice();, which the C# parser reads as an assignment to a nonexistent var (three CS0103 in os/exec’s converted test sources: cmdPipeTest, cmdStdinClose, cmdStderrFail, the wall that held that package in front of the TestMain flag bridge) — and inside a function literal it was doubly wrong: the literal’s signature builder normalizes the absent name to _ and declares params ꓸꓸꓸnint _ʗp, while the prologue kept rendering ʗp from the raw name, so the dead local carried both an empty name and a name the signature never declared. A blank parameter emitted var _ = _ʗp.slice();, which compiles but declares a REAL local named _ (a plain var _ = e; declaration is a variable, not a discard) that then hijacks every _ = … discard the body writes — the same CS0029 class bodyUsesBlankDiscard exists to prevent for a blank parameter name. Both spellings now emit no rebinding; the signature is untouched, keeping the params array under its own ʗp name and simply leaving it unread, exactly as the Go parameter does. This is the same ruling, for the same reason, that an unnamed/blank pointer parameter’s deref alias already takes (it would otherwise emit ref var = ref Ꮡ.Value;). A named variadic still rebinds — the skip is scoped to the two unreferenceable spellings, not to variadic parameters at large. (Guarded by the UnnamedParams behavioral test, which pins all three shapes — unnamed, blank, and a named control that IS read — at declaration, method and function-literal positions, output-compared vs Go.) Stdlib footprint: zero. An AST census of GOROOT finds exactly one production site, syscall/syscall_linux.go’s func cgocaller(unsafe.Pointer, ...uintptr) uintptr, and it is bodyless (a //go:uintptrescapes linkname target, emitted internal static partial uintptr cgocaller(@unsafe.Pointer _Δp0, params ꓸꓸꓸuintptr ʗp);) so it has no prologue to skip on any target; the other four sites are all in os/exec’s test sources.
A non-escaping variadic parameter binds through the stack-only sslice<T> view. The C# signature
already receives the arguments as params Span<T>, so a prologue whose uses are proven frame-local now
emits var xs = xsʗp.sslice(); instead of xsʗp.slice(). The old Span<T>.slice() path calls
ToArray(); the stack view removes that allocation and, for a spread call f(s...), keeps the callee
aliased to the caller’s backing array as Go requires. The proof is intentionally narrow: direct
len/cap, element indexing, and range are eligible. Passing or spreading the slice, assigning or
returning it, append, address-taking, slicing, interface boxing, channel/go use, or a nested function
literal falls back to the heap slice<T>. When defer/recover requires an execution wrapper, the converter
passes the incoming params Span<T> by ref through the one-reference func overload (whose generic
reference slot uses C# 13’s allows ref struct anti-constraint) and performs the slice rebinding
inside that wrapper. The wrapper therefore does not capture the outer ref-like parameter, and an
otherwise-eligible body keeps .sslice() plus Go spread aliasing. Uses that genuinely require a heap
slice still fall back to .slice(). Function literals use the same eligibility map and ref-wrapper path.
Golib’s cap<T>(in sslice<T>) complements the existing len overload; append deliberately has no sslice
grow path, so any append remains a heap case. (Guarded by VariadicPointerParam: safe range/len/cap/index
sites emit .sslice(), a spread element replacement is observed through the caller’s slice against
go run, defer-wrapper declarations and closures pass the params Span by ref, preserve spread element
writes, and nested captures still pin the .slice() fallback.)
The ꓸꓸꓸT alias identifier mirrors the GO name; its referent must be using-independent. The readable params ꓸꓸꓸT form is a namespace-scope C# using alias (using ꓸꓸꓸstring = Span<@string>;), so it applies only where a legal alias identifier exists — everything else falls back to the inline params Span<T>. Two separate constraints decide that, and only the first is about the name. (1) The identifier cannot contain <, > or ., so it transliterates the element’s Go name, joining any package qualifier with TypeAliasDot — Go’s ps ...unsafe.Pointer becomes params ꓸꓸꓸunsafeꓸPointer psʗp, the same pkgꓸType convention package_info.cs already uses for its global usings. Go names are preferred over the emitted C# ones: they need no @ keyword-escape stripping (@ is legal only at identifier start — ꓸꓸꓸ@string is a lex error, CS1002/CS0116 — while the Span<> referent keeps the escape), carry no _package class suffix, and undo a Δ collision-rename, so go/types’ ...Type reads ꓸꓸꓸType rather than ꓸꓸꓸΔType. Any Go identifier is a legal C# one and the ellipsis prefix defuses keywords (...event → ꓸꓸꓸevent); types with no Go name — a basic type, a universe type (error), a lifted anonymous struct (internal/fuzz’s CorpusEntry) — transliterate the emitted C# name instead. Because only the name is constrained, a qualified referent still reads like Go: a same-package element is qualified with the package class (a bare nested name like statDep does not resolve at namespace scope, CS0246) yet still emits params ꓸꓸꓸShape shapesʗp over using ꓸꓸꓸShape = Span<main_package.Shape>;. (2) The referent is resolved by C# with the compilation unit’s own using directives not in effect, so it may not name another file-local alias. A cross-package element renders in the short alias form (@unsafe.Pointer, ast.Expr), which is exactly that — left as-is it fails CS0246, and go2cs-gen (which copies these usings into its generated files) cannot resolve the symbol either and falls back to unescaped text, Span<unsafe.Pointer>, whose bare keyword cascades to CS8956. Such a referent is therefore rewritten to the alias’s own target — Span<unsafe_package.Pointer>, Span<global::go.go.ast_package.Expr> — which is using-independent by construction, being what the using <alias> = <target>; line itself resolves. The mapping is recorded where the import using is emitted rather than re-derived, so the -tests package-under-test rebinding is honored automatically; an alias not yet bound when the declaration is visited (visitFile synthesizes canonical aliases for inference-only foreign references after the walk) degrades to the inline form. A pointer element is the one constructed form that does transliterate: go2cs already writes *T as ж<T>, so bs ...*box reads params ꓸꓸꓸжbox bsʗp. Its pointee resolves through the same routine and so takes the same namespace-scope qualification — the alias referent says Span<ж<main_package.box>> where the inline form could say bare ж<box>, since only the inline form sits inside the package class — and the two rules compose, giving go/doc’s ...*ast.File a ꓸꓸꓸжastꓸFile = Span<ж<global::go.go.ast_package.File>>. A pointee with no alias form of its own (a type parameter, a constructed pointee such as *[]byte) takes the whole element inline with it. Genuinely inline-only, then: a type parameter (never in scope inside a referent — First<T>(params Span<T> valsʗp)) and every other constructed element, for which no established transliteration exists (map<@string, any>, slice<byte>, Action<ж<options>>). Two element types transliterating to one identifier in a single file would bind it twice (CS1537), so the first claim wins and the loser stays inline. Deriving from Go names is also what keeps os/signal’s long-standing using ꓸꓸꓸosꓸSignal = Span<osꓸSignal>; and text/template’s ꓸꓸꓸreflectꓸValue byte-identical through this path — they already followed the convention, because their bare C# names come from global usings in package_info.cs, which do carry over into a using-alias referent. (Guarded by the VariadicSlotInterfaces behavioral test — a same-package interface element — and by VariadicPointerParam, which pins all three outcomes in one file: an aliased pointer (ꓸꓸꓸжbox), an aliased cross-package element (ꓸꓸꓸunsafeꓸPointer), and an inline constructed one (Span<slice<byte>>); the type-parameter arm of that fallback is held by GenericVariadicFunc. All output-compared vs Go. Stdlib footprint: every previously-inline site becomes an alias except the type-parameter and non-pointer-constructed ones — same-package elements read bare (ꓸꓸꓸAttr, ꓸꓸꓸtraceArg, ꓸꓸꓸWriter, ꓸꓸꓸType), cross-package ones carry their qualifier (ꓸꓸꓸunsafeꓸPointer in runtime, ꓸꓸꓸastꓸExpr in go/types), and pointers take the ж form — unicode.In(r, ranges ...*RangeTable) becomes params ꓸꓸꓸжRangeTable rangesʗp.)
A named result is DECLARED only when something reads it (2026-08-08). Go’s named results are ordinary addressable locals, so the converter declared every one of them at function entry. But Go names results for documentation far more often than it uses them — func EncodeRune(r rune) (r1, r2 rune) never mentions r1/r2 again — and the emitted rune r1 = default!; is then dead on arrival. That one shape was 1,218 of the corpus’s 1,219 CS0219 (“assigned but its value is never used”) warnings. Nothing is lost by omitting it, because the names survive exactly where a reader reads them — on the C# tuple return type — so the emission moves closer to the Go source, not further from it:
// before
public static (rune r1, rune r2) EncodeRune(rune r) {
rune r1 = default!;
rune r2 = default!;
if (r < surrSelf || r > maxRune) {
// after — the names still read off the signature
public static (rune r1, rune r2) EncodeRune(rune r) {
if (r < surrSelf || r > maxRune) {
The declaration is kept whenever anything can read it, and the check is deliberately conservative in every unclear case — a retained dead declaration costs one line, a dropped live one is CS0103. It stays when the body references the result (read, assigned, address-taken, or captured by a closure — a capture is a use, so that walk descends into function literals); when the body has a naked return, which reads every named result by definition (that walk stops at a nested *ast.FuncLit, whose bare returns belong to the literal — the iter.Pull shape above depends on this); when the result is heap-box backed, whose box is the storage the render sites reference; and when the function is lowered through either defer form — namedReturnDeferMode, where the declarations sit outside the func() wrapper precisely so deferred closures can mutate them, or a GoFrame, whose named exit emits a trailing return <names>; after the try. In those last two the generated code reads the locals and the Go body need never mention them, so liveness is switched off wholesale rather than inferred. The same rule and the same opt-out apply to function literals (namedReturnDeclLines). Keeping the check rather than suppressing the code corpus-wide also preserves CS0219 as a live signal: a genuinely dropped assignment to a named result still surfaces. (Guarded by namedResultLiveness_test.go, which pins one function per liveness reason plus the dead shape and the outer-dead/inner-naked case, and is proved in BOTH directions by negative control. Corpus effect: CS0219 1,219 → 52, none of them a named-return prologue — 33 are in hand-owned files the converter never re-emits, 18 are Go’s own var witnesses for a constant-folded unsafe.Sizeof/Offsetof, and 1 is a folded local const.)
Slices and Arrays
Go slices and arrays are converted to the golib slice<T> and array<T> structures. A make-style allocation uses a constructor; a composite literal builds a C# array and projects it with the .slice() / .array() extension:
package main
import "fmt"
func main() {
primes := [6]int{2, 3, 5, 7, 11, 13} // array literal
nums := []int{10, 20, 30} // slice literal
buf := make([]byte, 4) // make
fmt.Println(primes[0], nums[2], len(buf))
}
converts to:
internal static void Main() {
var primes = new nint[]{2, 3, 5, 7, 11, 13}.array();
var nums = new nint[]{10, 20, 30}.slice();
var buf = new slice<byte>(4);
fmt.Println(primes[0], nums[2], len(buf));
}
A named slice/array type (type d [3]rune, type s []int) lowers to a struct wrapping array<T>/slice<T>; its composite literal cannot use C# collection-initializer braces (the lowered struct has no Add), so it is constructed through the underlying-collection constructor: d{0, 32, 0} → new d(new rune[]{0, 32, 0}.array()).
The empty composite of such a type is its zero value, not a one-element literal: the generic named-composite nil filler (which gives a named struct composite its new T(nil) zero-value ctor argument) previously landed inside the element literal — tmpBuf{} (type tmpBuf [32]byte, runtime string.go’s *buf = tmpBuf{}) emitted new tmpBuf(new byte[]{nil}.array()), a NilType element in a byte[] (CS0029). An empty array composite now emits a zeroed fixed-length backing — new tmpBuf(new byte[32].array()) — because Go’s [N]T{} is a full-length zero array (an empty {} literal would produce a length-0 backing); an empty named-slice composite emits an empty non-nil backing — pm{} → new pm(new uint32[]{}.slice()). (Guarded by the NamedArrayWrapper extension — empty array composite read/written at full length, zeroing an existing wrapper through a pointer via *buf = tb{}, and an empty slice composite appended to; values vs Go. The nil-vs-empty distinction — pm{} == nil is false in Go — is a separate pre-existing golib model latent: the slice nil-compare conflates nil with empty-but-allocated.)
Reslicing SHARES the backing array — golib slice<T> stores capacity. A Go reslice is a view adjustment, never a copy: writes through s[a:b] are visible through s (and vice versa), append within capacity writes the shared backing in place, and only append beyond capacity reallocates and detaches. The emitted forms are the C# range indexer for 2-index expressions (s[a:b] → s[a..b], s[a:] → s[a..], s[:b] → s[..b], s[:] → s[..]) and the golib .slice(low, high, max) extension for 3-index expressions (s[a:b:c] → s.slice(a, b, c); a missing low is the -1 sentinel). Both take bounds relative to the view (the source slice may itself sit at a non-zero offset in its backing array), default a missing high to len(s) — the range indexer resolves a from-end Index against the slice length, so s[1..] is Go’s s[1:] even when len < cap — allow high up to cap(s), and panic Go-style (RuntimeErrorPanic.SliceBoundsOutOfRange) when out of range. To represent a capacity-restricted 3-index view (s[a:b:c], c below the backing array’s end) without copying, slice<T> stores m_capacity as its own field rather than deriving it from the backing array’s length; the slice<T>(T[] array, nint low, nint high, nint max) constructor builds such views. (Historically golib copied on any reslice that didn’t span the whole backing array — a base[2:5] sub-slice was a detached array with lost aliasing — derived capacity from the backing end, and mis-measured Available/Append for non-zero-offset views. Guarded by the SliceAliasing behavioral test — copy into and element writes through a low>0 reslice, reslice-of-reslice offset compounding, slice-of-array reslices, restricted-capacity 3-index writes, in-place vs reallocating append, all read back through the base and value-compared vs Go.)
A make length/capacity/size-hint of a non-int integer type is cast to nint. The golib allocating constructors all take nint — slice<T>(nint length, nint capacity), map<K,V>(nint capacity), channel<T>(nint capacity) — and C# does not implicitly convert a uintptr/uint/uint32/uint64/int64 (C# nuint/uint/ulong/long) to nint. So make([]byte, n/goarch.PtrSize) with a uintptr length would leave new slice<byte>(n / …) with no applicable constructor, and overload resolution falls onto slice<T>(T[]) — reported as CS1503 (“cannot convert nuint to byte[]”); a map/chan with a uintptr hint is a direct nuint→nint CS1503. The converter casts each such length/capacity/hint argument to nint: new slice<byte>((nint)(n / goarch.PtrSize)) (both args of make([]byte, l, c)), new map<K,V>((nint)(hint)), new channel<T>((nint)(size)). A plain int (nint) and an untyped-constant argument (make([]byte, 4) → a bare 4) bind directly and are left uncast (no golden churn) — as are the widening int8/int16/uint8/uint16 kinds. (Guarded by the MakeSliceUintptrLen behavioral test — uintptr/uint/uint32/uint64 slice lengths, a uintptr len+cap, a uintptr map hint and chan size, and int/untyped controls, all len/cap/element values verified vs Go; runtime hits this in mbitmap’s make([]byte, n/goarch.PtrSize).)
Slicing a pointer-to-array. Go lets a *[N]T be sliced directly — p[lo:hi:max], p[:] — auto-dereferencing the array. The C# box ж<array<T>> has no slice/range members (its underlying array<T> does), so the converter dereferences first: p[1:3:4] → (~p).slice(1, 3, 4), p[:] → (~p)[..], p[2:] → (~p)[2..]. Without the deref the call binds to the box and fails (CS1929). The resulting slice shares the array’s backing storage, matching Go. (The (*[N]T)(ptr)[:n] pointer-cast form is different — see Pointer-cast slice below.) A deref-aliased pointer parameter or receiver is the exception: it is emitted as the pointed-to value, not a box, so a ~ on it would deref a non-pointer (CS0023). When that value is a named array type — b *pageBits emitted ref pageBits b, where pageBits is [N]uint64 — the wrapper has no slice/range members, so its underlying array<T> is reached via .Value: b[:2] → b.Value[..2]. When it is an anonymous array (p *[N]T → ref array<T> p) the value already is the array<T> and is sliced directly (p[:] → p[..]). Only a pointer-to-array box (a local, a field, a call result) gets the ~ deref. (Guarded by the PointerArraySlice behavioral test — local box, named-array receiver, and named-array parameter; runtime hits this in select.go’s cas1[:ncases:ncases] / mprof.go’s stk[:n:n] (locals) and mpallocbits.go’s pageBits receiver methods (clear(b[:])).)
Named-slice pointer reinterpret ((*[][]byte)(buf) with buf *Buffers). Go converts a pointer-to-named-slice to a pointer to its underlying slice type freely — net’s fd.pfd.Writev((*[][]byte)(buf)), where poll’s Writev reslices the header through the pointer (consume advances *v), and the caller must observe it. The C# boxes ж<Buffers> and ж<slice<slice<byte>>> are unrelated generic instantiations (CS0030), so the conversion emits a field view over the wrapper’s own backing slice:
consume(Ꮡbuf.of(Buffers.Ꮡm_value));
Two generator pieces make the view real aliasing: a named-slice wrapper’s m_value is mutable (ReadOnlyValue = false — a readonly field would force a defensive copy and lose header writes), and ISliceTypeTemplate emits the field-ref accessor internal static ref slice<T> Ꮡm_value(ref Buffers instance) that ж<T>.of() projects through. Claimed narrowly: pointer→pointer, source pointee a NAMED type whose underlying is a slice identical to the (unnamed) target pointee. (Guarded by the SortArrayType extension consumeOne — a (*[]Person)(&crew) reinterpret whose reslice through the view shrinks the original Roster, runtime-verified against Go.)
The reverse direction — an underlying-slice pointer to a NAMED-slice pointer, (*Buffer)(&b) with type Buffer []byte (log/slog/internal/buffer’s sync.Pool.New) — is asymmetric, because the projection above cannot run backwards: a named-wrapper box contains the underlying slice (project it out), but a bare-slice box does not contain a wrapper to project. It is emitted as golib’s storage reinterpret instead — Ꮡb.Reinterpret<slice<byte>, Buffer>() — which re-views the same slot as the wrapper type rather than constructing anything: a generated named-slice wrapper is a single-field struct over the slice header, exactly the layout correspondence ReinterpretAliasesStorage recognizes, so the managed alias arm engages and the derived pointer ALIASES the addressed slice. A bare (ж<Buffer>)(Ꮡ(b)) cast is CS0030 (unrelated instantiations). The source comes two ways and both render in BOX form (the isPointer ident context): an address-of arg (&b, &h.field) and an existing pointer arg (cryptobyte’s (*String)(out) with out *[]byte). Claimed narrowly by the mirror gate: pointer→pointer, target pointee a NAMED type whose underlying is a slice identical to the (unnamed) source pointee.
It previously constructed a wrapper box over a copy — Ꮡ(new Buffer(b)) — on the stated assumption that such a reinterpret is only ever used through the returned pointer. That assumption is false wherever the conversion exists precisely to write BACK, which is the shape’s dominant corpus use, and the writes went nowhere:
| Site | What the copy cost |
|---|---|
log/slog commonHandler.withAttrs — (*buffer.Buffer)(&h2.preformattedAttrs)
|
Every attribute WithAttrs pre-formatted was dropped, while the handler still advanced groupPrefix/nOpenGroups — so the JSON it emitted afterwards was unbalanced. Four testing/slogtest rows (WithAttrs, multi-With, empty-group-record, resolve-WithAttrs). |
crypto/tls readUint{8,16,24}LengthPrefixed — (*cryptobyte.String)(out) with out *[]byte
|
An out-PARAMETER whose whole purpose is the write-back: the caller’s slice never received the length-prefixed field. |
crypto/tls parseECHConfigList — (*cryptobyte.String)(&ec.PublicKey)
|
A struct FIELD that stayed empty after a successful parse. |
vendor/…/cryptobyte ReadASN1Bytes — (*String)(out)
|
Same out-parameter shape, same loss. |
Corpus A/B footprint of the correction: 5 files, 8 sites (log/slog/handler.cs, log/slog/internal/buffer/buffer.cs, crypto/tls/ech.cs ×2, crypto/tls/handshake_messages.cs ×3, vendor/golang.org/x/crypto/cryptobyte/asn1.cs), plus the dead ref var @out = ref Ꮡout.DerefOrNull() locals the aliasing form no longer needs. (Guarded by the NamedSlicePointerReinterpret behavioral test — a direct (*Buf)(&b) read back through b, the closure-returned func() *Buf { … return (*Buf)(&s) } shape, a pointer-parameter fillVia(out *[]byte) read back through out, and a struct-field (*Buf)(&h.preformatted) appended across a reallocating growth and then truncated through a freshly derived pointer — output-compared vs Go.)
Pointer-cast slice ((*[N]T)(ptr)[:n]). A Go conversion that casts an unsafe.Pointer to a pointer-to-array and slices it produces a []T over the pointed-to memory. It is emitted as the golib slice<T> — the C# representation of every []T — built from a ReadOnlySpan<T> over the raw pointer: new slice<T>(new ReadOnlySpan<T>((T*)ptr, (int)n)). (Earlier it was a bare Span<T>, but a Span<T> does not range as (index, element) tuples — for i := range s → CS8130 — and has no Ꮡ(s, i) element-address — CS0411; slice<T> supports both, since it is IArray<T>.) The ReadOnlySpan<T> constructor takes a C# int, so a Go int/uint length (nint/nuint) is narrowed via getRangeIndexer (through the underlying for a named numeric); an int literal is left as-is. The slice copies the pointed-to memory (ReadOnlySpan.ToArray()), which is self-consistent for code that only uses the resulting slice (e.g. runtime’s printDebugLog ranges state and writes &state[i], never re-reading the raw buffer; os_windows ranges an unsafe []byte read-only). Since this is always the (*[N]T)(ptr) unsafe-cast form, it is memory-layout-dependent code whose raw values flow through the unsafe.Pointer=nuint round-trip (a transient fixed address → not GC-stable), so the runtime values are not the contract — only compilable, rangeable, element-addressable C#. (Guarded by the PointerCastSliceRange behavioral Compile + target test — index range, value range, and &s[i] element-address over a pointer-cast slice; runtime greened debuglog’s printDebugLog and os_windows, ~25 errors via the cascade. The length narrowing is covered by StdLibInternalAbi.)
An untyped (type-inferred) composite literal — the inner {…} of a [][]rank{ key: {…} }, which has no explicit type node — is emitted as a target-typed new(…) when its inferred type is a struct (the struct constructor takes the field values). When the inferred type is a slice or array, that form is wrong (slice<rank>/array<rank> have no element-list constructor → CS1729); the converter emits the element-array projection instead — {rA, rB} (inferred []rank) → new rank[]{rA, rB}.slice(), and an inferred [2]int → new nint[]{…}.array(). When the inferred type is a pointer-to-struct — the []*T{ {…} } shorthand for &T{…} — it is emitted as the boxed struct constructor Ꮡ(new T(field: val, …)) (a bare new(…) would target the box ж<T>, whose constructor lacks the struct’s fields → CS1739). When such an untyped slice/array literal is keyed ({joiningL: stateBefore, …} — the inner {…} of x/net/idna’s joinStates = [][numJoinTypes]joinState{stateStart: {…}, …}), the element-array projection above cannot take Go’s key: value syntax — new joinState[]{ joiningL: stateBefore } is a C# array initializer, which has no keyed element form (CS1003 ×62). The keyed case is routed to a golib golib.SparseArray<T> collection initializer instead — new golib.SparseArray<joinState>{ [joiningL] = stateBefore, … }.array() (.slice() for a slice element) — the same form the typed keyed slice/array path emits (see below); the .array()/.slice() IEnumerable<T> extension materializes the dense backing, and a defined-integer key takes the [(int)key] cast exactly as in the typed path. (Guarded by UntypedNestedSliceComposite; runtime/lockrank.go’s lockPartialOrder is a [][]lockRank and runtime1.go’s dbgvars is a []*dbgVar of the positional forms, and x/net/idna’s joinStates is the keyed form.)
An indexed (keyed) slice/array literal — []string{lockRankSysmon: "sysmon", …} — is emitted as a golib golib.SparseArray<T> collection initializer ([index] = value). Its indexer takes a Go int. When an index key’s Go type is a defined integer type whose underlying type does not implicitly widen to C# int (i.e. int/int64/uint/uint32/uint64/uintptr, as opposed to int8/uint8/int16/uint16/int32), the key is cast to int so it satisfies the indexer (CS1503 otherwise): [lockRankSysmon] (a type lockRank int) → [(int)lockRankSysmon]. A key that already widens (e.g. a uint8-backed Kind) is left uncast.
A keyed slice/array literal whose element type is a non-empty INTERFACE routes its elements
through the interface-cast element loop (each value wraps via convertToInterfaceType) instead of
convExprList — and that loop rendered a KeyValueExpr as a FLAT key, value pair, feeding the
SparseArray collection initializer one item at a time (Add(key) then Add(value):
CS1950 + CS1503 ×21 pairs on go/internal/gccgoimporter’s lookupBuiltinType,
[...]types.Type{gccgoBuiltinINT8: types.Typ[types.Int8], …} — untyped named-const keys in a
function-body literal, immediately indexed). The loop now emits the same [key] = wrappedValue
indexer form the non-interface path produces, with the key routed through sparseArrayKey (so a
defined-integer-type key keeps its [(int)key] cast alongside the interface-wrapped value); a
keyed MAP literal with an interface element type reaching the same loop takes the identical
indexer form. (Guarded by the SparseArrayIfaceElem behavioral test — a function-body sparse
literal with untyped named-const keys immediately indexed, the package-level form, and a
named-uint-keyed form, elements read back and output-compared vs Go.)
A keyed (sparse, constant-index) literal of a named array-wrapper type — internal/trace/oldtrace’s timedEventArgs{1: uint64(ev.StkID)} where type timedEventArgs [4]uint64 — backs onto the golib array<T>(length) (which has an indexer setter), not a raw C# array. The wrapper’s constructor takes an array<T> (the positional path already produces one via .array()), and the keyed elements render as the [i] = v indexed initializer — valid on new array<uint64>(4){[1] = v} but not on new uint64[]{[1] = v} (CS0131, an array-initializer takes no indexed members). A positional literal of the same wrapper keeps the new uint64[]{…}.array() form (unchanged — no churn). (Guarded by NamedArrayKeyedLiteral — a type args [4]uint64 with multi-keyed, single-keyed, and positional literals, element reads output-compared vs Go.)
A generic named array type carries its type parameters (and their constraints) onto the forward declaration, and its element type is emitted fully qualified in the [GoType] attribute so the generated array-backed partial — which lives in a file without this file’s package-relative using aliases — can resolve it:
type table[T any] [3]atomic.Pointer[T]
[GoType("[3]sync.atomic_package.Pointer<T>")] partial struct table<T>
where T : new();
An anonymous array/slice field whose element type lives in a multi-segment-path package — cpuLogWrite [2]atomic.Pointer[profBuf], children [4]atomic.UnsafePointer (atomic = internal/runtime/atomic) — keeps its array<…> wrapper. The field’s type name is built structurally from the [N]/[] marker plus the recursively resolved element, not from the type’s package-qualified string: that string ([2]internal/runtime/atomic.Pointer[…]) goes through a cross-package last-segment strip that would also remove the leading [2], collapsing the field to the bare element type (atomic.Pointer<…> = new(2)) whose array new(2) initializer then mis-binds the element constructor (CS1503). With the structural rendering the field stays array<atomic.Pointer<profBuf>> = new(2). An array of a current-package or basic-typed element was unaffected (its string has no foreign path to strip). (Guarded by the ArrayOfCrossPackageType behavioral test — [3]atomic.Int32 / [2]atomic.Uint64 fields; runtime’s trace/traceMap structs hold these.)
A struct’s array fields get their fixed length from a generated parameterless constructor
A Go [N]T array FIELD has a zero value of N zero elements — never nil. The converter emits the field
with a length initializer — internal array<atomic.Int32> c = new(3); — but a C# struct field
initializer only runs when an explicitly declared parameterless constructor is invoked; the implicit
struct constructor that new counters() would otherwise use zeroes every field and SKIPS initializers,
leaving the array’s backing T[] null (an NPE on the first index or len). The TypeGenerator
therefore emits an explicit parameterless constructor for every struct, so new S() runs the field
initializers and each array field gets its new(N) backing. (C# 11 auto-defaults any field without an
initializer; a slice/map/chan field — which has no new(N) initializer — stays its nil zero value,
matching Go.) The NilType constructor preserves the initializers too: it used to re-assign
this.field = default! to every plain member — running after the field initializers, which nulled an
array field’s fresh new(N) backing, so S{} (emitted new S(nil)) NREd on the first index. The
NilType and parameterless constructor bodies (AppendZeroValueInitializers) now assign only what C#’s
implicit zeroing would leave broken: the promoted-embed boxes (see
Struct Type Embedding), and — see next paragraph — any plain struct-typed field
whose own type needs construction; everything else is left to the field initializers plus C# 11
auto-default. This is generator-only and produces no golden churn (the .g.cs output is not a
golden). It is what lets ArrayOfCrossPackageType run as an output-compared test (len(x.c) /
len(x.d) print 3 2); before the fix, indexing &x.c[i] threw a NullReferenceException, so the test
was compile+target-only.
A plain (non-embed) struct-typed FIELD whose type itself needs construction is the recursive case:
default(T) gives such a field a default(FieldType), whose nested promoted-embed box or fixed-array
backing is null — so the first touch NREs even though T’s own boxes were constructed. This is fmt’s
pp{ … fmt fmt … } where fmt embeds fmtFlags (a ctor-allocated box): newPrinter’s @new<pp>()
ran pp()’s ctor, which left fmt as default(fmt) with a null box, and p.fmt.init(&p.buf) →
clearflags NREd — the first crash of any converted fmt.Println. AppendZeroValueInitializers
therefore also emits this.f = new FieldType(nil); for each such field, and because that runs
FieldType’s own NilType constructor (which recursively constructs its needy fields), a single level of
construction fixes every depth. “Needs construction” (StructTypeNeedsConstruction) is: has a promoted
embed, a fixed-array field, or a nested struct field that needs construction; a reference field
(pointer/interface/delegate) keeps its correct nil zero value.
The resolution must be by SYMBOL, not by syntax. StructTypeNeedsConstruction originally answered
only for structs it could find a StructDeclarationSyntax for (GetStructDeclaration), and left every
other field type default on the reasoning that “its own package constructs it, and its nil zero value is
correct anyway”. The first half is wrong and the second half does not apply to a fixed array. A
<ProjectReference> reaches the compiler as a PortableExecutableReference — compiled metadata with
no syntax trees — so in any real MSBuild build every cross-package field type was unresolvable, and
nothing in the consuming package ever constructed it. When such a type carries a fixed array at any depth,
default leaves that array<T>’s backing null (golib’s deliberate zero-value discriminator), so len and
range silently measure zero and the first index or pin throws — GCHandle.AddrOfPinnedObject’s
InvalidOperationException: Handle is not initialized (see golib ж.cs, pinnedArrayData). The live case
was math/rand/v2’s ChaCha8, whose internal chacha8rand.State state; never got State’s
buf = new(32) / seed = new(4), where Go’s new(ChaCha8) yields 32 real zeroed words. (The syntax path
only ever worked because CompilationReferences — in-memory Roslyn compilations — do carry syntax; that is
the shape unit tests use, not the shape MSBuild produces.)
GetStructDeclaration is therefore backed by Compilation.FindTypeSymbol, which resolves a
fully-qualified display name to an INamedTypeSymbol through GetTypeByMetadataName (stripping global::
and verbatim @, rendering type arguments as arity suffixes, and trying each namespace-vs-nested-type split
of the dotted name since a display string spells both .). The metadata walk applies the same three
triggers over GetMembers() — a ref-returning property is a promoted embed, a go.array<T>-typed field is
a fixed array, a struct-typed field recurses — with the same cycle guard. On the metadata path the
public T(NilType) constructor that new T(nil) needs is checked rather than assumed (metadata is
fully compiled, so the generated constructor is really there): a hand-written golib struct or any other
referenced type without one returns false and correctly keeps its default. Scalars, pointers, slices,
maps and interfaces are still left default, because default is their Go zero value — over-
constructing would add an allocation to every instantiation for no semantic gain. (Guarded by the
CrossPackageArrayZeroValue output-compared test — a Holder whose field type lives in the bufpkg
sibling library sub-project, so the reference is genuinely metadata; a same-project field type resolves by
syntax and would pass even unfixed. Against the unfixed generator the test panics with
index out of range [2] with length 0.)
The field-wise constructor closes the same gap for a PARTIAL composite literal. The parameterized
constructor (GenerateConstructor, used by a composite literal that sets some fields —
&Holder{tag: "lit"} → new Holder(tag: "lit")) took T f = default! for every member and assigned
this.f = f; unconditionally, so an OMITTED needy-struct argument arrived as the broken default(T) and
overwrote the field — identical breakage to the NilType path above, and (because it never consulted
StructTypeNeedsConstruction) firing for a same-package field type too. The live case is io.pipe,
whose onceError rerr, werr value fields each embed sync.Mutex via the promotion box: io.Pipe()
builds the pipe with new pipe(wrCh: …, rdCh: …, done: …), omitting rerr/werr, so both boxes were
null and the first Store/Load Lock() NREd on the pipe’s writer goroutine — crashing every io.Pipe
consumer (encoding/base32’s TestBufferedDecodingPadding; the goroutine NRE aborted the whole test
host). A fixed-array member beside it already had the analogous if (f.Source is not null) guard (its
= new(N) field initializer supplies the omitted zero); the needy-struct member has no field initializer
to fall back on, so it must be constructed. GenerateConstructor now emits the needy value-struct
member’s parameter as nullable — onceError? rerr = default! — making an omitted argument a genuine
null sentinel that default(onceError) (a real struct value with a null box) could never be — and its
body reconstructs only when omitted: this.rerr = rerr ?? new onceError(nil);, exactly mirroring the
pointer-embed ?? new ж<T>(nil) handling for a promoted embed. A caller-SUPPLIED value is used as-is (no
extra allocation, unchanged reference semantics — the struct copy shares the same embed box); an omitted
one gets T’s own NilType ctor, which recursively constructs its needy members. The predicate
IsNeedyValueStructMember reuses StructTypeNeedsConstruction (member is not a promoted embed, not a
reference, not a fixed array, and its struct type needs construction), so every ordinary member’s
parameter/assignment is byte-identical to before — the change is confined to genuinely-needy value-struct
fields. All of the NilType, parameterless, and now field-wise constructors are covered, so this reaches
new(T)/@new<T>()/T{}/&T{…} (empty and partial composite literals). (Guarded by the
PromotedEmbedZeroValueField output-compared test — a slotBox{id: 3} partial literal omitting a
holder value field that embeds a promoted counter, whose promoted inc() is then called on the
omitted field; against the unfixed generator it NREs with Object reference not set to an instance of an
object, exactly as encoding/base32 did.)
A MIXED-VISIBILITY struct needed one arm more: the PUBLIC field-subset constructor, which does not
name the needy member at all. The paragraph above fixes the member’s parameter — but a struct with
any unexported field gets two field-wise constructors (Constructors): a public one over
PublicStructMembers and an internal one over all of them, the public subset carrying
OverloadResolutionPriority(-1) so a same-assembly named-args call binds the full overload. An
unexported needy member is therefore absent from the public subset ctor’s parameter list and its
body, so the ?? new T(nil) reconstruction never applied to it and the field was left at
default(T) — the exact state AppendZeroValueInitializers exists to prevent. The deprioritization
is also what hid it: every same-package literal binds the internal all-fields ctor and is correct, so
only a literal in another package reaches the broken constructor. syscall.SockaddrUnix is the
shipped case — &SockaddrUnix{Name: path} from net left raw default, so raw.Path’s [108]int8
backing was zero-length and sockaddr()’s own if n > len(sa.raw.Path) guard returned EINVAL
before bind ever reached the kernel, failing every AF_UNIX listen/dial on Windows with “invalid
argument” (net’s TestModeSocket and TestUnixConnLocalWindows). Because the error is Go’s own
invented EINVAL (APPLICATION_ERROR-based, message “invalid argument”) rather than the kernel’s
WSAEINVAL (10022, “An invalid argument was supplied.”), the failure reads like a rejected sockaddr
and invites a hunt through the marshaling — which is sound and was not at fault. GenerateConstructor
now reuses AppendZeroValueInitializers over the members the ctor does not name, giving them the
same zero-value construction the parameterless ctor gives them; the all-fields internal ctor omits
nothing, so nothing changes there, and a struct with no unexported members emits no subset ctor at
all. (Guarded by the CrossPkgLiteralNestedField output-compared test — an addrlib sibling library
supplies Addr{Name string; raw rawAddr} in SockaddrUnix’s exact shape plus an Embedder whose
unexported member is a promoted embed, both built from the parent package by composite literal;
it reads the nested fixed array’s length, runs a guard-then-fill Encode() mirroring sockaddr(),
reads bytes back out, and checks that an over-long name is still rejected — so it separates “the array
is right” from “the guard always fails”. Against the unfixed generator the capacity reads 0 and
Encode answers 0 false.)
A bare var x T zero-value declaration (no initializer) calls none of those, so the converter
closes the remaining gap on its side: when T needs construction it emits T x = new(); — the generated
parameterless constructor, which runs the same field initializers + AppendZeroValueInitializers — instead
of the T x = default!; that left an array field’s backing null (an NRE on the first index/len). The
converter mirrors StructTypeNeedsConstruction with the Go-side structZeroValueNeedsConstruction (promoted
embed / fixed-array field / nested needy struct, recursively; a reference field keeps its correct nil zero
value). A promoted-embed var keeps its existing new(nil) (the NilType ctor) and a scalar-only struct
keeps default!, so the change is confined to genuinely-needy structs — one pre-existing corpus golden
re-baselined, PublicizedFieldType’s var cr CaseRange (a [3]rune Delta field). A needy struct
global likewise gets new() in place of the bare static T x;. This var/global path is guarded by the
ZeroValueStructVar output-compared test (var z holder with a [8]int field, a nested wrapper, and a
scalar-only point control). Guarded by
the NestedPromotedEmbedInit output-compared test (a printer holding a formatter field that embeds
flags and holds a [3]byte, reached via both new(printer) and &printer{}, its promoted fields and
array written and printed against Go); before the fix the promoted-field write NRE’d.
The zero-value ladder is one ladder, and a NAMED RESULT climbs it too
The var x T path above and the named-result prologue are the same question asked at two syntactic
sites — what does a declaration with no initializer put in the slot? — but only the first had the
full answer. A named result declared T name = default!; at function entry got exactly one rung
(structHasPromotedEmbeds → new(nil)); the fixed-array and structZeroValueNeedsConstruction
rungs were missing, so a [N]T result arrived with length 0 and an array-bearing struct result
arrived with a null backing. Both shapes were shipped, and both were measured live in crypto/tls:
// src/core/net/netip/netip.cs — func (ip Addr) As16() (a16 [16]byte)
array<byte> a16 = default!; // was: length 0, null backing
byteorder.BePutUint64(a16[..8], ip.addr.hi); // -> ArgumentException out of slice<T>'s ctor
// src/core/crypto/tls/common.cs — func (c *Config) ticketKeyFromBytes(b [32]byte) (key ticketKey)
ticketKey key = default!; // skips `aesKey = new(16)`, `hmacKey = new(16)`
copy(key.aesKey[..], hashed[16..]); // -> copies 0 bytes -> "aes: invalid key size 0"
Both now emit their construction — array<byte> a16 = new(16); and ticketKey key = new(); — from
a single shared helper, zeroValueInitializer, which the three named-result declaration sites
(visitFuncDecl’s plain and blank-slot prologues, iifeOperations.namedReturnDeclLines for a
function literal and the deferred-named-return lowering) and the var/global paths all read. Its
rungs, in order: an unnamed fixed-size array → new(N) plus arrayZeroValueArgs’ element
factory; a promoted-embed struct → new(nil); a struct carrying a fixed array at any depth →
new(); everything else → default!. A named array type is deliberately excluded — go2cs-gen’s
array wrapper allocates its backing lazily from its own known size, so its default is already
usable — and a scalar-only result still stays default!, which is what keeps the change confined
to types whose Go zero value genuinely is not all-bits-zero.
Guarded by the ZeroValueArrayNamedResult output-compared test, which pins all five emission sites
against go run: an As16-shaped [16]byte result written through a slice of itself, a
ticketKey-shaped struct result filled by copy, a nested value-struct result, a result declared
by the deferred-named-return lowering, and a function literal’s named result — plus a scalar-only
control proving the ladder does not over-fire.
Two sites of the same class are knowingly not changed, because the corpus does not exercise
either and an unexercised emission change is an unmeasured one: the tuple element a blank named
result contributes to an explicit return (visitReturnStmt), and the zero-results return default!;
that closes a value-returning function’s recovered-panic catch arm (visitFuncDecl/convFuncLit).
Censused at zero — no return default!; in the corpus sits in a function whose return type mentions
array<, and GOROOT declares no blank named result of array type. A third, narrower gap stays open
for the same reason: a map miss on an array-valued map returns default(V) rather than Go’s
zeroed [N]T (html/entity’s map[string][2]rune, the corpus’s only such map, only ever reads on
a hit).
A slice-bounds fault is a PANIC, not an ArgumentException
The same investigation named why this defect was so expensive to find. slice<T>’s two windowing
constructors threw a plain ArgumentException/ArgumentOutOfRangeException for an out-of-bounds
window, and array<T>’s slice→array conversion threw IndexOutOfRangeException. Neither is a
PanicException, so neither is visible to recover() and — the expensive part — both satisfy
Goroutine.CanContain, which means a converted test host contains them and records them on the
TestExecution instead of failing. When the dying goroutine is the one another goroutine is waiting
on, the record never flushes and the package deadline burns with no output at all. 17 of
crypto/tls’s 53 measured divergences presented that way — 10 as an infrastructure-error line, 7 as
a silent hang — where Go would have failed loudly in milliseconds.
All six throws now raise RuntimeErrorPanic.SliceBoundsOutOfRange(low, high, max, capacity), whose
message shapes already mirror the Go runtime’s, and the conversion-length check raises the new
RuntimeErrorPanic.ArrayConversionLength (same text as before — it was already Go’s — now as a
recoverable panic). The netip case reproduces Go’s message exactly: a16[:8] on a length-0 array
prints runtime error: slice bounds out of range [:8] with capacity 0.
Prefer the file-local package alias over the fully-qualified _package name
A cross-package named type has two C# spellings: the fully-qualified form sync.atomic_package.Int32 (the namespace-rooted class, from getFullyQualifiedTypeName) and the file-local alias form atomic.Int32 (the using atomic = sync.atomic_package; shorthand, from getAliasQualifiedTypeName). For visual fidelity — the converted C# should read like the Go original, which writes atomic.Int32 — body emission prefers the alias. But the alias is only resolvable where the using is in scope, so the choice is made per emission site by getScopeCheckedTypeName, which returns the alias form only when every cross-package type referenced by the type is imported in the current file (checked against the per-file importQueue), and otherwise falls back to the fully-qualified form.
The fallback matters: a Go file may index an atomic-typed array field of a struct — &x.c[i] → …at<E>(i) — without ever naming the element type E, so it carries no import "sync/atomic" and no using atomic. There the element type must stay fully-qualified (it resolves inside namespace go; with no alias) or the file fails CS0246. When the package is imported (the common case, and every behavioral test of this), the prettier alias is used.
getScopeCheckedTypeName is applied at the body-emission sites that land in the current source file:
-
named struct-field declarations —
internal atomic.Int64 total;(wassync.atomic_package.Int64); -
heap-box allocations —
ref var n = ref heap(new atomic.Int32(), out var Ꮡn);; -
element-address
at<T>—…at<atomic.Int32>(0).
It is not used for forms consumed by the source generators in alias-less generated files, which must stay fully-qualified: the [GoType("…")] attribute string (e.g. [GoType("sync.atomic_package.Uint32")], [GoType("[3]sync.atomic_package.Pointer<T>")]), the global using type-alias declarations, and the promoted-interface/embedded-field registration keys. (Embedded fields keep the full form for their promoted accessors; only the named-field branch uses the display name. Struct-embedding promotion across packages re-derives member types from the Roslyn semantic model, not from the field’s emitted text, so aliasing the field declaration is safe.) Guarded by ArrayOfCrossPackageType, AtomicValues, FuncTypeParam, GenericAtomicPointerField, GlobalAtomicDefer, GlobalAtomicFieldMethod, and StructPromotionWithInterface/StructPointerPromotionWithInterface.
Combined field-element address base.at(field, i)
The address of an element of an array/slice FIELD of a boxed value — &x.c[i] where c is an
array field, or the implicit address taken to call a pointer-receiver method x.c[i].inc() — was
rendered as a two-step chain Ꮡx.of(counters.Ꮡc).at<atomic.Int32>(i): of(field) takes the field’s
address (a ж<array<E>>), then at<E>(i) takes the element’s. The explicit <E> is needed because
golib’s standalone at<TElem>(nint) is generic in an element type unrelated to the pointer’s T, so
it cannot be inferred. golib adds combined overloads ж<T>.at<TElem>(FieldRefFunc<…array<TElem>…>, nint
index) (one per field-accessor shape and array/slice kind, each forwarding to of(field).at<TElem>(i))
whose TElem IS inferred from the field accessor’s return type. The converter then collapses the chain
to Ꮡx.at(counters.Ꮡc, i) — dropping both the .of( step and the <E> type argument. It rewrites the
recursively-built field address base.of(Type.Ꮡfield) by retargeting its trailing .of(field) to
.at(field, i), only when the field segment is parenthesis-free (a plain Type.Ꮡfield accessor, so the
final ) provably matches the last .of(); any other shape falls back to the explicit chained form.
The combined overload is behaviorally identical to the chain (it literally forwards to it). (Guarded by
ArrayOfCrossPackageType, IndexedElementDirectBoxMethod and PointerFieldArrayElementAddress — all
output-compared; the .inc()/bump() element writes verify runtime equivalence.)
The routing gate sees through nested value fields to the chain root. &pp.wbBuf.buf[0] (runtime
mwbbuf.go) roots at the pointer pp through the value field wbBuf; the original gate checked
pointer-ness only one level up (pp.wbBuf, a struct), fell to a naive Ꮡ prefix (Ꮡpp.wbBuf… — CS1061
on the box), and the same failure hit the closure-captured variant (&mp.trace.buf[gen%2], trace.go).
The gate now walks intermediate selectors to the root, so any pointer-rooted (or heap-boxed) chain routes
through the recursive &field machinery — pp.of(pstate.ᏑwbBuf).at(wbBuf.Ꮡbuf, 0) — which already
rendered multi-hop of-chains. A nested-index base — &cache.entries[ck][i] (2-D array via a pointer,
symtab.go) — is an IndexExpr, not a selector, so it gets its own arm: recursively take the inner
element’s address (cache.at(pcvalueCache.Ꮡentries, ck)) and chain the outer .at<T>(i) onto it — the
gate also accepts a HEAP-BOXED value root (&grid.cells[1][2] on an address-escaping local), fixing that
shape too. An unboxed value-rooted chain keeps the prior naive form (corpus byte-identical). Known
remaining gap (pre-existing): an intermediate IndexExpr inside the selector chain —
&ptr.items[i].buf[j], an array-of-structs hop — defeats the root walk (both arms only step through
selectors) and keeps the CS1061 naive form; the recursive machinery likely has the pieces when a runtime
site demands it. (Guarded by
the NestedFieldElementAddr behavioral test — all three runtime shapes with write-through vs Go; note a
ZERO-VALUED struct’s array-field backing is null in the C# emulation — a separate pre-existing latent —
so the test initializes its arrays.)
Element address of a by-value ARRAY PARAMETER. Array parameters are cloned by value in the
function preamble (value = value.Clone();, Go’s array-copy semantics) but are never
escape-analyzed, so they have no heap box — the naive element-address form would name a box that
does not exist (Ꮡvalue.at<byte>(0), CS0103 — syscall SetsockoptInet4Addr, &value[0] on
value [4]byte). The converter boxes a copy of the wrapper struct instead:
Ꮡ(value).at<byte>(0). array<T> wraps a T[] reference, so the copied wrapper SHARES element
storage with the cloned parameter — element reads and writes through the pointer stay behaviorally
correct. (One accepted edge, no stdlib hit: reassigning the whole array param after taking an
element address leaves the pointer on the older backing array.) (Guarded by DeferTypelessReturns’
first — element address of a [4]byte parameter, value vs Go.)
Element address of a POINTER-to-array — &t[i] where t is *[N]E. Go auto-derefs the index
((*t)[i]), so the element lives in the pointed-to array on the heap; t already IS the ж<[N]E>
box. The converter emits t.at<E>(i) — ж’s at materializes the array’s lazy backing on the REAL
storage and then returns an element pointer over the shared backing. (How at reaches that lazy
getter has changed twice and matters: a reflection-built constrained delegate first — fatal under
Native AOT, d5c0c9c10 — then an unsynchronized box-touch-copy-back, and since 2026-08-30 a
per-box atomic publish; see The array-backing publish is atomic per box below.) The base is
rendered in POINTER context so it yields the box: a deref-aliased pointer
PARAMETER gives Ꮡt (the parameter is ж<[N]E> Ꮡt, deref-aliased to ref var t = ref Ꮡt.Value in
the prologue), while a box-valued LOCAL from new([N]E) gives the plain t. Previously this shape
fell through every array/slice branch (the base’s type is a *types.Pointer, not an array or slice)
to the generic Ꮡ(t.Value[i]) copy form, which boxes a snapshot of the element and silently
drops any write made through the returned pointer. This is exactly hash/crc32’s
slicingMakeTable/simpleMakeTable: simplePopulateTable(poly, &t[0]) populated a throwaway copy,
leaving every CRC table all-zeros (checksums degenerated to ~0-with-shifts — TestGolden,
TestSlicing). The same latent write-through-a-copy bug lurked corpus-wide wherever &ptr[i] on a
pointer-to-array was written through — crypto/internal/nistec (p224GG[i].SetBytes(…) in static
init), internal/bisect and runtime (atomic.Store*(&arr[i], …)) — all now alias correctly. (A
pointer-to-SLICE cannot reach here: Go does not auto-deref *[]E for indexing; it is written
(*t)[i], a StarExpr the slice branch already aliases.) (Guarded by PointerToArrayElementAddress
— write-through &g[j] on a *grid local, value vs Go.)
Element address of a SLICE FIELD of the receiver — &b.lines[i]. The address of a slice element
uses one of two golib forms: the element-aliasing two-arg Ꮡ(x, i) (→ new ж<T>(IArray, index),
whose ValueSlot returns ref backingArray[index], so writes land in the shared backing array), or the
copy-boxing Ꮡ(x[i]) (→ Ꮡ(in T), which boxes a copy of the element value). For a slice the copy
form is only sound when nothing is written back through the pointer. Inside a pointer-receiver method the
converter had a refRecv fast-path that chose the copy form for a “receiver reference to a slice” — but
its detection keyed off getIdentifier(indexExpr.X), which walks the selector chain to its root
identifier. So a slice field of the receiver — &b.lines[i], whose base b.lines roots at the
receiver b — matched the fast-path too, and emitted the copy form. text/tabwriter’s terminateCell
does line := &b.lines[len-1]; *line = append(*line, cell): the append grew a copy of the row’s
slice header and wrote the new length into the boxed copy, never back into b.lines, so every line
stayed length 0 and all formatted output came out empty (only the newlines survived). The fix
restricts the copy form to the case the receiver is directly the slice (indexExpr.X is the bare
receiver identifier); any slice base that is a field, call result, or other non-identifier expression
uses the element-aliasing Ꮡ(x, i) form — which is correct for a slice in all cases, since a slice value
always shares its backing array. This also corrected a benign read-only site (NamedFuncTypeStructuralField’s
s.by(&s.items[j], &s.items[i]) comparison) from copy to alias. (Guarded by
SliceFieldElementAddress — append-through-pointer into a [][]int field of a pointer receiver plus an
in-place element mutate, value vs Go; validated end-to-end by text/tabwriter’s test suite.) The ARRAY
branch carried the identical defect and is narrowed the same way — next.
Element address of an ARRAY FIELD of the receiver — &d.hashHead[h]. The array branch had its own
refRecv fast-path with the same root-identifier detection, and so the same bug: an array field of the
receiver roots at the receiver and took the copy-boxing Ꮡ(d.hashHead[h]), whose Ꮡ(in T) overload
heap-boxes a copy of the element. compress/flate’s deflate() is the canonical victim — it does
hh := &d.hashHead[hash&hashMask]; … *hh = uint32(d.index + d.hashOffset) to maintain the chained hash
table. Every head write landed in a throwaway box, so hashHead stayed all-zero, d.chainHead was always
0, and the d.chainHead-d.hashOffset >= minIndex guard (0-1 >= 0) meant findMatch was never
called at all. Levels 2–9 therefore emitted LITERALS ONLY: still-valid deflate streams roughly the size
of HuffmanOnly output. Since png.BestCompression maps to flate level 9, a 256×256 PNG encoded to
134,644 bytes instead of Go’s 36,760 — pixel-identical on decode, ~3.7× weaker compression, with
NoCompression, HuffmanOnly and BestSpeed (whose deflatefast.go encoder writes e.table[…]
directly and never takes an element address) all byte-exact, which is what localized it. The fix mirrors
the slice branch: the copy form is kept only when the receiver is directly the array (indexExpr.X is
the bare receiver identifier); an array field of the receiver uses the element-aliasing two-arg
Ꮡ(d.hashHead, (int)(…)).
Note the array field deliberately does not route through the .of(field)/.at<T>(i) box machinery
described above, even though that machinery exists for array fields. Its trigger is baseIsPointer — the
Go receiver type is *T — but a Go pointer receiver renders as this ref T recv, which has no box
companion, so it emitted Ꮡr.of(RegArgs.ᏑInts) for internal/abi’s &r.Ints[reg] → CS0103. The two-arg
form needs no box and aliases correctly regardless: array<T> is a readonly struct wrapping an eagerly
allocated T[], so evaluating the field copies only the wrapper while the copy shares element storage —
the same reasoning the array-parameter case above relies on. (That reasoning holds for array<T> and
not for a field whose type is a NAMED array, whose wrapper allocates its backing lazily; such a
field is projected through .Value first — see The element address of a VIRGIN named array must
materialize through the receiver. No corpus site currently has that shape; the gate is there because
the shape is legal Go, not because something was found broken.) Seventeen corpus files corrected, several
of them silently broken in the same write-dropping way: runtime’s &r.statusTraced[gen%3],
&h.counts[…] and &m.stats[gen] performed .CompareAndSwap/.Store/.Add on a copy;
crypto/internal/edwards25519 built its lookup tables via (&v.points[i]).FromP3(…) into copies;
image/jpeg wrote Huffman/quantization tables through &d.huff[tc][th] and &d.quant[…]. (Guarded by
RecvArrayFieldElementAddress — chained-hash write-through with an unsigned index, plus a nested
&h.pairs[i][j], value vs Go; the flate ratio itself is verified by deflating fixed buffers at every
level and byte-comparing the sizes against go run.)
Array ASSIGNMENT copies the whole array (.Clone() on the RHS)
Go array assignment copies the array — data := ints yields independent storage — but the emitted
array<T> is a struct over a shared T[] backing store, so a plain C# struct copy aliases: a
write through the copy was visible through the source. The first operational hit was sort’s
TestReverseSortIntSlice (data := ints; data1 := ints left ONE store sorted ascending then
descending, so the ascending/descending mirror check failed ×7 — misdiagnosed at first as an
embed-override dispatch defect; the dispatch was correct). The converter now appends golib’s
strongly-typed array<T>.Clone() to an assignment RHS that copies an array out of existing
storage (see cloneArrayValueCopy in visitAssignStmt.go):
d := garr // → var d = garr.Clone();
var e = garr // → array<nint> e = garr.Clone();
e = src // → e = src.Clone();
f := h.arr // selector RHS → var f = h.arr.Clone();
row := m[1] // index RHS → var row = m[1].Clone();
g := *q // deref RHS → var g = q.Value.Clone();
x, y = y, x // tuple swap → (x, y) = (y.Clone(), x.Clone());
Only existing storage takes the clone — an ident, selector, index, or deref RHS reads a value some
other name can still reach; a composite literal, call result, or conversion is freshly constructed
and stays bare. The shape/type gate is the shared exprReadsArrayValueFromStorage
(arrayCloneOperations.go), which tests the UNDERLYING type — so direct, alias-declared, and NAMED
array types all clone (the named wrapper via its strongly-typed Clone(), next section), and an
interface-typed LHS (var x any = arr) boxes the clone. (Guarded by the ArrayPassByValue
extension — all seven assignment shapes above, written-through and read back against the source —
and ArrayValueCopySites’ namedAssignCopies — named :=/var/any-boxed forms, values vs Go.)
The other copy sites of the same defect class — range elements, composite-literal elements,
returns, channel sends, append elements — are covered by the follow-up section below.
Array VALUE-COPY at every transfer site (range, composite, return, send, append) — DEEP for nested arrays
Go copies the whole array at every value transfer, not just assignment and parameter passing —
but the emitted array<T> (and the generated named-array wrapper) is a struct over a shared T[]
backing store, so every plain C# struct copy ALIASES. The converter appends the strongly-typed
.Clone() wherever an array value is read out of existing storage (an ident, selector, index,
or pointer-deref — exprReadsArrayValueFromStorage in arrayCloneOperations.go; a composite
literal or call result is freshly constructed and needs none):
for _, row := range m { row[0] = 99 } // → foreach (var (_, vᴛ1) in m) { var row = vᴛ1.Clone(); … }
for i, row = range m { … } // pre-existing vars → row = vᴛ1.Clone(); inside the body
m := [2][3]int{a, b} // → new array<nint>[]{a.Clone(), b.Clone()}.array()
s1 := holder{arr: a} // keyed struct field → new holder(arr: a.Clone())
mv := map[string][3]int{"x": a} // map value → ["x"u8] = a.Clone()
mk := map[[2]int]string{k: "kv"} // map KEY → [k.Clone()] = "kv"u8
lst := []any{b} // interface boxing → new any[]{b.Clone()}.slice()
return h.arr // return → return h.arr.Clone();
ch <- a // channel send → ch.ᐸꟷ(a.Clone())
s = append(s, a) // append element → s = append(s, a.Clone())
The range emission routes an array-valued key/value through the same iterate-a-temp mechanism a reassigned range var uses (a C# foreach variable cannot be redeclared from itself); a map range KEY of array type clones the same way. The RECEIVE side of a channel needs no twin — the send stored an unaliased element and a buffered element is dequeued exactly once.
Three deeper repairs make the single .Clone() correct everywhere:
-
array<T>.Clone()is DEEP for nested arrays (golibarray.cs): Go’s[2][3]intcopy copies the inner arrays too, but the shallowT[]clone left every nested backing shared — so even the cloned sites under-copied at depth ≥ 2 (m.Clone()thenm[0][0] = 99wrote the source’s inner array). An element that is itself an array wrapper (anything implementingIArray) is re-cloned through itsICloneablesurface, which now returns the properly-wrapped clone so the unbox recurses through any nesting depth; thetypeofgate keeps flat element types on the single shallow copy. -
NAMED array types get a strongly-typed
Clone()(IArrayTypeTemplate/IArrayViewTypeTemplate): the wrapper’s only clone was the object-returningICloneableform, so named-array copies could not be expressed.public Row Clone() => new Row(Value.Clone());(and the view-wrapper equivalent through its underlying wrapper) lets every site above — plus the function-parameter preamble, func-literal parameters, and array-typed VALUE RECEIVERS — clone named and alias-declared arrays exactly like direct ones (typeIsArrayValuetests the underlying type, widening the old direct-*types.Arraypreamble gate). -
array<T>equality/hash is structural per-ELEMENT (golibarray.cs): Go arrays are comparable values, so amap[[2]int]Vkey must be found again by an equal array with different backing.GetHashCodehashed the backing reference (every structural-equal key missed), and theEqualsoverloads passed container-typed comparers (EqualityComparer<T[]>) whereIStructuralEquatablecalls them per boxed element — throwing on the first equal-length distinct-backing comparison. Element-typed comparers fix both and recurse through nested arrays.
The suffix must be WRAPPED on a ~-prefixed rendering. A deref whose operand is a pointer CAST
(*(*T)(p), convStarExpr’s casted-pointer-deref path) renders with the PREFIX ~ operator, and C#
postfix binds tighter than unary — so a naked .Clone() re-binds onto the cast’s inner operand
instead of the dereferenced array. reflect’s InterfaceData is the real-world case:
return *(*[2]uintptr)(v.ptr) // reflect/value.go
return ~(ж<array<uintptr>>)(uintptr)(v.ptr).Clone(); // WRONG — .Clone() reads v.ptr (CS1061)
return (~(ж<array<uintptr>>)(uintptr)(v.ptr)).Clone(); // emitted — clone the dereferenced array
Every clone-append site therefore routes its rendering through appendArrayValueClone
(arrayCloneOperations.go), which wraps only when the rendering starts with the deref operator —
the same precedence guard convStarExpr already applies when IT appends the postfix .Value to a
cast/deref rendering. Every other shape exprReadsArrayValueFromStorage admits (ident, selector,
index, and the postfix .Value deref form) is already a C# primary expression, so the change is
byte-neutral wherever the suffix was correct — the corpus-wide A/B footprint was exactly this one
reflect line, whose CS1061 had blocked the whole converted stdlib through fmt→reflect.
(All guarded by the ArrayValueCopySites behavioral test — one output-compared section per site
class, including multidimensional deep-copy through range and parameter passing — plus
ArrayCastDerefClone, which guards the wrapped cast-deref form above. That guard is now
output-compared: its TYPED-pointer half (*(*T)(p) where p is already *T) RUNS once the
identity reinterpret stops routing through the raw-address uintptr bridge (see A SAME-TYPE
reinterpret … collapses to the pointer itself), so the clone’s copy semantics are proven by VALUE
— mutating the returned array must leave the pointed-to original untouched, and the lvalue form
must write through. Its unsafe.Pointer half stays compile-shape only, with the results
deliberately discarded: reconstructing an array through an unsafe.Pointer round trip reads raw
memory and cannot reproduce Go’s values under the managed model.)
Known remaining gaps (documented, not yet emitted): (1) golib-internal
element-wise transfers of nested-array elements (copy(dst, src), spread append(dst, src...))
copy element structs without re-cloning; (2) an array-typed map KEY at an index-STORE (mk[k] = v
stores k uncloned — only the composite-literal key form clones); (3) a named↔underlying array
CONVERSION ([4]int(named)) hands the wrapper’s backing through the implicit operator uncloned;
(4) an EMBEDDED struct member is held as a ж<T> box, so a struct copy shares the embed outright
(b := a; b.n = 99 writes through to a.n) — a defect of the embed model, wider than arrays and
untouched by the section below.
A STRUCT carrying array fields copies through its generated ΔClone()
The aliasing above is not confined to array-typed variables: a struct whose FIELD is a fixed-size
array has exactly the same problem one level up, because the plain C# struct copy carries the
field’s array<T> header — and therefore its shared T[] — into the copy. crypto/sha256’s Sum is
the canonical case; it copies the digest precisely so it can finalize the copy while the caller
keeps writing the original:
type digest struct{ h [8]uint32; x [chunk]byte; nx int; len uint64; is224 bool }
func (d *digest) Sum(in []byte) []byte {
d0 := *d // Go copies h and x INLINE
hash := d0.checkSum() // …then destroys d0's state finalizing it
return append(in, hash[:]...)
}
With the copy sharing h and x, checkSum destroyed the CALLER’s running state: every second
Sum on one hash returned a different digest, and TestGolden’s write-half → Sum → write-rest
sequence produced the hash of the empty string. sha1/sha256/sha512 all failed the same way, as did
cryptotest.TestHash’s SumAppend/ResetState/OutOfBoundsRead/StatefulWrite subtests.
The converter now treats such a struct exactly like an array. typeNeedsValueClone
(arrayCloneOperations.go) is the widened gate — a fixed-size array, or a struct carrying one
in a field, directly or through another such struct — so every site the array machinery already
covered (assignment/var-decl RHS, composite-literal element and keyed field, map key and value,
return, channel send, append element, range key/value, function/func-literal parameter and value
receiver) clones a struct too. The struct declaration is stamped with the fields that need it, and
go2cs-gen turns the stamp into the deep copy:
[GoType] partial struct digest {
internal array<uint32> h = new(8);
internal array<byte> x = new(chunk);
internal nint nx;
internal uint64 len;
internal bool is224;
}
[GoRecv] internal static slice<byte> Sum(this ref digest d, slice<byte> @in) {
ref var d0 = ref heap<digest>(out var Ꮡd0);
d0 = d.ΔClone(); // was `d0 = d;` — the arrays were shared
var hash = Ꮡd0.checkSum();
…
}
// generated (go2cs-gen StructTypeTemplate)
internal partial struct digest : IGoValueClone
{
public digest ΔClone()
{
digest copy = this;
copy.h = h.ΔClone();
copy.x = x.ΔClone();
return copy;
}
object ICloneable.Clone() => ΔClone();
}
Four details make this correct and collision-free:
-
The CONVERTER decides which fields clone, not the generator. Only the converter has the Go
type information, and it must agree with itself at the copy sites;
[GoValueClone("h", "x")](golibGoValueCloneAttribute) is that single source of truth. A defined type over such a struct (type IpMaskString IpAddressString, whose underlying holds a[16]byte) is emitted as go2cs-gen’s inherited wrapper, so it is stamped[GoValueClone("Value")]and its clone forwards to that one member — without it,syscall.IpAddrString’s own clone had noΔCloneto call (CS1061). -
The stamp is written on the
package_info.csaccessibility record, not on the declaration above —[GoValueClone("h", "x")] internal partial struct digest {}— so the converted source keeps the shape of the Go struct it came from.TypeGeneratorreads it off any part of the partial type, which is also what lets a hand-owned conversion keep stamping it inline. See Extended attributes. -
The method is NOT named
Clone. A Go type may declare its ownClonemethod, which converts to an EXTENSION method on the package class — and an instance member of the same name silently SHADOWS it. Vendoredx/crypto/sha3’sfunc (d *state) Clone() ShakeHashis the real case: its recv-overload forwarder bound to the generatedstate Clone()and failed CS0029. The name isSymbols.ValueCloneMethod(ΔClone), reusing the sameΔcollision-avoidance marker the promoted-accessor rename uses. Copy SITES for plain arrays keep golib’s publicClone(); only the generated bodies use the uniform name, whicharray<T>and the named-array/array-view wrappers alias to their ownClone()so one call form covers every clone-needing field type. -
array<T>.Clone()recurses through the new marker. It already re-cloned an element that is itself an array ([2][3]int); an element that is one of these structs ([2]digest) now clones the same way, throughIGoValueClone/ICloneable. -
EMBEDDED members are never listed and never cloned. go2cs-gen holds an embed in a
ж<T>box whose member accessor writes THROUGH the box, so assigning one in a clone would corrupt the source. Embedded-struct copy aliasing is the separate, pre-existing gap (4) above; this change neither fixes nor worsens it, and a struct that needs cloning only because of an embed is not stamped.
A BLANK or unnamed parameter is skipped: it is emitted under a synthetic name and can never be
referenced, so there is nothing for the copy to protect — and the preamble would otherwise be
written against the empty analyzed name ( = .ΔClone();, CS1525 ×2 in log/slog’s benchmark
Handle(disabledHandler, context.Context, slog.Record), every parameter of which is blank). The
array-typed arm had the same latent hole; no blank array parameter happened to exist in the corpus.
(Guarded by the StructArrayFieldValueCopy behavioral test — one output-compared line per site
class: pointer-deref copy, ident copy, selector copy, composite-literal element, nested-struct copy,
by-value parameter, returned field, array and slice index, range value, map value, value receiver.
Validated end-to-end by crypto/sha1, crypto/sha256, crypto/sha512 and bufio, whose only residue is
the alloc-profile disclosure the extra managed allocations force.)
Nil-vs-empty slice identity (s == nil is representation nilness, not emptiness)
Go distinguishes a nil slice (nil backing pointer) from a non-nil empty slice (a real backing
pointer with zero length), and programs observe the difference through s == nil, reflect.DeepEqual,
and marshaling — bytes’ TestTrim/TestTrimFunc/TestClone assert it directly (TrimRight of a
non-empty slice trims in place and stays non-nil; Clone of a non-nil input must return non-nil;
the []byte{} want-side literals must not classify as nil). golib slice<T> carries the
distinction in its representation — the backing m_array field is null exactly for the nil slice —
but the observation and two construction paths used to lose it:
-
operator ==(slice<T>, NilType)testedLength == 0 && Capacity == 0, misclassifying every zero-length zero-capacity view ([]byte{},[]byte(""),x[len(x):]) as nil. It now testsm_array is null— representation nilness. -
operator ==(slice<T>, slice<T>)was structural content equality. Go forbids comparing two slices, so the only converted code binding this operator is the nil comparisons == nil, which renders ass == default!(the nil literal rendersdefault!in value contexts). It is now Go slice-header identity (same backing array reference, offset, length, capacity), which against the default header(null, 0, 0, 0)is exactly the nil test. Structural equality remains on theEqualsoverloads for C#-side collection use, and the generated named-type wrappers bindEquals(not this operator), so their behavior is unchanged. -
Reslice(everys[a:b]/s[a:b:c]) laundered a nil backing into a fresh empty array (m_array ?? []). Reslicing nil is legal only within zero bounds, and Go’snil[0:0]is the nil slice (the result shares the nil backing pointer) — it now returnsdefaultfor a nil source. -
Appendwith zero elements allocated a fresh empty slice for a nil source. Go’sappend(s)(andappend(s, empty...)) returnssitself — no growth is needed, so the same header comes back: nil stays nil, and bytes.Clone’sappend([]byte{}, b...)with emptybreturns the non-nil literal.Appendnow returns the source unchanged when there is nothing to add. (builtin.widen, the generic pointer-instantiation projection, likewise now projects only a nil source to nil instead of any empty one.)
The full identity enumeration golib maintains (invariant: nil ⟺ m_array is null):
| Construction | Go identity | golib path |
|---|---|---|
var s []T, struct/element zero values |
nil |
default(slice<T>) — null backing |
[]T(nil), nil literal in slice context |
nil |
T[]-taking ctors map null → default
|
nil[0:0], nil[0:0:0]
|
nil |
Reslice/bounded ctors preserve the null backing |
append(nilSlice) — nothing to add |
nil |
Append returns the source header unchanged |
[]T{} composite literal |
non-nil empty |
new T[]{}.slice() — real empty array |
[]byte("") / []rune("") / conversions of empty strings |
non-nil empty | span/@string paths materialize a real array |
make([]T, 0) |
non-nil empty | parameterless ctor / Make — real empty array |
s[a:a], s[len(s):] of non-nil s (even cap 0) |
non-nil empty |
Reslice shares the real backing array |
append(emptySlice) — nothing to add |
that same non-nil empty |
Append identity return |
append(s, elems...) with elements |
non-nil | in-place or reallocated — always a real array |
f() — zero-argument variadic call |
nil | empty params pack → Span<T>.slice() answers default
|
f(nilSlice...) — spread of nil |
nil |
ꓸꓸꓸ/ToSpan of a null backing → same path |
f([]T{}...), f(x[:0]...) — spread of non-nil empty |
non-nil empty | span carries a real reference → the copy path |
The last three rows close what this section used to record as a known adjacent gap: a
zero-argument variadic call materialized a non-nil empty where Go passes nil, because the pack’s
Span<T> was copied through ReadOnlySpan<T>.ToArray(), whose Array.Empty<T>() is a real (hence
non-nil) backing array. Nil-ness crosses the C# params boundary intact once you read the right
property: a span’s data reference is null exactly when Go’s slice header’s data pointer is nil,
which is the same invariant slice<T> already keeps one level up (m_array is null ⟺ nil).
SliceExtensions.slice<T>(this Span<T>, …) now answers default for an empty pack whose reference
is null and takes the copy path otherwise, which separates all three variadic shapes above. Roslyn’s
choice of default(Span<T>) for an empty pack is not language-guaranteed, but the degradation if it
ever changes is to the previous behavior (a real reference, hence non-nil), never to a nil where Go
has storage; the spread half depends on golib’s own ToSpan, not on the compiler. One residual, from
ToSpan rather than from the rule: a zero-size element type ([]struct{}) spans as Span<T>.Empty
when empty, so an empty non-nil slice of a zero-size type reads as nil across a spread.
(Guarded by the SliceNilVsEmpty behavioral test — every row of the table probed with s == nil,
len, and cap against go run; resliceTailCapZero discriminates the operator fix, nilReslice
the Reslice fix, appendNilNothing the Append fix, and packZeroArgs/spreadNil versus
spreadEmptyLiteral/spreadMakeZero/spreadResliceTailCapZero the variadic rows, with
variadicDerived checking that a pack’s nil-ness survives the reslice, no-op append, and
re-spread a callee typically performs before observing it. NilSliceConversion continues to guard
the []T(nil) conversion row.)
A slice of a ZERO-SIZE element type carries no storage — make([]struct{}, math.MaxInt) allocates nothing
Go’s struct{} has size 0, so mallocgc(0, …) returns the address of the runtime’s global
zerobase and charges no malloc: makeslice multiplies the element size by the capacity and compares
that against maxAlloc, which for a zero-size element is 0 whatever the length. make([]struct{}, n)
therefore succeeds for every n up to math.MaxInt, and Go’s own library leans on it — slices.Concat,
slices.Repeat and their tests use []struct{} precisely BECAUSE it exercises the length arithmetic at
MaxInt without touching memory.
golib allocated a real backing array for the same expression and panicked makeslice: len out of range
at Array.MaxLength — a ceiling Go does not have here, because Go has nothing to allocate. Both
slices.TestRepeat and slices.TestConcat_too_large died on their OWN make([]struct{}, MaxInt) before
reaching the function under test.
The predicate is a SHAPE question, not a size question. Unsafe.SizeOf<T>() cannot answer it: C#
gives an empty struct one byte, so every zero-size Go type measures 1. What survives the conversion
faithfully is the FIELD SET — a Go struct is zero-size exactly when it has no fields of nonzero size, and
the emitted C# struct carries the same fields — so GoZeroSizeFacts<T> asks that instead, recursively,
with “no instance fields at all” as the base case (golib’s EmptyStruct for an anonymous struct{}, and
every [GoType] partial struct noCopy { } the converter emits for a named one). The answer is a
static readonly per closed T, so every gate written against it folds at JIT time and no ordinary
element type pays for the branch.
Under it, a zero-size slice’s m_array is a non-null placeholder — one shared single-element array,
golib’s zerobase — so s == nil keeps the representation-nilness rule above (a maked one is never
nil, the zero header still is), while every window bound is checked against m_length/m_capacity
alone. Each operation Go answers from length arithmetic does the same here: make skips the allocation
and the Array.MaxLength ceiling (Go’s len < 0 rule stays), Reslice rebuilds the header directly,
append bumps the length and applies the identical growth rule, copy returns min(len(dst), len(src))
and moves nothing (Go’s memmove of n * 0 bytes), clear is complete before it starts, indexing
answers the ONE shared element (Go computes &s[i] as data + i*0, so every index names the same
address), and range is a counted loop.
One honest ceiling remained, and the slice-shaped-spread arc retired it (2026-08-27, ruled
post-B2): Span<T>.Length is int32 while a Go slice length is int, and a VARIADIC SPREAD used
to cross exactly that boundary — append(dst, src...) emitted append(dst, src.ꓸꓸꓸ), and ꓸꓸꓸ is a
Span<T>, so slices.Grow’s append(s[:cap(s)], make([]E, n)...) could not reach n == MaxInt.
The priced remedy landed as priced: a slice-typed spread operand now travels AS THE SLICE IT IS —
the emission routes it to golib’s ISlice<T>-taking forms, whose name carries the spread the way the
operand property did:
hello.cipherSuites = append(hello.cipherSuites, defaultCipherSuitesTLS13...)
s2 := append(s[:i], v...) // in a generic body over S ~[]E
hello.Value.cipherSuites = appendꓸꓸꓸ((~hello).cipherSuites, defaultCipherSuitesTLS13);
var s2 = appendꓸꓸꓸ<S, E>(subslice<S, E>(s, 0, i), v);
Inside golib the window’s own span still serves every copy a span can express (managed backings are
int-bounded by T[], so allocation counts and in-place/grow behavior are byte-identical to the span
core), zero-size windows route by pure length arithmetic BEFORE any span form (which is what lets
TestConcat_too_large’s make([]struct{}, math.MaxInt) fakes flow through Concat’s Grow chain
allocation-free, with the growth rule’s even-rounding guarded against wrapping past MaxInt), and a
named-slice wrapper arriving through the boxed interface lends its own ꓸꓸꓸ projection — one
interface call, inside the core. Two shapes are deliberate: the constrained form takes BOTH type
arguments explicitly (appendꓸꓸꓸ<S, E>), because a constraint surface does not participate in C#
inference; and the name is DISTINCT from append by design — sharing the overload set re-entered
the C#14 params/betterness thicket (measured CS0121 against the constrained span twin), and the
converter alone mints these calls. Census at the landing: 401 corpus sites across 151 files routed;
string spreads keep the span route (their spread is a byte projection, not a slice). Emission:
convCallExpr’s append classification (spreadArgAsSlice/appendTypeArgs) + convExprList’s
spread arm; golib: builtin.appendꓸꓸꓸ ×2 over slice<T>.Append(in slice<T>, ISlice<T>). The row
this unlocked: slices validates 119 matched · 3 disclosed the moment the arc lands.
Known and deliberate divergence: Go’s OTHER zero-size shape is the zero-length array ([0]T, and
[N]struct{}). go2cs emits a Go array as array<T>, whose backing is a managed reference field, so such
a type classifies as NON-zero-size and keeps the allocating path — the honest answer for the
representation as it stands, since claiming zero-size for a type whose C# shape genuinely carries a
reference would put a wrong element ref in front of every consumer.
(Guarded by GolibTests.ZeroSizeSliceSemanticsTests, which pins every operation at MaxInt scale plus
the positive controls — a negative make length still panics, and an ordinary element type keeps its
allocation ceiling — because a fix that got any ONE operation wrong would still let a package row go
green while leaving the storage-free path unsound for the next consumer.)
Named slice/map/channel wrappers (defined types). The distinction extends to DEFINED types
(type S []int, generated as go2cs-gen InheritedTypeTemplate wrappers). The comparison Go permits on
a named slice/map/channel is x == nil; the converter renders it as s == default! (nil literal in
value context) or s == nil (pointer context), and — verified against the emitted C# — both bind the
wrapper’s operator ==(S, NilType) overload, not the same-type operator ==(S, S) (reverting the
latter has no observable effect; reverting the former flips the result). That overload emitted
value.Equals(default(S)), and the slice wrapper’s Equals (from ISliceTypeTemplate’s
Equals(ISlice<T>?)) is structural content equality, so an empty non-nil named slice (S{},
make(S, 0), an s[len(s):] tail) was misclassified as nil. It now delegates to slice<T>’s own
== NilType — REPRESENTATION nilness (null backing array, R13) — via value.m_value == nil, so
IntSlice{} == nil is false while the zero value stays nil. Audit of the other nil-comparable kinds:
map and channel wrappers were already correct and are unchanged — they declare no structural
Equals, so Equals(default) falls back to reference identity through the backing field
(map<K,V>.Equals is ReferenceEquals(m_map, …); channel<T> compares its queue by reference).
Array/numeric/string/struct/any wrappers are not nil-comparable in Go (and the pointer wrapper
already uses a reference-identity Equals override), so they keep the structural default; the same-type
operator ==(S, S) is likewise left untouched (Go forbids comparing two slices, so no converted code
reaches it). Because this is a compile-time (go2cs-gen) change it leaves TRANSPILER output — the
.cs.target goldens — byte-identical, so it is gated on the FULL behavioral suite (four phases)
plus a full corpus build rather than CNR alone.
Separately, NilType’s operator ==(ISlice?, NilType) dropped its historical
{ Length: 0, Capacity: 0, Source: null } arm: slice<T>.Source (and every wrapper’s
IArray.Source) materializes a DETACHED copy (ToSpan().ToArray()) and is never null, so the
property pattern could never match — the expression already reduced to slice is null
(representation nilness), which is what it now states plainly. No converted s == nil routes through
this interface operator: a concrete slice<T> binds its own == NilType, and interface/any
comparands bind NilType’s object arm.
(The named-wrapper rows are guarded by the NamedSliceNilVsEmpty behavioral test — named
slice/map/channel zero value (nil) vs empty literal (non-nil), plus the slice resliceTailCapZero and
nilReslice discriminators, output-compared vs go run; it fails if the wrapper’s == nil regresses
to structural equality.)
A composite literal omitting a fixed-array field keeps the zeroed backing
A Go struct’s fixed-array field is emitted with a field initializer carrying its Go length
(badCharSkip [256]int → internal array<nint> badCharSkip = new(256);), and C# runs field
initializers in every explicitly declared constructor — but the generated parameterized
constructor then assigned every member from its argument, and an argument the composite literal
OMITS arrives as the zero value default!, whose backing T[] is null. The assignment nulled the
initializer’s backing, so the field’s first walk NREd (strings’ Boyer-Moore
stringFinder{pattern: …, goodSuffixSkip: …} never sets badCharSkip — the
TestFinderCreation/TestFinderNext operational blocker, Phase-4 row R8). The generated
constructor now guards exactly the fixed-array members:
if (badCharSkip.Source is not null) this.badCharSkip = badCharSkip;
array<T>.Source intentionally returns the RAW backing reference (null discriminates a
never-constructed zero value), and keeping the initializer for a zero-value argument is precisely
Go’s semantics — the zero [N]T IS the zeroed backing the initializer produced. A constructed
argument assigns as before (see the copy-semantics gaps above for the literal-argument clone).
Separately, golib array<T> reads are now null-safe: a bare default(array<T>) (a zero value
no constructor ever touched) enumerates/compares/prints as an EMPTY array and panics Go-style on
any index, instead of throwing NRE — mirroring @string’s null-safe zero value. The empty view is
a disclosed approximation: the declared length only exists where a constructor or initializer ran,
so a holder z = default!; zero-var local still reads its array field at length 0, not N (a known
converter gap, chipped separately; the make([]S, n) half of it is now closed — see make([]E, n)
constructs its ELEMENTS by the same rule below). (Guarded by the
ZeroValueArrayField behavioral test — the literal-omission shape ranged/indexed/printed vs Go,
plus an explicit-argument control.)
A fixed-array composite literal carries its DECLARED length (.array(N))
A [N]T{…} literal is N long however many elements it writes — Go zero-fills the rest, so
[8]byte{} is eight zero bytes and [8]byte{1, 2} is 1, 2 followed by six zeros. The literal
renders as a C# element array projected through golib’s .array() extension, and that element
array holds only the elements actually written, so the projection produced an array as long as the
LITERAL rather than as long as the TYPE. [8]byte{} became length 0: it compiled cleanly and
then panicked on first use (index out of range [7] with length 0 — math/rand/v2 chacha8’s
Seed, whose [8]byte{} never held a byte). The projection now takes the declared length:
a := [8]byte{} // eight zero bytes
b := [8]byte{1, 2} // 1, 2, then six zeros
c := [3]byte{1, 2, 3} // already full
var a = new byte[]{}.array(8);
var b = new byte[]{1, 2}.array(8);
var c = new byte[]{1, 2, 3}.array(); // full literal keeps the plain projection
Only a short literal takes the length argument. A full literal — and every [...]T{…}
ellipsis literal, whose length is its element count — already yields the right length and keeps
the plain .array() form, so existing goldens for those are unchanged. A slice literal is
genuinely as long as its elements ([]byte{} IS empty) and never pads; its .slice() projection
is untouched. golib’s array<T>(T[] source, int length) constructor does the zero-filled copy,
which is deliberately distinct from the array(slice<T>, nint) slice-to-array conversion ctor
(there a short source is a Go panic; here it is the normal case).
The same dropped length reached the indexed/keyed form by a second route. A keyed literal
whose indices all fold to constants renders as new array<T>(N){[i] = v}, which was already
correct — but the scan used 0 as its “no constant keys” sentinel, so a literal whose only key
is 0 ([8]byte{0: 9}) read as unresolved and fell to the SparseArray projection, whose extent
is max index + 1, not N. Constant-key detection is now tracked separately from the maximum
index, and the SparseArray projection — still used for a key that is constant but not a literal
(a const identifier), which SparseArrayIfaceElem’s [kLast]shape registry exercises — also
carries the declared length. (Guarded by the ArrayLiteralDeclaredLength behavioral test: empty,
partial, full, ellipsis, keyed, zero-keyed, named, aliased, package-level, non-byte element types,
a tail write proving the backing is really N long, and a []byte{} slice control, output-compared
vs go run; the pre-fix converter exits with the index-out-of-range panic.)
…and the padding itself needs the element factory when the ELEMENT’s zero value must be
constructed (closed 2026-08-26). The length argument sizes the OUTER dimension and fills it with
default(T), which is not usable storage for an unnamed nested fixed array ([2][3]uint8 emits
array<array<uint8>>, and the inner length lives only in the Go type) or for a struct whose
fixed-array field initializer runs only inside a declared constructor. So [2][3]uint8{} came out
2 long with two zero-length elements — len(x[0]) reported 0 where Go says 3, and the first
indexed write into one panicked — while the DECLARED form var x [2][3]uint8 was correct all along,
because it routes through the zero-value construction ladder instead. Two spellings of one Go type
disagreeing is how it surfaced: through reflect, where a constructed ArrayOf(2, TypeOf([3]uint8{}))
compared unequal to the literal-built value and TypeOf(lit).Elem().Len() answered 0.
The padding now carries arrayZeroValueArgs’ element factory — that same ladder’s own renderer,
reused rather than restated, so the literal and the declaration cannot drift apart. It recurses, so
depth composes:
n := [2][3]uint8{}
d := [2][3][4]uint8{}
c := [2]cell{} // cell has a [4]uint8 field
m := [2]named{} // type named [6]byte
var n = new array<uint8>[]{}.array(2, () => new(3));
var d = new array<array<uint8>>[]{}.array(2, () => new(3, () => new(4)));
var c = new cell[]{}.array(2, () => new());
var m = new named[]{}.array(2); // NAMED element: its wrapper allocates its own backing
All three routes to the padding carry it: the positional projection above (golib’s new
array<T>(T[], int, Func<T>) extension), the constant-keyed indexed form
(new array<array<uint8>>(4, () => new(3)){[1] = …}, through array<T>’s existing
(nint, Func<T>) constructor), and the SparseArray projection for a key that is constant but not
a literal. The sparse one is not simply the first with a different receiver: a sparse literal’s zero
values are its gaps, which can sit anywhere rather than only in a tail, and enumerating a
SparseArray renders a gap as default! — indistinguishable afterwards from an element the literal
genuinely wrote. So that overload asks the sparse array which indices were SET
(SparseArray<T>.TryGetItem) and constructs the rest. An element whose default(T) is already the
Go zero value — every scalar, and every NAMED array element, whose generated wrapper allocates its
backing lazily from its own known size — renders the bare length exactly as before, so no existing
golden moves. (Guarded by the same ArrayLiteralDeclaredLength test, extended with empty/partial/full
nested, three-deep, a needy-struct element, a named-element counter-case, package-level, and both
indexed forms; failing-first measured as nested empty 2 0 0 against Go’s 2 3 3, followed by the
panic, and keyed nested 4 0 3 0 3 against Go’s 4 3 3 3 3.)
An array or slice literal may MIX positional and keyed elements
Go’s “all elements keyed, or none” rule is a struct-literal rule. An array or slice literal may mix the two freely, and the positional elements take the indices Go computes for them: the first element is index 0, a keyed element sets the index to its (constant) key, and each following positional element continues from there. So the literal below is sixteen bytes long, not three:
ip := []byte{0xfe, 0x80, 15: 0x01} // 0: 0xfe, 1: 0x80, 15: 0x01 — length 16
a := [8]int{1, 2, 5: 9, 10} // 0: 1, 1: 2, 5: 9, 6: 10 — length 8, its declared one
The converter’s keyed-literal detection read Elts[0] alone (its own comment cited the struct rule
as the justification), so a mixed literal took the plain positional emission while its keyed
elements still rendered through the key/value arm — whose sparse form wants an assignment target
that does not exist in an expression position:
// before — CS1525, invalid expression term '<'
new byte[]{0xfe, 0x80, <nil>[15] = 0x01}.slice()
A mixed literal is now normalized to an all-keyed one carrying Go’s own indices, which lets the existing sparse-array machinery render it unchanged — and recovers the LENGTH, which is the part a wrong emission gets silently wrong rather than loudly:
new slice<byte>(16){[0] = 0xfe, [1] = 0x80, [15] = 0x01}
new array<nint>(8){[0] = 1, [1] = 2, [5] = 9, [6] = 10}
An all-positional or already-all-keyed literal is untouched by construction, so the corpus is
byte-identical across the change; a literal whose keys do not fold to constants is left exactly as
it was, because an index the converter cannot compute is one it must not invent. Guarded by
mixedKeyedComposite_test.go, which converts both mixed forms plus the two unmixed controls.
(Found by the Phase-4 measurement of net/netip, whose TestAddrFromSlice/TestAsSlice write
IPv4-in-IPv6 addresses this way — zero production sites in the converted standard library, which is
why a shape this ordinary survived to be found by a test conversion.)
A fixed-size array constructs its ELEMENTS when default(T) is not usable storage
new array<T>(N) fills its backing with default(T), which is the correct Go zero value only when
default(T) is itself well formed. For a NESTED fixed array it is not: [2][4]byte emits
array<array<byte>>, and the inner length 4 lives only in the Go type — array<T> has nowhere to
carry it, so golib cannot recover it from T. Every element kept a null backing, so len(x[1])
reported 0 where Go says 4, and the first indexed write panicked (index out of range [2] with
length 0) — a silent-correctness defect that compiled clean. The same held for an element whose own
zero value needs construction: default(T) skips the generated constructor that runs a struct’s
fixed-array field initializers and allocates its embed boxes.
Only the converter knows the element’s shape, so it supplies an element factory to a golib
array(int length, Func<T> elementFactory) constructor:
var x [2][4]byte // len(x), len(x[1]) => 2 4
var deep [2][3][4]byte
var se [2]inner // type inner struct { b [3]byte }
array<array<byte>> x = new(2, () => new(4));
array<array<array<byte>>> deep = new(2, () => new(3, () => new(4)));
array<inner> se = new(2, () => new());
The factory nests to any depth, and each element gets its OWN storage rather than one shared inner
array. It is emitted from every fixed-array zero-value site — local var, package-level var
(including the addressed-global ж<> box form), the type-ALIAS-to-array spelling, a struct’s
field initializer (internal array<array<nint>> entries = new(2, () => new(3));), the
heap-boxed (address-taken) local, and the new([N]T) builtin:
var leafCounts [maxBitsLimit][maxBitsLimit]int32 // addressed: copy(leafCounts[i][:i], …)
f.bits = new([maxNumLit + maxNumDist]int)
ref var leafCounts = ref heap(new array<array<int32>>(16, () => new(16)), out var ᏑleafCounts);
f.bits = Ꮡ(new array<nint>(316));
Those last two were the same silent-correctness defect one layer down. The heap-boxed declaration is
a THIRD emission path (convertToHeapTypeDecl, a string path that never consulted
arrayZeroValueArgs), and it is exactly the shape compress/flate’s Huffman coder uses —
bitCounts’ leafCounts [16][16]int32 is boxed because copy(leafCounts[i][:i], …) slices an
element, and its first leafCounts[level][level] = 2 panicked with index out of range [1] with
length 0, taking compress/gzip and compress/zlib down with it. new([N]T) is a FOURTH: golib’s
@new<T>() builds the zero value through the parameterless constructor, where array<T>() has no
length at all, so f.bits came back length 0 (Go: 316). A NAMED array type keeps the zero-value
@new<row>() form for the same reason a named element needs no factory.
A NAMED array element needs no factory and is deliberately left alone: type row [4]byte generates
a wrapper that allocates its backing lazily from its own known size (go2cs-gen’s
m_value ??= new row(4)), so array<row> nr = new(2); is already correct. Elements whose
default(T) is a valid zero value (scalars, pointers, slices, maps) likewise keep the bare
new(N), which keeps the A/B footprint to genuinely nested shapes.
This mirrors go2cs-gen’s AppendZeroValueInitializers/NeedsConstruction, which does the same for
struct FIELDS, and narrows the zero-value gap disclosed above — a default! zero-var local still
reads an array field at length 0. (Guarded by the NestedFixedArrays behavioral test: inner len,
writes read back through inner arrays, per-element storage independence, three-level nesting,
struct/named-array elements, and the global paths, all compared against go run.)
make([]E, n) constructs its ELEMENTS by the same rule
slice<T>’s length constructor fills its backing with default(T) exactly as array<T>’s does, so
the identical silent-correctness defect reached make. make([][hashSize]int, n) emitted
new slice<array<nint>>(n) and produced n zero-length arrays, because the inner length lives
only in the Go type — so hash/maphash’s avalancheTest1 panicked on its first g[j] += …
(index out of range [0] with length 0), and image/draw’s Floyd-Steinberg quantErrorCurr/Next
rows and x/text/transform’s chain buffers carried the same latent defect unexercised.
make now threads the same arrayElemFactory the fixed-array zero-value sites use into a golib
slice(nint length, Func<T> elementFactory, nint capacity = -1, nint low = 0) constructor — one
rule, one predicate, both containers:
grid := make([][hashSize]int, n) // hash/maphash smhasher_test.go
q := make([][4]int32, r.Dx()+2, cap) // image/draw
var grid = new slice<array<nint>>(n, () => new(64));
var q = new slice<array<int32>>(r.Dx() + 2, () => new(4), cap);
The factory fills the whole backing, not just the first length elements: Go zeroes the entire
allocation, so the capacity beyond the length is already valid storage once a re-slice or append
exposes it. As with array<T>, a NAMED array element (type row [4]byte) and every element whose
default(T) is a valid zero value keep the plain length constructor, so the A/B footprint stays on
genuinely nested shapes. The composite-literal path ([][4]int{{…}}) is still open, chipped
separately with the array-literal case above. (Guarded by NestedFixedArrays, extended with the
make length and length+capacity forms, a struct element needing construction, and a named-array
element control, output-compared vs go run.)
A DEFINED slice type routes the factory through its underlying slice<E> (2026-07-27). make’s
target is not always slice<E>: for type SortedMap []KeyValue the target is the go2cs-gen wrapper,
which declares SortedMap(nint length, nint capacity = -1, nint low = 0) and no element-factory
overload, so the lambda bound to nint — CS1660: Cannot convert lambda expression to type 'nint'
at internal/fmtsort’s make(SortedMap, 0, n). The factory-filled backing is therefore built as the
underlying slice<E> — bit-for-bit the value the unnamed form produces — and handed to the wrapper’s
T(slice<E> value) constructor, which the generator always emits:
sorted := make(SortedMap, 0, n) // internal/fmtsort sort.go
var sorted = new SortedMap(new slice<KeyValue>(0, () => new(), n));
This was a live defect, not a latent one, and its blast radius is worth recording: -tests
regenerates production .cs on every run, so once the run for one banked package left a broken
sort.cs on disk, every later package downstream of fmt failed to build too — a validated sweep
read as 41 pass / 20 fail from this single root. It is the fourth instance of the zero-value-
construction class in a fourth emission path, which is the standing argument for centralizing that
construction rather than patching sites.
clear rebuilds each element through golib’s GoZero — the RUN-TIME half of zero-value construction
The fifth instance of that class landed in golib rather than the converter, and it is the one that
made the centralization real. builtin.clear(slice<T>) assigned default! to every element, so
clear(q) over a [][4]int32 replaced each element with a length-zero array — Go’s
clear leaves four zeroed int32s. image/draw’s Floyd–Steinberg dither calls
clear(quantErrorNext) once per scan line, and the next row’s quantErrorNext[x][0] panicked with
index out of range [0] with length 0. The same held one level in for a struct element carrying a
fixed-array field: default! skips the generated parameterless constructor that runs its field
initializers.
The converter cannot help here — a clear call site has no Go type shape to thread a factory
through, and the C# type array<E> is the same for every N. Patching the site would have been the
fifth per-site repair of one defect, so the shape recovery moved into golib and became a single
entry point:
public static T GoZero<T>(T template) // builtin.cs
GoZero returns the Go zero value of T, consulting template only for run-time shape, and
resolves one of three answers from a per-closed-T static (ZeroFacts<T>, the same JIT-folding
shape as AssertFacts<T>), so the overwhelmingly common case compiles to a constant default:
T |
zero |
|---|---|
any reference type, or a value type golib owns (@string, slice<T>, map<K,V>, the numerics) |
default |
array<E> — implements the new golib marker IGoZeroShaped
|
GoZeroLike(): a new array of the template’s LENGTH, elements zeroed recursively so [2][3]int32 keeps its inner lengths |
a converted Go struct ([GoType] + a generated parameterless constructor) |
that constructor — exactly what the converter emits for var x T, and default for a plain struct |
All three slice-shaped clear overloads (slice<T>, Span<T>, and the constrained ISlice<T>)
route through one Span<T> body that keeps the vectorized Span.Clear() whenever
default(T) is already the Go zero value:
q := make([][4]int32, 3)
q[1][2] = 7
clear(q)
len(q[1]) // 4, not 0
The [GoType]-plus-constructor rule is deliberately broad rather than a per-shape enumeration
(fixed-array field, promoted embed, …): calling a converted struct’s own zero-value constructor is
always correct, so a FUTURE field shape that needs construction is covered without re-opening the
class a sixth time. That generality is the whole point — this is the run-time counterpart of the
converter’s arrayZeroValueArgs and go2cs-gen’s AppendZeroValueInitializers, which build a zero
value where the shape is known statically; GoZero recovers it from a value that already carries
it, which is what a built-in is handed. (Guarded by the ClearBuiltinShadow behavioral test,
extended with clear over an array-element slice, a struct-with-array-field slice, and an
array-of-arrays element, each written to after the clear and output-compared vs go run.)
A map READ of a shape-carrying element supplies the zero from the CALL SITE
The sixth instance of the zero-value-construction class, and the last emission path that had no seat
for it. Go’s read of an ABSENT key — every read of a nil map included — yields the element type’s
zero value, and for [N]T that zero is N zeroed elements. golib’s map<TKey, TValue> indexer
answered default(TValue), and default(array<T>) has length zero, so the first index into a
missed entry panicked index out of range [0] with length 0 where Go reads a zero:
// html/escape.go — entity2 is map[string][2]rune
if x := entity2[string(entityName)]; x[0] != 0 {
That is not an edge path. A miss is the NORMAL outcome for any &… run that is not a two-rune
entity, so html’s TestUnescape died on ordinary input (the package measured 2 of 3).
The shape cannot come from the map. It is a property of the Go map TYPE, and neither
map<TKey, TValue> nor a default (nil) one carries it — reading it off an existing entry would
answer only for a POPULATED map and guess for an empty or nil one. The READ SITE always knows it
statically, so the same ladder every declaration site uses (zeroValueInitializer /
arrayZeroValueArgs) is threaded into a golib indexer overload that invokes the factory only on a
miss; the emitted lambda is non-capturing, so it is cached and a HIT costs nothing:
x := entity2["notthere"] // len 2, both runes 0
n := nested["zzz"] // map[string][2][3]int — inner lengths survive too
z, ok := entity2["alsomissing"] // comma-ok form
v := nilMap[7] // quadMap is map[int][4]byte, nil — len 4
var x = entity2[notthereˢ, () => new array<rune>(2)].Clone();
var n = nested[zzzˢ, () => new array<array<nint>>(2, () => new(3))].Clone();
var (z, ok) = entity2[alsomissingˢ, () => new array<rune>(2), ꟷ];
var v = nilMap[7, () => new array<byte>(4)].Clone();
All three map surfaces answer it, so this is one rule rather than three: map<TKey, TValue> declares
the two overloads, go2cs-gen’s IMapTypeTemplate forwards them for a NAMED map type, and
IMap<TKey, TValue> carries them as default members for a map-cored type parameter. Two exclusions
keep the emission unchanged where it is already right — a NAMED array element (its wrapper allocates
its backing lazily from its own known size, the same exclusion arrayElemFactory documents), and an
assignment TARGET, which carries a value and needs no zero. The A/B footprint over the whole
converted standard library is one line, html/escape.cs:153 — the crash site itself.
(Guarded by the MapArrayValueZero behavioral test: plain and comma-ok reads, hit and miss, a
nested element, a named map type read both nil and empty, an unnamed map read both nil and empty,
and a store-then-read control, all output-compared vs go run. Failing-first proof: transpiled with
the pre-fix converter the same program panics index out of range [0] with length 0 at
array.cs:284 — the html signature exactly.)
A named slice wrapper’s non-generic ISlice.Append is an EXPLICIT implementation
The generated wrapper for type S []E implements both halves of the golib slice surface, and both
declare an Append: the typed ISlice<E>.Append(E[]) and the non-generic ISlice.Append(object[]).
ISliceTypeTemplate emitted both public, which is fine for every E except one — with
E = any, object[] and E[] are the SAME parameter list, so the wrapper carried two public
methods differing only in return type: CS0111. That is a single duplicate-member emission, and
it held two whole converted test suites, fmt’s type SE []any (63 verdicts) and archive/tar’s
type fileOps []any (97) — a []any named slice is a table-driven-test idiom, which is why the
production corpus never met it.
The non-generic overload is now explicit —
ISlice? ISlice.Append(object[] elems) => ((ISlice)m_value).Append(elems);
— which is what golib’s own slice<T> has always declared (ISlice ISlice.Append(object[] elems)
beside ISlice<T> ISlice<T>.Append(params T[] elems)), so the wrapper now matches the type it
wraps. The reasoning is the same one the template already applies to GetEnumerator in the
subsection above: this is the boxing, interface-typed path, taken only when a consumer asks for the
interface, and the public surface is the typed overload. Converted code never calls it by name —
Go’s append emits golib’s append builtin, which reaches slice<T>.Append statically.
Measured after: both suites clear this root and stop on unrelated ones — fmt on five
(CS1955 map used as a method, CS0030 on renamed complex types, CS1729/CS0103/CS0034 around
Scan_type), archive/tar on the duplicate global using alias its board row records as closed
and which is in fact still live. Neither banks. Guarded by the NamedAnySliceType behavioral test —
both suites’ declarations verbatim, spread into a variadic ...any, appended to, indexed, ranged,
sub-sliced, spread into a second named []any, and compared against nil, output-compared vs
go run.
Strings (@string and sstring)
Go’s string is represented by golib @string, not System.String. That is a semantic decision, not just a naming one: Go strings are immutable byte sequences, so len, indexing, ranging, concatenation, conversion to []byte/[]rune, equality, and type assertions must all observe Go’s UTF-8/byte model rather than C#’s UTF-16 string model. A zero-value @string is also null-safe and reads as "", which lets default! stand in for Go’s zero value without sprinkling null checks through converted code.
Plain Go string literals usually render as C# UTF-8 literals ("..."u8, a ReadOnlySpan<byte>) and are target-typed only at the boundary that needs an actual Go string. That gives allocation-free fast paths such as []byte("hi") -> slice<byte>("hi"u8), @string s = "hi"u8, and comparisons against sstring views. When the literal’s bytes cannot be represented faithfully as source UTF-8 – notably high \xHH escapes and greedy hex-escape runs – the converter emits a byte-array-backed @string instead, so byte indexing and len stay Go-correct.
Named string types are generated as real [GoType("@string")] wrappers. The generator supplies the Go string surface directly on the wrapper – byte indexers, range/sub-slice behavior, Length for len, ReadOnlySpan<byte> bridging for u8 literals, comparisons, and conversions through the underlying @string – so code that declares type Token string keeps distinct-type behavior while still reading like a string in method bodies.
The heap @string form is always the correctness fallback for string([]byte): it copies bytes into an immutable string, matching Go when the value escapes or the source buffer can later mutate. The performance fast path is golib sstring, a stack-only readonly ref struct view over a ReadOnlySpan<byte>. A local or expression-level string([]byte) conversion may emit sstring only when the converter can prove the view is read-only, non-escaping, and not observed after a source mutation; if that proof is too weak, the conversion stays @string. Because sstring is a ref struct, most missed escape cases are C# compile errors rather than silent aliasing bugs.
A built-in used as a generic type argument is rendered in its golib form, the same as anywhere else — in particular Go string becomes golib @string, never C# string (System.String). This matters because the converter adds a new() constraint to every generic type parameter: @string is a struct with a public parameterless constructor and satisfies it, whereas System.String would violate it (CS0310), and assigning a string literal — emitted as a u8 ReadOnlySpan<byte> — into such a field would fail (CS0029). So:
type Pair[A any, B any] struct { a A; b B }
var p Pair[int, string]
p.b = "hi"
Pair<nint, @string> p = default!;
p.b = "hi"u8;
This applies uniformly to every type-argument position — first, second-or-later, and nested (Pair[int, Box[string]] → Pair<nint, Box<@string>>). (The behavioral test GenericStringTypeArg guards these cases; NestedGenericTypes covers the nesting depth without string args.)
Slicing a string is a WINDOW, not a copy — @string carries an offset and a length
A Go string header is a pointer plus length into shared immutable storage, which is what makes s[i:j] an O(1) operation that allocates nothing. @string originally held a bare byte[], so its range indexer had to materialize the sub-string’s bytes: s[i:] was O(n) with an allocation. That is invisible in the small and quadratic in the ordinary Go idiom for walking a string by runes —
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
i += size
}
— which is exactly what archive/zip’s detectUTF8 does over every file name and comment. At the 65,535-byte names its TestZip64LargeDirectory builds, each call copied ~2.1 GB; the test takes 13.2 s in Go and had not finished in 45 minutes in C#.
@string therefore carries the Go header’s shape — a backing array plus an offset and a length — and its range indexer returns a window over the same array. Slicing a string now allocates nothing and copies nothing, matching Go’s cost model. The same test completes in 20.2 s against Go’s 11.3 s, and archive/zip validates 98/98.
Sharing the backing array is safe for precisely the reason it is safe in Go: @string is immutable, and every conversion out to storage the receiver may mutate — []byte(s), the byte[] operator — already copies (see the next section, and the unicode/utf8 TestDecodeRune corruption that pinned those copies down). Two consequences worth carrying:
- The backing array is private to
@string. A consumer reading it directly instead of reading the window would silently see the whole backing rather than the string, so privacy makes that a compile error rather than a wrong answer — which is how the remaining raw-array readers (sstring’s mixed comparison/concat operators,builtin.slice(@string,…),ByteSeqExtensions.ToGoString) were found and corrected. Bounds that were measured against the backing array are now measured against the window (@string.SliceBounds) — the same correctionarray<T>.slicealready carried for an alias window. -
unsafe.StringDatapins a window that does not begin at the backing array’s start by materializing its bytes first, since aGCHandlepins an object from its start. Whole-backing strings — the overwhelming majority, and every string that reached there before windows existed — pin in place unchanged.
Converting a string to []byte / []rune
A Go []byte(s) / []rune(s) element-decoding conversion is emitted as the golib element-slice form slice<byte>(…) / slice<rune>(…), which relies on the @string→slice<byte>/slice<rune> conversion. When the source is a string variable it is already golib @string, so the conversion applies directly. When the source is a bare string literal, that literal would otherwise render as a System.String (no such conversion exists — CS1503/CS1929), so the converter casts it to @string first:
bs := []byte("hello")
rs := []rune("héllo")
var bs = slice<byte>((@string)"hello");
var rs = slice<rune>((@string)"héllo");
The @string cast fires only on a string-literal argument; a string-variable conversion ([]byte(s)) needs no cast. (Guarded by the behavioral test StringLiteralSliceConversion.)
The cast reaches a top-level literal argument only, which left Go’s line-splitting literal idiom — one constant string written as several +-joined pieces so it fits the source width — rendering as a bare C# string concatenation. C# will not chain the two user-defined conversions string → @string → byte[] that golib’s slice<T>(T[]) would need, so crypto/hmac’s long-key vectors failed CS1503: cannot convert from 'string' to 'byte[]'. A []byte/[]rune conversion whose argument is a constant-valued binary expression therefore casts the rendered operand as a whole, which keeps the source’s split verbatim (C# constant-folds the concatenation itself, so preserving it costs nothing at run time):
key := []byte("This is a test using a larger than block-size key " +
"and a larger than block-size data. The key needs to " +
"be hashed before being used by the HMAC algorithm.")
var key = slice<byte>((@string)("This is a test using a larger than block-size key " + "and a larger than block-size data. The key needs to " + "be hashed before being used by the HMAC algorithm."));
The gate is deliberately narrow — a + chain whose every leaf is a string literal that renders plainly — so it fires on exactly the shape that produces a bare C# string and nothing else. A non-constant concatenation (s + "x") already yields an @string through its variable operand; an ident or selector naming a string constant emits its declared symbol; and a raw-byte (\xHH) leaf takes convBasicLit’s byte-ARRAY route, which yields an @string that carries the whole chain with it (slice<byte>("" + ((@string)(new byte[]{0xff, 0x80}))) — the ByteTableStringVar case, byte-identical across this change). (Guarded by StringLiteralSliceConversion, which now also covers the split []byte/[]rune idiom and a raw-literal piece in the chain.)
string([]rune) encodes an INVALID rune as U+FFFD, never fails
Go’s rune-to-string conversions replace every invalid rune — a surrogate (0xD800–0xDFFF) or an
out-of-range value (< 0 or > 0x10FFFF) — with U+FFFD (utf8.RuneError, bytes EF BF BD),
one replacement per invalid element: string([]rune{0xD800}) is "�" (probed vs go run).
Every golib rune-span encoding routes through one seam, builtin.ToUTF8Bytes (the @string
rune-span constructor, the slice<rune>/single-rune→@string operators, and rune append all
land there), which used the element conversion int → System.Text.Rune — and that conversion
THROWS ArgumentOutOfRangeException for exactly Go’s invalid values, killing the host instead of
producing the replacement bytes (strings’ TestCaseConsistency builds a string of every rune
0..MaxRune, surrogates included — Phase-4 row R7). The encoder now uses
Rune.TryCreate(value, out codePoint) and substitutes Rune.ReplacementChar on failure; the
4-bytes-per-rune buffer estimate still covers the 3-byte replacement. (Guarded by the
InvalidRuneString behavioral test — slice and single-rune conversions over runtime values, byte
values and lengths compared vs Go; runtime values keep both compilers from constant-folding the
conversions.)
Converting a string literal to a named string type
A Go conversion of a string literal to a named type whose underlying type is string — errorString("…") where type errorString string — needs the same @string intermediate. The literal renders as a u8 ReadOnlySpan<byte>, which has no conversion to the named type, so a bare (errorString)"…"u8 is CS0030. The converter routes it through @string (which converts implicitly from the u8 span and to which the named type converts):
return errorString("kaboom")
return ((errorString)(@string)"kaboom"u8);
This is the form the runtime uses for every panic(errorString("…")) / plainError("…"). (Guarded by the behavioral test NamedStringConversion.)
The same intermediate is needed with NO explicit Go conversion written — a named string type’s
zero value in a RESULT position (2026-08-08). Go converts an untyped string constant to a defined
string type implicitly, so the source says only return nil, ""; the emitted C# still has to cross
the two user-defined conversions, so a bare "" has no conversion to the named type at all
(CS0029). In a multi-result return the damage spreads: the failed element leaves its tuple
siblings with no target type either, so the default! beside it is CS8716. The result type’s
being a defined type over string is the signal, and the literal takes the @string step:
// os/zero_copy_linux.go — poll.String is `type String string`
func getPollFDAndNetwork(i any) (*poll.FD, poll.String) {
sc, ok := i.(syscall.Conn)
if !ok {
return nil, ""
}
return (default!, (@string)"");
A plain string result is deliberately excluded (it emits as @string, which a literal already
reaches in one conversion), as is a type parameter, whose emitted form is not a [GoType] wrapper.
An ALIAS of a named string type is the same type and takes the same route. (Guarded by the behavioral
test NamedStringZeroValue — the multi-result shape that carries the cascade, the single-result
shape, the alias, and the named type still behaving as a string under +, += and ==,
stdout-compared against go run.)
A POSITIONAL struct-composite element in a string field renders u8
Go requires a positional composite literal to list every field in order, so element i is field i.
A string-literal element whose field is a plain string renders as the u8 span, which binds the
generated constructor’s @string parameter through one implicit conversion:
type StructuralError struct{ Msg string } // encoding/asn1
return StructuralError{"empty integer"}
return new StructuralError("empty integer"u8);
KEYED elements (fileListEntry{name: "./"}) and ELIDED positional elements ([]pair{{"e2", …}})
already emitted u8 — they route through convKeyValueExpr and the elided element context
respectively — so this only closes the TYPED positional gap, where the element stayed a bare C#
string that Encoding.UTF8.GetBytes transcoded on every evaluation. It was the corpus’s last
converter-emitted bare-UTF-16 literal class in a constructor position: 83 sites across 9 types
(asn1’s StructuralError/SyntaxError, net/http’s ProtocolError/contextKey, math/big’s
ErrNaN, encoding/xml’s UnmarshalError, net/url’s Error, …). An any field keeps the boxed
(@string)"…"u8 form (markAnyFieldLits runs after and overrides).
A string-literal ELEMENT of a typed slice/array composite renders u8
The element twin of the struct-field rule above. An elided slice/array literal already rendered
u8 (its element context is nil, and convExprList’s nil-context default leaves u8StringOK on),
but the typed form built its own CallExprContext and never set u8StringArgOK, so every
element fell back to a bare UTF-16 C# string that Encoding.UTF8.GetBytes re-transcoded on each
evaluation:
return StructuralError{[]string{"Format specifies USTAR", whyNoUSTAR}} // archive/tar
return new headerError(new @string[]{"Format specifies USTAR"u8, whyNoUSTAR}.slice());
The span binds the @string element slot through @string’s implicit ReadOnlySpan<byte>
conversion; a NAMED type over string binds the same way through its generated span conversion, so
the rule keys on the element type’s underlying basic kind (matching markStringFieldLits).
An empty-interface element type — []any{"a"} — keeps its mandatory (@string) box but now
takes the u8 half too (new any[]{(@string)"a"u8}): the cast is what makes the span boxable, the
u8 is what keeps the bytes a compile-time constant. These two were the corpus’s last
converter-emitted bare-UTF-16 literal classes — 385 new @string[]{"…"} element sites plus the
any-element form. KeyValueExpr elements (maps, sparse arrays) are not BasicLits and route
through convKeyValueExpr, which already emitted u8. (Guarded by the behavioral tests
AnyStringLitComposite, DeepEqual, GenericCompositeLiterals, and 15 others whose goldens carry
the form.)
A string CONCAT element of a composite literal keeps the u8 span form
The two rules above mark a composite’s string-typed element slots as span-tolerant, but they marked
only the elements that were BasicLits. That flag does double duty: convExprList derives
spanTargetUnsupported from it, and convBinaryExpr reads that to suppress u8 on a string
concat’s literal operand. A concat element is an ast.BinaryExpr, never a BasicLit, so it left
its own slot looking span-hostile and the literal fell back to bare UTF-16:
allowed := []string{prefix + "-a", prefix + "-b"}
var allowed = new @string[]{prefix + "-a", prefix + "-b"}.slice(); // before
var allowed = new @string[]{prefix + "-a"u8, prefix + "-b"u8}.slice(); // after
The suppression itself is correct where it was born — an object[] vararg slot cannot box a
ReadOnlySpan<byte>, so fmt.Println(allowed[0] + ":" + msg) must keep plain operands (CS1503) —
but a string element slot is not span-hostile, which the sibling spellings already proved: the same
concat rendered u8 when parenthesized (at the time, because convParenExpr dropped the incoming
literal context — a defect in its own right, fixed in the next section) and when the composite’s type
was elided (nil element context). Marking every positional element makes the three agree, and it is
what keeps the parenthesized spelling on the span form now that parentheses inherit their slot’s
context instead of discarding it. The span operand then binds golib’s operator +(@string, ReadOnlySpan<byte>), which
block-copies the literal’s ROM bytes straight into the single result buffer — no
Encoding.UTF8.GetBytes transcode, no throwaway intermediate @string. Two literal operands need no
operator at all: C# folds "x"u8 + "y"u8 into one UTF-8 literal at compile time.
A SLICED string literal in a concat needs one real @string operand
That last sentence is the whole hazard: C#’s + over UTF-8 spans is a literal-only compile-time
feature, and a slice of a literal is not a literal. Go’s format_test.go builds a fraction the
obvious way:
nanosec, err := strconv.ParseUint("012345678"[:test.fracDigits]+"000000000"[:9-test.fracDigits], 10, 0)
Both operands render as "…"u8[..n], i.e. two bare ReadOnlySpan<byte> values with no operator
between them (CS9047). The literals deliberately keep their u8 form — Go slices a string by bytes,
so re-rendering them as C# string and slicing that would index by UTF-16 code units, right for ASCII
and silently wrong for anything else. Instead the sliced operand is cast to @string, which gives the
concat one real operand and lets golib’s operator +(@string, ReadOnlySpan<byte>) (or its mirror) bind
while the other half stays a span:
strconv.ParseUint(((@string)"012345678"u8[..(int)(test.fracDigits)]) + "000000000"u8[..(int)(9 - test.fracDigits)], 10, 0)
Only token.ADD is affected; a comparison against a sliced literal already binds golib’s span-aware
operators and keeps its zero-allocation form. (Guarded by the PackageNameShadowing behavioral test,
case 5.)
For a slot of a named string type the old form did not merely cost a transcode — it did not
compile. The generated [GoType("@string")] wrapper carried the span comparison operators but no
+ at all, so C# fell back to converting both operands to a C# string and calling string.Concat:
the result was a string where the wrapper was wanted (CS0029 in an element slot, CS1503 in a
generated constructor), and a u8 operand had no candidate to reach at all (CS0019).
InheritedTypeTemplate now mirrors @string’s concat set — +(T, T),
+(T, ReadOnlySpan<byte>), +(ReadOnlySpan<byte>, T) — so a named string type survives a concat
exactly as Go says it does, keeping its method set:
type version string
func (v version) tag() string { return string(v) + "!" }
short := base + "-rc" // still a version, so short.tag() stays callable
version @short = @base + "-rc"u8;
The same BasicLit-only gate applied to markStringFieldLits, so a positional STRUCT element in a
string field had the identical defect (rec{base + "-s"} over a version field was CS1503); both
gates now mark every positional element. KEYED elements still skip — index i is not field i for
them, and convKeyValueExpr resolves their slot itself. (Guarded by the behavioral tests
CompositeElementStringConcat, which also pins the vararg suppression that must NOT change, and
NamedStringConcat; ReturnTupleFuncLitArg’s golden carries the slice-element form.)
Parentheses inherit their slot’s literal context
Parentheses are transparent in Go: (x) lands in the enclosing slot exactly as x does. But
convParenExpr rendered its operand with a fresh context list — it built the StarExprContext
the pointer-cast path needs and passed only that — so the incoming BasicLitContext never reached the
operand. The slot’s span-tolerance signal (spanTargetUnsupported, above) was silently reset to the
default tolerant, and a pair of parentheses could re-enable the u8 span form inside a span-hostile
slot:
panic("a" + "b") // suppressed correctly
panic(("a" + "b")) // parenthesized — the same slot, the opposite rendering
throw panic("a" + "b"); // before and after
throw panic(("a"u8 + "b"u8)); // before — CS1503
throw panic(("a" + "b")); // after
C# folds two adjacent utf8 literal constants into a single ReadOnlySpan<byte>, and a span has no
boxing conversion to object, so the parenthesized spelling did not merely differ — it did not
compile. panic is where this surfaces because it is the one span-hostile slot with no second line of
defence: the others pick up an outer (@string) box cast whose helper (constExprIsStringLiteralConcat)
already unwraps ParenExpr, while panic short-circuits in convCallExpr before convExprList ever
runs. With a non-constant operand the drop was invisible rather than fatal — (a + "-y"u8) binds
golib’s operator +(@string, ReadOnlySpan<byte>) and yields an @string, which boxes fine — but it
still rendered the parenthesized and unparenthesized spellings of one expression differently.
The fix threads the incoming literal context through the paren arm instead of replacing it; the
StarExprContext is passed alongside it, so the pointer-cast path is unchanged. Every span-hostile
slot — panic, a vararg any, an interface-typed assignment, return, or ValueTuple element — now
renders both spellings alike.
The inverse must hold too: a composite literal’s string element slot is span-tolerant, and its parenthesized concat has to keep the span form. It does, through that composite’s own per-element gate (previous section) rather than through the dropped context — which is exactly why the two changes belong together:
elems := []string{a + "-g", (a + "-h")}
var elems = new @string[]{a + "-g"u8, (a + "-h"u8)}.slice();
Re-transpiling the behavioral corpus and reconverting the full standard library both produce
byte-identical output, so this is latent-defect hardening rather than a rendering change: no site in
either corpus spells a concat this way today. (Guarded by the behavioral test
ParenthesizedConcatContext, which spells every affected slot both ways so the pair must agree.)
One related gap is deliberately left open. The decisions that mark a string literal boxable —
u8StringArgOK / useGoStringArg in convCallExpr, and the same test in visitSendStmt,
convKeyValueExpr, convCompositeLit, markAnyFieldLits, and convFuncLit — all gate on
isStringBasicLit, a bare *ast.BasicLit type assertion that does not unwrap parentheses (unlike
constExprIsStringLiteralConcat, which does). So a parenthesized standalone literal in an any slot
(fmt.Println(("lit"))) is not
recognized as one, and now renders (@string)(("lit")) rather than the constant-span
(@string)(("lit"u8)) it got by accident from the dropped context — correct, and one
Encoding.UTF8.GetBytes per evaluation slower. Making those gates paren-aware is the general fix for
that family; it is a distinct change with its own footprint and is not folded in here.
Converting a string literal to a named []byte / []rune type
The byte/rune-slice sibling of the named-string rule above: a string literal converting to a named type whose underlying is []byte or []rune — htmlSig("<!DOCTYPE HTML") where type htmlSig []byte (net/http sniff.go’s signature table) — cannot cast directly either. The u8 span converts to neither the [GoType] wrapper (whose implicit operator takes exactly its underlying slice<byte>/slice<rune>) nor through @string in one hop (C# chains at most one user-defined conversion — CS0030). The converter materializes the underlying slice exactly the way the plain []byte("…") conversion does (the slice<T>(T[]) builtin over the literal’s @string), and the wrapper’s own operator then applies:
type htmlSig []byte
sig := htmlSig("<!DOCTYPE HTML")
var sig = ((htmlSig)slice<byte>((@string)"<!DOCTYPE HTML"u8));
The rune form decodes code points — runeSig("héllo") yields a rune-counted slice<rune> — matching Go’s conversion semantics. The rule is stated over the OPERAND’S TYPE, not over its syntax: a string variable and a defined string are the same two-hop problem (((namedByteSlice)plainVar) was CS0030 exactly as the literal form was), so any string-typed operand takes the underlying-slice hop. (Guarded by the behavioral test NamedByteSliceFromStringLit — direct, composite-element, and argument positions, byte/rune element reads, all output-compared vs Go — and by DefinedElemStringConversion for the variable and defined-string operands.)
A string ↔ byte/rune-slice conversion with a DEFINED type on either END
Go spells []byte(s), []E(s) and string(b) identically whether the string, the slice or its element is a defined type or the plain builtin — the conversion is defined over the UNDERLYING types. C# reaches the two ends through different machinery, and neither end can be reached by chaining, because C# applies at most ONE user-defined conversion in a single context. The two ends therefore have two different remedies, and one conversion may need both at once.
The STRING end. A [GoType("@string")] wrapper converts to golib @string, and @string converts to byte[]/rune[] — two user-defined hops, so slice<byte>(v) over a defined string finds no applicable slice<T>(T[]) overload (CS1503: cannot convert from 'strMarshaler' to 'byte[]'). Spelling the (@string) step leaves exactly one implicit step for the argument conversion — the same remedy the split-literal idiom above already takes:
type strMarshaler string
func (s strMarshaler) MarshalJSON() ([]byte, error) { return []byte(s), nil }
[GoType("@string")] internal partial struct strMarshaler;
internal static (slice<byte>, error) MarshalJSON(this strMarshaler s) {
return (slice<byte>((@string)s), default!);
}
The ELEMENT end. slice<byte> and slice<myByte> are unrelated generic instantiations with no conversion between them at all — the element wrapper’s own byte↔myByte operators say nothing about the slices written over them. The elements are projected one at a time through that operator, using golib’s widen. This is not a concession: Go’s string↔slice conversion always materializes fresh storage, so an element-wise copy is exactly its cost model ([]E(s) and string(b) both allocate in Go too), and the projection preserves its source’s nil-vs-empty identity.
type Uint8 byte
type renamedRenamedByteSlice []renamedByte
want := []Uint8("hello")
r := renamedRenamedByteSlice("abc")
s := string(want)
var want = widen<byte, Uint8>(slice<byte>((@string)"hello"u8), elemᴛ0 => (Uint8)elemᴛ0);
var r = ((renamedRenamedByteSlice)widen<byte, renamedByte>(slice<byte>((@string)"abc"u8), elemᴛ0 => (renamedByte)elemᴛ0));
var s = ((@string)widen<Uint8, byte>(want, elemᴛ0 => (byte)elemᴛ0));
The lambda parameter carries the temp-var marker (ᴛ) because C# rejects a lambda parameter that shadows an enclosing local, and a converted Go identifier can be any plain name.
A plain string converting to a plain []byte/[]rune is deliberately not claimed by either arm: golib’s @string converts straight to byte[]/rune[], so the existing single call already IS the whole conversion, and claiming it would rewrite the corpus to no effect. []E(s) is also the only direction a defined element can be reached from — Go permits a slice→slice conversion only between identical element types, so []myByte([]byte) is not Go at all and the projection is never asked to alias.
Census. Across the whole Go 1.23.1 standard library — production and test sources — the shapes appear five times, all in encoding/json’s suite ([]byte(strMarshaler), []byte(*strPtrMarshaler), []byte(marshaledValue), []Uint8("hello"), renamedRenamedByteSlice("abc")), which is why the corpus compiled clean without them; the string([]myByte) direction has zero stdlib sites but is ordinary Go and is emitted by the same rule. These were five of the eight errors standing between encoding/json and its first run. (Guarded by the behavioral test DefinedElemStringConversion — every direction, value and pointer operands, named and unnamed slices, byte and rune elements, with the plain-on-plain controls in the same program.)
⚠ The reflection mirror of the element end is still open: reflect.Value.Bytes() casts its receiver to slice<byte> and throws InvalidCastException for a slice<myByte> (core/reflect/value_impl.cs), which is what encoding/json’s TestSliceOfCustomByte and TestEncodeRenamedByteSlice report. Emission and reflection are independent seams; closing this one did not close that one.
A string literal with high raw-byte escapes (\xHH hex, \NNN octal) emits a byte-array @string
Go’s \x escape is exactly two hex digits denoting one raw byte; C#’s \x escape is a greedy 1-to-4-hex-digit code-unit escape, and a C# "…"u8 literal UTF-8-re-encodes its content. So re-emitting a Go token verbatim as a C# string literal both (a) mis-parses \xdb followed by ASCII "5""0" (the token \xdb50) as the single code unit U+DB50 — a lone high surrogate that cannot UTF-8-encode into a golib @string (CS9026, time/tzdata’s embedded zip blob) — and (b) silently widens every byte ≥ 0x80 to two UTF-8 bytes, so @string byte indexing / len would not match Go. Such literals are emitted as the exact bytes in a parenthesized byte-array-backed @string:
const zipdata = "\x50\x4b\x03\x04\xdb50\xff\x92\x00LMT" // raw bytes
internal static readonly @string zipdata = ((@string)(new byte[]{0x50, 0x4b, 0x03, 0x04, 0xdb, 0x35, 0x30, 0xff, 0x92, 0x00, 0x4c, 0x4d, 0x54}));
The outer parentheses are load-bearing: an inline-indexed literal ("…"[i]) would otherwise bind [i] to the inner byte[]. Only a raw-byte escape trips it — for \xHH, a byte value ≥ 0x80 or a trailing hex digit (the octal companion is below) — so a literal written with actual UTF-8 characters ("Michał", "白鵬翔") round-trips through "…"u8 and keeps the readable string form, as does an all-ASCII escape run with no greedy extension (image/jpeg’s "\x00\x10\x01\x11"u8[i]), and there is no behavioral-golden churn. (Guarded by the HexByteStringLiteral behavioral test.)
Go spells a raw byte two ways, and the same rule covers both: \NNN is exactly three octal digits, likewise denoting one byte. Octal has no greedy hazard — Go’s escape is exactly three digits and the C# \uXXXX it would render as is exactly four hex digits, so neither side can extend into the following text — but the byte-width hazard is identical and just as silent: Go’s "\377" is the single byte 0xFF, whereas the character U+00FF that replaceOctalChars would emit UTF-8-encodes to the two bytes 0xC3 0xBF. The UTF-16-string and u8 renderings produce those same wrong bytes, so an octal escape ≥ \200 takes the byte-array path as well; below \200 it is ASCII-safe and keeps the readable form:
const octalData = "\377\200\303\277\101\000\177Z" // 8 raw bytes
const asciiOctal = "\101\102\011\103" // ASCII "AB\tC"
internal static readonly @string octalData = ((@string)(new byte[]{0xff, 0x80, 0xc3, 0xbf, 0x41, 0x00, 0x7f, 0x5a}));
internal static readonly @string asciiOctal = "\u0041\u0042\u0009\u0043"u8;
Left unfixed, len(octalData) is 12 rather than 8 and every byte index past the first is wrong. This one is worth recording as a latent defect: it was found by design review, not by a miscompile, and the corpus had no instance of it (CNR is byte-identical across the behavioral corpus, and the rule is purely additive — it can only divert literals that were already being emitted with the wrong bytes). Note that the folded-value rule below catches the octal case for concatenated constants by a different test (utf8.ValidString), since a lone \377 byte is not valid UTF-8. (Guarded by the extended HexByteStringLiteral behavioral test — a high-octal table, the \200 low boundary, sub-0x80 controls asserting the readable form survives, and a non-const local, all byte-indexed and len-measured, output-compared vs go run; stringLiteralNeedsByteArray’s rule — both escape forms, the sub-0x80 controls, and the escaped-backslash parity cases — is unit-tested in convBasicLit_test.go.)
The sub-\200 rewrite that renders the readable form obeys the same backslash-parity rule, and
it did not. replaceOctalChars matched \NNN with a plain regex, so in "\\101" — an escaped
backslash followed by the ordinary characters 1, 0, 1 — it matched from the SECOND backslash
and emitted "\\u0041", whose C# value is the six characters \u0041 where Go’s is the four
characters \101. Wrong content, wrong length, silently. ("\\377" is the same case above the
diversion boundary: parity keeps it out of the byte-array path too, since it holds no raw byte.) The
rewrite is now a positional parity scan — a \NNN is an escape only after an ODD run of
backslashes — which also fixes a second defect of the regex form: it paired FindAllString with
strings.Replace(…, 1), replacing the first textual occurrence of each match rather than the
matched position, so a literal carrying both forms rewrote the escaped one twice:
const escapedOctal = "\\101|\101|\\\101|\\377" // Go: `\101` | 'A' | `\`+'A' | `\377`
internal static readonly @string escapedOctal = "\\101|\u0041|\\\u0041|\\377"u8;
Three octal digits cap at \777 = 0x1FF, so the C# \uXXXX code-unit escape always suffices (the
old \UXXXXXXXX branch was unreachable). The same helper feeds the token.CHAR path, where the
parity case cannot arise (a rune literal holds one character) but the escape rewrite is shared. This
was found by review, not by a miscompile: CNR shows no corpus instance (byte-identical apart from
the guard’s own golden). (Guarded by the extended HexByteStringLiteral behavioral test — both
forms as a const and as a local, plus the rune pair, output-compared vs go run — and by
TestReplaceOctalChars.)
The above routes a single *ast.BasicLit through convBasicLit’s scan. A string constant whose value is a concatenation — const rev8tab = "" + "\x00\x80…" + … (math/bits’ bit-reversal table) — folds to one value with no single BasicLit, so it bypassed that scan and rendered a UTF-16 string literal: rev8tab[1] returned 0xC2 (the UTF-8 lead byte of U+0080), not 0x80, and Reverse8 was wrong. The const-string path now tests the FOLDED value directly — a value that is not valid UTF-8 (utf8.ValidString) cannot round-trip through a C# string/u8 literal, so it emits the same byte-array @string from its exact bytes (byteArrayStringLiteral, shared with emitByteArrayString); a valid-UTF-8 value keeps the readable getStringLiteral form. This catches any non-UTF-8 byte table built by concatenation (crypto S-boxes, embedded blobs), not just single literals. (Guarded by the ByteTableStringConst behavioral test — a concatenated \x00\x80… table byte-indexed and len-measured, output-compared vs go run; the pre-fix converter returns 0xC2 for index 1. The full corpus compiles with the byte-array consts, and CNR is byte-identical.)
Valid UTF-8 is not sufficient, and that gap cost net/http/fcgi a row (2026-08-09). The folded
arm’s utf8.ValidString test answers only the byte-widening half of the round-trip; the greedy-
escape half above applies to a folded constant exactly as it does to a BasicLit. FastCGI’s
const want = "\x01\n\x00\x00\x00\x12\x06\x00" + "\x0f\x01FCGI_MPXS_CONNS1" + … folds to a value
that is entirely ASCII — perfectly valid UTF-8 — so it took the readable path and emitted
\x0f\x01FCGI…, in which C# reads \x01F as U+001F and eats the F. Nothing failed to compile;
TestGetValues simply compared a correct response against its own corrupted constant, and reported a
%q diff whose cause is invisible unless you already know C#’s escape is variable-length. The arm
now applies stringLiteralNeedsByteArray to the folded value’s own quoted form, so BOTH declaration
routes ask exactly the same question and diverge only where the answer genuinely differs. Corpus
reach, measured before the fix: one live site — every other \x+hex-digit run in the emitted corpus
sits inside a C# verbatim (@"…") literal, where \x is two ordinary characters. (Guarded by the
extended ByteTableStringConst behavioral test, whose second constant is the ASCII-only
"\x0f\x01" + "FCGI_MPXS_CONNS1" + "\x0a\x0d" + "BEEF" — two greedy sites, byte-indexed, len- and
%q-printed, output-compared vs go run; and by net/http/fcgi’s banked suite.)
The var form of the same table needs no separate rule, and it is worth stating why, because
the two declaration kinds reach the byte-array emission by genuinely different routes. A const
is folded by go/types, so the concatenation is gone by the time the declaration is emitted and
only the folded value can be inspected — hence the utf8.ValidString test above. A var’s
initializer is rendered as an expression: var tbl = "" + "\xff…" + … walks the BinaryExpr and
converts each operand through convBasicLit, so every piece is scanned on its own and the
non-UTF-8 pieces become byte-array @strings that then concatenate as @strings:
internal static @string tbl = ""u8 + ((@string)(new byte[]{0xff, 0x00, 0x80})) + ((@string)(new byte[]{0x01, 0xfe}));
This holds for a package-level var, a function-local var, an explicitly typed var
(var t string = …), a single non-concatenated literal, and a []byte("" + "\xff…") conversion.
Note encoding/hex’s reverseHexTable — the 256-byte table that motivated a second look at this
area — is a const, already covered by the folded-value rule; the corrupted UTF-16 literal
still visible in a stale src/core/encoding/hex/hex.cs is pre-fix output, not current
converter behavior. (Guarded by the ByteTableStringVar behavioral test — package-level, local,
typed, and single-literal non-UTF-8 tables byte-indexed and len-measured, plus valid-UTF-8
controls asserting the readable literal form and UTF-8 byte-count len, output-compared vs
go run.)
A value-materializing string literal is HOISTED to a static readonly field beside its first use
Go keeps string literals in RODATA: return "true" allocates nothing in a Go binary. Emitted
inline, the converted C# pays a fresh backing byte[] at every evaluation, because each
literal→@string materialization copies the u8 span. A whole-package pre-pass therefore hoists
each package-unique literal that materializes a VALUE to one private static readonly field,
declared immediately above the function whose body holds its first package-wide use, and every use
site becomes a field reference — so the literal costs at most one allocation per program run:
// strconv/atob.go
func FormatBool(b bool) string {
if b {
return "true"
}
return "false"
}
// Hoisted @string literals (single allocation; Go keeps these in RODATA)
private static readonly @string trueˢ = "true"u8;
private static readonly @string falseˢ = "false"u8;
// FormatBool returns "true" or "false" according to the value of b.
public static @string FormatBool(bool b) {
if (b) {
return trueˢ;
}
return falseˢ;
}
What hoists. Only contexts that materialize a value, where a shared immutable @string is
indistinguishable from a fresh one: a value return; an assignment to a local, parameter or struct
field; an argument bound to a string parameter (including a variadic ...string element); an
any/empty-interface target (argument, result, channel send, assignment); a standalone map-index
key; and a conversion of the literal to a named string type. A literal whose EVERY package use is an
any target is emitted pre-boxed — private static readonly object xˢ = (@string)"…"u8; — so
those sites allocate nothing at all. Mixed-use literals get one @string field and box per any
call; there are never two fields for one literal.
What does not hoist, and why — deterministic filters, not hotness heuristics:
| Context | Why it stays inline |
|---|---|
comparison operands (incl. lowered switch chains) |
already zero-allocation: @string and every named string type compare against a u8 span in place |
concat operands (x + "…") |
operator +(@string, ReadOnlySpan<byte>) already consumes the span without materializing it |
[]byte("…") / []rune("…") sources |
the result must be freshly MUTABLE; that one allocation is mandatory |
fmt/log/testing *f format-position literals (recognised structurally: a variadic callee named …f with a string parameter immediately before the variadic) |
a format string slugs badly ("%v" → vˢ) and a formatting call’s cost is dominated by formatting itself |
| degenerate slugs — no usable ASCII word content, or a slug of ≤ 3 characters |
strˢ7 / dˢ carry no information; the literal reads better inline. "true" slugs to a healthy four-character true and DOES hoist |
the empty literal ""
|
already 0 B (ToArray() of an empty span returns Array.Empty) |
| composite-literal elements and keys | uniform hoisting would emit thousands of fields above the table-building functions and move their allocations out from under a sync.Once into the type initializer. (A standalone index into the same map — table["composite key"] — still hoists) |
literals inside func init()
|
run exactly once by construction |
package-level var/const initializers — decided on the Go AST position, not the emitted C# shape |
one-time by nature (a package-level table is emitted into an initᴛ* method body, which a shape-based rule would mistake for an ordinary function) |
| func literals OUTSIDE a function declaration | no FunctionPrefixMarker anchor exists to hoist above |
\xHH / high-octal raw-byte literals |
already diverted to the byte-array-backed @string path |
every declaration a [module: GoManualConversion] file or entry owns |
its emission is redirected to a non-compiled .cs.auto (or replaced by a placeholder comment), so it renders no prefix marker and must never CLAIM a field; its own literals stay inline, and the reconvert gate asserts no hoisted field is ever declared in a .cs.auto
|
universe builtins (panic, print, copy, unsafe.Slice, …) |
go/types records a call-site-specific signature for these, but the converter emits each through its own path — panic deliberately keeps the bare interned literal, which is zero-cost until a panic actually fires |
Naming. HoistedLiteralMarker (ˢ, U+02E2 — a new symbols.json entry, never hardcoded)
suffixes a camelCase slug of the literal’s own content, joined at word boundaries and truncated at
≤ 24 characters. That budget is total, first word included (corrected 2026-08-15 — see below).
The alphabet is ASCII letters and digits, not unicode.IsLetter: a C#
identifier is lexed over UTF-16 code units, so a letter outside the BMP is a surrogate pair and can
never appear in one — go/types spells its universe type set "𝓤" (U+1D4E4, category Lu), and a
rune-wide slug emitted 𝓤ˢ, a CS1056/CS1519 cascade. An ALL-CAPS word folds whole
("TESTING KEY" → testingKeyˢ, "CONTENT-TYPE" → contentTypeˢ); touching only its first
character would leave tESTINGKEY, which was 9% of the corpus’ hoisted names on the first cut.
Distinct literals whose slug collides take a package-wide first-occurrence ordinal (fooˢ, fooˢ2),
checked against the package’s declared names and the already-claimed hoist names —
performNameCollisionAnalysis walks Go declarations only and never sees a synthetic name. Because
every hoisted name ends in ˢ, it can collide with neither a C# keyword nor a Go-derived identifier.
The 24-character budget binds the FIRST word too (2026-08-15, the crypto/tls lane). It did not:
the word-boundary truncation only applied once the slug was non-empty, so the leading word was written
whole at whatever length it happened to be. A literal that is ONE long word — a hex test vector, a
base64 blob, an alphabet string — therefore minted an identifier of exactly its own length, and
crypto/tls’s key_schedule_test.go carries a 2,176-character hex vector: the field name was
2,176 characters and the compile died CS7013: Name '…' exceeds the maximum length allowed in
metadata. The committed corpus was already past the design’s intent without failing — 33 of its 5,928
hoisted names exceeded 24 characters, the longest 256 — so this was luck, not a boundary case. Raising
the number would not close the class; making the budget total does: len(literalSlug(v)) ≤ 24 is now
an invariant, so a literal of any size mints a name within budget or no name at all. A word that alone
overflows has no word-boundary truncation available (the design’s “never mid-word — that is where
unreadable names come from” rule), so the slug is empty and the degenerate rule keeps the literal
inline, which is exactly where an unreadable identifier was the alternative. A/B footprint: those 33
literals inline instead of hoisted, all but a handful hex/base64/alphabet content; zero behavioral
goldens move (no behavioral literal has an over-budget first word).
Two orderings the mechanism has to respect.
Initialization order. C# runs static field initializers in textual order within a class PART and in
unspecified order across parts, so a package-level var whose initializer transitively reads a
hoisted field could observe default(@string) (""). The converter already owns the defense —
initOrderOperations relocates dependency-ordered initializers into the generated static
constructor, which runs after ALL field initializers — but its graph is keyed on Go variables and
cannot see a synthetic field. Every function that reads a hoisted field is therefore registered in
that graph, so any package-level initializer reaching one transitively is relocated. Three live
corpus instances surfaced immediately: net/http/internal/testcert’s LocalhostKey =
testingKey(…) reads two hoisted fields declared later in the same file and would have run
strings.ReplaceAll(s, "", ""); internal/profile and runtime/pprof are the other two.
Where the relocation is unavailable the rule takes a second arm: the -tests variant conversion
does not run collectMovedInitVars at all (the test project has no package_init.cs emission path,
and an internal variant shares the production class, which may already own a static constructor — a
second one is CS0111), so there nothing inside a function a package-level initializer can REACH is
hoisted at all; those sites keep the inline rendering, and the same literal still hoists from any
other use. This is not hypothetical: encoding/pem’s var pemData = testingKey(…) is declared
~300 lines above the testingKey whose two hoisted fields it depends on, ran
strings.ReplaceAll(s, "", ""), and left every "TESTING KEY" in place. The corpus compiled
clean with that bug present — only running the package’s own Go tests found it.
Two-pass -tests conversion. An internal _test.go file emits into the PRODUCTION package class
and can sort BEFORE the production file that owns a field. The test pass’s registry is therefore
pre-seeded with the production literal→field map (recomputed by the same collector over the
production files, with their manual-conversion flags), and a test file may only REFERENCE a seeded
literal, never claim it — that is what prevents CS0102, not name luck. An EXTERNAL <pkg>_test
variant carries no production files, so its seed is empty and it claims freely into its own class,
which is required: a production field is private to a different class. Production output is
byte-identical whether or not tests are converted.
Determinism. File conversion is sequential in sorted-filename order (concurrency was removed for
exactly this reason), and names and placement derive only from literal content plus source order, so
two runs over the same tree emit the same bytes. Emission itself is a pure substitution at
convExpr’s single *ast.BasicLit arm; the decision cannot be made there, because pre-boxing needs
every use of a literal and the init-order rule needs the reader set before any file emits.
One interaction is worth naming: applyUntypedConstBoxCast re-applies the (@string) default-type
box cast to anything that does not already lead with it, and a hoisted name does not. It now skips a
hoisted literal outright — an @string field needs no cast, and re-casting a PRE-BOXED object
field would unbox and allocate a fresh box on every evaluation, defeating the hoist at exactly the
any-slot sites it targets.
Corpus effect (Go 1.23.1, 302 packages): 3,253 hoisted fields across 467 files, 62 of them
pre-boxed. Per function the blocks are small — 1,634 blocks, median 1, p90 4, p99 11, max 61
(net/http’s StatusText, the worst case the design accepted up front). Per file the median is 3
and the max 127, in the 20k-line bundled net/http/h2_bundle.cs. A side effect worth recording: two
dead deref-alias prologues disappeared (runtime’s lfnodeValidate, go/types’ suspendedCall)
because bodyReferencesIdentAsValue is a text test whose own comment names “a string” as a source of
spurious matches — the words node and call inside those functions’ message literals were the only
textual occurrences keeping the aliases alive. Moving the literal out of the body drops the dead
local; a genuinely live alias is still never dropped, since a real value use emits the identifier
regardless. (Guarded by the StringLiteralHoisting behavioral test — every row of both tables above,
plus slug-collision ordinals, cross-file dedupe, and the init-order case, output-compared vs
go run.)
Composite types render structurally ([]*T keeps the pointer)
A slice/array type is rendered structurally in every type-name path: the [N]/[] marker plus the recursively resolved element, never from the go/types string form. The string form is path-qualified ([]*internal/abi.Type), and the cross-package last-segment strip would eat everything before the slash including the pointer marker, silently dropping the ж<> (reflect’s []*abi.Type fields compiled against the WRONG element type). The recursion also resolves lifted anonymous elements and cross-package generic elements:
ptrs := vals.([]*atomic.Int32)
var ptrs = vals._<slice<ж<atomic.Int32>>>();
Guarded by ArrayOfCrossPackageType (the type assert and a var declaration).
A SAME-PACKAGE instantiated generic is rendered structurally for the same reason — the name plus each type argument recursively resolved, never from the go/types string. A cross-package generic already took the structural path (getAliasQualifiedTypeName/getFullyQualifiedTypeName both special-case pkg != v.pkg), but a generic whose OWN type is local while a type ARGUMENT is cross-package fell through to the t.String() form: curve[*repro/sub.Item], whose slash-strip then ate everything before the / — including the curve[ header — collapsing the wrapper. crypto/elliptic’s var p224 = &nistCurve[*nistec.P224Point]{…} and its p256Curve struct { nistCurve[*nistec.P256Point] } embed emitted ж<nistec.P224Point>> / ref go.nistec.P256Point> … (a CS1519/CS1526 cascade, ~137 errors across elliptic/ecdh/mlkem768). Both getAliasQualifiedTypeName (the var-type path) and getFullyQualifiedTypeName (the struct-embed field path) now render a same-package generic as Name[args…] with each arg via the same function, so the arguments carry their short, slash-free package-qualified names and the header survives → ж<nistCurve<ж<nistec.P224Point>>>. Byte-identical across the behavioral corpus; an A/B of crypto/elliptic+ecdh shows only wrapper-restorations at every site (var types, adapter ctors, GoImplement attributes, the embed accessor, the unmarshaler array). (Guarded by the CrossPkgUser extension — a same-package Holder[*CrossPkgLib.Sensor] as a var type AND a struct embed, field read/write vs Go.)
A reference-type-pointee pointer parameter uses the nil-check-free .ValueSlot deref alias
The entry deref-alias for a pointer parameter is ref var p = ref Ꮡp.Value. The .Value getter
throws NilPointerDereference when the box reports IsNull — for a MANAGED box, m_val is null.
That is correct when the pointee is a VALUE type (a null m_val means a genuinely nil pointer). But
when the POINTEE is itself a reference type — *error, *[]T, *map[K]V, **T, *func(…),
*chan T — the box holds the reference VALUE directly, and that value is legitimately null when it
is the zero value (a nil interface/slice/map). The pointer is still a valid, non-nil box (Ꮡ(err)),
so establishing the entry ALIAS is a read of the held value, not a dereference of the box: in Go,
*(&err) of a nil error yields nil, no panic. .Value’s IsNull check misfires on m_val is
null and panics spuriously at function entry — text/scanner’s digits(…, invalid *bool) and, for a
reference pointee, text/tabwriter’s handlePanic(err *error) (deferred from Write, whose err is
a nil named-return interface) crashed with a nil-pointer panic before recover() even ran. The fix:
when the pointee isInherentlyHeapAllocatedType, emit the nil-check-free .ValueSlot accessor
(ref var err = ref Ꮡerr.ValueSlot), mirroring namedResultBoxAccessor — a named result of the same
type already reads this way. .ValueSlot returns the same real m_val slot as .Value in every
non-throwing case, so write-through and non-null reads are byte-behaviorally identical; only the
spurious-panic case changes. Corpus-wide the swap touches 49 stdlib files + 10 behavioral goldens, all
value-preserving (full behavioral suite Output 0-fail). (Guarded by the PointerToInterfaceParamDeref
behavioral test
— a *error parameter read through inside a deferred recover/re-panic where the pointee is nil at
address-of time; before the fix the entry alias NREs, after it prints the re-panic message,
output-compared vs go run.) ⚠ This fixes only the spurious CRASH. A SEPARATE latent defect remains:
a non-heap-promoted address-taken named return — Ꮡ(err) boxes a COPY — so *err = … in the
deferred handler writes the copy while return err reads the original; text/tabwriter’s tests need
that heap-promotion of address-taken named returns before they fully validate.
The value-type nilable case — a genuinely nil *rune/*bool/*int optional-out-param, deref’d only
under a body VALUE guard — was handled for a fortnight by a companion call-site nil-argument
detection, and is now subsumed by the unconditional nil-deferring entry alias (see A pointer
PARAMETER is nil-deferring for exactly the reason a receiver is). The problem it solved is worth
keeping on the record, because it is the cleanest demonstration of why an entry-alias policy cannot be
an analysis: collectNilSafePtrParams scanned only the body for param == nil/!= nil, so
text/scanner’s digits(ch0 rune, base int, invalid *rune) — whose sole deref *invalid == 0 sits
behind ch >= max (never invalid != nil) and which is called digits(ch, 10, nil) — kept the
strict .Value entry hoist and NRE’d at entry, where Go never dereferences (ch >= max is false on
the nil-call path). The remedy was a package-wide pre-pass (collectNilArgPtrParams) recording, per
*types.Func, the pointer-parameter positions ever passed the untyped nil at a call site — which
worked, but only for SAME-package call sites, because the converter processes one package at a time.
A parameter passed nil solely from another package stayed strict and stayed broken; that residual is
what .DerefOrNull() closes structurally, and the pre-pass was deleted with the rest of the analysis.
(Still guarded by the GuardedNilPointerParamDeref behavioral test — a *int out-param deref’d under
an i >= base guard, called once with a real pointer and once with nil; NREs at the entry hoist under
either predecessor, matches go run now without one.)
A pointer-element composite literal takes the box for a deref-aliased ident
A bare identifier element of a pointer-element composite literal ([]*CommentGroup{c}) renders
the pointer VALUE — the box Ꮡc — not the deref’d receiver ref-local c. Every named pointer
parameter is deref-aliased in C# (ref var c = ref Ꮡc.Value), and the bare name is the value
alias; the array element type is ж<CommentGroup>, so the alias form was CS0029 (go/ast’s
CommentMap.addComment — the sibling append(list, Ꮡc) already took the box through the
call-argument pointer arm). The routing mirrors the struct-field pointer arm: the element index
is marked argTypeIsPtr, which convExprList turns into the pointer ident context:
list = new ж<CommentGroup>[]{Ꮡc}.slice();
Gated to bare idents of pointer type — keyed elements (maps) and address-of/composite elements
manage their own pointer rendering. Guarded by the PointerParamWalk extension collect (the
literal arm and the append arm, aliasing proven by a post-collect write through the original).
A pointer value passed to an any argument takes the box
A deref-aliased pointer passed WHOLE (as an argument, not p.field) to an EMPTY-interface (any)
parameter renders the pointer VALUE — the box Ꮡp — not the deref’d value alias p. Go boxes the
pointer into the interface, so dropping the box stores the pointed-to VALUE and loses pointer
identity: a later x.(*T) assertion (rendered ._<ж<T>>()) then finds a bare T and panics
(“interface conversion: … is T, not *T”). This is fmt’s own sync.Pool round-trip —
func (p *pp) free() { … ppFree.Put(p) } (Put’s parameter is any) feeding newPrinter’s
ppFree.Get().(*pp) — which crashed the SECOND time through the pool, blocking every multi-call fmt
program. Both a pointer RECEIVER and a plain *T PARAMETER take the box:
func (p *pp) free() { poolPut(p) } // p is *pp (pointer receiver); poolPut(x any)
func keep(q *pp) { poolPut(q) } // a plain *T parameter, same shape
internal static void free(this ж<pp> Ꮡp) {
ref var p = ref Ꮡp.Value;
…
poolPut(Ꮡp.OrTypedNil()); // NOT poolPut(p) — a pp VALUE loses pointer identity
}
internal static void keep(ж<pp> Ꮡq) {
poolPut(Ꮡq.OrTypedNil());
}
(The OrTypedNil() suffix is the other half of the same boundary — see
A pointer crossing into an interface carries its static type,
which generalized this arm from the call-argument slot to every empty-interface slot.)
This mirrors the composite-literal element arm above: the argument index is marked argTypeIsPtr,
which convExprList turns into the pointer ident context, so convIdent emits the parameter box
(Ꮡp) or the current method’s direct-ж receiver box. It fires ONLY for the empty interface — a
NON-empty interface already routes the pointer through its *T→interface adapter (interfaceTypes),
and the two arms are mutually exclusive. A pointer LOCAL is excluded (it already holds its box
directly — the bare name IS the box), an unsafe.Pointer argument is excluded (not a *types.Pointer),
and the treatment fans out across a variadic ...any. The receiver form reaches through a closure
too — Ꮡs.Value.d.note(Ꮡs) for s.d.note(s) inside a nested lambda (the database/sql (*Stmt)
shape). Guarded by PointerValueToInterfaceArg (a minimal sync.Pool-shaped free list round-tripping
a *pp via both a pointer receiver and a pointer param, each .(*pp)-asserted after the any hop —
the 2nd pool Get panicked before the fix) and the NestedLambdaReceiverField receiver-in-closure case.
Appending to an interface-typed slice casts the element
A value appended to a []Iface slice whose type is not already the interface – a pointer rendering as the *T-to-interface adapter ctor, or a raw struct value – leaves both golib append overloads applicable (append<T>(ISlice, params T[]) infers the concrete/adapter type; append<T>(slice<T>, params Span<T>) infers the interface – CS0121). The converter casts such elements to the element interface type:
pack = append(pack, (Animal)(new CatжAnimal(Ꮡ(new Cat(nil)))));
pack = append(pack, (Animal)(new Dog(nil)));
An already-interface-typed element stays bare. The empty interface (any) element type is affected identically and takes the same cast — append(args[:len(args):len(args)], c.output) with args []any and c.output []byte infers T=[]byte on the ISlice overload but T=any on the slice<T> overload (testing’s flushToParent, CS0121), and appending a scalar (append(anys, 5)) is the same shape; the differing element is cast to any so both overloads agree:
args = append(args.slice(-1, len(args), len(args)), (any)(c.output));
Guarded by InterfaceCasting (non-empty interface) and AppendUntypedConst (the empty-interface []byte-into-[]any and scalar-into-[]any cases).
A nil element appended to a slice casts to the element type
A bare nil appended as a single element – append(b.lines, nil) on [][]cell – renders nil as default!, which C# overload resolution binds to append’s params parameter as the whole null array (a non-expanded params call), appending ZERO elements rather than one nil element. This silently no-ops the grow: text/tabwriter’s addLine never extended b.lines, so every terminateCell indexing b.lines[len(b.lines)-1] panicked with index [-1]. The interface (error) and named-composite branches above already cast a nil element (its untyped-nil type differs from the element type), but an unnamed nillable element type – slice/map/pointer/chan/func – matched no branch and emitted bare. The converter now casts any untyped-nil element to the slice’s element type, forcing single-element binding:
lines = append(lines, (slice<nint>)(default!)); // []int element
maps = append(maps, (map<@string, nint>)(default!)); // map element
ptrs = append(ptrs, (ж<nint>)(nil)); // pointer element (nil renders in pointer context)
A nil element is only ever valid when the element type is nillable, so the cast target always exists. Spread appends (append(dst, src...)) are excluded (the existing Ellipsis.IsValid() guard). Guarded by the AppendNilSliceElement behavioral test (slice/map/pointer element types, output-compared vs Go).
A ONE-FIELD struct’s positional nil literal names its field constructor
The universe nil renders in a value context as the typeless default!, which takes its type from
whatever it is assigned or returned into. A constructor ARGUMENT is the one position where nothing
supplies that type, and a generated struct partial offers exactly two one-argument constructors: the
nil constructor T(NilType) and the field constructor T(F field = default!). default! converts
to both, so a one-field struct’s positional literal carrying nil is CS0121 — the call is
ambiguous:
new TestWriter_testClose(default!) // ambiguous: T(NilType) vs T(error)
new TestWriter_testClose((error)default!) // names the field constructor, and only it
The argument now carries the field’s type, via the same per-element castArgToType plumbing the
narrow-integer and any-field element casts use. Only a one-field struct can reach this: Go
requires a positional composite literal to list every field in order, so at any other arity the call
already differs from T(NilType) in argument count — and only nil can, because every other element
renders with a type of its own. A POINTER field is excluded and deliberately unchanged: there the
literal renders golib’s nil, whose type NilType is an exact match for T(NilType) and so beats
the field constructor’s user-defined conversion without ambiguity, producing the zero struct, which
is the correct value. archive/tar’s testClose{nil} is the reported shape (×9, and the last wall
in front of that package’s 97 verdicts); database/sql’s stubDriverStmt{nil} is the same root.
A struct-literal interface field takes a pointer element’s adapter
A composite struct literal whose field is an INTERFACE type, initialized with a POINTER element whose pointer-receiver method set satisfies that interface, must record and route the same *T→interface adapter a call argument does — &handlerWriter{l.Handler(), &logLoggerLevel, capturePC} (log/slog SetDefault), where field level is Leveler and *LevelVar implements Leveler via a pointer-receiver Level(). The struct-field interface routing (checkStructFields) recorded/routed a NAMED VALUE element that satisfies the field (DecodingError{InvalidIndexError(idx)}) but matched only a *types.Named element, so a POINTER element fell through: no GoImplement<LevelVar, Leveler>(Pointer = true) was recorded, and the box ᏑlogLoggerLevel was passed bare to the interface-typed constructor parameter (CS1503). The detection now takes the concrete satisfying type from the element OR the pointee of a POINTER element (types.Implements tested on the element’s own pointer method set, the non-interface guard tested on the pointee), so a pointer element records and routes exactly like the value case:
new handlerWriter(l.Handler(), new LevelVarжLeveler(ᏑlogLoggerLevel), capturePC)
// [assembly: GoImplement<LevelVar, Leveler>(Pointer = true)] -- in package_info.cs
The record flows through the existing pointer-target arm of convertToInterfaceType (the ж<T>-wrapped name unwraps to GoImplement<T, Iface>(Pointer = true), and the render wraps the box in the generated TжIface adapter), so a same-package local (streamWriter→io.Closer in net/http/fcgi) and a foreign pointee (*ast.SelectorExpr→ast.Expr, *Basic→Type, *Func→Object in go/types) route through their local or foreign adapters uniformly. Positional and keyed literals both resolve their field (a keyed element renders d: new SettingжDescriber(Ꮡs)); an already-interface element and a value element are unchanged. (Guarded by the PointerInterfaceStructField behavioral test — a pointer-receiver-only implementer placed in an interface-typed struct field, positional via an addressed global and keyed via an addressed local, output-compared vs Go.)
The struct-field interface routing also fires on an ELIDED element composite
The routing above lived only on the TYPED composite path (checkStructFields, reached from
convCompositeLit’s *types.Named/*types.Struct arms). An elided element composite — the inner
{v0, v1, …} of a []struct{…}{…} / map[K]struct{…}{…} / [N]struct{…}{…}, which drops the repeated
struct type and resolves it by inference (compositeLit.Type == nil) — took the separate target-typed
new(…) constructor branch, which emitted its element values through convExprList with no interface
recording or routing at all. So a struct field of interface type in such a literal was passed bare: a
POINTER form lost its new TжIface(…) adapter wrap, and a VALUE form whose concrete was used only in the
elided literal (never converted to the interface anywhere else) was never GoImplement-recorded, so no
partial struct T : Iface was generated for it. Both compile to CS1503. This is exactly errors’
wrap_test, whose []struct{ err error; … }{ {&poser{…}, …}, {errorUncomparable{}, …} } produced 17
cannot convert from 'ж<poser>' / 'errorUncomparable' to 'error' at the new(…) sites while the sibling
multiErr{poser} slice-element cast (a different path) wrapped its poser correctly.
The interface-field record+route loop was extracted from checkStructFields into a shared
recordStructFieldInterfaceCasts(compositeLit, structType, callContext) and is now called from both the
typed path and the elided path (against the inferred *types.Struct), so an elided struct composite routes
its interface fields identically:
new(new poserжerror(poser), err1, true) // *poser → error (Pointer = true)
new(new errorUncomparableжerror(Ꮡ(new errorUncomparable(nil))), …) // *errorUncomparable → error
new(new errorUncomparable(nil), …) // value form: partial struct : error boxes
// [assembly: GoImplement<poser, error>(Pointer = true)] + <errorUncomparable, error>[(Pointer = true)]
The extracted logic is byte-for-byte the proven typed-path logic (same keyed-vs-positional field resolution,
same value/pointer method-set satisfaction test), so it inherits every guard the typed path already carried
(the gif keyed-field bogus-record avoidance, the types.Implements pointee test). An isolated A/B
full-reconvert of a production cross-section (fmt, errors, net/http, encoding/json, flag, go/types, os, time,
text/template — 172 .cs) shows zero production emission change: the pattern is overwhelmingly a
test-code shape, so the fix is inert for ordinary packages and only realizes the previously-uncompilable test
literals. (Guarded by the ElidedStructInterfaceField behavioral test — a pointer-receiver *pointerErr
and a value-receiver valueErr, each used only in an elided []struct{ err error; … }{…}, output-compared
vs Go; the pre-fix converter emits the bare box / bare value and fails CS1503 on both.)
And on the elided POINTER element composite (2026-07-31). There are three composite paths, not
two: []*struct{…}{{…}, …} — Go’s shorthand where the & is implied — has its own arm in
convCompositeLit, reached before the elided-struct arm above and emitting Ꮡ(new T(…)) rather than
the target-typed new(…). That arm marked any field literals but never called
recordStructFieldInterfaceCasts, so a concrete element in an interface slot again reached the
generated constructor bare. net ip_test’s []*struct{ in IP; str string; byt []byte; error } — an
embedded error field — handed a ж<AddrError> to the error parameter with no
AddrErrorжerror wrap (CS1503). The arm now makes the same record+route call its two siblings do:
Ꮡ(new ipStringTestsᴛ1(new IP(…), "?0123456789abcdef"u8, default!,
new net_test_package.net_AddrErrorжerror(Ꮡ(new AddrError(Err: …, Addr: …)))))
The tell is worth carrying forward: each of the three paths grew its own field-marking sequence
(markStringFieldLits / markAnyFieldLits / recordStructFieldInterfaceCasts) independently, and the
one that fell behind is the one nobody had a failing case for — the same shape-versus-its-pointer-
composition asymmetry as An anonymous struct lifts from ANY depth of its declared type. Behavioral
CNR is byte-identical across the whole corpus: like its sibling, this is a test-code shape. (Guarded
by the ElidedStructInterfaceField extension — a []*struct{ want string; error } whose interface
field is embedded, carrying both a pointer-receiver and a value-receiver implementer.)
A keyed element’s interface target is the composite’s own SLOT, never the LHS variable’s type
A composite literal assigned to an interface-typed variable converts to that interface as a
WHOLE — visitAssignStmt’s convertExprToInterfaceType (and visitValueSpec’s
convInterfaceDeclValue for a declaration) wraps the finished literal in its adapter. convKeyValueExpr
also consulted the LHS variable’s type (context.ident) for each keyed element, so the interface was
applied a SECOND time, to values whose real slot is not an interface at all. On a map whose element type
is a POINTER that is silently destructive: the element renders correctly as Ꮡ(new T(…)), the spurious
*T → Iface conversion adds the deref prefix, and convertToInterfaceType’s
“~ of an immediate Ꮡ(…)” collapse then hands back the bare struct — a map[K]*T slot holding a
VALUE (CS0029).
os’s TestCopyFS is the reached case: fsys is an fs.FS and the test reassigns it
fsys = fstest.MapFS{"william": {Data: []byte("Shakespeare\n")}} // map[string]*MapFile
which emitted ["william"u8] = new fstest.MapFile(Data: …) instead of Ꮡ(new fstest.MapFile(…)), ×5.
The same literal in a var declaration, as a call argument, or assigned to its own concrete type was
always correct — only the reassignment path carried the LHS type down into the elements, which is the
tell that the LHS was never the right source of truth.
The element’s target is now the composite’s own value slot (valueSlotType, already computed for the
MapSource/StructSource untyped-constant boxing just above), with the LHS ident kept only as the
FALLBACK for a composite that does not state its slot type here — the sparse-array shape it was
originally added for. A struct FIELD of interface type keeps its single conversion through
structFieldIfaceType. An interface-VALUED container (map[K]Iface{k: v}) still converts every element,
now through the slot rather than the variable, so the two agree by construction. Behavioral CNR is
byte-identical across the corpus — the shape needs a named container of pointers reassigned to an
interface variable, which the behavioral corpus did not contain. (Guarded by the
ElidedPtrElemIfaceAssign behavioral test: a map[string]*Item and a []*Item, each declared into,
reassigned into, and passed into an interface, with a write through a stored element pointer proving the
map holds the same object; plus an interface-VALUED map as the live control for the preserved
conversion.)
A GoImplicitConv record needs at least one LOCAL operand
ImplicitConvGenerator realizes a recorded conversion as a partial struct <name> inside THIS package’s
class, so the record has to name a type this package declares. The generator already relocates the host
when exactly ONE side is foreign (its “foreign SOURCE via a local alias” / “foreign TARGET via a qualified
reference” arms), and the converter’s aliased-numeric arm swaps target and argument for the same reason —
to anchor the record on the local operand. With NEITHER operand local the swap merely picks the other
foreign one and the generator has nothing to extend: it declares partial struct <simple name> locally, a
PHANTOM type of that name, and the operator body’s src.Value does not exist (CS1061).
os reaches it from os_windows_test.go’s privilege helper, syscall.CloseHandle(syscall.Handle(t)) over a
syscall.Token — both operands in syscall. Both the struct-conversion and the aliased-numeric arms of
checkForImplicitConversion now require conversionRecordHasLocalOperand, stated once as the property
rather than per-arm. Declining costs nothing: the call site already emits the explicit
((syscallꓸHandle)(uintptr)t) cast chain, which needs no generated operator, and an operator between two
foreign types could not be hosted in either of their assemblies from here in any case. Behavioral CNR is
byte-identical. (Guarded by the ForeignPairNumericConv behavioral test — a sibling library declaring two
named numerics and never converting between them, converted across in main, with the
foreign→local and local→foreign directions as the live controls for the records that are still needed.)
…but the POINTER-BOXING route needs none, and a whitebox-production operand still counts
The rule above is about HOSTING, so it stops where hosting does. A record of the form T → ж<T> —
the shared Go pointer-boxing route, and the corpus’s dominant record family at 193 of the 268
GoImplicitConv records across the emitted package_info.cs files — hosts nothing at all:
ж<T> is golib’s generic box, no converted package declares it, and ImplicitConvGenerator looks the
target up by struct declaration and continues when it finds none. No host is ever chosen, so no phantom
can be minted and no closed assembly can be mutated. recordsRequireProductionMutation already stated
exactly this when deciding whether a white-box test project can keep the reference model; the predicate is
now written once (pointerBoxConversionRecord) and both readers share it.
That matters because of the second refinement. On the internal -tests variant go/packages merges the
production files into the test package, so a production type’s obj.Pkg() IS the converted package while
its C# lives in the CLOSED referenced production assembly — which is why typeDeclaredInConvertedPackage
subtracts such a declaration (whiteboxProductionObject; internal/reflectlite’s flag(typ.Kind()) minted
a phantom partial struct flag in the test class, CS1061). Subtracting it for the pointer-boxing route as
well was one notch too far: it silently shrank every white-box package’s committed package_test_info.cs
on regen. crypto/rc4 lost its Cipher → ж<Cipher> record and the
using testing = go.testing_package; qualifier alias that the same record site registers;
go/types lost three (Basic, Interface, Tuple). Nothing catches it: CNR never runs
-tests, and the records are inert in the generator, so the only symptom is a -tests regen that no
longer reproduces committed bytes.
conversionRecordHasLocalOperand therefore takes the record shape as an argument and readmits a
WHITEBOX-PRODUCTION operand — and only that — when the record is the pointer-boxing route. A
BOTH-FOREIGN pair stays declined exactly as the section above describes, which is what keeps the change a
restoration rather than a widening: go/types’ test conversion also reaches types.Basic → ж<types.Basic>
and ast.FuncType → ж<ast.FuncType>, and those must not start recording. (Guarded by
TestWhiteboxProductionPointerBoxConvStillRecorded, whose both-foreign arm is the boundary, and
TestPointerBoxConversionRecordShape for the shared predicate; the numeric phantom keeps its own guard,
TestWhiteboxProductionNumericConvNotRecorded.)
Named-string wrapper surface (indexing, sub-slicing, span bridge)
A named type over string is indexed and sub-sliced in Go (tag[i], tag[i:j] – reflect StructTag.Get), but C# indexing never applies user-defined conversions. The InheritedType template therefore forwards the @string surface on every named-string wrapper: byte this[int] / byte this[nint] indexers, a Range indexer returning the WRAPPER (a Go sub-slice of a named string keeps the named type), nint Length for len(), and an implicit ReadOnlySpan<byte> operator so u8-literal comparisons and assignments bind. Guarded by NamedStringConversion.
A :=-declared string local keeps its named type and its heap box
A string-underlying local declared with := takes its EXPLICIT declared type through the same general
declaration path every other type uses (never var — a u8 literal would infer ReadOnlySpan<byte>).
The old dedicated string branch hardcoded @string as the declared type, which (a) DISCARDED a named
string type — go/types check.go’s fileVersion := asGoVersion(…) declared its goVersion locals as
@string, so the goVersion extension methods isValid()/cmp() no longer bound (CS1929 ×4) — and
(b) BYPASSED the escape-analysis heap-box check, so cause := "" followed by &cause emitted an
unboxed local while the call site referenced the nonexistent box Ꮡcause (CS0103):
goVersion fileVersion = asGoVersion((~@file).GoVersion); // named type preserved
ref var cause = ref heap<@string>(out var Ꮡcause); // escaping local heap-boxes
cause = ""u8;
A plain, non-escaping string local emits exactly as before (@string s = "…"u8; — the general path’s
explicit-type arm resolves to @string). The same explicit-type routing applies in the for-init
tuple-declaration form. (Guarded by the NamedStringDefine behavioral test — a named-string := with
methods called on the local, an escaping cause := "" written through its pointer, and a plain string
local, output-compared vs Go.)
A typed const of a named string type keeps the named type
The CONST-DECL arm of the same materialization family: visitValueSpec’s string-constant emission
hardcoded @string, so net/http pattern.go’s const equivalent relationship = "equivalent" (with
type relationship string) emitted internal static readonly @string equivalent = … — and every
comparison rel == equivalent was then ambiguous, because the [GoType("@string")] wrapper and
@string convert implicitly BOTH ways (CS0034 ×20 across pattern.cs). A typed string const now keeps
its named type, initializing through the wrapper’s ReadOnlySpan<byte> implicit operator (the
StringSurfaceMembers u8 bridge):
internal static readonly relationship equivalent = "equivalent"u8;
Function-body typed string consts take the same form (relationship localRel = "moreSpecific"u8;);
an UNTYPED string const keeps @string (its type is not a *types.Named). Full-stdlib footprint:
net/http pattern.cs, traceviewer’s ViewType consts, and regexp/syntax parse.cs.
The u8 form is required for EVERY value expression, not just a bare literal. The rule above got
its u8 from the literal path, which fires only when the spec’s value expression is an
*ast.BasicLit — i.e. the const x T = "…" spelling. Go’s other spellings put a different node
there: a conversion (const opLoad = mapOp("Load")) is a CallExpr, and a folded
concatenation (prefix + "Delete") is a BinaryExpr. Those fell to the folded-value path and
emitted a plain C# string literal, from which the [GoType("@string")] wrapper is two
user-defined conversions away (string→@string→wrapper) — which C# forbids, so the whole
declaration group failed (CS0029 ×9 on sync map_test.go’s mapOp const block). The folded value
now takes the same u8 rendering whenever the declared type is named:
const opLoad = mapOp("Load") // conversion — CallExpr
const opStore = mapOp("op" + "Store") // folded concatenation
internal static readonly mapOp opLoad = "Load"u8;
internal static readonly mapOp opStore = "opStore"u8;
A RAW (backtick) value has no u8-suffixable verbatim form, so it takes an explicit (@string) cast
instead — also a single conversion. A plain @string const is untouched (string→@string is
already single-step), which is what keeps the folded-untyped-const emission byte-identical corpus-wide.
(Guarded by NamedStringConsts — package-level and local typed consts compared against values and
each other, a method called on a const, the conversion and folded-concatenation spellings at both
package and function scope, and an untyped const staying plain, output-compared vs Go.)
A grouped var spec with one multi-result call deconstructs
A grouped var (name, offset, abs = t.locabs() ...) spec is not a :=, so the assignment tuple machinery never saw it – the per-name path assigned the WHOLE result tuple to the first name and silently DEFAULTED the rest (time appendFormat read a zero abs; a silent-wrongness class beyond the CS0029 that exposed it). Function-local specs now emit the C# tuple deconstruction, matching the := form; package-level specs use the once-evaluated hidden-field component reads:
var (ln, ls) = pair();
Guarded by GlobalTupleVarDecl (both levels, with a call-count check proving single evaluation).
The function-local gate asks whether a name has a BOX, not whether it “escapes” (2026-07-31).
That branch is gated to specs no name of which needs a ref heap<T> box declaration, and it read the
raw identEscapesHeap flag — which the escape analysis blanket-sets for every inherently
heap-allocated local (pointer, slice, map, chan, interface, func), because those are already
references and get no box unless their address is genuinely taken. So the gate rejected specs that are
entirely plain, and every tuple with an interface or func result fell back to the very per-name path
this branch exists to replace:
context.Context ctx = context.WithCancel(context.Background()); // the WHOLE tuple (CS0029)
Action cancel = default!; // silently defaulted
identHasHeapBox is the predicate that answers the gate’s actual question, and it is what the branch
now calls. This is the trap paramAddressTakenNeedsBox already documents from the other side — a
verdict the box gate then refuses leaves identEscapesHeap set with no box behind it — and it stayed
hidden because (int, string)-shaped tuples, the ones anyone reaches for when probing, work fine.
net’s var ctx, cancel = context.WithCancel(context.Background()) is the corpus site. (Guarded by the
GlobalTupleVarDecl extension — a local var si, fi = ifaceAndFunc() returning an interface and a
func, both read back.)
string() of an untyped constant reference hops through the default type
string(utf8.RuneError) renders the argument as its cross-package static readonly Untyped* wrapper, from which @string has no conversion (CS0030). The conversion hops through the constant’s DEFAULT Go type first – exactly Go’s conversion semantics; a plain literal is already a C# constant and keeps its direct form:
fmt.Println("a" + ((@string)(rune)CrossPkgLib.Sep) + "b");
Guarded by CrossPkgUser (string(CrossPkgLib.Sep)).
A := from a named untyped constant materializes the default type
codepoint := unicode.ReplacementChar must not declare with var: the constant renders as its
static readonly Untyped* wrapper (UntypedInt/UntypedFloat/UntypedComplex), so var binds the
LOCAL to the wrapper type instead of Go’s inferred default type, and a later Go conversion like
string(codepoint) fails (CS0030 — no UntypedInt→@string form; go/types conversions.go). The
declaration materializes the Go-inferred default type instead — exactly Go’s := typing:
rune codepoint = replacementChar; // NOT `var codepoint = …` (binds UntypedInt)
float64 factor = scale;
The gate is an Ident/Selector RHS resolving to a *types.Const of untyped NUMERIC kind (int is already
routed to the explicit nint form, and string consts to the explicit string path); literals and computed
constant expressions render as plain C# literals and keep var. Applies in both the single-declaration
and the mixed-statement paths. (Guarded by the UntypedConstDefine behavioral test — untyped rune and
float package constants :=-bound then converted/multiplied, output-compared vs Go.)
A computed untyped float constant materializes at its destination’s float width
A named untyped constant can still need its Untyped* wrapper because another use demands a different
type. When that name participates in a computed float constant, C# must not evaluate the expression
through the wrapper’s arithmetic operators:
const repetitions = 100000
var loopBound int = repetitions
mean := .5 * repetitions
var quarterMean float64 = .25 * repetitions
The wrapper form makes C# overload resolution prefer UntypedInt.operator*, converting .5 or .25
to an integer and truncating it to zero before the result reaches the float local. The converter instead
folds the exact Go constant expression once at the destination’s resolved float32 or float64 width.
An explicitly typed destination is visible on the expression itself; for a new := local, go/types keeps
the RHS untyped and records the default type on the declared identifier, so the declaration edge supplies
that width. Both paths reuse the same named-constant fold and leave bare references, non-float constants,
and expressions without a wrapper-emitted named constant unchanged. Guarded by UntypedConstDefine;
hash/maphash’s 100,000-sample SMHasher avalanche bounds are the corpus witness.
complex() over a NAMED untyped constant pins the element width
golib’s complex builtin is overloaded on element width — complex(float32, float32) => complex64,
complex(float64, float64) => complex128 — and UntypedFloat converts implicitly to both. C#
then applies its better-conversion-target rule, which prefers the narrower target (float32
converts to float64, not the reverse), so a complex128 the Go checker typed as such was silently
constructed at float32 width:
const maxFloat32 = 3.40282346638528859811704183484516925440e+38
over := complex(maxFloat32*2, maxFloat32*2) // complex128, 6.805646932770577e+38
var over = complex((float64)(maxFloat32 * 2D), (float64)(maxFloat32 * 2D));
Without the casts over is (+Inf+Infi) — and encoding/gob’s TestOverflow then found nothing
out of complex64’s range to reject, because float32FromBits accepts +Inf at either width. A
LITERAL argument never had the problem: the untyped-const analysis records the call’s element type
as the argument’s context and convBasicLit renders the F/D suffix from it (complex(1.5D,
2.5D)). A named untyped const (Δmath.MaxFloat32), or a constant expression over one, renders
as the UntypedFloat symbol and cannot carry a width — so exactly those calls pin their untyped
arguments explicitly, at the element width Go’s own typing gives the call
(complexCallElementType, resolving an untyped-complex-constant call through its recorded context
and Go’s complex128 default). A MIXED call needs nothing and gets nothing: complex(g, half) with
g a float64 was always unambiguous, since float64 has no implicit conversion to float32.
The rule cannot be expressed from golib’s side, and the attempt is instructive. Naming the
untyped pair explicitly (complex(UntypedFloat, UntypedFloat) => complex128) makes every MIXED call
ambiguous — complex(0D, gHalfPi) has the float64 overload better on the first operand and the
untyped one better on the second, so neither wins (CS0121). Completing all four width pairings does
not rescue it either: UntypedFloat converts implicitly in both directions with float32 and
float64, so for an operand that is neither — complex(7/2, 0D), an int — no candidate is
strictly better and the ambiguity simply moves. Overload resolution has no way to say “prefer the
width the call was typed at”; only the emitter knows that.
Corpus footprint: zero. The trigger is a named-untyped-const operand, and no complex() call in
the standard library (math/cmplx included) has one — every corpus site is either width-pinned
literals or has a typed operand. Guarded by the extended ComplexConstContext (the overflow pair,
its float32-range question, a named-untyped-const pair in both a complex128 and an explicit
complex64 context, and the mixed call that must stay unchanged; neuter-proven — with the arm removed
the guard’s over prints (+Inf+Infi) and over-fits-float32 true where Go says false).
A non-escaping string([]byte) local emits the stack-string sstring
Go elides the copy in s := string(buf) when s does not escape and buf is not observed to change,
letting s alias buf. @string (a heap byte[] wrapper) cannot do this — every string([]byte) is an
allocation + copy — which is the dominant cost the PerfString benchmark measures. The converter therefore
emits, for the provably-safe case, a stack-only sstring
(a readonly ref struct over ReadOnlySpan<byte>) that VIEWS the source with no allocation:
sstring s = ((sstring)buf); instead of @string s = ((@string)buf);. Where the string escapes, the
implicit sstring→@string conversion copies the bytes to the heap at that boundary.
The escape pass (markSStringEligible) records the verdict; it is deliberately conservative — the MVP’s
safest idiom only. A local is eligible iff: it is the built-in string type bound by a single
s := string(x); x is an UNNAMED []byte (a []rune→string must UTF-8-encode — an allocation, no view;
a named []byte would need a two-hop cast C# will not chain); it does not escape by any channel the escape
analysis detects; every use is a safe read — a len/cap argument, a byte index s[i], or a comparison
against a string literal OR a plain-string operand (a variable or field, s == want) — so anything else
(passed to a function, stored, ranged, concatenated, RETURNED, reassigned) disqualifies it; and the source
is never written except at its own declaration. (The comparison operand may be any plain-string
expression, even a call, because the whole-function “never written” guard already means the source cannot
change; only the built-in string type is allowed on the other side — a NAMED string type has no operator
against sstring, so it stays @string.) Emission is
two coordinated sites: convCallExpr retargets the conversion cast to sstring (after the Go→C# name
map) under a transient flag; and visitAssignStmt declares the explicit type as sstring and sets that
flag around the RHS. The comparison literal KEEPS its "…"u8 ReadOnlySpan<byte> form: sstring has
zero-allocation comparison operators against ReadOnlySpan<byte>, so s == "x"u8 compares the backing
spans in place. This is what makes the win real — rendering the literal as a plain C# string would force
a UTF8.GetBytes allocation on every comparison, and @string == "…"u8 allocates the literal-as-@string
each time (a copy neither the JIT nor Native AOT elides); the sstring form is the only zero-allocation
one. Measured: the comparison idiom (string(buf) == "…") runs ~12× faster than @string on the JIT and
~11× faster on Native AOT.
Because sstring is a ref struct, the escapes the predicate does NOT enumerate (storing into a field/
array/map, boxing to an interface, channel send, closure capture) are C# COMPILE errors, not silent bugs;
the two vectors that would be silently wrong — escape via return and mutation of the source buffer — are
guarded explicitly.
A second, broader case needs no escape analysis at all. An UNNAMED string(x) temporary that is an
operand of a comparison is created and consumed within the single comparison expression, so it cannot
escape; it is emitted as (sstring)x (markSStringComparisonConversions, keyed per-*ast.CallExpr) as long
as the OTHER operand cannot mutate x before the view is read. Three safe shapes qualify (sstringOtherOperandSafe):
- a string literal (
string(buf[:4]) == "ZLIB",string(item) != "null") — the literal keeps its"…"u8span form and bindssstring’s zero-allocationReadOnlySpan<byte>comparison operators; - a pure-read plain-
stringexpression — a variable, field, or index read (string(b[:n]) != magic,string(word) != "package") — which runs no code, so it cannot write the buffer, and compares via the new mixedsstring/@stringoperators (byte-ordinal span compare, no heap copy of either side); -
another
string(bytes)conversion (string(a) == string(b)) — both become zero-copy views and comparesstring == sstring.
It stays @string when the other operand could mutate the source before the compare — a function call
(string(a) == next(): Go’s string(a) is a copy taken before next() runs, but a stack view would be read
only at the ==, after next() could have written a) — or when it is a NAMED string type (no operator
against sstring). This byte-signature / header-check idiom is by far the most common string([]byte) pattern
in the stdlib: the literal form alone reaches ~23 sites, and the plain-string-operand and two-conversion
forms extend it further across crypto/* (md5·sha1·sha256·sha512, comparing against the magic
gob-stream prefix), crypto/tls (downgrade-canary checks), hash/*, go/internal/*importer, html/template,
and more.
The mixed comparison also widens the named-local case above: s := string(x) compared against a string
variable or field (s == want, s == cfg.name) is now eligible, not only s == "literal".
A switch string(x) { case … } is the same comparison family in statement form
(markSStringSwitchConversions). A Go string switch ALWAYS lowers to a single temp assigned the tag value,
then compared against each case label with == — an if/else chain, never a C# switch and never the
constant-pattern (is) form, because string constants render as static readonly @string (not a C#
const) and literals as "…"u8, neither of which is a C# case constant that a ref struct could be the
subject of. So var exprᴛN = ((sstring)x) infers the stack string and every exprᴛN == label binds a
zero-allocation operator (span for a u8 literal, the mixed operator for an @string const/variable).
Because the tag is evaluated exactly ONCE into the temp, the only requirement is that no case label can
mutate x before the view is read — every label must be sstringOtherOperandSafe (a literal, a pure read,
or another conversion — never a call, which is rejected, and never a named string type, which has no
operator). This covers the common binary-format-detection idiom switch string(magic) { case elfMagic: … },
and applies both to an unnamed tag (switch string(x)) and to a named local used as the tag
(s := string(x); switch s { … }, where the tag read is added to the named local’s safe-use set).
Concatenation (string(x) + suffix) is the same operand family in a + expression. A Go string
concatenation always allocates a fresh result, so the result is a heap @string that may itself escape —
only the operand is a stack value, and the win is skipping the intermediate ((@string)x) copy of it.
golib’s sstring gained operator+ overloads (against @string, another sstring, a
ReadOnlySpan<byte> u8 literal, and a plain C# string, both operand orders, all returning @string)
that block-copy the operand span straight into the single result buffer instead. The plain-string
overload resolves an otherwise-ambiguous string + sstring (both convert implicitly to the other): a
literal in an object/vararg concat context renders without its u8 suffix (the converter suppresses it),
so panic("incorrect mantissa: " + string(hm)) (math/big) becomes "…" + ((sstring)hm) where "…" is a
plain C# string — the explicit overload makes it an exact match rather than a CS0034 ambiguity (mirroring
why the comparison form keeps its literal as u8). A string(x) operand of a + is emitted as sstring
under the same rules as a comparison operand — markSStringBinaryOperandConversions (formerly
…ComparisonConversions) now also matches token.ADD, and requires the other operand to be mutation-safe
(a literal, pure read, or another conversion — never a call); a named local used in a concatenation
(s := string(x); s + suffix) is likewise added to sstringUsesAreSafe. string(a) + string(b) becomes
((sstring)a) + ((sstring)b), saving both operand copies.
A third refinement is an optimization, not a widening of eligibility: loop-invariant / repeated-conversion
hoisting. When the same eligible string(x) over a never-written source is emitted repeatedly — several
comparison operands, or one inside a loop — the inline ((sstring)x) re-materializes the view at every use,
and the JIT will not hoist a ref struct view out of a loop (measured: a non-throwing
MemoryMarshal.CreateReadOnlySpan golib view, added so the ToSpan bounds check could not block
loop-invariant-code-motion, made zero difference and was reverted — the fix must be converter-level). A
per-FuncDecl pre-pass (planSStringHoists) instead lifts each such group to ONE
sstring <temp> = ((sstring)x); at function scope and rewrites every use to the temp (convCallExpr returns
the temp name for a lifted *ast.CallExpr; visitBlockStmt injects the decl before the group’s anchor —
the first top-level body statement that contains a use). The safe gate is strong and needs no liveness
analysis: the conversion operand must be a bare identifier x (never a sub-slice/index — string(buf[:7])
and string(buf[8:12]) are distinct views that must not share one temp), and that x must be a plain
function-local or parameter that is NEVER written in the body (objectIsWritten == false), declared before the
injection point, with no use inside a nested func literal (a ref struct cannot cross a closure boundary —
that is a C# compile error, so the gate keeps the impossibility loud). Worth doing only when the conversion is
genuinely repeated — ≥2 uses, or ≥1 use inside a loop — so a lone comparison stays inline. Real Go-1.23 stdlib sstring sites are mostly single comparisons where this
is a no-op, so it changes few-to-zero stdlib goldens (the Go-1.23 reconvert hoists zero sites — the one
candidate, net/http’s is408Message, is string(buf[:7])/string(buf[8:12]), distinct sub-slices the
bare-identifier gate keeps inline); the win is targeted at loop/tokenizer patterns — a scanner comparing
string(buf) against several keywords — where a clean back-to-back A/B took PerfStringView from ~4.8× → ~3.0×
Go on the JIT (35.9 → 22.5 ms) and ~4.5× → ~1.9× on Native AOT (34.4 → 14.1 ms). That is about the practical
floor within .NET: a decomposition micro-benchmark confirmed the sstring == operator itself adds zero
over a raw span compare — the whole recoverable cost is the per-use view reconstruction, and the residual is
inherent (SequenceEqual’s per-call setup on a tiny buffer vs Go’s inlined memcmp).
Guarded by the SStringElision behavioral test — the eligible cases (two eligible locals, an unnamed
comparison operand, two repeated-conversion groups that each hoist to a single reused sstring temp — one in
a loop, one straight-line — plus the mixed-comparison additions: a named local compared against a string
variable and against a struct field, and two string(bytes) conversions compared directly; plus the switch
additions: a switch string(x) with literal cases, a named local as the switch tag, and a magic-constant
switch whose case labels are named @string consts; plus the concatenation additions: a named-local
s + suffix, an unnamed string(x) + literal and + variable, two conversions concatenated, and a concat
into an object context — fmt.Sprint("v=" + string(b)) — that exercises the plain-string operator+)
emit sstring; source-mutated, print-escaped, and returned locals, a compare-against-a-function-call, a
switch with a function-call case label, and a concat with a function-call operand stay @string —
asserting emitted forms and byte-identical Go/C# stdout. Remaining phases (unnamed conversions
passed to non-retaining callees / used as map keys, and a precise per-iteration liveness guard that would
reach the PerfString loop) are deferred; see docs/Roadmap.md.
Maps and Channels
Go maps and channels convert to the golib map<K,V> and channel<T> structures. make becomes a constructor; channel send/receive use the runtime operators:
m := make(map[string]int)
c := make(chan int, 3)
var m = new map<@string, nint>();
var c = new channel<nint>(3);
Map reads honor Go’s nil-map and comma-ok semantics (see Nil and Zero Values and Multi-Result Values and Comma-Ok Forms).
A range body may MUTATE the map it is ranging over — the enumerator walks a KEY SNAPSHOT
Go’s spec permits the body of a range to add to and delete from the very map being ranged:
“If a map entry that has not yet been reached is removed during iteration, the corresponding iteration value will not be produced. If a map entry is created during iteration, that entry may be produced during the iteration or may be skipped.”
.NET’s Dictionary<TKey, TValue> enumerator permits neither reading. A structural add bumps its
internal version and the next MoveNext throws
InvalidOperationException: Collection was modified; enumeration operation may not execute. Two
adjacent mutations do not throw, which is exactly what made this so easy to miss: since .NET Core
3.0 an overwrite of an existing key and a Remove are both version-free. Only the insert
bites — and only when the inserted key is genuinely new.
golib’s map<K,V> used to hand out that enumerator directly, so every legal Go range-with-insert
became a runtime fault. It now implements Go’s contract itself: the range takes a snapshot of the
entries and re-reads each value at the moment it is visited
(map.cs,
enumerateStore). That lands every clause of the spec —
- an entry removed before it is reached fails the visit-time lookup and is not produced, which is the half Go guarantees;
- an entry created during the range is absent from the snapshot and so is never produced, which is the “or may be skipped” half Go leaves free;
- a value overwritten during the range is produced at its current value, which is what Go’s own range reads out of the bucket when it arrives there;
- every pre-existing entry is still produced exactly once, so a body that inserts cannot be re-entered for a key it has already handled.
The nil-key entry (see The NIL map key) is produced first; Go’s range order is unspecified and deliberately randomized, so the position is free.
One key shape makes the visit-time lookup the wrong instrument, and it is a real Go shape rather
than a curiosity. A NaN key is equal to nothing, itself included,
so m[NaN] = v twice stores two entries and neither can ever be read back or deleted. For such
a key the lookup always misses, so a re-read on arrival silently drops every NaN entry from every
range — a worse defect than the one this machinery exists to fix, because nothing raises. So a miss
is disambiguated with the store’s own comparer: if the key is not even equal to itself, no lookup
can match it and no delete can remove it, so the snapshotted entry is produced. Using the
dictionary’s comparer settles “unretrievable” by exactly the relation whose failure is being
interpreted, rather than by a hardcoded list of float types — a custom comparer gets the same
treatment for free. The one operation that does remove such an entry is clear, which empties the
store outright, so a now-empty store suppresses it.
encoding/json reads this out immediately, and loudly: mapEncoder sizes
sv = make([]reflectWithString, v.Len()) and fills it by index from MapRange, so a range that
yields fewer entries than len() leaves zero reflect.Values in the tail and panics inside
stringEncoder’s v.Type(). That is TestMarshalTextFloatMap, and it is the reason the shape is
guarded at both layers.
// Legal Go: the body inserts a new key into the map it is ranging.
for k, v := range m {
if len(k) == 1 {
m[k+"!"] = v * 10
}
}
foreach (var (k, v) in m) {
if (len(k) == 1) {
m[k + "!"u8] = v * 10;
}
}
The emission is an ordinary foreach — the fidelity lives in the runtime type, not in the emitted
shape, so nothing about the converted code advertises the difference.
The cost is one KeyValuePair[] per non-empty range where there was none, and the self-equality test
only ever runs on the miss path. That is a deliberate trade: this is the construct’s semantics, and
go2cs converts behavior first. If a range ever measures hot enough to care, the snapshot is the one
thing to pool; the shape above does not change.
This is not an exotic corner. net/http’s HTTP/2 server hits it in promoteUndeclaredTrailers,
which ranges the handler’s header map and writes each promoted "Trailer:Foo" entry back under
"Foo" — a new key. The exception escaped the handler goroutine, the Phase-4 test host’s
containment policy absorbed it as a test failure, the h2 stream was therefore never completed with
its trailers and END_STREAM, and the client blocked in http2pipe.Read forever. That was the
deterministic hang of TestServerUndeclaredTrailers/h2, and it stalled the whole net/http row —
the hang, not any divergence, is what left the rest of the suite unreached. Guarded from the Go side
by tests/Behavioral/MapMutateDuringRange, which covers insert, overwrite, delete, insert-with-delete,
a control that mutates a different map, and the NaN-key shapes; and at the golib level by
tests/GolibTests/MapRangeMutationTests.cs, which pins what the Go side cannot reach — the nil-key
entry’s participation, and that a map without a nil key never yields a phantom entry.
m[string(b)] — the map-READ key does not copy (tmpstring)
The Go compiler special-cases m[string(b)]: because a map lookup hashes and compares its key but
never retains it, the []byte→string conversion’s result provably does not outlive the index
expression, and the copy is skipped (runtime.slicebytetostringtmp). The converted C# paid that copy
on every probe — one backing byte[] per call — which is exactly the allocation
net/textproto.TestCommonHeaders’ want-ZERO testing.AllocsPerRun assert measures over
canonicalMIMEHeaderKey’s common-header probe (L11). The converter now recognizes the same shape and
emits golib’s tmpstring(b) — a TRANSIENT @string windowing the slice’s live backing through
@string.TransientAliasOf, zero allocation:
if v := commonHeader[string(a)]; v != "" { return v, true }
v, ok := m[string(b)]
@string v = commonHeader[tmpstring(a)]; if (v != ""u8) { return (v, true); }
var (v, ok) = m[tmpstring(b), ꟷ];
The scope is deliberately EXACTLY the shape whose safety Go’s own optimization proves
(mapReadTmpStringKey, convIndexExpr.go): a map index in rvalue position — plain or comma-ok —
whose key type is the PREDECLARED string and whose key expression is a conversion to predeclared
string over a plain []byte (element exactly basic uint8). Everywhere the string ESCAPES the
copying conversion stays: an assignment target (m[string(b)] = v stores the key — emitted
m[((@string)b)] = v), delete(m, string(b)), the function’s own return string(a) paths, a
named-string key type, a named-over-byte element. Compound assignments and ++ mark the index an
assignment target, so they keep the copy for both their read and write halves. (Guarded by the
MapStringBytesLookup behavioral test — hit/miss/comma-ok probes through a mutating slice, a
sub-slice operand, and the store-then-mutate case proving the STORED key copied — and by
GolibTests.AllocationCounterTests.TmpStringMapProbeChargesNothing, which pins the zero charge in
both units.)
The NIL map key
Go’s map accepts nil as a key whenever the key type can be nil — map[any]V, map[error]V,
map[*T]V, a named-interface key — and that entry is an ordinary entry: it reads, comma-oks,
overwrites, deletes, counts toward len, is visited by range, is dropped by clear, appears in a
composite literal, and copies through maps.Clone. The converter renders it as default!, so the
Go and C# sides line up member for member:
m := make(map[any]string)
m[nil] = "nil-key"
v, ok := m[nil]
delete(m, nil)
lit := map[any]int{nil: 1, "b": 2}
var m = new map<any, @string>();
m[default!] = nilKeyˢ;
var (v, ok) = m[default!, ꟷ];
delete(m, default!);
var lit = new map<any, nint>{[default!] = 1, [(@string)"b"u8] = 2};
golib’s map<TKey, TValue> wraps a Dictionary<TKey, TValue>, which rejects a null key with
ArgumentNullException before its comparer is ever consulted — there is no comparer to teach. So
the nil entry gets a slot of its own: the backing store is a private Dictionary<TKey, TValue>
subclass carrying HasNilKey + NilKeyValue, and every member of the map surface routes a null
key to that slot (indexer get/set, Set, comma-ok, TryGetValue, ContainsKey, Add, Remove,
Clear, Count, both enumerators, Keys/Values, the copy constructor behind CloneMap, and
ToString). range yields the nil entry ahead of the buckets — Go’s range order is unspecified and
deliberately randomized, so the position is free, and every map without a nil key stays on the
dictionary’s own enumerator unwrapped.
Two design points are load-bearing. First, the slot lives on the store, not on the struct: a Go
map is a reference type, so every copy of a map<K,V> value must observe the same nil entry, and a
field on the readonly struct would make a write through one copy invisible through another.
Deriving from Dictionary (rather than wrapping it) also keeps the struct exactly one reference wide
— no extra allocation, no widened value — and leaves every existing Dictionary interop path (the
implicit conversions, the ICollection<T> casts, the reflection bridge’s backing-field probe)
binding as before. Second, map<K,V> is golib’s hottest type, so the nil test is
!typeof(TKey).IsValueType && (object?)key is null: typeof(TKey).IsValueType is a JIT-time
constant, so for a value-type key — map[string]V, map[int]V, the overwhelmingly common shape —
the test and every branch it guards fold away and those instantiations compile to exactly the code
they had before nil keys existed. Only a reference-typed key pays a null check, and the slot
operations themselves sit behind [MethodImpl(MethodImplOptions.NoInlining)] so the hot members stay
small. Measured: PerfMap (map[int]int) is flat — 276.4 ms with the slot vs 271.8 ms without
(median of three 9-run sessions each), inside the 260–310 ms run-to-run band the unchanged build
spans on the same machine.
One consumer cannot see the slot and had to be threaded explicitly: reflect.DeepEqual walks the
backing IDictionary through a reflected field probe, which never yields a nil key, and a lone nil
entry does not necessarily show up in the Len comparison either (one extra ordinary key on the
other side hides it). IMap therefore exposes a non-generic NilKeyEntry — (present, boxed value)
— with a default implementation on IMap<TKey, TValue> that asks the comma-ok indexer, so the
generated named-map wrappers satisfy it with no go2cs-gen change; DeepEqual compares that entry
before the dictionary walk. (Guarded by NilMapKey: set/get/comma-ok/overwrite/delete/len/range/
clear on map[any]string, a nil-key composite literal, map[error]int, and the nil-key reads on
both a nil and an empty map, all output-compared vs go run. Before the fix the very first
m[nil] = … threw ArgumentNullException, which is how sync’s TestIssue40999 died as an
infrastructure error.)
PRINTING a nil-key map is a second such consumer, one layer down. fmt orders map keys through
internal/fmtsort, which walks the map with reflect.Value.MapRange and compares the key Values —
and the reflection bridge typed each entry from its BOXED OBJECT, which for the nil key is null, so
Key() handed back the invalid zero Value. fmtsort.compare cannot compare that at all: it reads
aVal.Type() first thing and falls through to panic("bad type in compare: " + aType.String()) on a
nil type, so printing any map carrying a nil key died inside fmt. Go’s rule is the slot rule the
bridge already applies to struct fields and slice elements — a map entry Value is typed by the map’s
DECLARED key/value type, so map[any]V hands out Kind Interface keys whatever the dynamic value is,
and a nil key or value is a VALID nil Value of that type. MapIter now carries the map’s key and value
types (plus the map Value’s read-only bits) and Key()/Value() build through makeTypedValue, which
also makes fmtsort’s nil-compares-low rule reachable for the first time — a nil key sorts first,
exactly as Go prints it. (Guarded two ways: NilMapKey gains the printing shapes — map[any]int with
a nil key through both Println and %v, a nil-only map, map[error]int, map[*int]int — and the
new ReflectMapRangeNilKey drives the bridge directly over map[any]int, a named map[any]int,
map[*int]string, map[error]int, a concrete map[string]int, a slice-valued map and a nil
interface VALUE, printing only order-independent facts because Go’s map iteration order is
unspecified. Pre-fix it reports any: 0 0 1 5 3 — zero interface-kind keys, zero nil keys, ONE
invalid key, and a value sum of 5 instead of 6 because the nil entry was skipped — against Go’s
any: 3 1 0 6 3.)
An INTERFACE map key compares by Go equality, never by adapter identity
Go compares interface values by (dynamic type, dynamic value), and that one relation serves both
== and map-key lookup: a map[Iface]V finds an entry under exactly the values == calls equal. In
the conversion the two had diverged. Emitted ==/!= route through golib’s
builtin.AreEqual,
which unwraps the three generated adapter tiers (IInterfaceAdapter, IжAdapter, IValueAdapter)
before comparing — but map<K,V>’s backing Dictionary<TKey,TValue> used the default comparer and
compared the wrappers.
That gap is observable because an interface value’s wrapper is not stable. The same Go dynamic value is
presented through whichever adapter the static interface currently holding it calls for, so asserting an
Object to a narrower dependency yields a different wrapper object over the same receiver box.
Under the default comparer the asserted value could no longer find its own entry in the map it came out
of — while == on the very same pair still answered true, because AreEqual unwrapped. Equal but
unfindable:
M := make(map[dependency]*graphNode)
for obj := range objMap { // objMap is map[Object]*declInfo
if obj, _ := obj.(dependency); obj != nil {
M[obj] = &graphNode{obj: obj}
}
}
for obj, n := range M {
for d := range objMap[obj].deps { /* ... */ } // every key of M IS a key of objMap
}
foreach (var (obj, n) in M) {
foreach (var (d, _) in (~objMap[obj]).deps) { /* ... */ }
}
This is go/types’ own initorder.dependencyGraph, and it is why the converted type checker could not
type-check any source: the missed lookup returned a nil ж<declInfo>, and ~ on it nil-panicked
one frame later — surfacing through check.cs:430, handleBailout’s faithful re-panic of Go’s own
default: panic(p) arm, which is a bailout frame and not the fault site.
The fix is in golib and centralizes on the relation that already existed:
GoEqualityComparer
projects AreEqual as an IEqualityComparer<TKey>, hashing the unwrapped root so the hash stays
consistent with it — the same rule the compile-time ImplementGenerator adapters already applied
(m_box.GetHashCode()), which the runtime shells never had. It is installed only for key types that can
actually carry an adapter (typeof(TKey).IsInterface, or any); a concrete key — @string, an
integer, a converted struct — is never wrapped and keeps EqualityComparer<TKey>.Default’s
devirtualized path, the test being a JIT-time constant per instantiation. Restating the relation inside
each generated shell was rejected for the reason the defect illustrates: AreEqual is golib’s single
definition of Go equality, and a second copy per shell class is exactly the drift that produced this.
(Guarded by the InterfaceAssertionMapKey behavioral test — pointer- and value-receiver implementors,
an Object that is not a dependency, lookup after narrowing, lookup after re-widening, and the
Object(d) != obj identity probe that pinned the equal-but-unfindable split.)
The same relation gained its last carrier on 2026-08-19 (the crypto/tls regression): golib’s own
error<T> — the hand-written generic shell for error, the one shell go2cs-gen does not emit — had
never joined the IInterfaceAdapter unwrap protocol its generated siblings define, so AreEqual
could see through every carrier EXCEPT it and two independently minted carriers of the same error
value compared by reference. The shape needs two minters for one value, which the white-box test
model makes routine: crypto/tls’s production code never casts AlertError to error (it only boxes
it into any), so fmt’s %w assert resolved the runtime shell, while the test assembly’s
errors.Is target arrived as its own generated ᴠ value adapter — and
errors.Is(err, AlertError(alertBadCertificate)) answered false for the very alert quicError had
wrapped (TestQUICHandshakeError). error<T> now carries the identical member every generated
shell does: the ж box when pointer-backed (Go pointer-identity equality), the wrapped value
otherwise. (Guarded by GolibTests.ErrorShellCarrierEqualityTests — two shells over one value, the
pointer-identity flavor, and the protocol membership itself.)
Named map types and constrained map access
A defined map type — type Grades map[string]int — emits the [GoType("map[K, V]")] partial struct forward declaration (completing the long-standing visitMapType stub), implemented by go2cs-gen’s Map template: full forwarding of IMap<K, V> (including the two-value comma-ok indexer), IDictionary<K, V>, enumeration, and the ISupportMake factory through the wrapped map<K, V>. Its composite literal wraps the concrete map literal in the named constructor — new Grades(new map<@string, nint>{["a"u8] = 1}) — mirroring named arrays/slices (a direct indexer-initializer would target a default wrapper with no backing dictionary; the old emission produced Go-style key: value inside C# braces — CS1513). Comma-ok indexing works through a constrained map type parameter too: v, ok := m[k] where M ~map[K]V detects the map CORE of the constraint (both at the assignment’s tuple gate and in the index emission) and routes the same m[k, ꟷ] two-value indexer, which lives on IMap<K, V> itself. The nil comparison m == nil — Go’s only legal map comparison, maps.Clone’s nil-preserve guard — emits the IMap.IsNil property (if (m.IsNil); backing-store null, distinct from an allocated empty map — no operator exists on a type parameter, CS8761), and delete(m, k) on a constrained map binds a golib delete(IMap<K, V>, K) overload (key/value types infer from the interface conversion). (Guarded by the GenericTypeInference extension EqualMaps — a maps.Equal clone over a named map type through the constraint, comma-ok + comparable-erased equality, values vs Go.)
For source-generated named-map wrappers, the generator parses the [GoType("map[K, V]")] payload at the top-level comma, not every comma in the string. This matters for function-valued maps: type opTable map[CrossPkgLib.Ticks]func(int, int) int emits map<global::go.CrossPkgLib_package.Ticks, Func<nint, nint, nint>>, preserving the full delegate as the value type. Any source-file alias used inside the [GoType] payload is resolved through Roslyn and rewritten to its fully-qualified target before the template emits IMap<K, V>, IDictionary<K, V>, and ICollection<KeyValuePair<K, V>>; generated files therefore do not depend on file-local package aliases such as using token = .... (Guarded by NamedMapCrossPkgKey.)
The named arm must also carry the map type into the key/value slot emission, exactly as the
unnamed arm does. Every MapSource slot rule in convKeyValueExpr is gated on that type: a pointer
KEY or VALUE boxes to Ꮡx rather than aliasing a deref’d value into a ж<T> slot (CS0029), an any
key or value slot re-renders a string literal as (@string)"…"u8 instead of the bare u8 span (which
has no conversion to an object slot — CS1503), an untyped-constant any key boxes at Go’s default
type so the store and every lookup agree on the boxed type, and an array-typed key clones into the
map. Left nil, a named map type silently opted out of all of them, so type namedAny map[any]int
with namedAny{nil: 1, "b": 2} emitted ["b"u8] = 2 and failed to compile while the identical
unnamed map[any]int{nil: 1, "b": 2} one line above emitted [(@string)"b"u8] = 2. (Guarded by
the DeepEqual behavioral test’s named-map block, which is written over map[any]int precisely so
the any-slot rule is exercised through a named type.)
A bare make(Grades) with no size argument defaults the size to 0 — emitting new Grades(0) — so the wrapper’s allocating (nint size) constructor runs and the backing dictionary is created. The generated wrapper struct has that (nint size) constructor but no parameterless one, so a plain new Grades() would be default(Grades) — a nil map (null backing store, so m == nil is true and a write panics), whereas Go’s make returns a non-nil empty map (m == nil false, writes succeed). The default is applied only to *types.Named defined types: the unnamed map<K, V> builtin already allocates in its own parameterless constructor and stays new map<K, V>(), and a type alias (type M = map[int]int) resolves to that builtin rather than a wrapper — so neither drifts (make emission in convCallExpr.go, right beside the named-channel default below). This mirrors the named-channel unbuffered default (make(closeWaiter) → new closeWaiter(1)); a sized make(Grades, n) (already new Grades(n)) and the Grades{} composite literal are non-nil already. (Guarded by NamedMapMakeNonNil — make with and without a size, a plain nil var, and a composite literal, each == nil-compared and output-compared vs Go.)
Two [GoType] payload conventions coexist, and the generator’s alias substitution must tell them
apart. The map/channel emitters write dotted types in source-alias form (CrossPkgLib.Ticks,
via getAliasQualifiedTypeName), which the substitution above resolves; the slice/array element and
defined-over-selector emitters write the namespace-qualified form (io.fs_package.FileInfo,
via getFullyQualifiedTypeName), which roots through the go namespace and must pass through untouched.
The telltale is the segment after the leading identifier: a real alias maps to a package class,
so its next segment is a type name — a _package-suffixed next segment means the leading
identifier is a namespace segment that merely collides with a file alias. net/http’s fs.go
aliases io while declaring type fileInfoDirs []fs.FileInfo → []io.fs_package.FileInfo;
substituting the io. produced the nonexistent go.io_package.fs_package.FileInfo (CS0426 ×48).
The substitution skips exactly those occurrences (a negative lookahead on _package.). On the
converter side, the namespace-qualified form must lead with the canonical qualifier, never a
file-local Δ collision-rename: a consumer whose own namespace has a same-named child imports under
using ΔIoLike = IoLike_package;, but []ΔIoLike.FsLike_package.Info resolves nowhere in the
alias-free .g.cs — canonicalizeQualifierRename reverts a leading import-rename segment
(mirroring the visitTypeSpec global-using-target rule). (Guarded by NamedSliceChildPkg — a
nested-namespace consumer package importing both IoLike and IoLike/FsLike, with a named slice
of the subpackage’s type used across the assembly boundary.)
A map indexed by a non-empty interface key converts a concrete key expression through the same interface-adapter path used by assignments and call arguments. For example, seen[item] = "kept" where seen is map[Node]string and item is *Item emits seen[new ItemжNode(item)] = "kept"u8; the comma-ok read emits the same adapter for the key, seen[new ItemжNode(item), ꟷ]. This records the pointer implementation (GoImplement<Item, Node>(Pointer = true)) and keeps dictionary lookup semantics aligned with Go’s interface key identity. Empty-interface map keys keep their existing literal handling (map[any]... turns string literals into Go strings rather than UTF-8 spans), and pointer-typed map keys keep the direct pointer-box path. (Guarded by InterfaceMapKeyPointer.)
A pointer-keyed map indexed by the method’s receiver supplies the receiver’s box as the key, exactly like the deref-aliased pointer-parameter case: t.m[c] inside func (c *conn) … emits t.m[Ꮡc] (plain read, write, and the comma-ok t.m[Ꮡc, ꟷ] alike) — net/http transport.go’s idle-connection bookkeeping (t.idleLRU.m[pc]) passed the deref-aliased VALUE where ж<persistConn> was expected (CS1503 plus the (v, ok) deconstruction cascade). The box exists only on a direct-ж method, so the receiver-as-map-key body shape itself now promotes the method to direct-ж (bodyUsesReceiverAsPointerValue gained an IndexExpr case, gated on a pointer-KEYED map operand) — a method whose only pointer-use of its receiver is the map key still gets this ж<conn> Ꮡc. A pointer LOCAL is unchanged (it is the key — no Ꮡ), and delete(t.m, c) boxes through the ordinary pointer-argument rule once the method is direct-ж. (Guarded by PtrKeyMapReceiverLookup — pure-shape promotion, plain read/write, comma-ok, and delete through two distinct receiver identities, values vs Go.)
A value sent into a channel of non-empty interface element type converts through the same
interface-adapter path used by assignments and call arguments — the send emission previously tested
the CHANNEL type itself for interface-ness (never true, its underlying is *types.Chan), so no
conversion ever fired. A value implementation sends bare while recording the implement pair for the
generator (vs.ᐸꟷ(new dog(name: "rex"u8)) with [assembly: GoImplement<dog, speaker>]); a pointer
implementation wraps the box in its generated pointer adapter:
ps := make(chan speaker, 1)
c := &cat{name: "tom"}
ps <- c
var ps = new channel<speaker>(1);
var c = Ꮡ(new cat(name: "tom"u8));
ps.ᐸꟷ(new catжspeaker(c)); // records GoImplement<cat, speaker>(Pointer = true)
A pointer-typed send value renders as its box (parity with the argument-position rule in
convExprList), and a type-parameter element (chan T in generic code) keeps the bare emission.
The string-literal empty-interface arm of the same helper is described under
Empty Interface (any). (Guarded by AnyStringLitChanSend — a value impl and a pointer
impl sent through a chan speaker, method-dispatched on receive, output-compared vs Go.)
Named channel types
A defined channel type — type closeWaiter chan struct{} (net/http’s h2 bundle) — emits the
[GoType("chan T")] partial struct forward declaration (completing the long-standing
visitChanType stub; the whole corpus previously had NO GoType("chan …") — CS0246 at every use),
implemented by go2cs-gen’s Channel template: the wrapper holds a channel<T> and forwards its full
surface — the Go-visual send/receive members (ᐸꟷ, ꟷᐳ, including the select-registration
ᐸꟷ(v, ꓸꓸꓸ)/Sending/Receiving forms), the comma-ok Receive(ꟷ)/Received pair, IChannel’s
object-typed members, enumeration for range, the ISupportMake factory, and a (nint size)
constructor so make(closeWaiter) emits new closeWaiter(0) — a REAL unbuffered channel (the make
path resolves the chan through Underlying(), giving named channels the same unbuffered default as
plain chan T; the wrapper constructor forwards the size unclamped, so named channels can be
unbuffered — see the channel-runtime section below):
type closeWaiter chan struct{}
func (cw *closeWaiter) Init() { *cw = make(closeWaiter) }
func (cw closeWaiter) Close() { close(cw) }
func (cw closeWaiter) Wait() { <-cw }
[GoType("chan EmptyStruct")] partial struct closeWaiter;
[GoRecv] internal static void Init(this ref closeWaiter cw) {
cw = new closeWaiter(0);
}
internal static void Close(this closeWaiter cw) {
close<EmptyStruct>(cw);
}
internal static void Wait(this closeWaiter cw) {
ᐸꟷ<EmptyStruct>(cw);
}
Two deliberate wrinkles. Free-function channel ops name the element type explicitly —
ᐸꟷ<EmptyStruct>(cw), close<EmptyStruct>(cw): golib’s ᐸꟷ<T>(channel<T>)/close<T>(in
channel<T>) reach the wrapper only through its user-defined conversion to channel<T>, which C#
generic inference never considers (CS0411); the explicit type argument lets the conversion apply at
the argument instead (namedChanElemTypeArg, applied at the unary-receive, select-registration and
close emission sites — a plain chan T operand is byte-identical; a package that ALSO
declares a close method keeps the builtin. shadow qualification of the general builtin path,
so net/http emits builtin.close<EmptyStruct>(cw)). The wrapper’s Close is an
explicit IChannel implementation only: Go code commonly defines its OWN Close() method on a
named channel type (the closeWaiter shape above), and a public instance Close would shadow that
method’s extension form at every call site; close(ch) routes through the golib free function, so
no public surface is lost. (Guarded by NamedChannelType — the closeWaiter trio plus a buffered
type intQueue chan int exercising make/send/len/cap/receive/comma-ok/close/range/select, output
vs Go.)
A function-LOCAL named type declaration hoists to member level (slice/map/channel/array/pointer)
C# forbids a type declaration inside a method body, so a type X []T / type X map[K]V /
type X chan T / type X [N]T declared inside a function cannot emit its [GoType(…)] partial
struct X; forward declaration in place — the following statements would then parse as MEMBER
declarations (CS1519 Invalid token 'foreach' in a member declaration, CS1513 } expected, the
map form’s CS8124). A local type X struct{…} already hoists: visitStructType/visitIdent/
visitInterfaceType each redirect the declaration into currentFuncPrefix (emitted at member level
ahead of the method), rename it with the enclosing-function prefix (ExampleChunk_People), and
register the lifted name in liftedTypeMap so every reference resolves to it. The array/slice, map,
and channel emitters did not — they wrote the forward declaration straight into the method body
(the reported slices example_test/maps maps_test defect). The shared helper liftLocalTypeDecl
(visitTypeSpec.go) now applies that same hoist to all three: at package scope it is a no-op
(target stays v.targetFile, finish() does nothing, so production emission is byte-identical),
and inside a function it prefixes the name, registers the lift, redirects to a member-level builder,
and flushes into currentFuncPrefix. A local slice/array of a local element type also needs the
element resolved to its lifted name: visitArrayType’s simple-identifier fast path (which keeps the
written name so [3]rune stays rune) is skipped when the element is itself a lifted local type
(!v.liftedTypeExists), routing it through getFullyQualifiedTypeName, which resolves liftedTypeMap — so
type People []Person (Person a local struct) emits [GoType("[]ExampleChunk_Person")] partial
struct ExampleChunk_People;, not the raw []Person. (Guarded by the LocalNamedTypeDecls
behavioral test — a function-local named slice-of-local-struct, map, channel, and fixed-size array,
each constructed/ranged/indexed in the body and output-compared vs Go; the unfixed converter leaks
four partial struct …; declarations into the method body.)
Two completions of the same rule, both demonstrated by encoding/gob’s test suite:
-
The POINTER kind hoists too.
type X *Twas the one forward-declaration kind still writing its[GoType("ж<…>")] partial class X;straight into the body — gob’scodec_test.gotype Rec ***RecproducedCS1525 Invalid expression term 'partial'and took the rest of the function with it. It now takesliftLocalTypeDecllike the other kinds, and the lift is taken beforeconvStarExprrenders the pointer text so a self-referential declaration resolves its own name throughliftedTypeMap. -
A SELF-REFERENTIAL local type re-resolves its element after the hoist. The array/map/channel
emitters resolved the element/key/value name before the declaration’s own hoist registered its
lifted name, so
type recursiveSlice []recursiveSlice/type recursiveMap map[string]recursiveMap(gob’sencoder_test.go) emitted[GoType("[]recursiveSlice")]on a member-levelTestRecursiveSliceType_recursiveSlice— a name that no longer exists,CS0246inside the generated slice/map partial. Each emitter now re-resolves its element throughliftedTypeMapwhen the hoist actually renamed the declaration; a package-level declaration never renames, so its emission is untouched (verified byte-identical across the whole behavioral corpus and the 302-package stdlib).
The ALIAS kind takes the lift too — and for a different reason. A local declaration that emits a
using ALIAS rather than a nested type — a real type X = Y, or a defined type over a named
interface such as type X any — was the last local type-declaration kind not taking the hoist. It
needs no member-level redirection (an alias is emitted at file scope either way), but it needs the
NAME, because the alias it writes is a global using: scoped to the whole compilation, not to
the file, let alone the function. Two functions declaring type testFnc any therefore claimed one
alias name — CS1537 the using alias 'testFnc' appeared previously in this namespace — whether they
sat in one file or in two of the same compilation. archive/tar’s suite is the shape: testFnc is
declared in writer_test.go’s TestWriter and TestFileWriter, and again in reader_test.go’s
TestFileReader, with fileMaker alongside it; three diagnostics held all 97 of that package’s
verdicts. The naming half of liftLocalTypeDecl is now the shared liftLocalTypeDeclName, and the
alias branch calls it when v.inFunction, emitting global using TestWriter_testFnc = object;.
The reference mapping is registered under a guard, liftedTypeDeclaredBy: only a *types.Named
or *types.Alias whose own Obj is this declaration qualifies. A wrong key here renames every
reference to an unrelated type — type X = Header inside a function binds the declaration’s object
to the existing Header, and (without materialized aliases) type X = int binds it to plain
int, so keying the lift on either would rewrite every Header, or every int, in the file.
Anything that does not qualify registers nothing and renders exactly as before. A function-local
declaration is also no longer published in exportedTypeAliases: it is not part of the package’s
exported surface whatever its Go name looks like, and after the lift the name a consumer would
import does not exist. Zero production-corpus impact by construction — an AST scan of the Go
1.23.1 sources finds no function-local alias-or-defined-over-interface declaration in any compiled
stdlib file (all 50 hits are internal/types/testdata, which is never built), which is why only two
test suites ever met it. (Guarded by the LocalTypeAliasScope behavioral test — the same local
names declared in two functions of one file and again in a second file of the same package, plus a
real type hdr = Header alias whose target is used bare alongside it; the unfixed converter emits
five duplicate global using lines.)
Known residual, a different one, in the same emission line: an alias whose target is an unnamed
composite renders its type ARGUMENTS unrooted — type names = []string emits global using names =
go.slice<@string>;, where only the outermost name is rooted and @string, a nested slice,
error, complex64, a same-package Header and a foreign io_package.Reader all arrive bare and
do not resolve at compilation scope (CS0246). This is package-level, not function-local, and
predates the lift above; getUsingAliasSafeTypeName exists for exactly this class of problem
(a using-alias RHS is resolved without reference to other using directives) but rewrites only the
csproj-level golib name aliases, never the rooting. No converted stdlib package declares such an
alias, so the corpus has never reached it; a converted user module would.
Known residual: a conversion expression to a hoisted local named pointer type
(NodePtr(&Node{V: 9}), with type NodePtr *Node declared in the function) still emits the
pre-hoist source name (new NodePtr(…), CS0246). The composite kinds do not have this — a local
Tally(m) correctly renders ((main_Tally)m) — so the gap is specific to the named-pointer
conversion arm’s target-name resolution. It was previously masked by the hard syntax error above and
has no consumer among the measured packages (gob only declares Rec and takes its address); the
LocalNamedTypeDecls guard therefore uses the assignment form var np NodePtr = &Node{V: 9}.
An embedded field’s NAME is the UNQUALIFIED type name (dot-imported embeds)
An embedded struct field’s name is, per the Go spec, the unqualified type name. A cross-package
embed written as a selector (struct{ io.Writer }) already stripped its qualifier for the field
name; a dot-imported embed (import . "io" then embedded ReaderFrom) reaches the emitter as a
bare *ast.Ident, yet getAliasQualifiedTypeName still renders it package-qualified — and, once the package is a
collision-rename, as Δio.ReaderFrom. Gating the qualifier-strip on the selector form left that
qualifier in the field name (internal io_package.ReaderFrom Δio.ReaderFrom;), whose embedded dot is
a C# syntax error (CS1003 '(' expected / CS1026 ') expected' — the reported io io_test
defect). visitStructType now strips to the last segment whenever the resolved embedded-type name
carries a qualifier (covering both the selector and dot-imported-ident forms; a same-package embed
has no dot, so it is a byte-identical no-op), yielding the correct public io_package.ReaderFrom
ReaderFrom;. (This is one root among several in the io test suite, which remains blocked by separate
import . "io" using-alias resolution issues — the Δio namespace is emitted but never aliased.)
Select statement lowering (terminating and empty clauses)
A select lowers to a C# switch over a golib runtime call that commits exactly ONE case and returns its ordinal: the blocking form switch (select(ᐸꟷ(a, ꓸꓸꓸ), …)) (selectgo — commits a uniformly-random ready case or parks), and the default form switch (trySelect(…)) (the same poll pass, returning -1 so the C# default: label runs when no case is ready). Receive cases keep a case N when selᴛN.ꟷᐳ(out v): guard that consumes the committed value; send cases are performed by the runtime commit and get a bare case N: label. Every case’s operands are hoisted into select-scoped temps (var selᴛN = …;) emitted in strict source order and evaluated exactly once at select entry — a receive case’s channel operand (used by BOTH the registration and the guard) and a send case’s whole registration call, so the registration list names only temps (see the operand-evaluation section below). The registration calls (ᐸꟷ(ch, ꓸꓸꓸ) receive, ch.ᐸꟷ(v, ꓸꓸꓸ) send) return SelectOp case descriptors and select(params SelectOp[]) runs a faithful selectgo (see the channel-runtime section below): it commits exactly ONE ready case — chosen uniformly at random — or parks until one becomes ready. A committed receive’s value crosses to the winning case’s unchanged guard (case N when ch.ꟷᐳ(out v):) through a per-thread pending-frame stack the guard pops (a stack, so a select nested in the guard’s target expression cannot destroy the outer commit — see the channel-runtime section), so the emitted select text is identical to the pre-redesign form. Two structural completions (io pipe.go’s read):
-
An EMPTY clause body still needs its jump. C# requires every switch section to end in a jump statement (CS8070 on a final
default:, CS0163 otherwise); the emittedbreak;was suppressed when the previous clause ended in a terminalreturn(the was-return flag is reset per statement, and an empty body has none). The flag resets per clause now — a bare Godefault:emitsdefault: { break; }. -
A terminating blocking select gets an unreachable trailing
return default!;. Go’s spec makes a select with nodefault:whose every comm-clause body ends in a terminating statement itself terminating, so a value-returning function may end with it. The lowered form’s guardedcase N when <recv>:labels cannot prove exhaustiveness to C# (CS0161). Mirroring the switch guarded-terminal-default rule, the emission appendsreturn default!;after the closing brace — gated on: no default, every clause terminating (isTerminatingStmtList, conservative), no select-targetingbreak, a value-returning signature, and not named-return-defer mode (void wrapper).
The golib non-blocking receive underpinning the default-form guards distinguishes the two “no value” cases per Go semantics: a closed empty channel is receive-ready with the zero value; an open empty channel reports not-ready, so the default: is taken. (Guarded by the SelectStatement extensions firstMsg — terminal blocking select in a value-returning func — and poll — empty default: after a returning case, polled both before and after close.)
A NIL channel is never ready — and asking must not throw
golib models a channel as a struct, so the nil channel is that struct’s ZERO value: every field
is null. Go gives a nil channel well-defined behavior — it is never closed, a receive or send on it
blocks forever, and in a select with a default the nil case is simply not chosen — so the
readiness probes must report “not ready” rather than dereference the absent state. Most of them
already did (SendIsReady / ReceiveIsReady / Receiving all null-check their backing fields);
IsClosed did not, so merely asking whether a nil channel was closed threw a
NullReferenceException.
This is not an exotic shape. os/exec’s Start runs
if c.ctx != nil {
select {
case <-c.ctx.Done():
return c.ctx.Err()
default:
}
}
and context.Background().Done() is a nil channel, so every child process launched through a
background context crashed in the probe — the last blocker on math/rand’s TestDefaultRace.
IsClosed now reports false for a nil channel, which makes the non-blocking receive fall through
to “not ready” and the default: clause run, matching Go. (Guarded by NilChannelSelectDefault:
nil receive and comma-ok receive taking the default, len/cap of a nil channel, a real channel
behaving normally alongside, and a mixed select where the nil case must never win over a ready real
case.)
The default form routes through trySelect — send cases are unguarded in both forms
The default: form was originally lowered as switch (ᐧ) with per-case try-operation guards
(case ᐧ when ch.ꟷᐳ(out v): / case ᐧ when ch.ᐸꟷ(v, ꟷ): — the interim fix for the dropped-send
defect os/signal’s process exposed, where an unguarded case ᐧ: ran unconditionally and silently
dropped the value). That shape is single-fire by construction (C# evaluates the ordered guards until
the first true) but its ready-case choice is the case order, never uniform-random — provably
unfixable against an ordered C# switch. The default form now routes through golib’s non-blocking
trySelect(…): the same registrations as the blocking form, the same selectgo poll pass (distinct
cores locked in Id order, Fisher-Yates pollorder, exactly one commit under the held locks), no
parking, and -1 — the default sentinel matched by the C# default: label — when no case is ready:
select {
case c <- sig:
default: // send but do not block for it
}
switch (trySelect(c.ᐸꟷ(sig, ꓸꓸꓸ))) {
case 0: {
break;
}
default: {
break;
}}
Send cases get a bare case N: label in BOTH forms — the runtime call performed the winning send
itself, so a guard would either send the value a second time or fail and silently skip the chosen
clause body. Receive cases keep their case N when ch.ꟷᐳ(out v): guard, which consumes the
committed value from the runtime’s per-thread pending-frame stack. Go’s remaining rules live in the
runtime: a closed channel’s send case panics (Go panics even when a default: exists — the
poll pass checks closed before readiness, so a closed FULL channel panics rather than taking the
default), and a nil channel’s case is never ready, so it is never chosen. golib’s non-blocking
Sent/ᐸꟷ(v, ꟷ)/TrySend surface remains for direct non-blocking sends (and the -uco=false
named-method mode), delegating to the same single runtime send implementation.
A pointer-element channel forced two further root fixes, both pre-existing and both previously
unreachable because the dropped send never compiled the value expression. net/rpc’s
func (call *Call) done() sends call.Done <- call: (1) the capture-mode pre-pass had no
send-value position, so the method was never promoted to direct-ж and had no receiver box to hand
out — bodyUsesReceiverAsPointerValue now recognizes a SendStmt whose value is the pointer
receiver; and (2) convSendValueExpr applied the pointer ident context only for interface
elements, so a deref-aliased pointer (a pointer parameter, or the receiver) rendered as its value
and could not bind the in ж<T> send parameter (CS1503). Both forms of send route through
convSendValueExpr, so the statement form ch <- recv — broken in exactly the same way — is fixed
by the same change.
(Guarded by SelectSendDefault: full buffered taking the default then the same select succeeding
once drained, free-capacity buffered delivering the value, unbuffered with a waiting receiver, nil,
closed-panics-through-the-default, one-ready-among-several, a send and a receive case with neither
ready, exactly-one-send when several are ready, and a no-default select still blocking.)
Every case’s operands are hoisted — evaluated exactly once, in SOURCE ORDER, at select entry
Go’s spec evaluates, for every case in the statement, a receive operation’s channel operand and a
send statement’s channel AND right-hand-side expressions exactly once, in source order, upon
entering the select. Leaving an operand inline in the select(…)/trySelect(…) registration
argument list breaks that in two distinct ways.
Evaluated twice. A receive case’s operand appears in the registration call AND again as the
winning guard’s receiver, and C# reads a struct method call’s receiver AFTER evaluating its
arguments, so even a bare identifier can change under the guard (the out-target expression runs
first). A non-referentially-stable operand — case <-time.After(d): (net/http/pprof),
case <-fresh():, or an identifier the out-target reassigns — re-evaluates to a DIFFERENT channel:
the runtime’s pending-frame core match then (correctly) refuses delivery, and the factory’s side
effect runs twice.
Evaluated out of order. C# evaluates the registration arguments in argument order, i.e. AFTER
every hoisted temp. A send case left inline therefore had its channel operand and value expression
observed after a later receive case’s operand: a select whose FIRST case was a send observed
[recv-chan, send-chan, send-val] where Go’s order is [send-chan, send-val, recv-chan].
The converter therefore hoists EVERY case’s operands into select-scoped temps, emitted in strict
source order, leaving the registration list naming only temps. Uniformly, with no stability
analysis — channel<T> struct copies share one core, so the temp preserves identity, and the hoist
IS Go’s up-front-once evaluation model:
select {
case v := <-fresh():
...
}
var selᴛ1 = fresh();
switch (select(ᐸꟷ(selᴛ1, ꓸꓸꓸ))) {
case 0 when selᴛ1.ꟷᐳ(out var v): {
...
break;
}}
A SEND case hoists its whole registration call rather than two separate operand temps
(SelectSendRecvMix, a send case textually first on a full channel plus a receive case on the
same channel):
select {
case ch <- 8:
fmt.Println("send fired on full channel (wrong)")
case took = <-ch:
}
var selᴛ3 = ch.ᐸꟷ(8, ꓸꓸꓸ);
var selᴛ4 = ch;
switch (select(selᴛ3, ᐸꟷ(selᴛ4, ꓸꓸꓸ))) {
case 0: {
fmt.Println("send fired on full channel (wrong)");
break;
}
case 1 when selᴛ4.ꟷᐳ(out took): {
break;
}}
That is both legal and stronger than two operand temps. Sending/ᐸꟷ(v, ꟷ) only BUILDS a
SelectOp descriptor — golib’s Sending is return new SelectOp(m_core, isSend: true, sendValue:
value); — and the communication is performed later by the runtime commit inside
select/trySelect, so moving the call ahead of the switch moves no send. The call evaluates its
receiver then its argument, i.e. channel operand then value expression, contiguously and in source
order: exactly Go’s rule. And the value expression keeps its ORIGINAL argument position, so every
implicit conversion the in T parameter applies — untyped-constant narrowing to the element type,
interface-adapter wrap, @string/nint boxing, array clone (see the send-value rules above) — is
preserved by construction, with no new type inference anywhere. A separate value temp would have to
re-render the element type to declare itself, and var inference is provably wrong there:
case bch <- 200: on a chan byte becomes var t = 200; — an int, which no longer converts to
byte at the call (CS1503) — and any divergence in a hand-rendered element type would SILENTLY
change the conversion instead. The whole-call hoist also leaves the ж<T>-pointer element case
(net/rpc’s call.Done <- call) unaffected by construction.
A send case’s winning label stays a bare case N: — the runtime commit performed the send, so it
carries no guard and nothing re-evaluates.
(Guarded by SelectOperandOnceEval for the once-only property — ready and parked call-expression
operands with printed call counters, the reassigned-identifier out-target, and the default form;
counter-proven against the pre-fix emission, which FailFasts on the pending-frame core-match assert.
And by SelectOperandSourceOrder for the ordering property — a send case textually first with all
three operand expressions logging their fixed source positions: the blocking form with the receive
winning and with the send winning, a default-form select interleaving send/receive/send with nothing
ready (including an untyped 200 into a chan byte and a value boxed into a chan any), and the
already-correct receive-first direction as a regression anchor. Every select there is deterministic
by construction — exactly one case can ever be ready — so the uniform-random commit never affects
the output. Counter-proven against the pre-fix converter, which prints
3:recv-chan 1:send-chan 2:send-val where Go prints 1:send-chan 2:send-val 3:recv-chan.)
Known exposure: marker-shaped USER identifiers can collide with synthetic names
The converter’s synthetic-name markers — ᴛ (TempVarMarker, U+1D1B: selᴛ1, tupleᴛ2,
elemᴛ0, iᴛ1, initᴛ<name>, lifted-type <name>ᴛ1), ʗ (CapturedVarMarker), Δ
(ShadowVarMarker), and the rest of the Symbols.cs family — are exotic Unicode LETTERS, legal in
Go identifiers. A Go program that itself declares an identifier matching a generated shape (e.g.
selᴛ1 used in a select, emitting var selᴛ1 = selᴛ1;) collides with the synthetic name —
loudly, at C# compile time (CS0128/CS0102), never silently. A general Δ-rename of user
identifiers matching the numbered-temp shape was attempted at the sanitizer choke point
(getCoreSanitizedIdentifier) and REJECTED: that choke point also renders the converter’s own
synthetic names (loop temps iᴛ1, lifted anonymous/named-value types main_MyBoolᴛ1,
cross-file anon-struct names), so the blanket rule Δ-renamed synthetic names too and churned
non-select goldens; distinguishing user from synthetic identifiers requires threading origin
through many naming call sites — deliberate sprawl for a trigger that demands typing U+1D1B in
Go source. Accepted as a documented family-wide exposure: the failure mode is a compile error
naming the colliding identifier, and the workaround is renaming the pathological identifier in
the Go source.
Real channel runtime — the hchan/selectgo port (rendezvous, cap/len, single-fire, uniform-random)
The four long-standing channel-semantics gaps (no unbuffered rendezvous; make(chan T) conflated
with make(chan T, 1); a blocking select performing EVERY send case; first-match instead of
uniform-random ready choice) were closed together by rewriting golib channel<T> over a faithful
port of Go’s runtime machinery (docs/phase4/DESIGN-channels.md — the blessed synthesized design;
rendezvous and the select rework land as ONE unit because staging rendezvous first regresses the
legacy Sending path):
-
ChanCore<T>is thehchananalog: a Monitor lock, a circularT[]buffer (null whendataqsiz == 0),sendx/recvx/qcount,closed, intrusiverecvq/sendqparked-waiter queues, and a monotonicId(the total lock order for select). Thechannel<T>struct holds only a reference to its core, so the zero value is the NIL channel and struct copies share one channel.chansend/chanrecv/closechanfollow Go’s routines exactly, including the buffered-full parked-sender head-take/tail-enqueue rotation and drain-before-zero comma-ok (a closed channel yields its remaining buffered values withok == truefirst, then(zero, false)). -
make(chan T)emitsnew channel<T>(0)— capacity 0 is a real rendezvous channel;cap()isdataqsizandlen()isqcount, somake(chan T)vsmake(chan T, 1)are finally distinct (the make default inconvCallExpr.gocovers plain and named channels; the gen Channel template’s wrapper constructor no longer clampssize < 1to 1). A parked operation blocks its goroutine’s own dedicated thread (Goroutine.Start), so parking costs nobody else’s capacity and a program can park thousands of goroutines at once — the shapeGoroutineParkStormguards. golib used to queue goroutines on the shared ThreadPool and raise its min-thread floor tomax(256, 4 × processor count)to compensate; both the floor and its premise retired with the dedicated-thread executor (docs/phase4/DESIGN-cooperative-scheduler.md). -
Blocking select is a selectgo port behind the unchanged emitted text. The registration
methods (
Receiving,Sending,ᐸꟷ(ch, ꓸꓸꓸ),ch.ᐸꟷ(v, ꓸꓸꓸ)) return type-erasedSelectOpdescriptors — invisible to overload resolution at every emitted call site — andselect(params SelectOp[])partitions out nil channels (never registered), locks the distinct cores inIdorder, scans a Fisher-Yates-shuffled poll order, and commits exactly one ready op under the held locks (uniform-random single-fire, gaps 3+4); otherwise it parks oneSelectState-linked waiter per case, where a singlewinnerCAS is the single-fire authority every waker — plain send, plain receive, another select’s commit, AND close — must win before touching a waiter. Publish-before-signal ordering and park-outside-the-lock discipline throughout. -
The committed receive value crosses to the guard via a per-thread pending-frame STACK (not a
single slot):
select/trySelectpush a frame (channel core, value, ok) on a receive commit, and the winning guard (Received/ꟷᐳ) pops exactly the frame whose core matches its own channel. A stack because the guard’s out-argument TARGET expression is evaluated BEFORE the guard call, and legal Go can run another select there (case a[f()] = <-ch:wheref()selects) — the inner select pushes and pops its own frames, so the outer commit survives; a single slot was destroyed by the inner select’s entry (outer value lost, or the next buffered value stolen — found by the adversarial verification round). Only receive commits push frames; a send-case win touches nothing (a select may have send and receive cases on the SAME channel, and clearing would destroy an outer frame mid-nest). Known residual: a panic unwinding between commit and consume strands a frame — unbounded under a repeated panic-in-target/recover loop, an accepted benign memory residual. The stack must never be CAPPED: live depth is dynamic, not textual — one textual select whose out-target expression recurses holds one live frame per recursion level, so a depth cap silently drops live outer frames (theDeepSelectRecursionguard, 100 levels, falsified an attempted depth-64 cap). Frames are never MIS-consumed (every consume matches the top frame by channel core); a strand stacked above a live frame makes the outer select fire zero cases — the committed value is abandoned exactly as the panic abandoned the communication, never delivered wrongly. Debug-only depth warnings, never a process-killing assert. With no matching frame the same guards are non-blocking probes, unchanged. -
A channel may have an OWNING TIMER (Go’s
hchan.timer), the hook Go 1.23’s synchronous timer channel needs:IChannelTimeris installed bychannel<T>.AttachTimerbefore the timer is armed,Capacity/Lengthreport 0 while the owner answersHidesBuffer(Go’schanlen/chancaptimer-channel branch), andDrainBuffer()— Go’sruntime.timerchandrain— empties the buffer without servicing parked waiters, so the owner can REVOKE a value the channel already accepted. It is the only sanctioned way to un-send, and only sound for a channel whose producer owns it exclusively;IsUnbufferedkeeps reporting the physical shape. Full semantics under Realizing the runtime TIMER contract. -
Close/panic semantics are Go’s: send on closed panics (even from within a select, and even
when a
default:exists); close of closed and close of nil panic; a parked select-send woken by close panics on its own thread; parked receivers (plain and select) wake with(zero, false); range-over-channel terminates on closed-and-drained;len/capof nil are 0. A boxedIChannel’s nil comparison is representation nilness (channel is null) — the old{ Length: 0, Capacity: 0 }pattern would misclassify a live empty unbuffered channel as nil.
(Guarded by ChannelRendezvous — cap/len 0, not-ready probes with no counterpart, rendezvous
round-trip, ping-pong alternation; ChannelCapLen — buffered fill/wrap/drain, nil/unbuffered
len/cap, comma-ok drain-after-close; SelectSingleFire — exactly one delivery among multiple ready
send cases, 100-iteration volume guard; SelectSendRecvMix — send+recv cases on the same channel,
one-commit-per-select; SelectRandomFairness — both branches of a two-ready select taken over 200
iterations; CloseWakesBlocked — close waking parked receivers/senders/selects in both directions
plus the whole panic family; NilChannelInSelect — nil cases never ready beside live ready and
parked cases; plus the extended NamedChannelType unbuffered named-channel rendezvous and the
pre-existing select/channel suite.)
An escaping comm-clause binding receives into a temp and heap-boxes at clause entry
A case result := <-ch: whose bound variable’s address is taken in the clause body — internal/fuzz
coordinatorLoop’s c.crashMinimizing = &result and writeToCorpus(&result.entry, …) — escapes to
the heap, so the body’s address-of emission references the Ꮡresult box companion (an escaping :=
local’s form). The comm-clause label emitted only a plain out var result, never a box, leaving
Ꮡresult undeclared (CS0103 ×2, the last own-errors keeping internal.fuzz red after its CS0234s
cleared). The when guard’s out var slot cannot declare a ref local, so selectCommBinding
(visitSelectStmt.go) receives into a uniquely-numbered temp and opens the clause body with the
entry-time box pattern proven by the escaping-parameter preamble:
case 2 when (~c).resultC.ꟷᐳ(out var resultᴛ1): {
ref var result = ref heap(resultᴛ1, out var Ꮡresult);
The gate is identHasHeapBox — the exact predicate the body’s &name emission uses — so the box is
declared iff it is referenced; alias/box names mirror convertToHeapTypeDecl (sanitized analyzed name
for the value alias, raw analyzed name behind Ꮡ for the box, matching boxBaseName). Both bindings
of the (val, ok) form are checked. A non-escaping binding keeps the direct out var <name> form
(preserving the shadow-rename render, e.g. out var errΔ5), and an ASSIGN-mode rebind of an existing
boxed local already writes through its ref alias — the full-stdlib A/B footprint was exactly
internal/fuzz/fuzz.cs. (Guarded by SelectEscapeBinding — escaping binding written through both
directions, escaping (val, ok) binding with a field address through the box, and a mixed
escaping/plain select, output-compared vs Go; the pre-fix converter fails it with exactly the
CS0103 Ꮡres class. A clause taking ONLY a field address (&res.value, no whole-var &res) still
copy-boxes — the known assignment-position escape-analysis gap, out of scope here.)
Generic Constraints
A Go generic constraint becomes a C# where clause. Most type-set constraints lift to the matching golib/.NET interface — a []T element constraint to ISlice<T>, [N]E array-core to IArray<E>, map[K]V to IMap<K,V>, chan T to IChannel<T> — plus, for operator-bearing type sets, the System.Numerics operator interfaces (IAdditionOperators, IComparisonOperators, …) so the body’s +/</== on the type parameter compile. The Go built-in comparable maps to golib’s CRTP comparable<T>.
An array-core constraint ~[N]E lifts to IArray<E>
A type-set constraint whose core is an ARRAY — func polyAdd[T ~[256]fieldElement](a, b T) T (ML-KEM’s
ringElement/nttElement share the core [256]fieldElement) — must map to where T : IArray<E>, NOT
to the operator interfaces the general type-set path would produce. An array is a comparable type in
Go, so the operator-set resolver put Array in the comparable set and lifted IEqualityOperators<T, T,
bool>; the named-array [GoType] wrapper (which the converter emits for ringElement etc.) does not
implement that interface, so every instantiation failed CS0315, and the interface exposes no array
surface, so the body’s t[i] (CS0021), for i := range t (CS8130 on the index deconstruction), and
for _, x := range t (CS1579/CS8183) had nothing to bind against. The fix (getArrayConstraintElem in
constraintOperations.go, a new branch in getGenericDefinition) detects a single-array-core type set,
extracts the element type, and emits where T : /* ~[N]E */ IArray<E>, new(). The array wrapper already
declares IArray<E>, ISupportMake<wrapper> (the go2cs-gen Array inherited-type template), whose
ref E this[nint] indexer and IEnumerable<(nint, E)> enumeration supply exactly the indexing/ranging
surface the body needs, and the new() (appended by the same path) covers var f T/T{} construction.
Greened crypto/internal/mlkem768 (census 254 → 255); the reconvert A/B changed only mlkem768.cs’s
four constraint lines. (Guarded by GenericArrayConstraint — two array-wrapper types over a shared
~[4]fieldElement core through a generic function that indexes, index-ranges, value-ranges, and
constructs the type parameter, values vs Go.)
A type set of COMPOSITE terms lifts nothing — the union survives only as a comment
The array-core entry above fixed one SHAPE of a wider defect, and the rest of it surfaced on
runtime/pprof’s testProfileRecordNullPadding[T runtime.StackRecord | runtime.MemProfileRecord |
runtime.BlockProfileRecord] — a union whose terms are all plain structs, which was the whole of that
package’s build wall (five call sites, error CS0315 on each).
The root is what IEqualityOperators<T, T, bool> MEANS on each side. Go’s == works on any comparable
type, so the operator-set resolver listed Struct, Array, Pointer and Channel in
comparableOperatorTypes and lifted that interface for them. But a C# where clause is a claim about
the type ARGUMENT implementing a BCL interface, not about an operator being available, and nothing on
the Go side of the corpus implements it: a [GoType] struct, array<T>, ж<T> and channel<T> all
compare through Equals/AreEqual. The lifted clause was therefore unsatisfiable by construction, and
the diagnostic named the concrete struct rather than the constraint that could not admit it.
Two changes, both in constraintOperations.go. The composite kinds leave comparableOperatorTypes, so
the rule is stated once where the operator sets are defined instead of per constraint shape (the
array-core branch’s hand-written suppressLiftedConstraints was the same rule applied to one shape; a
union of named array types took no such branch). That alone exposed the fall-through underneath: with
no operator lift and no interface to name, getGenericDefinition’s generic tail emitted the Go union
text VERBATIM as a C# constraint list — where T : runtime.StackRecord | runtime.MemProfileRecord | …,
error CS1003 ×4, a syntax error rather than a type error. So constraintTypeSetIsInexpressible closes
it, asked LAST after every shape with a real emission has been tried: a non-empty type set whose
operator set is empty emits the union as a breadcrumb comment plus the one constraint C# can still
express —
internal static T testProfileRecordNullPadding<T>(ж<testing.T> Ꮡt, @string name, Func<slice<T>, (nint, bool)> fn)
where T : /* runtime.StackRecord | runtime.MemProfileRecord | runtime.BlockProfileRecord */ new()
— the same answer, for the same reason, the built-in comparable arm
reaches: Go’s own checker validated every instantiation before conversion, so the C# clause has nothing
left to enforce. new() is kept (unlike that arm) because a composite type set admits no pointer type
argument. The corpus footprint is one line: censused at the fix, this was the ONLY converter-emitted
IEqualityOperators clause in the whole corpus that is not on a numeric or ordered union — which is the
shape’s own signature, since a composite type set is a subset of no other operator set and so lifts the
comparable operators ALONE, with no arithmetic siblings. (Guarded by Constraints — a struct-only
recordA | recordB | recordC union through a generic function instantiated at each term; without the
fix it reproduces CS0315 at all three sites.)
A single-term pointer constraint [P *T] erases the parameter to ж<T>
A type parameter constrained to a single, non-tilde pointer term — go/types’ flat-copy helper
func clone[P *T, T any](p P) P { c := *p; return &c } (predicates.go) — cannot be modeled as a C#
type parameter: no C# constraint fixes a parameter to a specific constructed type, and ж<T>
implements no interface through which *p/&c could be expressed generically. The operator-lift
fallback emitted where P : /* *T */ IEqualityOperators<P, P, bool>, new() with the deref dropped
(c = p — CS0029 P→T) and the box mismatched (return Ꮡc — CS0029 ж
The Go spec makes the faithful lowering an identity, not an approximation: a non-tilde term’s type
set is a singleton, so P’s only permissible type argument is *T itself. The converter therefore
erases such a parameter (pointerCoreConstraint in constraintOperations.go): it leaves the
emitted <…> list and where clauses (a breadcrumb comment preserves the Go constraint), renders as
ж<T> everywhere it appears (a getAliasQualifiedTypeName arm beside the *types.Pointer arm), and the parameter
classification treats a p P exactly like a p *T (paramPointerType — deref alias, Ꮡ box naming),
so the entire existing pointer machinery applies unchanged:
internal static ж<T> clone<T>(ж<T> Ꮡp)
/* where P : *T (erased: P renders as ж<T>) */
{
ref var p = ref Ꮡp.Value;
ref var c = ref heap<T>(out var Ꮡc);
c = p;
return Ꮡc;
}
Call sites drop the erased position from any synthesized explicit type-argument list
(renderedTypeArgs, applied at convCallExpr’s two synthesis blocks and convSelectorExpr’s
method-group form): clone(asig) emits clone<ΔSignature>(asig), and a callee whose remaining
parameters make C# inference sufficient stays bare (setThrough(Ꮡn, 55)). An EXPLICITLY written Go
instantiation equally drops erased positions — full (setThrough[*int, int](…) →
setThrough<nint>(…)), partial (clone[*thing](…) → bare clone(…), the rest inferring), and the
function-VALUE form (fv := clone[*thing, thing] → var fv = clone<thing>;) — via
explicitTypeArgsAfterErasure in convIndexExpr/convIndexListExpr. A C# consumer calls the emitted
method naturally — T sits in a real parameter position, so inference works without spelling the
phantom P.
The pointer classification flips at every use shape, not just the deref/address pair: returning the
parameter WHOLE yields its box (return a → return Ꮡa;), passing it onward to another erased
callee — including self-recursion — supplies the box (cloneChain<T>(clone<T>(Ꮡp), …); the
interface-shaped argument arm carves out erased params exactly like instantiated pointers), copying
it into a local is a Go pointer copy (q := p → var q = Ꮡp;, writes through q land in the
caller’s referent), and a nil comparison takes the box form over the nil-deferring entry alias
(if p == nil → ref var p = ref Ꮡp.DerefOrNull(); … if (Ꮡp == nil) — a nil argument reaches the
guard instead of throwing at entry, e.g. orZero[*int, int](nil)). The NAMED constraint-interface
spellings — [P PtrOf[T]] and the embedded [P interface{ PtrOf[T] }], where
type PtrOf[T any] interface{ *T } — resolve to the identical singleton type set and erase
identically. The constraint interface’s own DECLARATION follows the existing constraint-interface
convention ([GoType] partial interface PtrOf<T> { /* Type constraints: *T */ }): a pointer term is
a type-set term, not an embeddable interface (previously it emitted an interface inheriting the
struct ж<T> — CS0527), and a GENERIC constraint interface carries its own <T> list, so the
arity-0 <ΔT> marker list and its generated operator machinery are both suppressed for it.
Erasure is deliberately gated to the identity case: function type parameters whose constraint
type-set is a single non-tilde pointer term. Declined shapes warn instead of silently mis-emitting —
an approximate ~*T admits named pointer types, which emit as [GoType("ж<E>")] wrapper classes
(not identity with ж<E>); pointer unions have no single identity; and erasing a generic named
type’s parameter would change its emitted arity at every use. None occur anywhere in the converted
stdlib (exhaustive GOROOT census: go/types’ clone is the only compiled occurrence of the pattern;
see DESIGN-pointer-core-typeparam.md on the fix branch for the full study). (Guarded by
PointerCoreConstraints — clone/read/write/round-trip through [P *T] and the swapped-order
[T any, P *T], flat-copy independence verified, values vs Go.)
An integer named-numeric wrapper implements the integer operator interfaces
A [GoType num:] wrapper (type stringID uint64) already declared the common numeric operator
interfaces so it could serve a cmp.Ordered-shaped constraint (IAddition/ISubtraction/
IMultiply/IDivision/IEquality/IComparison/IIncrement/IDecrementOperators), but the
integer-only three — IModulusOperators, IBitwiseOperators, IShiftOperators<T, int, T> —
were deliberately left off because their operators (%, &|^~, <<, >>) are kind-gated. That
left a named integer type unable to satisfy a converter-emitted ~integer operator constraint:
internal/trace’s type dataTable[EI ~uint64, E any] instantiated with type stringID uint64 was
CS0315 ×48 on exactly those three interfaces. The NumericTypeTemplate operators already exist
(same kind-gate), so InheritedTypeTemplate now also declares the three integer interfaces for an
integer underlying (float/complex keep only the common set). IShiftOperators additionally requires
operator >>> (unsigned right shift) — added to the integer operator block; Go emits no >>>, but
the member is needed to satisfy the interface. Cleared internal/trace’s 48 CS0315 (49→1, the residual
being the unrelated ΔLabel CS0542). Guarded by NamedNumericOperatorConstraint (a generic
mix[K ~uint64 | ~int32] applying modulus/bitwise/both-shifts on the type parameter, instantiated
with a named uint64 and a named int32, values vs Go). Corpus-verified against math/big (Word),
archive/tar, and time (Duration).
A named-numeric wrapper is IComparable<T> as well as ordered by operators
Ordering has two surfaces in .NET and the wrapper only carried one. IComparisonOperators<T,T,bool>
(above) serves a constraint lifted from cmp.Ordered; IComparable<T> is what the BCL’s own
ordering binds — Array/List.Sort, SortedSet<T>, Comparer<T>.Default — and, decisively for
converted code, what golib’s N-argument min/max are constrained on. (The two-argument forms take
IComparisonOperators, because a type parameter constrained by cmp.Ordered has no
IComparable<T> conversion; the params ReadOnlySpan<T> forms cannot, since a span element must
compare through a member, not an operator.) So a named numeric bound min(a, b) and failed
min(a, b, c, d): min(a-got, got-a, a-got+q, got-a+q) over crypto/internal/mlkem768’s
type fieldElement uint16 was CS0315, “no boxing conversion from fieldElement to
System.IComparable<fieldElement>”. InheritedTypeTemplate now declares IComparable<T> on the
same kind-gate as IComparisonOperators — every numeric kind except complex, which Go orders no
more than C# does — and NumericTypeTemplate emits its single member inside the same gated block:
public int CompareTo(fieldElement other) => m_value.CompareTo(other.m_value);
Forwarding to the underlying value’s CompareTo, rather than writing the comparison out of the
wrapper’s own </>, is what keeps a named float on the BCL total order (NaN below everything) —
which is what makes min yield NaN when any argument is NaN, as Go’s does. Every underlying a
[GoType num:] wrapper can name satisfies it: the aliases are BCL primitives, uintptr is a golib
struct that declares IComparable<uintptr> itself, and a wrapper over another wrapper picks up the
member this template gives it. The wrapper was already IEquatable<T>; this makes it ordered too,
matching the golib uintptr and @string structs, which are both. (Guarded by extensions to the
MinMaxBuiltin behavioral test — min/max at two and four arguments over named unsigned,
floating and signed underlyings, values vs Go; the pre-fix generator is CS0315 ×10 across the three
kinds.)
Lifted shift constraint uses the BCL shape IShiftOperators<T, int, T>
The lifted Integer operator set constrains shifts as IShiftOperators<T, int, T> — the shift count is int, not the type parameter. Every BCL binary integer implements exactly that shape (IShiftOperators<TSelf, int, TSelf>); only C# int itself happens to also satisfy the self-typed form, so the self-typed constraint made every non-int instantiation fail (CS0315 — strconv’s bsearch[S ~[]E, E ~uint16 | ~uint32] on ushort/uint). The shape is also exactly what emitted bodies need: the converter coerces every shift count to int (x << (int)(k)), so a generic body can only ever perform T << int. The generated named-constraint interface template (Integer in go2cs-gen) and its dynamic-conversion placeholder shift operators use the same int-count shape, keeping the two emitters consistent. (Guarded by the GenericTypeInference extensions bsearchLike/halve — ~uint16 | ~uint32 instantiations with a shift on the type parameter, values vs Go.)
Builtins over constrained slice type parameters
golib’s builtins carry interface-typed overloads so a value held as a constrained type parameter (S ~[]E, boxed to its ISlice<E> constraint) binds directly: copy(ISlice<T1> dst, ISlice<T2> src) (plus an ISlice<byte>/@string form), clear(ISlice<T> s), and two-argument min/max constrained on IComparisonOperators (Go’s cmp.Ordered lifts to operator interfaces; a constrained E has no IComparable<E> conversion). All four min/max overloads PROPAGATE NaN, which is Go’s own spec rule — if any argument is a NaN the result is a NaN — and which neither natural C# spelling gives for free: the operator form x < y ? x : y answers the NON-NaN side whenever the NaN sits on the left, because every C# comparison involving a NaN is false; and the params form’s IComparable<T> total order sorts NaN BELOW everything, which happens to be Go’s answer for min and is the OPPOSITE of Go’s answer for max. Both test explicitly now, through one shared per-T fact (builtin.OrderedFacts<T>) that classifies the floating kinds — the two BCL primitives, and the generated single-field [GoType("num:floatNN")] wrapper a NAMED Go float becomes, recognized by walking that one field so a wrapper-over-a-wrapper resolves for free. The gate is a static readonly per closed T, so it folds at JIT time and no integer instantiation pays for it, and the fact carries a same-width reinterpret rather than an operator because CompareTo/Equals cannot see a NaN at all (double.NaN.CompareTo(double.NaN) is 0 and double.NaN.Equals(double.NaN) is true, both by BCL design). Measured by slices’ TestMinMaxNaNs, which replaces each element of a float64 slice with NaN in turn and requires slices.Min AND slices.Max to propagate it; guarded by GolibTests.OrderedMinMaxNaNTests, which holds all four overloads including a named-float wrapper. The box wraps the same backing array, so interface writes land in the caller’s storage — copy/clear into an S are true write-throughs (span windows, memmove semantics for overlap). Overload resolution keeps concrete calls on the exact slice<T> overloads (an exact parameter beats a boxing conversion), so nothing outside generic bodies changes. Cleared ~37 of the slices package’s constraint seams. (Guarded by the GenericTypeInference extension CopyClearMinMax — copy into and clear through constrained values, write-through verified by value vs Go.)
S-preserving sub-slice and append. Go’s sub-slice of a named slice type yields the same named type sharing the same backing — pdqsort’s recursion depends on it (pdqsort(s[:mid]) with s S). The ISliceWrap<TSelf, T> static-abstract factory (TSelf Wrap(in slice<T> source)) supplies the non-copying reconstruction: slice<T> implements it as identity, every generated named-slice wrapper wraps the window in its own type, and the ~[]E where-clause carries it (ISlice<E>, ISupportMake<S>, ISliceWrap<S, E>). A sub-slice of a constrained type parameter emits golib’s subslice<S, E>(s, lo, hi) (type arguments explicit — E is constraint-only) which routes S.Wrap(window.Reslice(…)); the new slice<T>(ISlice<T> view) constructor SHARES storage (unboxes a slice<T>, reconstructs any other implementer from its source array and window). append on a constrained value binds golib’s append<S, T>(S, params ReadOnlySpan<T>) (S from the first argument, T from the span — fully inferrable) and wraps the result back to S; its body routes to the core slice<T>.Append directly, since a recursive append(…) call would resolve back to itself (slice<T> satisfies the constraints). The same change fixed the named-slice WRAPPER template’s sub-slice members, which routed through ToSpan() — detached copies, a silent write-through divergence for named slice types generally; they now route through the wrapped m_value (sharing). (Guarded by the GenericTypeInference extensions SumHalves — recursion over sub-slices of S with a write through the deepest view, verified against the caller’s array — and AppendKeep.)
No bound of a constrained sub-slice is a SENTINEL (2026-08-26). The omitted-high form used to travel as high = -1 through the three-argument method and the omitted-low form as low = -1, so s[i:] and s[i:-1] were the identical call — and the low convention was worse than an ambiguity, because the method clamped EVERY negative low to 0 rather than only the sentinel. Go panics for a negative index, so slices.Insert(s, -1, …) and slices.Replace(s, -1, 2, …) — whose bodies OPEN with _ = s[i:] and _ = s[i:j] as their bounds check, the expressions existing for no other purpose — silently succeeded. The remedy removes both sentinels rather than moving them: an omitted LOW is emitted as the 0 it means (Go’s s[:h] is s[0:h], so no overload is needed), and an omitted HIGH selects a two-argument subslice<S, E>(s, low) overload. subslice3 needs no companion — Go’s grammar requires the high bound in a full slice expression — and all three now route slice<T>.Reslice directly rather than the slice() extension, whose own -1 defaulting convention would have re-opened the collision one layer down. A golib-only remedy was impossible and the reason is worth stating: with one signature and the converter passing -1 for “omitted”, the two calls are byte-identical at the boundary, so no amount of golib logic can separate them — the honest layer is the emission. The corpus footprint is core/slices/{slices,iter}.cs and two behavioral goldens, since subslice is emitted only for type-parameter receivers. Residual, recorded not fixed: the ORDINARY (non-type-parameter) path emits s[Low..], whose int→Index conversion throws ArgumentOutOfRangeException for a negative bound — a .NET exception, not a Go panic, so it is neither recover-able nor contained the way RuntimeErrorPanic.SliceBoundsOutOfRange is; and SliceExtensions.slice’s -1 default still collides at exactly -1 for the three-index form. Neither is reachable from a banked row today. (Measured by slices’ TestInsertPanics and TestReplacePanics; guarded by GolibTests.ConstrainedSubsliceBoundsTests, which holds the negative, out-of-range, valid and backing-shared cases together.)
Every generated named-slice wrapper also implements the non-generic IArray surface explicitly. The public typed Source remains T[] for the concrete wrapper, but the interface member is emitted as Array IArray.Source => ((IArray)m_value).Source!;, matching golib’s IArray.Source contract and keeping len(IArray), element-address helpers, and interface-typed builtins bound to the wrapper. Pointer elements use the same form, e.g. type queue []*item emits ISlice<ж<item>> plus the explicit Array IArray.Source member. (Guarded by NamedSlicePointerElements.)
S where []E is expected. Go assignability lets a named-slice-typed value pass where the unnamed []E is expected (rotateRight(s[m:i], …), pdqsortOrdered(x, …)); the converter materializes such an argument through the SHARING slice<T>(ISlice<T>) constructor — pdqsortOrdered(new slice<E>(x), …) — a cast cannot apply (interface-constrained source; C# forbids user conversions from interfaces). The constructor unboxes a boxed slice<T> directly and otherwise takes the implementer’s full-window interface sub-slice, which every golib implementer returns as a boxed shared slice<T> — NOT Source, which materializes a detached copy (caught by the write-through gate: the helper’s write must land in the caller’s array). The 3-index form on a constrained value emits subslice3<S, E>, and a constrained spread (append(s, v.ꓸꓸꓸ) — a Span<E>) binds an exact params Span<T> twin of the constrained append (betterness otherwise picked the legacy params T[] candidate with T = Span<E>, a ref struct as type argument — CS9244). (Guarded by the GenericTypeInference extension PassSlice — S passed to a concrete []E helper, write-through verified by value vs Go — and by the ConstrainedSliceParamInPlace behavioral test, which drives a full in-place mutation through the materialized slice<E> — an element reversal and a real insertion sort mirroring slices.Sort/SortStableFunc and internal/fmtsort’s make+append-built SortedMap — over plain, named, and []string sequences, asserting the caller observes the reordering. A detached copy would leave the caller’s slice untouched.)
Explicit []E(x) conversion of a ~[]E type parameter. The explicit twin of the assignability case above — Go that spells out the slice conversion (reverse([]E(x)), x of type S ~[]E) rather than relying on assignability — took a different converter path and was broken (CS1503). isTypeConversion’s *ast.ArrayType arm rejects it (a type parameter’s Underlying() is its constraint interface, not []E, so the identical-underlyings gate fails), so it fell through to the general call assembly and rendered slice<E>(x) — the golib array-only builtin slice<T>(T[]), which the ISlice<E>-typed source S cannot satisfy. The converter now intercepts this shape in the general call path (mirroring the sibling string|[]byte-union []byte(x) special case at convCallExpr.go): when the conversion target is a slice-type literal and the sole argument is a *types.TypeParam with a ~[]E slice core (typeParamSliceCore — the same recognizer the implicit path uses), it emits the SHARING new slice<E>(x) constructor, so explicit and implicit ~[]E→[]E conversions land identically on the sharing ctor and preserve Go’s slice-conversion aliasing. Genuine conversions are untouched: named-slice casts ([]CaseRange(special)) take the isTypeConversion cast path; string/nil sources are not type parameters; the string|[]byte union is handled by the block just above (typeParamSliceCore is nil for it). Proven output-neutral (all 1696 stdlib .cs byte-identical across an old-vs-fixed reconvert; the behavioral corpus unchanged). (Guarded by the ConstrainedSliceParamInPlace behavioral test’s explicitReverseSeq case — reverse([]E(x)) over plain and named ~[]E sources, which did not compile before the fix.)
An untyped-int literal in a ~[]E-locked type-parameter slot is cast to the resolved element type. A bare untyped-integer literal passed where the parameter is the element type parameter E of a sibling ~[]E-constrained type parameter — Index[S ~[]E, E comparable](s S, v E) called Index(s, 2), or the variadic element of Insert[S ~[]E, E any](s S, i int, v ...E) called Insert(b, len(b)-1, 0) (the slices shape) — drives C# generic inference from the literal’s own C# type. Go infers E from S’s core type ([]int → E = Go int → nint), but C# has no analogue for ~[]E core-type inference: the emitted where S : ISlice<E> does not flow S’s concrete element to E, so C# infers E SOLELY from the value literal. A bare C# int literal is System.Int32, so Index(s, 2) with s []int made C# infer E=int, and slice<nint> then failed the ~[]int constraint — CS0315 (no boxing conversion slice<nint>→ISlice<int>), CS0411 (inference failed), or CS1503 (arg conversion). go/types has already resolved the literal to E’s instantiation (Info.Types[lit].Type — int for the []int caller, byte for a []byte caller, int64→long, uint→nuint, …), so convCallExpr emits the literal AT that C# type via the shared castArgToType plumbing: Index(s, (nint)(2)), Insert(b, len(b) - 1, (byte)(0)). The sibling-lock gate (typeParamIsSliceElementOfSibling) is what keeps the footprint minimal and correct: the cast fires ONLY when the parameter’s type parameter is the slice-element of another type parameter’s ~[]E constraint — the one shape C# cannot infer. Everything C# already infers correctly is left with its bare literal: a freely-inferred type parameter (First[T any](v ...T) — T=int32 satisfies any, and the value is identical), one determined directly by another argument (setThrough[P *T, T any](p P, v T) — C# infers T from the pointer), and an explicitly-instantiated call (NewOption[nint](42) — the type argument is already pinned). A resolved int32/rune kind is skipped even inside the gate (a bare int literal already IS System.Int32 — the []int32 element case stays a plain literal), and a value convBasicLit already casts ((nint)…L for an out-of-int32 constant) is not double-wrapped (the wholeExprIsCastOfType skip in convExprList). The literal-constant test reuses isUntypedNumericConstArg, the same recognizer the append-element and narrow-int casts key off, so a tightened local const — already declared at its concrete type — is excluded. This unblocks the whole slices-package Index/Insert/Replace/Contains-family value-argument seam (cleared the entire CS0315 cluster in the slices Phase-4 test host — 53→40 residual errors, the remainder unrelated classes). Proven zero-drift on the behavioral corpus. (Guarded by the GenericUntypedIntArg behavioral test — Index/appendAll over []int, a named numbers []int, []byte, and []int32 element types with bare int-literal args, which did not compile before the fix; the []int32 case proves the no-cast arm.)
Range-over-func on named/generic Seq types. Go 1.23’s for v := range seq (and the two-value for k, v := range seq2) on an iter.Seq[E]-shaped value emits through golib’s yield-adapting range() overloads. Three pieces make the named/generic form work: detection unwraps the type’s Underlying() (a defined or instantiated func type is a Named, not a bare Signature); a NAMED func type renders as a C# delegate, which has no conversion to the overloads’ Action<Func<…>> parameter — its method GROUP does, so the emission appends .Invoke; and because C# cannot infer a type parameter from a method group’s parameters, the element types are spelled out from the yield signature: foreach (var v in range<nint>(countdown(5).Invoke)). break inside the body ends the foreach, which cancels the adapter’s producer — the yield function receives false, matching Go’s semantics; a two-value range<K, V> overload adapts pair-yields onto the tuple machinery. One adjacent gate was refined en route: a call’s result being a generic instantiation adds explicit type arguments only for conversions and GENERIC callees (NewOption<nint>(42) — an untyped-const arg would infer C# int where Go infers nint), never for a plain function returning a generic named type (countdown<nint>(5) was CS0308). (Guarded by the GenericTypeInference extensions — a generic Seq[V] ranged with break and a two-value KVSeq[K, V], values vs Go.)
An EXPLICITLY-instantiated generic function through a package selector renders its type arguments once. Go’s pkg.Func[T](…) is an IndexExpr (or IndexListExpr for pkg.Func[K, V]) whose base X is the selector pkg.Func. convIndexExpr/convIndexListExpr renders the [T] as <T> itself. But the base is also a generic-function value, so convSelectorExpr — which spells a generic function’s inferred type arguments when it appears as a method-group value (the slices.SortFunc(all, slices.Compare) path, needed because C# can’t infer a method group’s type parameters) — appended <T> a second time, producing pkg.Func<T><T>(). Depending on context this surfaced as CS1525 (reflect.TypeFor[X]() → invalid expression term), CS0119 (a plain-return generic like saferio.SliceCap[T]), or CS8124 (<T>() parsed as a one-element tuple) — ~67 errors across encoding/gob, xml, asn1, json, text/template, database/sql/driver, debug/macho·pe·elf, and unique. The index expression now converts its base with a suppressGenericTypeArgs context flag, so convSelectorExpr skips the value-path append when it is the base of an explicit instantiation (the standalone method-group-value case is unchanged — no flag, still appends). A local generic function (Func[T](), base is an Ident not a selector) never hit this, since only convSelectorExpr appends. (Guarded by the CrossPkgUser extension — CrossPkgLib.Wrap[int](5) (IndexExpr) and CrossPkgLib.Pair[string, int](…) (IndexListExpr), both rendering single type-argument lists, output vs Go.)
Integer type-parameter conversions route through golib (the E(100) family)
C# has no cast to or from a type parameter, so the Go conversions in rand.N[Int intType] — Int(x), uint64(n) — and an untyped constant compared against the parameter (n <= 0, which Go types AS Int but C# leaves as int, unacceptable to the lifted IComparisonOperators<Int, Int, bool>) all failed (CS0030/CS0019). Three coordinated pieces, gated on a constraint whose every type-set term has an integer underlying (typeParamIsInteger): a conversion to the parameter emits golib’s runtime-typed ConvertToType<Int>(…) (typeof-dispatch that JIT-folds to a single branch per instantiation; signed kinds sign-extend, unsigned zero-extend — Go’s exact conversion semantics; a [GoType("num:*")] wrapper instantiation falls back to a reflection-cached bridge over its Value property/ctor); a conversion from the parameter to a basic integer emits ConvertToUInt64<Int>(n) (plus a plain numeric cast when the target is not uint64); and a constant operand of a binary op against the parameter materializes via ConvertToType<Int>(0) — except a SHIFT count, which Go types independently and the emission already coerces to int. Result: if (n <= ConvertToType<Int>(0)) … return ConvertToType<Int>(ConvertToUInt64<Int>(n) / 2);. (Guarded by the GenericTypeInference extension halveN — ~int32 | ~int64 with the compare, both conversions, and a negative value proving sign-extension, values vs Go; clears math/rand/v2’s N.)
A named-wide-integer or type-parameter slice index casts to nint
Go permits any integer type as a slice/array index, converting it to int for the access. The C#
slice<E>/array<E> indexer takes nint, and the existing wide-basic index cast already routed
uint/uint32/uint64/uintptr/int64 through (nint). Two more index kinds need it, both from
internal/trace’s dataTable:
- a named type over signed
int64—type ProcID int64indexingspans[procID](CS1503). There is nothis[long]overload,int64→nintdoes not narrow implicitly, andint64→ulong(which would bindthis[ulong]) is a signed→unsigned conversion — so it has no bare path.(nint)(procID)composes as one user conversion (named→long) plus one built-in (long→nint). Every other kind is deliberately excluded — no churn, and casting some would even break: an unsigned named type (type kindT uint/uint32/uint64) binds the golibthis[ulong]overload bare, and a nuint-backed wrapper (uint/uintptr) is CS0030 under a(nint)cast; anint/int32/nintunderlying narrows implicitly (type rank intstays bare). So only signedint64both needs and accepts the cast; - a numeric type parameter —
dataTable[EI ~uint64]doingd.dense[id]. A constrained type parameter has no C# cast at all, so it routes through golib’sConvertToUInt64<K>bridge (theE(100)integer-type-param family above) and then narrows:d.dense[(nint)(ConvertToUInt64<EI>(id))](an arithmetic indexid/8is stillEI, so it wraps the same way).
Cleared ~11 of internal/trace’s index CS1503/CS0030 (17→6). The companion shift-count case —
1 << (id % 8) where the count is a numeric type parameter, coerced to int by intCastOperand
(the same coercion the shift-width machinery uses) — routes through the same bridge:
(uint8)1 << (int)(ConvertToUInt64<EI>(id % 8)) (a bare (int)(EI) is CS0030). Cleared internal/trace’s
last two shift-count CS0030 (6→4). Guarded by NamedNumericSliceIndex (a generic lookup[K ~uint64]
indexing by the type parameter and an arithmetic result, a pick indexing by a named int64, a
num:nint rank index that must stay bare, and a bitset[K ~uint64] shifting by the type parameter,
values vs Go).
Method-set interface constraints bind the interface directly; pointer instantiations project through the adapter
A type parameter constrained by a regular method-set interface (a pure method set, no type-term
unions — go/ast’s walkList[N Node](v Visitor, list []N)) emits where N : Node against the
arity-0 emitted interface. Only union+method constraint interfaces take the generic CRTP form
(ConstraintTest1<ΔT>); the method-set arm previously emitted the phantom Node<N>, new(), which
was doubly wrong: Node is emitted arity-0 (CS0308), and the instantiation may itself be an
interface (walkList takes N=Stmt/Expr/Spec/Decl), which can never satisfy new(). The
interface-typed and interface-inheriting instantiations then satisfy the constraint natively
(Stmt : Node is emitted inheritance).
A pointer instantiation (walkList(v, n.Names) with N=*Ident) cannot: the ж<Ident> box
does not implement the interface — its generated pointer adapter does. The call site projects the
slice element-wise through the adapter, instantiating N as the interface itself:
internal static void walkList<N>(Visitor v, slice<N> list)
where N : Node
{
foreach (var (_, node) in list) {
Walk(v, node);
}
}
// call site, N=*Ident:
walkList(v, widen<ж<Ident>, Node>((~n).Names, elemᴛ1 => new IdentжNode(elemᴛ1)));
golib’s widen<T, TWide>(slice<T>, Func<T, TWide>) copies the slice HEADER only — elements alias
the original objects through the shared boxes, so method calls through the projected slice mutate
the real objects. A callee that reassigned list[i] itself would not write back; the projection
targets the read/widen shape (Go itself performs the same per-element interface widening inside
the loop). convertToInterfaceType supplies the adapter reference and the GoImplement recording,
exactly as at scalar *T→iface call sites. (Guarded by GenericInterfaceConstraint — pointer,
interface, and embedded-interface instantiations of a method-set-constrained generic, calling a
constraint method on the parameter and widening walkList-style, values vs Go; clears go/ast’s
CS0308, the go/* toolchain gate.)
A SELF-REFERENTIAL generic method-set constraint uses a box-wrapping proxy as the type argument
The widen-to-the-interface escape above works only when the constraint interface is non-generic
(N=Node, so N can be Node). crypto/elliptic’s nistCurve[Point nistPoint[Point]] — where
nistPoint[T] is a generic, self-referential method-set interface (Add(T,T) T,
SetBytes([]byte) (T,error), …) — cannot: you can’t substitute Point = nistPoint<Point> (infinite
regress), and the golib box ж<P224Point> cannot nominally implement nistPoint<ж<P224Point>>
(it is a sealed golib type in another assembly, and Go’s structural satisfaction has no C# analog).
Four coordinated pieces make it convert and dispatch:
-
The constraint interface is emitted GENERIC. A method-set interface whose own Go type parameter is used in its member signatures carries its
<T>(and constraints) in C#, exactly like a generic struct —[GoType] partial interface nistPoint<T> { … T Add(T, T); (T, error) SetBytes(slice<byte> _); }. Without it the declaration is arity-0 yet the constraint that references it spells the arity-1where Point : nistPoint<Point>(CS0308) and every bareTis undefined (CS0246). (Go’s operator-only constraint interfaces are arity-0 in Go, so this is disjoint from the<ΔT>operator machinery.) -
One GENERIC adapter class implements the outer interface:
nistCurveжCurve<Point> : Curve, IжAdapter where Point : nistPoint<Point>wrappingж<nistCurve<Point>>— NOT a class per instantiation. The converter’sGoImplementrecords are per-instantiation but all resolve to the open form here, soImplementGeneratorde-dups on the open(struct, interface)pair and forwards its type parameters and the struct’s own constraint (GetGenericConstraintClause). The converter composes the reference name+args separately (nistCurveжCurve<…>, base+ж+iface, then the closed args) so the type arguments do not bake into the identifier (the oldnistCurve<…>жCurvewas CS1526). -
A self-referential PROXY stands in for the type argument. For each concrete pointer type used to instantiate the generic (
nistCurve[*P224Point]), the converter renders the type argument as a generated proxyP224PointжnistPoint(element-simple+ж+iface-simple) instead of the boxж<P224Point>, and records[assembly: GoImplement<P224Point, nistPoint<P224Point>>(ConstraintProxy = true)](the interface’s own argument is a placeholder).ImplementGenerator.EmitConstraintProxyemits:internal sealed class P224PointжnistPoint : nistPoint<P224PointжnistPoint>, IжAdapter { private readonly ж<P224Point> m_box; public P224PointжnistPoint(ж<P224Point> box) => m_box = box; public static implicit operator P224PointжnistPoint(ж<P224Point> box) => new(box); public static implicit operator ж<P224Point>(P224PointжnistPoint proxy) => proxy.m_box; // T rewritten to the proxy itself; the implicit conversions marshal every T-boundary: P224PointжnistPoint nistPoint<P224PointжnistPoint>.Add(P224PointжnistPoint a, P224PointжnistPoint b) => m_box.Add(a, b); (P224PointжnistPoint, error) nistPoint<P224PointжnistPoint>.SetBytes(slice<byte> b) => m_box.SetBytes(b); // … }The proxy implements the interface over itself, so
Point = P224PointжnistPointsatisfieswhere Point : nistPoint<Point>(CS0311 otherwise) and resolves everyp.Add(…)/newPoint().SetBytes(…)call insidenistCurve’s body. The implicitж<P224Point>↔proxy conversions do all the T-boundary marshalling automatically: each forwarder is a barem_box.M(args)(arguments unwrap to the box on the way in, results rewrap to the proxy on the way out — including element-wise inside a(T, error)tuple), and a value flowing into aPoint-typed position (base: Ꮡ(new P224Point(…))) converts implicitly at the site. The proxy forwards to the element’s exportedж-extensions even cross-assembly (m_box.SetBytesbinds nistec’s extension from crypto/elliptic). -
A
func()-typed field’s method-group initializer is re-wrapped as a lambda.nistCurve’snewPoint func() PointbecomesFunc<P224PointжnistPoint>, but a method group (newPoint: nistec.NewP224Point, returningж<P224Point>) cannot convert to it — a C# method-group conversion does not apply the user-defined implicit operator (CS0407). Inside a constraint-proxy composite the converter re-wraps such an initializer as a lambda,newPoint: () => nistec.NewP224Point(), whose return position does apply the conversion. -
The proxy forwards the WHOLE method set, embedded interfaces included. A Go constraint interface may embed others, and Go embedding emits as C# interface inheritance — so the members a proxy must forward are not the ones the constraint DECLARES but the ones its method set CONTAINS. The emitter walked
GetMembers()only, which is the declared half, and any member reached through an embed was simply absent: the proxy did not implement its own interface (CS0535, one per inherited member). It surfaced at full size innet/http, whoseclientserver_test.godeclarestype TBRun[T any] interface { testing.TB Run(string, func(T)) bool }giving proxies for
*testing.Tand*testing.Bthat forwardedRunand were each missing all 18 members of the embeddedtesting.TB— 36 diagnostics, and the last wall but one in front of a 1,352-verdict suite.AllInterfacesis already transitive, so a two-level embed needs no recursion of the emitter’s own.Each member is qualified by its own declaring interface, not by the constraint: C# explicit interface implementation must name the interface that declares the member, so
void Derived.M()is CS0539 whenMcomes fromBase. A generic embed is closed over the proxy exactly as the constraint itself is (Bar<T>embedded inFoo<T>forwards asBar<proxy>.M), reusing the sameRenderWithProxysubstitution. The constraint’s OWN members are emitted first in declaration order, so a proxy that embeds nothing is byte-identical to what the emitter always produced; the embedded sets follow, ordered by rendered reference so the emission does not depend on the orderAllInterfaceshappens to report. This also brings the constraint-proxy path into line with the interface-ADAPTER path beside it, which had walked base interfaces from the start.
(Guarded by GenericPointerInterfaceImpl — a self-referential curve[Point point[Point]] implementing
Curve via pointer receiver, instantiated two ways, with a newPoint func() Point field and a
(T, error)-returning constraint method, values vs Go — and by ConstraintProxyEmbeddedInterface for
the method-set walk: a constraint embedding Middle embedding Base, so one member arrives one level
deep and another two, recorded from two instantiation sites to exercise the per-pair de-duplication,
output-compared vs go run. Reverting the walk reproduces CS0535 on Middle.Size() and Base.Name()
— both levels. Embedding the constrained generic and greening the whole crypto-curve family is the
next subsection.)
A func LITERAL at a proxied delegate position declares its parameters AT the proxy
Item 4 above moves a METHOD GROUP’s T-boundary into a lambda body, because a method-group conversion
will not apply the user-defined conversion. A func literal meets the same wall one position further
in and cannot take that remedy — it already is the lambda, and it renders its own parameter list
from the Go signature. At T = ImplжConstrained a func(t T, mode int) argument emits
(ж<Impl> t, nint mode) => … against a delegate requiring Action<ImplжConstrained, nint>, and
C# applies no user-defined conversion at a parameter DECLARATION: CS1678 + CS1661, one pair per
call site. net/http’s suite is written on this shape throughout —
run[T TBRun[T]](t T, f func(t T, mode testMode), opts ...any) — and it was 48 of its 81 body
diagnostics.
The remedy is the same principle: move the conversion to a position C# performs it. The parameter is declared at the proxy under a synthesized name and the body opens with the natural-typed alias:
run<TжTBRun>(Ꮡt, (TжTBRun tΔ1Δp, testMode mode) => {
var tΔ1 = (ж<testing.T>)tΔ1Δp; // the proxy's own implicit operator
… // body unchanged, byte for byte
});
Everything after that line is the body exactly as it would have been emitted, so no member access, capture, or nested literal inside it renders differently. Declaring the parameter at the proxy and letting the body use it directly would not work: the forwarders are explicit interface implementations, reachable through a type parameter’s constraint but not by member lookup on the concrete proxy type.
Restricted to a parameter whose type is the proxied type parameter exactly. One that merely
MENTIONS it ([]T, map[K]T, func(T)) is excluded — slice<ImplжConstrained> and
slice<ж<Impl>> are distinct instantiations with no conversion between them, so no single assignment
could stand in the prologue, and guessing would trade a clear diagnostic for a wrong one.
⚠ Both halves key on the RENDERED name. A literal’s signature is generated from synthesized vars
carrying the shadow-renamed name, so keying the proxy map on the Go name misses every renamed
parameter — and misses it asymmetrically, because the body prologue reads the same map from the
AST, where the Go name is present. The first cut did exactly that: it emitted the prologue while
leaving the declaration at its natural type, producing a local with the same name as the parameter
beside it. net/http is entirely the renamed case (its inner t shadows the outer), so the guard
carries both spellings and the un-renamed one alone would have passed over the real defect.
The anchored adapter REFERENCE keeps the shadow marker
The -tests metadata-anchored resolution composes the adapter class reference a cast site will use
(anchoredAdapterMemberName) while go2cs-gen composes the class it emits. The two must agree
character for character, and they disagreed on the shadow marker: the generator names a local adapter
from adapterBaseName — the C# type name verbatim, Δhandler — and a foreign one from
GetSimpleName(structName), neither of which strips it, while the reference side stripped it and
named a class that is never emitted. net/http’s internal test variant declares
type handler struct{ i int } (server_test.go), shadow-renamed to Δhandler, so the generator minted
ΔhandlerжΔHandler and every cast site referenced handlerжΔHandler — CS0426 ×9.
The rule the strip violated: the marker belongs to the C# IDENTITY of the type, not to a rendering
convention. adapterStructKey strips it for GROUPING, which is right and unchanged — a collision
group must not depend on which side got renamed — but that key must not double as the emitted name.
Only the -tests anchored path was affected: a production conversion resolves through
adapterResolvedName, which never stripped, so the corpus could not move (and CNR confirms it did
not). The measured shape here also corrects a plausible-looking diagnosis worth recording: the
symptom reads as an adapter minted for one test variant and referenced from the other, and it is not
— the record is correctly bridge-anchored in package_info_internal_test.cs and the class is minted
in http_internal_test_package, exactly where the reference looks for it. Only the NAME differed.
⚠ One adjacent surface is deliberately NOT addressed and is worth naming, since a reader meeting it
will otherwise read it as this defect: a T RETURNED out of a constrained generic into concrete code
arrives as the proxy TYPE, whose forwarders are explicit interface implementations and so are
unreachable by member lookup there (r := second(p); r.Name() — CS1929, resolving instead to the
element’s own extension whose receiver it cannot satisfy). The proxy carries an implicit conversion
back to ж<element>, but C# does not apply a user conversion during member lookup. Nothing in the
corpus or in net/http reaches it; the guard’s second builds its result inside the generic context
on purpose.
A struct embedding the constrained generic promotes its members — three residual crypto-curve fixes
crypto/elliptic’s p256Curve struct { nistCurve[*nistec.P256Point] } — a non-generic struct
embedding a concrete instantiation of the self-referential-constrained generic above — must PROMOTE
nistCurve’s internal fields (newPoint, params) and methods (Add/Double/Params/ScalarMult/…)
onto p256Curve, exactly as an embed of a plain struct does, so p256.params = … binds and the generated
p256Curve→Curve interface adapter can forward curve.Add(…) to the promoted shim. Because the type
argument is the box-wrapping proxy of the previous subsection, its rendered name embeds the marker glyph
ж (nistCurve<P256PointжnistPoint>) — the thread that runs through all three fixes that made the whole
crypto-curve family (elliptic, ecdh, nistec) COMPILE (+3 packages):
-
The proxy marker glyph
жis not a pointer prefix. The generator’s simple-name / underlying-name helpers (GetSimpleName,GetUnderlyingTypeName) detected a pointer typeж<T>by scanning for a bareжand slicing from it. The proxy’s own name embeds that glyph mid-identifier (P256PointжnistPoint), so an embed typednistCurve<P256PointжnistPoint>was mis-sliced into garbage (its simple name becameoint.Value, its underlying name an unresolvable string) and the embed promoted nothing (CS1061 onparams, CS1929/CS1501 on every forwarded method). Both helpers now match the pointer prefix as the two-characterж<, so a marker embedded in an identifier is left intact. -
A generic-instantiation embed resolves to its declaration and substitutes its type arguments. An embed of a generic INSTANTIATION (
nistCurve<P256PointжnistPoint>) resolves to the generic DECLARATION (nistCurve<Point>) by base-name + arity (FindStructDeclaration— an instantiation can never string-match a declaration that carries its type PARAMETERS), and a generic struct’s extension methods now match on the type-parameter-bearing receiver (nistCurve<Point>, not the barenistCurve). The promoted field and method signatures are harvested from the declaration, so they carry its type PARAMETER (Func<Point>,pointFromAffinereturning(Point, error)); the template rewrites each to the instantiation’s type ARGUMENT before emission —internal ref global::System.Func<P256PointжnistPoint> newPoint => ref nistCurve.newPoint; internal static (P256PointжnistPoint p, error err) pointFromAffine(this ref p256Curve target, ж<bigꓸInt> Ꮡx, ж<bigꓸInt> Ꮡy) => target.nistCurve.pointFromAffine(Ꮡx, Ꮡy);— so no promoted member references the out-of-scope
Point. (The member ACCESS hop keeps the bare property namenistCurve; only the emitted TYPE is substituted.) When the ENCLOSING struct is itself GENERIC —wrapped<T>embeddingtag<T>(theGenericStructFieldsguard) — the promoted method is a GENERIC extension method carrying the struct’s own type parameters (static T show<T>(this wrapped<T> target) => target.tag.show();, the substitution then an identityT→T), else theTin the receiver and return is an undefined type name (CS0246). -
The constraint proxy imports its element’s package namespace. The proxy forwards each interface method to the boxed element’s box extension methods (
m_box.Bytes()), which live in the element type’s PACKAGE class (nistec_package, namespacego.crypto.@internal). The[assembly: GoImplement<…>(ConstraintProxy = true)]attribute driving the proxy sits inpackage_info.cs, whose usings never cover a FOREIGN element, so the forwarders bound nothing (ж<P224Point>“has noBytes”, CS1929/CS1501).EmitConstraintProxynow emitsusing <element-namespace>;for the box element’s namespace. -
An open-generic interface cast is CONVERTED but not RECORDED. Inside a generic method the receiver itself is cast to the interface — crypto/ecdh’s
return newBoringPrivateKey(c, …)withc *nistCurve[Point]. The converter must still WRAP it in the generic adapter (new nistCurveжΔCurve<Point>(Ꮡc)— the adapter the CLOSED per-instantiation records already generate), but must NOT RECORD it as an implementation: a record emits[assembly: GoImplement<nistCurve<Point>, ΔCurve>], whose type-PARAMETER argumentPointis out of scope in an assembly attribute (CS0246).convertToInterfaceTypenow skips the record for an open-generic target while still firing the adapter-wrapping conversion.
(Fix 2 is guarded by the GenericEmbedPromotion behavioral test — a non-generic struct embedding a concrete
curve[*p224] over a self-referential proxy: reading a promoted internal field, calling a promoted method
whose parameter is the type argument (passed the promoted proxy-typed field), and reaching the promoted
methods through a non-generic interface adapter, values vs Go. Fixes 3 and 4 need a cross-package element /
a generic-method interface cast the single-package baseline cannot express; they are validated by the census
— elliptic, ecdh, and nistec now emit their DLLs, 254 → 257 packages.)
Constraint-only type parameters need explicit type arguments
Go infers a type parameter that appears only in constraints through core types — func Twice[S ~[]E, E Integer](s S) infers E from S’s underlying element; the slices package’s whole Sort[S ~[]E, E cmp.Ordered] → pdqsortOrdered chain relies on this. C# never infers a type parameter that does not appear in the parameter list (CS0411 — at every call site, concrete instantiations included). When the callee declares such a constraint-only type parameter, the converter renders the call’s type arguments explicitly from the instantiation go/types already resolved (info.Instances): Twice<Point, int32>(p, 2) at a concrete site, Scale<S, E>(s, c) inside a generic body. Calls to generics whose every type parameter is argument-visible keep their bare Go-shaped form — C# infers them as Go does, no churn. (Guarded by the GenericTypeInference extension — a constrained S/E pass-through chain plus a concrete call to a constraint-only-param generic, values vs Go; clears the 14 CS0411s in the slices/maps wave.)
The same explicit-type-argument rule applies to a generic function referenced as a method-group value, not just a call. slices.SortFunc(all, slices.Compare) (runtime/pprof) passes slices.Compare[S ~[]E, E cmp.Ordered] as SortFunc’s comparison delegate; C# cannot infer a generic method group’s constraint-only E when converting it to Func<…> (CS0411). convSelectorExpr now spells the arguments on the selector — slices.Compare<slice<uintptr>, uintptr> — when the selector is NOT the callee of a call (!context.isCallExpr, so convCallExpr’s own type-arg site still owns the call form) and info.Uses[Sel] is a generic function with an info.Instances instantiation. Byte-identical across the behavioral corpus and across an A/B of pprof+slices+sort+maps+cmp+net+go/types (a single line moves — the slices.Compare argument; every Compare(...) call stays bare). GUARD OWED — the shape needs a cross-package generic function with a constraint-only type parameter passed as a method-group value, which the single-package baseline cannot express.
The bare-IDENT variant is no longer latent (2026-08-26, the generic-inference arc that unblocked slices). convIdent now carries the same call-vs-value flag convSelectorExpr always had — IdentContext.suppressGenericTypeArgs, set by convCallExpr for a call’s CALLEE and by convIndexExpr/convIndexListExpr for the base of an explicit instantiation — so a same-package generic function passed as a method group (apply(s1, Reverse) against Reverse[S ~[]E, E any], slices’ TestInference) spells its arguments out (reverse<slice<nint>, nint>) exactly as the qualified form does, and the two spellings of one Go reference cannot disagree. Without the flag there was no way to add the append without also writing every direct call in the corpus out longhand; with it, the value form is the only one that moves. The append rides on whichever spelling the ident’s own tail arms produce (a -tests Δ-rename, a white-box bridge qualification), so a renamed generic function passed as a method group is covered too.
Three further shapes in the same family were fixed with it, all first measured as compile errors in slices’ converted test suite (16 errors across roughly six exported generics, every one CS0411/CS0305 or a CS1503 cascade behind one):
-
An explicitly-instantiated generic function argument is still a method group.
EqualFunc(s1, s2, equal[int]),CompareFunc(s1, s2, cmp.Compare[int]),CompactFunc(s, equal[int]),equalToCmp(equal[int])— writing the type arguments fixes the group’s shape, not its C#-inference status, so the enclosing generic call still needs its own arguments spelled.exprIsMethodGroupmet an*ast.IndexExpr, matched neither of its two cases, and answered “not a method group”; it now peelsParenExpr/IndexExpr/IndexListExpr(indexing a function value is not legal Go, so peeling can never reclassify an ordinary map/slice index). Eight of the sixteen errors. -
A type parameter reachable only through an UNSUPPLIED parameter position.
Insert[S ~[]E, E any](s S, i int, v ...E)called asInsert(s, 0)hands C# an emptyparams Span<E>, andEis inferable from nothing — whilecalleeHasConstraintOnlyTypeParamcannot see it, becauseEdoes appear in a parameter type.calleeTypeParamUnsuppliedByCallasks the question C# inference actually asks — which parameter positions did this call supply — and since every non-variadic parameter is always supplied in a well-typed Go call, it can fire on nothing but an empty variadic.Insert(s, 0, 7, 8)keeps its bare form. -
A PARTIAL explicit instantiation. Go allows a written prefix and infers the rest through core types (
Equal[Slice]againstEqual[S ~[]E, E comparable], slices’ owniter_test); C# has no partial instantiation, so the prefix alone is CS0305, “requires 2 type arguments”.completedInstantiationTypeArgsreplaces the written list with the resolved one only when the resolved list is longer — a complete instantiation, which is nearly all of them, renders verbatim and byte-identically. The comparison is made after erasure filtering on both sides, so an erased pointer-core position cannot make a complete instantiation look partial.
Every one of the four is an ADDITION to the existing trigger set rather than a replacement, and each is gated on a property that is arithmetic rather than heuristic (is this argument a function reference; did this position receive an argument; is the written list shorter than the resolved one). That is what keeps the footprint at exactly the shapes that were failing: CNR at 645 behavioral packages moves only the guard project itself, and a seeded reconvert of the whole converted standard library re-emits 4,173 artifacts byte for byte (0 changed, 0 new; marker gate 0 violations across 78 marked files) — slices and maps included, whose own production code leans hardest on the inference this arc is about. Guarded by the MethodGroupGenericArg extension, which fails on the pre-change converter with exactly the slices error set — CS0411 ×4 (instantiated method-group argument, empty variadic, bare-ident generic value ×2) plus CS0305 ×1 (partial instantiation) — and passes after.
The comparable constraint
Go’s built-in comparable admits every ==-able Go type — numerics, strings, pointers, channels, and comparable structs/arrays/interfaces. No C# constraint can express that set: golib’s old comparable<T> CRTP interface was implemented by nothing (every real instantiation failed — maps.Keys[M ~map[K]V, K comparable] could not be used at all), and lifting IEqualityOperators would reject structs, which Go admits. A comparable type parameter therefore emits no C# constraint at all — no where clause — relying on the two facts that make it sound: Go’s checker already validated every instantiation, and emitted equality on type parameters routes through AreEqual, never operator ==.
Until the B1 per-kind box split (2026-08-26) this arm emitted where K : /* comparable */ new(). The new() was a holdover nothing needed — golib’s @new<T> constructs through the runtime, and no comparable-constrained body in the corpus constructs its parameter — and the split turned it from dead weight into a defect: Go pointers are comparable, a Go pointer type argument instantiates at the abstract ж<T>, and no abstract class satisfies a constructor constraint. unique’s HashTrieMap[*abi.Type, any] was the corpus witness (CS0310); slices/maps/cmp instantiations at pointer element types were the latent class behind it. Guarded by the corpus compile plus the Constraints-family behavioral goldens, which now pin the clause-free form.
An interface that embeds comparable inherits that fact whole, and both sides of the emission
have to say so. type netipTypeCmp interface { comparable; netipType } (net/netip’s fuzz_test.go)
disagreed with itself: the DECLARATION appended a bare comparable to the C# base list — that
unimplementable generic named with no type argument, CS0305 — while the CONSTRAINT decided the
interface was not a method set and took the generic CRTP form netipTypeCmp<P> against a declaration
emitted arity-0, CS0308. comparable contributes no methods, so it is dropped from the base list
(leaving the interface’s C# surface exact) and DISCOUNTED when deciding whether an interface is a
pure method set — which puts the constraint in the arity-0 where P : netipTypeCmp form the
declaration actually emits. An interface mixing comparable with a real type-term union still
restricts its type set and keeps the generic treatment.
AreEqual itself is not a performance tax on that path: a generic overload AreEqual<T>(T, T) — automatically preferred by overload resolution exactly where both operands share the type parameter — takes EqualityComparer<T>.Default.Equals for value-type arguments, which the JIT specializes per type and devirtualizes to the type’s own IEquatable<T> (operator-comparable speed, no reflection or boxing; golib wrappers emit operator == and Equals as consistent pairs, so semantics match). Reference/interface type arguments delegate to the reflective AreEqual(object, object) overload, preserving its typed-null and runtime-type semantics. (A constraint-differentiated overload pair is not expressible — C# treats where clauses as outside the signature, CS0111 — and a source-generated == twin is unnecessary given the EqualityComparer<T>.Default JIT intrinsic.) (The behavioral GenericVariadicFunc golden captures the erased form with unchanged output.)
Floating-point equality follows Go’s IEEE-754 ==, not Equals. The Equals-based fast path
above is wrong for exactly one family: double/float report NaN.Equals(NaN) as true (and
Complex/golib complex64 inherit that componentwise), while Go’s == — the operation AreEqual
stands in for — is IEEE: NaN compares unequal to everything, itself included. That inverted every
generic NaN probe of the x != x form: cmp.isNaN emits !AreEqual(x, x), so cmp.Less lost its
NaN-first ordering (sort’s TestFloat64s — Float64s → slices.Sort produced a NaN-scrambled
order) and cmp.Compare reported NaN equal to everything, which let the mis-sort slip past
TestSortFloat64sCompareSlicesSort’s own equality check. The generic overload now special-cases
double, float, complex128, and complex64 to the operator compare (JIT-constant typeof(T)
guards, box-cast elided). The reflective object overload (boxed/interface comparisons) was already
IEEE-correct and stays untouched: on .NET 7+ the primitives DECLARE op_Equality (the
IEqualityOperators implementation), so its cached operator lookup finds the real operator rather
than falling to Equals. Concrete (non-generic) float comparisons were always correct — f != f
emits the C# operator directly. (Guarded by the ReverseSortNaNOrder
behavioral test — generic isNaN/less/eq legs over float64/float32/complex128/
complex64, boxed-any NaN equality, and a NaN-aware interface sort, values vs Go.)
Generic struct equality is decided per FIELD, not per type parameter
A generic [GoType] struct’s synthesized Equals (see Struct Types) was gated on
the struct’s TYPE PARAMETERS: unless every parameter carried an IEqualityOperators-implementing
constraint (and, stricter still, every constraint of every parameter implemented it), the whole
struct’s Equals body was the constant false /* missing equality constraints */. Since a
comparable parameter deliberately emits no C# constraint (previous subsection),
essentially every generic struct in the corpus — all 22 generic [GoType] declarations at the time
of the fix — carried a constant-false Equals, breaking equality that never depended on the
parameter at all. unique.Handle[T]’s only field is *T (ж<T>), whose pointer-identity == is
valid for every T, yet no two handles ever compared equal — directly contradicting the type’s
documented contract (“two handles compare equal exactly if the values used to create them would”);
internal/weak.Pointer[T]’s only field does not mention T at all. Even
internal/trace.dataTable[EI, E] — whose EI explicitly lists IEqualityOperators — failed the
every-constraint quantifier because IAdditionOperators and its siblings do not themselves
implement the equality interface.
The gate now decides per member (GetEqualityFallbackMembers in
StructDeclarationSyntaxExtensions): a member whose type supports == independent of the
unconstrained parameters keeps the same this.f == other.f compare a non-generic struct emits, and
only a member whose type IS an unconstrained type parameter falls back to golib’s
AreEqual — the identical routing the converter emits for Go == on any type-parameter
operand, giving EqualityComparer<T>.Default speed on value types while preserving IEEE float
semantics (raw EqualityComparer reports NaN equal to itself, inverting Go — see the
floating-point note above) and typed-null/runtime-type semantics for reference and interface
instantiations. Real emissions (from the converted stdlib’s generated sources):
// unique.Handle<T> — ж<T> has pointer-identity == for every T:
public bool Equals(Handle<T> other) =>
this.value == other.value;
// database/sql.Null<T> — mixed: the T field routes through AreEqual, the rest keep ==:
public bool Equals(Null<T> other) =>
global::go.builtin.AreEqual(this.V, other.V) &&
this.Valid == other.Valid;
// net/http.mapping<K, V> — golib slice/map fields carry their own ==, so no member falls back:
public bool Equals(mapping<K, V> other) =>
this.s == other.s &&
this.m == other.m;
The member classifier asks only “does == COMPILE for this member type”: a type parameter
qualifies through ANY IEqualityOperators-implementing constraint (matching C# operator
resolution, not the whole-struct gate’s every-constraint test); reference types (classes incl.
ж<T> and unsafe.Pointer, interfaces, arrays, delegates), enums, pointers, and built-in value
types always qualify; a [GoType] struct qualifies by its attribute — both struct templates
emit a same-type operator == unconditionally, and for a struct of the same compilation the
attribute is the only visible evidence, because that operator does not exist yet while the
generator runs; any other value type qualifies only by actually declaring a same-type
op_Equality. Structs that passed the old whole-struct gate, and every non-generic struct, emit
byte-identical bodies to before — the fallback set is computed only when the gate fails.
GetHashCode needs no matching change (golib.HashCode.Combine always compiled and hashes
consistently with both compare forms). (Guarded by the GenericStructEquality behavioral test —
the Handle pointer-identity shape, the plain-T fallback shape, a T-independent-field struct, the
Null-shaped mix, a nested generic struct field, and a generic struct as a map key, all
output-compared vs Go.)
A generic struct implementing an interface BY VALUE partials at its OPEN definition
A Go method on a generic type is declared for every instantiation, so func (g G[T]) M() makes
G[int], G[string] and G[G[int]] all satisfy an interface with M. The converter records a
[assembly: GoImplement<…>] per instantiation it sees, and ImplementGenerator’s value-form arm
wrote one partial struct per record, spelled with the record’s TYPE ARGUMENTS:
partial struct G<IntPtr> : I. C# reads that argument list as a type-parameter list, so the
declaration disagrees with the converter’s own partial struct G<T> (CS0264) and the mismatched
parts stop merging — every member the template writes then lands in the containing static
package class instead (CS0715 on the operators, CS0708 on Equals/GetHashCode/ToString,
CS0563 and CS0540 in the cascade). The arm now emits ONE partial against the open definition, keyed
by (OriginalDefinition, interface) so all instantiations of a pair fold into it; the member and
value-pair dedupe indexes key on the same open form, since two interfaces over one open generic
share a single partial. Constraints are deliberately omitted — a partial declaration may leave them
off and they merge from the converter’s declaration, so omission can never raise CS0265. The
pointer-adapter arm had always done this (emittedGenericPointerAdapters, crypto/elliptic’s
nistCurve[Point]); this is its value-form sibling.
Behind it sat a second, independent defect in the shared GetSimpleName helper, and it is the one
that explains why the two packages holding this class both name their generic with a single
letter. Asked to drop a type-argument list, the helper tested typeName.IndexOf('<') > 1 — so
G<T>, whose < sits at index 1, kept its arguments. StructTypeTemplate derives the constructor
name from that call, and emitted public G<T>(NilType _), which is not a constructor to C#: the
partial struct G<T> scope never opens and the same spill follows. Every multi-character generic
in the corpus (meta<T>, nistCurve<Point>, Handle<T>) cleared the guard, which is why this
survived to the first single-letter one. The guard is now > 0 and indexes the simple name
rather than the full one — the latter also closes a latent, currently unreached miscut on a dotted
generic (a.Map<K, V> indexed at 5 into an 8-character Map<K, V>, yielding Map<K).
Measured on internal/reflectlite (type B[T any] struct{}) and runtime/debug
(type G[T any] struct{} with var dummy I = G[int]{} and var dummy2 I = G[G[int]]{}), the two
packages the board recorded behind one CS0715 root. runtime/debug moves from build-blocked to a
measured 2 of 9; internal/reflectlite clears this root and stops on five unrelated ones.
Guarded by the GenericValueInterfaceImpl behavioral test — a single-letter generic held as an
interface at three instantiations, a sibling type named exactly like the type parameter (the
runtime/debug shape that made the spilled members render as debug_test_package.T), struct
equality, struct-versus-interface comparison, and interface dispatch over a mixed slice, all
output-compared against go run.
The string | []byte union
C# generic constraints are conjunctive (“and”), so they cannot express Go’s string | []byte union directly. The two members share no operators (the union is neither comparable nor additive), so a conforming body may only use the read operations common to both — indexing, len, and sub-slicing. These are captured by the golib read-only byte-sequence interface IByteSeq, which both @string and slice<T> implement; the converter emits it for the union and suppresses the (spurious) lifted operator constraints:
func HashStr[T string | []byte](sep T) uint32 { /* uses sep[i], len(sep) */ }
public static uint32 HashStr<T>(T sep)
where T : /* string | []byte */ IByteSeq<T, byte>, new()
{ /* … */ }
The constraint is self-referential — the C# rendering of the CRTP shape, IByteSeq<TSelf, T> : IByteSeq<T> where TSelf : IByteSeq<TSelf, T>, instantiated at the type parameter itself. @string implements IByteSeq<@string, byte> and slice<T> implements IByteSeq<slice<T>, T>, so the sub-slice indexer returns the concrete sequence type rather than the interface. That extra type parameter exists purely to keep the union allocation-free (next subsection).
len resolves through a len<TSeq>(TSeq) where TSeq : IByteSeq overload that is dispreferred for concrete slice<byte>/@string arguments, so existing call sites keep their specific overloads (no ambiguity). Go’s []byte(s) and string(s) over a constrained value render as the golib extensions s.ToSlice() and s.ToGoString(), each generic over the caller’s concrete type; both preserve Go’s per-instantiation semantics exactly ([]byte([]byte) shares its backing, []byte(string) copies, string(string) is free, string([]byte) copies). The behavioral test StringByteUnionConstraint exercises both the string and []byte instantiations across all of these.
Allocation-free union-constrained bodies
The interface was originally single-parameter (IByteSeq<T>) with an IByteSeq<T> this[Range] sub-slice indexer. Every member whose signature named the sequence type therefore forced a box, and Go bodies over this union sub-slice constantly:
| Emitted shape | Boxed | Why |
|---|---|---|
((bytes)(s[a..b])) |
48 B (slice<byte>) / 24 B (@string) |
the range indexer returned the INTERFACE, so the struct result was boxed — and the cast then unboxed it |
len(s) |
one box per call | the overload took IByteSeq<T>, an interface parameter |
new slice<byte>(s) — Go’s []byte(s)
|
one box per call | the constructor took IByteSeq<T>
|
new @string(s) — Go’s string(s)
|
one box per call | the constructor took IByteSeq<byte>
|
A parseRFC3339-shaped body (seven sub-slices, eight len calls, six []byte(s) conversions per parse) measured 720 B/parse on the slice<byte> instantiation and 776 B/parse on @string — for Go code that allocates nothing. The remedy is one idea applied four times: never name the sequence type as an interface in a signature a generic body reaches.
-
Sub-slicing moves to the self-referential
TSelf this[Range]. Both implementers’ public range indexers already return their own type, so this is satisfied implicitly, and the call becomes aconstrained.direct dispatch on the value type. The result IS the type parameter, so the converter emits the range expression bare (above). -
lentakes the constrained type parameter (len<TSeq>(TSeq) where TSeq : IByteSeq) instead of the interface. -
[]byte(s)/string(s)become theToSlice/ToGoStringextension methods rather than constructors. C# has no generic constructor, so a constructor can only accept the interface; and a static factory cannot even be named here, because converted code carriesusing static go.builtin, which shadows thesliceand@stringtype names with the builtin conversion methods (slice<byte>.From(s)is CS0119 — “is a method, which is not valid in the given context”). An extension call is member access on the receiver, so it sidesteps both problems. Each foldstypeof(TSeq) == typeof(…)to a per-instantiation constant, so the sharing case reduces to a field copy.
Measured after: 0 B/parse on slice<byte>, and 776 → 416 B/parse on @string (what remains there is the byte[] each []byte(string) conversion must materialize — Go copies too). Guarded by GolibTests ByteSeqAllocationTests, which asserts the measured bytes and carries a deliberately-boxed control that must still report the old 720 B/parse. That control pins its box behind a [MethodImpl(NoInlining)] boundary on purpose: the JIT elides a box/unbox pair written adjacently in one method, so an inline control would report 384 B/parse and understate what the redesign is worth. The real interface indexer boxed inside a callee and returned, which is why the cost was really paid.
Explicit type arguments come from the callee’s instantiation
A generic function’s explicit type arguments are read from the CALLEE’s resolved instantiation (info.Instances), not from the RESULT type’s arguments – the two lists differ whenever the callee has more type parameters than the result names. reflect’s rangeNum[T, N](num N) iter.Seq[T] called rangeNum[int8](v): the result Seq[T] carries ONE argument where the method needs TWO (CS0305). The result’s own arguments still gate whether to emit, so a generic callee returning a plain named type keeps C# inference:
return rangeNum<int8, int64>(v.Int());
Guarded by GenericTypeInference (seqOf[T ~int64, N ~int32 | ~int64](n N) Seq[T]).
A CONSTANT argument that fixes a type parameter is retyped to the instantiation
Go infers a type parameter from an untyped constant’s default type; go2cs then maps that Go type to C#. The two do not meet: an untyped int defaults to Go int, which is go2cs nint, but the constant emits as a bare C# literal whose own type is int (System.Int32). C# infers the type argument from the ARGUMENT, so wantValue(0) makes C# choose T = int where Go chose nint.
Most such calls are fine and deliberately stay bare, because C# repairs the mismatch wherever an implicit conversion bridges it – int -> nint is implicit, so a result that IS the bare type parameter converts at the use site. What cannot be repaired is an invariant position: C# generics have no variance for these, so Action<int, bool> is not Action<nint, bool> and slice<int> is not slice<nint>. Wherever the type parameter reaches a CONSTRUCTED type, the wrong instantiation is terminal (CS1503/CS0315/CS0411).
Two gates therefore retype the constant to the C# spelling of the type Go resolved (untypedIntGenericArgCastType, applied through the per-argument castArgToType plumbing):
-
The sibling
~[]Elock.Index[S ~[]E, E comparable](s S, v E)(slices): Go fixesEfromS’s core type, butwhere S : ISlice<E>carries no such flow, so C# infersEfrom the value alone andslice<nint>then fails the~[]intconstraint. -
An invariant RESULT position (
typeParamReachesInvariantResult): the parameter appears inside a func, slice, array, map, chan or pointer result.internal/concurrent’s own test suite ships the control pair that isolates this exactly –expectMissing[K, V comparable](t, key K, want V) func(got V, ok bool)calledexpectMissing(t, s, 0)mis-inferredVand its returned delegate then rejected the map’snint(CS1503 x16), whileexpectDeleted(..., 15) func(deleted bool)– the same untyped literal,Vabsent from the result – compiled untouched.
wantValue((nint)(0))(i, false); // V reaches func(V, bool) -- retyped
wantPresent(15)(true); // V absent from the result -- bare
bareResult(42); // result IS V; implicit conversion repairs it -- bare
wantValue<nint>(0)(i, false); // Go wrote the type argument -- bare
sliceOf((nint)(9)); // []V is invariant -- retyped
wantInt64((int64)(1234567890123L)); // the cast follows the RESOLVED width, not always nint
The cast type comes from the resolved type, so it is nint, long, byte, nuint and so on as the instantiation requires; a resolved int32/rune is skipped because a bare C# literal already IS System.Int32. A generic NAMED result is skipped too – invariant likewise, but the explicit type-argument rule above already pins that instantiation, and retyping the argument as well would be redundant. Non-constant arguments never qualify: their emitted C# already carries the mapped Go type. Folded constant EXPRESSIONS do qualify (3 + 4, 1 << 10), since they emit as bare C# arithmetic in exactly the same way.
Guarded by GenericUntypedIntArg (the ~[]E lock) and GenericUntypedConstInfer (the invariant-result gate, its control shapes, and the nint/long/byte widths), both output-compared vs go run.
Increment/decrement on a type parameter
i++ / i-- on a constrained type parameter binds IIncrementOperators<T> / IDecrementOperators<T>, which the lifted Arithmetic operator set now includes (reflect rangeNum’s loop, CS0023). They live in the numeric-only Arithmetic set – never the string-including Sum set, since @string implements neither. The list is emitted in two places that must stay in sync: the converter’s getLiftedConstraints (constraintOperations.go) and the go2cs-gen InterfaceTypeTemplate.
Unary negation on a type parameter
-x on a constrained type parameter binds IUnaryNegationOperators<T, T>, which the lifted
Arithmetic operator set now includes (math/rand/v2’s
func keep[T int | uint | int32 | uint32 | int64 | uint64](x T) T { return -x }, CS0023). Like
increment/decrement it is numeric-only — @string has no negation — so it never joins the Sum set.
Satisfying it needed a matching change on the generator side, because a NAMED Go numeric type is
instantiated through its go2cs-gen wrapper. Go defines -x on EVERY numeric type, unsigned
included, as the wrap-around 0 - x; C# has no unary minus for ulong at all and widens
uint/ushort/byte to a signed type, which is why NumericTypeTemplate previously emitted the
operator only for signed types. It now emits the unsigned form as that subtraction under
unchecked — exactly Go’s semantics — so a generic over ~uint64 instantiated with a named
unsigned type (internal/trace’s dataTable[EI ~uint64, E] over type stringID uint64) satisfies
the constraint instead of failing CS0315:
// generated for `type counter uint64`
public static counter operator -(counter value) => (counter)unchecked((uint64)((uint64)0 - value.m_value));
The list is emitted in THREE places that must stay in sync: the converter’s getLiftedConstraints
(constraintOperations.go), the go2cs-gen InterfaceTypeTemplate “Arithmetic” constraint list, and
InheritedTypeTemplate’s NumericInterfaces declaration list (whose operator bodies come from
NumericTypeTemplate). Guarded by GenericNegation, which negates across the primitive widths and
through named types over both a signed (~int32) and an unsigned (~uint64) underlying type,
output-compared against Go so the unsigned wrap is verified rather than merely compiled.
uintptr as a generic numeric type argument
The golib uintptr struct declares the full generic-math interface set the lifted numeric constraints demand (IAdditionOperators through IComparisonOperators, IShiftOperators<uintptr, int, uintptr> with a >>> operator, IIncrementOperators/IDecrementOperators) – matching operators alone never satisfy a C# where-clause (CS0315 at reflect’s rangeNum<uintptr, uint64>). At runtime, ConvertToType/ConvertToUInt64 have uintptr fast paths, and the reflection-cached TypeParamCaster probes a public Value FIELD as well as the generated wrappers’ Value property (hand-written wrappers keep a field for Interlocked/Volatile ref x.Value seams). Guarded by GenericTypeInference (growShrink[U ~uint32 | ~uintptr]).
Latent gap (banked): generated
[GoType("num:*")]wrapper structs do NOT yet declare the generic-math interfaces – a NAMED numeric wrapper used as a union-generic type argument would CS0315. No corpus site hits this yet.
Union-constrained sub-slices ARE the type parameter
A sub-slice of a string | []byte union-constrained value is typed by Go as the type parameter again, so it assigns back to, passes as, and returns as the parameter (time format_rfc3339). The emission is the bare C# range expression:
return parse(s[0..2]) + parse(s[3..5]);
s = s[19..]; // Go: s = s[19:]
Nothing converts it, because nothing needs to: the constraint is the self-referential IByteSeq<T, byte> (above), whose TSelf this[Range] indexer returns T itself, so the range expression already has the type parameter’s type. All four bound shapes take this route — s[..hi], s[lo..hi], s[lo..] and s[..] — and a three-index slice cannot occur on a string-including union (Go forbids it on strings), so Slice3 never reaches it. Downstream members bind through the constraint on the resulting value directly: s[i..j].ToGoString() for Go’s string(s[i:j]) (bytealg’s Rabin-Karp), src[lo..hi].ꓸꓸꓸ for the variadic spread (below).
Func-literal parameters typed as the union type parameter render as the parameter itself (the enclosing method’s type parameter is in scope inside a lambda), matching the Go:
var parse = (T part) => {
History. When
IByteSeqwas single-parameter, its Range indexer returned the interface, and the emission wrapped every sub-slice in((T)(…))to recover the type Go gives the expression (CS0266/CS0310/CS0029 without it — a runtime-checked unbox of a struct the indexer had just boxed). The self-referential constraint retired the box and left the cast an identity conversion emitting no IL; the cast was then retired in turn, so the rendering matches the Go instead of narrating a conversion that no longer happens.
Guarded by StringByteUnionConstraint — trimHead/headSum (assigned back, passed on, returned), digitSum (both bounds, through a func-literal parameter), prefixMatch (low omitted) and wholeSpan (both omitted). Its golden is the A/B: the cast’s removal moves those lines and nothing else, while the stdout comparison against go run stays byte-identical.
Spreading a union-constrained value
A union-constrained value may also be spread into a variadic — encoding/json’s appendString[Bytes []byte | string] does append(dst, src[lo:hi]...) (and the open-ended append(dst, src[lo:]...)). The sub-slice is typed as the type parameter again (above), so the spread renders as src[lo..hi].ꓸꓸꓸ. A bare type-parameter value has no members of its own, so the spread ꓸꓸꓸ (which yields the Span<byte> the append<T>(slice<T>, params Span<T>) overload binds) must be declared on the constraint interface — a member access on a constrained type-parameter value resolves through its constraint. IByteSeq<T> therefore exposes Span<T> ꓸꓸꓸ { get; }; both implementers already satisfy it (slice<T> as Span<T>, @string as Span<byte>), so the interface member is implicit and adds no cast (CS1061 otherwise — the type parameter Bytes had no ꓸꓸꓸ). (Guarded by the StringByteUnionConstraint extension appendRun — a bounded and an open-ended sub-slice of the union value spread into append, both instantiations value-compared vs Go.)
Type Aliasing
Go supports two kinds of type aliasing: a “type definition” and a “type alias declaration”.
Type Definitions
For a Go “type definition” the new type is a distinct type that shares an underlying type with its base. Because converted types are structs (no inheritance), the converter relies on the source generators (see Source Generators) to emit the bridging needed for these to be used interchangeably while remaining distinct: implicit conversion operators down to the underlying type (via ImplicitConvGenerator / TypeGenerator), and, where the base is a built-in like slice, the relevant interface (ISlice<T>, etc.) implementation. A named type also supports the extension methods (receiver functions) of its underlying types, which the generators surface as proxy/overload methods.
When a pointer conversion (*Target)(srcPtr) bridges two structurally-identical structs, the converter records an indirect (boxing) implicit conversion Source → ж<Target> and ImplicitConvGenerator emits implicit operator ж<Target>(Source src) => Ꮡ(new Target(<members>)). For a self-boxing conversion — Source and Target are the same struct (mspan → ж<mspan>), which arises from a self-referential struct’s recursive sub-struct conversions — that member-by-member reconstruction is both unnecessary and wrong: a pointer field whose target ctor parameter is itself a ж<…> was deref’d (src.f?.Value ?? default!), and a value cannot bind a pointer parameter (CS1503). The generator detects self-boxing (the boxed element type equals the source) and emits Ꮡ(src) instead — boxing a copy of the whole struct directly, identical in effect for a pointer-free struct and correct for one with pointer fields. (Validated by the green baseline build, which regenerates every .g.cs, plus the TypeConversion behavioral test for the non-self-boxing form; runtime exercised self-boxing for mspan, g, stackScanState, hmap, etc.)
Type Alias Declarations
For a Go “type alias declaration” the alias matches C# aliasing implemented with the using keyword. Since the alias may be exported and referenced across files, the converter emits a global using (C# 10’s Global Using Directive) into the package’s generated aliases. For example:
type P = *bool
type M = map[int]int
type table = map[string]int
global using P = go.ж<bool>;
global using M = go.map<nint, nint>;
global using table = go.map<go.@string, nint>;
A global using RHS renders csproj-alias numerics as C# keywords. C# resolves a using directive’s target without reference to other using directives — aliases are invisible to one another — so the golib csproj-level aliases (uint64, float64, any, …) that resolve everywhere else in the compilation are CS0246 inside global using X = …;. The alias-declaration emission (only) rewrites those names to their using-safe keyword/BCL equivalents: fiat’s type p224UntypedFieldElement = [4]uint64 emits global using p224UntypedFieldElement = go.array<ulong>;. Body code keeps the Go-visual alias names; already-safe names (byte, bool, nint, go.@string) are untouched, and the rewrite deliberately skips dot-qualified names so a package type sharing a builtin name is left alone. (Guarded by the AliasStructComposite extension words — an alias to [4]uint64 used as a parameter type, output-compared.)
An alias to an unnamed array/slice resolves through types.Unalias at type-switched decision points. An alias is a *types.Alias (Go 1.22+) — neither the AST’s ast.ArrayType nor the resolved *types.Array — so an emission that type-switches on the syntax node or the unresolved type misses it. Three sites resolve through types.Unalias: (1) the range operand dispatch — for _, e := range w on an alias-typed array previously matched no arm and emitted the whole loop as a C# comment, a silent behavioral hole; it now emits the normal foreach (var (_, e) in w). (2) the composite-literal dispatch — words{10, 20, 30, 40} emits the same element-array projection the unnamed literal uses, new uint64[]{10, 20, 30, 40}.array(); the alias name renders as an Ident (not an ast.ArrayType), so it cannot take the composite-initializer bracket rewrite, and keeping it produced a C# collection initializer on the alias (new words{…} — CS1061, array<T> has no Add). (3) var declarations — a local or package-level var w words allocates the fixed-size backing (words z = new(4); / internal static words gw = new(4);) instead of default!/uninitialized, whose null backing array throws NRE on the first element write. An alias to a named type is unaffected: unaliasing lands on *types.Named and the existing wrapper-struct arms apply, with the alias name preserved. (Guarded by the AliasStructComposite extensions — alias-typed range loop, composite literal, and local + global var with element writes, values vs Go.)
A same-package alias TARGET carries the package’s FULL namespace, not just its class. An exported alias whose target is lifted into the package class — type CorpusEntry = struct{…} lifts its anonymous struct to a nested CorpusEntryᴛ1 (and the same for an alias to a same-package named type) — must qualify that target with the package’s whole namespace. For a package in a nested namespace (internal/fuzz → namespace go.@internal, class fuzz_package; net/http → go.net / http_package) the lifted type lives at go.@internal.fuzz_package.CorpusEntryᴛ1. Building the qualifier from the bare <pkg>_package class alone dropped the intervening namespace segment, so the emitted global using CorpusEntry = go.fuzz_package.CorpusEntryᴛ1; — and the matching [assembly: GoTypeAlias("CorpusEntry", "go.fuzz_package.CorpusEntryᴛ1")] that every consumer replays verbatim through its <ImportedTypeAliases> block — named a namespace that does not exist → CS0234 at the using-alias line and at every use (internal/fuzz’s CorpusEntry, ×60). The qualifier is now taken from the same packageNamespace that emits the namespace …; declaration (minus the root, plus the class), so the target and the declaration always agree: go.@internal.fuzz_package.CorpusEntryᴛ1. A top-level package’s namespace is exactly the root (go), leaving no intervening segment, so its target stays go.<pkg>_package.… — the emission is byte-for-byte unchanged there. (Guarded by the NestedAliasUser behavioral test — a top-level package main that imports its own nested inner subpackage, whose C# namespace is go.NestedAliasUser; inner exports an anon-struct alias Entry, and both inner’s own global using and the consumer’s imported global using innerꓸEntry resolve to go.NestedAliasUser.inner_package.Entryᴛ1, values vs Go.)
The whole RHS is namespace-ROOTED, at every nesting depth — the alias resolves at COMPILATION scope. The two paragraphs above each close one hole in this wall; this is the wall. C# resolves a using alias’s target as if the immediately containing compilation unit had no using directives, which puts it outside the file’s namespace go;, outside the emitted <pkg>_package class, and with none of the csproj-level golib aliases in effect. Every other rendering in the converter elides the root namespace from nested names, because every other rendering lands inside namespace go — where the elision is legal and is what makes the emitted C# read like Go. Only the outermost name was rooted here, so each type ARGUMENT under it named nothing: type names = []string emitted global using names = go.slice<@string>;, CS0246 on @string. The same held for slice, error, complex64 and ж; for a same-package Header (which is go.main_package.Header at that scope); for a cross-package io_package.Reader (go.io_package.Reader); and for the BCL Func/Action of a func-type alias, since System is not in scope there either. Nine aliases produced seventeen CS0246.
The alias emission therefore renders in a rooted-nesting mode, in which the target and everything it nests carry full qualification:
type Header struct{ Name string }
type nested = map[string][]Header
type fn = func(string) int
type rdrs = []io.Reader
global using nested = go.map<go.@string, go.slice<go.main_package.Header>>;
global using fn = System.Func<go.@string, nint>;
global using rdrs = go.slice<go.io_package.Reader>;
Four qualifiers are in play and they are not interchangeable: golib types root to go., the BCL delegates to System., golib’s variadic delegate FAMILY (Actionꓸꓸꓸ/Funcꓸꓸꓸ) back to go., and a same-package name to go.<ns>.<pkg>_package. — the mechanism of the paragraph above, applied now at every depth rather than to the target alone (which is why the target no longer needs a prefix computed for it separately). The csproj-alias names are the exception that proves the rule: uint64, float64, any and friends are not members of go at all, so they are substituted with the keyword or BCL type they stand for rather than rooted — the first paragraph’s rewrite, moved inward. An alias whose target is ITSELF an alias resolves through types.Unalias before rendering, since a C# using alias may not name another using alias.
This is a user-code defect rather than a corpus one, and the census says so precisely for the type-ARGUMENT arm: the whole converted standard library declares exactly four package-level aliases with type arguments (fiat’s p224/p256/p384/p521, each [4]uint64), and all four take a C# keyword as their argument, so that arm moves nothing. An end-user package that aliases a slice, map, channel or func type — the ordinary type Handlers = map[string]Handler — hit it on the first build, and a -recurse module conversion hit it over a third-party type.
The substitution arm is the one with corpus sites, and they were live CS0234 nobody had seen. A csproj-alias name reaching the alias RHS as the WHOLE target was rooted rather than substituted — go.int32, which the compiler rejects with “the type or namespace name int32 does not exist in the namespace go”, since int32 is a <Using Alias=…> for System.Int32 and not a member of go at all. getUsingAliasSafeTypeName could not catch it because that sweep deliberately skips dot-qualified names, exactly so a package type sharing a builtin name is left alone. Six sites carried it, all cgo _C_* typedefs in darwin-exclusive files (os/user/darwin/cgo_lookup_syscall.cs, net/darwin/cgo_unix_syscall.cs), which is why they stayed latent: the default $(GoTargetOS) is windows, so nothing compiles them. type _C_int = int32 now emits global using _C_int = int;, and the neighbours that were already right are unmoved — _C_char = byte (a C# keyword) and _C_size_t = go.uintptr (uintptr IS a real golib struct, so rooting it is correct). (Guarded by the PackageAliasRootedTypeArgs behavioral test — twenty-five package-level aliases covering golib element types, keyword element types that must NOT be rooted, same-package named types at one and two levels of nesting, a lifted anonymous struct and interface, a cross-package interface, both directional channel forms, both delegate spellings, and an alias to an alias, output-compared vs Go. Also pinned by TestRecurseChannelOfHyphenatedModulePath, whose assertion recorded the unrooted cross-package form until this landed.)
Rooting is IDEMPOTENT: an already-global::-rooted target is not rooted again. The rooted mode
above prefixes the root namespace onto every name it renders, and one caller hands it names that are
already rooted — a white-box test conversion, whose test-alias qualifiers build a reference to a
production declaration with an explicit global:: (global::go.net.netip_package.uint128). Prefixed
a second time that becomes go.global::go.net.netip_package.uint128, which is CS7000 “unexpected
use of an aliased name”: global:: is the root, so anything in front of it is by construction not a
name. net/netip’s export_test.go re-exports two unexported production types this way
(type Uint128 = uint128, type AddrDetail = addrDetail) and both of its alias lines failed to
parse, taking all 266 of the package’s verdicts with them. The renderer now returns a global::
target unchanged, stated at the renderer rather than at the one caller, because every future caller
wants the same answer. (Guarded by mixedKeyedComposite_test.go’s
TestRootedUsingAliasKeepsGlobalQualifier, which asserts the rooted and unrooted renders both
leave such a name alone.)
Delegates to Value Receiver Instances
A Go METHOD EXPRESSION — (*timers).run, the unbound method as a func value whose first parameter is the receiver (runtime time.go’s abi.FuncPCABIInternal((*timers).run)) — selects a method off a type. Emitting the selector naively renders the type in value position ((ж<timers>).run — CS0119 + CS1503). Go types the expression as the func signature with the receiver prepended, so the converter renders that signature as the concrete delegate type and casts the method’s static form to it: (Func<ж<timers>, int64, int64>)(run). For a [GoRecv] method the RecvGenerator’s ж-overload matches the delegate exactly; a value-receiver method expression (counter.get) casts to its value-typed delegate ((Func<counter, nint>)(get)); a direct-ж method’s primary form matches directly. (Guarded by the MethodExpression behavioral test — pointer- and value-receiver method expressions assigned, passed inline, and invoked, with mutations accumulating through the receiver box, values vs Go.)
A method expression on a FOREIGN type — (*http.Request).Write (net/http/httputil persist.go), or (*Reader).ReadBytes in an EXTERNAL test (package bufio_test) that dot-imports bufio — must additionally qualify the method’s static form: the [GoRecv]/extension static (and its RecvGenerator ж-overload) lives in the defining package’s class, so the bare name is CS0103 (a using static imports an extension method only for recv.M() invocation, never as a bare method group — so (Func<…>)(ReadBytes) cannot bind it; a same-named local method would instead mis-bind, CS0123). The qualifier is derived from the method’s OWN package via go/types (importQualifier(obj.Pkg().Name()), with the file’s import-alias override) — identical to how getAliasQualifiedTypeName qualifies the receiver type inside the delegate (bufio.Reader) — rather than by peeling the Go source spelling: a dot-imported type is a BARE ident (Reader), not a pkg.T selector, so the old source-peel silently dropped the qualifier and emitted the bare name. The result is (Func<ж<http.Request>, io.Writer, error>)(http.Write) / (Func<ж<bufio.Reader>, byte, (slice<byte>, error)>)(bufio.ReadBytes). A same-package method expression keeps the bare name — the static is in scope — so existing emissions are unchanged. (Guarded by the CrossPkgUser extension — pointer-receiver (*CrossPkgLib.Sensor).Calibrate (write observed through the original receiver) plus value-receiver Sensor.Hot / Celsius.Add foreign method expressions, each invoked through its func value, output-compared vs Go — and by the MethodExprDotImport behavioral test, which dot-imports a sibling package and uses (*Reader).Read / (*Reader).Peek as func values; the pre-fix converter fails it with bare (Read)/(Peek) — CS0103 — exactly the bufio TestUnreadByteOthers failure.)
The bound method value — d.compute = metricReader(read).compute (runtime metrics.go), types.MethodVal used as a value — forwards through a lambda that captures the receiver expression and carries the method’s own parameters, explicitly typed: (ж<statAggregate> p1, ж<metricValue> p2) => ((metricReader)read).compute(p1, p2). The previous emission hardcoded arity zero (() => x.m()), mismatching any non-nullary target delegate (CS1593). One documented divergence: the receiver expression is evaluated inside the lambda (per call), where Go binds it once at method-value creation — acceptable for the compile milestone and the simple receivers observed. (Guarded by the MethodExpression extension — a bound c.add invoked repeatedly, mutations accumulating through the bound receiver, values vs Go.)
An INTERFACE-receiver method value in assignment context is exempt from that lambda: an interface method is a genuine C# instance method, so a plain method group over the evaluated receiver expression both compiles and matches Go’s bind-once semantics exactly — f = conf.Sizes.Alignof (go/types sizes.go, conf the ref receiver) emits f = conf.Sizes.Alignof;, evaluating conf.Sizes once at delegate creation. The synthesized lambda there was doubly wrong: it re-evaluated the receiver per call and captured conf — capturing a ref receiver is CS1628. This mirrors the value-context rule below, which already leaves interface receivers on the plain emission; whole-stdlib footprint of the change: sizes.cs ×6, database/sql convert.cs, debug/buildinfo, net/http h2_bundle — every hunk a lambda collapsing to its method group. (Guarded by the IfaceFieldMethodValueBind behavioral test — a method value on an interface field of a pointer receiver with the field REBOUND after the value is taken, proving bind-once, output-compared vs Go.)
A POINTER-receiver method value in a value context — passed as a call argument rather than assigned: s.nonDefaultOnce.Do(s.register), registerMetric(…, s.nonDefault.Load) (internal/godebug) — cannot use the bare selector: the [GoRecv] emission is an extension method whose first parameter is a value type, and C# cannot create a delegate from that (CS1113/CS1061). Go binds the receiver address once at method-value creation (s.register ≡ (&s).register), so the converter emits exactly that binding as a box-bound method group over the RecvGenerator’s ж-overload (class-typed, delegate-legal): Ꮡs.register for the receiver itself, Ꮡs.of(Setting.ᏑnonDefault).Load for a receiver value-field chain (the &x.field machinery renders the real field box). Unlike the assignment-context lambda above, this form matches Go’s bind-once semantics exactly. A method whose body contains such a method value on its own receiver (or a value-field chain of it) is promoted to direct-ж by the capture-mode pre-pass (bodyHasPointerMethodValueOnReceiver) so the receiver box Ꮡrecv exists in scope. (Guarded by the ReceiverFieldMethodCall extension — method values on the receiver, on a receiver value field, and on a boxed local’s field, passed as func values and invoked with mutations landing on the real storage, values vs Go.)
The VALUE-receiver analog captures rather than binds. When a value-receiver method value roots at the enclosing method’s receiver — kdf.hash.New (crypto/internal/hpke’s hkdfKDF, whose hash field is a crypto.Hash and New a value-receiver method; also crypto/tls’s c.hash.New) — the emitted method is an extension over a value receiver, which likewise has no C# delegate (CS1113), so the converter synthesizes a wrapping lambda carrying the method’s own parameters: () => kdf.hash.New(). But that lambda captures the receiver, and a non-direct-ж pointer-receiver method renders this ref hkdfKDF kdf whose ref var kdf = ref Ꮡkdf.Value alias cannot be captured by a C# closure (CS1628 — “cannot use ref/in/out parameter inside a lambda”). So a method whose body contains such a method value is promoted to direct-ж by the capture-mode pre-pass (bodyCapturesReceiverInValueMethodValue, the value-receiver sibling of bodyHasPointerMethodValueOnReceiver), giving it a receiver box Ꮡkdf that the synthesized lambda references as a capturable reference: () => Ꮡkdf.Value.hash.New(). Two supporting pieces make the receiver render through its box inside the synthesized lambda (which has no *ast.FuncLit node): the capture-analysis walk now marks the field-chain root receiver box-ref, not only a bare-ident receiver (kdf.hash.New roots at kdf, not a bare ident); and the value-receiver synthesis renders the receiver expression in a lambda-conversion context (conversionInLambda) so convIdent emits the Ꮡkdf.Value box form. Same documented divergence as the other method-value forms — the receiver expression re-evaluates inside the lambda (per call), where Go’s value-receiver method value binds a copy of it once at creation; acceptable for the compile milestone (the closure sees the same receiver instance, matching the pointer-receiver semantics of the enclosing method). This also cleared the identical latent CS1628 in crypto/tls’s key_schedule.cs (expandLabel/extract/finishedHash each pass c.hash.New to hkdf/hmac, and the direct-ж fixpoint promoted their callers deriveSecret/trafficKey/… with call sites adapting to Ꮡc.expandLabel / Ꮡsuite.trafficKey). (Guarded by the ReceiverCapturedInClosure extension — a pointer-receiver method capturing its receiver through a value-receiver method value on a value field-chain (w.id.render) and on the bare receiver (w.tag), alongside the pre-existing func-literal capture, all invoked and output-compared vs Go; whole-stdlib reconvert diff: exactly hpke + the two crypto/tls files changed, nothing else.)
The go-statement sibling of the receiver-capture family: a go statement calling a value-returning method through the enclosing method’s pointer receiver — go q.conn.HandshakeContext(ctx) inside func (q *QUICConn) Start (crypto/tls quic.go, CS1628) — is FORCED into the synthesized-lambda emission because goǃ has only void Action overloads (the x/net/nettest CS0407 form): goǃ(ᴛ1 => q.conn.HandshakeContext(ᴛ1), ctx). That lambda references the receiver exactly like the method-value cases above, but neither closure predicate sees it — there is no *ast.FuncLit and no method-VALUE expression, only a go-call whose lowering will synthesize one. The capture-mode pre-pass therefore also promotes on bodyHasGoStmtLambdaCapturingReceiver, which mirrors visitGoStmt’s lambda-form decision (a nullary call synthesizes a lambda only for a value-returning or named-func-type callee; a call with arguments does so when the callee returns a value or the arity mismatches — variadic never matches) and fires when the CALLEE expression references the receiver (arguments render outside the lambda, as goǃ call arguments). With the method direct-ж, the go-stmt capture analysis’ existing box-ref marking of the receiver (varIsDerefdPointerParam) takes effect and the lambda renders the chain through the box: goǃ(ᴛ1 => Ꮡq.Value.conn.HandshakeContext(ᴛ1), ctx). The method-group emissions are excluded and unchanged — a void matching-arity callee (os/exec’s go c.watchCtx(resultc)) binds the receiver chain at delegate-creation time, outside any lambda; a defer sibling needs no equivalent because any function-level defer already promotes via bodyWrappedInDeferContext. Known divergence (Phase-4 item): the synthesized lambda reads the receiver chain (Ꮡq.Value.conn) at goroutine-run time, whereas Go evaluates the method-value receiver at go-statement time — go q.conn.M(x); q.conn = other deterministically calls the OLD conn in Go but races toward the NEW one here (arguments are statement-time in both). The same lazy-chain window already exists for every synthesized go-lambda over a non-receiver chain (the CS0407 discard form); the faithful fix for the whole class is hoisting the receiver-chain prefix into a statement-time temp before the lambda, which would also avoid the direct-ж signature flip — direct-ж was chosen here for machinery reuse under the compile-first milestone. (Guarded by GoStmtReceiverLambda — go e.tally.bump(delta) (argument arm) and go e.tally.report() (nullary arm), both value-returning through a pointer field of the receiver, with the goroutine’s writes read back through the original receiver chain, output-compared vs Go.)
A conversion to a named func type — metricReader(read) where type metricReader func() uint64 — targets a C# delegate declaration (internal delegate uint64 metricReader();). Distinct delegate types have no cast conversion (a (metricReader)read from Func<ulong> is CS0030); C# converts via delegate creation: new metricReader(read), which accepts a compatible delegate or method group. The general conversion branch special-cases a named target whose underlying is a *types.Signature. Composed with the bound-value lambda this renders the full runtime metrics.go registration: d.compute = (ж<statAggregate> p1, ж<metricValue> p2) => new metricReader(read).compute(p1, p2). (Guarded by the MethodExpression extension — a named-func-type conversion with a bound method invoked through a func field, values vs Go.)
A GENERIC defined function type — Go 1.23 iter’s type Seq2[K, V any] func(yield func(K, V) bool) — emits a generic delegate: public delegate void Seq2<K, V>(Func<K, V, bool> yield);. Two converter details make this work: the type parameters live on the NAMED type, not the *ast.FuncType’s signature, so the delegate declaration derives its generic definition from the TypeSpec’s defined type (as the struct/array paths do — deriving from the signature emitted a non-generic Seq2 whose K/V were undefined, CS0246/CS0308); and a conversion to a generic instantiation (Seq2Like[string, int](fn)) peels IndexListExpr (multi-parameter — the single-parameter IndexExpr already peeled) and resolves the instantiated target from the Fun expression’s type (the TypeName resolves to the uninstantiated generic, against which convertibility fails), then routes through the same delegate-creation form: new Seq2Like<@string, nint>((@string k, nint v) => …). The instantiated-target override is gated to uninstantiated-generic named targets so pointer conversions ((*uint64)(p), whose Fun type is the full *T with the * re-applied separately) are untouched. (Guarded by the GenericTypeInstantiation extension — a generic defined func type declared, instantiated with two type arguments, and invoked both through a generic function and directly, values vs Go; clears the iter package’s five wave-1 errors.)
In Go a function is a value; a value-receiver method can be assigned to a variable, and the variable captures its own copy of the receiver value at the moment of assignment. This surprises many non-Go programmers:
package main
import "fmt"
type data struct {
name string
}
func (d data) printName() {
fmt.Println("Name =", d.name)
}
func main() {
d := data{name: "James"}
f1 := d.printName
f1()
d.name = "Gretchen"
f1()
}
This prints Name = James twice (run it) — f1 bound a copy of d, so the later mutation is not observed. To preserve this semantic, the converter copies the receiver value into the delegate’s capture rather than capturing the variable by reference, so the delegate executes against the snapshot taken at assignment time.
A method value reassigned via = hoists its receiver capture
The receiver-snapshot decl above is emitted as a full statement (var dʗ1 = d;) before the lambda. In a := declaration this hoists naturally, but a plain = reassignment to a pre-declared variable — database/sql’s checker = nvc.CheckNamedValue — already wrote the LHS and = operator by the time the snapshot is generated, so writing it inline split the assignment into three token-broken pieces (CS1002). The converter routes the snapshot to the statement hoist buffer so it precedes the whole statement:
var checker func(*driver.NamedValue) error
checker = nvc.CheckNamedValue // reassign a method value
var nvcʗ1 = nvc;
checker = nvcʗ1.CheckNamedValue;
This matches the :=-define path (which already hoists) and also covers a reassignment inside a tagless switch case. (Guarded by the MethodValueReassignCapture behavioral test.) CheckNamedValue here is an interface method, so the assignment binds a plain method group over the hoisted snapshot (see the interface-receiver rule above); a concrete-receiver method value keeps the param-carrying lambda form, referencing the snapshot the same way.
A POINTER-receiver method value binds the ADDRESS in assignment context too
Go’s sw.Closesocket, where Closesocket has a *Switch receiver and sw is addressable, is
(&sw).Closesocket — the address is taken once, at method-value creation (the same spec rule the
heap-box arm above rests on). The VALUE-context arm — a method value passed as a call argument —
already synthesized that & and bound the method group over the box. The assignment-context arm
did not: it rendered the receiver expression plainly and forwarded through the param-carrying lambda,
so the lambda body called the [GoRecv] ж<T> extension with a struct value receiver. net’s six
socket-hook installs in main_windows_test.go were five CS1929 plus one CS1501, the latter because
the value receiver made a different, differently-arity’d overload the compiler’s best candidate:
poll.CloseFunc = sw.Closesocket // sw is a package-level socktest.Switch
listenFunc = sw.Listen
poll.CloseFunc = Ꮡsw.Closesocket; // was (syscallꓸHandle p1) => sw.Closesocket(p1)
listenFunc = Ꮡsw.Listen;
The arm takes the value-context emission wholesale — a method GROUP over the box — rather than only re-pointing the forwarding lambda’s receiver, because two things were wrong, not one:
- The lambda is unnecessary here. A pointer-receiver method value binds once and aliases, so the group is both simpler and strictly more faithful than the lambda’s documented per-call receiver re-evaluation. (The lambda remains right for a value receiver, where C# cannot build a delegate over a value-type extension at all.)
- The receiver snapshot was the deeper error, and only a local receiver exposes it. The snapshot
exists to preserve a value receiver’s bind-a-COPY semantics, which a pointer receiver does not
have — the escape analysis has already ruled the other way by heap-boxing the local (the rule
A pointer-receiver METHOD VALUE heap-boxes its receiver above) — and it renames the receiver, so
the synthesized
&producedᏑcʗ1, a box nothing declares (CS0103).visitAssignStmtnow skips the snapshot for this shape at both of its method-value sites (methodValueBindsReceiverAddress).
net’s own sites never showed the second half: sw is package-level, so no capture snapshot is taken.
The local-receiver form was broken before this change too — as CS1929 rather than CS0103 — so the
guard moved the error rather than introducing it. A receiver expression that is already a pointer
keeps both its existing pointer-context rendering and its snapshot: Go copies the pointer there, which
is exactly what the snapshot models. (Guarded by MethodValueReassignCapture’s counter.bump arm — a
pointer-receiver method value assigned to a pre-declared func var, with the mutation read back through
the original local, output-compared vs Go.)
A VALUE-receiver method value snapshots its receiver in EVERY position
The receiver snapshot above exists because Go binds a value receiver by copy at evaluation — x.M
saves the receiver when the method value is created, not when the resulting func is called. The two
visitAssignStmt sites did that for assignment contexts. Every other position — a composite-literal
element, a call argument — reached convSelectorExpr’s param-carrying lambda instead, which rendered
the receiver live and so re-read it per call. The comment there recorded that as a caveat; it is
observable, and it presents as three unrelated-looking symptoms depending only on how the enclosing
slot happened to render the variable:
x := frame{Name: "a"}
parts := []func() string{ x.label, func() string { return x.Name } }
x.Name = "b"
fmt.Println(parts[0](), parts[1]()) // Go: a b
// before — the receiver read from the box at CALL time, printing "b b"
var parts = new Func<@string>[]{ () => Ꮡx.Value.label(), () => Ꮡx.Value.Name }.slice();
// after — the method value binds its own copy; the closure still sees the variable
var xʗ1 = x;
var parts = new Func<@string>[]{ () => xʗ1.label(), () => Ꮡx.Value.Name }.slice();
Spelling the same shape []any{…} renders the receiver as the bare ref-local alias instead
(() => x.label()), which a lambda cannot capture at all — CS8175, the loud member of the family
and the one a runtime -tests conversion surfaced. Both close with the one snapshot; a fix that
instead routes the receiver through the box closes the loud member by converting it into the silent
one.
Three properties make the snapshot correct rather than merely compiling:
-
It is per-EVALUATION, never shared. Two method values over one variable in different statements
bind different receivers (
p, thenqafter a write), so each mints its own snapshot. Converging every capture of one variable onto a single name — the shape a persistent capture-name registry would impose — would printp p. -
It diverges from a sibling closure over the same variable, deliberately. In the example above the
method value must report the pre-write receiver and the closure the post-write one, from one
statement. The snapshot’s initializer is therefore rendered outside lambda context while the
wrapper body binds the snapshot name, which also bypasses
convIdent’s box rewrite — renaming inside it yieldsᏑxʗ1.Value, a box nothing declares (CS0103). -
It is gated on a statement-level sink existing. The declaration goes to
v.hoistedDecls, the same bufferconvFuncLitdrains into for a func literal “on the RHS or inside a composite-literal element of it”, which is what gives a nested element a valid declaration position. Where no sink exists the previous rendering stands: never apply a rename you cannot also declare.
The assignment position needed the same treatment for a different immediate reason. There the
snapshot is asked of the capture machinery, and it is delivered — until something heap-boxes the
variable, at which point processPotentialCapture returns early on boxRefVars (“must NOT be
snapshot-captured”). That early return is correct for a CLOSURE, which has to observe later writes
through the shared box, and wrong for a value-receiver method value, which must not:
x := frame{Name: "a"}
f := func() string { return x.Name } // heap-boxes x
m := x.label
x.Name = "b"
fmt.Println(m(), f()) // Go: a b — C# was: b b
So that site mints its own temp too, gated on the variable actually being box-ref, so the ordinary
path keeps producing the one snapshot it already produces correctly — two would be a second copy of a
single evaluation. It is gated on a value, non-interface receiver besides: a pointer receiver binds the
ADDRESS and must not be copied, and marking one for snapshot is what turns this fix into CS1003/CS1002
across production files. A census of the whole standard library found 54 assignment-context
method-value sites, of which 8 are box-ref and all 8 are pointer-receiver (database/sql,
go/parser ×4, go/types ×2, net) — so this arm fires nowhere in the corpus today and its emission
is byte-identical. Both silent members are reachable and neither is currently reached: they close a
shape one refactor away, not an observed wrong answer.
The receiver EXPRESSION is evaluated exactly once, for every kind and every shape
The rule above is about the receiver’s value; this one is about the expression. Go saves the
receiver when the method value is created, so f().M calls f exactly once and a.b.M reads the path
exactly once. Every wrapper-lambda emission deferred that expression into the lambda, re-doing it on
each invocation. Two independent mechanisms, and the second is invisible if you only look for the
first:
-
M1 — the expression is deferred. Re-executes calls and re-reads paths per invocation. It is
kind-independent: measured red on a value receiver (
makeFrame().label, Go calls it once, the conversion called it per invocation) and on a pointer receiver (makePtr().bump, likewise). The pointer case is reachable in exactly one shape — a call returning a pointer, since a value result is not addressable — which is why “pointer receivers emit method groups, so they already evaluate once” is true of every shape but that one, and false overall. -
M2 — the root-ident snapshot aliases. The capture machinery snapshots the root ident of the
receiver expression, which is sufficient for a base with VALUE semantics (a struct, a slice header)
and useless for one with REFERENCE semantics: copying a pointer or a map header still reads the same
object.
p15.f.labelandm13["k"].labelboth re-read live state through the copy.
So a field chain is correct over a struct and broken over a pointer, with identical syntax — the discriminator is the base’s storage, not the shape.
The cut hoists the receiver into a statement-level temp and binds the temp. The temp is kind-correct
by construction: whatever the arm already rendered is exactly what Go saves — a value copy, the
bound Ꮡ address, or the interface value — so nothing derives the temp’s content from the kind, which
is where a shape-first version would snapshot a value and bind its address. The initializer is
re-rendered in the ENCLOSING context, not reused from the caller: the caller’s string is produced
in-lambda, so a captured base reads as the capture machinery’s snapshot name, and that snapshot is
declared into the same hoist buffer after this temp — emitting var recvʗ1 = h6ʗ1.f; above
var h6ʗ1 = h6;, CS0841 on every captured base.
A bare ident receiver is left alone, which is not an exception: a local read has no side effect and nothing to alias, and the ident paths already produce a once-evaluated temp of their own. Most sites the cut rewrites were therefore already correct, by accident of value semantics; it makes them correct by construction, which is why its diff is wider than its behavioural yield.
Restricted to a plain ident receiver of non-pointer type — the shape an emission-attached census of the
whole standard library found (19 sites: bytes ×3, strings ×3, encoding/json ×8, crypto/tls ×3,
crypto/internal/hpke ×2, of which 12 have an ident receiver). A pointer base auto-deref’d to a value
receiver needs the deref snapshotted rather than the pointer, and takes the rule in the next section.
(Guarded by MethodValueReceiverSnapshot, which output-compares all five positions — typed element,
any element, cross-statement independence, call argument, and the box-ref assignment — against
go run.)
A VALUE receiver reached through a POINTER expression hoists the POINTEE’s copy
Go’s implicit dereference: h.p.label with p *frame and a value-receiver label IS (*h.p).label,
so what the method value saves is the pointee’s copy, taken at evaluation. Two consequences follow,
and they are separate questions — a later write through the pointer is not visible through the method
value, and repointing the pointer afterwards is not either.
The evaluate-once rule above renders the receiver as the enclosing context does, which for this shape is
the box, and binding a ж<T> where the emitted extension wants a T does not compile:
h := holder{p: &frame{Name: "a"}} // p is *frame; label has a VALUE receiver
fieldV := h.p.label
h.p.Name = "A"
fmt.Println(fieldV(), h.p.label()) // Go: a A
// before — the receiver expression rendered as the box, per call
var hʗ1 = h;
var fieldV = () => hʗ1.p.label(); // CS1929: ж<frame> offered to label(frame)
// after — the temp holds the DEREF, so it is the pointee's copy at evaluation
var recvʗ1 = ~h.p;
var fieldV = () => recvʗ1.label();
h.p.Value.Name = "A"u8;
operator ~ returns T by value, so a value receiver’s copy semantics come out of the dereference
itself rather than out of a rule about it: a mutation inside the method reaches the copy and never the
pointee, and the temp is pinned to the pointee that was there at evaluation. The check runs before the
bare-ident early return, because for a pointer ident the once-evaluated rendering already in hand is the
BOX — a different value from the pointee, so hoisting it is not the “second copy of one evaluation” that
return exists to prevent.
Two narrowings, each matching a rule the CALL path ((~z).make(n)) already proved: a deref-aliased
receiver expression — a pointer parameter, or the enclosing method’s pointer receiver — already renders
as the value, so a second ~ would dereference a non-pointer (CS0023) and the temp takes the plain
rendering (var recvʗ1 = p;); and a promoted method reaches its receiver through the .of(…) hop
machinery, so it keeps its existing emission. Both the assignment arm and the value (call-argument) arm
take the hoist — the same defect stood in both, and fixing one of them is not the fix.
Footprint, measured as two seeded whole-stdlib emissions diffed against each other (never against
the committed tree, which is a moving baseline): zero in src/core — 0 changed files and 0 hunks
across 6004 files per side, 0 unreadable, with both conversions exiting 0 and both emissions asserted
to carry the run’s own mtimes. The shape is unreached in the production corpus, which is why it was
declined rather than guessed at when the evaluate-once family landed. It is not unreached in the behavioral corpus: ReceiverCapturedInClosure’s
viaBareMethodValue is exactly it — a pointer RECEIVER ident under a value-receiver tag — and its
golden re-baselines from call(() => Ꮡw.Value.tag()) to a once-evaluated var recvʗ1 = w;. Its sibling
viaFieldMethodValue (w.id.render, a value field) is untouched, which is the narrowing working:
what matters is the type of the receiver EXPRESSION, not of the base it is reached through. No output
moves there — nothing writes between creation and call — so it is a correct-by-construction change with
no locally observable consumer, stated as such. (Guarded by MethodValuePointeeCopy, which
output-compares nine positions against go run: pointer field, repointed pointer, pointer local ident,
call argument, a call-shaped pointer receiver counted for evaluate-once, a method with parameters, the
value receiver’s own mutation-does-not-reach-the-pointee direction, a pointer parameter, and a
pointer-typed slice element. Eight of the nine positions are CS1929 on the pre-cut converter — nine
errors, the call-argument position carrying two sites — and the ninth, the pointer parameter, compiles
there and silently reports the pointee as it stands at CALL time.)
A bare function value in := takes its named delegate type, not var
Go’s short-declaration from a bare function value whose type is a named func type — text/template/parse’s state := lexText, where lexText is func(*lexer) stateFn and type stateFn func(*lexer) stateFn (the classic self-referential state machine) — infers the local as the unnamed signature. The converter cannot emit var state = lexText; (a C# method group has no var-inferable delegate type — CS8917), and typing the local structurally as Func<ж<lexer>, stateFn> makes it a distinct C# delegate from the stateFn the method group produces and that each state = state(l) reassignment yields (CS0029). It declares the local with the matching package named delegate instead:
state := lexText
for state != nil {
state = state(l)
}
stateFn state = lexText;
while (state != default!) {
state = state(l);
}
A := from a method group whose signature matches no package named func type keeps the existing path. (Guarded by the NamedFuncTypeStateMachine behavioral test.)
A DISCARDED function value is cast, never declared
One statement form over, the same typeless right-hand side needs the opposite treatment. Go writes _ = someFunc to force a symbol to be referenced — debug/elf’s file_test.go does exactly _ = net.ResolveIPAddr // force dynamic linkage. A C# discard infers its type from its RHS, and the two func-value forms cannot supply one: a method group, and a lambda (a func literal, or the method value that converts to one). Both are CS8183, “cannot infer the type of implicitly-typed discard” — the discard analogue of the var problem above, and notably NOT the same answer, because C# 10 does give a method group a natural type for var f = pair; while still refusing it for a discard.
A blank LHS is a discard, never a declaration, so the type goes on the RHS as a cast:
_ = pair // func(string, int) (string, error)
_ = sink // func(int)
_ = lexText // matches the named func type stateFn
_ = c.bump // method value
_ = (Func<@string, nint, (@string, error)>)(pair);
_ = (Action<nint>)(sink);
_ = (stateFn)(lexText);
_ = (Func<nint, nint>)((nint p1) => cʗ1.bump(p1));
A package named func type whose underlying signature matches is preferred over the structural Func<…>/Action<…> render so the reader sees the Go type’s own name; for a discard either is sound, since nothing observes the value. The parentheses around the RHS are load-bearing for the lambda form — (Func<…>)(nint p1) => … does not parse as a cast. A variadic signature takes the golib delegate family (Funcꓸꓸꓸ<@string, any, @string> for fmt.Sprintf), and a func-typed variable is left alone: it already has a C# type.
Routing the named-type case through the declaration branch instead was the pre-fix behavior and is worse than the missing cast it fixed: stateFn _ = lexText; declares a local literally named _, which turns every other discard in the same scope into an assignment to it (CS0841 before it, CS0123/CS0029 after) and collides outright with a second one (CS0128). The blank test therefore short-circuits every declaration arm, not just the var one. (Guarded by the BlankIdentifierCollision extension — all seven RHS shapes plus the func-typed-variable and :=-named-delegate controls, proven failing-first at 11 diagnostics in 7 classes.)
Two unsafe.Pointers compare as BOXES, not as addresses
unsafe.Pointer is the one Go pointer that carries an address rather than being one, and its C# form says so: golib’s Pointer : ж<uintptr>, whose Value is the address. That makes it the one type where the box and its value are both plausible comparands, and Go’s rule picks the box — p == q is pointer identity.
The box is also the only one that works. Pointer overrides ж<T>.Equals to compare PointerOrderToken (IsNull ? 0 : Value.Value), so equality, hashing and ordering are one fact about the address; the base ==/!= operators route through it, and it is nil-safe by construction. Comparing .Value instead bypasses all of that: it is right by accident for two non-nil pointers, and it throws on a nil one, because a nil unsafe.Pointer local is default! — a C# null reference.
k := LoadPointer(&x.i)
if k != p { … } // p ranges over testPointers(), whose first element is nil
if (k != p) { … } // NOT k.Value != p.Value — that NREs on the nil element
The pointer context is therefore suppressed for an equality comparison with unsafe.Pointer on both sides (convBinaryExpr), so convIdent’s x.Value arm — which is correct where an address is genuinely wanted — does not fire. The scope is exact: Go admits no other pairing without a conversion (unsafe.Pointer == *T and == uintptr are type errors), and comparison against untyped nil has its own arm and is unaffected. Corpus footprint is seven runtime sites (alg, map, map_fast32/64, mbarrier, traceback, pprof/map), each a deref-compare collapsing to a box compare, all verified compiling. (Guarded by the ManagedAtomicPointer extension — same-address, distinct-address and nil operands on both sides, a fresh conversion of one address compared against an earlier one, the nil-first table walk, and a selector-versus-ident pairing; the pre-fix converter fails it on both Target and Output, exiting 2 on the nil operand.)
Defer / Panic / Recover
A function that defers or recovers emits its body INLINE, inside a frame
A Go function is a stack frame: it registers defer records in its own frame and runs them on the
way out, whatever the way out is. The converted C# says the same thing with statements — the body is
emitted inline in the method, wrapped in try/catch/finally, beside a GoFrame local that
holds this call’s defer list and nothing else:
internal static void Main() {
GoFrame ᒐ = default;
try {
fmt.Println(openFileˢ);
defer(ᴛ1 => fmt.Println(ᴛ1), closeFileˢ, ref ᒐ);
fmt.Println(writeDataToFileˢ);
}
catch (Exception ᒐex) when (GoFrame.IsPanic(ᒐex, out PanicException? ᒐp)) { GoFrame.Capture(ᒐp); }
finally { ᒐ.Run(); }
}
Each part does one of the three things Go’s runtime does for free:
- the
catchparks a panic — an explicitpanic()or a .NET exception that maps to a Go runtime panic — whererecover()can read it. The filter is the single adoption point that also snapshots the panic’s origin, so a non-panic exception (andGoexitException, deliberately) fails it and propagates unchanged; - the
finallydrains the deferred calls, which is Go’s guarantee that they run on every exit path — normal return, panic,runtime.Goexit, or a mapped runtime fault. They run after the panic has been parked, which is exactly what lets a deferred call recover the panic raised by the body it was registered in; - the frame is the defer list. It is a
ref struct, so it lives in the method’s own stack frame, the JIT can enregister its four inline slots, and the machinery allocates nothing.
GoFrame.Run() is the whole of the ordering contract: LIFO drain, the HandledPanic save/restore
that keeps a traceback honest for the length of the deferred sequence, the re-panic origin
inheritance behind Go’s defer func(){ panic(recover()) }() idiom, and the final re-throw of a panic
no deferred call recovered.
Guards: DeferSimple, DeferCallOrder, DeferClosure, PanicRecover, GoexitDefers,
DeferFrameScopes, and golib’s GoFrameTests (which A/B every scenario against the machinery this
replaced).
Why the body is not a lambda
The obvious alternative models the same three things as an object that owns the body — a
func((defer, recover) => …) execution context supplying a catch, a finally and a Stack<Action>.
Owning the body forces the body to be a delegate; a delegate forces a display class for everything
the body touches; and a display class forces a generic ladder for everything a delegate cannot
capture (a ref local, a Span). The frame form needs none of it, and avoids two things beyond the
machinery:
- a capture-semantics divergence class, by construction. A body-owning lambda closes over variables the Go original never closed over; an inline body closes over nothing at all, and only the deferred closures capture — exactly as Go’s deferred closures do.
-
the ref-parameter ladder. A variadic deferring function would have to thread its
params Spanthrough the wrapper, because a lambda cannot capture one. An inline body has no parameters to thread, so it simply uses the one it has (GenericVariadicFunc,VariadicPointerParam).
Measured with GC.GetAllocatedBytesForCurrentThread over 5,000 Release calls, the frame costs
0 B with no defers, 0 B with one or two whose targets are cached static method groups, and
192 B for the two-capturing-defer shape of internal/poll.FD.Write — the residue being the
display class and delegate of each defer that genuinely closes over something. The body-owning
alternative measures 160 B, 248 B and 440 B for the same three. The full as-built record
of the frame’s design lives in phase4/DESIGN-closure-emission.md.
The named-result form: results outside the try, exits through a label
func f() (r int) { defer func(){ r++ }(); return 1 } returns 2 in Go: the deferred call runs
after the result parameter is assigned and before the caller sees it. A C# finally cannot change a
value the return has already evaluated, so the results are declared before the try and read
back after the finally, and every exit inside the try leaves through a goto — which runs the
finally exactly as a return would, without freezing a result the deferred calls may still change:
internal static (nint @out, @string label) compute(nint x) {
nint @out = default!;
@string label = default!;
GoFrame ᒐ = default;
try {
defer(() => {
@out += 1000;
}, ref ᒐ);
if (x < 0) {
(@out, label) = (-1, negˢ);
goto ᒐdone;
}
(@out, label) = (@double(x), fmt.Sprintf("v=%d"u8, x));
}
catch (Exception ᒐex) when (GoFrame.IsPanic(ᒐex, out PanicException? ᒐp)) { GoFrame.Capture(ᒐp); }
finally { ᒐ.Run(); }
ᒐdone: return (@out, label);
}
The label is emitted only when something jumps to it — a body that simply falls off the end of the
try reaches the return anyway. A heap-box-backed named result keeps the split it already had: the
box is declared outside the try and the value alias re-derived inside it, because the deferred
closures are still lambdas and a lambda cannot capture a ref local.
Guards: NamedReturnDefer, NamedResultDeferCapture, DeferFrameScopes (which exits from inside a
loop, a switch arm and an if).
The catch arm returns Go’s zero results
For a value-returning function whose results are unnamed, the catch arm ends with return default!;.
A panic a deferred call recovered leaves the function returning the zero results, which is Go’s
rule; one no deferred call recovered never reaches that return at all, because Run() re-throws
it from the finally and a throw from a finally overrides a pending return. It is also what keeps
the method’s endpoint unreachable, so nothing is needed after the try statement.
recover() is a static call, not a parameter
recover() resolves to builtin.recover(), which reads the one thread-local slot the emitted
catch parked the panic in (GoFrame.Capture). That it resolves statically is not an
optimization — it is the load-bearing fact of the whole design: a deferred closure can therefore
recover without holding any handle on the frame that registered it, which is what allows the frame to
be a ref struct in the first place.
One consequence: recover is a Go predeclared identifier, not a keyword, so a package may declare
its own — text/template/parse has func (t *Tree) recover(errp *error). Inside that package’s
class the extension method wins over the using static go.builtin import, so such a package emits
its built-in calls qualified as builtin.recover(), exactly like every other shadowed built-in.
defer registration: the arity ladder, and no bang
Go evaluates a deferred call’s ARGUMENTS at the defer statement and runs the call later, so every
argument is captured at registration. defer is an arity ladder of generic rungs (Action and a
result-discarding Func twin per arity, 1 through 16, plus a nullary rung) whose last parameter is
ref GoFrame — the frame to register into:
defer(Ꮡfd.writeUnlock, ref ᒐ); // Go: defer fd.writeUnlock()
defer(ᴛ1 => fmt.Println(ᴛ1), closeFileˢ, ref ᒐ); // Go: defer fmt.Println("Close file")
The rungs are generic rather than params object[] so a value argument is captured without boxing on
a path that runs on function exit throughout the corpus.
The registration reads as plain defer because nothing else in scope claims that name: defer is a
Go keyword, so no Go identifier can ever be spelled that way, and no C# keyword collides. Its
siblings carry a ǃ (U+01C3, a legal C# identifier character where ! is not) for reasons of their
own: goǃ cannot be go, which is the root namespace every converted file sits in, and makeǃ
cannot be make, which is a predeclared Go identifier a package may shadow.
A VARIADIC deferred/spawned func literal is cast to its golib family delegate
A deferred func LITERAL is normally handed to the arity rung directly — defer((nint cnt) => { … },
count, ref ᒐ) — because a non-variadic literal emits explicitly-typed parameters that convert to
Action<T1, …>, from which the rung’s type arguments infer. A variadic literal emits
params ꓸꓸꓸ@string dirsʗp and converts to nothing: it is neither an Action<…> (so defer<T1, T2>
cannot infer — CS0411) nor a method group (so the nullary rung’s Action slot rejects it), and it
cannot even be invoked where it stands (CS0149). C# 13 does give it a natural type, but that is a
compiler-synthesized <>f__AnonymousDelegate<N>, unrelated to anything golib declares.
golib already has the right type — the Actionꓸꓸꓸ/Funcꓸꓸꓸ family whose tail is a
params Span<TArg> — and iifeDelegateType already renders it from a signature, because the
immediately-invoked-literal path needs the same thing. So a variadic literal callee is CAST to its
family delegate and then invoked, exactly the ((<delegate>)(<lambda>))(<args>) shape a non-variadic
IIFE already uses, and the registration takes the temp-parameter form so there is something to invoke:
// Go: defer func(dirs ...string) { for _, dir := range dirs { os.RemoveAll(dir) } }(dir1, dir2)
defer((ᴛ1, ᴛ2) => ((Actionꓸꓸꓸ<@string>)((params ꓸꓸꓸstring dirsʗp) => {
var dirs = dirsʗp.slice();
foreach (var (_, dir) in dirs) {
os.RemoveAll(dir);
}
}))(ᴛ1, ᴛ2), dir1, dir2, ref ᒐ);
Go’s defer-TIME argument evaluation is untouched: dir1/dir2 are still the eager arguments the rung
snapshots, and ᴛ1/ᴛ2 are what the thunk receives back at unwind. A result-returning literal
takes the Funcꓸꓸꓸ half and the rung discards the result, as every value-returning deferred callee
does. The nullary form additionally suppresses the method-group trim: defer f() normally emits
the callee alone (defer(Ꮡfd.writeUnlock, ref ᒐ)), but trimming ((Actionꓸꓸꓸ<nint>)(<literal>))()
back to the cast delegate hands the Action rung a family delegate (CS1503), so the invocation is
kept and wrapped — defer(() => ((Actionꓸꓸꓸ<nint>)(<literal>))(), ref ᒐ). go takes all of the same
arms for the same reasons.
Reach. ONE site in the entire Go 1.23 tree — html/template’s examplefiles_test.go:90 — and it
was that package’s SOLE remaining build wall, standing in front of 243 verdicts. The same cast
also closes the immediately-invoked variadic literal (func(parts ...int) int { … }(1, 2, 3)), which
convCallExpr’s IIFE interception had explicitly excluded on the stale reasoning that “delegate type
would need a params array” — iifeDelegateType has rendered the family form for a variadic signature
all along. (Guarded by DeferLambdaParam, extended from its one non-variadic row to cover the
variadic literal at every arity around the shape: no fixed parameter with one argument, a fixed
parameter ahead of the tail, none at all, a result-returning literal, and the immediately-invoked
form — plus the defer-time snapshot itself, whose arguments are reassigned after the defer and must
not change what the thunk prints. Counter-proven failing-first by neutering each half separately: the
temp-parameter force alone gives CS0411 ×4, the delegate cast alone gives CS0149/CS1503 ×6.)
Two adjacent walls this deliberately does NOT close, both measured here and neither caused by it:
-
A SPREAD argument to any deferred variadic call.
defer f(nums...)emitsnums.ꓸꓸꓸ, aSpan<T>, as the type argument ofdefer<T>— and C# forbids a ref struct there (CS9244). Proven independent: a NAMED variadic callee with no func literal anywhere emits the identicaldefer(ᴛ1 => f(ᴛ1), nums.ꓸꓸꓸ, ref ᒐ)and fails the same way. Closing it means passing the SLICE and spreading inside the thunk, at every variadic deferred call in the corpus. -
An empty variadic call passes an empty slice where Go passes NIL.
f()onfunc f(parts ...int)answersparts == nilastruein Go andfalsehere. Visible from a plain direct call — no defer, no literal — so it is an argument-CONSTRUCTION difference with corpus-wide reach.
A ZERO-ARG deferred/spawned call of a NAMED variadic callee keeps the lambda — the group carries params
The named-callee sibling of the literal cast above, found blocking os/signal’s test-host compile
(defer Reset() on func Reset(sig ...os.Signal), 2026-08-27 — the same variadic-binding family as
the C#14 params-flip fix). The zero-argument arm of visitDeferStmt/visitGoStmt trims f() back
to the method group f so golib’s arity-0 defer/goǃ take it as an Action — valid only when the
callee’s C# arity is genuinely zero. A variadic callee’s C# form always carries the params
parameter, so its method group converts to no Action (defer) and no WaitCallback (go) —
CS1503 at both statements, measured as the failing-first red of the guard below. The with-args
forms were never exposed: getFunctionParamCount answers -1 for a variadic signature, which
already forces the temp-parameter ladder.
The guard is signature-level (types.Signature.Variadic()) in both statements’ zero-arg arms, and it
covers the pointer-receiver box method group too — a variadic method’s box overload carries the
same params parameter (defer c.bump() on func (c *counter) bump(deltas ...int)). The emission
keeps the invocation and wraps it: defer(() => Reset(), ref ᒐ). Wrapping a zero-operand call
disturbs no defer-time evaluation — there are no operands to evaluate. Emission-inert corpus-wide by
construction: an existing zero-arg variadic defer/go site would have been a compile error, and the
corpus compiles. Guarded by DeferVariadicCallee (both statements, both arities, plain func and
pointer-receiver method, output-compared vs go run).
defer f(g()) spreads a MULTI-VALUE call: the tuple is the eager argument, the thunk expands it
Go lets a call whose arguments come entirely from one multi-value call omit the intermediate
variables — f(g()) passes g’s results as f’s parameters, and the language permits it only
when that call is the sole argument. Under defer/go the two halves of the semantics split:
g() is evaluated at the statement, on the current goroutine, and f runs later (at unwind,
or on the new goroutine) with the results g produced back then.
The eager half was already right — argument capture happens in exactly one place, the argument-list
renderer, which hands the eager expression to the registration and substitutes a temp parameter
into the thunk body. What was missing is the expansion: len(Call.Args) is 1 for this shape,
so one ᴛ1 marker went to a callee wanting N parameters.
defer(ᴛ1 => show(ᴛ1), two(), ref ᒐ); // BEFORE — two() is (int, string), show takes both: CS7036
C# has no splat, and it needs none: the tuple g() returns is a perfectly good single eager
argument. The arity-1 rung captures it at exactly Go’s moment and exactly once, and the thunk
spreads its components when the call actually runs:
func two() (int, string) // Go:
defer show(two()) // two() runs at the defer; show runs at unwind
defer(ᴛ1 => show(ᴛ1.Item1, ᴛ1.Item2), two(), ref ᒐ);
Item1…ItemN are System.ValueTuple’s own fields, so NAMED Go results ((n int, s string), which
emit a named C# tuple) address identically — the element names are compiler aliases, never a
replacement. Hoisting the results into statement-time locals instead would also be correct, but it
buys nothing and costs a name: the thunk’s parameters are already ᴛ1…ᴛN, so the hoisted temps
would collide with them in the enclosing scope (CS0136).
Three pieces, each independently red-proven. The component-wise substitution
(convExprList) is the fix proper. The temp-parameter force in visitDeferStmt/visitGoStmt
matters only for a FUNC-LITERAL callee — an ordinary callee already takes that form from the
existing arity test (one argument against N>1 declared parameters), but a literal reaches neither
that test nor the variadic one (CS0411 without it). And a non-variadic func-literal callee is
the one defer/go shape rendered as an INVOCATION rather than handed to the rung as a delegate, so
it additionally needs the immediately-invoked-literal delegate cast that phase 1a declines for
every other defer/go callee (CS0149 without it):
defer(ᴛ1 => ((Action<nint, @string>)((nint n, @string s) => { … }))(ᴛ1.Item1, ᴛ1.Item2), two(), ref ᒐ);
Reach. Zero sites in the production corpus — the idiom is a TEST one, which is where it was
found: Go’s own save-and-restore hook defer reflect.SetArgRegs(reflect.SetArgRegs(a, b, c))
(three results into three parameters, the callee returning values so the thunk binds the
result-discarding Func rung) accounts for four of the reflect test host’s errors, at
abi_test.cs ×3 and all_test.cs ×1. A converted-test emission diff over that host moves exactly
those four lines and nothing else. (Guarded by DeferMultiValueSpread: arity 2 and 3, the
capture-not-re-read case, LIFO ordering, a pointer-receiver method callee, a per-iteration loop, a
func-literal callee, a variadic callee, and the result-returning save/restore shape — plus two
CONTROLS that must keep their existing emission, plain matching-arity arguments and a
SINGLE-value call as the sole argument, both of which stay bare method groups. go mirrors every
arm and is covered by two of its own.)
defer panic(v) captures its value at the defer, and the sequence survives it
panic is the one built-in emitted as a throw statement rather than as a call, and that made it
the one built-in the deferred-argument machinery could not see. Argument capture happens in exactly one
place — the argument-list renderer substitutes a temp parameter (ᴛN) into the thunk body and hands the
eager expression to the registration — and the panic arm returns throw panic(<expr>) before reaching
it. So the thunk inlined the ORIGINAL expression and the registration’s argument slot was left empty:
defer(ᴛ1 => throw panic(errΔ2), , ref ᒐ); // CS0839: Argument missing
The arm now performs the substitution itself, so the value is evaluated at the defer and thrown from
the thunk’s parameter:
err := fmt.Errorf("first")
defer panic(err)
err = fmt.Errorf("second") // Go recovers "first" — arguments evaluate at the defer
defer(ᴛ1 => throw panic(ᴛ1), err, ref ᒐ);
Capturing the expression in the thunk body instead — dropping the parameter — also compiles, and is
wrong for exactly the shape above: it would report whatever the variable held when the frame
unwound. The same rule reaches go panic(v); visitGoStmt now forces the temp-param form for a
built-in callee just as visitDeferStmt does, which also repairs go close(ch) (a built-in’s method
group is generic with in parameters and never converted to Action<T> — CS1503).
Compiling was only half of it. A deferred panic is also the smallest case of a panic raised by a
deferred call, and GoFrame.Run treated that as the end of the sequence: the panic escaped the loop
and the frame’s remaining deferred calls never ran, so the recover() thunk registered before the
panic thunk never saw it. Go continues the sequence — the new panic joins the one unwinding and
becomes what a later recover() answers — so Run now parks the raised panic where recover() reads
it and keeps going, re-raising it at the end only if nothing recovered it. The catch filter is IsPanic,
matching the emitted frame’s own catch, so a runtime fault in a deferred call is recoverable exactly as
one in the body is; GoexitException deliberately fails that filter and still unwinds.
Guarded by DeferPanicArg, which output-compares six shapes against go run: a plain value, an error
variable reassigned after the defer, a computed expression, a pointer value round-tripping the any
boundary and answering a type assertion, a deferred panic replacing one already in flight, and two
deferred panics in one frame (Go keeps the LAST one to run, i.e. the FIRST registered).
The re-raise of an unrecovered panic belongs to the frame that CAUGHT it, not to the thread
GoFrame.Run ends by re-raising a panic no deferred call recovered. The panic itself is parked in a
thread slot (GoFuncRoot.CapturedPanic, where recover() reads it), so the obvious tail — if the
slot is non-empty, throw it — reads correctly and is wrong, because the slot is the THREAD’s and
Run is a FRAME’s. It stays non-empty for the whole of the panicking frame’s deferred sequence, and
every ordinary function that sequence calls runs its own Run from its own finally. Each of those
callees caught nothing; each of them found a panic parked; each of them threw it. The caller’s
deferred cleanup was therefore abandoned at whatever statement happened to follow the first callee
that had a defer of its own — with no diagnostic, because a panic escaping a deferred sequence is
exactly what is supposed to happen next.
Go has no such rule. A panic resumes unwinding when the frame that is panicking has finished its deferred calls; a function called during that sequence returns normally, runs its own defers, and resumes the caller.
So the claim is made explicit rather than inferred. The emitted catch body already calls
GoFrame.Capture, and a catch body and its finally are adjacent — nothing runs between them — so
Capture ARMS a claim that the next Run on the thread CLAIMS, and that next Run is always the
same frame’s. A frame that caught nothing claims null and leaves the in-flight panic alone. The
emission is unchanged: this is entirely inside golib.
public void Run()
{
PanicException? owned = GoFuncRoot.ClaimPanic(); // null unless MY catch just captured
… // deferred calls, LIFO
if (owned is not null && GoFuncRoot.CapturedPanicValue is not null)
throw GoFuncRoot.CapturedPanicValue;
}
Two cases keep the rule from becoming a swallow. A panic raised by this frame’s own deferred call
becomes owned even though nothing was claimed on entry (owned = raised in the sequence’s catch), so
defer func(){ panic(v) }() still escapes a frame that was never panicking; and a callee that panics
while an outer panic is unwinding still replaces it, which is Go’s own behaviour.
Measured on database/sql’s TestConnRaw. Conn.Raw’s deferred cleanup calls release →
closemuRUnlockCondReleaseConn → Conn.close, and close reaches c.dc = nil only after
dc.releaseConn → db.putConn → dc.Close → finalClose → withLock — a two-line helper holding
one defer and panicking nothing, whose Run threw the callback’s panic on the way out. The
connection was left open, and the test’s five-second waitCondition poll (which sizes itself from
t.Deadline()) burned the package’s ENTIRE deadline: 3,418 s against Go’s 0.005 s, which read as a
hang rather than as the assertion failure it was. The package validates at 137 of 139 with the rule
in place, and its suite runs in about three seconds.
Guarded twice, both neuter-verified. PanicDeferCalleeFrame output-compares the shape against
go run — the reduced acquire/cleanup/release chain, three deferring callees stacked below one
deferred call, and the two negatives. GolibTests.GoFrameTests pins it at the frame:
AFrameCalledFromADeferredCallDoesNotReRaiseTheOuterPanic,
AFrameWithNoDefersCalledDuringAPanicDoesNotReRaiseItEither (the m_count == 0 path, which skips
the sequence entirely and reaches the tail directly),
APanicRaisedByADeferredCallStillEscapesAFrameThatCaughtNothing and
APanicRaisedInsideADeferredCleanupReplacesTheOneUnwinding.
Adjacent and still open, deliberately unfixed: the parked panic is one slot per thread, so a
recover() reached during a nested frame’s OWN deferred sequence clears the outer frame’s panic too,
and the outer Run then finds nothing to re-raise. That is a second consequence of the shared slot
rather than of the ownership rule — it predates this change and is unaffected by it — and no measured
consumer asks for it today, so it is recorded here rather than repaired speculatively. Closing it
means giving the slot the same save/restore discipline HandledPanic already has, which also has to
decide what go2cs’s deliberately looser recover() (Go answers nil unless recover is called
directly by a deferred function of the panicking frame) should mean at a nested call.
A nested defer scope gets a frame of its own
A ref struct cannot be captured by a lambda, and it does not need to be: a function literal that
defers carries its own frame inside the lambda (or local function) it emits as.
Every frame reads under the same name, at every nesting depth. A C# lambda or local function may
declare a local spelled like one in the enclosing method — the pre-C# 8 CS0136 rule does not fire —
and the inner declaration precedes every inner use, so an inner GoFrame ᒐ = default; simply shadows
the outer one. The one name that cannot repeat is the named-result exit label: labels do not
shadow (CS0158, “the label shadows another label by the same name in a contained scope”), so
ᒐdone alone carries a nesting-depth suffix. Both facts were settled by compiling the shapes rather
than reasoning about them.
That is also what makes a deferred literal that defers on its own account expressible.
defer func(){ defer cleanup(); … }() scopes the inner defer to the literal in Go; the literal is
a deferred-call target, so it gets no recover scope of its own (its recover() recovers the
enclosing function — the whole point of the idiom), but it does get a frame for its own defers, and
the inner registration lands there rather than in the enclosing function’s. Guarded by
DeferFrameScopes.
An unrecovered panic crashes the process Go-style: report on stderr, exit code 2
throw panic(x) unwinds until some enclosing frame’s deferred sequence recovers it. When nothing
does — including in a goroutine, since goǃ runs the body at the root of its own dedicated thread
with no frame around it and a frame’s exception filter only adopts panic-convertible exceptions — the exception reaches
golib’s AppDomain.UnhandledException backstop (registered in builtin.InitializeGoLib). The backstop
matches Go: it writes the report to stderr (panic: <message>, first mapping runtime-error
exceptions through RuntimeErrorPanic.TryAsPanic so e.g. an integer divide by zero reports Go’s
panic: runtime error: … form) and terminates with exit code 2 — exactly like an unrecovered Go
panic, minus the goroutine stack-trace lines. It previously printed to stdout and called
Environment.Exit(0), which polluted compared output and signaled false success to every caller
(shells, CI, the Phase-4 differential oracle). The behavioral output-comparison harness validates this
differentially: the Go binary is the oracle, so exit codes must match (not be zero), stdout must
match, and the first stderr line must match — the remainder of Go’s panic stderr is a
machine-specific goroutine stack trace, so only the first line is compared. (Guarded by the
GoroutinePanicExitCode behavioral test — a goroutine panics unrecovered while main blocks on a
channel receive; both binaries must exit 2 with panic: goroutine boom as the first stderr line and a
clean stdout.)
A panic VALUE renders through Go’s preprintpanics rule — an error prints its message, not its address
The report above is only as useful as the value in it, and rendering that value is a rule of its own.
Go’s runtime does not print the panic value directly: preprintpanics (runtime/panic.go) SUBSTITUTES
first — an error panic value becomes its Error(), a Stringer its String() — and only then is
the result printed. PanicException rendered state?.ToString(), so a converted panic(err) whose
value is a pointer-held error printed its ADDRESS:
panic: 0x211163e3340 // was
panic: open final.txt: code 13 // Go, and now
That is not a cosmetic divergence: a traceback exists to carry exactly the information the address
destroys, and it cost the row-harvest-2 lane a diagnostic round-trip on the only defect it was
chasing (text/template’s goodFunc rejection, whose message had to be recovered by instrumenting
the callee). The rule now lives in PanicException.PanicText and both readers of a panic value go
through it — the unhandled-exception backstop above, and debug.Stack’s panic line in
runtime/managed_impl.cs, which had its own copy of the old rendering.
The Stringer arm is not redundant with ToString(). A Go named type’s generated ToString()
forwards to its UNDERLYING value (go2cs-gen’s InheritedTypeTemplate), so panic(2 * time.Second)
would print 2000000000 where Go prints 2s. The method is found the way golib’s error<T> finds
Error — through the extension-method registry, which is where a converted Go method lives — and
the receiver shape is re-checked before the call, since the registry’s precedence comparer can hand
back a ж<T>-declared method for a value receiver.
Computed on first READ, not at construction, because that is when Go computes it: preprintpanics
runs only once a panic has gone unrecovered and is about to print. A RECOVERED panic — fmt’s
catchPanic, text/template’s errRecover, every defer func(){ recover() }() in the corpus — must
therefore never call a user Error()/String() at all, which an eager render would do on every
panic in the corpus. recover() still hands back the value itself: the substitution is a PRINTING
rule, not a value rewrite. Go throws a fatal "panic while printing panic value" when the
substitution itself panics; reproducing the FATALITY from a Message getter would be worse than the
divergence it reports, so the text is returned instead. (Guarded by the PanicValueRendering
behavioral test — an unrecovered panic(err) whose first stderr line is compared against go run,
over a stdout half proving the recovered path is unchanged — plus PanicValueTextTests for the arms
one process cannot reach: Stringer, the failure text, and the laziness rule.)
An IMPLICIT divide-by-zero panics with the runtime’s OWN value, so it satisfies runtime.Error
Go’s compiler lowers an integer division to a zero check plus runtime.panicdivide(), which panics
with runtime.divideError — a value whose dynamic type is the unexported runtime.errorString, and
which therefore satisfies the runtime.Error interface. go2cs instead lets the CLR raise
DivideByZeroException and maps it to a Go panic at the recover boundary
(RuntimeErrorPanic.TryAsPanic), so the panic carried only the message TEXT. That is invisible to
code which merely prints the recovered value, but it fails a type assertion — math/bits’
TestDiv32PanicZero asserts exactly that:
} else if e, ok := err.(runtime.Error); !ok || e.Error() != divZeroError {
Div32 divides without an explicit zero guard (unlike Div/Div64, which panic(divideError)
themselves), so its panic came from the hardware trap: ok was false, e was nil, and the test’s
e.Error() then NRE’d.
golib sits UNDER the converted runtime package and so cannot name divideError, so the dependency
is inverted: golib exposes RuntimeErrorPanic.IntegerDivideByZeroValue and the runtime package
registers its own canonical value through a [ModuleInitializer] in the hand-owned
runtime/panicvalues_impl.cs bridge. An implicit (trapped) divide-by-zero then carries the same
value an explicit panicdivide() would — which is precisely Go’s own invariant — and
err.(runtime.Error) resolves through the generated errorString→ΔError adapter. A converted
program that never links runtime keeps the plain-message fallback, which reads and prints
identically and loses only the assertion.
(The fallback path is guarded by the DivideByZeroPanic behavioral test, which prints the recovered
value; the runtime.Error-typed path is guarded by the committed math/bits Go test suite itself —
behavioral tests build against the baseline src/core, which has no runtime package, so the typed
form cannot be exercised there.)
make([]T, len[, cap]) out-of-range panics are RECOVERABLE, with Go’s messages
Go’s makeslice panics recoverably for a negative or over-allocatable length/capacity — the
recovered value’s text is runtime error: makeslice: len out of range (or cap; probed vs
go run — the recovered value is a runtime.errorString). golib’s make path (the
slice<T>(nint length, nint capacity, nint low) constructor) raised
ArgumentOutOfRangeException/OverflowException for the same inputs — .NET exceptions
recover() cannot catch, so a deferred recover never ran and the process died. The constructor
now validates first and throws RuntimeErrorPanic.MakeSliceLenOutOfRange() /
MakeSliceCapOutOfRange() (recoverable PanicExceptions carrying Go’s message text), using
Array.MaxLength as .NET’s maxAlloc equivalent. The same validation class applies to the
hand-owned internal/bytealg.MakeNoZero (bytealg_impl.cs) — Go’s runtime implementation of it
panics len out of range before allocating, and strings/bytes TestRepeatCatchesOverflow
recovers that panic and matches on "out of range" (Phase-4 row R6; strings.Repeat of a
near-maxInt product reaches MakeNoZero after passing Repeat’s own overflow pre-checks).
Like the established golib runtime-panic convention, the panic STATE is the message string, not
an error value — a recovering type switch takes Go’s case error: arm only in Go; both sides
converge on the same err.Error() text through the fmt.Errorf("%s", v) default arm. (Guarded
by the MakeSlicePanicRange behavioral test — in-range, negative, huge-length, and huge-capacity
make under recover(), messages compared vs Go.)
A panicked C# string boxes as Go string at golib’s boxing boundary
Go’s panic takes an any, so the panicked value’s dynamic type is observable on the recover
side — if p != "x", err.(string), and case string: all test it. Two converted spellings hand
golib a bare C# System.String rather than a Go @string: a string literal (panic("x") — the
emission deliberately suppresses the u8 suffix there, so the argument stays a C# literal) and a
computed value from a stub that returns C# string (the baseline fmt.Sprintf does). Boxed as
System.String, such a value matched nothing on the recover side, which compares against @string:
sync’s testOncePanicX reported the self-contradictory want panic x, got x (3 tests).
builtin.panic(object) — the single boxing boundary for the builtin — now normalizes string to
@string. Choosing that layer over an emission-site (@string) cast is deliberate: the cast would
fix only the literal spelling, while the boundary covers literal, computed, and hand-owned callers
alike. It is a narrow normalization, not a coercion — a NAMED string type keeps its own identity
(a [GoType("@string")] wrapper is not a C# string), and non-string values are untouched. golib’s
own RuntimeErrorPanic values are unaffected: those construct PanicException directly, and Go’s
dynamic type for them is a runtime error, not a string. (Guarded by PanicRecover, extended with a
recover-side type switch over a literal, a computed, a variable, a named-string-type, an int, and a
no-panic case, output-compared vs Go.)
A NIL-POINTER dereference is a RECOVERABLE panic, with Go’s message
The same class as the makeslice arm above, for the most common Go runtime panic of all. Go’s nil
dereference is recoverable and real code depends on it — sync’s TestNilPool calls Get/Put on a
var p *Pool and asserts that recover() catches the panic. The converted equivalent (reading
Ꮡp.Value through a nil ж<T>, or any nil reference deref in emitted code) raises .NET’s
NullReferenceException, which recover() could not see: the panic escaped past every deferred
recover and surfaced as an unrecoverable host error. RuntimeErrorPanic.TryAsPanic — the single
predicate every emitted frame’s exception filter (GoFrame.IsPanic) and the process-level unhandled
handler share — now maps it to RuntimeErrorPanic.NilPointerDereference(), whose text is Go’s
verbatim runtime error: invalid memory address or nil pointer dereference.
This is faithful rather than lenient: Go recovers a genuine nil-deref bug exactly the same way, and
an unrecovered one still prints Go’s message on stderr and exits 2 (the unrecovered-panic arm
above). (Guarded by the NilPointerPanic behavioral test — a nil pointer-receiver method call, a
nil struct-pointer field read, and a nil map/slice-of-pointer element deref, each under recover()
with the recovered text compared vs Go, plus an unrecovered control.)
Named-delegate and builtin callees keep the lambda form
A zero-argument deferred/goroutine’d call whose callee is a named func type (defer cancel()
with cancel context.CancelFunc, net dial) cannot take the bare trimmed method-group form —
the named type is a DISTINCT C# delegate with no conversion to the Action golib expects
(CS1503) — so the invocation stays wrapped: defer(() => cancelʗ1(), ref ᒐ) / goǃ(() => f()).
A builtin deferred WITH arguments (defer close(returned)) is generic with in parameters,
so its method group neither infers nor converts to Action<T>; the temp-param lambda keeps
defer’s eager-argument evaluation: defer(ᴛ1 => builtin.close(ᴛ1), returned, ref ᒐ); (net
dial.cs). (Guarded by DeferCallOrder’s stopFn + close(drained) shapes, output-compared vs Go.)
A value-returning goroutine callee is wrapped in a discarding lambda
Go’s go f(…) discards f’s result. Every goǃ runtime overload takes a void Action<…>
delegate, so a value-returning callee passed as a bare method group binds no overload (CS0407 “no
overload matches the delegate” — x/net/nettest conntest.go’s go chunkedCopy(c2, c2), where
chunkedCopy(io.Writer, io.Reader) error returns error). visitGoStmt resolves the callee
signature and, when it returns a value, keeps the invocation inside a lambda so the result is
discarded — an expression-bodied lambda over a value-returning call converts to Action (the same
form the variadic path, e.g. go fmt.Println(…), already emits):
go chunkedCopy(c2, c2) -> goǃ((ᴛ1, ᴛ2) => chunkedCopy(ᴛ1, ᴛ2), c2, c2); // param callee
go q.conn.HandshakeContext(ctx) -> goǃ(ᴛ1 => q.conn.HandshakeContext(ᴛ1), ctx); // selector method
go c.Close() -> goǃ(() => c.Close()); // nullary callee
This parallels the defer case. Both defer and goǃ carry seventeen Func<…, TResult> twins
alongside their Action rungs, so a value-returning method group with arguments binds either
directly; neither has a nullary Func<TResult> rung, so a nullary value-returning callee takes the
() => call() discard on both sides (see Deferred calls whose callee returns a value below). The
converter’s remaining discarding wraps cover the shapes a method group cannot express here — a
value-returning callee reached through a selector or parameter, and the CS1113
value-receiver-extension case below, whose reason is delegate creation rather than the callee’s
result. Func-literal callees and void-returning method groups are untouched (goǃ(() => { … }),
goǃ(emit, out)).
The runtime’s Func twins are what make one shape expressible at all: a func-literal callee that
returns a value. go func(ln Listener) (retErr error) { … }(ln) (net sendfile_test) emits its
literal with an explicit error return type, because a named result set by a defer needs one, and
no Action<Listener> overload accepts it (CS8934). Wrapping it in the converter would mean
suppressing the literal’s own return type and rewriting its trailing return retErr; — rewriting a
correct emission to fit a runtime gap. The Func siblings fix that shape, and every other one, at
the seam where Go’s rule actually lives: go f(…) discards results, for any f. A void lambda
or method group cannot bind Func<TResult> at all, so no existing call site is affected. (Guarded by
the GoStmtValueReturn behavioral test — value-returning nullary, single-, multi-param, multi-result
and func-literal goroutine callees, output-compared vs Go.)
A VALUE-receiver method callee forces the same lambda forms even when void and
arity-matching: every Go named type emits a C# struct and the method an extension on it, and C#
forbids constructing a delegate from an extension method over a value-type receiver (CS1113 —
net/http/httputil’s go spc.copyToBackend(errc), switchProtocolCopier). So
goǃ(spcʗ1.copyToBackend, errc) becomes goǃ(ᴛ1 => spcʗ1.copyToBackend(ᴛ1), errc) and a nullary
go vs.ping() keeps its invocation (goǃ(() => vsʗ1.ping())). The receiver snapshot (spcʗ1)
still evaluates at go-statement time. An INTERFACE-receiver method group is excluded (a genuine C#
instance method binds delegates fine), and pointer receivers keep the box-group machinery
(pointerReceiverBoxMethodGroup — ж<T> is a class, so its group is delegate-legal). (Guarded by
GoStmtReceiverLambda’s valueSender arms — value-receiver argument and nullary go-statements
with blocking-receive completion proof, output-compared vs Go.)
A func-literal ARGUMENT of a deferred call hoists its captures before the call
When a deferred call’s callee is itself a func literal (defer func() { … }()), that literal’s
lambda-capture snapshots (var sʗ1 = s;) are threaded to a builder emitted before the defer(…)
call. But when the deferred callee is an ordinary call whose argument is a capturing func literal —
x/net/nettest conntest.go’s defer once.Do(func() { stop() }) — the argument literal’s snapshot
declarations were dumped inline into the deferred call’s argument list, an invalid statement
mid-expression (defer(Ꮡonce.Do, var stopʗ1 = stop; () => …) → CS1001/CS1002/CS1003/CS1026).
The hoist sink (lambdaContext.deferredDecls) is now provided unconditionally in visitDeferStmt,
not only for the func-literal-callee case, so convFuncLit (reached via convCallExpr → convExprList →
the argument’s LambdaContext) routes any argument literal’s captures to it, and they are emitted before
the call — the guard’s defer run(func(){ pf.x = 77 }) shape:
var pfʗ1 = pf;
defer(run, () => {
pfʗ1.Value.x = 77;
}, ref ᒐ);
The empty builder is inert for a deferred call with no capturing func-literal argument (zero golden
churn — the behavioral corpus is byte-identical), and a deferred call whose own arguments are plain
captures keeps its existing pre-call generateCaptureDeclarations() emission. (Guarded by the
FuncLitArgCapture extension case 14 — a func literal passed as the argument of a deferred run(…)
call capturing a local pointer, whose deferred write lands through the shared pointer box, output-compared
vs Go.)
Defer/go EAGER arguments follow the enclosing closure’s capture renames
Go evaluates a deferred (or spawned) call’s function value and arguments at statement time, in the
enclosing scope. The defer/go emission enters its own lambda-conversion state (its callee snapshots
need a fresh remap set), but that fresh state previously hid the ENCLOSING lambda’s capture renames
while the eager arguments rendered: inside an IIFE that snapshot-captured a heap-boxed outer local
(var baseʗ1 = @base;), the argument of defer func(t Tally) { … }(base) emitted the raw ref-local
@base — uncapturable in the IIFE’s C# lambda (CS8175) — with the snapshot left declared but unused;
where the raw name IS capturable (a reference-typed local — net/http transport.go’s
defer close(didReadResponse) inside a go func() { … } lambda), it silently bypassed the snapshot
every other read in that body uses. visitDeferStmt/visitGoStmt now enter through a seeded
variant (enterDeferGoLambdaConversion) that copies the enclosing lambda’s renames into the fresh
state, so the arguments render exactly like any other expression in the enclosing body:
var baseʗ1 = @base;
((Action)(() => {
GoFrame ᒐ = default;
try {
defer((Tally t) => {
report(deferredˢ, t, 4);
}, baseʗ1, ref ᒐ);
}
catch (Exception ᒐex) when (GoFrame.IsPanic(ᒐex, out PanicException? ᒐp)) { GoFrame.Capture(ᒐp); }
finally { ᒐ.Run(); }
}))();
prepareStmtCaptures still OVERRIDES the statement’s own captured-callee entries afterward (their
defer-time snapshots), and a function-level defer/go — no enclosing lambda — is untouched (the seed
set is empty). Whole-stdlib footprint: exactly one file, net/http transport.cs, where the deferred
close argument becomes the goroutine lambda’s didReadResponseʗ1 (semantically neutral there — both
names alias one channel object; the fix matters for ref-local-boxed value locals). (Guarded by the
DeferArgEnclosingCapture behavioral test — a heap-boxed struct local passed eagerly to a deferred
func literal, a deferred NAMED callee, and a go-statement literal, each inside an IIFE, with the
mutations landing on the deferred copies and the source read back untouched, output-compared vs Go.)
A func-literal ARGUMENT inside an if/for condition hoists its captures before the statement
The same capture-snapshot hazard occurs when a capturing func literal is passed as a call argument
inside a condition. go/types is dense with this shape — underIs(t, func(u Type) bool { … }),
typeSet().is(func(t *term) bool { … }) — and the literal’s snapshot declarations (var suʗ1 = su;)
are statements, invalid inside the condition expression. visitExprStmt / visitAssignStmt already
route such decls to a pre-statement hoist buffer (v.hoistedDecls), but visitIfStmt and
visitForStmt converted the condition with convExpr(cond, nil) and no hoist target, so the decls
were dumped inline into the condition (if (tpar.underIs( var suʗ1 = su; (ΔType u) => { … })) →
CS1003/CS1026/CS1002/CS1022/CS1513, ~63 errors across go/types alone). Both statement emitters now
convert the condition into a hoist buffer and write any collected decls on their own lines before
the if/for, mirroring visitExprStmt:
ΔType su = default!;
var suʗ1 = su;
if (tpar.underIs((ΔType u) => {
…
if (suʗ1 != default!) { u = match(suʗ1, u); … }
})) { … }
The condition is converted after an if/for init clause (preserving capture-counter ordering),
and the if-with-init sub-block hoists between the init and the if. The traditional for reuses the
existing ForVarInitMarker slot — the hoisted condition decls are emitted at the same pre-for position
as the for-init heap allocations. The hoist buffer is empty for a condition with no capturing func-literal
argument, so the behavioral corpus is byte-identical; the only stdlib deltas are five go/types files
(under.cs, builtins.cs, expr.cs, index.cs, instantiate.cs) and one crypto/tls
slices.ContainsFunc call. This clears the syntax-error layer in those files (go/types had ~63
CS100x/CS1026 from this one construct); it does not by itself green go/types, which compiles far
enough afterward to surface a deeper layer of latent semantic defects (a map[token.Token]func()
mis-lowered to a malformed explicit-interface IDictionary/ICollection implementation, named-slice
wrappers not satisfying IArray.Source, token resolution) — the frontier moves from syntax to
semantics, “progress, not regression.” (Guarded by FuncLitCaptureInCondition — a func literal
capturing an enclosing map, passed as an argument inside a plain if condition, an if condition with
an init clause, a traditional for condition, and a while-style for condition, all output-compared vs
Go.)
A func-literal ARGUMENT inside a return expression hoists its captures before the return
The third statement position with the same hazard: a capturing func literal passed as a call argument
inside a return expression — net/http’s findHandler returns
HandlerFunc(func(w ResponseWriter, r *Request) { … allowedMethods … }), "", nil, nil, and traceviewer’s
MainHandler returns http.HandlerFunc(func(){ … views … }). A direct func-literal result threads
lambdaContext.deferredDecls (the go/defer/return channel in convFuncLit), but a literal nested as a
call argument falls back to the pre-statement hoist sink, which visitReturnStmt never provided —
the snapshot declaration was dumped inline inside the return expression (10 syntax errors in server.cs,
a 4-error cascade in traceviewer). visitReturnStmt now provides the same hoist buffer as
visitExprStmt/visitIfStmt/visitForStmt and splices it before the return through its existing
DeferredDeclsMarker slot (ahead of any deferred tuple-deconstruction temps):
var allowedʗ1 = allowed;
return (wrap((@string msg) => {
fmt.Println(allowedʗ1[0] + ":" + msg, len(allowedʗ1));
}), "label", default!);
The buffer is empty for a return with no capturing-literal argument, so the behavioral corpus is
byte-identical. (Guarded by ReturnTupleFuncLitArg — a slice-capturing literal as a call argument inside
a three-result return tuple, and a map-capturing one inside a single-result return, output-compared vs
Go.)
A func literal inside a RANGE expression hoists its captures before the loop
The fourth statement position, and the one the table-driven test idiom lands on constantly:
for _, test := range []struct {
desc string
f func()
}{
{desc: "WithCancel(bg)", f: func() { c, cancel := WithCancel(bg); cancel(); <-c.Done() }},
…
} {
The literal’s snapshot declaration (var bgʗ1 = bg;) is a statement, and the composite-literal
element position it would be written into is pure expression context. visitRangeStmt converted the
range expression with convExpr(rangeStmt.X, nil) — no hoist target — so the decl was dumped inline
after the f: argument name, and the whole file died in a syntax cascade (context’s x_test.cs:
CS1003/CS1026/CS1002/CS1513/CS0106 ×195, from TestAllocs and TestCause alone).
visitRangeStmt now converts the range expression into a hoist buffer and splices the collected
decls in at the statement’s own start — it records that offset before conversion and inserts there
afterwards (spliceOutput, the positional twin of replaceMarker), because the foreach header is
emitted much further down through a dozen different arms:
var bgʗ1 = bg;
foreach (var (_, test) in new TestAllocs_type[]{
new(desc: "WithCancel(bg)"u8, f: () => { var (c, cancel) = WithCancel(bgʗ1); … }),
…
}.slice()) {
The buffer is empty for a range expression with no capturing func literal, so the behavioral corpus is
byte-identical. (Guarded by RangeExprFuncLitCapture — slice- and map-capturing literals as struct
fields of a ranged composite literal, plus a bare []func(string) element list, output-compared vs Go;
its A/B reproduces the cascade exactly.)
The class, stated once: visitExprStmt, visitAssignStmt, visitIfStmt, visitForStmt,
visitReturnStmt, visitValueSpec and now visitRangeStmt each provide the pre-statement sink. The
statement kinds that still do not — a switch tag, a select comm-clause, a bare send — have no
demonstrated corpus site, and each would repeat this failure exactly. They are deliberately not widened
speculatively: the tell that one has been reached is a syntax cascade whose first error sits on the line
after a <name>: argument label.
The enclosing statement’s hoist buffer does NOT extend into a literal’s BODY
Those four positions all work the same way: the enclosing statement opens a hoist buffer, and a capturing func literal inside it writes its snapshot declarations there. The buffer is a valid position for that literal’s own captures — they name bindings from the enclosing scope, which exists before the statement. It is not a valid position for anything the literal’s body hoists: a statement inside the body opens its own buffer, and a nested literal whose captures name a binding declared inside this body would be declared outside it.
time’s BenchmarkStaggeredTickerLatency nests three levels of b.Run(…, func(b *testing.B){…}). The
middle literal makes a stats slice; the innermost go func(…) captures it. The snapshots landed in
the OUTER literal’s b.Run(…) statement buffer — two blocks above the declaration:
for (nint tickersPerP = 1; …; tickersPerP++) {
nint tickerCount = gmp * tickersPerP;
var statsʗ1 = stats; // CS0103 — `stats` is declared below, inside bΔ2
bΔ1.Run(…, (ж<Δtesting.B> bΔ2) => {
var stats = new slice<…>(tickerCount);
convFuncLit now detaches v.hoistedDecls for the duration of the body walk and restores it after, so
a nested hoist can only reach a position inside the body. The literal’s own captures are unaffected —
they are flushed before the body is converted, while the enclosing buffer is still installed. (Guarded
by FuncLitArgCapture case 15.)
Handling Go defer / panic / recover is what the FRAME above is for: the body is emitted inline in try/catch/finally beside a GoFrame local that holds this call’s defer list. panic is the global panic built-in and recover the global recover (both a using static go.builtin). A function that neither directly nor indirectly (through a deferred lambda) uses defer/recover gets no frame at all – the scope is per function, so a main that merely calls f() is emitted as a plain method body.
-
Named results + defer. See The named-result form above: the results are declared before the
tryand read back after thefinally, and every exit inside leaves through agoto. -
IIFEs. An immediately-invoked function literal that itself uses defer/recover carries its own frame inside its own delegate-cast invocation (
((Action)(() => { GoFrame ... }))()), so its defers are its own – while itsrecover(), being a static call, still reads the one panic slot. -
A
returnemits against ITS OWN function’s results, not the enclosing function’s. A barereturnin a function with named results emitsreturn (n, ok);(the named results). A nested function literal must be converted against its own signature – otherwise a barereturninside a void closure would inherit the enclosing function’s named results and emitreturn (n, ok);into avoidlambda (CS8030, “anonymous function converted to a void-returning delegate cannot return a value”). Runtimemprof.goroutineProfileWithLabelsSync(named(n, ok)) passesforEachGRace(func(gp1 *g) { ...; return; ... })– the void closure’s bare returns must stayreturn;. The return signature is tracked separately fromcurrentFuncSignature(which stays the enclosing function’s, so the receiver/parameter detection still resolves a captured pointer parameter – an outer parameter – correctly):convFuncLitsets a dedicated return-signature to the literal’s own signature with save/restore, andvisitReturnStmtemits results against it. (Guarded by theClosureBareReturnNamedResultsbehavioral test – a void closure with bare returns nested in a named-results function, output verified vs Go; cleared runtime’s 4 CS8030.) -
Return-type INFERENCE is not a concern. An inline body returns against the METHOD’s own declared result type, so no inference runs over the return statements and the two shapes that would defeat one cannot arise: every return carrying an untyped
default!(Gonil– syscall’sgetProcessEntry), and returns of two unrelated concrete types sharing only the declared interface (go/parser’sparseTypeNamereturning&ast.SelectorExpr{...}beside a plain*ast.Ident). Both are pinned as guards:DeferTypelessReturns(unnamed results, a defer, and every return carrying nil) andDeferInterfaceReturn(a defer/recover func returningShapeviaCirclevsSquare, plus a heterogeneous(Shape, bool)tuple return).
A BLANK result mixed with a named one still needs the named-return-defer handling
Go permits mixing the blank identifier and real names in one result list — func parse(s string, flags Flags) (_ *Regexp, err error) (regexp/syntax) — and deferred code can still mutate err. The detection required every result to be named and non-blank, so the first _ rejected the whole signature and the function fell back to the unnamed-result form, whose catch arm returns Go’s zero results. On a recovered panic that arm returned default!: the deferred handler assigned err, the catch arm discarded it, and the function reported (nil, nil) — a successful parse of an expression that must fail. Every “expression too large” / “nesting depth exceeded” input (a{100000}, strings.Repeat("(", 1000)+…) came back as a valid parse, and the caller’s dump(re) on the nil pointer then panicked.
A blank result is a real result slot — only a return statement can write it, the body cannot name it — so it needs a declaration alongside the named ones. C#’s _ is the discard: declaring it would capture every later _ = expr in scope, and two blank results would collide outright, so namedResultName mints a generated slot name (interned per result object, so the declaration, each return’s assignment and the post-defer read all agree):
internal static (ж<Regexp>, error err) parse(@string s, Flags flags) {
ж<Regexp> _ᴛ1 = default!; // the BLANK slot
error err = default!;
GoFrame ᒐ = default;
try {
defer(() => { … err = new ΔErrorжerror(…); … }, ref ᒐ); // recover assigns the named result
…
(_ᴛ1, err) = (literalRegexp(s, flags), default!); goto ᒐdone; // `return literalRegexp(s, flags), nil`
}
catch (Exception ᒐex) when (GoFrame.IsPanic(ᒐex, out PanicException? ᒐp)) { GoFrame.Capture(ᒐp); }
finally { ᒐ.Run(); }
ᒐdone: return (_ᴛ1, err); // reads BOTH slots after the defers ran
}
A result list that is entirely unnamed (func f() (int, error)) or entirely blank keeps the plain form: there is nothing deferred code could mutate, and Go likewise returns the zero results after a recover. Go forbids mixing named and unnamed results, so seeing one truly unnamed result settles the whole signature. (Guarded by the NamedReturnDefer extension — a (_ *box, err error) function whose recover sets err; the pre-fix converter compiles it and returns (nil, nil).)
Function-literal named results
A func literal with named results declares them at the top of its emitted block, zero-initialized — Go’s semantics for next = func() (v1 V, ok1 bool) { …; return } (the iter.Pull shape): a bare return yields the named results as currently assigned, so the lambda emits () => { V v1 = default!; bool ok1 = default!; …; return (v1, ok1); }. Without the declarations the emitted tuple referenced undeclared names (CS0103 — the iter package’s last wave-1 errors). Two interactions: a named-results literal whose first statement is a bare return must NOT collapse to an expression-bodied lambda (the names exist only as block declarations), and the namedReturnDefer path (named results that deferred code mutates) keeps its own arrangement — declarations before the try, returned after the finally. Declarations reuse the shadow-aware naming, so a literal result shadowing an outer local renames consistently in both the declaration and the return (nΔ1). (Guarded by the FuncLitArgCapture extension — bare returns with assigned and zero named results, plus the first-statement-bare-return shape, values vs Go.)
Because a named result lives in the literal’s OWN scope, a reference to it in the body is the result, never an outer-scope capture — so named results are excluded from the lambda-capture set (convFuncLit) exactly as parameters are. text/template’s readFileFS returns func(file string) (name string, b []byte, err error), whose closure captures the enclosing fsys AND writes b via the captured tuple call b, err = fs.ReadFile(fsys, file). Because the closure genuinely captures fsys, the capture analysis ran and mis-flagged b too — hoisting var bʗ1 = b; into the enclosing function, where b does not exist (CS0103), and renaming the body’s b to the captured bʗ1. Filtering the named-result names out of the capture set (alongside the parameter names) leaves b a plain in-block declaration. (Guarded by CrossPkgUser’s makeScanner — a captured closure returning named results, one written via a tuple call whose RHS uses the capture, output-compared vs Go; crypto/x509 and html/template shared the same latent shape.)
Deferred calls whose callee returns a value take the lambda form
The no-arg defer arm passes a bare method group (defer(k.Close, ref ᒐ)) only when the callee returns VOID – an error-returning method (defer k.Close(), registry Key.Close) is a Func<error> method group that cannot bind the golib defer(Action, ref GoFrame) (CS1503). The lambda form discards the result, exactly Go’s deferred-call semantics:
defer(() => hʗ1.close(), ref ᒐ);
Guarded by DeferTypelessReturns.
Deferred pointer-receiver nullary calls bind the box method group
defer conf.releaseSema() with conf *resolverConfig (net nss.go / dnsclient_unix.go) trimmed to the deref-alias method group Ꮡconf.Value.releaseSema — a struct VALUE against the [GoRecv] ref extension, which cannot create a delegate (CS1113). The emission binds the BOX method group instead:
defer(Ꮡconf.releaseSema, ref ᒐ);
The ж<T> overload is class-typed and delegate-legal, and the method-group conversion captures the receiver when the delegate is created — exactly Go’s binding time. Mirrored in the go-statement arm. Gated to methods declared DIRECTLY on the pointee — a PROMOTED method (net interface.go’s defer zc.Unlock(), declared on the embedded sync.RWMutex) has no extension on the outer box (CS1061) and keeps the lambda emission — and to void results (a Func<> group binds neither defer(Action) nor go(Action)). (Guarded by DeferCallOrder’s acquireAndWork, output-compared.)
The same box-method-group emission also covers a value receiver whose type is exactly the pointer-receiver’s pointee — defer b.deck.reset() (runtime/pprof; also database/sql, log/slog), where deck pcDeck is a value FIELD reached through a nested selector and reset has a *pcDeck receiver, so Go auto-takes &b.deck. The original arm required the receiver be an already-pointer ident; the value case renders &receiver through the shared address machinery (the same &ast.UnaryExpr{AND} → convUnaryExpr synthesis used elsewhere) — a boxed base gives the aliasing field-ref Ꮡb.of(profileBuilder.Ꮡdeck), an escaping value local gives its box Ꮡx, a plain value gives the Ꮡ(value) copy — then binds the method: defer(Ꮡb.of(profileBuilder.Ꮡdeck).reset, ref ᒐ), the ж
A deferred pointer-receiver method on an escaping value local captures by-box, not by-copy
The emission above binds the box (Ꮡstate.free) for a defer state.free() on a value local — but the CAPTURE analysis must cooperate. defer/go/closure bodies are lambda-conversion scopes: a variable used inside them that escapes to the heap is normally snapshot-copied into a var stateʗ1 = state; declaration so the C# closure captures a value, not an uncapturable ref-local. For an escaping value local used only as the receiver of a pointer-receiver method call (state a handleState value, free a *handleState method — log/slog handler.go’s defer state.free()), that snapshot is doubly wrong: the address-taking is implicit (Go auto-takes &state), so the emission still binds the box — but of the snapshot name Ꮡstateʗ1, which is a plain value with no Ꮡ companion:
ref var state = ref heap<handleState>(out var Ꮡstate);
state = h.ch.newHandleState(buf, true, " "u8);
var stateʗ1 = state; // snapshot copy — WRONG
defer(Ꮡstateʗ1.free, ref ᒐ); // Ꮡstateʗ1 never declared → CS0103
The capture analysis now recognizes this implicit address-of (a value receiver of a pointer-receiver method, matching the pointee exactly and NAMED — the same guard the emission uses) as a reason to treat the local as a box-ref var, exactly like an explicit &state: it skips the snapshot, and the emission binds the original heap box:
ref var state = ref heap<handleState>(out var Ꮡstate);
state = h.ch.newHandleState(buf, true, " "u8);
defer(Ꮡstate.free, ref ᒐ); // binds the live variable's box
This is not merely a compile fix — a value snapshot is taken at defer time, so it would miss any mutation the body makes to state before the deferred call runs; binding Ꮡstate matches Go’s semantics of deferring against the live variable. Gated to an escaping local (a non-escaping one has no Ꮡ box and keeps the compiling Ꮡ(copy) form) used as a value receiver whose type is exactly the method’s pointer-receiver pointee (an already-pointer receiver’s box group is the pointer variable itself, whose snapshot name IS declared, so it is excluded). The same generalization silently corrects the closure form (func(){ x.mutate() } on an escaping value local previously mutated a lost copy — go/types conversions.go/typeset.go) and removes now-dead var xʗ1 = x; snapshots wherever the box was already used. (Guarded by the DeferHeapLocalPtrMethod behavioral test — a value local deferring a pointer-receiver method, mutated after the defer, with the deferred method observing the final value, output-compared vs Go.)
The same box-ref treatment covers a promoted pointer-receiver method reached through value embeds — lazyCert.Do(…) on var lazyCert struct { sync.Once; v *Certificate } (crypto/x509 AppendCertsFromPEM): Go takes &lazyCert.Once, an address into the variable’s own storage, so the closure must share the original variable. The detection resolves the call through info.Selections and walks the selection’s embedded-field index path — only value embeds along the path root the address at the variable (a pointer embed re-roots it at that pointer’s target, where the snapshot, which copies the pointer, stays sound). Emission then renders the promoted call through the box’s field projection and field uses through the box read; the snapshot form had referenced a never-declared snapshot box (ᏑlazyCertʗ1, CS0103) and divorced the closure’s writes from the original:
ᏑlazyCert.of(AppendCertsFromPEM_lazyCert.ᏑOnce).Do(() => {
(ᏑlazyCert.Value.v, _) = ParseCertificate(certBytesʗ2);
…
});
return (ᏑlazyCert.Value.v, default!);
A variable marked box-ref is also never snapshot-copied by a nested literal — the box is a plain reference local that closures at any nesting depth capture directly, so the per-layer var lazyCertʗ2 = lazyCertʗ1; chains disappear with it. (Guarded by the ClosureEmbeddedPromotedPtrMethod behavioral test — an anonymous-struct local with a value embed whose pointer-receiver method is called from sibling and nested closures interleaved with field writes, cumulative counts observed vs Go.)
The same box-ref treatment covers a pointer-receiver method on a value-struct FIELD projection of the escaping local — defer p.fake.setLines() (go/internal/gcimporter iimport.go/ureader.go), where p is an escaping iimporter value local and setLines a *fakeFileSet method on the value field p.fake: Go takes &p.fake, an address INTO p’s own storage, and the emission renders it through the box’s field view — but the snapshot path renamed the base first, referencing a never-declared snapshot box:
var pʗ1 = p; // snapshot copy — WRONG
defer(Ꮡpʗ1.of(iimporter.Ꮡfake).setLines, ref ᒐ); // Ꮡpʗ1 never declared → CS0103
The capture analysis now matches the single field-projection receiver (a FIELD selected on the var’s own value-struct storage — the same &m.field form lambdaBoxRefAddressForm emits — whose type is exactly the method’s pointer-receiver pointee and NAMED) as the same implicit address-of, marks the local box-ref, and the defer binds the live box’s field view:
defer(Ꮡp.of(iimporter.Ꮡfake).setLines, ref ᒐ);
As with the direct case this is a write-visibility fix, not merely a compile fix: gcimporter registers the defer before importing (which populates p.fake.files), so a snapshot would flush an empty file set. The same generalization corrects deferred closures that read such a variable — go/parser’s defer func(){ …; err = p.errors.Err() }() snapshot-copied p at defer time, so the closure read the parser state from before parsing (errors always empty); box-ref renders those reads Ꮡp.Value.errors… against the live variable. A deeper chain (p.a.b.m()), a pointer field hop, or a method promoted through the field’s own embeds keeps the existing snapshot handling. (Guarded by the DeferHeapFieldPtrMethod behavioral test — a heap-boxed value local deferring a pointer-receiver method on its value field, with lines appended after the defer observed by the deferred flush, output-compared vs Go.)
Expression Switch Statements
Go expression-based switch statements are flexible: cases do not fall through automatically (no break needed), and the fallthrough keyword runs the next case body bypassing its expression. Based on the Manual Tour of Go Conversions, converting to if / else if / else is the best choice for most cases. When every case label is a C# compile-time constant and there is no fallthrough, a traditional C# switch works. “Constant” here means a C# const — a literal, a computed literal expression (a + b), or a typed basic-type const — not merely a Go constant. A case label that references a plain variable, a struct field (case frame.fp), an untyped / named-type / cross-package const emitted as static readonly (case goarch.PtrSize), or an address-of expression (case &g) is not a C# constant, so a C# switch case label there is invalid (CS9135 / CS0150). Such switches fall back to the if / else if form comparing the tag with == (a temp captures the tag: var exprᴛ1 = tag; if (exprᴛ1 == frame.fp) …). The same constant-vs-runtime-value test also chooses is (constant pattern) vs == for a single-value case within the if-else form. A Go break inside a case exits the switch (skipping the rest of the case); in the if / else if form there is no enclosing C# switch for it to target (CS0139), so a case body that contains such a break is wrapped in a do { … } while (false) — the break exits that one-shot loop, i.e. the case. The wrap is emitted only for a case whose body actually has a switch-targeting break (one not caught by a nested loop/switch/select), so every other case is unchanged. (A break inside a nested loop within the case still targets that loop, as in Go.) For cases that use fallthrough, the cases are expanded to standalone if statements with a local fall-through flag and goto to handle break-style exits — the most complex (and least pretty) scenario. In that if-chain form a trailing default: reached via fallthrough is emitted as a guarded if (fallthrough || !match) { … } — the guard is needed so the default does not run after a matched-but-non-fallthrough case, but C# cannot prove it always executes. So when such a guarded-default switch is the last statement of a value-returning function and every case is terminal, C# reports CS0161 (“not all code paths return a value”) even though the Go default makes the switch exhaustive (runtime startpanic_m). Because a guarded-terminal-default switch cannot be legally followed by reachable Go code (it always returns/exits), the converter emits an unreachable return default!; after the if-chain to satisfy C#’s definite-return analysis — gated on the enclosing function/literal actually returning a value (via its own return signature), so a void function or a switch that isn’t terminal is unaffected. (Guarded by the SwitchFallthroughDefaultReturn behavioral test; cleared runtime’s CS0161.) A comparison case may use a C# relational/constant pattern (case {} when x is < 0) only when the compared-to operand is a C# compile-time constant; for a variable (case x == y) or a static readonly const (untyped/cross-package), it falls back to a when guard (case {} when x == y) — a relational pattern there is invalid (CS9135).
A switch on a static readonly constant tag lowers to if-else
A switch TAG that is itself a constant emitted as static readonly – an untyped const’s UntypedInt wrapper (switch goarch.PtrSize, reflect abi.go) or a uintptr-struct const – cannot govern a C# switch: the int case labels are not constants OF the wrapper struct type, and the is constant-pattern lowering fails the same way (CS9135). The recorded tag type is no help (go/types records the untyped constant’s DEFAULT type in tag position), so the gate is on the object resolution: a constant-valued tag that is not a true C# const forces the if-else form (wrapper == operators) and disables the is pattern:
var exprᴛ1 = CrossPkgLib.Precision;
if (exprᴛ1 == 1) {
A variable tag stays switchable. Guarded by CrossPkgUser / CrossPkgLib.
A leading constant-true case stays opaque to the compiler
Go’s switch { case true: ... case cond: ... } (time parseStrictRFC3339 deliberately disabling its strict checks) compiles the LATER cases as dead code; a foldable when true makes C# reject them outright (CS8120). A constant-true case condition on a NON-LAST clause therefore emits the golib ᐧᐧ marker – a static readonly bool the compiler cannot fold:
case {} when ᐧᐧ: {
The marker is deliberately SEPARATE from the const ᐧ switch governor: that const’s foldability is itself load-bearing (case ᐧ when ... label patterns need a constant, and an infinite for (...; ᐧ ;...) relies on the fold for reachability proofs – CS9135/CS0161 when it was made readonly in place). Guarded by ExprSwitch.
No constant pattern against a named-numeric wrapper
A constant expression whose CONTEXTUAL type is a wrapper struct – golib uintptr or any [GoType("num:...")] named numeric (time’s Duration) – can never be a C# constant, so no constant/relational pattern can compare against it: d is >= 0 types the literal 0 as Duration (CS9135). The lowering keeps the plain operator form (d >= 0, the wrapper’s operators). Guarded by ExprSwitch (the pace switch).
An index-expression case label falls back to equality
A case label that INDEXES a package-level array/slice variable (case Typ[UntypedNil]: — go/types
operand.go, where Typ is the universe *Basic array) is a runtime value, never a C# constant. The
single-value is form is doubly broken there: C# parses exprᴛ1 is Typ[UntypedNil] in pattern position
as an array TYPE (CS0246 + CS0270). canUsePatternMatch rejects an *ast.IndexExpr label the same way
it rejects a non-constant identifier/selector, so the clause takes the ==/AreEqual comparison the
multi-value arm already produced:
if (AreEqual(exprᴛ1, Typ[UntypedNil])) { // NOT `exprᴛ1 is Typ[UntypedNil]`
(Guarded by the IndexExprCaseLabel behavioral test — single- and multi-label clauses indexing a
package-level array var, output-compared vs Go.)
Literal case labels under a named-type tag compare through a cast
A tagged switch whose tag type is a NAMED (non-interface) type — net/http’s
func (code socksReply) String() switching on code — renders the tag as a [GoType] wrapper
struct. An untyped-LITERAL label adopts the tag’s named type in Go (go/types records it on the label
expression), but its C# render is a bare literal of the UNDERLYING type, which can neither be a
constant pattern (exprᴛ1 is 0x01 — CS9135, constant pattern against the wrapper) nor compare bare
(exprᴛ1 == 0x01 is ambiguous between the wrapper’s == and the underlying’s built-in ==, both
reachable through the wrapper’s two-way implicit operators — the same ambiguity family as the
named-string consts). Two converter pieces:
- the pattern-match decision excludes any named-wrapper tag (
tagIsNamedWrapper, beside the existingnamedTypes/tagIsStaticReadonlyConstgates — those could not catch the mixed const-ident + literal switch, because the per-label screening short-circuits onceallConstgoes false and never reaches its named-type check); - a CONSTANT label that is not an ident/selector/conversion-call (those already render AT the wrapper type) casts to the tag type:
var exprᴛ1 = code;
if (exprᴛ1 == socksStatusSucceeded) { // named-const label — no cast
return "succeeded"u8;
}
if (exprᴛ1 == (socksReply)(0x01)) { // literal label — cast to the tag type
return "general SOCKS server failure"u8;
}
This also repairs the ALL-literal switch over a named type, which previously emitted the ambiguous
bare == form. Full-stdlib footprint: 12 files (socks_bundle, archive/zip, encoding/xml,
go/printer, go/types, internal/poll, syscall zsyscall shims). (Guarded by NamedNumericSwitchLiteral
— a mixed named-const + literal switch including a multi-label clause, and an all-literal switch,
output-compared vs Go.)
A trailing default in a switch WITH fallthrough is guarded on !match
A Go switch with no fallthroughs lowers to a plain if / else if / … / else { default } chain, where
the trailing else correctly runs the default only when no case matched. But a fallthrough breaks
the chain: the case that fallthrough targets is emitted as a SEPARATE, !match-guarded if
(if (fallthrough || !match && <labels>) { … }) so it can be entered both by falling through and by a
direct match. A trailing default after such a case was emitted as that if’s bare else — which
fires whenever the fallthrough-target if is false, i.e. after any matched NON-fallthrough case, not
only when nothing matched. fmt’s printValue is exactly this shape:
switch f.Kind() {
case reflect.Int, …: p.fmtInteger(…) // a matched non-fallthrough case
case reflect.Pointer: … ; fallthrough
case reflect.Chan, reflect.Func, reflect.UnsafePointer: p.fmtPointer(f, verb)
default: p.unknownType(f) // wrongly ran after fmtInteger
}
so formatting an int slice element ran fmtInteger AND then unknownType (→ reflect name
resolution → a resolveNameOff stub → panic), breaking %v of every composite. The trailing default
is now emitted else if (!match) { /* default: */ } (matchVarName). This is byte-equivalent to the
bare else in a pure else-if chain (the default is reached only when !match either way) and correct
in the broken chain, so it is a safe general lowering. Like the fallthrough-reached default, the guarded
form leaves C# unable to prove exhaustiveness, so a value-returning terminal switch still gets its
trailing return default!;. Guarded by SwitchFallthroughDefault (a fallthrough+default switch
where a matched non-fallthrough case must NOT run the default, output-compared vs Go).
A NON-TRAILING default is guarded on the PRECOMPUTED any-case-match, never the running flag
Go allows default in any clause position and still picks it only when no case matches — position
is presentation, not semantics. The if-chain lowering emits clauses in source order, so a default
that is not last is guarded by a predicate that has only seen the arms emitted before it. Every clause
after such a default is then dead code, and the default body runs for values those clauses own. Two
shapes reach the chain, and the corpus held one live instance of each:
Fallen into. encoding/json’s decode.go array is the witness:
switch v.Kind() {
case reflect.Interface:
if v.NumMethod() == 0 { … return nil }
fallthrough
default:
d.saveError(&UnmarshalTypeError{Value: "array", Type: v.Type(), …})
d.skip()
return nil
case reflect.Array, reflect.Slice:
break
}
The default participates in fallthrough, so it cannot be reordered — the fallthrough link is
source-order-sensitive. It was emitted if (fallthrough || !matchᴛ1), and for a slice or array target
matchᴛ1 was still false (only the Interface arm had run), so the error arm fired and the
Array, Slice arm below it was unreachable. json.Unmarshal therefore failed every JSON array
whose target was not a bare interface{} — json: cannot unmarshal array into Go value of type
[1]interface {} for a fixed-size array (which is how net/rpc/jsonrpc passes its [1]any params),
and the identical error for every []T. The default now reads the precomputed any-case-match
already built for the mirror shape — the OR of every case condition in the switch, materialized ahead
of the chain — so it fires only when nothing matches:
var exprᴛ1 = v.Kind();
var matchᴛ1 = false;
var matchᴛ2 = exprᴛ1 == reflect.ΔInterface || (exprᴛ1 == reflect.Array || exprᴛ1 == reflect.ΔSlice);
if (exprᴛ1 == reflect.ΔInterface) { matchᴛ1 = true; … fallthrough = true; }
if (fallthrough || !matchᴛ2) { /* default: */ … }
if (exprᴛ1 == reflect.Array || exprᴛ1 == reflect.ΔSlice) { matchᴛ1 = true; … }
Participating in no fallthrough at all. For a non-constant-label tagged switch the converter
already normalized this shape by moving the default clause to the end. A constant-label switch
that has fallthroughs elsewhere also lowers to the chain, and that reorder gate did not cover it — so
the un-moved default emitted as a bare block, with no condition at all, and ran unconditionally.
internal/bisect’s parsePattern leads with default: return …parseError, so every bisect pattern
parse returned “invalid pattern syntax” and all six digit arms below it were dead code.
(regexp/syntax’s parseEscape has the identical shape and survived only by luck: its default body
re-tests !isalnum(c), which happens to exclude every one of its own case labels.)
These are now guarded in place — if (!matchᴛ2) { /* default: */ … } — rather than reordered.
Widening the reorder gate was tried and rejected: reordering moves the clause’s statements, but
comments attach by source position, so parseEscape’s default-clause commentary migrated up into the
octal arm above it. Guarding in place fixes the same defect with no code motion, keeps the emitted C#
in Go’s clause order, and costs nothing structurally — emitting an if instead of a bare block is
precisely what lets the following clause keep its else, which is the CS8641 problem the reorder
exists to avoid in the first place.
Guarded by JsonFixedArrayUnmarshal, which unmarshals JSON arrays into [1]any, [2]int, [3]string,
nested [2][2]int and a struct array — including the over-length (truncate) and under-length
(zero-fill) cases — and, in the other direction, asserts that the default arm still produces Go’s
exact error for the targets it genuinely owns, so the fix cannot be an over-broad one that drops the
guard.
A clause’s else may only be dropped when EVERY preceding clause terminates
In the if-chain lowering the converter omits the else before a clause when the preceding clause
ended in a return — the chain is then unnecessary, since control cannot reach the later clause
anyway. That decision was driven by a shallow “the last statement emitted was a return” flag, which
reports only the immediately preceding clause. Dropping the else is sound only when every
preceding clause terminates.
For a case the mistake is invisible: its condition cannot match a value an earlier case already
matched, so the unchained if simply evaluates to false. For default: it is silently fatal —
default has no condition and therefore always runs. os’s (*Process).wait is exactly this shape:
switch s {
case syscall.WAIT_OBJECT_0: // a bare `break` — falls OUT of the switch
break
case syscall.WAIT_FAILED:
return nil, NewSyscallError("WaitForSingleObject", e)
default:
return nil, errors.New("os: unexpected result from WaitForSingleObject")
}
The break case is not a Go terminating statement, but the returning WAIT_FAILED case set the flag,
so the default emitted as an unguarded block that ran straight after the success path:
if (exprᴛ2 == syscall.WAIT_OBJECT_0) { do { break; } while (false); }
else if (exprᴛ2 == syscall.WAIT_FAILED) { … return; }
{ /* default: */ … return errors.New("os: unexpected result from WaitForSingleObject"); }
Every child-process wait therefore failed — and it compiled cleanly the whole time. The decision now
uses the accumulated allCasesTerminal check that the trailing-return default!; logic already
relies on (genuine Go terminating-statement analysis, so an if { return } with no else is
correctly non-terminating). The original condition is kept as one arm of the test, making the change
strictly add an else where one was missing and never remove one, so no already-correct emission
changes. Guarded by SwitchFallthroughDefaultReturn’s waitShape (non-constant case labels force the
if-chain; verified to fail without the fix).
A break in a case that also fallthroughs must skip the fallthrough
A case body containing a Go break is wrapped in do { … } while (false) so the break has a C#
target in the if-chain lowering (the case above shows the wrapper). A case ending in fallthrough
raises a fallthrough flag the next clause’s guard reads. A case with both put the flag after
the wrapper:
if (exprᴛ1 == stdISO8601ColonTZ || …) { matchᴛ1 = true;
do {
if (len(value) >= 1 && value[0] == (rune)'Z') { value = value[1..]; z = ΔUTC; break; }
} while (false);
fallthrough = true; // ← reached by the break as well
}
so the break — which in Go exits the switch — fell through instead. time.Parse is exactly this
shape: RFC3339’s Z07:00 layout element consumes a literal Z and breaks, and the fallthrough handed
the remaining text to the numeric-offset arm, which rejected it. Go reports
extra text: "07:00"; C# reported cannot parse "Z07:00" as "Z07:00", and every Z-terminated
RFC3339 value routed through Time.UnmarshalText failed the same way.
Because Go’s spec requires fallthrough to be the final non-empty statement in its clause, “control
reached the end of the body” is “the fallthrough statement was reached” — which is what lets the flag
be raised at the end rather than at the statement. In a break-wrapped case that equivalence holds only
inside the wrapper, so the assignment moves there and a break skips it:
do {
if (…) { …; break; }
fallthrough = true;
} while (false);
A case with a fallthrough and no switch-break is unaffected (no wrapper exists), as is a case whose
only break belongs to a nested loop — caseBodyHasSwitchBreak already stops at a nested
loop/switch/select/closure, so no wrapper is emitted and the flag stays where it was. Guarded by
SwitchBreakBeforeFallthrough (the Z-consuming break-then-fallthrough arm, a fallthrough arm with no
break, a nested-loop break that must still fall through, and a break with no fallthrough behind it —
output-compared vs Go).
A continue in a break-wrapped case targets the LOOP, not the wrapper
The do { … } while (false) switch-break wrapper is itself a C# iteration statement, and C# binds
continue to the innermost enclosing one — so a Go continue (meaning: continue the enclosing for)
inside a wrapped case continued the wrapper instead, which exited on its false condition and fell
through past the switch into the rest of the loop body. The Go continue’s intent was silently
discarded; the wrapper retargeted break but never considered continue — the symmetric twin of the
fallthrough hazard above. net/http’s ParseSetCookie is the live corpus shape: the max-age and
expires cases each hold both a switch-break (the malformed-attribute bail-out) and a loop-continue
(the parsed-attribute accept), so a successfully parsed Max-Age/Expires ran its case to completion and
then fell through into c.Unparsed = append(c.Unparsed, …) — Expires and RawExpires parsed
correctly, yet the raw attribute also landed in Unparsed, which is only possible when the case body
runs to completion and falls out.
Such a continue now lowers to a goto targeting a labeled empty statement at the very end of the
enclosing loop’s body, where control reaches the loop’s post-statement and condition exactly as
continue would:
for (nint i = 0; i < len(words); i++) {
var exprᴛ1 = words[i];
if (exprᴛ1 == "skip"u8) {
do {
if (i == 0) {
fmt.Println(aBreakingAtˢ, i);
break; // Go break-of-switch — exits the wrapper (its purpose)
}
fmt.Println(aContinuingAtˢ, i);
goto continueᴛ1; // Go continue-of-loop — a bare C# continue would bind the wrapper
} while (false);
}
else if (exprᴛ1 == "stop"u8) {
fmt.Println(aStopAtˢ, i);
}
fmt.Println(aAfterSwitchˢ, i);
continueᴛ1:;
}
Mechanics, each load-bearing:
-
The label is minted per loop (
continueᴛN, the standard temp-name convention; nested loops each get their own) and emitted only when some wrapped case actually targets it — the loop’s body suffix carries a marker that resolves to the labeled empty statement or to nothing once the body has been emitted, so every wrapper site without a loop-continue stays byte-identical (no label, no goto; an unconditional label would also draw CS0164). -
The label precedes the per-iteration copy-backs. A Go 1.22+ transformed loop (a body closure
captures the clause variable) re-declares the variable from a carrier each pass and must copy the
final value back before the post clause; the bare-
continueemission writes those copy-backs inline at the continue site, but the goto path instead flows through them — the label sits with thecontinue_<label>:target, ahead of the copy-backs — so a wrapped continue in such a loop cannot leave the carrier stale (which would re-run the same index). -
A continue belonging to a nested real loop inside the wrapped case stays a bare
continue. The emitter keeps a stack of continue targets — loop entries (for/range) and wrapper entries — and only a continue whose innermost entry is a wrapper takes the goto, targeting the nearest loop entry beneath; a for/range loop nested in the case body pushes its own entry and its continues bind it natively, exactly as Go requires. -
Labeled
continue Lis untouched — it already lowers togoto continue_L, which passes through the wrapper correctly, and the range/foreachform takes the same end-of-body label asfor.
Guarded by SwitchBreakContinueWrapper: the defect shape in a for and in a range loop, an
unwrapped-continue control, a break-only wrapped control (byte-identity), a nested inner loop whose
continue must keep binding inward, a labeled continue outer through a wrapper, and the
per-iteration-capture loop whose wrapped continue must flow through the carrier copy-back — all
output-compared vs Go.
Type Switch Statements
For a Go type-switch, C#’s type-pattern switch works well. The runtime exposes the dynamic type via .type(), and the empty interface is any:
func do(i interface{}) {
switch v := i.(type) {
case int:
fmt.Printf("Twice %v is %v\n", v, v*2)
case string:
fmt.Printf("%q is %v bytes long\n", v, len(v))
default:
fmt.Printf("I don't know about type %T!\n", v)
}
}
converts to:
internal static void @do(any i) {
switch (i.type()) {
case nint v: {
fmt.Printf("Twice %v is %v\n"u8, v, v * 2);
break;
}
case @string v: {
fmt.Printf("%q is %v bytes long\n"u8, v, len(v));
break;
}
default: {
var v = i.type();
fmt.Printf("I don't know about type %T!\n"u8, v);
break;
}}
}
Go int/uint cases and the synthetic concrete case. A Go int maps to C# nint, but an int-valued literal boxed into an interface (do(1)) has C# dynamic type int32, not nint. So a case int: emits its native form plus a synthetic concrete case int32: (and case uint: adds case uint32:) sharing the same body, to catch both boxings. The exception: if the same switch also lists an explicit case int32:/case uint32: (or case rune: ≡ int32), the synthetic is skipped — emitting it would duplicate the explicit case (CS8120 “unreachable case”) and, being emitted first, would steal the explicit case’s values and run the wrong body. With the synthetic suppressed, a typed int value (nint) hits case int: and a typed int32 value hits case int32:, distinctly. (Runtime’s printpanicval switches over int, int8, …, int32, …, uint, …, uint32, …; guarded by the TypeSwitch behavioral test.)
Duplicate-mapped cases — the identical-body merge. Go type distinctions can vanish in the C# type map, making a later case unreachable (CS8120). The canonical example was uint + uintptr under the old System.UIntPtr alias — now moot: uintptr is a distinct golib struct (see Constant Values) and both cases emit their own labels, each dynamic type routing to its own body exactly as in Go. The merge machinery remains for any alias pair that still shares a C# type: a duplicate-mapped case merges only when its Go body is byte-identical to the first occurrence’s — the earlier label already routes both dynamic types to that shared body, so the merge is exact. A marker comment replaces the duplicate label:
case uint64 vΔ1: {
print(vΔ1);
break;
}
/* case uintptr vΔ1: merged with an earlier case mapping to the same C# type (identical body) */
case float32 vΔ1: {
If the bodies differ, both labels are kept and the CS8120 stands: a compile error is preferable to silently routing one Go case’s values into another case’s body. Duplicate detection keys on the resolved C# type (uintptr→nuint, rune→int32, byte→uint8) per switch statement; the synthetic int32/uint32 cases register too, so an explicit later duplicate of a synthetic with the same body also merges. Guarded by the TypeSwitch behavioral test (uint + uintptr with identical bodies, values hitting both Go paths).
Multi-type cases stack labels and bind at the tag’s interface type. Go binds a type-switch case
variable at the listed CONCRETE type only when the clause lists exactly one type; a multi-type clause
(case *Alias, *Named:) binds it at the TAG’s static (interface) type. The old emission split such a
clause into one concrete-bound C# case per listed type (duplicating the body), so every body use in an
interface-typed context broke — as an argument (isGeneric(t) with t a ж<Alias>, CS1503), an
interface assignment (CS0266), and an extension-method receiver (CS1929 — 18 errors in go/types alone).
A multi-type clause now emits stacked labels binding only a discard, over one shared body and
re-binds the variable to the guard expression — the same re-bind the default arm uses — so the body
compiles at the interface type exactly as in Go:
switch (x.typ.type()) {
case ж<Alias> _:
case ж<Named> _: {
var t = x.typ; // t: Type (the tag's interface type), as in Go
if (isGeneric(t)) { … }
break;
}
case ж<ΔSignature> t: { // single-type case keeps the concrete binding
The _ designation is load-bearing, not stylistic: it forces the label into PATTERN context. A bare
case int8: label resolves the identifier as an EXPRESSION first, where using static go.builtin finds
the same-named conversion FUNCTION (int8(…)) — a method group, neither constant nor type — failing
CS8917 (encoding/binary’s Size/intDataSize stacks; caught by the census build, not the behavioral
corpus, whose labels happened not to collide). case nil stacks as case null:, a dynamic-interface
label stacks in its non-binding {} ᴛn when ᴛn._<Iface>(out var _) form, and the synthetic
int32/uint32 companions of case int:/case uint: stack with the same discard. The
duplicate-mapped-case merge applies per label (a merged label leaves its marker comment above the stack).
An UNBOUND multi-type clause (switch x.(type)) stacks the same way with no re-bind — the body is no
longer duplicated per label. The re-bind re-evaluates the guard EXPRESSION at body entry — harmless
for a pure tag (nothing can mutate it between dispatch and entry), and an IMPURE tag is hoisted into
a one-time temporary first (see The type-switch tag evaluates exactly once below). (Guarded by the TypeSwitchMultiCase behavioral test — bound multi-type
cases over values and pointers with interface-dispatched body uses, nil stacked with a concrete type, an
unbound multi-type clause, and the synthetic-int stacking, output-compared vs Go; also rewrote
TypeSwitch’s case int, int64, uint64: golden with output proven unchanged.)
Runtime dispatch — .type() unwraps the interface adapters. The case patterns match against
whatever object the switch operand .type() returns, so it must surface the Go DYNAMIC value, not
the C#-only wrapper classes the runtime uses to carry it. A non-empty interface value created from
a Go pointer is a generated IжAdapter wrapping the receiver box (var v shape = &c emits
new circleжshape(Ꮡc); see Interfaces), and an interface-to-interface
assignment can wrap the source in an IInterfaceAdapter. .type() therefore unwraps —
IInterfaceAdapter.Value chains first, then IжAdapter.Box — mirroring the type-assert machinery
in _<T>, so a Go case *circle: (emitted case ж<circle> t:) matches the adapter-wrapped value
exactly as it matches the raw box that an EMPTY interface (any) holds directly. The bound t
IS the original receiver box, so writes through it (t.Value.r += 10) alias the original object,
matching Go’s interface-holds-the-pointer semantics; case nil (emitted case null:) still sees
the nil interface unchanged. A known edge remains: an interface holding a nil *T (in Go a
non-nil interface that matches case *T: binding a nil t) stays wrapped — no C# type pattern
can bind a null — so it falls to default rather than wrongly matching case null:. (Guarded by
the TypeSwitchPointerAdapter behavioral test — pointer-receiver implementations of a non-empty
interface dispatched through single-type, multi-type, no-bind, and write-through cases, plus
value-receiver, nil, and raw-box-in-any controls.)
Both unwrap tiers sit behind ONE IGoAdapter probe (2026-07-26). .type() is evaluated once
per type switch, and it used to test IInterfaceAdapter and then IжAdapter separately — two
failing interface type tests for every ordinary Go value, which is what the overwhelming majority
of type switches dispatch on. Measured, that is ~2.9 ns each on the JIT, against a failing sealed-
class test too small to measure: a failing interface isinst walks the type’s interface map. The
two adapter interfaces now share an empty base marker, IGoAdapter, and .type() probes it once to
gate both unwraps, dropping the type switch from 16.0 ns to 4.5 ns per iteration on PerfIface.
The unwrap ORDER and results are unchanged; the string → @string arm moved after the IжAdapter
arm, which cannot change an answer because string is sealed and implements neither marker. See
Interfaces for the same gate on the type-assert side and the full measurement.
Named-interface case labels dispatch by method set through the adapter registry. An
INTERFACE-typed case label — named (case fmt.Stringer:, case error:) or anonymous — must match
by Go METHOD-SET semantics, and after the unwrap above the operand is the raw receiver box, which
never nominally implements a C# interface (the generated pointer adapter does). A plain C# type
pattern (case Stringer t:) therefore missed every pointer-sourced implementation. All interface
labels now emit the when-guard form the anonymous labels already used —
case {} Δx when Δx._<Stringer>(out var x): — routing dispatch through golib’s type-assert
machinery, which resolves in order: a nominal implementer (value structs made partial, adapter
instances, duck-type wrappers) matches directly; a raw box ж<X> (or an adapter asserting to a
different interface, via its Box) re-wraps through go.AdapterRegistry — each generated
pointer adapter registers (typeof(ж<X>), typeof(Iface)) → box => new XжIface(box) from a
[ModuleInitializer], so the lookup is a dictionary hit and a compiled factory, reflection-free
and Native-AOT-safe (the initializer also roots the adapter against trimming); an anonymous
interface falls back to its runtime duck-typing shell (this said “its generated ᴛAs duck-typing
conversion” until 2026-07-25, when anonymous interfaces moved onto the same shells named ones use).
The out var x binds at
the CASE interface type exactly as Go binds the case variable, the re-wrapped adapter forwards to
the original box (writes through the binding alias the original object), label order is preserved
(C# tests patterns top-to-bottom, and a when-guarded pattern never makes a later label
unreachable), and case nil is unaffected ({} never matches null). The type-assert core is
non-throwing (TryTypeAssert), so a non-matching label — the NORMAL control flow in a type
switch — costs no exception; this also makes a nil-interface v, ok := x.(T) return ok=false
(Go semantics) instead of faulting, and a named interface with no registered adapter is a MISS
rather than the former missing-conversion-method hard error. Known residuals, all of the same shape (the
adapter type does not exist or its module never loaded, so Go would match where C# misses): a
(struct, iface) pair with no conversion site anywhere in the program, a generic struct’s
adapter (an open registration key is unrepresentable and a generic class cannot host a module
initializer), and FOREIGN value adapters (ᴠ-composed), which are not yet registered.
(Guarded by the TypeSwitchNamedInterfaceCase behavioral test — pointer-adapter value, raw
box-in-any, value-struct implementer, case error: in both adapter-carried and raw-box forms,
non-matching control, label-order precedence both directions, a multi-type clause of two interface
labels, interface-tag-to-interface-label dispatch, and write-through aliasing, output-compared
vs Go.)
Struct Types
Go structs are converted to C# struct types and used on the stack to optimize memory use and reduce GC pressure; when an instance must escape the stack it is wrapped in a heap box, ж<T> (see Pointers). Rather than spell out the whole struct body, the converter emits a partial struct carrying a [GoType] attribute, and the TypeGenerator source generator synthesizes the members (equality, ISupportMake, embedding promotion, etc.):
[GoType] partial struct Person {
public @string Name;
public nint Age;
}
The generator also chooses the access modifier from the Go name (exported → public, unexported → internal), except where the converter emits an explicit modifier — for instance, an unexported type used as the type of an exported field is published as public to satisfy C# accessibility (the converter emits public partial struct … and the generator honors that explicit modifier).
The synthesized value-equality body compares the struct’s fields against a parameter named other (public bool Equals(Person other) => this.Name == other.Name && this.Age == other.Age;). Each comparison’s left operand is qualified with this. so a Go field whose name happens to collide with the parameter — a field literally named other — still binds field-to-field. Without the qualification a type holder struct { mark int; other int } would emit other == other.other, where the left other resolves to the parameter (a holder) rather than the field (an int), failing to compile with CS0019 (== cannot be applied to holder and int). GetHashCode/ToString reference the same field names but have no colliding parameter, so they need no qualification. (Guarded by the StructFieldNamedOther behavioral test.)
A combined Go field declaration — x, y int — emits a single combined C# line (internal nint x, y;) so the output mirrors the Go source’s line grouping. The combined form is only used when every name in the group shares the same emitted type and access modifier and none needs per-name special handling; otherwise the converter falls back to one line per name. The fallback applies when any of these hold: a blank field _ (renamed per occurrence — _, __, …), a name equal to the enclosing struct type (renamed with the Δ collision marker), a per-field array initializer ( = new(N)), or a mix of exported and unexported names in the same group (X, y int → public nint X; / internal nint y;). Field comments and tags attach to the whole Go field, so they never diverge within a group.
C# does not allow inline or intra-function type definitions, so these are “lifted” out of the function. A named local type is lifted with its enclosing function’s name as a prefix to avoid collisions — a type x struct{…} declared in main becomes main_x. An anonymous struct (or an anonymous struct used as a field/value) is lifted to a synthesized name with a ᴛN suffix and marked dynamic, e.g. [GoType("dyn")] partial struct settingsᴛ1. Struct “definitions” that match structurally remain usable interchangeably (the generator and implicit conversions handle this). A reference to a lifted type as a bare identifier is renamed to the lifted name, and so is its use as a slice or array element type — []entry (where entry is a local type) emits slice<process_entry>, not the short slice<entry> (which is unresolved at package scope → CS0246). The element is resolved through the same lift registry as the bare-identifier and anonymous-struct cases. (Guarded by the LocalTypeSliceElement behavioral test, covering both the slice and fixed-array forms; runtime hit this on printDebugLog’s []readState and traceAdvance’s []untracedG.)
The dyn marker is also the type’s RUN-TIME identity, and it must not leak the synthesized name. An unnamed Go struct has no name to report, so reflect.Type.String() and %T render it STRUCTURALLY — struct { X int; y int }, and struct {} for golib’s EmptyStruct. Because a lift gives the type a synthesized C# name (settingsᴛ1), that name would otherwise be what reflection reports; [GoType("dyn")] is exactly what distinguishes a lift from an ordinary declared struct, whose Go name IS its own. golib’s GoReflect.TypeNaming therefore renders a dyn-marked value type from the GoFields projection — the same field table NumField/Field and the value side read, so a type’s reported name and the fields it hands out cannot disagree — following Go’s format exactly: an embedded field contributes its type alone, a tagged field appends the strconv.Quoted tag. Before it, go/ast’s TestPrint reported ast_internal_test.typeᴛ1 where Go prints struct { X int; y int }, and internal/platform’s decode error named []platform_test.listEntry rather than Go’s structural spelling. (Landed 2026-08-09 with go/ast’s 9/9 bank; see docs/phase4/DESIGN-reflection-bridge.md.)
A map whose VALUE type is an anonymous struct is lifted the same way. A package-level var m = map[K]struct{…}{…} — crypto/internal/hpke’s SupportedKEMs (map[uint16]struct{ curve ecdh.Curve; hash crypto.Hash; nSecret uint16 }) and SupportedAEADs — names its value struct through the lift so the map type reads map<uint16, SupportedKEMsᴛ1>; without it, getAliasQualifiedTypeName’s map arm stringified the value as raw Go struct{…} syntax straight into the C# map signature (map<uint16, struct{ curve ecdh.Curve; … }>) — which C# cannot parse (a CS1519/CS1003 syntax cascade). extractStructType already lifts a slice/array element struct (its ArrayType arm) but has no map arm, so a dedicated extractMapValueStructType lifts the map VALUE struct at the package-level value-spec composite-literal site. The keyed element literals stay the target-typed new(…) constructor form — [0x0020] = new(ecdh.X25519(), crypto.SHA256, 32) — which binds to the lifted struct’s generated constructor; a func-typed value field (SupportedAEADs’ aead func([]byte) (cipher.AEAD, error)) lifts to a Func<…> field that a method-group or func-value element still fills. Both the declaration type (getCSharpTypeName → the map arm) and the literal’s own type render (convMapType → getExpressionTypeName) resolve the value through the shared liftedTypeMap (the lift runs before the initializer is converted). (Guarded by the MapAnonStructValue behavioral test — a package-level map with an anonymous-struct value type, including a func-typed field, constructed and read back by key, output-compared vs Go.)
The empty struct struct{} is never lifted — it maps to the shared golib EmptyStruct, so a struct{}{} composite literal emits new EmptyStruct() and a map[K]struct{} (“set”) emits map<K, EmptyStruct>. Lifting an empty struct would be doubly wrong: it has no fields to model, and the lift mis-attributes its name and identity. When the struct{}{} is the value assigned to a map element (seen[k] = struct{}{}), the enclosing assignment passes the LHS ident (seen) into the struct-conversion context to name the lift — so the empty struct was being lifted to <func>_seen and registered under seen’s own type, the map map[K]struct{}, in the lifted-type registry. That poisoned every later reference to that map type: the function parameter seen map[K]struct{} rendered as the phantom struct instead of map<K, EmptyStruct>, and its comma-ok deconstruction ((_, ok) = seen[k]) and two-arg indexer vanished (CS8130/CS0021), while real-map call sites mismatched (CS1503). convStructType now short-circuits an empty struct to EmptyStruct before any lift, mirroring the !isEmptyStruct guard that extractStructType already applies everywhere else. (Guarded by the EmptyStructMapSet behavioral test; runtime hit this on typesEqual’s seen map[_typePair]struct{} parameter.)
An empty interface{} field is never lifted either — it maps to any, exactly as a bare interface{} type does. When visitStructType lifts an anonymous struct it walks its fields and lifts any anonymous interface field to its own named [GoType("dyn")] interface. Those three inline lift sites (a plain interface{} field, a *interface{} field, and a []interface{}/[N]interface{} element) type-asserted *ast.InterfaceType directly, diverging from extractInterfaceType — the canonical lift gate, which already excludes empty interfaces. So encoding/json’s slice-encoder cycle memo — ptr := struct{ ptr interface{}; len int }{v.UnsafePointer(), v.Len()} — lifted its ptr interface{} field to a named empty marker interface encode_ptr_ptr. A named empty interface is implemented by nothing, so constructing the struct from the boxed uintptr failed (cannot convert from 'uintptr' to '…encode_ptr_ptr', CS1503). The three sites now carry the same !isEmptyInterface guard, so an empty-interface field falls through to the normal field-type conversion and renders any (*interface{} → ж<any>, []interface{} → slice<any>). (Guarded by the AnonymousStructs extension cycleMemo — an in-function anonymous struct with an interface{} field constructed from a pointer, read back through the field, and used as a map key, output-compared vs Go; fails CS1503 without the guard. Part of greening encoding/json.)
A returned anonymous-struct composite literal records its implicit conversion AFTER it is lifted. Two structurally-identical anonymous structs are the same Go type but the converter lifts each occurrence to a distinct C# name, so a conversion between them must be bridged by a recorded [assembly: GoImplicitConv<…>] (the ImplicitConvGenerator emits the operators). A closure whose result type is an anonymous struct that returns a composite literal of the identical anonymous struct — mk := func(…) struct{ptr any; len int} { return struct{ptr any; len int}{p, n} } — lifts the closure-result type (…_func_R0) and the composite-literal type (…_type) separately, and visitReturnStmt’s checkForDynamicStructs records the conversion between them. Each side’s C# name is resolved through the per-file lifted-type registry (liftedTypeMap), but a function-local composite literal is only added to that registry during its own convExpr. The recording therefore had to move to run after the result expression is converted: reading the arg’s type earlier found it unlifted and stringified it as raw Go struct{…} text — an invalid C# generic argument in the emitted attribute ([assembly: GoImplicitConv<struct{ptr interface{}; len int}, …_func_R0>], CS1031 “Type expected”). Recording after the lift resolves both sides to their lifted names ([assembly: GoImplicitConv<…_type, …_func_R0>]). The dynamicCast template checkForDynamicStructs may return is applied to the already-converted result expression identically either way, so the reorder is otherwise output-neutral (the full-stdlib A/B reconvert is byte-identical). This is latent for the current stdlib (encoding/json builds its identical memo struct once into a variable, avoiding a second same-shape lift). (Guarded by the ClosureReturnAnonStruct behavioral test.)
Lifted anonymous structs embedding an interface
archive/tar’s ReadFrom-hiding shape — io.Copy(struct{ io.Writer }{tw}, r) — exercises four
coupled rules: a SELECTOR embed’s interface check resolves the Sel (Writer), not the
package ident (io), so the cross-package interface embed emits as a plain interface FIELD
(the promoted-struct property form made the generator construct the interface — CS0144); the
composite literal routes the element through the interface conversion at render
(interfaceTypes[i], not just the record-only call); a receiver placed into an
INTERFACE-typed composite field triggers direct-ж (Go’s interface holds the *T, so the
pointer adapter wraps the box Ꮡfr); and the generator emits ONE value-form impl per
(struct, interface) pair, folding a Promoted duplicate in (CS0111). ARGUMENT-position values
of a mismatched delegate type wrap in the named delegate’s constructor exactly like
composite-literal fields (generic delegate params stay native — unsubstituted type params
cannot render). Guarded by AnonymousInterfaces (tally/fill, byteRepeat, and the
named-array quad/frame Range slice).
The same interface-field record+route also fires for a named non-struct element that
implements the field’s interface. The gate above triggered only when the element’s underlying is a
struct (or the field is embedded), so a named scalar with a method set — hpack’s
DecodingError{InvalidIndexError(idx)}, where type InvalidIndexError int has an Error() method
satisfying the error field — recorded no GoImplement<InvalidIndexError, error> and passed the
value bare to the interface-typed constructor parameter (which surfaces as NilType, CS1503). The
gate now also fires when a named, non-struct, non-interface element type types.Implements the
field’s interface — mirroring the call-argument path, which routes any argument into an interface
parameter with no struct-only restriction. Recording the GoImplement is what clears the error (the
generated implementation makes the scalar implement the interface, so the bare pass then converts
implicitly); the render routing (interfaceTypes[eltIndex]) is set too, matching the struct case. An
interface-typed element is excluded — it is already the interface and needs no adapter. (Guarded by
the InterfaceFieldNamedScalar behavioral test — a named int into an error field and a named
string into a local interface field, positional and keyed forms, output-compared vs Go.)
Naming a lift that has no name source — new(struct{ SomeIface }). Every dyn-lift derives its
C# type name from context (the declared var, the struct field, the parameter name). A package-level
var reserved = new(struct{ types.Type }) (go/internal/gccgoimporter’s singleton) had NO source:
the initializer is a CallExpr (the composite-literal up-front lift didn’t fire), so the declaration
type fell to the raw t.String() mangle (ж<types.Type}>), and the lift arrived late from the
call-argument path under builtin new’s UNNAMED parameter — an EMPTY lift name, declaring
partial struct { and registering "" for every reference (@new<>(),
[assembly: GoImplement<, …>], new жΔType(…) — a whole-package syntax cascade). Two-part fix:
visitValueSpec lifts a new(struct{…}) initializer’s struct UP FRONT under the var’s name
(mirroring the composite-literal and hpke map-value lifts), so the declaration, the @new<…> type
argument, the GoImplement recordings, and the pointer-adapter names all resolve through
liftedTypeMap:
[GoType("dyn")] partial struct reservedᴛ1 {
public global::go.go.types_package.ΔType Type;
}
internal static ж<reservedᴛ1> reserved = @new<reservedᴛ1>();
p.typeList[n] = new reservedᴛ1жΔType(reserved);
and visitStructType itself falls back to the generic "type" when a lift arrives with an empty
name (the FUNCTION-LOCAL x := new(struct{…}) form still reaches it through the unnamed-parameter
path → main_type), so no caller can produce an unnamed type declaration. (Guarded by the
NewAnonStructIfaceEmbed behavioral test — the package-level singleton converted to its embedded
interface through the lifted type’s pointer adapter, the embedded field filled and called through
the promotion, plus the function-local form — output-compared vs Go.)
An anonymous struct lifts from ANY depth of its declared type
The lift only happens if the converter can find the struct{…} literal in the declaration it is
converting, and the probe that found it used to look exactly one level down: through a pointer, or
through a slice/array element, or (a later addition) a map value — never through a composition of
those. So []struct{…} lifted and []*struct{…} did not, and net’s
var ipStringTests = []*struct {
in IP // see RFC 791 and RFC 4291
str string // see RFC 791, RFC 4291 and RFC 5952
byt []byte
error
}{ … }
emitted the raw Go type text into the C# declaration — slice<ж<struct{in net.IP; str string; byt
[]byte; error}>> — which is not C# at all: CS1031 Type expected, followed by a 90-error
syntax cascade that hid every real diagnostic in the file behind it. The shape had been invisible
because a composed occurrence still resolved if some other declaration in the package happened to
register the identical signature first; error embedded here makes the signature unique, so
nothing did.
The probe is now a recursive descent over the type-composing syntax — pointer, array/slice element,
...T, parenthesization, map value then key, channel element — so an anonymous struct (and, by the
same helper, an anonymous interface) is found wherever it sits. The AnonStructComposedTypes golden
shows the same shape lifting and its elements constructing normally:
[GoType("dyn")] partial struct ptrElemsᴛ1 {
internal nint @in;
internal @string str;
internal error error;
}
internal static slice<ж<ptrElemsᴛ1>> ptrElems = new ж<ptrElemsᴛ1>[]{
Ꮡ(new ptrElemsᴛ1(1, "one"u8, default!)),
Ꮡ(new ptrElemsᴛ1(2, "two"u8, default!))
}.slice();
This strictly widens what lifts: anything that lifted before still lifts, under the same name. The
walk is deliberately first-match, because each lifting caller can name only one anonymous type
per declaration — a type expression carrying two distinct anonymous literals (map[struct{…}]struct{…})
lifts the value’s and leaves the key’s. That residual used to apply to every composed shape; it is
now confined to that one. The map-value case had its own one-off probe, which the recursion subsumes
and which was deleted with it.
Scope, measured by a whole-standard-library A/B reconvert against a converter built from the previous
commit — not by a source scan, which got this wrong. A grep for the shape found it only in
_test.go files and would have concluded the production corpus was untouched; the A/B found
encoding/gob/type.cs, because bootstrapType("_reserved1", (*struct{ r7 int })(nil)) reaches its
anonymous struct through a parenthesized pointer conversion — (…) then * then the literal — a
composition the scan’s pattern never looked for. (Charter §9’s false-alarm rule, from the other
direction: a zero-hit scan is only as good as its positive controls, and this one had a control for
[]*struct{…} and none for (*struct{…}).) The corpus footprint is exactly those seven tReservedN
declarations, and the change there is a naming improvement, not a behavior change: the lift now
happens at the call argument, where it takes the parameter’s name (eᴛ1…eᴛ7), instead of arriving
late from convStarExpr’s fallback as the generic Δtype/Δtypeᴛ1…. Declarations, uses and the
package_info.cs accessibility block all move together. (Guarded by the
AnonStructComposedTypes behavioral test: a slice of
pointer-to-anonymous-struct with an embedded error, a map to pointer-to-anonymous-struct, and a
slice of slice of anonymous struct, read and written through and output-compared vs Go.)
The struct-FIELD arm was the same probe, and it now shares the same descent. visitStructType
kept its own hand-written peel — a chain of field.Type.(*ast.StarExpr) / .(*ast.StructType) /
.(*ast.InterfaceType) / .(*ast.ArrayType) arms, each looking exactly one level down — so a struct
field declared [N]struct{…} lifted while [N]*struct{…}, []*struct{…}, map[K]struct{…} and
chan struct{…} fell through to the same raw-Go-text emission. That the map case had already been
patched in as its own arm rather than as a rule is the shape a point-repair leaves behind, and it is
what marked this as the next site. The arm now calls extractStructType / extractInterfaceType,
the identical helpers every other lift site uses:
type Composed struct {
Ptrs [2]*struct{ Size uint32 }
ByKey map[string]struct{ Count int }
}
[GoType("dyn")] partial struct Composed_Ptrs { public uint32 Size; }
[GoType("dyn")] partial struct Composed_ByKey { public nint Count; }
[GoType] partial struct Composed { // package_info.cs records [GoValueClone("Ptrs")]
public array<ж<Composed_Ptrs>> Ptrs = new(2);
public map<@string, Composed_ByKey> ByKey; // was: map<@string, struct{Count int}>
}
Two properties keep the shared helper faithful to what the arm did before. The lift name stays
<struct>_<field>, which is well-defined for every shape because a field type carrying an anonymous
literal always names the field — the Go spec makes an embedded field a type name, never a literal.
And sub-struct tracking (subStructTypes, which feeds addImplicitSubStructConversions) still
records only the two shapes it ever recorded — the field is the anonymous struct, or a pointer
straight to it — because that map describes the field’s own declared type; a struct reached through a
slice/array/map/channel element is not the field’s type and never was tracked.
Measured by the same whole-standard-library A/B, the widening has no corpus consumer today: no
converted package declares a composed anonymous-struct field. What the A/B did change is four files
in two packages, all one incidental canonicalization — the shared helpers exclude the empty
struct{}/interface{}, and the old field arm did not:
- [GoType("dyn")] partial struct Func_opaque { } // …and NamedArg__NamedFieldsRequired, Out__…
- [GoType] partial struct Func { internal Func_opaque opaque; }
+ [GoType] partial struct Func { internal EmptyStruct opaque; // unexported field to disallow conversions
Go’s opaque struct{} is struct{}, and golib’s EmptyStruct is what every other site already maps
it to — so runtime.Func, database/sql’s NamedArg and Out stop minting a private empty type
apiece, three [GoType("dyn")] declarations and their package_info.cs entries disappear, and the Go
trailing comment lands back where Go writes it. Nothing referenced the removed names (verified across
the whole reconverted corpus, with the baseline emission as the positive control), and the 302-package
corpus builds with 0 errors. (Guarded by the AnonStructArrayElement behavioral test, extended: a
[2]*struct{…} field, a []*struct{…} field, a map[K]struct{…} field and a map[K]interface{…}
field, each read back through its lifted type, alongside the pre-existing one-level [N]struct{…}
control and the parenthesized (*struct{ r7 int })(nil) conversion. Its A/B reproduces the defect
directly: against the previous binary the three struct fields emit raw struct{…} text. The composed
fields are read at their ZERO values on purpose — constructing a value of an anonymous struct type
lifts a second, function-scoped name for the same Go type, and a container of it has no implicit
conversion to bridge the two. That is the recorded cross-context anonymous-lift identity split, which
applies equally to the one-level shape and is a separate increment.)
A global addressed only by the package’s own _test.go is still heap-boxed
A Go pointer to a package-level var aliases that var’s real storage, which in C# means the global
must be backed by a heap box (see Pointers); packageAddressedGlobals decides that by
scanning the package for &g. But go/packages excludes _test.go from a production package, so
an address taken only by the package’s own in-package test half is invisible at the declaration.
path/filepath is the canonical case — path.go declares var lstat = os.Lstat // for testing and
export_test.go declares var LstatP = &lstat, the whole point being that a test can swap the
implementation the production Walk calls. The production emission left lstat a plain field, and
the test variant’s Ꮡlstat named a box nothing declared: CS0103.
The converter now scans the build-selected in-package _test.go files for the identifiers they take
the address of and folds them into the addressed-global set, so the production declaration carries
the box:
internal static ж<Func<@string, (fs.FileInfo, error)>> Ꮡlstat = new(os.Lstat);
internal static ref Func<@string, (fs.FileInfo, error)> lstat => ref Ꮡlstat.ValueSlot; // for testing
Three properties make this the right shape rather than a -tests-only patch:
-
It runs in ordinary conversion too, exactly as
siblingTestFuncMethodNamesdoes for reference spelling, so a package’s production storage shape is mode-stable — an-stdlibreconvert and a-testsrun emit the same bytes. Conditioning it on-testswould make the banked corpus flip between the two. - The scan is a cheap direct directory read, not a second type-check — no test dependency graph is loaded. It is therefore name-based, and the production pass resolves each candidate against the real package scope, dropping anything that is not a package-level var (a type, a func, an import qualifier, a name that exists only in the test file).
-
It errs toward recording nothing. Names bound anywhere inside the enclosing top-level
declaration — receiver, parameters, results,
:=,var/const/type, range and type-switch bindings — are excluded, so&counteron a local that shadows a global does not box the global. Under-recording restores today’s loud CS0103; over-recording would silently box a global no pointer aliases.
Only build-selected test files are scanned (go/build’s MatchFile, with the run’s GOOS/
GOARCH and -tags), so the boxed set is a property of the build configuration exactly as the
converted production sources themselves are: path_windows_test.go contributes on Windows and
path_unix_test.go does not. That is the same rule siblingTestFuncMethodNames already follows, and
it is the correct answer — a global no selected file addresses needs no box in that configuration.
Measured across the whole standard library by an A/B reconvert: 13 globals in 13 files, and every
single one is a Go “for testing” hook — path/filepath and os’s lstat, os’s
testingForceReadDirLstat and allowReadDirFileID, runtime’s readRandomFailed, useAeshash,
doubleCheckReadMemStats, casgstatusAlwaysTrack, forcegcperiod and timeBeginPeriodRetValue,
reflect’s callGC (whose own comment reads “for testing; see TestCallMethodJump and
TestCallArgLive”), internal/poll’s logInitFD, net/http’s maxWriteWaitBeforeConnReuse and
testHookEnterRoundTrip, and time’s usPacific. No false positives, which is what the
bind-aware exclusion buys — and the same set is forward work, since os, runtime, reflect,
net/http, internal/poll and time all need those hooks to alias real storage before their own
suites can pass.
External (package foo_test) test files are deliberately not scanned: they reach the package only
through its exported surface, and &otherpkg.Var from any other package is a separate, still-open
gap — collectAddressedGlobals only ever scans the package under conversion. (Guarded by the
SiblingTestAddressedGlobal behavioral test, whose export_test.go addresses a bare global, a
global through a field selector, and a global from a function body, against negatives for a
test-file-local declarator and a shadowing local. It is the first behavioral project to carry a
_test.go; the corpus harness skips _test.go when pairing sources with .cs goldens, since a
production transpile never emits one.)
Astral rune literals
A quoted rune literal beyond the BMP ('\U0001D504') cannot be a C# char literal — it emits
the code point ((rune)0x1D504); BMP literals keep their source text verbatim (html’s entity
table, CS1012 ×133). Guarded by StringConvPostfix (glyphs).
Type-switch default arm binds the interface value
The default clause binds the guard to the ORIGINAL guarded expression (var x = err;), whose
static type is the interface — the switch-operand form (err.type()) is object and cannot
flow back out (default: return x, go/build/constraint’s pushNot, CS0266).
The type-switch tag evaluates exactly once
Go evaluates the TypeSwitchGuard’s operand exactly once, but the default-arm and multi-type
re-binds above textually re-emit the tag expression, so a tag containing a call or channel
receive evaluated once at dispatch and again at each matched re-bind arm —
switch p := recover().(type) re-called recover() (which returns nil the second time,
silently losing the recovered value in a case nil, *bailout:-style arm that reads p;
go/types handleBailout), and a switch v := (<-ch).(type) re-received. Such a tag is now
HOISTED into a one-time temporary, and both the dispatch operand and every re-bind read it:
var switchᴛ1 = next(x);
switch (switchᴛ1.type()) {
case @string _:
case bool _: {
var v = switchᴛ1; // re-bind reads the temp — next() ran exactly once
…
default: {
var v = switchᴛ1;
The hoist is deliberately GATED — only a tag containing a call (conversions hoist
conservatively; the temp is merely unneeded) or a receive, and only when some arm actually
re-binds (a bound default, or a multi-type clause with a non-blank ident) — so every pure-tag
type switch keeps its direct, byte-identical emission. The temp name comes from the
per-package getGlobalTempVarName counter (switchᴛN), so nested and sibling hoists never
collide. Single-type concrete labels and the when-guard interface labels bind from the
dispatch operand’s pattern variable and never re-evaluate the tag regardless. (Guarded by the
TypeSwitchImpureTag behavioral test — a counting-function tag whose per-switch eval count is
printed and output-compared vs Go [the pre-fix emission provably prints calls: 7 for Go’s
calls: 4], a recover() tag in a deferred multi-type switch, and a channel-receive tag that
would deadlock on re-receive.)
Generated code global::-qualifies root-namespace references
Inside a package whose namespace nests a same-named segment (go/build/constraint emits into
namespace go.go.build), C# binds a generated reference’s leading go RELATIVELY to go.go
(CS0234). The generators qualify every type-reference position via GlobalQualify
(Common.cs); generated signatures also carry parameter REF KINDS (in slice<byte>) and a
canned System.IFormattable impl where the interface inherits it (the hand-finished io
stub’s dyn machinery).
The converter faces the same go.go shadowing in the import using directives it emits for a
go/* package (go/token lands in namespace go.go, imports sync/unicode sub-namespaces): a
rooted using atomic = go.sync.atomic_package; / using go.sync; binds its leading go to the
enclosing go.go namespace, resolving go.sync to the nonexistent go.go.sync (CS0234). rootQualifyIfAmbiguous routes its rooting returns through rootQualified, which emits global::go.
instead of a bare go. when the package’s namespace second segment is itself go:
using atomic = global::go.sync.atomic_package;
using global::go.sync;
The shadowing is NOT limited to go/* packages themselves: any package with a go/* package
anywhere in its transitive import CLOSURE compiles with namespace go.go in scope (its referenced
assembly makes go.go a member of namespace go), and C#’s inner-to-outer lookup then binds the
bare leading go of a rooted using target to that member from EVERY namespace nested under the
root — internal/fuzz (imports go/ast) emitted using bits = go.math.bits_package; inside
namespace go.@internal, resolving to the nonexistent go.go.math (CS0234 ×16, plus the same
shape in net/rpc’s Δhttp alias and testing/internal/testdeps). rootQualified therefore also
emits global::go. when packageChildNamespaces carries the go.go key (populated from the
transitive import closure by computeImportAliasRenames’ pre-pass). A package with no go/*
anywhere in its closure — every package that was compiling before, and all pre-existing behavioral
tests — keeps the bare go. prefix, so there is no golden churn. Cleared go/token, go/doc/comment,
go/build/constraint (own-namespace branch); internal/fuzz’s 18 CS0234 and net/rpc’s latent pair
(closure branch). Guarded by the GoNamespaceShadow behavioral test, which covers BOTH branches
through a nested local module literally named go/nsshadow (emitting namespace go.go, the shape
a single-file behavioral test cannot express): the nested lib imports math + math/rand so its
own rooted using exercises the own-namespace branch, and the importing main package (namespace
go, with go.go in its closure) exercises the closure branch.
Under -tests the shadow gate spans BOTH compilation halves, and the directly-composed using
targets must go through it too. The gate had two holes that only a test conversion can expose,
and math/rand/v2 (whose regress_test.go imports go/format) hit both — 13 of the package’s 22
compile errors:
-
The closure was computed per PACKAGE, not per ASSEMBLY. A
-testsrun recompiles the package’s PRODUCTION sources into the test assembly, so that assembly’s reference closure is the UNION of the production and_test.goclosures. The production conversion pass saw only its own half, never learnedgo.gowas in scope, and emitted bareusing bits = go.math.bits_package;into a compilation that did containgo.go.collectSiblingTestClosurenow runs a metadata-only (NeedName|NeedImports|NeedDeps) load of the test variants before the production conversion and records their transitive import paths insiblingClosureImportPaths, whichcomputeImportAliasRenamesfolds into the closure it walks — so every consumer of the namespace maps (the shadow gate,rootQualifyIfAmbiguous,isStrippedGoPathPackageRef) describes the assembly rather than the package. The set is empty for every non--testsconversion, so no other output moves. -
Targets composed straight from
packageNamespacebypassedrootQualifiedentirely. Both the package-under-test anchor (visitImportSpec’sisPackageUnderTestbranch, which REPLACES therootQualifyIfAmbiguous-derived target with<packageNamespace>.<pkg>_package) and the test host’susing go.testing_runtime;were bare, which is why one emitted file could show a correctly-qualifiedusing iotest = global::go.testing.iotest_package;beside a brokenusing static go.math.rand.rand_package;.globalQualifyRootedapplies the same gate to an ALREADY-rooted path and both sites now route through it. It is idempotent and a no-op with no shadow, so unshadowed packages emit byte-identically.
Both holes fire for ANY package whose test closure reaches a go/* package, and a regress_test.go
importing go/format is a common stdlib idiom — this is not a v2 quirk. Guarded by
TestGlobalQualifyRootedForcesGlobalUnderRootShadow and TestSiblingClosureContributesRootShadow
(src/go2cs/rootShadowQualification_test.go); the behavioral corpus cannot cover them because it
never runs -tests and no behavioral package imports a go/* package.
A GoImplement record’s adapter key is canonical, not textual
interfaceImplementations is keyed by RENDERED type name, so one resolved pair recorded under two
spellings is two records — and go2cs-gen turns two records into two definitions of the SAME adapter
type. The interface side arrives class-relative when PARSED from a package_info.cs
(rand_package.Source, via loadPackageImplements) and fully namespace-qualified when rendered at
a CAST SITE (go.math.rand.rand_package.Source). canonicalRecordIfaceName stripped only the root
prefix, so the two keyed differently, the foreign-adapter existence proof missed, and the pair was
re-recorded under the second spelling.
Under -tests this is routine rather than exotic: the EXTERNAL (package <name>_test) variant
reaches the package under test through its import path, so it renders that package’s types
qualified, while the seeded production metadata carries them short. math/rand/v2 emitted both
[assembly: GoImplement<PCG, Source>(Pointer = true)] and
[assembly: GoImplement<go.math.rand.rand_package.PCG, go.math.rand.rand_package.Source>(Pointer = true)],
and ImplementGenerator’s GetUniqueHintName silently uniquified the duplicate FILE name — so the
duplicate TYPE reached the compiler as CS0102 + CS0111 ×5 + CS8646 on rand_package.PCGжSource.
(math/rand escapes only by luck: its one self-qualified record targets a different interface than
any short record.)
The adapter’s identity is exactly <class>_package.<Type> — the pair ImplementGenerator composes
its class name from — so the record key collapses a longer chain to its <pkg>_package tail, leaving
a nested type reference (x.y_package.Outer.Inner) untouched. The EMISSION side is normalized to
match: stripLocalTypeQualifier rewrites a reference naming one of THIS package’s own types through
the package’s fully-qualified class back to the bare local form the attribute file’s
using static <ns>.<pkg>_package; resolves, so the two spellings collapse in the emitting
HashSet. Guarded by TestStripLocalTypeQualifier.
(Superseded in the details, 2026-08-02: canonicalRecordIfaceName is retired. Both record sets now
compose one key through implementRecordKey / canonicalImplementRecordIfaceName — same collapse
rule, now shared rather than duplicated. See A foreign implement record is keyed in ONE spelling,
and a VALUE one is trusted only for a partial struct.)
⚠ The collapse only reaches records the CURRENT run rendered — a stale spelling already on disk
slips past it, because package_info_external_test.cs / package_test_info.cs are MERGE-PRESERVING (see
the anchor-routing note above). The merge reads each existing attribute line VERBATIM into the
emitting HashSet, so a record persisted by an OLDER converter — before stripLocalTypeQualifier
reduced it — arrives under the pre-collapse spelling and never meets the fresh, already-collapsed
one. container/heap (banked at package #8, before the collapse landed) committed
[assembly: GoImplement<IntHeap, go.container.heap_package.Interface>(Pointer = true)]; a fresh
-tests run of a NESTED package-under-test now renders that same pair as the bare
[assembly: GoImplement<IntHeap, Interface>(Pointer = true)] (the qualified go.container.heap_package.
prefix gets rootQualifySubNamespaceTypeRefs-rooted then stripped, whereas a TOP-LEVEL package’s
sort_package.Interface is never rooted so it is never stripped and stays byte-stable). The two
spellings both survived the merge → GetUniqueHintName uniquified the second .g.cs → a duplicate
IntHeapжInterface reached the compiler (CS0102 + CS0111 + CS8646). writePackageInfoFile now runs
every merged-in [assembly: GoImplement<…>] line through the SAME qualifyLocalTypeRef pipeline the
fresh render applies, so a stale record collapses into the canonical one instead of duplicating it —
the whole-line pass is safe because the pipeline only rewrites package-qualified name tokens (bare
flag keywords Pointer/Promoted and the assembly/GoImplement scaffolding are untouched), and it
is scoped to GoImplement lines specifically so it cannot rewrite a GoImplicitConv attribute’s
ValueType = "…" keyword (ValueType is a System-colliding name the rooter would otherwise qualify).
Because whole-package conversions (-stdlib, every behavioral test) write with mergeExisting=false
they never take this path, so the corpus and behavioral goldens are byte-identical. Guarded by
TestMergedStaleGoImplementSpellingCollapses.
A type ALIAS is the third spelling, and it is resolved at the SOURCE. The two collapses above
reconcile spellings of one type after they are rendered. An alias cannot be reconciled that way:
type Expr = ast.Expr is a name for a type that already has a name, and go2cs-gen composes the
adapter class from the resolved symbol, never from the record’s text — so a cast site that
composes the class name from the alias spelling names a class the generator never emits (CS0246),
and the pair is additionally recorded twice, once per spelling. convertToInterfaceType therefore
resolves BOTH operands through types.Unalias before composing anything, which is where the
function already reached ad hoc at five later points.
The defect long predates the case that exposed it: any alias whose name differs from its target’s
mismatched the same way, and a package-level type E = ast.Expr would have done it just as well.
It stayed invisible because the only aliases the corpus reached were spelled exactly like their
targets, so the composed name happened to be right. go/types’ rangeStmt declares
type Expr = ast.Expr function-locally, and once function-local type declarations began taking
the enclosing-function lift (rangeStmt_Expr, so two functions never claim one compilation-scoped
global using), check.errorf(lhs[i], …) started composing ast_rangeStmt_Exprᴠpositioner against
the generator’s ast_Exprᴠpositioner — 557 verdicts behind two lines. With the resolution in place
the aliased and unaliased cast sites in that same function land on one adapter and one record.
(Guarded by the LocalTypeAliasScope extension: a function-local type S = fmt.Stringer converted
to a local namer beside the same conversion written through fmt.Stringer directly, so a
spelling-composed name shows up as both a second ᴠ class and a duplicate GoImplement record.)
A test project’s references cover UNROOTED alias targets (single- AND multi-segment)
A -tests project sets DisableTransitiveProjectReferences, so its references are the
direct-import closure plus whatever aliasReferenceImports recovers by scanning the emitted using
aliases for namespace tokens. The scan matched only the ROOTED token (go.hash_package), but a
SINGLE-SEGMENT package emits its alias UNROOTED — using hash = hash_package; inside
namespace go.math.rand, where C#’s outward lookup finds the class in the enclosing root namespace
with no qualifier. math/rand/v2’s chacha8_test.cs needs hash purely because sha256.New()
RETURNS hash.Hash, so the package appears in no import list and only this scan could have found
it: the reference went missing and the build failed CS0246 on hash_package. The scan now also
carries a bare token per single-segment package, matched on a SEGMENT boundary (target == token or
target starts with token + ".") — a substring test would let hash_package match
go.hash.maphash_package and pull in a package nothing references. Guarded by
TestAliasReferenceImportsMatchesUnrootedSingleSegmentAlias and
TestAliasReferenceImportsDoesNotMatchAcrossSegmentBoundaries.
A MULTI-segment package hits the identical gap when the test’s enclosing namespace SHADOWS the
root go. From namespace go.math the alias for os/exec is emitted ROOTED — using exec =
go.os.exec_package; (math/rand’s default_test.cs, caught by the HasSuffix(target, token) arm) —
but from a namespace whose first segment re-binds go, the alias is emitted UNROOTED and relies on
C# outward lookup: go/doc/comment’s std_test.cs (in namespace go.go.doc) and internal/abi’s
abi_test.cs (in namespace go.@internal) both emit using exec = os.exec_package;, again purely
because testenv.Command(…) RETURNS *exec.Cmd so os/exec appears in no import list. The rooted
token (go.os.exec_package) is now ALSO matched when it ends with the unrooted target after a
segment boundary — HasSuffix(token, "." + target), so os.exec_package matches
go.os.exec_package while the leading . anchor keeps os.exec_package from matching an unrelated
go.notos.exec_package. This was the single shared root cause blocking both internal/abi and
go/doc/comment (CS0246 on the os namespace). Guarded by
TestAliasReferenceImportsMatchesUnrootedMultiSegmentAlias and
TestAliasReferenceImportsUnrootedTailAnchoredOnSegmentBoundary.
An emitted CONVERSION RECORD names packages no import list and no alias mentions. A using alias
is not the only line in the test metadata that must BIND: go2cs-gen realizes every
[assembly: GoImplement<…>] / [assembly: GoImplicitConv<…>] record into a generated adapter,
partial or operator, so both generic arguments have to resolve at the attribute itself. The converter
records an interface pair from a type’s use, and that use can be entirely implicit —
os/signal’s test does cmd.Stdout = &buf, whose os/exec field type is io.Writer, so
package_test_info.cs carries
[assembly: GoImplement<strings_package.Builder, io_package.Writer>(Pointer = true)]
while io appears in no import list of the production package or its tests, and in no alias. Under
DisableTransitiveProjectReferences that is CS0246 on io_package at the attribute line, plus a
cascading go2cs-gen CS8785 (ImplementGenerator failed … second generic type argument must be an
interface) once the unbound interface degrades to an error type — the generator then contributes
nothing and the whole package’s adapters vanish. The scan therefore also reads the record lines,
extracting each type reference’s package-class qualifier — everything up to and including the
first segment ending in _package (io_package, go.io.fs_package, go.@internal.abi_package).
That is deliberately the qualifier, not the whole type reference: it has exactly the shape a using
alias TARGET has, so the same three token-match arms above decide both, with no second matcher to
keep in sync. Only the record’s generic argument list is scanned (first < to last >, so a nested
ж<…> argument is covered whole) — an attribute’s (Pointer = true) / (ValueType = "…") payload is
metadata, and the ValueType is a string, not a reference. Additive as before, and the manifest’s
dependency list stays import-derived. This is what lets os/signal validate (its TestCtrlBreak,
1/1 vs go test). Guarded by TestAliasReferenceImportsMatchesConversionRecordPackages,
TestAliasReferenceImportsMatchesConversionRecordQualifierShapes and
TestAliasReferenceImportsIgnoresConversionRecordAttributePayload.
Referencing a go/*-package TYPE loses a root segment because the path’s own go collides with the root namespace. A go/ast type reference renders correctly as go.go.ast_package.X (root go + the path’s go.ast → namespace go.go, class ast_package), but convertToCSTypeName then strips the leading go. as a redundant root (bodies live inside namespace go), leaving go.ast_package.X — namespace go, which has no ast_package (CS0234/CS0426 in the go/* consumers go/doc, go/printer, go/internal/typeparams, whose GoImplement attributes and using aliases both carry the stripped form). The two rooting helpers now recognise this: isStrippedGoPathPackageRef splits the ref at its first _package class segment and tests the namespace portion against packageChildNamespaces (the current package’s rooted import-closure namespaces): the ref is stripped iff that namespace is NOT already a real rooted namespace but becomes one when the root go. is prepended. This is a membership test, not a string-shape test, so it recognises a stripped go/-package ref at any depth — go.ast_package (ns go✗ → go.go✓), go.build.constraint_package (ns go.build✗ → go.go.build✓, three-segment go/build/constraint), go.doc.comment_package (ns go.doc✗ → go.go.doc✓) — while leaving a genuinely-rooted ref alone (go.io.fs_package — ns go.io is already real). (The earlier two-segment string heuristic — “the class segment sits immediately after go.” — recognised only the depth-one go.ast_package shape and silently missed the three-segment go/build/constraint and go/doc/comment sub-package refs, which are string-indistinguishable from a correctly-rooted go.io.fs_package; the membership test is what disambiguates them.) rootQualifySubNamespaceTypeRefs (the assembly-scope GoImplement/GoImplicitConv attributes) re-roots the stripped form to a bare go.go.ast_package; rootQualifyIfAmbiguous (the in-namespace using aliases) re-roots to global::go.go.ast_package — always global::, because a bare go.go.<pkg>_package re-binds its leading go to the nearest enclosing go from *any importer (a go/-package’s own go.go.* namespace, and equally internal/pkgbits at go.internal.pkgbits resolving the second go inside go.go, CS0234). This un-blocks the whole go/ chain at the rooting level (go/doc’s own-errors 17 → 1); each go/* package still needs its remaining per-package residuals (e.g. a methodless-func-type’s [GoTypeAlias] still names an inline-rendered ΔFilter) to fully compile. The depth-one shape is now guarded by GoNamespaceShadow (its go/nsshadow nested module’s import renders through isStrippedGoPathPackageRef → using nsshadow = global::go.go.nsshadow_package;); the multi-segment sub-package depth (go/build/constraint) remains census-verified only — the A/B reconvert-diff showed only the four go/build/constraint- and go/doc/comment-importing packages, go/build, go/doc, go/parser, go/printer, gaining the corrected double-go rooting, with the depth-one go.go.ast_package refs unchanged and zero collateral.
BCL names in generator templates are global::-qualified too — a Go type can shadow any bare BCL
name. The generated partials sit inside the package class, where every Go type in the package is
a sibling member that wins name lookup over System.*: internal/trace/traceviewer declares
type Range struct, so the named-string wrapper’s sub-slice indexer this[Range range] bound the
Go Range instead of System.Range (CS1503 inside its own ViewType.g.cs). This is a class of
collisions, not one bug — any package declaring a type named Range, Index, Type, Span, … is
exposed — so the audit qualified every BCL reference the TypeGenerator templates emit:
global::System.Range (string/slice/array indexers), global::System.Span<T>/ReadOnlySpan<byte>,
the IEnumerator/IEnumerable members, ICloneable, IEquatable and the System.Numerics
operator interfaces on numeric wrappers, System.Type/Reflection.MethodInfo/Activator/
NotImplementedException/[DebuggerNonUserCode] in the dynamic-interface machinery, and the
GeneratedCode attribute stamped on every generated declaration (Common.cs, shared by all
generators). golib names (slice<T>, NilType, IChannel, …) stay bare — they live in the go
namespace the generated code owns. Converter-emitted visible code is not part of this rule (it
renders BCL names by the file-scoped conventions above). (Guarded by BclTypeNameShadow —
a package declaring type Range struct alongside a named string type and a named slice type, both
sub-sliced with the Go Range’s fields as bounds, output vs Go.)
Generic embedded fields
A GENERIC embed (entry[K,V] embedding node[K,V], internal/concurrent) arrives in the AST as
an IndexExpr/IndexListExpr over the base type; the anonymous-field walk unwraps it (plain,
pointer, and selector forms) and the member emits under the base name with type arguments
stripped before the selector dot-strip — the arguments may contain qualified types whose
dots otherwise win the LastIndex (*concurrent.HashTrieMap[T, weak.Pointer[T]] misnamed its
member Pointer instead of HashTrieMap). The TypeGenerator’s promoted accessors carry the
type parameters on the instance param (ref Δentry<K, V> instance) and strip them from the
member access (instance.node.isEntry). A promoted method call through a raw ж box local
hops X.Value ahead of the cross-package pointer-embed hop
(m.Value.HashTrieMap.Value.Load(value), unique). BANKED: unqualified promoted METHOD calls
through a generic embed (w.show()) — receiver wrappers resolve the embedded type by exact
name; qualified calls work. Guarded by GenericStructFields (wrapped[T]/tag[T]) and
CrossPkgUser (holder[T] embedding *CrossPkgLib.Cache[T]).
A func literal in an any slot states its Go result type explicitly
A function literal converted into a real empty-interface parameter has no delegate target
type, so C# natural-types it from its return arms — func(x int) int { return 0 } inferred
Func<nint, int> (the literal 0 is C# int, i.e. Go int32), and the natural type becomes
the value’s runtime dynamic type, which reflection then classifies: func(int) int and
func(int) int32 collapsed to ONE managed type, so quick.CheckEqual saw equal func types
where Go’s differ (testing/quick’s TestFailure #3). The emission states the declared Go result
type explicitly:
CheckEqual(func(x int) int { return 0 }, func(x int) int32 { return 0 }, nil)
CheckEqual(nint (nint x) => 0, int (nint x) => 0, default!);
Scoped to single-result literals in any slots (CallExprContext.emptyInterfaceArgs →
LambdaContext.untypedInterfaceTarget → convFuncLit’s explicit-return-type mechanism);
target-typed positions are untouched — their delegate supplies the type, and an explicit return
type there could only add identity-match constraints against hand-written stub delegate types.
Multi-result any-slot literals kept natural tuple typing until html/template supplied the
consumer that caveat was waiting for (see below). Guarded
by the LiftedLocalTypes behavioral test; operationally by testing/quick’s banked suite.
The same slot is reached through a KEYED COMPOSITE, and there the loss is total rather than
merely imprecise. The argument position above was the first consumer; a map[K]any value, an
any struct field and a sparse-[N]any element are the same empty-interface slot arrived at
through convKeyValueExpr instead of convExprList, and they were not marked. For a literal
with a reachable return the natural type is at least a func type of the right arity, so the
defect only narrowed a result type. For a literal whose body never completes normally there
is no return statement to infer from at all, so C# infers Action and the Go result type is
gone outright:
FuncMap{"die": func() bool { panic("die") }} // text/template exec_test
["die"u8] = bool () => { throw panic("die"); } // was: () => { throw panic("die"); }
The reflection bridge then reports NumOut() == 0 — truthfully, because the datum is missing
from the emission, not from the bridge — and text/template’s own goodFunc rejects a function
Go accepts (“function die has 0 return values; should be 1 or 2”), panicking as the FuncMap is
registered and taking 16 of that package’s 52 verdicts with it. The mark is applied where the
value’s declared slot is already resolved, so all three keyed forms are covered by one predicate;
a slot with a CONCRETE func type (map[string]func() bool) has a delegate target and is
deliberately left exactly as it was. Guarded by untypedInterfaceFuncLit_test.go
(TestUntypedInterfaceFuncLitResultType — the panic-only literal, a normal-return literal, an
any struct field, the MULTI-result arm, and the concrete-slot control), each arm proven
failing-first independently.
The MULTI-result arm has the same owner from the opposite end. The single-result rule above
was scoped for want of a demonstrated consumer; html/template’s escape_test is one. Its
FuncMap{"pred": func(a ...any) (any, error) {…}} renders every arm as a C# tuple carrying a
typeless element — return (i - 1, default!) and return (default!, fmt.Errorf(…)) — so where
the panic-only literal has NO arm to infer from, this has arms that contribute nothing. Neither
fixes a delegate type, and inference fails outright (CS8917, then CS1662/CS8716 on each return).
The declared result tuple is stated explicitly through generateResultSignature, the same helper
the generic-inference arm already used:
["pred"u8] = (any, error) (params ꓸꓸꓸany aʗp) => { … }
Lifted function-local types: anonymous structs dedupe, named types carry [GoLocalName]
C# forbids type declarations in method bodies, so the converter lifts function-local types to package scope under a function-prefixed name. Two Go type-identity rules ride the lift:
-
Structurally identical anonymous struct types are ONE Go type. Repeated textual
occurrences (
new(struct{ A Struct })four times in encoding/binary’s TestSizeStructCache) must lift to a SINGLE C# type — per-occurrence lifts splitreflect.Typeidentity per occurrence, so binary’sstructSizecache gained four entries where Go adds one. Lifted anonymous structs dedupe by structural signature within a scope — a function, and, since 2026-08-18, PACKAGE level within a file: two package vars over one written anonymous struct (internal/reflectlite’sassignableTests/implementsTests, reflect’s ownfuncLookupCache/structLookupCache) are one Go type, and splitting them made the C# types un-unifiable where Go unifies freely —append(assignableTests, implementsTests...)could not type (CS9244 + CS8130 on the range deconstruction). The scope discriminator is explicit (function name, or “” at package level) so the scope-keyed MAP never dedupes across scopes, and NAMED declarations keep per-declaration identity and never dedupe. Cross-scope unification is instead the ADOPTION path’s job: a lift adopts a PACKAGE-LEVEL lift of the same anonymous type rather than minting a second one (so a function-local literal of a package-lifted anonymous struct reuses the package’s type — Go’s anonymous-struct identity is scopeless, and assigning the local to a package var is legal Go needing one C# type — the coordinator ruling at the local-iface-cast × escape-box-copy merge):encoding/xml’sread_test.godeclarestype Child struct{ G struct{ I int } }— liftedChild_G— and then writes the same anonymous type as a composite literal inside a function, and Go assigns one to the other (CS1503 ×6 while they were two C# structs). The package-level registry decides it, keyed by the fulltypes.String()including field tags, which is exactly what Go’s struct identity compares; reuse is one-directional, so no package-level lift is ever renamed. The residuals: cross-FILE splits (both mechanisms are file-ordered — the registry needs the package-level declaration already visited, which declaration order guarantees within one file and nothing guarantees across files), and the adoption path’s ordering generally. (Guarded byTestPackageLevelAnonStructDedup; corpus footprint of the package-level extension measured at exactly one site, reflect’s lookup-cache pair, by seeded whole-stdlib reconvert.) -
A lifted local NAMED type carries its original Go name via the golib
[GoLocalName]attribute — a SEPARATE attribute, never a[GoType]definition token (the TypeGenerator matches that slot by exact string and throws on unknown forms). The reflection bridge’s naming (GoReflect.GoQualifiedName→Type.String(),%T) prefers it, so a local type prints Go’s*binary.Person, never the lifted*binary.TestNoFixedSize_Person(TestNoFixedSize asserts the exact error text). Being read off the runtimeTypeis also what makes the stamp movable, so it is written on thepackage_info.csaccessibility record and the lifted declaration reads as the plain lift it is (Extended attributes):func TestNoFixedSize(t *testing.T) { type Person struct { … }```csharp [GoType(“dyn”)] partial struct TestNoFixedSize_Person {
// package_info.cs [GoLocalName(“Person”)] public partial struct TestNoFixedSize_Person {}
Guarded by the `LiftedLocalTypes` behavioral test (single lifted declaration for repeated
anonymous occurrences + `[GoLocalName]` pinned in the golden); operationally by
encoding/binary's banked suite.
### A lift inside a PACKAGE-LEVEL func literal flushes at package scope, seeded by the declaration
A func literal's body is function scope, and `convFuncLit` sets `inFunction` for it accordingly —
but that flag does **not** say there is an enclosing function DECLARATION. `currentFuncName` and
`currentFuncPrefix` (the lift's name prefix and its declaration sink) are allocated together by
`visitFuncDecl`, so for a literal in a package-level initializer they held whatever the *previous*
function declaration in the file left behind. Every lift site keys on `lifted && inFunction` and
then writes into that prefix, so a type lifted there was named after an unrelated function and
written into a buffer already flushed:
```go
var readers = []struct {
name string
f func(string) io.Reader
}{
{"ReaderOnly", func(s string) io.Reader {
return struct{ io.Reader }{strings.NewReader(s)} // fmt/scan_test.go
}},
}
The declaration vanished, leaving only its use site — new Scan_type(…) named after the
preceding Scan… function, with no such type declared anywhere: CS1729 (no one-argument
constructor), plus CS0103/CS0034 in the ImplementGenerator wrapper generated for the
phantom type from its [assembly: GoImplement] record. With no preceding function declaration
the buffer was nil rather than stale and the converter panicked (nil receiver inside
strings.Builder.copyCheck); that panic is recovered per file, so the whole FILE was skipped with
only a visit file error warning. One root — which symptom appeared depended solely on
declaration order within the file.
A package-level literal now gets its own sink, flushed at package scope, and takes its name
seed from the declaration being initialized (packageInitLiftName, set by visitValueSpec):
[GoType("dyn")] partial struct readersᴛ1 { … } // the OUTER anonymous struct (unchanged)
[GoType("dyn")] partial struct readers_type { // the lift from inside the func literal
public io_package.Reader Reader;
}
Package scope is where a lifted type belongs anyway — it is exactly where the sibling
package-level lift (readersᴛ1) already goes — and the flush lands before the var’s own field
because a package-level initializer is converted to a string first and written afterwards.
Seeding from the declaration is what keeps the name unique per var, as readersᴛ1 already is.
(Guarded by the PackageVarFuncLitTypeLift behavioral test, whose two files cover BOTH symptoms:
main.go places a function declaration before the var — the dropped-declaration form — and
varfirst.go declares the var first — the panic form.)
A methodless named func type renders as its base delegate
Go treats a named func type as freely interconvertible with its underlying func(...) when the
type has no methods — the name is purely documentary. type releaseConn func(error)
(database/sql) and type CancelFunc func() (context) are assigned to and from anonymous
func(...) values without conversion: grabConn returns releaseConn, queryDC takes
func(error), and Go passes one to the other. Emitting the named type as a distinct C#
delegate (ΔreleaseConn) broke this — the base Action<error> its underlying renders to has no
implicit conversion to it (CS1503/CS0029), and the mismatch even excluded the ж-receiver
overload of methods taking such a param, so db.pingDC(...) on a boxed *DB failed with CS1929.
A non-generic named func type with no methods is therefore rendered AS its base C#
delegate (Action/Func<…>) everywhere it is referenced (getAliasQualifiedTypeName/getFullyQualifiedTypeName return
the underlying signature), and its declaration is skipped (visitFuncType emits only a marker
comment). Every named↔underlying conversion becomes identity, exactly as Go models it:
// type releaseConn is a methodless func type — rendered inline as its base delegate
internal static (ж<driverConn>, Action<error>, error) grabConn(this ж<ΔConn> Ꮡc, context.Context _) { … }
internal static error queryDC(this ref DB db, …, Action<error> release, …) { … }
Three exclusions keep the collapse sound — a type is left as a named delegate if any holds:
- it has methods (its method set is meaningful — the
FirstClassFunctions/hashFuncwrap case below still applies); - it is generic (it is referenced as
Seq<V>, and the type parameter must stay in scope — see the generic-Seqrange-over-func case); - its signature references another named func type, including itself. A self-referential func
type —
type stateFn func(*machine) stateFn(a Go state machine,NamedFuncTypeStateMachine) — has no finite base-delegate form (Func<M, Func<M, …>>is infinite); and a reference to another named func type (strategy func(score) action) would leave that name undefined after collapse. Only the leaves of the func-type reference graph collapse; a referencing type stays named and renders the collapsed leaf inside its own signature.
Because the collapse applies at both the declaration and every reference, and to foreign types too
(context’s CancelFunc collapses in context’s own conversion, so database/sql sees Action),
consistency holds across packages. One position needed a companion fix: a variadic ...Option
element is package-class-qualified (main_package.Option) for a package-local named type, which
would mangle a collapsed delegate to main_package.Action (CS0426) — variadicElementType now
skips the qualifier when the element collapsed. Cleared 13 of database/sql’s 17 errors (the whole
named-func family + the CS1929 it masked). Guarded by MethodlessFuncType (a function returning
the named type, one taking the anonymous underlying, a struct field, and a tuple-deconstruction
seam across the two); regression-checked against the self-referential (NamedFuncTypeStateMachine,
unchanged), nested-reference (FirstClassFunctions), and variadic-param (PublicizedFuncTypeParam)
cases.
A collapsed methodless func type must NOT export a [GoTypeAlias]. When such a type is also
collision-renamed — type Filter func(...) alongside a method Filter (go/ast’s Filter vs
(CommentMap).Filter; the ReservedTypeMethodCollision shape) — the rename records an exported
[assembly: GoTypeAlias("Filter", "ΔFilter")] so consumers can name the renamed type. But because
the type collapses to its base delegate, no <pkg>_package.ΔFilter type is ever emitted — so a
consumer that loads the alias generates global using astꓸFilter = go.go.ast_package.ΔFilter;
naming a nonexistent type (go/doc referencing ast.Filter, CS0426). visitFuncType now records
each collapsed methodless func type’s name in packageInlineFuncTypeNames, and the exported-type-
alias emission skips any alias whose key or value matches (the collision path stores the alias
under the renamed value ΔFilter, the plain path under the raw name) — so the alias is never
exported and the consumer renders ast.Filter inline as Func<nint, bool> through the normal
collapse. (Guarded by the CrossPkgUser extension — a cross-package CrossPkgLib.Sift methodless
func type colliding with a Sift method, named as a var type and rendered inline, output vs Go; and
by ReservedTypeMethodCollision whose [GoTypeAlias] is now correctly absent.)
When such a collapsed delegate’s signature carries a parameter whose type lives in a sub-package
(an import path with a slash), the Func<…>/Action<…> rendering must qualify that type as the
package class, not the namespace. The collapsed signature is produced from the Go signature’s
t.String(), which keeps the canonical import PATH inline — func(*sync/atomic.Int32) int32,
func(string, io/fs.DirEntry, error) error (path/filepath’s WalkDirFunc) — losing the file’s import
alias. convertToCSFullTypeName converted the whole slash-bearing string as one import path, dotting
the type straight into the namespace: sync.atomic.Int32 / io.fs.DirEntry — CS0234, since atomic
is not a namespace of go.sync (the type lives in class atomic_package). It now splits the trailing
.TypeName off at the first . after the last path /, converts the package path with the class
suffix, and re-appends: sync.atomic_package.Int32, io.fs_package.DirEntry. The suffix is only added
when the path segment does not already carry it — some callers (a recorded [GoType] underlying,
sync/atomic_package.Uint32) hand a pre-suffixed path, which would otherwise double to
atomic_package_package (a DefinedTypeOverPkgType regression, caught and gated). The behavioral
corpus is byte-identical except the intended change, and an A/B reconvert of net+go/types (same package
set) is byte-identical — only the func-type-subpackage-param shape moves. (Guarded by the
SubpackageFuncTypeParam behavioral test — a methodless applyFunc func(*atomic.Int32) int32 whose
collapsed delegate carries the sync/atomic sub-package parameter, output-compared vs Go; the same
shape drives path/filepath’s WalkDir/Walk referencing io/fs.DirEntry/FileInfo.)
A collapsed func type’s parameter list must not be double-converted. convertToCSFullTypeName’s
func( handler split the parameter string with extractTypes, then re-ran convertToCSTypeName over each
result — but extractTypes already renders a NAMED parameter in C# form (it strips the Go name and converts
the type). Re-feeding an already-C# map<@string, ж<Object>> through the map< arm’s splitMapKeyValue
mis-parsed it into map<@string, ж<Object>, > — a spurious trailing empty type arg (CS1031 “Type expected”,
go/ast’s NewPackage taking type Importer func(imports map[string]*Object, path string) (…)). The fix makes
extractTypes always return C#-form (the bare-type/unnamed branch now converts in place too, matching the
named branch), and the caller trusts that output directly instead of a second pass. This is byte-identical
everywhere except named-parameter func types — bare-type func types (func(int, string)) were already
converted once and stay so, just at the extractTypes site rather than the caller. (Guarded by the
NamedFuncTypeMapParam behavioral test — type Importer func(imports map[string]*Node, path string) (pkg
*Node, err error) used as a function parameter, output-compared vs Go; CNR byte-identical across the corpus,
and an A/B reconvert of go/ast shows only that one collapsed-delegate parameter shape moving.)
A collapsed methodless named func type’s DELEGATE TYPE renders through iifeDelegateType, not the string
path. The double-conversion fix above kept the collapsed delegate on convertToCSFullTypeName’s func(
string handler — but that string domain naively slash→dots a cross-package element’s import PATH. go/doc
passes simpleImporter to ast.NewPackage (whose importer is ast.Importer, a methodless func type), so
the converter wraps the method group in the collapsed base delegate
new Func<map<@string, ж<go.ast.Object>>, @string, (ж<go.ast.Object> pkg, error err)>(simpleImporter) —
go/ast.Object mangled to go.ast.Object (no _package class, no file alias), so ast is not a namespace
of go (CS0234 ×2), and the resulting error-typed delegate then fails the method-group→delegate conversion
(CS0123). getCSharpTypeName now routes a methodless named func type through the SAME structural
iifeDelegateType path an ANONYMOUS signature already takes (that path exists precisely because the string
path mangles slash-bearing package paths), naming each element via aliasedElementTypeName — so the
cross-package ast.Object keeps its ast alias (and a Δ-renamed foreign element its recorded ꓸ-alias):
new Func<map<@string, ж<ast.Object>>, @string, (ж<ast.Object>, error)>(simpleImporter). The only visible
change for a SAME-package/single-segment element is that a multi-result signature’s delegate type drops its
Go result-tuple element NAMES ((ж<Node> pkg, error err) → (ж<Node>, error)), matching how anonymous
signatures already render — cosmetic, both compile. An A/B full-stdlib reconvert moves 11 files, all
equal-or-better: the go/doc mangle fixed, plus go/parser/go/scanner (go.token_package.ΔPosition →
tokenꓸPosition), go/internal/gccgoimporter (a malformed (io.ReadCloser>, error) → valid),
internal/trace/traceviewer (net.http_package.Request → http.Request), and path/filepath
(io.fs_package.DirEntry → fs.DirEntry) all cleaned up, with bufio/go/ast/nettest only dropping
cosmetic tuple names; CNR touches only three existing goldens (NamedFuncTypeMapParam,
SubpackageFuncTypeParam, FirstClassFunctions), all the same pattern. (Guarded by the CrossPkgUser
extension — a package-level simpleResolve passed as a METHOD GROUP to CrossPkgLib.Resolve, whose
Resolver is a methodless func type naming the cross-package *CrossPkgLib.Node, so the wrapped delegate
renders ж<CrossPkgLib.Node> via the alias, output-compared vs Go. The single-segment producer compiles
either way, so the byte-golden — unnamed vs Go-named result tuple — is what guards the routing; the exact
slash-bearing CS0234/CS0123 needs a multi-segment producer like go/ast, verified by the go/doc source A/B.
go/doc’s own remaining block is the SHARED generated-adapter forwarding of go/ast’s unexported interface
marker methods — a separate root.)
A companion root cleared path/filepath fully: a cross-package type ALIAS whose target lives in yet
another package — os.FileInfo = fs.FileInfo (os/types.go, target in io/fs) — is emitted as an
assembly-scoped global using FileInfo = go.io.fs_package.FileInfo; in os’s own conversion, never as
a member of the os package’s C# class, so a cross-package reference os_package.FileInfo does not resolve
(CS0426, path/filepath’s lstat = os.Lstat func value). getAliasQualifiedTypeName now renders such an alias as its
target — os.FileInfo → fs.FileInfo (→ io.fs_package.FileInfo via the file’s fs using). Gated
to a different-package target: an alias to a SAME-package type (CrossPkgLib.Temperature = Celsius)
already resolves through the existing ꓸ global-using alias (CrossPkgLibꓸTemperature) and is left
untouched — narrowing here reverted a CrossPkgUser churn the blanket form caused. CNR byte-identical;
an A/B of os+io/ioutil (same package set) shows only that one intended resolution (io/ioutil’s ReadDir
sort lambda moved osꓸFileInfo → fs.FileInfo, matching the file’s other fs.FileInfo refs — still
compiles). GUARD OWED — the shape needs three packages (B declares Y, A aliases type X = B.Y, C
references A.X), which neither the single-package baseline nor the 2-package CrossPkg harness
expresses; validated by the core/path/filepath build (1→0) + io/ioutil build.)
A func type renders structurally in EVERY type-name path — the signature never stringifies.
getAliasQualifiedTypeName now carries a *types.Signature arm (signatureTypeName, beside iifeDelegateType)
mirroring the slice/map/chan composite arms: Go syntax — func(name type, …) results — with every
parameter/result type resolved recursively, so a cross-package element keeps the file’s short
import alias exactly like the neighboring map/slice fields. Previously only some positions routed
through the structural iifeDelegateType (var declarations; variadic or slash-bearing struct
fields); every other position — a struct field of a named methodless func type (go/importer’s
importer gccgoimporter.Importer), a MAP field’s func value type (net/http’s TLSNextProto
maps), a same-package named func field (traceviewer’s f MutatorUtilFunc) — reached
convertToCSFullTypeName as t.String() text with import PATHS inline, and the slash heuristics
mangled those one of three ways depending on the string’s shape: the whole-string
path-conversion arm fires when no dot-after-slash precedes the first [ (a leading map[ bracket),
naively dotting every path — ж<go.types.Package>, ж<crypto.tls.Conn> (no _package class, and
under a go.go-nested namespace the leading segment binds the child namespace — CS0234) — while the
split-at-dot arm mangles a mid-signature path to a classed-but-unrooted form
(@internal.trace_package.UtilFlags, traceviewer mmu.cs). With the structural arm the string
reaching the parser is slash-free (func(*Server, *tls.Conn, Handler)) and each element converts
through the normal alias route. Result NAMES are preserved, so a named multi-result field keeps its
named C# tuple (the display-path advantage the old struct-field routing existed to protect); a
same-package/builtin signature renders byte-identically to the old t.String() path (zero churn —
CNR confirmed across all 331 behavioral projects). A variadic tail renders ...elem (which the old
path’s ..-strip reduced to the unparseable .elem) and lowers through the parser to the golib
ꓸꓸꓸ delegate family (next paragraph). One side effect: element recursion passes through
getAliasQualifiedTypeName’s foreign-ALIAS arm, so a signature naming a cross-package alias (os.FileInfo →
io/fs.FileInfo) now registers the target’s package for a file-local using — a few stdlib files
gain a benign using fs = …; alias line (collectTypePackages’ Named case does not match a
*types.Alias, so the old path never registered it). Whole-stdlib A/B footprint: 20 files — the
go/importer field fixed, ж<tls.Conn> in net/http server/transport/h2_bundle (field + composite
literals), traceviewer’s Func<trace.UtilFlags, (slice<slice<trace.MutatorUtil>>, error)>,
go/scanner’s err field moving to the canonical tokenꓸPosition alias (the old
go.token_package.ΔPosition resolved only by go.go-namespace luck), the variadic type-assert
target below, one comment-alignment shift, and the benign using-line additions. Cleared the
IMP-2/HTTP-3 CS0234 cluster (net/http ×8 + go/importer ×6 + traceviewer). (Guarded by the
SynthesizedDelegateChildPkg behavioral test — a nested CHILD subpackage (slash-bearing import
path) whose *inner.Record rides a named methodless func-type field with a nested-tuple lookup
param AND a map[string]func(*inner.Record, string) field, both invoked at runtime vs Go.)
The func-type string parser splits parameters at TOP-LEVEL commas only — and a variadic tail
lowers to the ꓸꓸꓸ delegate family. extractTypes split the parameter list with a naive
strings.Split(signature, ","), so a nested func param returning a TUPLE — lookup func(string)
(io.ReadCloser, error) (go/internal/gccgoimporter’s Importer, surfacing as go/importer’s
gccgoimports.importer field) — shredded at the tuple’s interior comma, unbalancing the assembled
delegate: Func<@string, (io.ReadCloser>, error) (the inner > closes before the tuple’s second
element — a 6-error syntax cascade, IMP-1). splitTopLevelParams tracks <>/()/[]/{} depth
(with the channel-arrow <- guard splitMapKeyValue already carries) and splits only at depth 0.
On top of that, a variadic tail (...elem, from the structural render above) converts its ELEMENT
type in extractTypes and carries an ellipsis-family marker that the func( assembler hoists into
the delegate FAMILY name — Actionꓸꓸꓸ<@string, any> — mirroring iifeDelegateType’s lowering
exactly. That fixed the variadic func type as a type-ASSERTION target as a rider:
.(func(string, ...any)) (net/http transport.go’s tLogKey logger) previously emitted the
unparseable ._<Action<@string, .any>>(ᐧ) and now renders ._<Actionꓸꓸꓸ<@string, any>>(ᐧ).
(Guarded by the FuncFieldNestedTupleParam behavioral test — builtin-typed struct fields with
nested-func-returning-tuple params in both the anonymous and named-collapse forms plus a
named-tuple-result sibling, all invoked at runtime vs Go.)
A type ASSERTION whose target is a methodless func type must assert against the collapsed
delegate, not the (never-emitted) name. ci.(Compressor) where
type Compressor func(io.Writer) (io.WriteCloser, error) (archive/zip’s compressor/decompressor
registries) rendered ci._<Compressor>() — convTypeAssertExpr converts the target via convExpr,
which emits the bare ident, and after collapse Compressor is undefined (CS0246). When the asserted
target is a methodless named func type, the assertion now renders its getCSharpTypeName (the collapsed
Func<…>): ci._<Func<io.Writer, (io.WriteCloser, error)>>() — matching how the stored value was
emitted (a collapsed delegate). Other assertion targets are unchanged. (Guarded by the
MethodlessFuncTypeAssert behavioral test — i.(Compressor) on a matching and a non-matching dynamic
type, output-compared vs Go; CNR byte-identical and an A/B of archive/zip shows only the two intended
_<Compressor>/_<Decompressor> → _<Func<…>> lines.)
An UNINITIALIZED local var of a methodless named func type renders its declared type through the
same structural path. visitValueSpec’s no-initializer branch computed the type from
convertToCSTypeName(getAliasQualifiedTypeName(...)) (the string path) and only re-routed a bare anonymous
*types.Signature through getCSharpTypeName; a methodless NAMED func type is a *types.Named, so it kept
the string render — and that render mangles a slash-bearing cross-package element. go/parser’s parseDecl
declares var f parseSpecFunction
(type parseSpecFunction func(doc *ast.CommentGroup, keyword token.Token, iota int) ast.Spec), which
emitted Func<ж<go.ast.CommentGroup>, go.token.Token, nint, go.ast_package.Spec> f = default!; — the
go.ast/go.token elements re-root to the nonexistent go.go.ast/go.go.token (CS0234), and the
declared delegate then mismatched the lambdas assigned to f and the parseGenDecl(keyword, f) parameter,
which render the SAME Go types structurally as ast.CommentGroup/token.Token (CS1661/CS1678/CS1503 — 12
errors, all this one declaration). The no-initializer branch now routes a func-typed var (anonymous
signature OR methodless named func, via methodlessNamedFuncSignature) through getCSharpTypeName →
iifeDelegateType, whose aliasedElementTypeName keeps each element’s pkg.Type alias:
Func<ж<ast.CommentGroup>, token.Token, nint, ast.Spec> f = default!;. This precedence matches
getCSharpTypeName’s own — the func render wins over the foreign-alias route (which for a methodless named
func would point at the SKIPPED delegate declaration); a non-func foreign-renamed local keeps its alias
unchanged. An A/B full-stdlib reconvert moves exactly one file (go/parser/parser.cs), greening go.parser
outright. (Guarded by the MethodlessFuncType extension — an uninitialized var find lookup where
type lookup func(string) (path string, ok bool); the byte-golden captures the structural render
Func<@string, (@string, bool)> — dropping the Go result NAMES the string path keeps — output-compared vs
Go. As with the delegate-routing sibling above, a single-segment/same-package producer compiles either way,
so the unnamed-vs-Go-named result tuple is what guards the routing; the exact slash-bearing CS0234 needs a
multi-segment producer like go/ast, verified by the go/parser source A/B.)
Named delegate types wrap mismatched initializers
A NAMED func-type field initialized with a value of a DIFFERENT delegate type has no implicit
C# conversion: internal/concurrent’s keyHash: mapType.Hasher feeds a hashFunc field from a
Func<…> field. The composite-literal walk resolves each element’s field BY NAME (keyed-aware)
and wraps mismatched delegate values in the target delegate’s constructor —
keyHash: new hashFunc((~mapType).Hasher) (the wrap splits a C# named-argument label first).
FuncLit and nil initializers stay bare. Guarded by FirstClassFunctions
(handler/provider/registry).
A named delegate value passed to a structural func parameter re-wraps
The MIRROR of the argument-position named-delegate wrap: a structural (written-anonymous) func
parameter receiving a value of a named delegate type — net/http h2_bundle’s
sc.scheduleHandler(…, handler), where handler is HandlerFunc and the parameter is
func(ResponseWriter, *Request) (CS1503). Go converts named→structural implicitly; C# needs the
same delegate re-wrap, targeting the synthesized structural delegate:
type Handler func(int, string) string // has a method → distinct C# delegate
func invoke(f func(int, string) string, n int, s string) string { return f(n, s) }
var h Handler = describe
invoke(h, 1, "a")
invoke(new Func<nint, @string, @string>(h), 1, "a"u8);
Two argument shapes render named and take the wrap: a value whose Go type is a named func type
(with methods), and a := local declared from a method group, which the declaration emission
types with the matching package named delegate (HandlerFunc handler = Ꮡsc.Value.handler.ServeHTTP;
— the bare-function-value := rule above) even though go/types keeps it structural — the exact
h2_bundle shape. A methodless named func type already renders as the structural delegate
(methodlessNamedFuncSignature collapses it — same C# type), so it stays bare; method groups and
func literals themselves convert natively. A generic structural parameter (unsubstituted type
params) also stays native. (Guarded by the NamedDelegateStructuralParam behavioral test —
named-with-method and method-group-declared locals wrapped, methodless/method-group/func-literal
controls bare, values vs Go.)
The same mirror applies to a composite-literal FIELD (2026-07-17; sort’s test-suite
conversion): the composite walk previously wrapped only the named-field ← different-delegate
direction, so GOROOT sort example_keys_test’s planetSorter{planets: planets, by: by} — a By
value (named, with a Sort method) initializing the written structural field by func(p1, p2
*Planet) bool — emitted the bare by: by against the Func<ж<Planet>, ж<Planet>, bool>
constructor parameter (CS1503; the Phase-4 blocker-map row B10b). The structural-field arm now
applies the identical named-rendering test and wrap: by: new Func<ж<Planet>, ж<Planet>,
bool>(by). Method groups, func literals, and nil stay bare, and generic fields stay native, as
at call sites. (Guarded by the NamedFuncTypeStructuralField behavioral test — the By-with-method
sorter pattern wrapped, a method-group field initializer control bare, values vs Go.)
Func-typed fields with a cross-package (slash-path) type render structurally
A func-typed struct field whose signature names a type from a multi-segment import path —
testing/quick’s Config.Values func([]reflect.Value, *rand.Rand), where rand is math/rand —
must render as a structural Action/Func<…> delegate via getCSharpTypeName, not through the
string display path. The display path stringifies the signature as func([]reflect.Value,
*math/rand.Rand) and splits the slash-bearing import path on /, emitting the dotted
math.rand.Rand; but math aliases to math_package, so math.rand resolves to the nonexistent
math_package.rand (CS0426). The structural renderer recurses per signature element and qualifies
each named type by its package name:
type Config struct {
Values func([]reflect.Value, *rand.Rand) // rand is math/rand
}
public Action<slice<reflectꓸValue>, ж<rand.Rand>> Values;
The re-routing is gated on the signature string containing / or the signature being variadic:
the string path cannot render a variadic signature at all — getAliasQualifiedTypeName’s .. strip reduces the
ellipsis of go/build’s JoinPath func(elem ...string) string (Context, build.go:84) to .string,
emitting the unparseable Func<.@string, @string> (CS1031 + CS1003 ×2, all three go.build errors),
and even unstripped it has no variadic lowering. Structurally such a field renders the golib
variadic delegate family (public Funcꓸꓸꓸ<@string, @string> JoinPath; — see the variadic-lowering
section below), which loose-arg, empty and spread calls through the field all bind. Every other
func field keeps the display path: func(string) (importPath string, ok bool) preserves its named
tuple elements that the structural renderer drops. (Guarded by the FuncTypeParam behavioral
test’s runner.gen field, and by VariadicFuncFields — a struct with variadic func-typed fields
assigned from a named func and func literals, called loose/empty/spread — for the variadic arm.)
A variadic func type lowers to the golib Actionꓸꓸꓸ/Funcꓸꓸꓸ delegates
A variadic function TYPE used as a value — a parameter, variable, struct field, or collapsed
methodless named type such as go/types’ reportf func(format string, args ...interface{}) — used
to have three mutually incompatible lowerings: the delegate type rendered Action<@string,
slice<any>> (no params — the BCL Action cannot express one), a variadic func LITERAL emitted
the named-function convention (@string format, params ꓸꓸꓸany argsʗp) => … (CS1661/CS1678
against that Action), and calls through the value passed loose Go-style args as if params
existed (reportf("…"u8, (~f).typ) — CS1503; reportf("empty type set"u8) — CS7036).
The lowering now targets a golib delegate family carrying a real C# 13 params Span<T> tail
(src/core/golib/variadic.cs; fixed-arity prefixes up to eight mirror the BCL Action/Func family,
and the ꓸꓸꓸ suffix reads as Go’s ...):
public delegate void Actionꓸꓸꓸ<T1, TArg>(T1 arg1, params Span<TArg> args);
public delegate TResult Funcꓸꓸꓸ<T1, TArg, out TResult>(T1 arg1, params Span<TArg> args);
iifeDelegateType — the single structural lowering every getCSharpTypeName(*types.Signature) and
collapsed methodless named func type routes through — names the family when sig.Variadic() and
passes the variadic element type as the last type argument. Everything else then agrees with
zero changes to the other emissions, because the parameter types match by identity
(ꓸꓸꓸT is Span<T>):
- the named-function convention (
internal static @string gather(@string prefix, params ꓸꓸꓸnint valsʗp)) converts as a method group —apply(gather)stays bare; - a variadic func literal (
(@string prefix, params ꓸꓸꓸnint valsʗp) => …, C# 13 params lambda) converts natively — go/types’comparable(typ, true, default!, (@string format, params ꓸꓸꓸany argsʗp) => {…})now binds itsActionꓸꓸꓸ<@string, any>parameter; - calls through the value pass loose args or an empty tail via C#
paramsexpansion, and a Go spread (f(nums...)) binds the slice’s.ꓸꓸꓸSpan in normal form; - a C# consumer calls a transpiled printf-style callback naturally (
ctx.Logf("…", a, b)) — the library use case that ruled out the pack-into-a-slice<T>alternative.
A :=-declared variadic func literal is untouched: it keeps C#’s natural (params-capable) lambda
type under var (the VariadicClosureSpread shape). One deliberate residue: defer/goǃ of a
call through a variadic func value would need to capture the Span tail, which a ref struct
cannot be — pack into a slice at such a site. ⚠ That residue now has its one demonstrated
consumer, and it is a TEST file (found 2026-08-19, lane claude/variadic-call): the census of
the whole Go 1.23 tree finds exactly ONE defer/go of a variadic func literal —
html/template/examplefiles_test.go:90, defer func(dirs ...string){…}(dir1, dir2) — which emits
defer((params ꓸꓸꓸstring dirsʗp) => {…}, dir1, dir2, ref ᒐ) and fails inference against
builtin.defer<T1,T2>(Action<T1,T2>, T1, T2, ref GoFrame): CS0411. That is why the claim used
to read “no stdlib occurrence” — the original A/B was over PRODUCTION sources, and the shape lives
only in a _test.go, so nothing before the Phase-4 -tests pipeline could see it. It is one of the
two roots now blocking html/template’s 243 verdicts. Full-stdlib
A/B footprint: go/types predicates.cs/expr.cs plus every file that renders a variadic func type
structurally (inspected file-by-file at introduction). (Guarded by VariadicFuncValues — a named
func AND a func literal satisfying a variadic func-typed param, loose/empty/spread calls through
it, and a nil-compared variadic func-typed var — output-compared vs Go.)
A type-ASSERTION target routes through the same structural lowering. convTypeAssertExpr rendered
the asserted type by converting the TYPE EXPRESSION through the string-based type-name path, which
skips the variadic lowering above — net/http transport.go’s
cw.(func(string, ...any)) emitted ._<Action<@string, .any>>(ᐧ) with a literal .any (CS1001, the
... mangled instead of lowered). An anonymous-signature assert target now renders through
getCSharpTypeName → iifeDelegateType, exactly like the collapsed methodless NAMED func target already
did: ._<Actionꓸꓸꓸ<@string, any>>(ᐧ). Non-variadic signatures render identically on both paths, so the
only full-stdlib delta is the transport.cs site. (Guarded by VariadicFuncTypeAssert — a positive
variadic assert invoked through the asserted value, a negative assert on a non-func value, and a
non-variadic anonymous func assert, output-compared vs Go.)
…and the BOXING side needs the matching cast, or the two can never meet (2026-08-20). Rendering
the assert target through iifeDelegateType fixes the reading half; the writing half is where the
value acquires a dynamic type, and for a variadic func that type is C#’s, not Go’s. C# gives a method
group or lambda at an untyped destination a natural function type: for a non-variadic signature
that is Func<…>/Action<…> — go2cs’s own lowering, so the two already agree and nothing is emitted
— but a params signature has no BCL delegate, so C# synthesizes one and the box carries
<>f__AnonymousDelegate0 forever. html/template’s funcMap is map[string]any of func(...any)
string escapers assigned as method groups, and its own TestRedundantFuncs reads them back with
funcMap[n].(func(...any) string): interface conversion: interface {} is <>f__AnonymousDelegate0,
not go.Funcꓸꓸꓸ<object, @string>. The assert was right, the box was wrong, and both were emitted by
the same converter.
So a variadic func entering EMPTY-INTERFACE space is cast to its Go func type at the boundary —
((Funcꓸꓸꓸ<any, @string>)(attrEscaper)) — which is the same carry-your-Go-type rule the pointer box
and the untyped-constant box already apply at that same finite set of slots, and it lives with them in
typedNilInterfaceBoxing.go. Both sides now name the type through getCSharpTypeName →
iifeDelegateType, one renderer, so they cannot drift. The cast is a no-op wherever the value already
has that type (a typed var, a call result), so it widens nothing; a NON-empty interface target needs
nothing either, since a bare func type has no methods and satisfies no other Go interface. (Guarded by
the extension to VariadicFuncTypeAssert — a variadic func literal direct to any, a variadic method
group as a map[string]any element, through a plain assignment, and as an []any{…} element, each
asserted back; plus a NON-variadic literal direct to any as the control that must keep matching
without a cast. Neutering the cast prints no match on all four and leaves the control passing.)
A variadic METHOD VALUE was the one shape in the family still frozen at fixed arity (2026-08-26).
errorf := t.Errorf — go/types’ and slices’ own idiom, errorf = t.Logf one statement later, then
loose Go-style calls — has TWO emissions, and both dropped the variadic tail. A bound method value
forwards through a lambda carrying the method’s own parameters, and that lambda rendered the tail as
the plain slice<T> the signature stores rather than the params ꓸꓸꓸT convention every declared
variadic function uses: (@string p1, slice<any> p2) => Ꮡt.Errorf(p1, p2). Every call through the
value was then an arity error — errorf("…", n) CS1503 on a bare n against slice<any>,
errorf("…", a, b) CS1593 “does not take 3 arguments”, errorf("…") CS7036 — which is the same
family the lambda’s explicit parameters were introduced to fix, one level in. The tail now renders
through variadicParamType, the same routine the named-function convention uses (a file-local
using ꓸꓸꓸT = Span<…>; alias where one is mintable, inline Span<T> otherwise), so the forwarded
argument binds the receiving params ꓸꓸꓸany parameter directly and the call inside the lambda is
unchanged.
The DECLARATION is the second half, and it is not optional. A params lambda has no BCL delegate, so
var gives it a synthesized natural type — which binds that lambda and gives C# no reason to hand
the same type to the second lambda the reassignment installs. visitAssignStmt’s method-group branch
therefore names golib’s variadic delegate family when the signature is variadic and no package named
func type matches — Actionꓸꓸꓸ<@string, any> emit = (@string p1, params ꓸꓸꓸany p2) => … — reusing
iifeDelegateType, the same lowering getCSharpTypeName already gives every func type used as a
value, so there is exactly one spelling of this type in the emission. Non-variadic method values keep
var, unchanged. (Guarded by the VariadicFuncValues extension — a pointer receiver’s variadic
method bound by :=, conditionally reassigned to a second variadic method, then called with loose
args, an empty tail and a spread; it fails on the pre-change converter with CS1503 + CS1593 + CS7036,
which is exactly the slices TestGrow/TestConcat error set.)
A/B footprint: this is the half of the arc that moves anything outside its own guard, and it moves
two lines. CNR at 645 behavioral packages reports DeferCallOrder and GoCallVariations, both
f1 := fmt.Println — a variadic PACKAGE function bound as a method value, which was the same
var-inferred synthesized delegate and is now Funcꓸꓸꓸ<any, (nint, error)>. Both still compile and
still match go run. The whole converted standard library re-emits byte-identically (4,173 artifacts,
0 changed), because the rule fires on nothing else: a method value whose signature is not variadic
never reaches it.
reflect.Value.Call over a variadic func value is TYPED dispatch — no reflective invoke can carry the tail
The params Span<T> tail above is what makes a converted variadic callable and readable from Go
AND from C#. It also puts the value permanently out of reach of every reflective invoke path:
Span<T> is a ref struct, and Delegate.DynamicInvoke and MethodInfo.Invoke both marshal
their arguments through an object?[] a ref struct cannot enter. System.Linq.Expressions
refuses one outright as well, so the method-value binder’s Expression.Lambda approach
(GoReflect.MethodSets.cs) does not generalize either. reflect.Value.Call therefore threw
NotImplementedException for every variadic func value — which is 13 of text/template’s 52
verdicts, since its whole FuncMap feature calls user functions exactly that way.
The call is made in typed code instead (GoReflect.InvokeVariadic, GoReflect.TypeLayout.cs).
One small generic trampoline per family arity — eighteen, the closed set golib’s variadic.cs
declares — is closed over the delegate’s own parameter types by MakeGenericMethod and cached as
an ordinary delegate, the elementBoxViaAt idiom GoReflect.FieldAccess.cs already uses:
private static object? callVariadicFunc1<T1, TArg, TResult>(Delegate d, object?[] a, Array t)
{ return ((Funcꓸꓸꓸ<T1, TArg, TResult>)d)((T1)a[0]!, new Span<TArg>((TArg[])t)); }
Inside the trampoline the tail is a TArg[] and its conversion to Span<TArg> is ordinary, so
nothing is boxed and the tail ALIASES the array rather than copying it. Two consequences worth
stating: a panic inside the callee propagates natively (a direct call wraps nothing in a
TargetInvocationException, unlike the fixed-arity DynamicInvoke path beside it), and a fixed
prefix beyond the family’s eight throws a named NotImplementedException rather than mis-indexing.
The delegate being called is not always the family type, and the rebind is what makes that
total. A variadic func literal in an any slot — a map[string]any FuncMap value, the exact
text/template shape — takes C#’s NATURAL delegate type instead, the same identity difference
TryFuncShape had to stop reading off the type NAME. Those rebind onto the family by RETARGETING
through Invoke (Delegate.CreateDelegate(familyType, del, "Invoke")), never by re-binding the
original’s own target and method: a delegate the BRIDGE itself built is expression-compiled — a
variadic method value from Value.Method is exactly that — and a compiled lambda’s Method is not
a runtime MethodInfo, which CreateDelegate rejects with “MethodInfo must be a runtime MethodInfo
object”. Retargeting also carries a multicast invocation list intact. The family’s type arguments
are built FROM the delegate’s own Invoke signature, so the two agree by construction.
Go’s Call contract shapes the arity rule too: Call itself builds the tail slice (CallSlice is
the form that takes it pre-built), so the last In() is the tail SLICE, every argument from that
position on is assignable to its ELEMENT, and there is no upper bound — only a lower one of
NumIn()-1. (Guarded two ways: behavioral ReflectVariadicCall output-compares eleven shapes
against go run — declared func, empty tail, no fixed params, ...any, multi-return, no-result,
two fixed params, a variadic METHOD value, and three map[string]any literals — while
GoReflectBridgeClosureTests pins the three delegate identities, the tail’s aliasing, the refusal
of a non-variadic delegate, and every family arity of both families, which are golib-only shapes no
Go program can construct. The arity row matters because only 0, 1 and 2 fixed parameters have a
consumer in the corpus today: 3 through 8 would otherwise be discovered by whichever package
reached them first.)
reflect.MakeFunc is Value.Call’s exact inverse — a compiled delegate over the descriptor’s carried System.Type (2026-08-29)
Go’s MakeFunc is runtime machinery end to end: it reinterprets the descriptor into a funcType
sub-record, asks funcLayout for a stack map, and pairs an assembly stub (makeFuncStub) with a
closure context the scheduler calls back through. None of that exists behind a managed-backed
descriptor — abi.synthType mints every one as a plain heap<Type> box with the CLR
System.Type as cargo, so the Reinterpret<abi.Type, funcType>() recovers a zero box and
funcLayout panics reflect: funcLayout of non-func type <nil>. First operational hit:
net/http/httptrace’s compose, which walks ClientTrace’s func-typed fields and MakeFuncs a
composed hook for every pair both traces set.
The hand-owned form (reflect/makefunc_impl.cs, displaced via the manualConversionFuncs
registry) runs the marshalling that Value.Call runs, in the opposite direction. Where Call
marshals a slice<Value> into a delegate’s DynamicInvoke, MakeFunc builds a delegate of
exactly the descriptor’s carried delegate type whose invocation boxes its CLR arguments,
types each one by the func’s STATIC parameter type (makeTypedValue — an interface-typed
parameter reports Kind Interface, a nil pointer is a VALID typed-nil Value, and a [N]byte
parameter carries the descriptor’s funcParamDims cargo, the one route a fixed array parameter’s
length reaches reflect at all), runs fn, and marshals the result Values back out under the SAME
assignability renderer Call’s arguments use (marshalIntoSlot — one rule for both directions).
A Go multi-return packs into the delegate’s own declared ValueTuple. The delegate itself comes
from golib’s GoReflect.MakeGoFuncDelegate — expression-compiled once per delegate type into a
factory (outer lambda takes the Func<object?[], object?> invoker, inner IS the typed delegate),
the same memoization rule the method-value binder follows — so the result is callable DIRECTLY as
a typed Go func (t.DNSStart(info)), through Value.Call, and through composition with itself.
The returned Value rides typ’s own descriptor box rather than a fresh synthType, so the
dims cargo survives and Type() interns back to the caller’s wrapper: MakeFunc(t, fn).Type() == t
by identity. A VARIADIC func type is a loud NotImplementedException, not a wrong delegate: its
tail is params Span<T>, a byref-like type no expression tree can carry — the route that exists is
the reverse of InvokeVariadic’s typed family trampolines above, unbuilt for want of a
demonstrated consumer, exactly as Value.CallSlice records. makeMethodValue’s identical
funcLayout read deliberately stays auto: it is reachable only through flagMethod, which the
bridge never sets (Value.Method binds the receiver into an ordinary delegate instead). With
MakeFunc live, reflect/iter.cs’s rangefunc Seq/Seq2 funcs gain their real implementation
path too. (Guarded by behavioral ReflectMakeFunc: the docs swap example invoked directly, the
httptrace compose shape, multi-return, canonical Type() identity, Call over a made func, an
interface-typed parameter, a typed-nil pointer argument, and a [4]byte parameter whose Len()
proves the dims cargo threads through. Banked consumer: net/http/httptrace 2|0.)
Major-version import directories
A /vN import path segment (math/rand/v2) hosts a package named for the PARENT segment, so the
emitted class follows the package NAME: consumers reference go.math.rand.rand_package, and the
namespace is go.math.rand — never the path-derived v2_package / go.math.rand.v2. Go’s own
convention (the directory is a version marker, not the package identifier) means the package name
equals the second-to-last path segment, and every place the converter derives a class/namespace/
alias from a /vN import path must honor it. There are four such derivations, reached by
different renderers, and each needed the convention applied at its own site:
-
using-alias + namespace emission —convertImportPathToNamespace(visitImportSpec.go) rewrites the last path part to the parent segment viamajorVersionSegmentRegex, so the file’susing rand = go.math.rand.rand_package;and the package’s ownnamespace go.math.randagree. -
t.String()-based FQ type rendering —getAliasQualifiedTypeName/getFullyQualifiedTypeName(main.go) build a foreign type’s name from the type graph’s path-qualified string, whose last segment slash-strip assumes the path tail IS the package qualifier. For a/vNtail it left the version behind (math/rand/v2.Rand→v2.Rand), which the alias-prepend then doubled intorand.v2.Rand(v2read as a member of classrand_package— CS0426). Both renderers now reduce the foreign import-PATH qualifier to the package NAME before the slash-strip.getFullyQualifiedTypeNamealso composespkg.Path()+"_package"directly for the qualified base name — routed throughpackageClassPath, which swaps a/vNtail for the Go package name. -
Cross-package reference metadata —
PackageInfo.RootPackageName(importOperations.go) is the code-facing qualifier that keys imported-alias loading and the foreign-implement records that cast sites reference (GoImplement<…rand_package.PCG, …rand_package.Source>(Pointer = true)). It was the path’s last segment (v2);rootPackageNameFromPathPartsnow returns the parent segment for a/vNtail.PackageNamestays path-formed — it also names the referenced.csproj, which ISmath.rand.v2.csproj. -
Imported type-alias TARGET class —
loadImportedTypeAliases(importOperations.go) qualifies an imported alias’s target asgo.<PackageName>_package.<Type>; the class path isPackageNamewith its final segment replaced byRootPackageName, so a/vNproducer’s exported aliases resolve torand_package, notv2_package.
The convention is that a package literally named vN would instead need the type-graph name; the
stdlib has none, so the regex/parent-segment rule holds corpus-wide. Guarded by the VersionedImport
behavioral test — a main importing a sibling vlib/v2 module (package vlib) that mirrors
math/rand/v2’s shape: a struct field ж<vlib.Rand> (renderer #2), a *PCG → Source pointer cast
recorded as go.vlib.vlib_package.PCG (#3/#4), output-compared vs go run across all four phases.
This is what unblocks sort as Phase 4’s second validated package (its test suite imports math/rand/v2).
A C# keyword inside a dotted import-path element
A Go import-path element may itself contain dots — a module host (gopkg.in, example.com,
golang.org) or a versioned tail (yaml.v3) — and every one of those dots is a namespace
separator in the emitted C#. So gopkg.in/yaml.v3 does not render two namespace levels, it renders
four, and each is a separate C# identifier that has to be keyword-escaped on its own.
Two sanitizers render namespace text, and until 2026-08-07 only one of them knew that. The
declaration side (getProjectName → getCoreSanitizedIdentifier) has always split an element on
its dots, so the dependency’s own file correctly opens namespace go.gopkg.@in;. Every consumer
emission — the import’s using yaml = …; alias, the enclosing-namespace using gopkg.…; an unaliased
import adds, the child-namespace map that decides root qualification, and the string-path type
renderer — goes through convertImportPathToNamespace, which sanitized each /-split element with
getSanitizedImport, measuring it whole. Whole, gopkg.in is not a C# keyword, so it passed
through bare and the importer emitted
using yaml = gopkg.in.yaml_package; // CS1001/CS1002/CS1022 — `in` is a keyword
using gopkg.in;
against a producer that had named itself go.gopkg.@in. The dependency compiled; nothing that
imported it could. (Issue #33: gopkg.in/yaml.v3 converts, then does not build.)
The fix is one function, both sides: getSanitizedImport splits on dots too, escaping each level
independently — exactly what the declaration side does. The recursion stays inside getSanitizedImport
rather than deferring to getCoreSanitizedIdentifier, because callers append the _package class
suffix to the final element before calling and the core sanitizer Δ-prefixes anything ending in
_package; that swap would emit Δyaml_package, a class no producer declares. Escaping is idempotent
(an already-@-marked part returns unchanged), so re-sanitizing a rendered namespace is stable.
The behavior change is exactly “a dotted input with a keyword sub-token is now escaped”: a string
containing a dot could never equal a keyword, so the old whole-string test never fired for one, and
hyphen/tilde replacement is per-part identical either way. Emission-neutral for both corpora, and
measured so — the behavioral corpus has no dotted module path at all, and the standard library’s only
dotted element is golang.org (the GOROOT-vendored tree), whose golang/org are not keywords:
CNR byte-identical across 572 packages, and a seeded full reconvert byte-identical
across 5,179 .cs/.csproj/README.md plus the generated go2cs-stdlib.slnx.
Guarded at both altitudes: TestGetSanitizedImportKeywordSegments (sanitization_test.go — several
keywords in several positions, the two sanitizers asserted to agree on a segment, the _package
suffix asserted NOT to be Δ-prefixed, and idempotence) and TestRecurseKeywordNamespaceSegment
(moduleConverter_integration_test.go — network-free, an unaliased import of a gopkg.in-shaped
dependency, asserting the producer’s declaration and both consumer emissions name the same namespace,
then sweeping every using in the file against the converter’s own keywords set so a keyword the
fixture never exercises is covered by the same assertion). Both neuter-proven: restoring the
whole-string measurement reproduces the reporter’s emitted text verbatim.
The import-path rewrite rewrites only the PATH, not the constructor in front of it
The string-path type renderer (convertToCSFullTypeName) peels a Go type expression one constructor
at a time — <-chan , chan , chan<- , *, [], [N], map[K], func(…) — recursing on what
is left. Its import-path rewrite runs first, before any of those branches, because a
package-qualified element has to become a C# namespace before the name means anything. Until
2026-08-08 that rewrite measured the path from index 0 of the whole string, constructor included.
convertImportPathToNamespace maps a hyphen to an underscore, because a Go path element may
legally contain one (mongo-driver, go-isatty). Handed the constructor as well, it rewrote the
- of <-chan too. The declaration in go.mongodb.org/mongo-driver/x/mongo/driver/session
type Pool struct { descChan <-chan description.Topology }
renders fully-qualified as <-chan go.mongodb.org/mongo_driver/mongo/description_package.Topology,
and came back as <_chan go.mongodb.org.mongo_driver.…. No channel branch recognizes <_chan, so it
fell through to the array branch, which slices past the > that a [N] length closes — and with
no > in the string at all, strings.Index returned -1 and the slice was typeName[0:]. The
renderer re-entered on the IDENTICAL string, without bound: fatal error: stack overflow, taking a
1,726-package -recurse run down at package 1456 (issue #33’s third report).
A single-segment path never had a slash to enter the rewrite, so <-chan time_package.Time was
always correct. That is the whole reason the standard library — which is nothing but single- and
multi-segment stdlib paths, none of them hyphenated behind a <-chan — never saw this, and only a
module dependency could.
The fix is importPathStart: find where the path begins by scanning backward from the candidate
region to the first byte no import path may contain, and rewrite only from there. - cannot be that
delimiter (it would split mongo-driver mid-path), but every constructor the renderer emits ends in
one that can — a space (<-chan , chan , chan<- ), *, ] ([], [2], map[K]) or (
(func(). <-chan then survives for its own branch, which recurses on the bare qualified element
exactly as it always has, and descChan emits
/*<-*/channel<go.mongodb.org.mongo_driver.mongo.description_package.Topology> against the
using description = global::go.go.mongodb.org.….description_package; the same file writes.
Two subtleties the first cut got wrong, both caught by CNR:
-
A non-ASCII byte is a path byte. Every delimiter this scan looks for is a constructor
character and all of them are ASCII, but the converter’s own synthetic markers are not (
ᴛ,ж,Ꮡ,ꓸ,Δ). Treating a multi-byte rune as a delimiter stranded the scan inside the type name, freezing the path in front of it:go.main_package/entryᴛ1forgo.main_package.entryᴛ1. ThePublicizedInterfaceAnonAliasandNestedAliasUserlifted-alias goldens are what surfaced it. -
The scan stops at the generic-argument bracket. Past it lie type ARGUMENTS whose
,, space and]are not constructor text and would strand the scan at the tail of the string.
Known residual. When the OUTERMOST constructor is [], its leading [ is read as the start of a
generic argument list and truncates the path scan to nothing, so []<-chan <module path>.T is still
mangled. Fixing it means no longer treating a leading [ as a generic bracket, which re-routes
every []<pkg>/<sub>.T in the corpus through the other branch (a _package-suffix change) — a
corpus-wide emission change that does not belong in a crash fix. It no longer crashes, which is the
part that mattered: see below.
The crash-proofing is separate from the rendering fix, and is the part that generalizes. The
array branch now requires the > it slices past. A Go stack overflow is a fatal runtime error,
not a panic, so the conversion driver’s per-file recover could not contain it and one unrenderable
type killed the whole run instead of its own package. Bounded, an unrecognized shape is reported by
name on stderr (Cannot render a C# type name for the unrecognized type expression "…") and the
package still converts. Every other branch consumes at least one byte before recursing, so bounding
this one bounds the renderer.
Guarded at both altitudes. typeNameResolution_test.go pins the renderer: TestImportPathStart
(each constructor, the marker runes, and the paths that must NOT move),
TestConvertToCSFullTypeNameConstructedModulePaths (the reported field in all three channel
directions, */[]/[N]/map[K]/nested, plus the single-segment and bare-path cases that must stay
byte-identical, plus the residual above pinned as a decision), and TestUnclosedBracketTerminates,
which runs in a child process with a 4 MB stack because the condition it guards is unrecoverable
in-process. TestRecurseChannelOfHyphenatedModulePath
(moduleConverter_integration_test.go) pins that a real declaration of the reported shape reaches
that renderer through an actual -recurse, over a network-free fixture whose module path mirrors the
report’s — hyphenated first segment, multi-segment tail. Its fixture carries a type alias to the
channel alongside the struct field, deliberately: a field DECLARATION emits the readable file-local
alias (description.Topology), so the fully-qualified render is computed but never written and a
test reading only the field cannot tell a correct render from a mangled one — while an exported alias
writes the fully-qualified string verbatim into both main.cs and the [GoTypeAlias] record.
Neuter-proven three ways: both reverted reproduces the reported fatal error: stack overflow; the
bound alone reverted fails the child test in 0.02 s; the rewrite alone reverted (bound in place)
makes the converter print the warning, exit 0, and still write every .cs — the crash-proofing
demonstrated independently of the rendering — while the integration test fails on the emitted
global using TopoChan = go.<_chan example.com.mongo_driver.…;.
A non-canonically-aliased import renders foreign types via the file’s alias
A file that imports a package under an explicit alias that differs from the canonical package
name must render that package’s types through the alias, not the canonical name. cryptobyte’s
asn1.go imports encoding/asn1 as encoding_asn1 — because the sibling vendored subpackage
.../cryptobyte/asn1 already claims the canonical asn1 — so a *asn1.BitString parameter must
emit ж<encoding_asn1.BitString>. getAliasQualifiedTypeName had rendered the canonical asn1.BitString
(importQualifier(pkg.Name())), which the file’s using asn1 = …cryptobyte.asn1_package resolves
to the subpackage (no BitString) — CS0426, and the RecvGenerator faithfully propagated the
wrong qualifier into its .g.cs. A types.Type carries no source alias, so a per-file
importPathAliases map (import path → the alias the file’s using bound) is threaded into
getAliasQualifiedTypeName; a foreign type whose import path the file aliased renders through that alias. Only
explicitly-aliased imports populate the map — unaliased / blank / dot / Δ-collision-renamed
imports are absent and keep the importQualifier(pkg.Name()) fallback, so nothing else changes
(value references were already correct — they come from the AST import name via convIdent; only
type references, sourced from types.Type, lost the alias). Cleared cryptobyte’s CS0426 (which had
masked deeper Builder.add/slice.Value roots, now banked). GUARD OWED — the shape needs two
packages whose names collide so one import is forced non-canonical, not expressible in the
single-library behavioral corpus.
Struct Type Embedding
Go structs use “type embedding” instead of inheritance. Since converted structs are C# structs (no inheritance), the TypeGenerator manages the equivalent: it adds a field for the embedded type and promotes the embedded type’s fields and methods (selection shorthand). Both field and method promotion are transitive through every embedding level: when top embeds mid which embeds inner, top gets an accessor for inner’s field n (top.n => ref mid.n) and a forwarding receiver for inner’s method describe (top.describe() => target.mid.describe()), each resolving through mid’s own one-level promotion. The generator collects an embedded struct’s members and methods recursively (following each field whose name equals its type’s simple name — Go’s embedding marker), with the closest declaration of a name winning, matching Go’s promotion rules. Pointer embeds promote too. Go also embeds by pointer (*traceBuf), whose C# field type is ж<traceBuf>; its methods and fields are promoted exactly like a value embed (the field’s ref-property is dereferenced — target.traceBuf.Value.method() — which binds the pointer-receiver method via the [GoRecv] ж<T> overload). The embedding-marker comparison dereferences the field type first, because a pointer field’s simple name carries a .Value suffix (traceBuf.Value) that would never match the bare embed field name. This matters most transitively: traceExpWriter embeds traceWriter (value) which embeds *traceBuf (pointer), and traceBuf’s varint/byte must promote all the way up — without the deref-aware marker the nested pointer embed is skipped and the upper struct silently loses the method (CS1929). (Guarded by the NestedEmbeddingPromotion behavioral test for value embeds and the PointerEmbeddingPromotion test for one-level and two-level-transitive pointer embeds; runtime relies on the field case for stackWorkBuf → stackWorkBufHdr → workbufhdr.nobj and the pointer case for the trace writers.) Because the promotion is performed at conversion time by the generator, methods added later in hand-written C# are not automatically promoted; keeping the source in Go and re-converting (or using explicit interfaces) is the maintainable path.
Zero values of promoted-embed structs construct through a generated constructor — never default. An embed is an inline field, so its own default is a usable Go zero value; what still is not is an embedded type that itself needs construction (a fixed-size array field, or a nested embed of one), whose default leaves a null backing. The generator therefore keeps allocating every embed in the type’s constructors, and the converter keeps rendering an uninitialized declaration of such a struct through them. Both halves close this: the converter renders every uninitialized declaration of such a struct through the NilType constructor instead of default! — var s shadowed emits shadowed s = new(nil);, an uninitialized package-level var g shadowed emits internal static shadowed g = new(nil); (the addressed-global box wraps the same, new(new shadowed(nil))), and a named result (r shadowed) declares shadowed r = new(nil); — while the generator allocates the boxes in the parameterless constructor too, so the new S() zero values materialized by heap(new S(), out var Ꮡs) (an address-taken local) and golib’s @new<T>() (p := new(shadowed), which constructs via Activator.CreateInstance<T>()) are equally usable. The detection (structHasPromotedEmbeds, visitStructType.go) mirrors the embedded-field emission: an embed takes the promoted-box path unless it is a same-package interface, a builtin non-named embed (int), or a pointer to a non-named type; a cross-package embed (selector type) always promotes. Residual gap: an instance materialized as default(T) outside a declaration — a missing-key map read, a freshly maked slice’s elements — still has null boxes; golib cannot run a constructor generically there. (Guarded by the NamedTypeOverStruct behavioral test — var s shadowed with explicit s.ctxt.fn and promoted s.fn access, plus new(shadowed), vs Go.)
A C#-keyword-named embed composes generated names from the unescaped member name. A Go struct named for a C# keyword (type base struct{…}) is emitted with the @ escape (@base), and embedding it makes @base the member name. Standalone identifier positions keep the escape (the partial ref @base @base accessor, the constructor parameter, member accesses like instance.@base.id), but every composed generated name must strip it, because @ is only valid leading an identifier: the promoted-struct inline field and its constructor assignments emit ʗbase (ʗ@base is CS1002), matching the already-stripped Ꮡ-prefixed field-reference statics and the converter’s structFieldBoxName. (Guarded by the NamedTypeOverStruct extension — a keyword-named embed with promoted field/method access, a keyword-keyed composite literal, and a write through &p.id promoted through the embed, all vs Go.)
Cross-package embeds resolve through the semantic model. The member-collection above resolves the embedded struct’s syntax (GetStructDeclaration) — same-package or via CompilationReferences. In a real MSBuild build, project references arrive as metadata references (never CompilationReference), so a cross-package embed — type rtype struct { *abi.Type } (runtime type.go) or a user package embedding a library struct — silently promoted nothing: the generated “Promoted Struct Field Accessors” section was empty and every t.TFlag/t.Str/t.Kind_ was CS1061. The field collection now falls back to the type’s metadata symbol (GetTypeByMetadataName on the normalized nested name, e.g. go.internal.abi_package+Type) and enumerates its public instance fields; the emitted accessors are unchanged in form — true refs through the embed (public ref abi.TFlag TFlag => ref Type.Value.TFlag; for a pointer embed), so writes through a promoted name reach the embedded target. Transitive promotion through a metadata type’s own embeds is not chased (no corpus site needs it). Promoted POINTER-RECEIVER method calls through a cross-package pointer embed are routed at the call site: the generator emits no method forwarder for a metadata embed (method promotion is syntax-resolved), so t.Uncommon() on Δrtype (embeds *abi.Type, runtime type.go) was CS1929; the converter now emits the explicit hop through the embed field’s box — t.Type.Value.Uncommon() — where the deref’d .Value is a ref return, binding the [GoRecv] ref extension addressably. A same-package pointer embed keeps its generated forwarder (no churn), and a promoted value-receiver method call (p.Hot()) remains a documented open gap — call through the embed explicitly. (Guarded by the CrossPkgUser Phase-4b extension — a promoted pointer-receiver Calibrate through the cross-assembly pointer embed, write-through observed via the target.) (Guarded by the CrossPkgUser Phase-4 extension — pointer-embed and value-embed field promotion across the assembly boundary, write-through observed via the embedded target, vs Go; cleared runtime type.go’s 4 CS1061, 68 → 64.)
Two refinements complete the cross-package pointer-embed story (2026-07-03, internal/reflectlite’s last 4): (a) the hop names the FIELD, which is struct-scoped — an embed field named like a Δ-renamed package type (rtype’s embedded Type vs reflectlite’s Type interface, Δ-renamed ΔType by its type-vs-method collision) is declared unrenamed, so the hop emission must not apply the package-level rename (t.ΔType.Value.Uncommon() was CS1061); both hop arms now route through structFieldBoxName, the same struct-scoped naming the box accessors use. (b) A generated interface implementation forwards through the hop too: when an interface member has NO direct struct method and is satisfied purely by Go promotion through a single embedded-pointer field (GoImplement<rtype, ΔType> — Size/Kind live on *abi.Type), the InterfaceImplTemplate emits this.Type.Value.Size() instead of the unbindable this.Size() (CS1929); the IжAdapter template forwards the same members m_box.Value.Type.Value.M(). Detection is syntax-level — the converter’s embed marker is the public partial ref ж<X> F {{ get; }} property (GetEmbeddedPointerHopNames) — and originally gated to a SINGLE hop, on the reasoning that multi-embed interface satisfaction was rare. The corpus surfaced one (jsonrpc’s pipe), and the gate is gone: several embeds now route each member to the unique embed declaring it (see With SEVERAL embedded pointers the hop is chosen per member, not per struct). (Guarded by the CrossPkgUser Phase-5 extension — a local Δ-renamed Meter interface colliding with the embed field name, satisfied purely by promotion through *CrossPkgLib.Meter, with all bump paths aliasing one shared object, vs Go.)
A pointer-receiver method promoted through a VALUE embed is routed at the call site, not by a generator forwarder. When timeTimer embeds timer by value and timer has a pointer-receiver method (func (t *timer) modify(…)), the generator emits no modify forwarder on timeTimer (a target.timer.modify(…) forwarder body would copy the value field, losing the write, and would not bind the ж<timer> overload) — so a promoted call t.modify(…) on a *timeTimer would leave the receiver as the whole ж<timeTimer> box, which the promoted method’s ж/[GoRecv]-ref overload cannot bind (CS1929). The converter instead routes the promoted call through the embedded field’s box, exactly as the explicit t.timer.modify(…) already renders: t.of(timeTimer.Ꮡtimer).modify(…) for a pointer local, Ꮡt.of(timeTimer.Ꮡtimer).modify(…) for a deref’d pointer parameter (the &receiver.field &-machinery supplies the correct box per receiver form). Because it field-refs the real embedded storage — never a Ꮡ(copy) — the mutation writes through. This is detected via the method’s types.Selection.Index() having a single embedded-field hop ([embeddedField, method]); it is gated to a value embed (a pointer embed already yields the box as its field value and is left to the generated forwarder — taking its address would double-box to ж<ж<T>>), and to a single hop (deeper chains fall through).
The pointer-interface ADAPTER projects through VALUE embeds the same way — chained. A GoImplement<T, Iface>(Pointer = true) whose interface members are satisfied only by promotion through value embed(s) — dwarf’s type UintType struct { BasicType }, type BasicType struct { CommonType }, func (c *CommonType) Common() — cannot forward m_box.M() (nothing binds on ж<UintType>, CS1929 ×18). The ImplementGenerator resolves each unbound interface member by walking the single-value-embed chain (syntax marker: the public partial ref X X {{ get; }} property whose name equals its type’s simple name — GetEmbeddedValueHopNames; bounded to 4 hops), composing the box projection hop by hop via the TypeGenerator’s static ref accessors: m_box.of(UintType.ᏑBasicType).of(BasicType.ᏑCommonType).Common(). At each level a direct-ж method binds on the projected box and anything else binds through its deref’d .Value (ref extensions bind on the ref-returning Value) — the same dichotomy as the pointer-embed hop. Mutations write through (the projection field-refs the real embedded storage in the receiver box). (Guarded by StructPointerPromotionWithInterface’s counterKind → kindBase → meta chain: st.Stamp() twice through the interface, Hits() reading the count mutated through the same boxes, vs Go.)
A FOREIGN value embed’s direct-ж method binds through METADATA. When the embedded type lives in another assembly — database/sql’s driverConn value-embeds sync.Mutex, cast *driverConn → sync.Locker — its direct-ж method (Lock/Unlock, emitted by the converter as this ж<Mutex> extensions) is visible only in the compiled sync assembly’s METADATA, never this compilation’s syntax trees. The syntax-based box scan (GetBoxReceiverMethodNames) therefore misses it and the chain-walk fell through to the unbindable m_box.Lock() (CS1929 ×2). The walk now also resolves the embed field’s TYPE SYMBOL and probes its containing package class’s static this ж<T> members via metadata (GetForeignBoxReceiverMethodNames, mirroring the foreignStruct arm’s boxBound scan — only a PUBLIC ж-extension binds cross-assembly, since unexported RecvGenerator twins are internal); when found it forwards the box hop m_box.of(driverConn.ᏑMutex).Lock(), exactly the converter’s own call-site form (Ꮡdc.of(driverConn.ᏑMutex).Lock()). The .Lock() resolves in the generated adapter because sync_package sits in the enclosing go namespace — the same reason the converter’s own call sites bind without a using static. (No single-baseline behavioral guard expresses this — it needs a foreign package’s ж-method type value-embedded AND implementing that package’s interface, the sync.Mutex+sync.Locker shape — so GUARD OWED; verified by a minimal two-assembly reproduction of that exact shape, 2×CS1929 → 0.)
The exception is the enclosing method’s own [GoRecv] ref receiver: a non-direct-ж pointer-receiver method renders this ref T recv with no box (Ꮡrecv exists only for direct-ж), so the box descent referenced a nonexistent name (CS0103 — runtime mgcscavenge.go, (*scavChunkData).alloc/free calling the promoted sc.setEmpty()/setNonEmpty() from the embedded scavChunkFlags). No box is needed either: the embedded field of a ref receiver is addressable, so the promoted method’s [GoRecv] ref overload binds on the explicit field call — sc.scavChunkFlags.setEmpty() — with faithful write-through. (A direct-ж target on the bare receiver would have promoted the enclosing method via the capture-mode fixpoint, so this arm’s target always has the ref overload.) The receiver name-match is guarded rendered==raw: an inner binding that shadows the receiver name is Δ-renamed by the shadow pass, declines the arm, and keeps the descent — the same hardening applied in convUnaryExpr’s &recv.field branch, where a pointer local shadowing the receiver name previously took the receiver arm and emitted Ꮡ+raw (a nonexistent box) instead of falling to the pointer-variable arm (cΔ1.of(chunk.Ꮡflags)). The fix also pre-cleared the same latent shape in archive/zip (f.FileHeader.hasDataDescriptor()), go/internal/gcimporter, go/types, and image (whole-stdlib reconvert diff: exactly those sites changed, nothing else). (Guarded by the EmbeddedValuePointerMethod behavioral test — value embed + mutating pointer-receiver methods called via a pointer local, a deref’d param, AND the enclosing [GoRecv] ref receiver, plus a shadowing-pointer-local control, all with write-through verified against Go; runtime relies on it for timeTimer’s modify/stop/reset and scavChunkData’s setEmpty/setNonEmpty.)
A POINTER embed’s BOX-receiver primary promotes through the box hop, not the deref’d value. The promoted-receiver harvest (GetExtensionMethods → IsExtensionMethodForStruct) matched only VALUE-receiver forms (T/ref T/…), so a direct-ж primary (this ж<T>, emitted when a method takes the address of a receiver field) on an embedded type had no promoted forwarder — sha3’s cshakeState embeds *state, whose Write is this ж<state>, so Ꮡc.Write(…) was CS1929. Such a method IS promotable through a pointer embed: the converter renders the hop target.<embed> as a ж<T>, so the forwarder target.<embed>.Write(…) binds the box receiver directly (no box construction). The TypeGenerator now collects those box primaries separately (GetBoxReceiverExtensionMethods, keyed off GetEmbeddedPointerHopNames so it fires ONLY for pointer embeds — a value embed’s target.<embed> is a value that cannot bind a ж-receiver, which would need the box-hop form the sibling GoImplement adapter uses above) and marks each MethodInfo.IsBoxRecv, so the emission drops the .Value a value-receiver forwarder appends (target.<embed>.M(…) for a box primary vs target.<embed>.Value.M(…) for a value method). The pointer-receiver forwarder delegates to the value form unchanged, and the shared box means write-through reaches the real embedded storage. (Guarded by the PointerEmbedBoxReceiver behavioral test — Outer embedding *Inner whose Add takes &n.total (a box primary), the promoted o.Add(…) mutating through the shared box, output-compared vs Go. Full behavioral suite green; a whole-corpus confirmation on the real sha3 is deferred to the next census, as with the sibling foreign-embed fix.)
Same-Go-package promotion survives the -tests reference model’s assembly seam. The metadata
fallbacks above implement Go’s CROSS-package rule — public members only — which was also their
accessibility filter. But the -tests reference model splits ONE Go package across two assemblies
(the test project references the production project instead of recompiling its sources), so a
white-box test struct embedding a production type by pointer — net’s resolvConfTest over
*resolverConfig (dnsclient_unix_test.go) — is a same-package embed whose type is nonetheless
METADATA in the test compilation: Go promotes its unexported fields (initOnce, dnsConfig,
lastChecked) and methods (init, tryAcquireSema, releaseSema), the field scan’s public-only
filter dropped every one, and the method harvest had no metadata path at all — promotion did not
happen and all eight of net’s cgo-off Linux test-build errors were promoted selections on that one
type (CS0117/CS1061/CS1929). The membership rule is now Go’s own, projected through what the
compiler already knows: a metadata member promotes when it is accessible to this compilation
(IsSymbolAccessibleWithin, which folds in the InternalsVisibleTo friend grant the test model
mints) and either public (what Go promotes across packages) or a member of the same Go
package as the embedding struct — decided by comparing the [GoPackage] identities of the two
containing package classes, the identity that survives the assembly split (net_package and
net_internal_test_package both carry [GoPackage("net")]; the external-test class carries
[GoPackage("net_test")], a genuinely different Go package that keeps public-only promotion exactly
as Go does, friend grant notwithstanding). Methods take a new metadata harvest
(GetMetadataPromotedMethods): a converted Go method is a static extension on the type’s containing
package class, so that class’s metadata carries full signatures; receivers split as the syntax side’s
do (this T/this ref T value forms vs the direct-ж box primary), IsExtensionMethod keeps
package-level functions out, and the harvest is same-Go-package only — a genuine cross-package
embed still yields no forwarders and keeps the converter’s explicit-hop call emission above, so
nothing changes corpus-wide. One name class is deliberately NOT minted: a promoted method whose name
a package-level function also carries (Go scopes them apart — LookupHost and
(*Resolver).LookupHost — but the emission folds both into one static package class). A forwarder
by that name lives in the test class, and C# member lookup finds class methods before using
static imports, so it would shadow every bare function call — net’s lookupCustomResolver embeds
*Resolver, and unsuppressed Lookup* forwarders cost 54 CS1501s on plain LookupHost(host)
calls. The bare function call has no other spelling the converter emits, while a promoted-method
call always has the explicit hop, so the function wins (residual, unmeasured: a Go promoted call of
such a colliding method through the embedding struct would need the converter’s explicit hop). No record schema moved: there IS no promotion witness in any
package_info file — an embed’s promotion has always been resolved at generation time (syntax
same-assembly, metadata otherwise), and the fix completes the metadata half for the one seam where
“same package” and “same assembly” part company. (Guarded by GenTests/PromotedMetadataEmbedTests —
the real TypeGenerator over the two-assembly friend shape, promoted internal fields and methods
asserted, the collision suppression asserted, plus the cross-package control pinning
public-fields-only and no method forwarders; verified end-to-end by net’s linux-target
net.tests.csproj building clean — the 8 promotion errors and the 46-site shadowing class both
closed.)
An embedded struct is an INLINE field, so a value copy copies it
Go gives an embedded field no special storage: it is a field like any other, and a struct value copy
copies it inline. The TypeGenerator originally held a promoted embed in a private readonly ж<T>
box — a heap allocation the constructors made and the partial ref accessor resolved through —
which gave the embed reference semantics that a plain C# struct assignment then shared:
type inner struct{ v int }
type outer struct { inner; tag string }
a := outer{inner: inner{v: 1}, tag: "a"}
b := a // Go: b.inner is a COPY
b.v = 2
// Go prints 1 2; the boxed emission printed 2 2 — and `tag`, an ordinary field, printed a b.
Every by-value transfer inherited it — assignment, c := *p, a value parameter, a returned value, an
element read out of a slice — so the copy and its source shared one embedded storage while the
enclosing struct’s own fields copied correctly.
What it cost. This is the root of go/types’ type parameter judged not identical to itself wall
(gcimporter’s 108 TestImportTypeparamTests mismatches, go/types’ own 33 failures, and the
validType0 stack overflow at TestFixedbugs/issue48951.go). go/types.Var embeds object, which
carries the field’s typ, and substitution copies a *Var to retype it:
func substVar(v *Var, typ Type) *Var {
copy := *v // C#: `copy = v` shared the ж<object> box
copy.typ = typ // …so this wrote the ORIGIN's typ
copy.origin = v.Origin()
return ©
}
So instantiating S[T] for the first method of a generic type rewrote the ORIGIN’s underlying
struct{V T₁} to struct{V T₂} in place. The second method then substituted {T₁ → T₃} over a
struct that no longer mentioned T₁, kept T₂, and Identical(T₂, T₃) correctly answered false —
a.V was judged not assignable to the method’s own T. Identical was never the defect, and
neither were the instance caches (both were instrumented and behave exactly as Go’s do).
The emission. The embed is an inline field, and the accessor is the same partial ref property
it always was, made legal by [UnscopedRef] — a struct member returning a ref to its own instance
state is CS8170 by default, because the receiver could be a temporary; the attribute states the
ref’s lifetime is the receiver’s, which is exactly the guarantee Go gives (the selection is the
enclosing value’s storage) and moves the burden to the call site, where C#’s ref-safety rules then
reject precisely the cases Go also rejects. It is the same technique the InheritedTypeTemplate
already used to forward a defined-type-over-struct’s fields.
public partial struct Var
{
private @object ʗobject; // was: private readonly ж<@object> Ꮡʗobject;
[UnscopedRef] internal partial ref @object @object => ref ʗobject;
[UnscopedRef] internal ref ΔType typ => ref @object.typ; // promotion chains the same way
internal static ref @object Ꮡobject(ref Var instance) => ref instance.@object; // unchanged
}
Everything downstream is unchanged in form: &v.embed still goes through the static Ꮡ-accessor
(Ꮡv.of(Var.Ꮡobject)), which builds a struct-field-reference box rooted at the enclosing box, so
pointer identity is still the enclosing allocation’s; a POINTER embed’s slot still holds a possibly
null ж<T> that reads and assigns without dereferencing; promoted methods, adapters and the
interface hops all still descend <embed> / <embed>.Value. One thing improves for free: a
default(T) reached where no constructor runs — a missing-key map read, a freshly maked element —
no longer has a null embed box, so the previously documented residual gap narrows to embedded types
that need construction in their own right (a fixed array at some depth).
The one residue, named. A fixed-size ARRAY reached only through an embed is still shared after
a copy: array<T> is a struct over a shared T[], and the converter’s clone walk
(typeNeedsValueClone) skips embedded fields when deciding whether a struct needs a
[GoValueClone] stamp. That is unchanged by this fix — it was shared before and is shared now, by a
different mechanism — and widening the walk is now sound (the generated
copy.<member> = <member>.ΔClone() lands in the copy’s own inline storage rather than corrupting
the source), but it moves converter emission corpus-wide and belongs to a change that owns that
footprint.
Guarded by the EmbeddedStructValueCopy behavioral test: assignment, by-value parameter, a
two-level c := *p, a slice-element read, plus a pointer embed proving both halves of Go’s rule —
reassigning the copy’s embedded pointer leaves the source’s alone, while the pointee stays shared
when it is not reassigned.
The address of a FIELD of a slice or array element aliases the element
Go’s &s[i].f is a pointer into the backing storage: a write through it changes s[i]. The
&-machinery builds such an address in two steps — the element’s address, then a field reference on
it — and the first step has to be the element-aliasing form the index branch already renders for
&s[i] itself (Ꮡ(s, i) for a slice, Ꮡarr.at<E>(i) / p.at<E>(i) for an array or a
pointer-to-array). The arm’s last-resort fallback instead renders Ꮡ(<value>), a box over a copy
of the element, and a field ref rooted there aliases the copy: every write through the pointer is
dropped while every read still looks right, so the container simply never changes.
p_A_Other := &p.Inst[pc].Out // regexp/onepass.go, onePassCopy
*p_B_Alt = *p_A_Other // patches the compiled program in place
var p_A_Other = Ꮡ((~p).Inst, pc).of(onePassInst.ᏑOut); // aliases the element
// NOT: Ꮡ((~p).Inst[pc]).of(onePassInst.ᏑOut) // a box over a COPY — write lost
This is the same write-dropping class the slice, array and pointer-to-array index branches each call
out by name (text/tabwriter’s empty lines, compress/flate emitting literals only at levels 2–9,
hash/crc32’s all-zero slicing tables), reached through a field of the element rather than
through the element itself. The predicate is exprIsIndexableElement: slice, array, or
pointer-to-array only. A map is excluded because Go does not permit &m[k] at all, so an index over
one can never legitimately reach the &-machinery, and admitting it would mask a front-end error as
a plausible emission; a generic instantiation shares *ast.IndexExpr’s shape but types as a
signature or a named type and falls out without a special case. The recursion is ordered before the
heap-boxed branch, which already recursed identically for an IndexExpr base, so a boxed base
reaches the same emission either way and no existing site moves.
Why it surfaced when it did. The PROMOTED case was masked for as long as go2cs-gen held an
embed in a shared ж<T> box (see An embedded struct is an INLINE field, so a value copy copies it
above): the embed’s reference semantics meant a copied element still pointed at the origin’s embedded
storage, so Ꮡ(elem).of(T.ᏑPromoted) reached the real element by accident. Making the embed an
inline field was correct and removed that accident, which is what exposed this — regexp’s
onePassCopy stopped patching, and TestCompileOnePass reported isOnePass=false for
^(?:(?:a+)*)$ and ^(?:(?:(?:a*)+))$. That commit fixed the sibling arm (a promoted
pointer-receiver call descending a copy box); this is the address-of-field arm of the same
defect. An ordinary, non-embedded field of an element was never masked and was broken all along.
The base the recursion newly exposed: a pointer RECEIVER over a named array. &t[i].field where
t is *semTable (type semTable [4]struct{…}, runtime’s semtable.rootFor) now reaches the
index arm’s pointer-to-array branch, which renders t.at<E>(i) on the assumption that a
pointer-to-array base yields a ж<[N]E> box. A Go pointer receiver does not: it renders as
this ref T recv, which has no box companion, so recv.at<E>(i) names a member the value does not
have (CS1061). It needs none — a named fixed-array type is generated as IArray<E> over a shared
backing E[], so the two-arg element-aliasing overload aliases on the wrapper itself. But that
wrapper’s backing is allocated LAZILY, and the two-arg overload takes its target BY VALUE, so on a
still-virgin wrapper the backing materialized on the call site’s boxing temp and the receiver’s own
storage was never written — see The element address of a VIRGIN named array must materialize
through the receiver below, which is why the emission carries .Value:
[GoRecv] internal static ж<semaRoot> rootFor(this ref semTable t, nint i) {
return Ꮡ(t.Value, i).of(semTableᴛ1.Ꮡroot); // was: Ꮡ(t.Value[i]).of(…) — a COPY
}
That is exactly the treatment the receiver’s array FIELD already gets in the same arm (see Element
address of an ARRAY FIELD of the receiver under Slices and Arrays), for the same reason. A
deref-aliased pointer PARAMETER and a box-valued LOCAL both DO have a box and keep .at<E>(i).
The base has to be the receiver identifier itself, not merely rooted at it — the same
object-identity-versus-root-identifier rule the slice and array branches state, inverted.
getIdentifier walks a selector chain to its root, so &p.chunks[l1][l2] (runtime’s
pageAlloc.chunkOf) and &u.inlTree[uf.index] (symtabinl) both report the receiver as their root
while their actual base is a pointer-to-array FIELD — a genuine ж<[N]E> rvalue that does have a box
and must keep .at<E>(i). Routing those through the two-arg overload hands it a ж<array<E>> where
it wants an IArray<E>, which does not bind. Neither shape has a behavioral test, and the corpus is
what caught them: a -stdlib reconvert of the affected packages moved both files, and reverting them
is what the identifier restriction does.
Guarded by the SliceElementFieldAddress behavioral test — the deliberate mirror of
SliceFieldElementAddress (that one is &(slice field)[i], this one is &(slice[i]).field) —
covering an ordinary field of a slice local and of an array local, a promoted field of a slice field
reached through a pointer, and onePassCopy’s own idioms: two pointers into one element swapped and
then written through, and a cross-element *dst = *src. The pointer-receiver-over-named-array
sub-case above is guarded by NamedArrayAnonElement’s Compile and golden phases, and — since
the lazy-backing gap below was closed — behaviorally by NamedArrayWrapper’s element-address
probe on a virgin wrapper. (NamedArrayAnonElement’s own main still deliberately never indexes
the array; that note said zero-valuing a named fixed-size array “does not yet materialize its
backing on the value itself”, which is exactly the gap the next section closes.)
The array-backing publish is atomic per box
ж<T>.at<Telem>(i) has to reach a go2cs-gen named fixed-size array wrapper’s LAZY backing, and
ж<T> is deliberately unconstrained in T, so golib cannot call an interface member on
ref Value without boxing a copy. The sequence it used was box the wrapper, touch Source so the
backing materializes on that copy, copy the whole wrapper back over the real storage — correct
single-threaded (that copy-back IS 47ddd5a50’s fix for the same lost write) and lossy with two
threads, because it is an unsynchronized read-modify-write of shared state. Two threads reaching a
still-lazy wrapper each allocated their own backing; the second copy-back discarded the first along
with every element already written into it, and the element pointers already handed out kept naming
the orphan. Because the wrapper is several words wide, the half-done copy-back could also be
observed, surfacing as a spurious IndexOutOfRangeException out of at’s bounds check rather
than as a lost write.
crypto/internal/boring/bcache’s concurrent section is the measured victim: entries lost in ~28% of
runs, and always in the first ~15 of 102,100 — the fingerprint of a bounded start-up window rather
than of a broken CAS or a GC interaction (both A/B-eliminated).
The publish is now gated per box, which is the only durable unit available: the by-value copy
cannot be one, and constraining T is not on the table — the constrained-CALL route was torn out in
d5c0c9c10 for killing every Native AOT binary at type-init. m_publishedArrayBacking serves two
jobs at once: null is the once-only gate (every thread serializes through lock (this), which is
exactly the cold-start window the race lives in), and a different backing is the reassignment
detector, so no stale ready-flag can hand out a pointer into a private copy after *p is assigned a
fresh zero wrapper. The fast path is lock-free — one acquire read, one type test, one reference
compare.
The publish path is also narrowed to the shapes that actually are lazy, which fixed a second,
separate defect the old unconditional probe carried. golib’s own array<T>/slice<T> and every
named-slice wrapper hold their backing in a field, so there is nothing to publish — and
slice<T>.Source is defined to return a DETACHED COPY, so the old code allocated and threw away
a full copy of the backing on every element take through a ж<slice<T>>. Measured (isolated
processes, median of three): slice .at() 215.09 → 29.00 ns/op, array 23.66 → 21.84, named
wrapper 28.51 → 26.22. Every shape got faster; the fix removes an allocation from the hot path
rather than adding a lock to it.
Doctrine: a lazy-initialization fix is not finished until the publish is atomic.
47ddd5a50correctly diagnosed “the allocation landed on the copy and the real storage stayed virgin” and added the copy-back. The single-threaded repair of a lost-write defect is exactly the shape that leaves a concurrency residue behind.
Not closed by this, because no golib-side gate can be: the generated Value => m_value ??= …
getter is itself a read-modify-write, so two threads first-touching the same struct instance by
ref still race (ref semTable semtable => ref Ꮡsemtable.Value; semtable[i] = x). Closing that needs
an atomic publish inside the generated getter (go2cs-gen). Measured unchanged at ~95% of trials
(ElemAliasProbe arm7) — closed separately, see The named-array wrapper publishes its lazy backing
atomically below.
The element address of a VIRGIN named array must materialize through the receiver
The arm above hands &t[i] to golib’s by-value Ꮡ<T>(IArray<T> target, int index), which was
reasoned sound because “a named fixed-array type is generated as IArray<E> over a shared backing
E[]”. That is true of golib’s own array<E> — an eagerly-allocated readonly struct, where a copy
shares the storage — and false of the go2cs-gen wrapper, whose backing is allocated on first
touch:
private array<E>? m_value;
public array<E> Value => m_value ??= new array<E>(N);
The overload takes its target by value, so the CALL SITE boxes the wrapper and golib only ever sees
that private copy. Over a still-zero wrapper the ??= therefore ran on the boxing temp, the
receiver’s storage stayed virgin, and every element pointer named a fresh throwaway array — every
write through it silently lost, single-threaded, no concurrency required. runtime’s rootFor is
the only access path to semtable, so nothing ever materialized the shared table: each call handed
back a pointer into its own private 251-entry array of zero semaRoots. (Latent only because
sync’s Mutex/RWMutex/WaitGroup are hand-owned on SemaphoreSlim and never reach
runtime.semacquire.)
The emission projects through the wrapper’s own Value getter first:
Ꮡ(t.Value, i) // was: Ꮡ(t, i)
Value is a mutating struct member, so invoking it on the ref receiver (or on a field of one)
runs the ??= against the REAL storage, and the array<E> it returns shares that backing — so the
element box aliases the receiver. Both wrapper flavors carry it: a direct-array RHS
(type Mont [4]uint64) exposes Value : array<E>, and a named RHS (type pallocBits pageBits)
yields the view wrapper whose Value is that named type, itself an IArray<E> over the same
storage. An UNNAMED [N]E base renders as golib array<E>, has no Value member and needs none,
so the projection is gated on the base being a named type over an array
(lazyArrayBackingProjection, convUnaryExpr.go) and every other site is unchanged — a seeded
whole-corpus reconvert, diffed emission-against-emission, moves exactly one file:
runtime/sema.cs.
The .at<E>(i) route would also be correct (it publishes through the box — see golib’s
arrayView/publishArrayBacking), but it is unavailable here for the same reason this arm exists
at all: a [GoRecv] ref receiver has no ж<> box.
Guarded by NamedArrayWrapper’s slots/slot probe — a pointer-receiver method returning
&s[i] on a virgin wrapper, written through and read back. Verified as a real gate rather than a
green that cannot go red: at the previous emission it reports stdout mismatch C# vs Go.
A different door, measured and NOT closed by this. runtime/mpallocbits.cs’s
Ꮡ((pageBits)(b)) binds golib’s standard-box Ꮡ<T>(in T) over a value produced by the generated
by-value conversion operator (implicit operator pageBits(pallocBits value) => value.view), which
materializes on the operator’s own parameter copy. First-touch writes through it are lost; once
anything else materializes b, every copy shares the backing and writes land (measured both ways —
ElemAliasProbe arm8). It needs its own increment.
The named-array wrapper publishes its lazy backing atomically
The third door of the same family, and the one neither of the others can reach: the generated
wrapper’s own Value getter, reached by a plain ref with no golib on the path at all —
internal static ref semTable semtable => ref Ꮡsemtable.Value; and then semtable[i] = x. A per-box
publish gate in ж<T>.at() never sees it (there is no at() call), and the receiver projection above
never sees it either (there is no Ꮡ). What it meets is m_value ??= new array<E>(N), a
read-modify-write of shared mutable state: two threads that first-touch the same zero-valued wrapper
each allocate a backing and the second store orphans the first, together with every element
pointer already derived from it. Silent — no fault, no exception — and confined to a start-up window
measured in microseconds. Measured at 872 of 900 concurrent first-touch trials (ElemAliasProbe
arm7, 24 threads × 300 trials × 3 batches).
The publish becomes an interlocked CAS. Every racing thread allocates, exactly one wins the slot, and the losers discard their allocation before anything can derive an element address from it — which is what makes it correct rather than merely narrower:
private global::System.Runtime.CompilerServices.StrongBox<array<uint64>>? m_value; // was: array<uint64>?
public array<uint64> Value
{
get
{
global::System.Runtime.CompilerServices.StrongBox<array<uint64>>? value = m_value;
if (value is null)
{
var created = new global::System.Runtime.CompilerServices.StrongBox<array<uint64>>(new array<uint64>(256));
value = global::System.Threading.Interlocked.CompareExchange(ref m_value, created, null) ?? created;
}
return value.Value;
}
}
Why the slot had to change shape at all. An interlocked publish needs ONE machine word. array<E>
is a 3-field readonly struct (backing plus the Alias window’s low/length), so array<E>? is 24
bytes — it can neither be CAS’d nor even read without tearing while another thread writes it. The
narrower one-word alternative, holding the bare E[], does not preserve the value: a
constructor-supplied array may be an alias window (array<E>.Alias, Go’s (*[N]E)(s)) whose
Source is wider than the array, and flattening it to its backing would silently widen the named
array and shift its origin. The holder carries the whole array<E>, so nothing is lost. It is
StrongBox<array<E>> and not plain object because an object slot makes every warm read an
unbox.any — a type-check helper call in the hot loop; measured on the element-address path over 64
cold tables, 1.97 → 4.13 ns/op for object against 1.97 → 2.45 for the typed holder.
The residual cost is one dependent load and the probe reports it honestly (arm9, both emissions in
one process): the raw Value getter gets faster (1.03 → 0.87 ns/op — the wrapper struct shrank
from 24 bytes to 8, so every Go by-value array copy moved with it), the element path over ONE
long-lived table — what the corpus’s named arrays actually are, and where the JIT hoists the
loop-invariant getter — sits between −1% and +12% run to run, and the pathological shape of 64
separate non-resident tables costs +17…25%.
The consequence that had to be measured, not reasoned. A Go fixed-size array is COMPARABLE and
legal as a map key. With no overrides a C# struct inherits ValueType.Equals/GetHashCode, and both
read the single m_value field — now a reference. So two distinct wrappers over equal content began
comparing unequal and hashing differently, missing each other in a map and in reflect.DeepEqual:
precisely the silent wrong answer this door exists to remove, traded for a different one. The ==
operator hid it completely, because EqualityExpression binds the wrapper’s own
Equals(IArray<E>) at COMPILE time and that was structural all along. The Array kind therefore emits
both overrides, delegating to array<E>’s element-wise pair so neither depends on the slot’s shape
any more:
public override bool Equals(object? obj) => obj is Table other && Value.Equals(other.Value);
public override int GetHashCode() => Value.GetHashCode();
One golib companion follows for the same reason: GoReflect.TryUnwrapWrapperValue reads m_value by
reflection to hand callers the wrapper’s underlying value, so it unwraps the holder’s extra level (no
converted or golib type is ever an IStrongBox).
Guarded by NamedArrayWrapper’s map-key probe — two separately built equal keys, a re-store
through the second, a third distinct key, and the same for the VIRGIN zero array whose backing neither
side has materialized. Verified as a real gate rather than a green that cannot go red: with the two
overrides suppressed and nothing else changed, it reports stdout mismatch C# vs Go.
Still not closed, by construction: a materialization that happens on a by-value COPY of the
wrapper publishes to the copy’s field, so the arm8 Ꮡ((pageBits)(b)) door above is untouched. The
emission-vs-emission blast radius is nil — a generator change alters no committed .cs, and the
suite’s Transpile and Target phases stay byte-identical across it.
A promoted field whose name equals the enclosing type is Δ-renamed
Go lets an embedded struct carry a field whose name equals the type doing the embedding —
debug/gosym’s type Func struct{ *Sym } where type Sym struct{ Func *Func; … }, so Sym.Func
promotes onto Func. The generator’s promoted-field accessor would then emit a Func member
inside struct Func, which C# rejects (CS0542 — a member cannot share its enclosing type’s name).
The TypeGenerator now Δ-prefixes just that accessor’s NAME when its simple name equals the
NonGenericStructName (the field ACCESS on the right keeps the original name), matching the
ΔGoType/Δslice collision-rename precedent:
public ref ж<Func> ΔFunc => ref Sym.Value.Func; // was: `Func => …`, CS0542
The promoted field is read on the embedded struct directly (sym.Func = fn), never via the outer
value, so no converter reference to the renamed accessor needs coordinating; a package that did
read outerFunc.Func would surface CS1061 in the gate (none does). Cleared debug/gosym’s lone
CS0542. Guarded by PromotedFieldNameIsType (a Node embedding a *sym whose Node field
collides — accessed through the explicit embedded path, values vs Go).
An embedded field is named by GO, not by the C# rendering of its type
Go names an embedded field after the unqualified type name — the field of struct{ *myInt } is myInt, the field of struct{ io.Writer } is Writer. The converter used to derive that member name from the rendered C# type, stripping the package qualifier and any type arguments back to something that looked like the Go name. For every ordinary embed the two strings are identical, which is why the derivation served for years; they part company the moment the converter renames the type.
A function-local type is hoisted to package scope under a mangled name (type myInt int inside TestAnonymousFields becomes TestAnonymousFields_myIntᴛ1). Naming the member after that left the declaration — and go2cs-gen’s generated constructor and promotion accessor, which are both read off it — spelling one thing while every use site kept spelling the Go field name:
encode_test.cs(604): CS1061 'TestAnonymousFields_Sᴛ4' does not contain a definition for 'myInt'
decode_test.cs(2532): CS1739 the best overload for 'TestUnmarshalEmbeddedUnexported_S3' does not have a parameter named 'embed1'
It also silently flipped the field’s exportedness, because the derived name begins with the enclosing function’s capital: embed1, unexported in Go, was emitted public. That is not cosmetic — encoding/json reads exportedness off the field, and TestUnmarshalEmbeddedUnexported asserts precisely that such a field is not settable.
The member name is therefore taken from the Go object the embed resolves to: a same-package embed resolves to the field itself (*types.Var, whose name IS the Go field name by definition), and a selector embed to the embedded type’s own TypeName. Both are already unqualified and free of type arguments, so this replaces the stripping rather than adding to it; a *types.PkgName (an unresolved selector) is deliberately not claimed, since it would name the member after the package.
type (
myInt int
MyInt int
holder struct{ myInt; MyInt }
)
[GoType("dyn")] partial struct embeddedLocalTypes_holder {
internal partial ref embeddedLocalTypes_myInt myInt { get; }
public partial ref embeddedLocalTypes_MyInt MyInt { get; }
}
The generator follows. StructTypeTemplate’s promoted-struct accessor derived its access modifier from the same rendered type name; C# requires both halves of a partial member to agree, so the corrected declaration met a generator that still said the opposite (CS8799). The accessor now scopes by the member name, which is what every sibling accessor in that template already did. (Guarded by the LiftedLocalTypes behavioral test — value and pointer embeds of function-local named and struct types, positional and keyed construction, writes through an embedded field, and promotion through the embedded struct, all output-compared vs Go.)
⚠ Related but distinct, and still open: %T of a lifted function-local non-struct named type still prints the hoisted identifier, because only lifted STRUCT types carry the [GoLocalName] stamp that the reflection bridge reads.
An EMBEDDED field whose derived name equals the enclosing type is Δ-renamed
The sibling of the case above, one layer earlier: an embedded field’s member name is the
unqualified type name (Go spec), so it can equal the enclosing struct’s own name outright.
io’s io_test.go declares exactly that — a bytes.Buffer embedded in a struct called Buffer:
// A version of bytes.Buffer without ReadFrom and WriteTo
type Buffer struct {
bytes.Buffer
ReaderFrom // conflicts with and hides bytes.Buffer's ReaderFrom.
WriterTo // conflicts with and hides bytes.Buffer's WriterTo.
}
The NAMED-field path in visitStructType had renamed such a field since net’s type file struct{
file *os.File } (typeCollidingFieldName, the Δ/ΔΔ rules described under
Type-vs-Method Name Collisions), and every ACCESS
site already emitted the renamed form — fieldCollidesWithType compares the selector against its
enclosing named type without caring whether the field is embedded, and structFieldBoxName runs the
same rename for the box accessor. Only the EMBEDDED-field DECLARATION path was out of step: it
emitted getCoreSanitizedIdentifier(goTypeName) raw, so the declaration and its accesses disagreed
and the struct itself was CS0542:
[GoType] partial struct Buffer {
public partial ref bytes_package.Buffer ΔBuffer { get; } // was: `Buffer { get; }`, CS0542
public io_package.ReaderFrom ReaderFrom;
public io_package.WriterTo WriterTo;
}
The rename is applied once, before the four embed emission forms (interface embed, the two plain-field
arms, and the partial ref promotion), using the same raw compare the named-field path uses (escape
and Δ markers stripped on both sides), so the marker doubling for a keyword-family or already-Δ-
renamed enclosing type carries over unchanged. Promotion is unaffected — the TypeGenerator derives
the backing box and every promoted forwarder from the DECLARED member name, so Ꮡ + the renamed
member is what both the generator and the converter’s call sites spell (rb.of(Buffer.ᏑΔBuffer)).
The collision is only expressible across packages (one package cannot declare two types with the same
name), so it is absent from the single-package behavioral corpus and CNR is
byte-identical apart from the new guard. Cleared io’s test-host CS0542. Guarded by
EmbeddedTypeNameCollision (a main.Buffer embedding inner.Buffer, exercising BOTH halves the
rename must keep consistent — the explicit field selector b.Buffer.Data incl. a write-through, and
the promoted fields plus value- and pointer-receiver methods reached through it — with a composite
literal keyed by the embedded field and a new(T) zero value, values vs Go).
Promoted pointer methods descend multi-hop value-embed chains
A pointer-receiver method promoted through two or more embedded VALUE structs descends hop by hop: the first hop through the &-machinery (box-vs-parameter distinction), then one .of(<Owner>.<field-box>) view per additional hop – the ж<T> field views compose onto the method’s receiver box (reflect’s sliceType embeds abi.SliceType embeds abi.Type, whose Common() extension binds ж<abi.Type> – CS1929):
Ꮡ(rg).of(rig.ᏑDevice).of(CrossPkgLib.Device.ᏑSensor).Calibrate(3);
The own-receiver bare form joins the hop path (recv.E1.E2.method(...)); a chain broken by a pointer embed falls through unchanged. Guarded by CrossPkgUser.
A nil embedded pointer is holdable and assignable — only its dereference panics
Go permits an embedded pointer to be nil: constructing &Setting{name: name} with the embedded
*setting unset, comparing it (s.setting == nil), and assigning it after construction
(s.setting = lookup(…) — internal/godebug’s New → once.Do population shape) are all legal;
only dereferencing through the nil embed panics. The generator’s promoted-pointer machinery
(a ж<ж<T>> box behind a ref accessor) previously conflated the two: the accessor resolved the box
with .Value, which treats a null held value as a nil-pointer dereference — so the first touch of
an unpopulated embed (even the legal == nil comparison, or the assignment that would populate it)
panicked from the accessor. Two generator changes separate holding from dereferencing
(StructTypeTemplate):
-
The promoted-struct accessor resolves through
ValueSlot— golib’s nil-check-free real slot. TheᏑʗbox is a real constructor allocation, so resolving the accessor is a read of the held value, never a dereference of the box; reads and writes of the heldж<T>must not panic. -
The parameterized constructors box a nil pointer for an omitted POINTER embed (
arg ?? new ж<T>(nil)), matching what the NilType/parameterless constructors already emitted. The held value is then a nil box rather than a raw null, so a genuine deref of the nil embed panics with Go’sruntime error: invalid memory address or nil pointer dereference(recoverable) instead of surfacing an unrecoverableNullReferenceException, and a nil embed compares equal regardless of which constructor produced it (ж.Equals: nil == nil). Value embeds are untouched (their member type is a value type — never null).
// Promoted Struct Accessors
internal partial ref ж<setting> setting => ref Ꮡʗsetting.ValueSlot; // was .Value — panicked on first touch
// internal ctor: an omitted pointer embed holds a nil BOX, not a raw null
internal Setting(@string tag = default!, ж<setting> setting = default!)
{
this.tag = tag;
Ꮡʗsetting = new ж<ж<setting>>(setting ?? new ж<setting>(nil));
}
Go’s nil-deref panic is preserved downstream, where the actual dereference happens: the promoted
field/method accessors descend setting.Value.name / target.setting.Value.bump(), and that inner
.Value — on the held nil ж<T> itself — still routes through the strict panic path. (Guarded by
the EmbeddedPointerNilAssign behavioral test — compare-nil on a fresh instance, a recovered deref
panic through the nil embed, assignment of a nil pointer variable, post-construction population, and
aliasing through the populated embed, vs Go; each half discriminates independently — without (1) the
nil-variable assignment leg panics, without (2) the recover leg crashes with an unrecoverable NRE.)
A field promoted through an embedded POINTER is rooted at the POINTED-TO allocation
A Go pointer’s identity is the storage it names, and f.pfd for type File struct{ *file } is by
definition f.file.pfd — one address, whichever spelling reaches it. go2cs encodes a field reference
as (containing allocation, field token), so that encoding is right only while the accessor stays
inside the allocation it was handed. A promotion that crosses a pointer does not: the generated
accessor deref’d on the right,
// was — reaches the right storage, but describes the WRONG allocation
internal static ref FD Ꮡpfd(ref File instance) => ref instance.@file.Value.pfd;
so of() rooted the resulting pointer at the outer ж<File> box. Reads and writes still landed in
the real file.pfd (the ref is correct), which is why nothing looked wrong; only the identity
was, and it was wrong in the one way that cannot be seen locally — &f.pfd taken through *File and
&file.pfd taken through *file stopped being the same pointer.
The accessor now takes the hop before the reference is built, handing the inner type’s own accessor to the inner box:
// now — the pointer is rooted where Go roots it
internal static ж<FD> Ꮡpfd(ref File instance) => instance.@file.of(global::go.os_package.file.Ꮡpfd);
Call sites are untouched (Ꮡf.of(File.Ꮡpfd) still): golib gains a FieldPtrFunc<T, TElem> delegate
plus matching of/at overloads, and C# picks the overload by the accessor’s return type. The form
composes for a multi-level embed (fileWithoutReadFrom → *File → *file) because the inner
accessor may itself be this shape. Value embeds keep the plain ref form — their promoted fields
live in the enclosing allocation, so the existing rooting is already right — and so does a
cross-package embed, whose declaration syntax is unavailable to the generator: there the member
list comes from metadata and can surface public fields the inner declaration never had (the reflect
bridge’s hand-added abi.Type.sysType/arrayDims, promoted into runtime.rtype), for which no inner
accessor exists to name. That fallback is fail-loud, not silent — naming a missing accessor is CS0117
at the corpus build.
Two identity fixes in golib sit underneath it, both in ж<T>. A field reference’s SOURCE is now
compared by pointer identity, not object reference, because an of() chain mints a fresh
intermediate box on every access: Ꮡo.of(Outer.Ꮡin).of(Inner.Ꮡv) allocates a new ж<Inner> each time
it is evaluated, so &o.in.v == &o.in.v was false at depth two while correctly true at depth one.
Equals, GetHashCode and PointerOrderToken resolve the source through the chain now, the way
ReferentObject already did for lifetime questions.
Both defects surfaced as one symptom, and it is worth recording because nothing about that symptom
points at pointer identity: internal/poll’s FD.Close hung forever on a file whose Read was still
in flight. Close parks on runtime_Semacquire(&fd.csema) until the reader’s readUnlock →
destroy → runtime_Semrelease(&fd.csema) wakes it, and those semaphores are keyed by pointer
identity. The two spellings of &fd.csema — os.close’s, reached through ж<file>, and os.read’s,
reached through ж<File> — landed in different buckets, so the release never reached the acquire.
Everything else on the path was already faithful: syscall.CancelIoEx really did abort the blocking
ReadFile, and the read really did return Go’s file already closed. Guarded by
PipeCloseUnblocksRead (a goroutine blocked on a pipe read, a closer, output-compared against
go run) and EmbeddedPointerFieldIdentity (depth-2 chain equality, map[*T]V keying, and both
spellings of a field promoted through an embedded pointer).
Interfaces
Go interfaces are duck-typed: a type implements an interface simply by having the methods. The converter emits each user-defined interface as a partial interface with a [GoType] attribute, and the ImplementGenerator source generator discovers which concrete types satisfy it and emits the implementing glue plus the implicit conversions. As a result, assigning a concrete value to an interface variable is direct — no reflection lookup or .As(...) call is needed:
type Stringer interface {
String() string
}
type point struct{ x, y int }
func (p point) String() string {
return fmt.Sprintf("(%d, %d)", p.x, p.y)
}
func describe() Stringer {
return point{1, 2} // point implements Stringer -> assignable directly
}
[GoType] partial interface Stringer {
@string String();
}
[GoType] partial struct point {
internal nint x, y;
}
[GoRecv] internal static @string String(this ref point p) {
return fmt.Sprintf("(%d, %d)"u8, p.x, p.y);
}
internal static Stringer describe() {
return new point(1, 2); // implicit conversion emitted by ImplementGenerator
}
The well-known built-in interfaces (error, fmt.Stringer, etc.) are hand-written in golib/the baseline rather than [GoType]-generated, but concrete types implement them the same duck-typed way. (Earlier strategies used a generic As/reflection mechanism; that has been superseded by the compile-time source generators.)
Each discovered “concrete type implements interface” pairing is recorded as an assembly-level attribute in the package’s package_info.cs, e.g. [assembly: GoImplement<point, Stringer>], which ImplementGenerator consumes.
Two refinements to the recording pipeline (io’s NopCloser/eofReader, 2026-07-03): (a) the interface-inheritance prune drops only COMMON implementations, and only from the LOWER interface. When an interface embeds others (ReadCloser = Reader + Closer), a type recorded on both the derived and an embedded interface needs only the derived pair — C# interface inheritance covers the embedded one. The prune intersects the two sets and removes the overlap from the embedded interface’s set; it previously intersected in place on the derived set (the HashSet mutates its receiver), which emptied the derived interface’s recordings whenever the overlap was empty — GoImplement<nopCloser, ReadCloser> vanished and the return nopCloser{r} failed CS0029. (b) An INDEX-expression assignment target records against its ELEMENT type. mr.readers[0] = eofReader{} assigns to a []Reader element; the interface-detection previously tested the container’s root identifier (mr — never an interface; Go forbids indexing one), so no pair was recorded and the concrete literal emitted bare. The check now types the whole index expression, and the conversion-recording path keeps the element type rather than redirecting to the container. (Guarded by the InterfaceCasting extensions — rdCloser, an inheriting interface returned concretely while the embedded rdr has its own recording, plus an interface-slice element assignment.)
The prune matches a FOREIGN base by its canonical name: the inheritance tracking stores both the alias render the declaration emits (fs.FileInfo) and the getFullyQualifiedTypeName render (go.io.fs_package.FileInfo) — the implementation-map keys are canonical, so the alias form alone never matched a foreign embed and both the derived and base impls emitted the same explicit members (zip’s headerFileInfo : fileInfoDirEntry + fs.FileInfo, CS8646 ×6/CS0111 ×2). Structural bases track their canonical names the same way. (Guarded by CrossPkgUser’s stamped — a local interface embedding the foreign CrossPkgLib.Labeled, with seal recorded against both; only the derived record survives.)
An embedded INTERFACE FIELD forwards the members it declares in the pointer adapter. zip’s type nopCloser struct { io.Writer } satisfies io.WriteCloser with Write living on the embedded interface VALUE (Go promotes the field’s method set) and Close on the struct. The IжAdapter forwards still-unbound members that the field’s interface declares through the field itself — m_box.Value.Writer.Write(…) (CS1929 with no forward). Detection is semantic (a non-static field whose name equals its interface type’s simple name — the converter emits embeds as real fields), gated to a SINGLE embedded interface field, and filtered to members the field’s interface (including its inherited interfaces) actually declares. (Guarded by InterfaceCasting’s wrapSink{Animal} cast by pointer to the wider speakShutter — both the promoted and the own member called through the interface, runtime-verified vs Go.)
Promoted forwarders through a Δ-renamed embedded interface use the markerless FIELD name. The converter names an embedded field after the Go embed name, so a struct value-embedding an interface whose C# TYPE was collision-renamed (see Type-vs-Method Name Collisions) declares public log.slog_package.ΔHandler Handler; — the marker lives on the type only. testing/slogtest’s type wrapper struct { slog.Handler; mod func(*slog.Record) } (slog has both a Handler type and a Logger.Handler() method, so the type is ΔHandler) broke in BOTH generated wrapper forms because the ImplementGenerator derived the promoted-forwarder field name from the interface TYPE’s simple name: the value partial struct emitted bare ΔHandler.Enabled(…) (CS0103 cross-package, CS0120 same-package where the bare name binds the nested interface type), and the pointer adapter emitted m_box.Value.ΔHandler.Enabled(…) (CS1061). The field name is now the Δ-stripped simple name (GetSimpleName(…, dropCollisionPrefix: true), the same derivation StructTypeTemplate already used for embedded-field accessors) in all three places: the value template’s promoted arm, the pointer arm’s promoted fallback, and the pointer arm’s semantic embedded-interface-field detection (which compares field name to type name and otherwise never matches Handler vs ΔHandler):
// value partial struct — promoted members forward through the field:
public bool Enabled(nint level) => Handler.Enabled(level);
// pointer adapter — through the box value's field:
bool global::go.main_package.ΔHandler.Enabled(nint level) => m_box.Value.Handler.Enabled(level);
An overridden member is untouched (it forwards to the struct’s own method, this.Handle(…) / m_box.Value.Handle(…)), and a non-renamed interface’s simple name has no marker to strip, so every other promotion emits byte-identical code. (Guarded by ShadowedInterfaceEmbed — a Handler interface Δ-renamed by a Logger.Handler() method collision, value-embedded in a wrapper struct that overrides one of its three methods, cast to the interface by BOTH value and pointer, promoted and overridden members runtime-verified vs Go; cleared testing/slogtest’s 6 errors.)
A FOREIGN struct’s adapter forwards a PROMOTED interface method through the box value, not a phantom static. When the pointer adapter is generated in an assembly OTHER than the struct’s — the struct’s package class lives in a different namespace segment, so its extension methods are invisible to extension-method lookup — the ImplementGenerator forwards each interface member through a package-class STATIC call (xcoff_package.ReadAt(m_box, …)). But this only works for a method the struct declares directly (whose RecvGenerator ж/ref static exists). A promoted interface method — debug/buildinfo’s *xcoff.Section → io.ReaderAt, where Section embeds the io.ReaderAt interface so ReadAt is promoted, not declared — has no such static, so the forward targets a nonexistent overload (CS1501, “no overload takes 3 arguments”). The static forward is now gated on a real box/ref-bound static existing; when absent, the adapter forwards through the box VALUE — m_box.Value.ReadAt(…) — invoking the struct’s own PUBLIC promoted method (the same promotion File.ReadAt etc. rely on). A directly-declared method keeps the static forward unchanged (no churn). (Validated by the core/debug/buildinfo build — its *os.File → io.ReaderAt [direct ReadAt, static] and *xcoff.Section → io.ReaderAt [promoted, box-value] adapters — plus the full behavioral suite + tar/math-big/net corpus; a single-assembly behavioral guard cannot host it — the shape needs a struct embedding a THIRD package’s interface, cast cross-assembly, so GUARD OWED.)
Pointer-sourced interface values use a generated ADAPTER, not the value-boxing partial struct (2026-07-03). A Go interface value created from a pointer — var s Iface = &t, New(new(lockedSource)), Rand{src: &runtimeSource{}} — holds the pointer: every call through the interface mutates the original object, s.(*T) recovers that same pointer, and interface equality is pointer identity. The old emission deref’d the box into the C# interface (~box, boxing a copy) — aliasing divergence — and could not serve direct-ж receiver methods at all (a method that takes the address of a receiver field is emitted with the box AS its receiver, this ж<T>, which a struct’s this can never bind — math/rand lockedSource CS1929). The converter now records such casts as [assembly: GoImplement<T, Iface>(Pointer = true)], and ImplementGenerator emits a sealed adapter class instead:
internal sealed class runtimeSourceжSource : go.math.rand_package.Source, IжAdapter
{
private readonly ж<runtimeSource> m_box;
public runtimeSourceжSource(ж<runtimeSource> box) => m_box = box;
public object? Box => m_box;
long go.math.rand_package.Source.Int63() => m_box.Int63(); // direct-ж / ж-twin binds the box
// Equals/GetHashCode delegate to box identity (Go pointer-interface equality)
}
Cast sites emit the adapter around the box (Incrementer inc = new CounterжIncrementer(c);, src: new runtimeSourceжSource(Ꮡ(new runtimeSource()))), covering call arguments, keyed composite-literal fields, and var declarations; a pointer-typed operand in these positions renders as the box (isPointer ident context), not the deref’d receiver ref-local. Member forwarding picks the receiver form per method: direct-ж and [GoRecv] ref-extensions (whose RecvGenerator ж-twin exists) forward to m_box.M(...); plain value-receiver methods forward to m_box.Value.M(...) (Go copies the value at the call). The golib type-assert machinery (_<T>()) unwraps IжAdapter.Box so s.(*T) yields the original ж<T>, and AreEqual unwraps both operands so interface-vs-interface and interface-vs-pointer comparisons are box identity (ж<T>.Equals is already identity-based); iface == ptr/iface != ptr comparisons emit AreEqual(...) with the pointer operand kept as the box (the old iface == ~p deref form compared a copy). Because each adapter is a distinct class, the interface-inheritance de-duplication (dropping GoImplement<T, Source> when GoImplement<T, Source64> exists and Source64 embeds Source) exempts pointer-form pairs — a Source-targeted cast site references runtimeSourceжSource even though runtimeSourceжSource64 also implements Source. VALUE-sourced casts (var s Iface = t) keep the partial-struct implementation — Go copies the value into the interface there, which is exactly C#’s struct-boxing semantic. Known limits (documented, not yet needed by the corpus): a cross-package pointer cast keeps the old deref-copy form (the adapter class only exists in the impl type’s assembly — isLocalImplType gate), and asserting an adapter-held interface to a different interface (s.(Source64) on a Source-created value) is not yet unwrapped. (Guarded by the InterfaceCasting extension — pointer-receiver Counter with a direct-ж member cast to an interface, mutations verified through BOTH the interface and the original pointer, assert-back recovering the same box, and back == c pointer equality, run-verified vs Go; and by InterfaceImplementation’s output comparison — zoo[0] == f interface-vs-pointer identity.)
Non-empty interface-to-interface conversions use a forwarding adapter. A Go interface value may be assigned or passed to another non-empty interface when the source interface method set satisfies the target (var local localLabel = foreign, where foreign is CrossPkgLib.Labeled). C# has no structural conversion between unrelated interfaces, so the converter records the interface pair as [assembly: GoImplement<CrossPkgLib_package.Labeled, localLabel>] and emits the cast site as a generated adapter:
CrossPkgLib.Labeled foreign = new CrossPkgLib.Sensor(Name: "adapter"u8, Temp: 21);
localLabel local = new CrossPkgLib_LabeledᴠlocalLabel(foreign);
fmt.Println(labelOf(new CrossPkgLib_LabeledᴠlocalLabel(foreign)));
ImplementGenerator emits a sealed adapter implementing the target interface and IInterfaceAdapter, stores the source interface value, and forwards each target member to that value. The golib assertion/equality helpers unwrap IInterfaceAdapter.Value before type assertions, Implements<TInterface>, and AreEqual, so the wrapper behaves as an interface view over the original Go interface value rather than a new concrete payload. Guarded by InterfaceToInterfaceAdapter, which imports CrossPkgLib.Labeled, assigns it to a local compatible interface, passes it as a parameter, and output-compares the forwarded calls.
Two rules govern how concrete implementation records are emitted:
-
Only impl types declared in the current package are recorded.
ImplementGeneratorrealizes the attribute by emitting apartial struct <Impl> : <Interface>into the current package’s namespace and class — so it can only add an interface to a type defined in the same assembly. A pairing whose impl type is imported from another package (e.g.image/color/palettebuilding[]color.Color{ color.RGBA{…} }) is therefore not re-emitted in the consumer: that relationship is already established in the impl type’s own package (image/colorrecords[assembly: GoImplement<ΔRGBA, Color>]). Re-emitting it in a consumer would generate a broken cross-assembly partial (a fresh emptypalette_package.ΔRGBArather than the realcolor_package.ΔRGBA), so the converter skips any pairing whose impl type is not local. -
Multi-segment interface references are root-qualified. The
GoImplementattributes are emitted before the file’snamespacewith onlyusing go;in scope; that directive imports the types of namespacego(so a top-levelio_package.Writerresolves unqualified) but not its nested namespaces. A multi-segment package class such ascontainer.heap_package.Interfaceis therefore root-qualified togo.container.heap_package.Interfaceso it resolves; single-segment refs (io_package,sort_package) are left unchanged.
Every eligible interface carries runtime duck-typing shells — the sole resolver of a structural assert
The GoImplement STRUCTURAL recorders were a compile-time approximation of Go’s structural satisfaction, incomplete by construction — which is why, once these shells existed, they were retired outright (2026-07-25; see the RETIRED note under Multi-Result Values and Comma-Ok Forms). A dynamic type may live in a package converted after the interface’s own, and then no record can exist: a dynamic type may live in a package converted after the interface’s own, and then no record can exist. io/fs is converted before os, so fs/package_info.cs records only subFS→ReadDirFS — os.dirFS is unreachable — and every fsys.(ReadDirFS) against an os.DirFS(…) value silently missed. golib’s structural probe answered the question correctly (StructurallyImplements) but had nothing to construct, so the assertion still failed. That is what kept io/fs at 16/18 (Glob returning nothing, WalkDir seeing only the root).
With no dynamic code generation available (Native AOT), a per-interface compile-time artifact is the irreducible minimum, and it must live in the interface’s own package class — the only placement guaranteed loaded at every asserting site, and the only one that yields a single cross-assembly identity. TypeGenerator therefore emits, for every non-generic, non-constraint, non-empty [GoType] interface, two sibling shells, discovered through a new [GoInterfaceShell] stamp on the interface itself. No static member is added to the interface — that shape would be inherited by every embedding interface, which is both a large CS0108 hiding class and a method-set corruption (no Go type can implement a static helper), and it makes the shell NAMES non-contractual so the generator may disambiguate freely:
[global::go.GoInterfaceShell(typeof(ΔSpeaker<>), typeof(ΔSpeakerᴛObj), "Speak")]
public partial interface Speaker
{
}
Tier 1 — ΔI<ᴛTTarget>, delegate-bound, for a REFERENCE-typed dynamic value (every ж<X> receiver box, i.e. every pointer-sourced Go interface value — the dominant case). Each interface method is a pre-bound delegate, so a forwarded call costs a delegate invocation, not a reflective one; that matters because a wrapper is obtained once and then called possibly millions of times (a duck-typed io.Reader inside io.Copy, a wrapped sort.Interface’s Less/Swap). It closes over the element type — the pointee — because that is what makes both receiver forms bindable, and the dispatch encodes Go’s *T method-set rule directly:
internal sealed class ΔSpeaker<ΔTTarget> : Speaker, IInterfaceAdapter
{
private delegate global::go.@string SpeakByPtr(ж<ΔTTarget> targetʗ);
private delegate global::go.@string SpeakByVal(ΔTTarget targetʗ);
private static readonly SpeakByPtr? s_SpeakByPtr;
private static readonly SpeakByVal? s_SpeakByVal;
public global::go.@string Speak()
{
if (m_targetᴛ_is_ptr && s_SpeakByPtr is not null)
return s_SpeakByPtr(m_targetᴛ_ptr!);
else
return s_SpeakByVal!(m_targetᴛ_is_ptr ? m_targetᴛ_ptr!.Value : m_targetᴛ);
}
static ΔSpeaker()
{
global::go.AdapterBinder.ResolveReceiverMethods(typeof(ΔTTarget), "Speak", out byPtrᴛ, out byValᴛ);
s_SpeakByPtr = byPtrᴛ is null ? null : byPtrᴛ.CreateStaticDelegate(typeof(SpeakByPtr)) as SpeakByPtr;
s_SpeakByVal = byValᴛ is null ? null : byValᴛ.CreateStaticDelegate(typeof(SpeakByVal)) as SpeakByVal;
…
ᴛBoundByPtr = boundByPtrᴛ; // read by the binder BEFORE the shell is handed out
ᴛBoundByVal = boundByValᴛ;
}
}
Tier 2 — ΔIᴛObj, reflective, for a VALUE-typed dynamic value (the forcing case: os.dirFS is [GoType("@string")] partial struct dirFS, a value type held in an fs.FS). It holds the value as object and forwards through MethodInvokers resolved once per (dynamic type, interface) pair, so it needs no generic instantiation at all. That is not a stylistic choice: under Native AOT ilc roots exactly the instantiations visible in source, and a MakeGenericType driven by a run-time GetType() over a value type is never one of them, so this is the tier that is unconditionally available. It is emitted only when every member survives the object round-trip (a Go variadic tail lowers to params Span<T>, a ref-struct that cannot be boxed):
The forwarder dispatches on ARITY (2026-07-26). GoShellBinding.Invoke used to build a fresh object?[args.Length + 1] on every forwarded call, purely to prepend the receiver — 32 B allocated and zeroed per call even for a Go method with no parameters at all, which is the common case (Len, Error, String, Less). The bound members are static extension methods, so MethodInvoker’s obj is always null and the receiver occupies the first argument slot; the BCL’s fixed-arity overloads take up to four arguments, so Go arities 0–3 now forward with no array and arity 4+ keeps the Span path. Measured on PerfIfaceShell (one object-tier call per iteration, provisional): JIT 633.7 → 588.0 ms, Native AOT 760.1 → 727.8 ms — the AOT column matters more in principle, because the binder’s belt degrades both shell tiers to this one there, so the cost is paid twice per iteration rather than once. The boxed return is not fixable this way: MethodInvoker returns object? and the shell unboxes, and removing that needs a non-reflective forwarder, which needs a generic instantiation — exactly what this tier exists to avoid. (Guarded by the ShellForwardArity behavioral test: one anonymous interface spanning arities 0–5 plus an int-returning and a mixed-parameter shape, every method folding its arguments into the printed result so a dropped, duplicated or reordered argument diverges from go run instead of passing silently. An instrumented run confirms all four fixed arms and the Span fallback are reached.)
internal sealed class ΔSpeakerᴛObj : Speaker, IInterfaceAdapter
{
object? IInterfaceAdapter.Value => m_targetᴛ;
public global::go.@string Speak() => (global::go.@string)m_bindingᴛ.Invoke(0, m_targetᴛ)!;
}
The tier choice branches on Type.IsValueType, and either tier belts to the other when construction fails — AOT rooting is source-shape sensitive, so “we never reach that instantiation” cannot be asserted, only constructed. (Honest limit: because tier 1 closes over the pointee, and a pointee is usually a struct, the pointer tier is AOT-graceful rather than AOT-guaranteed — an unavailable instantiation degrades to the object shell, not to a miss.)
One binder owns the method-set discipline. golib’s AdapterBinder resolves every binding through TypeExtensions.GetGoMethodSetCandidates(element, isPointer) — the same receiver rule StructurallyImplements applies, factored out for exactly this reason. So a value-sourced shell binds only value-receiver methods and can never widen a Go method set: PtrOnly{} (whose Speak has a pointer receiver) MISSES, &PtrOnly{} matches, and a pointer source calling a value-receiver method dereferences the box per call, matching Go’s copy-at-the-call. This also fixes a latent over-broad lookup: GetExtensionMethod collapses a closed ж<X> to the open ж<> definition (correct for single-dispatch precedence, wrong for a method-set query), so a name shared across types could bind the wrong receiver; the binder matches on element identity instead.
A candidate’s EMITTED name is not always its GO name — the method set must be read in Go names (2026-08-01). A Go method set is a Go-level fact reconstructed at run time from emitted C#, and the emitted name can carry the converter’s collision-avoidance marker instead of the Go name. A -tests variant Δ-renames a test-file method declarator whose bare name would hijack a same-named dot-imported function at every unqualified call site — B9 in performNameCollisionAnalysis, and a real compile fix, since C# resolves the enclosing class’s method group ahead of any using static import. io’s multi_test.go is exactly that shape: func (c *writeStringChecker) WriteString(string) against the dot-imported io.WriteString, so the method emits as ΔWriteString while io.StringWriter’s member keeps the bare WriteString. StructurallyImplements compared the two emitted names, answered MISS, no shell was built, and MultiWriter’s w.(StringWriter) fell through to the Write leg — which returns the same (n, err), so the divergence was silent: only Go’s own TestMultiWriter_StringCheckCall, which asserts that WriteString was called, could see it. TypeExtensions.GoMethodNameMatches now projects a candidate’s leading ShadowVarMarker away, the same way GoReflect’s type naming (ΔHandle → Handle) and struct-field projection already recover a Go name from an emitted one. It runs as a second pass, after an exact-name pass satisfies nothing, so a Δ-renamed candidate can never displace a plainly-named one; AdapterBinder.ResolveReceiverMethods applies the identical two-pass rule, because a binder that bound a method the probe would not have counted is precisely the disagreement GetGoMethodSetCandidates exists to prevent. Measured: io 47 → 48 of 54.
The COMPILE-TIME adapter reads the same rule — an interface member is IMPLEMENTED under the interface’s name and FORWARDS under the emitted one (2026-08-19). The run-time half above kept binder and probe in step; the third consumer, go2cs-gen’s ImplementGenerator, was left re-deriving nothing at all — it spelled the interface member’s Go name at BOTH positions. The two positions are not the same name. The member being implemented must always carry the interface’s name (an explicit implementation of Stringer.String is spelled String, and renaming it would implement nothing), while the implementation it forwards TO carries whatever the converter emitted — and those part company at exactly the Δ-rename above. flag_test.go’s five flag.Value types each declare String/Set against the production flag package the test variant dot-imports, so all ten emit ΔString/ΔSet; the adapter forwarded m_box.String(), which binds nothing on the box, and C# reported the nearest candidate it could see — bytes_package.String(ж<bytes_package.Buffer>), an unrelated extension. Ten CS1929, and the whole 24-verdict suite sat behind them.
The resolution is a LOOKUP, not a re-derivation: ImplementGenerator already builds localImplNames, every method the struct declares in either receiver form, so the emitted name is a fact in hand. Common.ResolveForwardMemberName matches the interface member against that set — exact name FIRST, the ShadowVarMarker projection only as a second pass — the identical two-pass shape GoMethodNameMatches runs at run time, so adapter, binder and probe now agree by construction rather than by coincidence. Exact-first is load-bearing rather than stylistic: Δ is a Unicode letter and therefore a legal Go identifier character, so a genuinely ΔX-named Go method must never be displaced by a projection of X. The resolved name drives the forwarding RECEIVER as well as the call target, because ForwardReceivers is keyed by declared names too — URLValue’s value-receiver ΔString(this URLValue) needs m_box.Value.ΔString(), and a Go-name miss fell through to the m_box default and stranded the call at CS1929 even once the target was right. ForwardName is null for every member the collision pass left alone, which is the whole production corpus: measured byte-identical across all 627 behavioral packages, and 0 errors on both solutions. Guard: src/tests/GenTests/CollisionRenamedForwardTests.cs. Measured: flag 0 (build-blocked) → 23 of 24.
TryCreate is FAIL-SOFT. The structural probe is deliberately NAME-ONLY for an open-generic receiver method — a Go gbox[int] whose Get() T returns an int matches interface{ Get() string } under that weaker rule (measured). Go answers ok=false there, so a shell that cannot be built answers false rather than escaping as an exception; a false-positive match must reproduce today’s harmless miss, never a crash.
Tier order in builtin.TryTypeAssert is unchanged, and the shells fire only where it previously answered MISS: nominal case T → IжAdapter unwrap → AdapterRegistry (compile-time adapters) → shell memo → Implements<T> gate → shell construction. That is the regression floor — no assertion that already resolved can take a different path. Construction costs on the order of a microsecond and fmt probes three interfaces per formatted value, so the memo ships in the same change: AdapterRegistry caches the (dynamic type, interface) decision, including the negative. It is deliberately not cleared on AssemblyLoad — Go fixes a type’s method set at compile time, and the extension methods carrying it live in that type’s own assembly, which is loaded by definition when we are holding an instance of it. (Extension-method discovery caches are still invalidated on load; only this decision cache is not.)
The two memoized tiers were folded into ONE per-interface itab cache (2026-07-26). The ladder above consulted the nominal AdapterRegistry (a (Type, Type) tuple hash) and then a separate per-interface shell memo, in sequence, on every assert — and for a shell-resolved pair the first of those could never hit, by construction: a hit would have returned before the shell tier was ever reached. Together with a target.GetType() whose result was dead for an interface target (its only consumers sat behind a typeof(T).IsValueType && that short-circuits false), a memoized assert cost three GetType() calls, two dictionary lookups and two RuntimeType property calls before it did any work. builtin.Itab<TInterface> replaces both reads with one: a single entry per (dynamic type, interface), holding the resolver whichever tier produced it — a registered nominal adapter factory, a runtime shell factory, or null for a decided MISS — exactly as Go keeps one *itab per pair. AdapterRegistry is unchanged as the authoritative durable record; the itab is a projection formed by reading it back, never by forming a second decision. IsInterface/IsValueType are hoisted to per-closed-generic statics, and a monomorphic slot sits in front of the dictionary (an assert site is overwhelmingly single-typed, the locality assumption Go’s per-site checks rely on), so the steady-state read is a static field load, an int compare and a reference compare. Tier precedence is unchanged — nominal still wins, shells still fire only where it answers MISS.
Two correctness points are load-bearing. The entry is one immutable object, never two static fields: a torn (type, resolver) pair would silently construct the wrong implementation. And unifying the caches reintroduces the hazard the two separate dictionaries avoided by construction — a shell (or a decided miss) memoized before a lazily-loaded assembly’s module initializer registers the nominal adapter for that same pair — so AdapterRegistry.Register bumps an epoch (only on a TryAdd that actually adds; the registry is first-wins, so a duplicate can invalidate nothing) and every itab entry carries the epoch it was decided under. An entry from an older epoch is simply never matched and is overwritten when its pair is next formed, which needs no clearing step that could race a concurrent fill. Registration is startup-time and append-only, so the steady state never re-forms anything. A side effect of recording the miss as null rather than reading a factory back is that a miss is now stable: previously a pair whose factory exists but throws its binding failure out of construction answered false once and then re-threw on the next assert, because the projection had published that factory.
Measured on PerfIfaceShell (5M iterations, each two asserts plus two forwarded calls; provisional, taken on a contended machine): JIT 789.8 → 683.1 ms for the unified cache alone, → 633.7 ms with the monomorphic slot — 158.0 → 136.6 → 126.7 ns per iteration. Native AOT 976.5 → 866.2 → 760.1 ms, a larger relative win because under AOT both shell tiers are reflective and dictionary work is a bigger share. (Guarded by the ItabLateRegistration behavioral test — shells decided first, a real module initializer registering real generated adapters afterwards, every pair re-formed, misses still misses, two interfaces over the same types kept separate — and by tests/GolibTests/ItabEpochTests, which pins what Go cannot express: tier precedence is deliberately invisible from Go, since a shell and an adapter forward to the same receiver methods, so the epoch’s effect is only observable at the golib level, where a memoized MISS must start succeeding once an adapter is registered for it.)
Guarded by three behavioral projects, each pairing a main package with a sibling library so the interface’s package genuinely cannot see the concrete types (the io/fs shape): NamedInterfacePointerMethodSet (the X3 negative — a value of a pointer-receiver-only type must MISS — plus the positives, a wrong-signature miss and the fail-soft generic-receiver miss), NamedInterfaceLateAssert (a value-typed defined string satisfying a THREE-DEEP embedded interface chain cross-assembly, an unexported interface, and a partial implementer that must not satisfy the derived interface), and NamedInterfaceAdapterIdentity (%T, re-assert, type switch and interface equality through a shell). Counter-proven: with shell emission disabled, all nine NamedInterfaceLateAssert assertions revert to MISS.
The ladder’s CONCRETE-target miss read a custom attribute per call (fixed 2026-07-26) — this was the whole of the Iface benchmark row. Everything above tunes the INTERFACE-target tiers. The concrete-target tiers below them ended in one that answers Go’s rule that only dynamic (anonymous) struct types convert to each other, and it asked that question with Type.IsDynamicType() — whose body is an uncached GetCustomAttribute<GoTypeAttribute>() that materializes a fresh attribute instance on every call. Measured against live golib: 785.92 ns and 368 bytes per call on the JIT, 3,826.27 ns and 2,017 bytes under Native AOT (ILC parses the attribute blob out of the compiled image’s metadata each time and has no equivalent of the JIT’s attribute caching). Every v, ok := x.(SomeStruct) that does not match reached it, so the ordinary named-struct assert — the most common assert in Go code — paid it corpus-wide, and it is what made PerfIface the worst row in the performance table at 158× Go on the JIT and 660× under AOT, a benchmark whose asserts miss on 4 of every 6 iterations. Two layers, one root cause: IsDynamicType now memoizes per type, beside GetStructFieldNames, which memoizes for exactly this reason (deliberately not added to ClearTypeCaches — like the field-name cache, an assembly load cannot change a type’s own attributes); and AssertFacts<T> gains IsDynamic, joining the IsInterface/IsValueType per-closed-generic facts already hoisted there, so a named-struct miss short-circuits before GetType() is even called. Reordering pure predicates in the && chain preserves semantics exactly, and AnonymousStructs pins both directions — an anonymous struct asserted against an identically-shaped anonymous struct must HIT, and against a named struct with the same fields must MISS.
One marker probe answers “is this a wrapper?” for both adapter tiers. With the attribute lookup gone, what remained on the common path was four failing interface type tests per type-switch-plus-assert iteration: builtin.type() probes IInterfaceAdapter then IжAdapter, and TryTypeAssert probes the same two. That is not free, and the asymmetry is the runtime’s, not the emission’s — measured on a boxed value implementing neither, one failing interface test costs ~2.9 ns (JIT) where one failing sealed-CLASS test is below measurement noise, because a failing interface isinst walks the type’s interface map while a failing class test is a short parent-chain compare the JIT inlines. Both consumers ask the same question first — is this object standing in for another value? — and for an ordinary Go value the answer is no, so it is worth exactly one test. IжAdapter and IInterfaceAdapter now share an empty base marker, IGoAdapter, and both call sites probe it once to gate their two tiers. Generated adapters implement the base transitively, so go2cs-gen is unchanged and no emitted C# moves. Ordering is preserved where it is load-bearing: in TryTypeAssert the IжAdapter match leaves the switch but stays BELOW the string and case T arms (an adapter that is itself a T still resolves as that T), and the gate flag is re-read after each unwrap since what an adapter yields need not be one itself; in builtin.type() the string arm moves after the IжAdapter arm, which cannot change an answer because string is sealed and implements neither marker.
Measured end-to-end on PerfIface (20M iterations of one slice-of-interface read, two interface dispatches, one concrete comma-ok assert and a three-case type switch). The per-stage A/B is a --filter PerfIface run: JIT 10,117.8 ms (158.24×) → 458.1 ms (7.20×) for the attribute fix, → 379.7 ms (5.95×) with the marker probe. The published figure is the full-table quiet-machine run, median of 5: JIT 370.1 ms (5.86×) and Native AOT 262.3 ms (4.15×), against the pre-fix 10,117.8 ms (158.24×) and 42,228.2 ms (660.42×) — a 27× and 161× improvement respectively. Peak working set fell 41.3 → 23.1 MB (JIT) and 29.6 → 11.1 MB (AOT) as ~4.9 GB of per-assert attribute garbage stopped being allocated. A decomposition micro-benchmark against live golib (best-of-5, DOTNET_TieredCompilation=0) attributes the whole of it, per iteration of the emitted loop: slice element read 1.8 ns, two interface dispatches 3.9 ns, comma-ok assert 579.0 → 12.1 → 8.1 ns, type switch 16.0 → 11.0 → 4.4 ns — against a floor of 1.2 ns for a plain s is Circle c and 1.4 ns for a bare C# pattern ladder, both indistinguishable from the slice read alone. IfaceShell moved 44.58× → 44.13× → 40.58× across the same two changes — flat for the first (it is the oracle, and does not touch the miss tier) and improving on the second, since the shell tier enters through the same assert. AOT now beats the JIT on this row, reversing the pre-fix order, because ILC’s failing interface type tests are markedly cheaper (3.7 ns for two against 9.2 ns on the JIT).
ANONYMOUS (dyn) interfaces use the SAME shells — the second renderer is gone. An interface literal was the original duck-typing case, and it had its own machinery long before named interfaces got any: TypeGenerator stamped two static ᴛAs<ᴛTTarget> conversion methods plus a ᴛAs(object) overload onto every [GoType("dyn")] interface, emitted a Δ<Iface><ᴛTTarget> wrapper next to it (with a full operator/nil block it never needed), and builtin.TryTypeAssert reached that wrapper by reflecting for the method by name and closing it with MakeGenericMethod. Two renderers of one idea, and the older one carried three real defects the shells do not:
-
It was the last unbelted
MakeGenericMethodin the assert path.MakeGenericMethodover a run-time type is dynamic code: under Native AOT it succeeds only for an instantiationilcalready rooted, and there was no fallback tier — an unavailable instantiation was an unrecoverable MISS, silently wrong rather than degraded. The shells’IsValueTypebranch answers the same case with a shell that needs no instantiation, and belts the other way when it does. -
Its static members were on the interface. A converted interface’s statics are inherited by every interface that EMBEDS it, so
interface{ error; Temporary() bool }forwardedᴛAsoverloads into its own wrapper (CS0102 ×6) and demanded a static helper from the dynamic value’s Go method set — both had to be filtered out downstream. Attribute discovery removes the shape rather than the symptom. -
Its binding used the by-name extension lookup.
GetExtensionMethodcollapses a closedж<X>to the openж<>definition — right for single-dispatch precedence, wrong for a method-set query — so a method name shared across types could bind another type’s receiver.AdapterBindermatches on element identity.
Nothing about the emitted dyn interface changes except the disappearance of ᴛAs: an interface literal is still a [GoType("dyn")] partial interface, still resolved structurally at run time, still fail-soft. The dyn key is no longer read by TypeGenerator at all — its only remaining reader is the runtime’s Type.IsDynamicType, used for Go’s anonymous-struct-to-anonymous-struct conversion, which reads the [GoType] attribute directly. Guarded by the existing dyn corpus (AnonymousInterfaces, DynIfaceParamNameCollision, DynamicInterfaceKeywordMethod, AnonIfaceMethodSetWidening, AnonIfaceThroughPointerAdapter, AnonInterfaceCrossFile, AnonInterfaceSignatureAssert, DerivedInterfaceStructuralProbe, StructuralAssertFailSoftMiss), which is the dyn contract and stayed green through the migration unchanged, plus the PerfIfaceShell performance benchmark, which executes both tiers under a Native AOT publish.
golib’s three hand-written interfaces joined the same mechanism. error (golib), fmt.Stringer and io.Reader (the baseline stubs) predate the marker and expose plain As<T> helpers; TryTypeAssert found those by the same reflective probe and closed them the same way, so deleting the probe would have taken their duck-typing with it. Each is now stamped [GoInterfaceShell(typeof(<I><>), null, "<M>")] — their existing <I><T> carrier class is the delegate-bound generic shell, and always was — and each carrier’s (in T) constructor became (T), because AdapterBinder locates a shell’s constructor by exact parameter type and an in parameter is T& in metadata. null for the object shell is deliberate rather than a gap: a reflective tier would have to reproduce these carriers’ %v/%T formatting contract (error<T>.ToString(format, provider)), so a value-typed error still binds through the generic shell — AOT-graceful, exactly as before. Because a hand-written shell has no ᴛBoundByPtr/ᴛBoundByVal flags, the binder treats those as optional and forces the type initializer explicitly (RuntimeHelpers.RunClassConstructor), so an unbindable pair is still decided — and memoized — at factory-build time rather than rediscovered per construction.
Cross-package pointer-to-interface conversions use the foreign adapter
A pointer-sourced cast to an interface implemented by a FOREIGN type references the foreign assembly’s PUBLIC adapter class - os’s err = &PathError{...} emits new fs.PathErrorжerror(Ꮡ(new PathError(...))), io/fs having generated the adapter from its own GoImplement<PathError, error>(Pointer = true) record. The record’s existence is read from the imported package’s package_info (parseExportedPointerImplements, the same imported-records pattern as GoTypeAlias). The existence key is the shared canonical spelling both sides compose through implementRecordKey — <declaring package>|<C# simple type>|<pkg>_package.<Iface> — exactly as the value-implement records do; keeping the package CLASS on the interface side is what stops image’s Paletted→image.Image record from satisfying a Paletted→draw.Image cast and referencing the adapter that implements the WRONG interface (CS1503). The reference goes through the file-local package ALIAS (fs.PathErrorжerror, user-ruled style) via getAliasQualifiedTypeName, which also registers the using — except when that yields a whole-TYPE alias for a collision-renamed foreign type (imageꓸRGBA), which is an identifier and not a path, so the base is rebuilt as the package qualifier plus the type’s EMITTED simple name (image.ΔRGBAжImage). Guarded by CrossPkgUser (rep = mtr -> new CrossPkgLib.MeterжReporter(mtr); &CrossPkgLib.Alarm{} -> error; and the same-simple-name LOCAL Labeled — var localLb Labeled = sp2 takes the LOCAL CrossPkgLib_SensorжLabeled, never the lib’s exported SensorжLabeled).
An EXPLICIT pointer-to-interface conversion — Go’s image.Image(dst) with dst *image.RGBA (image/draw) — is the same interface cast in conversion clothing and routes through the same machinery: isTypeConversion probes the ORIGINAL pointer type against an interface target (the value type alone does not implement it — the elem-only probe misread the conversion as a constructor call, new image.Image(dst), CS0144), and the emission re-renders the argument in its BOX form and CASTS the adapter to the interface — ((image.Image)new image_ΔRGBAжImage(Ꮡdst)) — because the adapter implements its members explicitly, and a chained member access on the conversion result (CrossPkgLib.Labeled(sp).Label()) cannot bind on the adapter class itself (CS1929). (Guarded by CrossPkgUser’s CrossPkgLib.Labeled(sp2).Label() / LabeledOf(sp2) pair, output-compared vs Go.)
%T (and type-name rendering generally) unwraps generated adapters and pointer boxes
Go’s %T prints the interface value’s dynamic Go type — *strings.byteReplacer, never an implementation artifact. The managed model interposes artifacts a name renderer must see through (strings’ TestPickAlgorithm, which %Ts each replacer algorithm, printed strings.byteReplacerжreplacer):
- a pointer-sourced ж adapter (
byteReplacerжreplacer : IжAdapter) stands in for the*Tit wraps → renders*strings.byteReplacer; - a value-sourced foreign ᴠ adapter (
typelib_Markᴠstamper) wraps a struct copy → renders the struct type,typelib.Mark; - an interface-to-interface adapter (
IInterfaceAdapter) forwards to its wrapped value’s dynamic type; - a raw receiver box
ж<T>(a pointer held in ananywith no adapter in its history) renders*main.loud; - a converted named type package-qualifies from its
<pkg>_packagedeclaring class:go.main_package+soft→main.soft.
The unwrap lives at the shared choke points, so every formatting path agrees: GoReflect.GoTypeName (which reflect.Type.String() serves the converted fmt’s %T from, via the Phase-1 reflection bridge) gains a TryAdapterWrappedType arm, and golib’s builtin.GetGoTypeName (the stub fmt %T, the testing shim’s TestFormat, and interface-conversion panic texts) unwraps IжAdapter.Box/IInterfaceAdapter.Value at the value level and routes ж/adapter/named types through GoReflect.GoTypeName. Adapter detection is structural, never name-parsed for the wrapped type: IжAdapter (or the ᴠ infix per Symbols.ValueAdapterInfix for value adapters) identifies the adapter, and the wrapped type is read from the adapter’s single one-parameter constructor (ж<T> → pointer-sourced; the struct type → value-sourced). reflect.Kind()/Elem() of an adapter type still report the adapter class (a reflection-bridge follow-up owned with R5’s DeepEqual work), but %T/String() — the only surface the corpus exercises — are Go-exact. (Guarded by FormatTypeAdapters — a two-project behavioral test whose typelib sub-package supplies the foreign value implementer, output-comparing %T over the ж adapter, the local value implementer, the raw box, a plain named struct, the ᴠ adapter, and a nil interface vs go run; without the unwrap it prints main_package+loudжgreeter / ж`1[[go.main_package+loud, …]] / main_package+typelib_Markᴠstamper.)
The VALUE mirror of the explicit conversion — crypto.SignerOpts(sigHash) with sigHash crypto.Hash (crypto/tls, CS0030 ×4) — routes a FOREIGN named non-interface VALUE source through the same convertToInterfaceType machinery, keeping the outer interface cast: ((crypto.SignerOpts)new crypto_HashᴠSignerOpts(sigHash)) plus the local value-form GoImplement<crypto_package.Hash, crypto_package.SignerOpts> record. A plain cast cannot bind here: a foreign value type implements its interfaces via extension methods (never structurally), and the converting assembly cannot partial a foreign type — the same reason the implicit both-foreign value arm exists (syscall.Signal→os.Signal). When the defining assembly already implements the pair (its package_info carries the value-form record), convertToInterfaceType falls through and the emission stays the plain cast spelling. LOCAL value sources originally kept the plain-cast route on the reasoning that a local type can be partial’d to declare the interface; that reasoning was half-right and the route now covers them too (next paragraph). The outer cast is load-bearing exactly as in the pointer arm — and additionally because var signOpts = … must type as the INTERFACE: each tls site reassigns signOpts to a different adapter two lines later (CS0029 hazard if the var typed as the adapter class). (Guarded by the CrossPkgLib/CrossPkgUser extension — Verdict implements Scored via a value receiver with deliberately NO witness in the lib, CrossPkgLib.Scored(CrossPkgLib.Verdict(4)) converts explicitly in the user package and the same var is then reassigned a local *tallies implementation, output-compared vs Go; whole-stdlib reconvert diff: exactly the four crypto/tls sites plus its package_info record.)
The LOCAL named VALUE source of the same explicit conversion — crypto.Signer(private) with type PrivateKey []byte (crypto/ed25519, CS0030 ×2) and pinUnexpMeth(EmbedWithUnexpMeth{}) (internal/reflectlite) — routes through convertToInterfaceType as well (2026-08-18). The original “no churn on local sources” boundary reasoned that a local type can be partial‘d to declare the interface, which is true and is exactly why the route matters: the partial is go2cs-gen’s, minted from an [assembly: GoImplement<T, Iface>] record, and a plain cast records nothing — so the cast had nothing to bind to whenever no other site recorded the pair. “No other site” is precisely the two shapes the speculative recorder (recordSamePackageImplements) declines: an interface declared in ANOTHER assembly (it pairs two locals only) and an UNEXPORTED local interface (its exported gate — a record is a cross-assembly contract). Framed by SYNTAX the rule is: Go’s Iface(x) and var i Iface = x are the same conversion, and the emission must not depend on which spelling the source used — the assignment form has always routed through convertToInterfaceType. For a local non-func value source the route is record-only (the expression text is unchanged, preserving the original boundary’s intent: a seeded whole-stdlib reconvert after the change is 1,668 emitted / 0 real differences / 0 new); a local named FUNC source is the one emission that moves, correctly, onto its generated ᴠ value adapter (a C# delegate cannot be a partial struct). An INTERFACE source still takes the plain cast — that position belongs to the recordableInterface class and is measured broken in call syntax on its own terms (valued(d) with d an interface throws InvalidCastException where assignment syntax builds the adapter), recorded on the phase-4 board as its own arc rather than folded in here. (Guarded by LocalValueIfaceCallConversion — slice-underlying and struct locals cast call-syntax to fmt.Stringer, the reflectlite shape verbatim at package scope, a no-churn local-exported-interface control, meter/gauge against one interface, identity/assert/map-key semantics output-compared vs Go — and by the converter-level TestLocalValueIfaceCallConversion, which pins the records that must appear, the emissions that must not move, and the interface-source position left untouched.)
No exported adapter — the LOCAL adapter for a foreign pair. When the defining package never
converts the pair itself (os never casts *File to io.Reader, so no record exists to
reference), the converting package records GoImplement<os_package.File, io_package.Reader>(Pointer
= true) locally and the generator emits a local adapter class for the foreign struct
(internal sealed class os_FileжReader; the m_box field is fully qualified). The class name is
package-qualified ({pkg}_{Struct}ж{Iface}): two same-named foreign structs adapting to one
interface otherwise compose a single colliding class — math/big records both bytes.Reader and
strings.Reader against io.ByteScanner (CS0102/CS0111/CS8646 ×8). The local VALUE adapters for
foreign structs qualify the same way (syscall_ΔSignalᴠΔSignal); a LOCAL delegate’s value adapter
stays bare (funcValueᴠValue). Forwarding decisions come from metadata — the compiled foreign
assembly exposes every converter and sibling-generator form as real symbols, so an extension on
ж<T> binds the box (m_box.Read(p)) and everything else binds the deref’d value
(m_box.Value.M(), ref extensions bind through the ref-returning Value). This replaces the old
deref-COPY fallback, so aliasing is faithful: fmt’s Fscan(os.Stdin, …) emits
Fscan(new os_FileжReader(os.Stdin), …) (CS1503 ×3, the last fmt family). Guarded by
CrossPkgUser (*Probe → Sampler via CrossPkgLib_ProbeжSampler, mutation read back through the
original pointer).
A cross-package interface’s unexported sealing marker is stubbed
Go seals an interface to its defining package with an unexported marker method — ast.Expr’s
exprNode(), ast.Stmt’s stmtNode(), ast.Decl’s declNode(), text/template/parse.Node’s
tree()/writeTo(). The method’s C# implementation is an internal extension in the interface’s
own assembly (internal static void exprNode(this ref IndexExpr _)), so an adapter generated where
the interface is CONSUMED — go/internal/typeparams casting go/ast’s *IndexExpr to ast.Expr, or
text/template casting *parse.RangeNode to parse.Node — cannot see it: forwarding
m_box.Value.exprNode() is CS1061. The C# interface member itself is public (unexported Go methods
render without a modifier), so it is still required — dropping it is CS0535, and an internal
interface member cannot be implemented cross-assembly at all. Because Go never lets a sealing marker
be called from outside its package, the adapter satisfies the member with a no-op / default!
stub instead of forwarding:
void global::go.go.ast_package.Expr.exprNode() { } // void marker
global::go.…parse_package.Tree global::go.…parse_package.Node.tree() => default!; // non-void marker
The ImplementGenerator flags a method as an inaccessible marker when its Go name is unexported
(GetScope == "internal"), its declaring assembly differs from the one the adapter is generated
into, and the struct declares no method of that name in the current compilation
(MethodInfo.IsInaccessibleMarker); a SAME-assembly impl keeps forwarding (the internal extension is
accessible there). Both the pointer (AdapterImplTemplate) and value (ValueAdapterImplTemplate)
adapters emit the stub. This greens go/internal/typeparams (whose only errors were the two
exprNode forwards) and is a prerequisite for text/template/go/doc. (Guarded by
CrossPkgLib/CrossPkgUser: the sealed Emitter interface with an unexported emitNode(), a
*Leaf implementing it, cast to Emitter in the consumer assembly — CS1061 without the stub.)
That third clause is the correction the white-box test model forced (2026-08-09, internal/profile).
The assembly comparison is a proxy for “there is nothing to forward to”, and it answers wrongly for
the one shape where a single Go package spans two C# assemblies: an INTERNAL (white-box) test
package. internal/profile’s proto_test.go is package profile — it declares packedInts and
its encode/decoder methods for the production package’s own unexported message interface. Same
Go package, different C# assembly, and genuinely reachable, because the test model mints an
InternalsVisibleTo grant for exactly this. Stubbing there is worse than a compile error: the
adapter COMPILES and silently does nothing, so marshal(source) returned an empty buffer and
unmarshal decoded nothing, with no diagnostic at any layer. Requiring the absence of a local
implementation — the struct’s own value/ref extensions plus its direct-ж primaries — leaves every
genuine marker stubbed unchanged, because a FOREIGN struct never declares the sealing method (Go
forbids implementing another package’s unexported method at all, so a [GoImplement] record naming
an unexported interface method can only come from a struct in that same Go package). The guard is
internal/profile’s own banked suite: the shape needs a white-box test package, which the behavioral
corpus has no way to express.
The white-box model then forced a SECOND correction to the same clause (2026-08-19, the crypto/tls
regression). internal/profile’s shape declares the STRUCT in the test compilation, so the
local-implementation evidence — gathered from the struct’s own declaration syntax — was findable
there. crypto/tls’s TestMarshalUnmarshal is the mirror shape: the struct is a PRODUCTION type
(*SessionState, metadata-only in the test compilation) and only its METHODS are test-declared
(handshake_messages_test.go’s marshal/unmarshal, the sole source of its handshakeMessage
satisfaction — Go lets a package’s test files add methods to its production types). With no local
declaration syntax the evidence set was EMPTY, both members classified as markers, and the identical
silent-stub failure recurred: marshal answered an empty buffer with nil error and the test reported
“failed to unmarshal” with no diagnostic. The evidence now also covers the friend bridge’s
extensions by receiver simple name, in BOTH receiver forms — direct-ж, and [GoRecv] ref (which
forwards through its RecvGenerator ж-twin, the same routing IsRefRecv applies to a local
declaration). Genuine markers still stub: a foreign struct with no bridge has no such extensions
anywhere in the compilation. (Guarded by GenTests.WhiteboxBridgeAdapterTests, which runs the real
generator over a two-assembly model of the shape, and the ref-scan rows in
GenTests.FriendBridgeBoxReceiverTests — the behavioral corpus still cannot express a white-box
test package.)
…so the DECLARING package must own the adapter, and its speculative record carves out for it
The stub above rests on one sentence — “Go never lets a sealing marker be called from outside its
package” — which is true and is not the whole rule. The marker cannot be called from outside; it is
called inside, on a value the consumer boxed, which is the entire reason a sealing interface has
unexported members in the first place. text/template/parse.ErrorContext(n Node) opens with
tree := n.tree(); html/template boxes an &n.BranchNode into that Node. Nothing in
html/template can call tree() — and nothing has to, because parse does it for them. A stubbed
tree() answers default! there, ErrorContext then substitutes its own receiver, and at
html/template’s (*parse.Tree)(nil).ErrorContext(e.Node) call site that receiver is also nil, so
~tree nil-dereferences. TestErrors was the symptom, three packages from the record that was never
written (2026-08-20).
So the stub is a last resort, not a design, and it must be unreachable for any pair that can be
realized properly. It can always be realized in ONE assembly: Go scopes an interface with an
unexported method to its declaring package, so every type that will ever implement it is declared
there, and the declaring assembly’s own adapter forwards the marker natively (its extension is
internal, and that is the assembly it is internal to). A consumer then references the exported
pkg.TжIface through the existing foreign-adapter-exists arm and mints nothing. That is what
recordSamePackageImplements already
does for the pairs a package satisfies but never witnesses — it simply withheld this one.
The gate it withheld on is generatorCanForwardPointerMethodSet, which demands every interface method
resolve DIRECTLY on the type (no promotion at all), on the stated reasoning that “withholding a
speculative record is always safe, because the consumer keeps the local adapter it had before”. That
sentence is true of an all-exported interface and false of a sealed one, where the local adapter
is not a fallback but an adapter that cannot work. *parse.BranchNode is exactly the shape: it
implements Node, but Type() and Position() are promoted from its embedded NodeType/Pos, so
the strict gate refused — and parse never casts a *BranchNode itself (it casts the
If/Range/With wrappers), so nothing demanded the record either.
The carve-out is therefore keyed on the interface, not on the type: when the interface carries an
unexported method, the pointer record falls back to the VALUE form’s depth-2 bound instead of being
withheld. It stays bounded — a promotion deeper than one embed hop is still refused, exactly as
before — and the shape the strict gate was written for (a speculative record whose promoted member
resolves through the wrong embedded POINTER hop, StructPointerPromotionWithInterface’s
MyCustomError) is an all-exported interface that never reaches the arm. Corpus footprint at
text/template/parse: one line, [assembly: GoImplement<BranchNode, Node>(Pointer = true)].
What remains stubbed is what should be: a pair the declaring package genuinely cannot realize
(promotion deeper than one hop, a generic, an unexported target) would still mint a consumer-local
adapter whose marker is a stub, silently. Censused after the fix, the standard library holds no such
pair: of the 1,307 ImplementGenerator adapters a whole-stdlib reconvert generates, zero
carry a => default! or empty-body member. The shape stays reachable — it is what to suspect when a
sealed interface’s member answers a plausible zero — but it has no instance today.
Guarded by CrossPkgLib/CrossPkgUser, extended for this: Emitter gains a VALUE-returning sealed
member nodeTag() string and the lib gains DescribeEmitter(e Emitter), the ErrorContext shape —
a declaring-package reader of the sealed member, on a value the consumer boxed. *Leaf (whole method
set declared directly, so its record was never withheld) is the control and reads leaf/lf either
way; *Branch (Emit promoted through its EmitBase embed) read branch/ before the carve-out
and reads branch/brn after — proven by neutering the carve-out and running the pair.
A dynamic interface’s runtime conversion class re-escapes a keyword method name
An anonymous or type-asserted interface is lifted to a [GoType("dyn")] partial interface (see
Anonymous interfaces used as an adapter target),
and for the dynamic form go2cs-gen’s InterfaceTypeTemplate additionally emits a runtime
conversion class — ΔI<ᴛTTarget> : I — that duck-types a target at run time by reflection-binding
each interface method to the target’s extension methods (the fallback for a duck-typed assertion the
compile-time ImplementGenerator could not resolve). When such a dynamic interface embeds an
interface carrying an unexported sealing method whose name is a C# reserved keyword — internal/testenv’s
interface{ testing.TB; Deadline() (time.Time, bool) }, where testing.TB has private() — that name
must be @-escaped in the generated class. The converter already escapes it in the interface itself
(void @private();), but the sealing method reaches the conversion class through the base-interface walk
(interfaceSymbol.AllInterfaces), and a symbol name read from Roslyn (IMethodSymbol.Name) arrives
UNescaped — unlike a syntax Identifier.Text. Emitting it raw yields void private() and
nameof(private); that syntax error corrupts the class body, and because the conversion class is nested
inside the public static partial class …_package container the parse recovery ejects every subsequent
operator into the static container — CS0715 (“static classes cannot contain user-defined operators”)
×25 plus a CS0246 cascade (~54): 84 errors from one keyword method. (The nesting itself is legal — a
non-static class nested in a static class holds instance members and operators fine, as every non-keyword
dynamic interface proves; only the broken body triggers the eject.)
The fix re-escapes the name only where it is emitted as its own identifier token — the method declaration
(MethodInfo.GetSignature) and each nameof(...) in the reflection-binding static constructor. The
compound delegate/field names ({Name}ByPtr, s_{Name}ByPtr) stay on the raw name: a keyword + suffix
is never itself a keyword, and @ cannot appear mid-token. EscapeCsKeyword is a no-op for every
non-keyword method, so all other dynamic-interface output is byte-identical. Emitted form:
internal class ΔcommandContext_type<ΔTTarget> : commandContext_type
{
private delegate void privateByPtr(ж<ΔTTarget> targetʗ); // compound name — raw
public void @private() { … } // declaration — escaped
// static constructor:
extensionMethod = targetType.GetExtensionMethod(nameof(@private)); // nameof — escaped
}
Greens internal/testenv (its only errors were this one method’s cascade). Guarded by the
DynamicInterfaceKeywordMethod behavioral test — a named TB interface with a private() sealing
method, embedded in a type-assertion’s anonymous interface so the lifted [GoType("dyn")] target’s
conversion class must implement the escaped @private(); it does not compile without the fix.
A keyword-named type’s interface adapters escape declarations and compose class names unescaped
A Go type whose name is a C# reserved keyword (type fixed struct{…}, type lock interface{…}) is
@-escaped by the converter everywhere it stands as its own identifier token ([GoType] partial struct
@fixed, ж<@fixed>, @lock l = f). Two other name paths mishandled such types:
-
ImplementGenerator’s emitted type positions. A LOCAL struct’s name reaches the generator as a bare Roslyn SYMBOL name — UNescaped, unlike display strings (ToDisplayString()usesCSharpErrorMessageFormat, which escapes, sogo.main_package.@lockarrives correct). Emitting the raw name producedpartial struct fixed : sizer— which the C# parser reads as a fixed-size-buffer declaration, ejecting mangled members into the static…_packagecontainer (CS0708'main_package.'“cannot declare instance members in a static class” plus a CS1642/CS1663/CS7092 buffer cascade) — and the same raw name inside the pointer adapter’sж<fixed>. The generator now appliesEscapeCsKeywordat those emission sites (InterfaceImplTemplate.StructName, the pointer adapter’s wrappedStructName, and the value-embed hop’s class qualifier); it is a no-op for every non-keyword name. -
Adapter class-name composition, BOTH sides.
@is only legal at the START of a C# identifier token, so a keyword part cannot carry its marker into a composed adapter name: the converter emittednew @fixedж@lock(Ꮡf), which lexes as TWO tokens (@fixedж+@lock— CS1526). Both composers now build from UNESCAPED simple names — the converter’sadapterTypeRef/valueAdapterTypeRefviastripSanitizationMarkers(which also clears a pre-qualifiedos_@fixed-style interior marker), and the generator’sAdapterNamecompositions viaGetUnsanitizedIdentifier— producingfixedжlock/fixedᴠlock. The composed name always contains theж/ᴠinfix or a package prefix, so it is never itself a keyword and needs no marker (the same rule the keyword-method compound names above rely on: a keyword + suffix is never a keyword).
Emitted form (from the KeywordNamedTypes goldens and its generated adapters):
sizer p = new fixedжsizer(Ꮡf); // converter cast site — composed, no marker
@lock lp = new fixedжlock(Ꮡf);
partial struct @fixed : global::go.main_package.@lock // generator value-form — escaped declaration
internal sealed class fixedжlock : global::go.main_package.@lock, IжAdapter
{
private readonly ж<@fixed> m_box; // escaped type reference
TypeGenerator and RecvGenerator were already correct — they read syntax Identifier.Text, which
keeps the @fixed spelling. Guarded by the KeywordNamedTypes behavioral test: struct fixed value-
and pointer-implementing sizer plus a keyword-named interface lock, with a pointer-receiver grow
exercising the RecvGenerator ж-twin on the keyword-named receiver.
An interface member’s keyword-named PARAMETERS escape in every generated implementation
The same symbol-vs-syntax asymmetry reaches parameter NAMES. sync.Map’s
func (m *Map) CompareAndSwap(key, old, new any) (swapped bool)
is emitted by the converter with the keyword escaped (any @new), but ImplementGenerator re-reads
the members off the interface SYMBOL when it realizes a [GoImplement] record, and
IParameterSymbol.Name — like IMethodSymbol.Name above — arrives with the escape stripped. Every
template renders that name straight into a declaration and a forwarding call, so the generated
explicit implementation emitted object new, which does not lex as a parameter; Roslyn’s recovery
reported CS0501 (“must declare a body because it is not marked abstract, extern, or partial”) on
the enclosing member — three of them in sync’s converted test build, one per type implementing the
test’s mapInterface.
The three symbol→MethodInfo projections (the interface-adapter path, the struct-adapter/explicit-impl
path, and MethodInfo’s own IMethodSymbol overload) duplicated the same tuple construction, so the
escape lands once in a shared ToParameterInfos extension that all three now call; it also carries the
in/ref/out prefix the two adapter paths need (an explicit implementation must reproduce the
ref-kind or it matches no member, CS0539). EscapeCsKeyword is a no-op for every non-keyword and for
an already-escaped name, so all other generated output is byte-identical:
bool global::go.sync_test_package.mapInterface.CompareAndSwap(object key, object old, object @new)
=> m_box.CompareAndSwap(key, old, @new);
Guarded by the SymbolParameterInfoTests GenTests cases (name escaping, ref-kind composition, and a
parse assertion on the rendered declaration + forwarding call — the failure is a parse failure, so
the string compare alone would not pin it) and by the InterfaceKeywordParamNames behavioral test,
which drives BOTH realization shapes — a pointer-receiver implementation (the ж<T> adapter) and a
value-receiver one — through an interface whose members declare new, lock, base and event
parameters, values vs Go.
A keyword-named addressed global’s heap-box field strips the escape after the Ꮡ prefix
An address-taken package-level var is backed by a heap-box FIELD plus a ref-returning property
(writeAddressedGlobalDecl). A keyword-named such global (var null = json.RawMessage([]byte("null")),
net/rpc/jsonrpc) arrives keyword-escaped (@null), and composing the box as Ꮡ + @null places the
escape INTERIOR to the identifier token — Ꮡ@null lexes as two tokens (a whole-file syntax cascade).
The Ꮡ prefix already de-keywords the composed name (the keyword + affix rule the adapter compositions
above rely on), so the field declaration strips the escape — matching every &null use site, which
already composed Ꮡnull through boxBaseName:
internal static ж<slice<byte>> Ꮡnull = new(slice<byte>((@string)"null"));
internal static ref slice<byte> @null => ref Ꮡnull.ValueSlot; // the var itself keeps its escape
var p = Ꮡnull; // use site, unchanged
Guarded by HeapKeywordVar (a package-level var null written through its pointer and read back both
ways), alongside its existing keyword-named LOCAL coverage.
A foreign struct’s promoted method forwards through its value embed
When the adapter’s struct is FOREIGN (defined in another assembly) it binds forwarding from METADATA
(the boxBound / refBound scan above); a member neither on its box nor a ref-static falls to
m_box.Value.M(). But an interface member the foreign struct PROMOTES through a VALUE-embedded field
has no extension on the struct’s OWN package class, so m_box.Value.M() is CS1929 — text/template
casting *parse.RangeNode to parse.Node, where RangeNode embeds BranchNode and the exported
String lives on BranchNode, not RangeNode. The generator now discovers the foreign struct’s
value embeds from metadata (GetForeignValueEmbeds: a member whose name equals its type’s simple
name) and, for a still-unbound member the embed’s package class declares as a public value/ref-receiver
extension (GetForeignValueReceiverMethods), forwards through the embed’s package-class STATIC:
global::go.@string global::go.…parse_package.Stringer.String() =>
global::go.…parse_package.String(ref m_box.Value.BranchNode);
The static form is required because the embed’s namespace is not imported in the adapter file (only
using go;), so an instance-form m_box.Value.BranchNode.String() cannot resolve the extension —
exactly as the foreign struct’s own extensions route through staticClass. The receiver argument
carries the extension’s ref-kind (ref/in/value). Rerouting is gated to a genuinely promoted member
(the struct binds it neither directly nor via a box/ref hop), so a struct that declares the method
itself is unaffected. This clears text/template’s last CS1929. (Guarded by CrossPkgLib/CrossPkgUser:
*Branch, which promotes the exported Emit through its EmitBase value embed, cast to Emitter —
CS1929 without the reroute.)
A promoted box-receiver method through an UNEXPORTED value embed is called cross-package via a public forwarder
An EXPORTED, pointer-receiver method that takes the address of a receiver field is emitted as a
direct-ж (box-receiver) primary M(this ж<T> …). When such a method is promoted through an
unexported VALUE embed — testing.T.Errorf, promoted from the embedded common (type T struct{
common; … }), or go/types’ TypeName/Var/Func, which embed object — an IN-PACKAGE caller
renders the descent through the embed’s box-field accessor (see Promoted pointer methods descend
multi-hop value-embed chains above):
Ꮡt.of(testing.T.Ꮡcommon).Errorf("…"u8, …); // in-package
Ꮡcommon is the TypeGenerator’s FieldReferences box accessor for the embed, and — matching the
embed’s unexportedness — it is internal. So a caller in another package/assembly (crypto/internal/
cryptotest, testing/slogtest, x/net/nettest, go/internal/gcimporter) cannot see it: a cross-assembly
reference to an internal member reads as CS0117 (“testing_package.T does not contain a definition
for Ꮡcommon”), not CS0122. Every path through the unexported embed (common, Ꮡcommon, its promoted
members) is internal, so no converter-only descent can reach it. The fix is two-sided:
-
go2cs-gen(StructTypeTemplate) — for a direct, non-generic VALUE embed, harvest the embed’s box-receiver primaries (GetBoxReceiverExtensionMethods, previously collected only for POINTER embeds) and, for each exported one, emit a single box-only shim (IsValueEmbedBoxRecv) that performs the descent internally, where theinternalaccessor is reachable:public static void Errorf(this ж<T> Ꮡtarget, @string format, params Span<object> argsʗp) => Ꮡtarget.of(T.Ꮡcommon).Errorf(format, argsʗp);No
this ref Toverload (a box receiver cannot bind on a value). The shim scope is the sharedmethodScope— the STRUCT’s exportedness, downgraded for a non-public return type — so it ispubliconly for an exported method on an EXPORTED struct returning void/public (the genuinely reachable case), andinternalon an UNEXPORTED enclosing struct (context’safterFuncCtx, reflect’sstructTypeUncommon), whoseж<T>receiver is itself internal — apublicshim there is CS0051. It is gated to an exported promoted method (an unexported one is never reachable across packages, so it needs no shim; its in-package callers keep the inline descent). The value embed is discriminated by!promotedStructType.Contains("<")(a plain value embed’s type name never carries<, whereas the pointer-box formж<…>and generic embeds do) — a more robust test than the@-keyword-escapedpointerEmbedTypeNamesmembership, whoseж<@file>-shaped names mismatch and mis-fired the shim onto os.File’s*filePOINTER embed (a strayFile.Ꮡfile.Value, CS0119). The embed’s own exportedness is NOT part of that discrimination — it was, until r56g, and the restriction was never a Go rule; see A value embed promotes its pointer-receiver methods into the outer POINTER method set below for why the narrower gate silently truncated a Go method set rather than merely skipping an unreachable shim. -
the converter (
convSelectorExpr) — when the promoted-method descent is reached through an unexported embed of a FOREIGN package (single hop), it drops the inaccessible.of(…)view and calls the promoted method DIRECTLY on the receiver box, binding the public shim:Ꮡt.Errorf("…"u8, …); // cross-package (Ꮡt for a deref'd param, tΔ1 for a lambda box param)The box is recovered from the first-hop
&embed-address the&-machinery already computes (the text before its last.of(), so it is correct for every receiver kind without re-deriving it.
(Guarded by PromotedEmbedLib/PromotedEmbedUser: Counter value-embeds an unexported common whose
exported Add/Report take &c.sum (box-receiver); the user package calls them on a *Counter local
and through a parameter, plus reads the exported Label field for contrast — output-compared vs Go.)
Plain-return-type addendum — a PLAIN (non-box) promoted method returning a public builtin. The
box-shim above covers a method emitted as a ж<T> primary (it takes &receiver.field). A method that
merely READS a field — testing.common.Name() (func (c *common) Name() string { return c.name }) — is
emitted as an ordinary Name(this ref common) extension, so the promotion machinery emits the usual
value + box forwarders Name(this ref T) / Name(this ж<T>) with body target.common.Name(). But their
scope was downgraded by the RETURN type: @string (and error, bool, nint, … — every golib builtin)
is a PUBLIC C# type whose Go-lowercase name the name-based GetScope heuristic reads as unexported, so
the forwarder was emitted internal and thus invisible cross-assembly. Cross-package the converter emits
the same bare Ꮡt.Name() (the foreign-unexported-value-embed arm fires for EVERY promoted
pointer-receiver method, not just box ones), which then bound a same-named FOREIGN extension —
x/net/nettest’s timeoutWrapper reads t.Name() == "…", and the only visible Name was
flag.Name(ref flag.FlagSet) (flag is imported by testing) → CS1929. The fix keeps the forwarder
public when its return type is GENUINELY accessible: go2cs-gen captures the return type’s ACTUAL C#
accessibility (MethodInfo.ReturnTypeIsPublic, computed by IsEffectivelyPublicType — the type and
every type argument / tuple element / array-or-pointer element is public, treating builtin special
types and use-site-bound type parameters as public) and, for the direct-unexported-value-embed case,
trusts it over the lowercase name (directEmbedIsUnexportedValue && method.ReturnTypeIsPublic):
public static @string Name(this ж<T> Ꮡtarget) { ref var target = ref Ꮡtarget.Value; return target.common.Name(); }
Every OTHER promotion keeps the conservative name heuristic (so no golden/compile churn), and an
UNEXPORTED enclosing struct still yields an internal forwarder (its ж<T> receiver is internal — a public
forwarder there is CS0051, and my change only prevents a downgrade below the struct’s own scope). This
greens x/net/nettest (census 271 → 272/302, zero regressions). (Guarded by PromotedValueEmbedLib/
PromotedValueEmbedUser: Widget value-embeds an unexported common whose plain Name() string is read
in an expression cross-package, alongside an unrelated Gadget.Name() — the foreign same-named extension
— output-compared vs Go; CS1929 without the fix.)
Pointer-expression-receiver addendum. The converter arm above recovers the box from the .of(…)
strip of the first-hop &embed address — which assumes the receiver has an addressable base (an
ident: a raw-box local, a deref’d param’s Ꮡx). A pointer receiver expression — a type-assert or
call chain like go/internal/gcimporter’s pkg.Scope().Lookup(name).(*types.TypeName).Type() — has no
such base: the &-machinery boxes a COPY (Ꮡ(x.@object), no .of( anywhere), so the arm silently
fell through to the spelled embed hop, internal cross-assembly (CS1061). A follow-up sub-arm
recognizes a pointer-typed receiver expression that renders as the raw box (pointer-typed and not
deref-aliased) and calls the promoted member straight on it — the box IS the receiver:
pkg.Scope().Lookup(name)._<ж<types.TypeName>>().Type(); // binds the public Type(this ж<TypeName>)
(Guarded by PromotedValueEmbedExprRecv: the promoted Name() called on a map[string]any
assert-chain receiver and on a constructor-call receiver, output-compared vs Go; CS1061 without the
fix.)
A value embed promotes its pointer-receiver methods into the outer POINTER method set
Go’s rule has no exportedness clause: for type S struct{ E; … } embedding E by value, the method
set of *S contains every pointer-receiver method of E, because &s.E is addressable. (The method
set of a plain S does not — that half is the narrowing this subsection also has to preserve.)
The shim above emitted exactly that promotion, but only for an unexported embed. That gate arrived
with the cross-package-reachability problem it solves (testing.T.Errorf, whose Ꮡcommon accessor is
internal) and reads as a scoping decision, which is why it looked harmless: for an EXPORTED embed the
accessor is public, so the converter’s own call sites descend inline and never need a shim.
They are not the only reader. golib reconstructs a Go method set at RUN TIME by scanning the EMITTED
extension methods (TypeExtensions.GetGoMethodSetCandidates, shared by the StructurallyImplements
probe and AdapterBinder’s shell binder — see Every eligible interface carries runtime duck-typing
shells). So an un-emitted promotion is not a missing convenience, it is an ABSENT Go method: the type
stops satisfying interfaces Go says it satisfies, at every site the compile-time recorders cannot reach.
debug/dwarf is the reached case. Its readType asserts to an anonymous interface —
typ.(interface{ Basic() *BasicType }).Basic()
— which the converter lifts to a package-local [GoType("dyn")] partial interface readType_type. The
concrete types (*IntType, *UintType, *CharType, *UcharType, *FloatType, …) satisfy it only
through func (b *BasicType) Basic() *BasicType promoted from their exported BasicType value embed, and
the value is held as a different named interface (Type) at the assertion site — so no compile-time
witness can exist for the pair and the run-time tier is the only thing that can answer. It answered MISS:
panic: interface conversion: interface {} is *dwarf.UintType, not dwarf.readType_type
The gate is now the Go rule — any direct, non-generic VALUE embed promotes its box-receiver primaries —
and the shim keeps its ж<S>-only receiver, which is what preserves the narrowing half (a Uint VALUE
must still miss the same anonymous interface):
public static ж<BasicType> Basic(this ж<UintType> Ꮡtarget) => Ꮡtarget.of(UintType.ᏑBasicType).Basic();
The of(…) view is load-bearing rather than incidental: it aliases the embedded storage, so dwarf’s
caller writing t.Name/t.BitSize through the returned *BasicType reaches the real field. A
copy-returning promotion would have compiled, run, and printed plausible zeros.
Reachability addendum — the shim was emitted, and emitted unreachable. Widening the collection gate
made the promotion EXIST; it did not by itself make it bindable. The shim’s scope is the shared
methodScope, whose return-type downgrade runs the name heuristic — and GetSimpleName reduces a type to
its last dotted segment, which for a Go MULTI-RETURN is error). Lowercase. So every tuple-returning
promoted method read as unexported and was emitted internal. archive/zip is the reached case: Open,
promoted from ReadCloser’s exported Reader embed, returns (io.fs.File, error), so the package’s own
test assembly could not bind the shim its ReadCloser→fs.FS adapter needed (CS1929, the whole
package build-blocked behind it). The accurate test (method.ReturnTypeIsPublic, from
IsEffectivelyPublicType, which walks tuple elements) already existed for the plain-return case; it now
also applies to IsValueEmbedBoxRecv, which is the stronger case for it — that shim exists precisely to
be reachable across assemblies, since it performs a descent the caller cannot spell, so emitting it
internal defeats its own purpose. Every other promotion keeps the conservative heuristic.
Only the collection gate widened. The return-type relaxation’s OTHER arm
(directEmbedIsUnexportedValue && method.ReturnTypeIsPublic, in the plain-return addendum above) stays on
the narrow condition, because it answers a different question — a cross-package call the converter emits
as a bare Ꮡt.M() — which remains the unexported-embed case alone.
Third gate, same reasoning — the promoted METHOD’s own exportedness (2026-08-29). The shim above kept
one more GetScope(…) == "public" test, this one on the method name, on the argument that “an
unexported method is never reachable across packages, so it needs no shim and its in-package callers keep
the inline descent”. That is true of the call sites and false of the method set — the identical
distinction the embed-exportedness paragraph draws, one gate over. net’s vectored write is the reached
case, and it is a silent behavioral divergence rather than any diagnostic:
type buffersWriter interface{ writeBuffers(*Buffers) (int64, error) } // UNEXPORTED, package net
func (v *Buffers) WriteTo(w io.Writer) (int64, error) {
if wv, ok := w.(buffersWriter); ok { // the ONLY thing that ever asks
return wv.writeBuffers(v) // writev fast path
}
… // per-chunk fallback
}
*net.TCPConn satisfies it only by promoting the unexported writeBuffers from its embedded
unexported conn, and conn’s methods are direct-ж primaries (ok compares the receiver against nil),
so the promotion is exactly the box shim this subsection emits. With the shim withheld, the emitted method
set had no writeBuffers, StructurallyImplements answered False, the assert MISSED, and the fallback
ran — the program is correct, just not vectored, which surfaces only as TestBuffers_WriteTo’s
write calls = 0; want 1 (nine verdicts, writev_test.go:91). Measured on the built net assembly, before
and after:
method-set entry : writeBuffers ABSENT from *TCPConn's Go method set | net_package.writeBuffers(this ж`1 …) [internal]
structural probe : False | True
type assert : False -> <miss> | True -> ΔbuffersWriter`1
The shim for an unexported method is emitted internal, not at methodScope: that keeps an unexported
Go method off the assembly’s public surface while leaving it inside the set the run-time probe reads, since
GetGoMethodSetCandidates resolves extension methods through NonPublic binding flags exactly as it does
the converter’s own internal static M(this ж<T> …) primaries. Guarded by the UnexportedIfaceDynamicAssert
behavioral test, whose conn (method declared directly) and ValueSink (value method set) rows are the
controls that hold while the promoted TCPConn row diverges.
No GoImplement record is involved, and none would have helped. The pair is same-package and the
interface is unexported, so recordSamePackageImplements’ exported-interface gate declines it by design —
correctly, since a record is a cross-assembly contract and no other assembly can name buffersWriter.
The resolution path for such a pair is the run-time tier alone: TryTypeAssert unwraps the io.Writer
adapter to the ж<TCPConn> box, misses the nominal AdapterRegistry, and binds AdapterBinder’s
generated ΔbuffersWriter<> delegate shell — which needs no record and no AdapterRegistry change, only a
complete method set. That is why the fix belongs to promotion emission and not to record emission: the
record layer was never the variable.
A named field whose name equals its interface type is NOT an embedded interface
ImplementGenerator forwards an interface member it cannot bind directly through an embedded interface
field (zip’s type nopCloser struct{ io.Writer } → m_box.Value.Writer.Write(…)), and detects one by
NAME: the field’s name equals its interface type’s simple name, modulo the Δ collision marker (the
converter names the field after the Go embed, so a Δ-renamed interface TYPE keeps a markerless FIELD —
slogtest’s wrapper embeds slog.Handler as Handler).
That test cannot, by itself, be right. Go emits an ordinary named field Type Type and an embedded
Type to the same C# field declaration, and only the first promotes nothing. debug/dwarf carries both
shapes in ONE struct:
type PtrType struct {
CommonType // a real embed — promotes Common()
Type Type // an ordinary field — promotes nothing
}
Common() was therefore forwarded through the FIELD, returning the referenced type’s CommonType
instead of the receiver’s own — a silent wrong answer whenever Type was non-nil, and a null dereference
when it was not. Five dwarf structs carry that field (QualType, ArrayType, PtrType, StructField,
TypedefType).
Resolved by precedence, not by a new signal — none is available, since the two emissions are identical
by construction. Promotion through a marker-backed depth-1 value embed (public partial ref CommonType
CommonType { get; } — a hard converter marker the name heuristic is not) now resolves BEFORE the
interface-field arm:
ж<CommonType> ΔType.Common() => m_box.of(PtrType.ᏑCommonType).Common(); // not m_box.Value.Type.Common()
Legal Go guarantees the two can never both be correct at depth 1: promoting one member from two depth-1
embeds is an ambiguity the Go compiler rejects, so a struct where both arms answer is a struct whose
“interface embed” is really a plain field. Deeper embed levels stay BELOW the interface arm, matching Go’s
shallower-embed-wins rule. Implemented by running the existing .of(…) descent in two passes (maxDepth
1, then 4) so the “what can bind at this hop” logic is not duplicated and cannot drift from itself.
(Both subsections guarded by PromotedEmbedAnonIfaceWitness, which pins the anonymous-interface assert
from a named-interface-held value, the two-concrete-type spread, the type-switch form, the ALIASING write
back through the promoted pointer, the VALUE-must-miss narrowing, and the Ptr{CommonType; Node Node}
shape checking the VALUE Common() returns rather than merely that nothing crashed. debug/dwarf
30 → 40 of 40.)
Cross-package value-to-interface conversions use the local VALUE adapter
A VALUE conversion of a FOREIGN named type to a LOCAL interface (os’s Signal interface is
DOWNSTREAM of syscall.Signal — neither assembly can partial the other) records
GoImplement<foreign, localIface> locally; the ImplementGenerator detects the foreign struct
(different containing assembly, no local declaration) and emits a value adapter class
{pkg}_{Struct}ᴠ{Iface} (composed with Symbols.ValueAdapterInfix; package-qualified for a
FOREIGN struct — see the pointer-adapter collision note above) wrapping a COPY of the struct
— exactly as a Go interface holds a value — with value equality. The conversion site emits
new syscall_ΔSignalᴠΔSignal(sig). The adapter’s struct field is fully qualified
(GetFullTypeName(true)): the bare name resolved to the LOCAL same-named type when os’s
ΔSignal interface shadowed syscall’s ΔSignal struct.
Method forwarding uses the container-qualified static form —
global::go.encoding.binary_package.Uint32(m_value, b) rather than m_value.Uint32(b):
converted Go methods are extension methods on the package class the struct nests in, and the
instance form only resolves when the generated file has a using for that namespace (using go;
covers root-namespace packages like io/os, but a sub-namespace package like encoding/binary
never resolved — debug/plan9obj CS1061 ×6). The static form is exactly equivalent and needs no
using at all.
BOTH-FOREIGN value pairs take the same route. When the interface is foreign too
(debug/plan9obj passes binary.BigEndian, an encoding/binary value, as binary.ByteOrder),
the converter first consults the imported package_info records (parseExportedValueImplements,
plain or Promoted GoImplement forms): if the defining assembly already implements the pair,
the bare value converts implicitly and nothing is recorded. Otherwise the pair is recorded
locally and the conversion site wraps in the locally generated value adapter
(new binary_bigEndianᴠByteOrder(binary.BigEndian)) — the value sibling of the both-foreign pointer
adapter above.
A value adapter declares IValueAdapter, so the runtime sees the Go DYNAMIC TYPE through it.
The adapter is a C# wrapper class, but Go’s dynamic type of the interface value it carries is the
wrapped struct. golib settles that question at three places — AreEqual (Go ==), TryTypeAssert
(x.(T) and every type-switch case guard), and type() (the type-switch operand) — and each one
unwraps the OTHER two adapter kinds through their marker (IжAdapter.Box, IInterfaceAdapter.Value)
while the value adapter carried no marker at all. So all three answered against the wrapper class:
got := dst.At(0, 10) // image: color.Color carrying a color.NRGBA
got == color.NRGBA{…} // Go true -> C# false (AreEqual: type mismatch, bails
// before the adapter's own Equals ever runs)
v, ok := got.(color.NRGBA) // Go ok=true -> C# ok=false
switch got.(type) { case color.NRGBA: … } // Go matches -> C# fell to default
The panic text made the shape unmistakable: interface conversion: interface {} is
colorlike.NRGBA, not colorlike.NRGBA — %T already unwrapped (through TryAdapterWrappedType)
while the assert did not. The fix is the missing third marker, mirroring the other two exactly:
public interface IValueAdapter : IGoAdapter { object? Value { get; } }
ImplementGenerator’s ValueAdapterImplTemplate now emits it on every ᴠ adapter —
explicitly implemented, so it can never collide with a forwarded Go method named Value (a
promoted adapter binds its members by bare name) — and the three sites unwrap it beside the pointer
kind through one shared UnwrapAdapter helper, still behind the single IGoAdapter probe that
keeps an ordinary Go value at one failing interface test. GetGoTypeName, GoDynamicTypeOf and the
reflect-bridge assignability check gained the same arm, and GoReflect.TryAdapterWrappedType
switched from a NAME probe for the ᴠ infix plus a package-class nesting check to the exact marker
test. One marker fixes equality, both assert forms, the type switch and %T together — an
Equals-only fallback would have fixed equality alone and left the dynamic-type rule weaker.
Unwrapping in the assert’s interface tier matters independently: the adapter class carries only
the ONE interface it was generated for, so probing it can never resolve the wrapped struct’s other
interfaces — image/gif’s m.ColorModel().(color.Palette) and c.(color.RGBA) are exactly that
shape.
Only the CROSS-ASSEMBLY shape reproduces any of this: a same-package value conversion implements the
interface on the struct directly (the value-boxing partial-struct implementation), so there is no
wrapper and never was a problem — which is why the defect stayed invisible until packages like
image/image/draw converted a foreign struct to a foreign interface. (Guarded by the
ValueAdapterDynamicType behavioral test: a sibling colorlike package declares the interface and
two value-receiver implementers, main does the conversion, and the test exercises == both
operand orders, interface-vs-interface equality, both type-assert forms plus a miss, a type switch
over both implementers, %T, method dispatch and an interface-keyed map — output-compared vs
go run.)
Under -tests, a white-box PRODUCTION type is FOREIGN to the generator — so the name carries the prefix
The two sides of an adapter name must compose it identically, and they answer “is the source type
foreign?” by different means: the generator tests the containing assembly, the converter tests the
Go package. Under the white-box reference model those two disagree about exactly one set of types —
the package under test’s own. go/packages merges the production files into the INTERNAL test variant’s
Go package, so pkg == v.pkg reads net.Conn as local; its C# lives in the referenced production
assembly, which the generator reads as foreign and therefore prefixes.
The value arm already carried that carve-out (whiteboxProductionTarget, added for encoding/binary).
The interface-sourced arm did not, so every cast site named a type that does not exist — 26 CS0426
across seven of net’s internal test files, 55% of everything left after the r27 syntax cascade closed:
new net_test_package.ConnᴠReader(c) // referenced (arm composed the name unprefixed)
public sealed class net_ConnᴠReader : … // generated (prefixed, in the SAME anchor class)
The anchor was never in doubt — the record lands in package_test_info.cs and the class is generated
into the test metadata class both sides name. Only the simple name disagreed. isSameAssemblyPkg
is deliberately left alone: it already answers correctly (both reference models clear
testPackagePath), and it remains the RECOMPILE fallback’s answer, where production sources really do
compile into the test assembly. Behavioral CNR is byte-identical — the shape exists only under
-tests.
An exported func type publicizes the unexported types in its signature
An EXPORTED named func type becomes a public C# delegate; an unexported type in its signature —
x/text/unicode/bidi’s type Option func(*options), where options is package-private — is then
less accessible than the delegate (CS0059, “inconsistent accessibility”). The type-accessibility
pass, which already publicizes the unexported types exposed by an exported struct field / package
var / method signature, also walks an exported named type whose underlying is a *types.Signature
and publicizes the unexported named types in its parameters and results:
type options struct{ … } // unexported
type Option func(*options) // exported -> public delegate
[GoType] public partial struct options { … } // publicized to match the delegate
public delegate void Option(ж<options> _);
Only a package with an exported func type over an unexported type is affected (no golden churn). (Guarded by the PublicizedFuncTypeParam behavioral test.)
A func-TYPED exported field or var publicizes the unexported types in the func signature. The
accessibility walk that publicizes an unexported type exposed by an exported field / package var
(collectUnexportedNamedTypes, CS0052) peels pointer/slice/array/map/chan wrappers to reach
the element type — but stopped at a *types.Signature, so an unexported type reachable ONLY through a
func-typed field’s signature was left internal. crypto/internal/hpke’s
type hkdfKDF struct{ … } // unexported
var SupportedKDFs = map[uint16]func() *hkdfKDF{…} // exported var -> public field
emits public static map<uint16, Func<ж<hkdfKDF>>> SupportedKDFs, whose type embeds hkdfKDF through
the func RESULT — but [GoType] partial struct hkdfKDF defaulted to internal, less accessible than
the public field (CS0052). collectUnexportedNamedTypes now has a *types.Signature case that recurses
into the signature’s PARAMS and RESULTS through the same named-only walk (which handles a nested func
result in turn), so hkdfKDF is publicized to [GoType] public partial struct hkdfKDF (and its exported
methods go public via the receiver-access cascade). Both sides of the signature are covered — a func
PARAMETER exposes an unexported type just as a func RESULT does (var Appliers = []func(*cfg) →
public static slice<Action<ж<cfg>>> Appliers, publicizing cfg). This routes through the named-only
collectUnexportedNamedTypes, NOT the signature-context collectSignatureTypes: a lifted anonymous
struct/interface written in the func signature stays the CS0050/CS0051 signature domain, so only genuinely
func-reachable NAMED types publicize here. (Guarded by the FuncFieldUnexportedType behavioral test — a
public map[uint16]func() *hkdfState var whose func result exposes an unexported type, plus a
[]func(*cfg) var whose func parameter exposes another, output-compared vs Go; both fail CS0052 without
the publicize.)
A publicized wrapper reaches through an UNNAMED composite RHS to its element type. A defined type whose [GoType] wrapper is emitted public (exported, or unexported-but-publicized) exposes its written RHS through the wrapper’s Value/ctor/indexer/operators, so an unexported RHS type must be publicized too. This holds not just for a NAMED RHS (type EncoderBuffer encoder) but for an UNNAMED composite RHS whose ELEMENT is an unexported named type: type ringElement [256]fieldElement exposes fieldElement through the array-wrapper’s indexer/Value/ToSpan, so fieldElement must be publicized (crypto/internal/mlkem768, CS0050/CS0051/CS0053/CS0054/CS0056/CS0057). collectPublicizedWrapperRHS therefore feeds the RHS unconditionally to the pointer/slice/array/map/chan-peeling walk (collectUnexportedNamedTypes) rather than gating on a named RHS. The walk has no *types.Struct case, so a struct RHS stays a no-op — an exported field of an unexported struct-field type is the CS0052 domain and is intentionally left internal. (Guarded by the NamedArrayWrapper extension — an exported Grid [3]unit over an unexported unit, output vs Go.)
A test-file exported helper over an unexported PRODUCTION type is emitted internal (the MIRROR)
The publicization passes above run over a single *types.Package and raise a production type’s
accessibility to match the exported production surface that exposes it. A _test.go-declared helper
is the mirror case and needs the opposite resolution. In the -tests pipeline the production
sources are converted first and independently (no test files), so an unexported production type is
already emitted internal on disk; the test files are converted afterward. Go’s strconv/internal_test.go
declares an EXPORTED helper returning that internal production type:
// internal_test.go (package strconv) — exports access to strconv internals for tests
func NewDecimal(i uint64) *decimal { … } // decimal is package-private, emitted `internal`
Emitting NewDecimal public (its capitalized name) makes it a public method whose result is the
less-accessible internal decimal — CS0050. Publicizing decimal is not the fix here: production
was already emitted (and is not re-emitted in the test pass), so the map entry would be inert, and a
public API surface for a test-only helper is semantically wrong. In the recompile test model the test
assembly is self-contained (production + internal-package strconv + external-package strconv_test
files all compile into ONE assembly, with no cross-assembly consumer of a test symbol), so the correct
and sufficient resolution is to downgrade the helper to internal:
internal static ж<@decimal> NewDecimal(uint64 i) { … } // was public → CS0050; internal ≤ any prod access
visitFuncDecl downgrades an exported free function (Recv == nil) declared in a _test.go file when
its signature references, in any param/result position (peeling pointer/slice/array/map/chan),
an unexported same-package named type declared in a production file
(signatureReferencesUnexportedProductionType). The production-file restriction is essential and is
what distinguishes this from sort’s example_multi_test.go, whose exported OrderedBy(...) *multiSorter
returns an unexported type declared in a test file: that type is publicized AND re-emitted public
within the same test pass (the framework above), so its referrer compiles as public and must not
be flipped. Only a production-declared unexported type stays internal-on-disk and forces the
downgrade. The change fires solely inside _test.go conversion, so normal-path output is byte-identical
(check-no-regression clean across the behavioral corpus) and no already-validated package drifts
(sort re-validates 63/63, SetOptimize(bool) bool in the same file stays public). This is the
blocker that lets strconv’s test host reach compilation of its file-reading suites (TestFp/TestAtof
read testdata/testfp.txt via os.Open + bufio.Scanner). No behavioral guard is expressible — the
-tests recompile model has no normal-path analogue — so the guard is the strconv pipeline (its
internal_test.cs emits NewDecimal internal; the CS0050 no longer blocks).
The same downgrade applies to a package-level VAR or CONST — the CS0052 half of the rule. A
white-box test file’s exported value faces the identical arithmetic on a field rather than a
method: internal/cpu’s export_test.go declares var Options = options over the production
type option struct{…}, and a public field of type slice<option> is CS0052 — inconsistent
accessibility, the field’s type being less accessible than the field. visitValueSpec runs every
package-level var/const access through testDeclaredValueAccess, which applies exactly the
predicate the func rule uses (typeReferencesUnexportedProductionNamed, peeling
pointer/slice/array/map/chan) and downgrades to internal on a hit; the production-file restriction
and the self-contained-assembly reasoning carry over unchanged.
This half only became reachable when the white-box bridge class started carrying an access
modifier. Before the unconditional bridge metadata
unit, an internal
test file’s partial class cpu_internal_test_package { was the class’s ONLY declaration, and a
top-level C# class with no modifier is internal — so its public members were internal in
effect and the inconsistency never arose. Making the bridge public static partial (which a
record-less mixed suite needs, or an extension method in an internal test file is CS1106) exposed
every such field at once. internal/cpu’s whole 8-verdict suite sat behind the one line.
(Guarded by TestExportedTestFileVarOverProductionTypeIsDowngraded, with three negative controls:
an exported production element type, a test-file-declared element type — which the publicize pass
re-emits public in this same pass — and a production-declared exported var over the same
unexported type, which stays public because the gate is the declaring FILE, not the type.)
A FUNCTION-LOCAL type is emitted internal — its Go name’s case carries no export meaning
Both rules above read an access modifier out of an identifier’s first rune, which is exactly what Go’s
export convention licenses — for a package-level identifier. A type declared inside a function
body is a different animal: it is unreachable from outside that function by construction, so Go
draws no visibility distinction between S8 and embed2 there. Neither is exported; neither can be.
go2cs hoists such a type to package scope under a <Func>_<name> identifier (see the lift sections),
and that hoist is where the meaning gets invented. Go’s encoding/json decode_test.go is the
witness — one function, two local types, one a field of the other:
func TestUnmarshalEmbeddedUnexported(t *testing.T) {
type embed2 struct{ Q int }
type S8 struct {
embed2
R int
}
…
}
The white-box bridge arm asked generatedTypeScope for the local name, so the two siblings landed
on opposite sides:
[GoType("dyn")] [GoLocalName("embed2")] internal partial struct TestUnmarshalEmbeddedUnexported_embed2 { … }
[GoType("dyn")] [GoLocalName("S8")] public partial struct TestUnmarshalEmbeddedUnexported_S8 {
public TestUnmarshalEmbeddedUnexported_embed2 embed2; // CS0053 — less accessible than the property
}
and a lifted anonymous struct, which carries no modifier at all, was scoped by go2cs-gen’s own
rule from the hoisted name — inheriting the case of the enclosing function, so
TestEncoderSetEscapeHTML_type came out public and its exported fields over the package-level
unexported strMarshaler were CS0052.
localTypeAccess (typeAccessibilityOperations.go) resolves both by emitting a function-local type
internal, consumed at the three points that finalize a modifier — visitTypeSpec’s bridge arm, and
the lift defaults in visitStructType and visitInterfaceType. internal is faithful (no Go
consumer outside the function can name the type) and sufficient (every emitted C# consumer — the
hoisted siblings and the converted function body — compiles into the same test assembly). Writing it
inline is load-bearing: go2cs-gen reproduces a modifier the declaration already carries and falls
back to its name rule only for a bare declaration, so pinning it inline is what stops the generator
from re-deriving public and colliding (CS0262). This was the entire compile wall of encoding/json’s
suite — 76 errors across CS0050/CS0051/CS0052/CS0053, four codes, one cause.
The rule is scoped to the bridge arm. On the production path the modifier is left empty and
recordTypeAccessibility pins generatedTypeScope of the mangled name, which gives every local
type of one function the same modifier — uniform, and therefore consistent, though for a reason
nobody chose. The same latent mixture is expressible there (a function-local struct with an exported
field of a package-level unexported type); no corpus package presents it, and flipping production
local types would move a public value adapter’s operand out from under it, so it is recorded rather
than pre-emptively changed. Guarded by TestFunctionLocalTypesShareOneAccessibility, which pins all
three shapes — the uppercase local, the lowercase local, and the anonymous lift reaching a
package-level unexported production type — and fails without the fix.
A publicized unexported interface is emitted public
The accessibility pass records an unexported interface used in an exported surface exactly like a
struct or func type — testing’s type testDeps interface { … } reached through func MainStart(deps
testDeps, …) *M is interned into packagePublicizedTypes, and visitTypeSpec sets
pendingTypeAccess = "public ". But on the EMISSION side, every top-level type-kind emitter consumes
v.pendingTypeAccess (struct, array, map, ident, the inline selector/star cases) except
visitInterfaceType, which dropped it — so the interface always emitted [GoType] partial interface
testDeps, defaulting to C# internal, less accessible than the public member that references it
(CS0051). visitInterfaceType now reads-and-clears pendingTypeAccess at entry (so the lifted/anonymous
interfaces it visits recursively see an empty value) and folds the modifier into the post-attribute slot,
emitting [GoType] public partial interface testDeps. Non-publicized interfaces are unchanged (no churn).
(Guarded by the PublicizedInterfaceParam behavioral test — an exported function taking an unexported
interface whose method returns a built-in type, output-compared vs Go.) The transitive cascade also
walks a publicized interface’s method signatures: the collectMethodSignatureUnexportedTypes fixpoint step
walked a type’s named.NumMethods() (declared receiver methods) but that is 0 for a defined interface
— an interface’s methods live on its underlying *types.Interface. It now also iterates
iface.NumMethods() for a publicized interface, so an unexported NAMED type in a public interface member’s
parameter/result signature is publicized in turn (CS0051/CS0050).
…and an interface member is public whether or not the GO method is exported (2026-08-08). That
walk still ran each method through a gate that returned early on !method.Exported(). The gate is
right for a CONCRETE method — an unexported one emits internal static … sockaddr(this
ж<SockaddrInet4> …) and exposes nothing — and wrong for an interface member, which
visitInterfaceType emits with no access modifier and which C# therefore makes implicitly
public. Go’s case convention simply does not survive into the emitted surface, so it is the
EMITTED C# accessibility, not the Go exportedness, that decides what must be lifted; the gate now
takes an explicit flag, set only on the interface arm. syscall’s Sockaddr is the archetype and the
idiom is deliberate Go: sockaddr() (unsafe.Pointer, _Socklen, error) is unexported precisely so
that only the package can implement the interface — a SEALED interface — yet the emitted member
returns the unexported _Socklen from a public interface (CS0050 on every unix flavor; Windows
spells the same method with int32, which is why the corpus never saw it). go/types is the other
reached case, and a subtler one: its exported Object interface has color() color and
setColor(color), and the wrapper only ever compiled because the type’s Δ collision-rename made
TypeGenerator’s name-based scope rule read the leading Greek capital as exported and emit it
public by accident. It is now publicized on purpose. (Guarded by
typeAccessibilityInterface_test.go, whose negative controls fail if the gate is dropped outright
rather than narrowed — a concrete unexported method must still publicize nothing.)
A public callable’s signature can also reference a lifted anonymous type, which the NAMED-only cascade
above cannot reach — testing’s testDeps.CoordinateFuzzing(… corpusEntry …) / RunFuzzWorker / ReadCorpus,
where type corpusEntry = struct{…} is an ALIAS to an anonymous struct. The signature type is not a
*types.Named but a lift (corpusEntryᴛ1), a synthesized name over a raw types.Type with no
*types.Object, so packagePublicizedTypes (keyed by object) cannot hold it. A parallel set
packagePublicizedLiftedTypes (keyed by the alias-stripped anonymous types.Type) fills the gap: a
SIGNATURE-context walker collectSignatureTypes — used by the exported-func, exported named-func-type, and
method/interface-method signature paths — records any lifted anonymous struct/interface it reaches, and the
lift emission in visitStructType consults isPublicizedLiftedType and emits public. This is deliberately
signature-scoped and does not fold into the shared named-only collectUnexportedNamedTypes: an exported
field/var of an anonymous struct is the CS0052 domain (a public struct/var over an internal anon field
type is legal while its own enclosing type is internal), so only signature positions lift — keeping golden
churn to the one genuinely-affected shape. (Guarded by the PublicizedInterfaceAnonAlias behavioral test — an
unexported interface publicized through an exported function, whose method both takes and returns a
type = struct{…} alias, output-compared vs Go; it fails to compile with CS0050/CS0051 without the lift
publicize.)
Publicized unexported types make their exported methods public
An unexported Go type reachable through an exported surface (an exported var — var BigEndian
bigEndian — an exported field, or an exported function’s signature) is emitted public
(packagePublicizedTypes, CS0052/CS0050). Its exported methods must then be public too —
Go callers hold such values through the exported var and call the methods cross-package, but the
receiver-based access rule alone rendered them internal (extension methods invisible outside
the assembly: binary.BigEndian.Uint32(...) CS1061). The receiver-access checks in
visitFuncDecl treat a publicized receiver as public, and collectPublicizedTypes cascades
through the publicized types’ exported method signatures to a fixpoint (a newly public method’s
unexported parameter/result types get publicized in turn, or the public method would be CS0050).
Unexported methods stay internal regardless.
Structural interface satisfaction emits C# interface inheritance
Go converts fs.File to io.Reader implicitly because the method set suffices; C# interfaces
are nominal. When a declared interface’s method set strictly contains an EXPORTED method
interface from a directly imported package (checked with types.Implements), the converter
emits real C# inheritance at the declaration and skips re-declaring the covered members
(redeclaring would HIDE the base member — implementers would need both):
[GoType] partial interface File :
io_package.ReadCloser
{
(FileInfo, error) Stat();
}
Every downstream interface-to-interface conversion then becomes an implicit reference
conversion — identity-preserving (the dynamic value flows through type asserts, unlike an
adapter wrapper) and zero-cost (os’s CopyFS passes an fs.File to io.Copy, CS1503).
Details: only the minimal covering set is listed (ReadCloser subsumes Reader/Closer);
the strict-subset guard rules out inheritance cycles (equal method sets never inherit);
candidates covered by a declared embed are skipped (the embed emission handles those);
bases reference the file-local package alias (io.ReadCloser, user-ruled style) via
getAliasQualifiedTypeName, which also registers the using — needed because the declaring Go file may not
import the candidate’s package (fs.go declares File without importing io); lifted/dyn
and constraint interfaces are excluded. Multiple non-subsuming bases sharing a method
(CrossPkgLib.Sealed and .Rated both carry Label): both are inherited, and the shared
member is re-declared — a member covered by exactly one listed base is inherited/skipped,
but one covered by two or more is re-declared so it hides both inherited slots and member
lookup through the derived interface stays unambiguous (CS0121). Go needs only one method to
satisfy all; the C# implementers satisfy every slot with the same public method. Consequently the converter never records an interface-to-interface
GoImplement — the generator’s impl types are structs, and an interface-typed record kills
its whole run. Bounds (banked): candidates come from direct imports only — same-package
structural pairs, the universe error, and non-imported-package pairs would still surface as
compile errors and would need the adapter complement. Guarded by CrossPkgUser (namedLabel :
CrossPkgLib_package.Labeled, passed to CrossPkgLib.Describe).
Embedded-pointer hop receivers split per method
An interface member satisfied by promotion through an embedded POINTER field forwards through
the hop — but the receiver form depends on the target method: a [GoRecv] ref extension (or
struct method) binds the deref’d value (this.File.Value.Name()), while a direct-ж primary
(an extension on ж<X> emitted when the receiver escapes — os’s File.Read/Write) binds the
box FIELD itself (this.File.Read(p); deref’ing first strands the receiver, CS1929). The
generator discriminates by scanning the compilation for this ж<X> extensions — only
converter-emitted primaries are visible to the single-pass scan (sibling-generator ж-twins are
not), which is exactly the needed split. Applied to both the value-form partial and the pointer
adapter’s hop arm. Guarded by StructPointerPromotionWithInterface (Describer over
deviceHandle{*Device}).
With SEVERAL embedded pointers the hop is chosen per member, not per struct
The hop forwarding above was gated to a struct with exactly ONE embedded pointer, on the
reasoning that multi-embed interface satisfaction was rare. It is not: net/rpc/jsonrpc’s
type pipe struct { *io.PipeReader; *io.PipeWriter } (all_test.go:310) gets Read and
Write entirely by promotion from two different embeds. With the gate closed, every promoted
member fell through to the templates’ bare receiver — m_box.Read(p) / this.Read(p) — which
binds nothing on the struct, so C# overload resolution reached the nearest same-named extension
anywhere in scope and reported CS1929 naming a type the package never mentions
(io_package.Read(ref io_package.LimitedReader, slice<byte>) from a jsonrpc test; likewise
os_package.WriteString(ж<os_package.File>, …)). That misdirection is the signature of this
defect — it reads as a missing reference and is not one.
The generator now indexes the hop path per member (GetMultiEmbedHopPaths), routing each
still-unbound interface member to the UNIQUE embed declaring it — which is precisely Go’s
depth-1 promotion rule. A name TWO embeds declare is dropped rather than guessed (Go promotes
neither, so only a method the struct declares itself can satisfy the member — *pipe.Close
over the Close both *io.PipeReader and *io.PipeWriter declare). Each embed’s method set
is read from local syntax where its type is declared in this compilation and from
metadata where it is not — a referenced assembly exposes both the converter’s direct-ж
primaries and the public RecvGenerator ж-twins as ordinary symbols, which is the whole
jsonrpc case. The receiver form keeps the per-method split of the section above: a direct-ж
primary binds the embed’s ж field itself (m_box.Value.PipeReader.Read(p)), anything else its
deref’d value (m_box.Value.writer.Value.Write(p)). Both emission paths are covered — the
pointer adapter and the value-form partial, since a pointer embed’s method set is in the
STRUCT’s method set too, so var rw ReadWriter = p (no &) records the pair as well. A member
neither resolution places is left unbound and keeps the old fallback, i.e. a loud CS1929 naming
it, never a silent wrong receiver.
Note this is not the same machinery as the TypeGenerator’s promoted-method forwarders,
which mint M(this ж<Outer> …) on the struct itself: those bail out on an embed with no local
declaration (GetStructDeclaration returns null for a metadata-only type), which is why a
struct with two LOCAL pointer embeds compiled all along and jsonrpc’s two FOREIGN ones did not.
Guarded by MultiPointerEmbedPromotion (local embeds in both receiver forms, foreign embeds
*strings.Reader/*strings.Builder resolved from metadata, an overridden Close both embeds
declare, and pointer- and value-sourced casts of each, with write-through observed via the
original embedded objects, vs Go).
A forwarded multi-value call deconstructs when tuple elements need interface conversion
return newRawConn(f) forwards a (*rawConn, error) tuple into a (syscall.RawConn, error)
result list — C# tuple conversions do not consult user conversions element-wise (CS0266). The
converter deconstructs into temps and converts each element through the usual interface
machinery (which also records the GoImplement pairing):
var (ᴛ1, ᴛ2) = makeRelay();
return (new relayжReporter(ᴛ1), ᴛ2);
Elements whose actual type is itself an interface are left alone (structural inheritance
covers those). Guarded by CrossPkgUser (getReporter forwarding makeRelay).
A multi-value call spread into a call’s parameters in an assignment hoists into temps
Go lets a MULTI-VALUE call fill the parameters of an enclosing call — r := t.newRange(t.parseControl("range")),
where parseControl returns five values feeding newRange’s five parameters. C# has no splat, so the inner
call is deconstructed into markers and passed expanded:
var (ᴛ6, ᴛ7, ᴛ8, ᴛ9, ᴛ10) = Ꮡt.parseControl("range"u8);
var r = Ꮡt.newRange(ᴛ6, ᴛ7, ᴛ8, ᴛ9, ᴛ10);
convExprList already performs this expansion, but only when the call’s deferredDecls hoist target is
non-nil — passing the whole tuple as one argument is otherwise CS7036 (text/template/parse’s rangeControl).
The return-form threads that target (visitReturnStmt); the assignment forms do too, on BOTH lowering
branches: the single-declare block and the mixed/escaping block (a pointer-result local that is heap-boxed is
not counted in declaredCount, so it takes the latter — the newRange case above). A statement-level
f(g()) (a bare expression statement, not an assignment) carries no deferredDecls of its own, so the
expansion now falls back to the enclosing ExprStmt’s v.hoistedDecls buffer — testing’s
registerCover2(deps.InitRuntimeCoverage()), where InitRuntimeCoverage returns three values:
var (ᴛ1, ᴛ2, ᴛ3) = deps.InitRuntimeCoverage();
registerCover2(ᴛ1, ᴛ2, ᴛ3);
The hoisted var (…) = …; lands in the statement’s existing hoist buffer, emitted before the statement.
Byte-identical corpus-wide except where the pattern occurs (and a harmless renumber of any later temps, since
the per-file marker index is monotonic). Guarded by TupleSpreadIntoCall (a value result, an escaping pointer
result, and a statement-level spread).
A PACKAGE-LEVEL var initializer has no statement sink at all — var debug = template.Must(
template.New("RPC debug").Parse(debugText)) (net/rpc debug.go; also internal/trace/traceviewer) passed the
whole (ж<Template>, error) tuple as Must’s one argument (CS7036). There the spill becomes a hidden
once-evaluated static tuple FIELD (v.globalDeclHoist, flushed by visitValueSpec before the var’s own
field — C# static field initializers run in textual order, the same holder shape visitPackageTupleVarSpec
emits for var a, b = f()), and the arguments read its components:
internal static (nint, nint) tupleᴛ1ʗ = parts();
internal static nint g = combine(tupleᴛ1ʗ.Item1, tupleᴛ1ʗ.Item2);
Guarded by the TupleSpreadIntoCall extension (a package-level var spreading a two-value call into a
wrapping call, value read back in main).
A range over a pointer-typed type conversion parenthesizes before the deref
Ranging over a pointer to an array implicitly dereferences it — the converter appends .Value to the
range expression. When the range expression is itself a pointer-typed TYPE CONVERSION it renders as a C#
cast ((ж<array<byte>>)(uintptr)(p), crypto/internal/nistec’s p256 init over
(*[43*32*2*4][8]byte)(*p256PrecomputedPtr)). A cast binds LOWER than member access, so a bare append
(ж<…>)(p).Value parses as (ж<…>)((p).Value) — the deref lands on the operand, not the cast result
(CS1579 “no GetEnumerator” on the box type, CS8130). visitRangeStmt now wraps the range expression in
parentheses — ((ж<…>)(p)).Value — whenever the pointer-unwrap deref is active and rangeStmt.X is a
*ast.CallExpr whose Fun is a type expression (info.Types[Fun].IsType(), which catches the
unsafe.Pointer conversions isTypeConversion deliberately excludes). Byte-identical corpus-wide (the
pattern only occurs on a pointer-producing conversion in range position, which never compiled before).
Guarded by RangePointerArrayConversion (transpile+compile+target only — the exact cast shape needs an
unsafe.Pointer source, whose runtime round-trip golib does not reproduce, so it is not output-compared).
Adapter accessibility: symbol-OR-name on both sides
The adapter class scope cannot be derived from Go name casing alone (error is lowercase yet the golib interface is public METADATA - the name rule made io/fs’s PathErrorжerror internal, CS0122 x40) nor from symbols alone (sibling generators’ public partial modifiers are invisible to a single-pass generator - the symbol rule broke same-assembly interfaces like CrossPkgLib.Reporter). The ImplementGenerator takes symbol-OR-name on the struct AND the interface.
GoImplement records de-duplicate at attribute emission
os converts dirEntry to fs.DirEntry both through its own alias (type DirEntry = fs.DirEntry) and through the io/fs name - two records for ONE interface made the generator emit the explicit implementation twice (CS8646/CS0111). The de-duplication happens at ATTRIBUTE EMISSION with the ALIASED record winning (its simple name resolves via the package usings); normalizing the RECORD KEY instead was twice wrong - qualified attr names break generator name resolution and flip the alias-locality gate. Measurement lesson: those declaration-phase errors had SUPPRESSED all of os’s method-body diagnostics (Roslyn phase gating) - a package is not truly measured until its declaration errors are zero.
The comparison must run on the EMITTED spelling, not the raw registry key (2026-08-08). The
covered set is built from exportedTypeAliases, whose values visitTypeSpec has already
canonicalized — it reverts a file-local import rename before recording the alias target — while the
registry key keeps whatever rendering the cast site produced. os aliases its io import to Δio
(io is shadowed once io/fs is in the reference closure), so on the unix flavors
unixDirent→fs.DirEntry registers as DirEntry and as Δio.fs_package.DirEntry, and neither
compares equal to the canonical io.fs_package.DirEntry the covered set holds. Both records were
emitted for the one pair and unixDirentжDirEntry was composed twice (CS0102, CS0111 ×9, CS8646 ×4).
That also produced a third adapter spelling, worth recording because it looks like a separate
defect and is not: two records composing one adapter name is exactly what adapterNameCollisionSet
exists to detect, so it saw a FALSE collision, applied its collision-conditional rule and qualified
the foreign side of one cast site to unixDirentжfs_DirEntry — a name neither record produces
(CS0246). Removing the duplicate removes the collision, and all three call sites in file_unix.cs
converge. The key is now built with qualifyLocalTypeRef, the same canonicalization the emission
applies, so the two sides are comparable by construction. Windows is unaffected, and that was
measured rather than argued: its os registers the renamed-canonical spelling ALONE, with no
alias-keyed record, so nothing is covered and the qualified record is still the only record.
(Guarded by implementRecordAliasCanonicalization_test.go, whose controls pin both the Windows shape
and a genuinely distinct second implementation that must NOT collapse.)
The interface-inheritance PRUNE exempts pairs that generate their own adapter CLASS. The same
attribute-emission stage also drops a “lower” GoImplement record when the SAME implementing type is
recorded against a derived interface that C#-inherits it (elf’s errorReader against both io.ReadSeeker
and io.Reader — the two value-form partial-struct implementations would implement Read twice,
CS0111/CS8646). That prune is only valid for the value-boxing PARTIAL-STRUCT form (one type, one
interface list). A pair whose implementation is a DISTINCT generated adapter class must survive, since
each cast site references the adapter for the EXACT interface it targets — the ж
A global:: root escape is a THIRD spelling of one type, and the record sets dedupe on text
The de-duplication above compares rendered attribute lines, so every distinct spelling of one type is
a distinct record. The alias case is the one that section documents; the root escape is the same
defect reached by a different route, and it is invisible in a production conversion. -tests alone
mints global:: — testAliasShadowOperations / convSelectorExpr escape to the root whenever a test
package’s own class shadows the leading segment of a qualified reference — so one test package can
register the same (impl, interface) pair from an escaped site and a bare site and emit both records.
go2cs-gen resolves both to the SAME symbol and mints the adapter twice: net/http’s
http_HandlerFuncᴠΔHandler came out as both -val.g.cs and -val.1.g.cs, giving CS0102 + CS0111 ×5
- CS8646 ×2 against a test suite that had never run.
dedupeRootEscapedRecords collapses lines that differ only by root escapes, as a shared pass over
both record sections — GoImplement and GoImplicitConv are built by the same
qualifyLocalTypeRef rendering over the same registries, so they carry the same exposure and a fix in
one alone would only wait for the other. It runs BEFORE recordEmittedPointerAdapterPairs, so the
adapter-naming authority sees the deduplicated set and cannot manufacture the false collision that the
alias case’s third spelling came from.
The escaped spelling wins a collapse: it is shadow-proof by construction, which is why the
machinery minted it, and keeping the bare form could reintroduce the shadow the escape exists to
defeat. That is the opposite preference from the alias case — there the ALIASED (simple) form wins
because the qualified form breaks generator name resolution — and the two are consistent once stated
as one rule: keep the spelling that resolves under the most conditions. A root escape adds
resolution guarantees; a package qualifier removed one. Ties fall to the lexicographically smaller
line so the output stays deterministic. The pass is an exact identity on any record set without an
escape, which is what makes the whole production corpus provably unaffected. (Guarded by
rootEscapedRecordDedupe_test.go, whose four cases pin the collapse, the escape-count preference
independent of sort order, a negative control that distinct records all survive, and the
production-inertness identity.)
The promoted-method twins class is named for the PACKAGE and the pair, not the pair alone
A struct that satisfies an interface member by promotion gets an `internal static class