Source Generators

Reference index · Summary of this topic Several Go semantics cannot be written directly in C#, so the converter emits compact, attributed partial declarations and lets a set of Roslyn source generators (src/gen/go2cs-gen/, referenced as an analyzer by every converted project) synthesize the rest at compile time. This keeps the visible converted code close to the Go original. The principal generators and attributes:

Common attributes the converter emits for the generators (and tooling) to consume: [GoType] (type bodies), [GoRecv] (receiver methods), [GoStr] (sstring twins), [GoTag] (struct field tags), [GoPackage] (package info), and the test-only [GoTestMatchingConsoleOutput]. The full vocabulary — every stamp, where it lands, who reads it, and which of them are kept off the visible declaration — is classified in Extended attributes: what stays on the declaration and what moves.

A defined type over a NAMED type passes the underlying’s name to the wrapper template, never as a kind. type MyBool bool and type winlibcall libcall reach InheritedTypeTemplate with the underlying’s name as its TypeClass, and that name is read (the bool arm prints Go’s lowercase true). The template’s KIND arms key on six values the kind arms set themselves — Array, Slice, Map, Channel, Pointer, Numeric — so a user type spelled exactly one of them took that kind’s template: mapstructure’s tests declare type MapCopy Map over a struct named Map, which generated IDictionary members over a struct. NamedUnderlyingTypeClass re-keys only those six names, so such a wrapper generates exactly as one over any other name. (Guarded by GenTests’ KindNamedUnderlyingTests, which compares each of the six with a wrapper over Plain.)

A generator’s view of ACCESSIBILITY is provisional — its own output is what supplies the access modifier (2026-07-25). The converter emits a Go type as a bare [GoType] partial interface X (or partial struct X) nested in the package class and leaves the access modifier to TypeGenerator, which derives it from the Go export convention (GetScope — public for an exported name, internal otherwise; an explicit modifier on the converter’s part wins). A C# nested type with no modifier is private, so until that generated partial exists the declaration is private — accessible from inside its own package class and inaccessible from any other class in the assembly. A generator cannot see its own output, so a semantic query that crosses package classes sees the provisional accessibility, not the real one.

That bit io’s external test package, and it is the first shape in the corpus that can hit it: io_test and io compile into ONE assembly (the recompile test-project model), as two classes. io_test.closer : io.Closer and io_test.testMultiWriter_sink : io.Writer bound their base to an IErrorTypeSymbol (CandidateReason.Inaccessible, candidate go.io_package.Closer/Interface/Private) with zero members, so every method the base contributes silently vanished from the generated interface shell and adapters — while the FINAL compilation, which does have the generated public partial interface Closer, still demanded them: four distinct CS0535 (six sites) across Δcloser<T>, ΔcloserᴛObj, PipeReaderжcloser, PipeWriterжcloser, ΔtestMultiWriter_sink<T> and bytes_BufferжtestMultiWriter_sink. The same io.Closer resolved normally as a base of io.ReadCloser inside io_package, and fmt.Stringer resolved normally from a referenced assembly (already public in metadata) — which is what made the failure look spelling-related. It is not: writing the base global::go.io_package.Closer fails identically.

Common.GetAllBaseInterfaces replaces the raw AllInterfaces walk in InterfaceDeclarationSyntaxExtensions.GetInterfaceMethods and in both of ImplementGenerator’s method-collection paths. It recovers an Inaccessible base from the error symbol’s CandidateSymbols when the candidate is an interface declared in this compilation’s assembly, and folds in that recovered base’s own transitive bases (AllInterfaces cannot traverse through an error symbol). The recovery is sound rather than a bypass: the generator is about to declare that very type public or internal, both reachable from anywhere in the assembly. A genuinely inaccessible foreign type keeps its error symbol.

The deeper alternative — having the converter emit the access modifier on its own partial, so the pre-generation source stops understating it — would remove the whole class of provisional-accessibility blind spots, but writing that modifier onto the inline [GoType] declaration re-baselines every converted .cs in the corpus and in ~490 behavioral goldens, and coarsens the Go-shaped declaration the converter works to keep readable. It was left on the table here and taken the following day in the form below. (No behavioral guard is possible for this row: one behavioral project is one Go package is one C# assembly, so two package classes never share an assembly there. The reproducer is the -tests pipeline on io, whose six CS0535 clear; gated by the full behavioral suite 490/490, the 302-package corpus build, and the bytes/strings/encoding/binary/strconv pipeline canaries all at banked counts.)

package_info.cs’s TypeAccessibility section pins each type’s accessibility IN SOURCE

Resolved (2026-07-25). The blind spot above is closed at the root, without touching a single converted .cs: package_info.cs gains a TypeAccessibility section, emitted inside the package class body (its entries are type declarations, and the types they name are nested in that class), carrying one condensed single-line partial declaration per converter-emitted [GoType] type:

[GoPackage("io")]
public static partial class io_package
{
    // <TypeAccessibility>
    internal partial struct discard {}
    internal partial struct nopCloser {}
    public partial interface Closer {}
    public partial interface Reader {}
    public partial struct LimitedReader {}
    // </TypeAccessibility>
}

C# lets a partial type carry its access modifier on any one of its parts, so the inline [GoType] partial interface Closer stays bare and Go-shaped while this part fixes the accessibility — and it is fixed in source, so a generator’s semantic query across package classes sees public/internal instead of the provisional private. The section carries its own explanatory prose in the style of the file’s other sections; a package info file written before the section existed has the prose and markers inserted on the next conversion (ensureTypeAccessibilitySection), so no migration step is needed.

Details that make it a pure relocation of the modifier rather than a change of it:

Measured, with a positive control (the -tests pipeline on io, whose CS0535 cluster is the reproducer): recovery on + section on → 0 CS0535; recovery neutered + section on → 0; recovery neutered + section off → the cluster returns. Gates: full behavioral suite 490/490 across all four phases (460 output-compared, 30 skipped) with every main .cs golden byte-identical — the churn is 490/490 package_info.cs, additions only; seeded 305-package reconvert (14/14 .cs.auto, no marked file clobbered) + overlay + full corpus build 0 errors; converter go test and GenTests green; pipeline canaries at banked counts (errors 61, encoding/csv 71, io/fs 18, bytes 81 with 7 disclosed).

Extended attributes: what stays on the declaration and what moves

The [GoType] declaration is the line a reader of converted code actually reads, so every other attribute stamped on it is machinery competing with the Go original for that reader’s attention. package_info.cs already exists to hold per-type records out of view, and the TypeAccessibility section above already moved the access modifier there. A stamp can follow it whenever its consumer reads the attribute off the TYPE rather than off a particular declaration — C# unions the attributes of every part of a partial type, so which part carries one is invisible to runtime reflection and to any generator that resolves the symbol.

That single criterion classifies the whole surface. The converter stamps nothing from the BCL — every [StructLayout], [MethodImpl] or [LibraryImport] in the corpus is in golib or in a hand-owned file — so the vocabulary is exactly this:

Stamp Lands on Consumer Verdict
[GoType], [GoType("dyn")], [GoType("num:…")], … struct / class / interface TypeGenerator’s syntax receiver keys on it; also read semantically and at runtime Must stay — it is the declaration’s identity, and the receiver has no type to resolve until it matches
[GoValueClone("f1", "f2")] struct TypeGenerator, reading field names to emit Clone() Moved
[GoLocalName("Point")] struct (lifted function-local named type) golib’s reflection bridge, GoReflect.TypeNaming Moved
[GoTag("json:\"x\"")] field golib reflection, via the DescriptionAttribute alias Must stay — field-level. A <TypeAccessibility> record is an empty {} body; C# has no way for a second part to re-declare a field and attach an attribute to it
[GoRecv] method RecvGenerator syntactically, plus runtime Must stay — same reason, one level up: a method exists on the part that defines its body
[GoStr] method StrGenerator syntactically Must stay — as [GoRecv]: it marks the member whose signature the generator reads
[GoArrayDims(4, 8)] parameter golib reflection — GoReflect.FuncParamDims off the delegate instance’s Method.GetParameters(), or MethodParamDims off the method table’s ParameterInfos Must stay — the sharpest case in the set: the datum is not type-keyed at all. It distinguishes two funcs that share one emitted delegate type (func([32]byte) bool and func([64]byte) bool are both Func<array<byte>, bool>), so a record keyed by type has nothing to key on, and the consumer reads the parameter’s own metadata
[GoInit] method the C# compiler — it is a using alias for ModuleInitializerAttribute Must stay — the compiler requires it on the method it initializes with
[GoPackage], [GoImplement<T,I>], [GoImplicitConv<S,T>], [GoTypeAlias], [GoSStringTwin] package class / assembly generators, runtime, and the converter’s own next run Already there — these are emitted into package_info.cs and never touched a mainline declaration
[GoManualConversion], [GoRequiresUnsafe] module the converter Already off — hand-written, module-scoped
[GoInterfaceShell], [GoTwinForwarder], [GoReflectCompanion] interface / lambda / field golib Not converter-emitted — the first two written by the generators ([GoTwinForwarder] by StrGenerator, on a twin delegate’s lambda), the last by hand

So the movable set is [GoValueClone] and [GoLocalName], and both moved. [GoType] [GoValueClone("intbuf")] partial struct pp { reads [GoType] partial struct pp {, with the record in package_info.cs carrying the rest:

    // <TypeAccessibility>
    [GoValueClone("grid")] internal partial struct holder {}
    [GoValueClone("b")] internal partial struct inner {}
    internal partial struct row {}
    // </TypeAccessibility>

Mechanics worth knowing:


← The go.golib support namespace · Index · The standard-library conversion applies -tags purego →