Skip to content

Latest commit

 

History

History
1355 lines (1133 loc) · 124 KB

File metadata and controls

1355 lines (1133 loc) · 124 KB

ADR-0115: cs2gs — a C#→G# migration tool and gap-discovery pipeline

  • Status: Accepted
  • Date: 2026-06-21
  • Phase: Tooling — issue #914
  • Related: issue #914 (C#→G# migration tool), ADR-0027 (Roslyn-fork decision); canonical-output rules cite ADR-0008, ADR-0014, ADR-0017, ADR-0019, ADR-0020, ADR-0024, ADR-0025, ADR-0029, ADR-0047, ADR-0049, ADR-0051, ADR-0053, ADR-0055, ADR-0065, ADR-0067, ADR-0075, ADR-0078, ADR-0079, ADR-0097, ADR-0098, ADR-0109; website/docs/ref/spec.md.

Context

G#'s surface syntax has matured (Phases 1–7; ADR-0001 through ADR-0114) to the point where idiomatic C# maps onto canonical G# in a largely mechanical way: C# class/struct/record/record struct correspond to G# class/struct/data class/data struct; C# generics map to bracketed G# generics (ADR-0020); C# delegates map to arrow function types (ADR-0075); C# attributes map to @-annotations (ADR-0047). Issue #914 asks for a tool that exploits this to do two things at once:

  1. Transform existing C# applications into G# projects — producing canonical G#, not a literal transliteration.
  2. Discover gaps in the G# compiler — by feeding the generated G# through a real build/verify/test pipeline and turning every failure into a filed issue, then re-running against an updated compiler until parity with the original C# is reached.

The hard requirement is that the process be accurate and repeatable: the same corpus run twice against the same gsc must produce the same G#, the same diagnostics, and the same triage artifacts. "Migration completed" has a precise meaning — the ported program compiles cleanly, its IL verifies, and its ported tests reproduce the C# baseline.

Two constraints bound the solution space. First, ADR-0027 removed Roslyn from the compiler: gsc has no Microsoft.CodeAnalysis dependency and emits ECMA-335 directly via ReflectionMetadataEmitter. Any use of Roslyn in this tool must not reintroduce that dependency into the compiler. Second, issue #914 mentions "pass the output to an LLM so it can file issues" — but a batch pipeline that embeds an LLM API client (and therefore API keys, network egress, and non-determinism) would violate the repeatability requirement and the repo's secret-handling rules. The triage hand-off must be designed around that.

The scaffolded solution under tools/cs2gs/ already fixes the project decomposition this ADR fills in:

Project Responsibility
Cs2Gs.CodeModel The purpose-built G# emit AST and the canonical pretty-printer.
Cs2Gs.Translator Roslyn front-end: CSharpCompilation + SemanticModelCs2Gs.CodeModel. References Microsoft.CodeAnalysis.CSharp.
Cs2Gs.Pipeline The ordered Translate → Compile → IL-verify → Test-parity stage machine and triage emission.
Cs2Gs.Report HTML + JSON report aggregation.
Cs2Gs.Cli The cs2gs entry point wiring Pipeline + Report together.
Cs2Gs.Tests xUnit tests for the above.

Decision

Build cs2gs as a Roslyn-based, offline, deterministic translator feeding a four-stage gap-discovery pipeline, with structured triage artifacts consumed by an external issue-filing agent. The tool lives entirely under tools/cs2gs/ and is never referenced by gsc or any compiler/runtime assembly.

cs2gs migrate produces a maintainable repository mirror by default. The destination preserves repository-relative directories and non-C# files, translates each checked-in .cs to the corresponding .gs, transforms each .csproj to a same-location .gsproj, and replaces .sln files with .slnx while preserving project coverage. Literal Nerdbank.GitVersioning versions below the G#-compatible 3.11.13-beta floor are upgraded in transformed projects and shared MSBuild props. Validation still runs through the four-stage pipeline, but logs, triage records, reports, and build intermediates are written to a separate artifacts root. The original timestamped per-app layout remains available through --diagnostic-run for corpus gap discovery, ledger gating, and CI compatibility.

A. Translation approach: Roslyn front-end → dedicated emit AST → canonical pretty-printer

Cs2Gs.Translator parses each input with Roslyn into a CSharpCompilation, binds a SemanticModel, and walks the bound tree to build a tree of Cs2Gs.CodeModel nodes — a purpose-built G# emit AST owned by this tool. Cs2Gs.CodeModel then pretty-prints canonical G# (section B). Before any generated .gs reaches gsc, the pretty-printed text is round-trip validated by re-parsing it with the real G# parser (Gsharp.CodeAnalysis syntax API, consumed read-only as a library reference); a file that does not parse is a translator bug and fails the Translate stage before a compile is ever attempted (section C).

This is chosen over the two alternatives named in issue #914 and one additional design that surfaced:

  • vs. a hand-rolled C# parser. A hand-rolled parser would have to re-implement C#'s lexer, parser, and its semantic model — overload resolution, generic inference, var type inference, definite-assignment, nullable flow — to know, e.g., whether a C# var local is immutable or what concrete type a method group resolves to. That is years of work that Roslyn already does correctly and that the C# language evolves under us. Rejected.

  • vs. a Roslyn analyzer that builds the compiler's G# AST. Two problems. (1) It would couple the tool to gsc's internal parse-oriented syntax tree, whose shape is tuned for binding/emit and carries trivia, spans, and invariants that a translator must satisfy but does not want. (2) It blurs ADR-0027's boundary: the compiler's AST would acquire a Roslyn-shaped construction path. Rejected in favor of a dedicated emit AST that the pretty-printer owns end-to-end.

  • vs. reusing the compiler's syntax tree as the emit AST. Even setting aside the analyzer framing, the compiler's SyntaxTree is an input contract (it must round-trip source text faithfully, preserve trivia, and back a SemanticModel); an emit AST is an output contract (it only needs to render canonical text and is free to normalize, drop, and re-shape). Conflating the two makes both harder. A small, output-only Cs2Gs.CodeModel keeps the translator's job — "decide the canonical G# shape" — separate from the printer's job — "render it deterministically." Rejected reuse.

Why Roslyn here does not contradict ADR-0027: ADR-0027 removed Roslyn from the compiler/emit pipeline (Lexer → Parser → Binder → Lowerer → ReflectionMetadataEmitter), because Roslyn's distinctive value (projecting G# symbols as ISymbol, hosting G# inside the Roslyn driver) is not on the v1.0 critical path and would import a multi-million-line rebasing burden into gsc. cs2gs uses Roslyn for the opposite direction and in a different process: as an external, offline C# front-end that reads C# source and never touches the G# compiler's metadata writer. The dependency lives only in Cs2Gs.Translator (PackageReference Microsoft.CodeAnalysis.CSharp); gsc gains no Microsoft.CodeAnalysis reference, loads no Roslyn assembly at startup, and incurs no fork. The two ADRs are therefore consistent: "no Roslyn in the compiler" (ADR-0027) and "Roslyn as a C# reader in a sibling tool" (this ADR) describe disjoint surfaces.

B. Definition of "canonical G# output"

This is the contract the pretty-printer in Cs2Gs.CodeModel must satisfy. Output is deterministic (identical input → byte-identical output) and idiomatic (matches the house style of samples/*.gs). The translator never guesses: a C# construct with no established canonical G# form is not approximated — it is recorded as a structured unsupported-construct triage record (section D) and, where the file can still be emitted, marked at the offending site, so the pipeline surfaces the gap rather than inventing syntax.

B.1 File, package, and import layout

  • One C# file → one .gs file. The first non-comment line is package <Dotted.Name> derived from the C# file-scoped/namespaced namespace (multiple namespaces in one file are split or hoisted to the dominant namespace; ambiguity → triage). Grammar: PackageDecl = "package" identifier { "." identifier } (spec §Packages and imports).
  • using X.Y;import X.Y; using A = X.Y;import A = X.Y (ADR-aligned alias form, spec §Packages and imports). One import per line, original order preserved, import block directly under package.
  • System is implicitly imported by the compiler, but the printer still emits an explicit import System when the C# file used it, matching samples/*.gs (e.g. Class.gs), so the file is legible and /noimplicitimports-safe.
  • global using and using static map to import where a direct equivalent exists; using static with no G# equivalent for member-hoisting is triaged.

B.2 Indentation and brace style

  • 4-space indentation, no tabs.
  • K&R / same-line braces: the opening { sits on the declaration/statement line, the body is indented one level, the closing } aligns with the opener's line. This matches every sample (Class.gs, Struct.gs, DataStruct.gs). One blank line between sibling member declarations; no trailing whitespace; file ends with a single newline.

B.3 let vs var vs const (immutability mapping) — ADR-0008, ADR-0067

C# G# Rule
const X const compile-time constant; initializer must be constant (ADR-0008).
readonly field let field immutable binding (ADR-0008 let; field keyword required by ADR-0067).
mutable field var field ADR-0067 requires the var/let keyword on every field.
local var x = e / explicitly-typed local never reassigned let x = e Roslyn data-flow (SemanticModel/DataFlowAnalysis) proves the local is never written after init → emit immutable let.
local that is reassigned var x = e mutable binding.

The immutability decision is driven by Roslyn's definite-assignment/data-flow analysis, not by the C# var/explicit-type spelling — C#'s var is a type-inference keyword, G#'s let/var is a mutability keyword (ADR-0008), so the mapping is semantic, not lexical. Type clauses are emitted only when C# wrote an explicit type and inference would be ambiguous; otherwise inferred (let x = e).

T2 — immutable-field initialization canonicalization. A G# let field is read-only after construction but — like a C# readonly field (issue #947) — is assignable inside the declaring type's init(...) constructor (ExpressionBinder.Assignments.cs). A C# readonly field assigned in the constructor can therefore now be reproduced directly as a let field assigned in init. The translator still prefers the idiomatic primary-constructor / field-initializer canonicalization when the single instance constructor (no : base(...)/: this(...) chain) is a simple parameter-to-member copy, because that yields cleaner G#:

C# constructor assignment G# canonical form Rule
_f = ctorParam; (RHS is exactly a constructor parameter) primary-constructor parameter Type(_f T) the field is lifted to a primary-constructor parameter named after the field; the standalone field declaration is dropped. The parameter-field becomes public (G# primary-ctor parameters are public fields) — recorded as an Info diagnostic.
_f = expr; (RHS independent of every constructor parameter) field initializer private let _f T = expr the assignment becomes a let-field initializer; the field keeps its private visibility and immutable binding.

When every constructor parameter is consumed by exactly one direct _f = param assignment, the explicit constructor is dropped entirely (its remaining _f = expr statements have all become field initializers). If any statement does not fit the pattern (a non-assignment statement, a parameter used in an expression, a duplicate/unconsumed parameter, multiple constructors, a record) the constructor is left untouched and translated as-is — and, since issue #947, the resulting let field assigned inside the explicit init(...) is now valid, compiling G# rather than a GS0127 error. Chosen over option (b) (a privacy-preserving var field) because for L1 the primary-constructor form yields clean, idiomatic G# when liftable; the public-visibility change is recorded for the human to review.

Since issue #948, the inline field initializers the translator emits here — private let _f T = expr from a lifted constructor assignment, a var/let field carrying a C# field initializer, and const _f T = expr from a C# const field — are directly compiling G#: the G# compiler honors inline const/let/var field initializers in a type body, running instance initializers before each constructor body and folding const initializers to compile-time literal fields. No constructor-assignment workaround is needed for these forms.

B.4 class vs struct vs data class vs data struct — ADR-0029, ADR-0025, ADR-0078, spec §Structs…

C# G#
class class (reference)
struct struct (value)
record / record class data class (reference, structural members)
record struct data struct (value, structural equality)

data class/data struct synthesize equality and copy/update ergonomics (ADR-0029, ADR-0032). The record keyword is not emitted (removed by ADR-0078); the canonical spelling is data class/data struct. C# positional records map to the G# primary-constructor form (data struct Point(X int32, Y int32)), fields-only records to the body form. A C# struct with exactly one field that C# treats as a newtype is not auto-promoted to inline struct (ADR-0033) — that is a semantic judgment the tool will not make; it emits a plain struct and leaves inline struct adoption to the human.

T4 — fieldless record → plain (open) class/struct. A G# data type requires at least one field (GS0104, "a data type requires at least one field"). A C# fieldless record — typically the abstract record Shape; base of a closed record hierarchy — therefore maps to a plain class (or struct), not a data class; it is marked open when any case derives from it (§B.6). Two further losses are made faithfully: G# has no abstract class modifier (the keyword is not recognized by the parser; abstract classGS0125), so C# abstract is dropped (the open class is subclassable but not non-instantiable); and the record-synthesized IEquatable<Self> interface is dropped from the base list because a fieldless record maps to a plain class that has no synthesized Equals, so emitting : IEquatable[Shape] would leave the interface unimplemented (GS0187). Naming the enclosing type as a base-clause type argument is itself legal since issue #949 (open class Shape : IEquatable[Shape] now compiles); the drop is a semantic-redundancy filter, not the former GS0113 syntax limitation. Each loss is recorded as an Info diagnostic. The case records (sealed record Circle(double Radius) : Shape) keep the data class Circle(Radius float64) : Shape mapping.

T1 — C# tuples → native G# positional tuples. A C# value/named tuple ((string Name, int Price, int Quantity)) maps to the native G# positional tuple type (string, int32, int32) (spec §Type syntax), not to a synthesized data struct. G# tuples are positional only — the named-element spelling (Name string, …) does not parse — so C# element names are dropped at the type, and a named-element access item.Price lowers to the positional field item.Item2 (resolved via Roslyn's IFieldSymbol.CorrespondingTupleField); positional item.Item1 passes through. Tuple construction (a, b, c) maps to the G# tuple literal (a, b, c). The mapping is recorded as an Info diagnostic. This was chosen over synthesizing a data struct per tuple shape because a data struct element type triggers a real compiler gap (below) and because native tuples are the genuinely canonical, round-trippable G# form.

for … in List[ownedType] element-type erasure — discovered compiler gap. Iterating a List[T] whose element T is a user type owned by the same compilation (a data struct/class declared in the same program) and then accessing a member of the loop variable fails to bind (GS0158/GS0159): the for-in binder erases the element type via TypeSymbol.FromClrType (StatementBinder.cs ~L2852, case ImportedTypeSymbol), which cannot resolve a same-compilation user type, so item.Member has no type. Arrays ([]T) and lists of BCL/primitive elements (List[int32], List[string]) bind correctly; only List[ownedUserType] is affected. Minimal repro:

data struct Item(Name string, Price int32)
var xs = List[Item]()
xs.Add(Item{Name: "a", Price: 1})
for it in xs { Console.WriteLine(it.Name) }   // GS0158 on it.Name

This is why T1 maps tuples to native positional tuples rather than a synthesized data structfor item in List[(string, int32, int32)] binds and item.Item2 resolves. Filed as #939; the translator does not work around it. The indexer path (xs[0].Name) binds correctly, so the defect is specific to the for-in enumerable element-type recovery, not List[T] instantiation in general.

B.5 Methods: in-body vs receiver-clause — ADR-0079, ADR-0024, spec §Functions and methods

Instance methods on a class (or data class) the package owns are declared in-body as func M(...) R { ... }. The receiver-clause form func (r T) M(...) R is reserved for non-owned receiver types — CLR/BCL types, primitives, and types from other packages — i.e. C# extension methods (this T first parameter). ADR-0079 (issue #719) made this the rule and emits the soft GS0314 warning when a receiver clause names an owned type; samples/MethodsWithReceivers.gs is the canonical example (in-body method on an owned class) and samples/ExtensionFunctions.gs is the canonical receiver-clause example (on int32). Operator overloads keep the receiver-clause form and are exempt from GS0314 (spec §Functions and methods).

Owned-struct methods — RESOLVED (issue #938). ADR-0079 frames the in-body canonical form as applying to owned class and struct receivers. As of issue #938 the compiler honours that for value types: a func member inside a struct/data struct body now binds as an instance method on the value type (synthesized by-ref this), reusing the receiver-clause owned-struct lowering and emission (Parser.cs accepts the member; the DeclarationBinder in-body path is no longer gated on syntax.IsClass). The in-body form is therefore the canonical, warning-free spelling for an owned-struct instance method, exactly as for classes; the receiver-clause form func (r T) M(...) R still binds identically but is flagged with GS0314 (DeclarationBinder.cs:3129) to steer authors to the in-body site. User-defined init(...) constructors remain class-only by design — value types are constructed via primary constructors and struct literals, so no constructor gap exists for structs. The cs2gs translator (§B.14) may still lift owned-struct methods to the receiver-clause form for mechanical reasons; emitting the in-body form instead would now be warning-free and is a possible future translator improvement.

C# extension methods (static R M(this T self, …)) translate to the receiver-clause form func (self T) M(…) R (ADR-0019), since T is non-owned by definition.

B.6 Inheritance and the : clause — ADR-0017, spec §Type declarations

  • C# classes are sealed-by-default in G#; a base class that is subclassed must be emitted open class, and the overriding member must carry override (ADR-0017). The translator uses Roslyn's INamedTypeSymbol.IsSealed/IsAbstract/inheritance graph to decide: a class that any other corpus type derives from → open; a C# abstract/virtual member that is overridden → open/override on the pair. C# sealed class → plain class (already the default) or sealed class when it participates in a closed hierarchy switched on exhaustively (ADR-0078).
  • Member open/override openness (Oahu.Decrypt round). G# only treats a member as overridable when it is explicitly open, and unlike C# this does not extend automatically to framework/metadata virtuals or to existing overrides. Three rules keep an inheritance chain compiling:
    • External-base overrides drop override. G# does not treat a virtual declared in referenced metadata (e.g. Object.ToString, Stream.Read, IDisposable.Dispose) as open, so emitting override for a method/property that overrides such an external base member fails (GS0183). The translator detects this (OverridesExternalBaseMethod/OverridesExternalBaseProperty: the overridden root is declared outside the compilation) and emits a plain func/prop — which still binds as the override — instead of override.
    • A non-sealed override is re-emitted open. A C# override member is itself overridable unless sealed, so a further subclass can override it again; G# requires the base member to be open for that (GS0184). A kept (non-external, non-sealed) override is therefore emitted as override open.
    • Member open is gated on class openness. G# rejects open on a member whose enclosing class is not itself open (GS0190). The translator only marks a member open when the containing type will be emitted open class (IsTypeEmittedOpen mirrors the class-declaration openness logic: abstract, has a protected member, or subclassed-and-not-sealed), so an override in a leaf/sealed class stays a plain override func.
  • Explicit class and plain-struct constructors preserve ABI (Oahu.Decrypt OD-T1 / issues #2746/#2766). The translator does not replace an explicit class or plain-struct constructor with a G# primary constructor: lifting renames constructor parameters after assignment targets, turns private fields into public fields, and turns assigned properties into fields without CLR getters. Instead it keeps the explicit init(...) and uses the normal field/property translators. A C# get-only auto-property ({ get; }) assigned by that constructor becomes a G# init-only property prop X T { get; init; } (a bare { get; } is read-only and the kept init assignment would be GS0127). An inline get-only-auto-property initializer ({ get; } = new();) — which G# cannot express as a property member initializer — is moved into the explicit constructor body. Native C# primary constructors are unchanged. Record ABI remains tracked separately by issue #2744.
  • The base clause lists the base class first, then interfaces: class Dog : Animal, IBark { … } (spec BaseClause = ":" QualifiedTypeName … { "," QualifiedTypeName }; samples/Class.gs). Constructor chaining renders as : Base(args) on the base clause or init(...) : Base(args) { … } (ADR-0065, samples/ExplicitConstructor.gs).

B.7 Generics — ADR-0020, ADR-0097, ADR-0098/ADR-0049

  • Bracket form for both declaration and instantiation: func Identity[T any](value T) T, List[int32]() (ADR-0020). No angle brackets ever appear in output.
  • Constraints render in the bracket: the legacy slot (any, comparable, an interface bound — non-generic or constructed-generic, including the self-referential IComparable[T]) plus repeatable flag constraints class, struct, init() (ADR-0097; the default-constructor constraint was renamed from new() to init() by issue #997). C# where T : class[T class], where T : struct[T struct], where T : new()[T init()], where T : IFoo[T IFoo], where T : IComparable<T>[T IComparable[T]] (issue #943). Variance in/out is carried on type parameters of interfaces/delegates (ADR-0021).
  • where T : notnull has no precise G# constraint keyword; the translator drops it (records an Info diagnostic). comparable/any would change the semantics, so the faithful choice is no constraint.

Generic-interface constraint where T : IComparable<T> — RESOLVED (issue #943). Previously a constructed generic-interface constraint had no canonical G# spelling: the bracketed constraint slot's lookahead failed to skip the constraint's own generic-argument brackets (func Max[T IComparable[T]]GS0005), and dropping the argument ([T IComparable]) named a non-existent constraint type (GS0113). Issue #943 fixed the parser lookahead and threaded the constraint through the binder (so members of the constraint interface are available on T) and the emitter (a constrained. !!T callvirt plus a GenericParamConstraint metadata row), so the constructed-generic constraint now parses, binds, emits verifiable IL, and is enforced (GS0152). The translator therefore emits the constraint directlywhere T : IComparable<T> renders as [T IComparable[T]] — rather than dropping it. Minimal repro, now compiling and running:

func Max[T IComparable[T]](a T, b T) T {
    if a.CompareTo(b) > 0 { return a }
    return b
}

B.8 Delegate types — arrow form, ADR-0075

Delegate types render in the canonical arrow form (A, B) -> R, never func(A, B) R (that legacy spelling emits GS0303). Void returns spell -> void; multi-return spell -> (T1, T2); async spell async (T) -> R. C# Func<int,int>(int32) -> int32; Action<string>(string) -> void; Func<Task<int>>async () -> int32. A C# named delegate declaration becomes type Name = delegate func(...) R (ADR-0059, samples/NamedDelegate.gs) — the one place the func keyword stays, because it is a named delegate declaration, not a type clause. Function-literal expressions keep func(x int32) int32 { … }; arrow lambdas use (x int32) -> expr (ADR-0074).

B.9 String interpolation — ADR-0055, ADR-0007, ADR-0011

Every G# string literal is interpolation-capable (no $ prefix; see samples/InterpolatedString.gs). Therefore:

  • A C# non-interpolated literal containing a literal $ must have each $ escaped to $$ on output.
  • A C# interpolated string $"...{expr}...{x:F2}...""...${expr}...${x:F2}...": each hole {e} becomes ${e}, C# {{/}} become literal {/}, and any literal $ in the surrounding text becomes $$. A bare {ident} may render as $ident only when ident is a simple identifier; complex holes always use ${…}.
  • Format/alignment specifiers inside holes are preserved (ADR-0055 rich holes).

B.10 Visibility and default-visibility mapping — ADR-0014, ADR-0109, ADR-0006

Defaults: top-level declarations default to public (ADR-0014); top-level private is permitted (ADR-0109). The printer emits an explicit accessibility modifier only when the C# accessibility differs from the G# default for that position, otherwise omits it for canonical minimalism:

C# top-level member
public omit (default) emit public where member default is not public
internal internal internal
private private (ADR-0109) private
protected internal + triage note (no top-level protected) protected (issue #950)
protected internal / private protected internal + triage note internal + triage note

Since issue #950, G# has a first-class protected member modifier (CIL family), so the translator maps C# protected members directly to G# protected. Because protected is only valid on members of an inheritable open class, the translator forces any class that declares a protected member to be emitted as open (alongside the existing "subclassed within the compilation" rule). The blended forms protected internal and private protected have no single-keyword G# equivalent; they continue to map to the nearest supported accessibility (internal) with a triage note. There is no top-level protected in G#, so a protected type still maps to internal with a triage note.

B.11 Members: fields, properties, constructors, statics, enums, attributes

  • Fields require var/let (ADR-0067, §B.3).
  • Propertiesprop Name T for auto-properties, with { get { … } set(v) { … } } bodies for computed/custom accessors (ADR-0051, samples/PropertyRef/Lib/Lib.gs). open prop/override prop mirror method virtuality. A C# init accessor maps to the first-class G# init accessor (issue #946); an init-only auto-property { get; init; } keeps its explicit accessors (it is not collapsed to the read-write prop Name T auto form, which would lose the init-only semantics). (Superseded note: earlier revisions mapped C# init to G# set with an Info gap diagnostic because G# had no init accessor; that gap is now closed.)
  • Constructorsinit(params) { … }, chaining via : Base(args) (ADR-0065). C# primary constructors / positional records map to the G# primary-constructor Name(params) head.
  • Static members → a shared { … } block (ADR-0053); except the program entry's static class, which is hoisted to top level (T3, above). Sibling static calls inside a non-entry shared { } block are emitted qualified (Geometry.Round(...)), since an unqualified sibling static call does not resolve there (GS0130).
  • Static constructor body (static Type() { … }) → a shared { init { … } } static-initializer block (ADR-0140, issue #2131). The block's statements run in the type's .cctor after the static-field initializers, and the type drops beforefieldinit — matching C# static-constructor semantics. (Superseded note: earlier revisions had no G# surface for a C# static constructor body; that gap is now closed.)

Static (shared) method overload resolution by arity — RESOLVED (#940). A user type whose shared { } block declares overloaded static methods (same name, different arity/params) formerly could not be called on any overload but the first-declared one: the binder resolved a single by-name match and arity-checked it (GS0144), never forming an overload set. Instance-method overloads on the same type always resolved correctly. Minimal repro:

class Geometry {
    shared {
        func Round(value float64) float64 { return Geometry.Round(value, 2) }
        func Round(value float64, digits int32) float64 { return Math.Round(value, digits) }
    }
}
Console.WriteLine(Geometry.Round(1.234))   // GS0144: 'Round' requires 1 arguments but was given 2

Root cause was: ExpressionBinder.Access.cs BindUserTypeStaticCall used StructSymbol.TryGetStaticMethod(name, out method) (single by-name) rather than building a static method group + running OverloadResolver (as the instance path does). Fixed in #940; static overloads now resolve by arity, and the L2 corpus app (overloaded Geometry.TotalArea/Round) translates and compiles through this construct.

  • Enumsenum Name { A, B, C } (samples/Enum.gs); payload-bearing C# unions (sealed hierarchy idioms) map to discriminated-union enums (ADR-0078 §5) only when the source is unambiguously that shape, else triaged.
  • Attributes@Name(args), one per line, order preserved (ADR-0047): C# [Obsolete("x")]@Obsolete("x"). Explicit attribute targets ([return: …], [field: …], [assembly: …]) map to the @target:Name(...) form.
  • foreachfor x in coll (ADR-0031); LINQ/extension calls keep instance-call syntax (xs.Where((x int32) -> x % 2 == 0), samples/LinqExtensions.gs).

T3 — C# entry point + static class → top-level. The program entry in G# is top-level statements (a sample .gs runs its top-level code; there is no Main method entry), and an unqualified sibling static call inside a shared { } block does not resolve (GS0130). The translator uses Compilation.GetEntryPoint(...) to find the C# Main and rewrites its enclosing static class to top level:

C# (entry class) G#
static void Main() body top-level statements appended after all package declarations (this is the program entry)
other static method of the entry class top-level func (siblings call each other unqualified at top level, which resolves)
the entry static class itself / a shared { } wrapper dropped — neither is emitted

The mapping is recorded as an Info diagnostic. Only the type that contains the entry point is hoisted; other static utility classes still map to a class whose members sit in a shared { } block (§B.11, ADR-0053). This applies to executable compilations (a library compilation has no entry point, so its static classes keep the shared { } mapping).

B.12 Numeric type names and identifiers — ADR-0049, ADR-0098

Canonical output uses width-bearing primitive names (ADR-0049): C# intint32, uintuint32, longint64, ulonguint64, shortint16, ushortuint16, byteuint8, sbyteint8, floatfloat32, doublefloat64, boolbool, stringstring, charchar, objectobject. The friendly aliases (ADR-0098) parse, but the printer emits the canonical width-bearing form so output is uniform. Identifier names are preserved verbatim from C# (PascalCase types/members, camelCase locals) — the tool does not rename to a different casing convention. Numeric literal spellings are likewise preserved verbatim (the original token text, not the bound value): a C# 2.0 stays 2.0 and hex such as 0xFF0000 stays 0xFF0000. This is load-bearing — G# does not implicitly promote across the integer/floating boundary at a binary operator, so collapsing 2.0 to 2 would type it as int32 and make int32 * float64 a hard GS0129 error.

B.13 Data-type structural equality and IEquatable<Self> — ADR-0078, ADR-0025

A data class/data struct auto-synthesizes value (structural) equality, GetHashCode, and the with updater. A C# record therefore drops its compiler-synthesized IEquatable<Self> from the base clause when emitted as a data type — re-stating : IEquatable[Self] is redundant because equality already comes from the data modifier. (Naming the enclosing type as a base-clause type argument is legal since issue #949; the drop is a redundancy filter, not a syntax restriction.) The structural ==/!= and with come for free from the data modifier.

A non-data struct that explicitly implements an interface (struct Money : IEquatable<Money> with a hand-written Equals) keeps its interface clause: gap #976 — the parser rejecting a : after a struct name — is resolved (issue #976), so the translator emits struct Money(Cents int32) : IEquatable[Money] and the struct's own Equals/GetHashCode satisfy the interface. A struct naming a class or struct base (rather than an interface) is now rejected with the dedicated diagnostic GS0382 rather than the former generic GS0005, matching the value-type-has-no-base-class rule. Only the redundant data-synthesized IEquatable[Self] is dropped (above); a genuinely hand-implemented interface on a plain struct is preserved.

B.14 Owned value-aggregate methods → lifted receiver-clause funcs — issue #938, ADR-0079

As of issue #938 a struct/data struct instance method can live in the type body — the parser and binder accept an in-body func on a value aggregate and bind it warning-free (§B.5). The cs2gs translator nonetheless still lifts such a method to a top-level receiver-clause func (self T) Name(...) emitted as a sibling immediately after the type, for mechanical reasons (its declaration-lifting pipeline predates the compiler fix). A top-level receiver-clause func has no implicit this, so inside the lifted body every bare instance-member reference is made explicit through the receiver (self.X). This compiles and runs correctly but emits the soft GS0314 warning (ADR-0079, owned-type receiver clause). Emitting the in-body form instead — now warning-free — is a possible future translator improvement; the GS0314 it currently produces is an expected, known diagnostic rather than a parity failure. Plain structs also admit explicit init constructors for ABI-preserving C# translation (issue #2766); data/inline structs retain their canonical primary-constructor-only shape.

B.15 with-expressions — spec §Records and with

A C# with-expression maps to the canonical G# form expr with { Field = value, … } (the update list uses =, not the struct-literal :). An empty update list renders expr with { }. The target may be any data value; the result is a structurally-distinct copy.

B.16 Object initializers and value-type construction → composite literals — spec §Composite literals

C# new T { Field = v, … } (an object initializer) maps to the G# composite literal T{Field: v, …} (the literal body uses :). Construction form depends on the target's kind: a reference type (class, data class) uses the call form T(args), but a value type (struct, data struct) uses the composite literal T{Field: value} even for positional construction — the call form on a value type is GS0130 ("Function doesn't exist"). Positional arguments are zipped against the type's ordered settable instance members (e.g. a record struct Point(double X, double Y) constructed as Point{X: …, Y: …}).

A C# collection initializer now maps to the canonical G# collection initializer Target{ … } (ADR-0117, issue #479) rather than being dropped. new List<int>{1, 2, 3}List[int32]{ 1, 2, 3 } (bare elements); a dictionary's complex-element form new Dictionary<K,V>{ {k, v}, … } → the key: value pair form Dictionary[K, V]{ k: v, … }; and the indexer form new Dictionary<K,V>{ [k] = v }Dictionary[K, V]{ [k] = v }. Constructor arguments ride on the construction target, so new Dictionary<string,int>(StringComparer.OrdinalIgnoreCase){ ["Key"] = 5 }Dictionary[string, int32](StringComparer.OrdinalIgnoreCase){ ["Key"] = 5 }. (Previously the translator emitted only the bare Dictionary[K,V]() construction and reported the initializer elements as unsupported; that gap is closed.)

B.17 Explicit casts → width-bearing conversion calls — ADR-0049

A C# explicit cast (T)expr maps to the G# width-bearing conversion call T(expr) (e.g. (int)xint32(x), (byte)nuint8(n)). This is behaviour-faithful for numeric narrowing: the CLR truncates a float64int32 conversion toward zero exactly as C# (int) does, so a half-up rounding such as (int)Math.Floor(d*100.0 + 0.5) reproduces the C# result bit-for-bit (ToCents(1.234) == 123).

B.18 Sibling static-call qualification in non-entry static classes — ADR-0053

Inside a G# shared { } block a bare sibling static call does not resolve (GS0130); the call must be qualified through the owning type (Geometry.Round(value, 2)). The translator therefore qualifies a C# bare static call (Round(value, 2)) with its declaring type whenever that type maps to a shared { } block. The entry static class is the explicit exception (§B.11, T3): its members flatten to top-level funcs that do call each other unqualified, so a bare sibling call inside the entry type stays bare (qualifying it through the dropped type would be GS0157).

B.19 Extension methods → top-level receiver-clause funcs; emptied static class dropped — ADR-0079

A C# extension method (static R M(this T self, …) on a static class) translates to a top-level receiver-clause func (self T) M(…) R (§B.5), because a receiver-clause func only binds its receiver at top level — inside a shared { } block the receiver is not in scope (GS0125/GS0157). The translator therefore lifts every extension method out of its enclosing static class to a top-level sibling (reusing the pendingTopLevelDeclarations mechanism of §B.14), and when every member of the static class is lifted the now-empty class is dropped entirely (a class with an empty body and empty shared block is GS0104/noise). samples/ExtensionFunctions.gs and samples/GenericExtensionFunctions.gs are the canonical forms.

B.20 Lambdas → canonical arrow form — ADR-0074, ADR-0075

A C# lambda maps to the canonical G# arrow lambda with a parenthesized, typed parameter list: n => n % 2 == 0(n int32) -> n % 2 == 0; (x, y) => x + y(x int32, y int32) -> x + y; a block-bodied lambda → (x int32) -> { …; trailingExpr } (samples/ArrowLambda.gs). Parameter types come from the Roslyn-bound delegate signature; an inferred parameter with no recoverable type falls back to the C# spelling. Func<int,int> parameters/returns spell as arrow types (int32) -> int32 (§B.8). LINQ method syntax stays as the instance/extension call chain — numbers.Where((n int32) -> n % 2 == 0).Select((n int32) -> n * n).Sum() (samples/LinqExtensions.gs, needs import System.Linq).

B.21 LINQ query syntax → method-call chain (lowering)

G# has no query-comprehension syntax, so a C# query expression (from n in numbers orderby n select n) is lowered to the equivalent method-call chain the C# compiler itself desugars it into: numbers.OrderBy((n int32) -> n).Select((n int32) -> n). where/orderby/select/from-continuation map to .Where/.OrderBy(+.ThenBy)/.Select/nested calls. The lowering is faithful because it reproduces Roslyn's own query-to-method translation.

B.22 switch expressions → G# switch expression (colon-arm form) — spec §Pattern matching

A C# switch expression maps to the G# switch expression used in expression position (assignable / returnable): switch subject { case <pattern>: <expr> … default: <expr> } (samples/SwitchExpression.gs; the colon-arm form is the expression form, distinct from the brace form samples/PatternSwitch.gs uses for statements). C# arm patterns map as: constant 0case 0:; relational < 10.0case < 10.0:; type Circle ccase c is Circle: (the designator is usable in the arm body, e.g. c.Radius); property { X: 0, Y: 0 }case { X: 0, Y: 0 }:; discard _default:. A property sub-pattern that binds a variable (Circle { Radius: var r }) is rewritten to a member access on the arm's type-pattern designator (rcircle.Radius) because G# has no var-binding sub-pattern.

B.23 async/await → G# async func with unwrapped return type — spec §Asynchronous, samples AsyncTask.gs

A C# async method maps to a G# async func, but the return type is the UNWRAPPED result type — the async modifier synthesizes the Task/Task<T> envelope itself (samples/AsyncTask.gs, AsyncValueReturns.gs). So async Task<int> M()async func M() int32 { … } (not Task[int32]), and async Task M()async func M() { … } (no return type). await operand maps to await operand unchanged. A non-async method that merely returns a Task<T> keeps its Task[T] return type (only async methods are unwrapped).

B.24 Predefined type as a static-call receiver → BCL type name

A C# predefined-type keyword used as an expression receiver of a static call (string.Concat(parts), int.Parse(s)) emits the BCL type name so the receiver resolves: stringString, intInt32, etc. (String.Concat(parts)). The lowercase keyword would not resolve as a static-call target in G#.

B.25 Target-typed new() → explicit constructed type

A C# target-typed new() emits the explicit constructed type inferred from the target: List<int> items = new();List[int32](). G# has no target-typed construction, so the type recovered from Roslyn's target type is made explicit.

B.26 Conditional (ternary) expression → value-producing if-expression — ADR-0064, sample IfExpression.gs

A C# conditional expression cond ? a : b maps to the canonical G# value-producing if cond { a } else { b } if-expression (issue #711 / ADR-0064). The terminal else is required in value position (the if-expression is exhaustive), so the two arms map directly; else if chains arise naturally from a nested conditional. Used wherever an expression is expected — let x = cond ? a : blet x = if cond { a } else { b }.

B.27 try/catch/finally and throw → G# try statement — sample Exceptions.gs

A C# try/catch/finally maps to the canonical G# try { } catch (e T) { } finally { }. A typed catch catch (FooException ex) becomes catch (ex FooException) (binder name first, then type — the receiver-clause ordering); a bare catch { } (no type) becomes an untyped catch { }; finally carries over verbatim. throw new FooException(args) maps to throw FooException(args) (object construction, §B.16). A C# bare re-throw throw; has no G# spelling (throw alone is GS0005), so within a named catch it re-emits the caught binder — throw ex — reproducing the same exception instance; a bare re-throw outside a named catch is reported unsupported.

B.28 Custom exception with : base(message) → explicit init with base chaining — sample ExplicitConstructor.gs

A C# constructor with a base initializer : base(args) maps to the canonical G# explicit-base form init(params) : base(args) { … }. This is how a custom exception (class FooException : Exception { public FooException(string m) : base(m) { } }) forwards its message to System.Exception. The base arguments are translated as ordinary arguments; a : this(...) constructor delegation has no canonical form yet and is reported unsupported. (Gap #975 — interpolated string in base-argument position — is resolved: the emitter now lowers an interpolated string in : base(...) position, so the translator emits it directlyinit(n int32) : base("only $n left") — with no bare-parameter forward. The base initializer is translated through the same expression path as any call argument, so interpolation, concatenation, and literals all carry over verbatim.)

B.29 using statement / using declaration → using let resource — sample Defer.gs

A C# using (var r = expr) { body } statement maps to a scoped block whose resource binds with the using prefix — { using let r = expr; …body } — so the resource is disposed at the end of that block (the explicit braces preserve the C# block's disposal scope). A C# 8 using var r = expr; declaration binds the same way at statement scope (using let r = expr), disposed at the end of the enclosing function/block. The resource is read-only after acquisition, hence let.

B.30 out/ref arguments → pass-by-address &x / inline out var x — ADR-0060, sample TryParseOutVar.gs

A C# out/ref argument maps to the canonical G# argument forms. A pre-declared out/ref variable passes by address: d.TryGetValue(k, out existing)d.TryGetValue(k, &existing); the uninitialised local binds as a mutable var existing T (an immutable let requires an initializer — see B.3). An inline out var x declaration maps to out var x and out _ to the discard out _. (Gap #977 — inline out var failing overload resolution against BCL methods — is resolved: an inline out var x against a BCL method such as Dictionary.TryGetValue now binds, so the translator emits the canonical inline out var x form for BCL and user calls alike; the pass-by-address &x form remains the canonical mapping for a pre-declared out target.)

B.31 Operator overloading → receiver-clause operator funcs — ADR-0035, sample Operators.gs

A C# operator overload public static X operator +(X a, X b) maps to the canonical G# receiver-clause operator func func (a X) operator +(b X) X: the first operand becomes the receiver, the remaining operand(s) become parameters (a unary operator operator -(X a) has no parameters: func (a X) operator -() X). Like every receiver-clause func, an operator func only binds at top level, so it is lifted to a top-level sibling of its owning type (for both value- and reference-aggregate owners) and carries no open/override modifier. The matching Equals/GetHashCode instance methods lift the same way on a value aggregate (§B.14).

B.32 Uninitialised local → mutable var name T

A C# local declaration with no initializer (int existing;, typically a pre-declared out target) binds as a mutable var name T rather than let name T: an immutable let requires an initializer (the spec's binding rules), and the type clause names the zero/default value. With an initializer the binding follows the usual let/var reassignment analysis (§B.3).

B.33 switch statement over patterns → G# switch block — sample PatternSwitch.gs

A C# switch statement maps to the canonical G# switch subj { case <pat> { … } default { … } } block form (sample PatternSwitch.gs): each case section's labels become a case <pattern> arm whose body is the section's statements wrapped in a block, a type pattern case T x: maps to case x is T, and the per-section break; (required in C#) is dropped (G# arms do not fall through). The default: section maps to a default { … } arm. A when guard on a case maps to a G# case <pattern> when <bool> { … } guard (§G #991, resolved) and a value-returning switch statement is emitted as a switch expression instead (the statement form is not exhaustiveness-checked for value returns), so the translator reserves the block form for void-bodied dispatch. Reuses the same pattern set as the §B switch-expression mapping (type/relational patterns).

B.34 Iterator (yield return) → sequence[T] generator — sample TupleSequenceIterators.gs

A C# iterator method (a body containing yield return) returning IEnumerable<T>/IEnumerator<T> maps to a G# generator whose return type is rewritten to sequence[T] (the element type T), with each yield return expr; emitted as a bare yield expr (sample TupleSequenceIterators.gs). The IEnumerable<T> wrapper is not carried through — sequence[T] is the canonical G# enumeration surface and is directly for x in …-iterable. yield break; has no G# spelling (§G #994). A user reference-type element (sequence[UserClass]) is now fully supported (§G #990, resolved); user data struct elements work too.

Numeric literal promotion at a converted-type site (§B.12 note). C# applies an implicit intdouble/float conversion when an integer literal is passed where a floating-point value is expected (e.g. M(30) where the parameter is double). As of issue #1281 gsc applies the same implicit widening at a concrete numeric parameter, so a bare int32 literal now compiles and verifies; the translator nonetheless emits the float literal spelling (3030.0) as the canonical converted-type form — it consults the bound ConvertedType so the emitted value is self-describing and matches its target type. (Historically a bare int32 literal produced invalid IL — ilverify StackUnexpected, found Int32 expected Double — before gsc widened numeric arguments at the call site; discovered by the L5 pipeline.)

Parameterless constructor initializing a property (§B.3 note). The primary-constructor / field-initializer canonicalization lifts a _field = expr constant assignment out of a dropped constructor into a G# field member initializer (var Name T = expr). G# has no property member initializer (prop Name T = exprGS0288/GS0113), so when the assigned member is a property, the explicit init() constructor is kept (its body faithfully assigns the property) rather than dropped. (Discovered by the L5 pipeline.)

Indexer declaration — RESOLVED (#944, ADR-0118). A C# indexer (public T this[int i] => _items[i];) now maps to the canonical G# indexer member prop this[i int32] T { get { … } } (ADR-0118). Originally this was a compiler gap: G# had no indexer-member form and a hand-written indexer-shaped property declaration crashed the compiler (GS9998, ArgumentNullException). The translator now emits the canonical indexer form and no longer reports an IndexerDeclaration unsupported record.

Null-coalescing operator ?? — RESOLVED (#941). value ?? fallback now maps directly to G#'s ?? null-coalescing operator. Originally this was a compiler gap (G# spelled the read ?: and the parser rejected ??); issue #941 / ADR-0116 respelled the G# operator to ?? and removed ?:, so the translator emits value ?? fallback verbatim. No longer surfaced as a translation-unsupported record.

Member access on a bare-identifier element access — RESOLVED (#942). values[i].Member (a single bare-identifier index immediately followed by .) formerly hit a parser ambiguity — ident[ident] was parsed as a generic instantiation expecting a ( call, so the . was rejected (GS0005). Literal and compound indices always parsed fine (values[0].M, values[i + 1].M). Issue #942 fixed the parser, so the translator now emits values[i].Member through the normal member-access path with no placeholder.

B.35 Oahu.Foundation round — additional statement/expression mappings

Translating the external Oahu.Foundation project (issue #914 continuation; 105 initial translation-unsupported diagnostics across 25 construct kinds) required the following additional canonical mappings. Each printed form round-trips through the real G# parser; gsc-accepted forms were verified empirically before adoption.

  • Conditional access a?.b / a?.b() / a?[i] → G# null-conditional access. A C# ConditionalAccessExpression maps to the corresponding G# null-conditional chain: a?.b, a?.b(), and a?[i] keep the ?. / ?[ spelling. Nested and chained when-not-null member bindings flatten onto the conditioned receiver.
  • is-pattern → nil tests + Kotlin-style smart cast (no binder). G# has no x is T t pattern binder (that is compiler gap #993). The translator therefore maps: x is nullx == nil; x is not nullx != nil; a bare type test x is T → the boolean x is T; and a binding type pattern if (x is T t) { …t… }if x is T { …x… }, relying on G#'s in-block smart-cast of x to T (every use of the binder t is rewritten to the smart-cast subject x). Predefined types keep their value-keyword spelling (x is string, not x is String).
  • Array creation / initializer → G# array forms. new T[n]array[T](n) (sized array); new T[]{ a, b, c }, new[]{ a, b, c } (implicit element type), a bare { a, b, c } array-initializer, and a new List<T>{ a, b } collection-initializer all map to the canonical G# composite/array literal forms. T[] as an ArrayType maps to the G# array type spelling.
  • typeof(T)typeof(T). Maps directly; the operand type is rendered through the type mapper so a predefined type stays a value keyword (typeof(string), typeof(object)). An unbound generic typeof(IEnumerable<>) drops the empty type-argument list → typeof(IEnumerable) (G# has no open-generic typeof).
  • default / default(T)default. Both the DefaultLiteralExpression (default) and the DefaultExpression (default(T)) map to G#'s default zero-value expression.
  • sizeof(T)sizeof(T). Maps directly for the blittable primitive set.
  • Assignment as an expression / chained a = b = c. A SimpleAssignmentExpression used in expression position (inside another expression, or right-associatively chained a = b = c) emits the G# assignment expression rather than only the statement form.
  • out var x declaration expression → inline out var x. A DeclarationExpression in argument position emits the inline out var x form (§B.30), with the binder name sanitized (below).
  • : this(args) constructor delegation → init(params) : this(args). A ThisConstructorInitializer maps to the G# this-chained initializer, mirroring the : base(args) mapping of §B.28.
  • Local function → nested func. A LocalFunctionStatement maps to a G# local func declaration in the enclosing body.
  • break / continuebreak / continue. Both map to their identical G# loop-control keywords (these had no prior translator case).
  • do … while (c) → G# do-while. A DoStatement maps to the canonical G# do-while loop.
  • lock (m) { … } → G# lock. A LockStatement maps to the canonical G# lock form over the monitor expression.
  • unchecked { … } → block body. An UncheckedStatement (G# arithmetic is unchecked by default) flattens to its inner statements.
  • ~T() destructor → deinit. A DestructorDeclaration maps to the G# finalizer/deinit member.
  • namespace N { … } → G# package. A file-scoped or block NamespaceDeclaration maps to the G# package declaration / membership; the CompilationUnit-level handling threads the package through the document so the doc-level translator no longer rejects the unit.

Reserved-word & verbatim-identifier sanitization (SanitizeIdentifier). A C# identifier that collides with a G# hard keyword (e.g. type, func, default, in) cannot be emitted verbatim — the round-trip parser rejects it. The translator sanitizes every emitted identifier: a leading @ (C# verbatim escape, e.g. @default, @In) is stripped, then a name equal to a G# reserved word is suffixed with _ (typetype_, funcfunc_). Applied at parameters, receivers, identifier references, foreach/for-in variables, local declarations, field/property/method/enum-case names, member-access member names, conditional-access bindings, and out var binders. (Oahu.Foundation has a @default field, an @In enum member, and type/func identifier sites.)

Width-bearing integer literals (NormalizeIntegerLiteralText). A suffix-less C# integer literal whose bound type is wider/unsigned than int32 is emitted with the matching G# suffix (UL/L/U) so the round-trip parser infers the same width — without this a uint64 constant such as the CRC seed 0xD800000000000000 parses as an overflowing int32/int64 (GS0004). The suffix is derived from the Roslyn-bound constant type.

Catch-all catch { } → typed binder. G# requires a typed catch binder (catch (e Exception) { }); a bare catch { }, catch e { }, or catch Exception { } all fail to parse. The translator synthesizes catch (ex Exception) { } for a C# bare catch-all (extending §B.27).

B.36 Oahu.Decrypt round — C# 12 collection/pattern/range surface

Translating the external Oahu.Decrypt project (issue #914 continuation; 109 initial translation-unsupported diagnostics across 19 construct kinds) required the following additional canonical mappings. Each printed form round-trips through the real G# parser; gsc-accepted forms were verified empirically (gsc 0.2.137+31ced6cfb7) before adoption.

  • Collection expression [a, b, c] → slice literal []T{ … }. A C# 12 CollectionExpression targeting an array/Span/IEnumerable<T> maps to the canonical G# slice literal []T{ a, b, c }, with the element type taken from the converted target type. Bare numeric literals are coerced to the element type (byte[] = [0, 1][]uint8{ uint8(0), uint8(1) }) to pin each element's type at the slice-literal boundary (§B.12).
  • Throw-expression x ?? throw e / c ? v : throw e → value-position throw lowering. G# throw is statement-only; a C# ThrowExpression used in a value position is lowered to the uniform form if true { throw e\n default(T) } else { default(T) }, which is a valid expression in every position (?? right-hand side, conditional branch, switch arm). The result type T comes from the converted type of the throw-expression. A bare arrow body => throw e maps directly to a G# throw statement.
  • is-pattern combinators (constant / relational / not / and / or / recursive) → boolean lowering. G# is in expression position supports only expr is Type; all other patterns are switch-only. So an is-pattern is lowered to a boolean: x is 0x == 0; x is 1 or 2x == 1 || x == 2; x is not T!(x is T); x is >= 0 and < nx >= 0 && x < n (C# precedence not > and > or preserved, parenthesized where needed); a recursive/property pattern x is { }x != nil (the property-binder local is rewritten to a member access on the subject). The switch-context pattern set (§B switch mapping) keeps the native case pattern forms.
  • Range index a[i..j] / a[i..] / a[..j].Slice(start, length). G# has no range operator (gsc gap, §G OD-1); a RangeExpression index over a Span/Memory/ReadOnlySpan lowers to a .Slice call: s[i..j]s.Slice(i, j - i), s[i..]s.Slice(i), s[..j]s.Slice(0, j).
  • Null-forgiving expr! → non-null assertion expr!!. G#'s postfix !! asserts non-null (spec: "Postfix !! asserts non-null"), the direct analogue of the C# null-forgiving operator, so the SuppressNullableWarningExpression operand is preserved with !! rather than dropped.
  • Post/pre-increment/decrement as an expression. G# now models ++/-- both as statements and as value-producing expressions (issue #1027). A PostIncrementExpression/PostDecrementExpression used as a value in a position that has a canonical statement seam (a[i++] = v, M(i--), x = y++) is still hoisted: the sub-expression reads the pre-increment value and the mutation is appended as a trailing i++ / i-- statement after the enclosing expression statement (in document order; nested-lambda bodies are not hoisted into the outer seam). In a position with no statement seam — e.g. inside the short-circuit && condition of an unsafe do-while (while data[i] == 0 && i-- > 0) — the faithful inline i-- / i++ / --i / ++i expression is emitted directly (formerly reported unsupported).
  • User-defined conversion operator → func operator implicit/explicit (issue #1017). A C# public static implicit operator T(U x) maps to the in-body member func operator implicit(x U) T (and explicitfunc operator explicit(x U) T); the single source parameter is preserved and the operator target becomes the return type. public static is dropped. (Formerly reported unsupported with a now-obsolete "gsc gap" note.)
  • stackalloc T[n]stackalloc [n]gT (issues #1024, #1057, #1041). The element type is mapped through the C#→G# type mapper (byteuint8), and the grammar is G#-style (count first, then element type), so stackalloc byte[2]stackalloc [2]uint8. A C# initializer is emitted faithfully (stackalloc int[] { 1, 2, 3 }stackalloc [3]int32{1, 2, 3}, the length inferred from the initializer). In a safe context this yields Span<T>; as an unsafe pointer target it yields T*.
  • fixed (T* p = src) { … }fixed p *gT = src { … } (issue #1026). The paren-less G# fixed pins a managed array/string and is only legal inside an unsafe context; multiple declarators emit nested fixed blocks. The required unsafe context is supplied by the unsafe-modifier mapping below.
  • unsafe modifier mapping (issue #1026 / ADR-0122). A C# unsafe class C → G# unsafe class C (the unsafe modifier precedes the visibility on a type declaration). A C# unsafe method maps by wrapping its body in an unsafe { } block (the parser does not combine a visibility modifier with unsafe func). A C# unsafe { } block maps to a G# unsafe { } block. This mapping is required for fixed, pointer-target stackalloc, and pointer code to be legal.
  • Tuple-deconstruction assignment (a, b) = (x, y) → element-wise assignment. G# has no tuple-assignment form, so an assignment whose left side is a tuple of existing variables is lowered to element-wise assignments through fresh temporaries (var __decon0 = x; var __decon1 = y; a = __decon0; b = __decon1), preserving C#'s evaluate-all-then-assign order (and aliasing such as (a, b) = (b, a)).
  • yield break → iterator-exit jump. G# has no yield break; cs2gs jumps to a synthesized label at the end of the nearest iterator body so termination is independent of surrounding loop/switch topology.
  • foreach ((a, b) in xs) tuple deconstruction → for a, b in xs. A ForEachVariableStatement maps to the G# two-name iteration form; element names are collected from the tuple/declaration designation.
  • Field-like event public event EventHandler<T>? X;event X <type>. An EventFieldDeclaration maps to the canonical G# name-then-type event member; the nullable annotation on the delegate type is dropped (a field-like event is nil-initialized). EventHandler<T> renders through the type mapper as its delegate signature (event X (object?, T) -> void).
  • UTF-8 string literal "…"u8 → byte slice []uint8{ … }. A Utf8StringLiteralExpression maps to a G# byte-slice literal of the UTF-8 bytes ("AB"u8[]uint8{ 0x41, 0x42 }).
  • Control characters in string literals → \uXXXX escapes. The printer now re-escapes any character below U+0020 (and U+007F) — e.g. C# "\0\0\0" — as a \uXXXX escape so the emitted G# string stays terminated and round-trips (previously a raw NUL produced GS0003: Unterminated string literal).
  • Unsigned literal coercion at an unsigned target (OD-T2, refined by issue #1281). A C# integer literal implicitly converted to an unsigned field initializer, assignment, or local declaration (uint samplesPerChunk = 0) is emitted with an explicit width cast (uint32(0)) by CoerceConstantToUnsigned. Call and base(...) argument positions no longer carry this cast when the parameter is a concrete numeric type: gsc now accepts the constant-expression narrowing/cross-sign conversion at the call site (issue #1281), so a : base(4) argument whose parameter is uint8 emits the bare literal 4 rather than uint8(4). The explicit cast is still emitted for a generic (type-parameter) argument position, where gsc's inference would otherwise fail (GS0159), and for a non-literal constant (e.g. a const field) that gsc does not fold.
  • new object() / target-typed new() to objectSystem.Object() (OD-T3). A construction of System.Object has no bare object() spelling (GS0130, "function object doesn't exist"); the translator emits the fully-qualified System.Object() callee.
  • Build-generated source exclusion (OD-T4). The project loader (CSharpProjectLoader.IsGeneratedSource) excludes build artifacts from the corpus so they cannot inject undefined attributes (GS0198): anything under an obj/ or bin/ directory, plus generated files (*.AssemblyInfo.cs, *.AssemblyAttributes.cs, *.GlobalUsings.g.cs, *.Version.cs, *.g.cs, *.g.i.cs) — e.g. the Nerdbank.GitVersioning ThisAssembly file.
  • Record property preservation (OD-T5, issue #2704). Body auto-properties on a C# record/record struct remain properties on the G# data class/data struct; init-only and required members are never downgraded to mutable public fields. Property initializers move to private backing fields with computed accessors, preserving per-instance defaults while object literals call the init accessor. Data aggregates support auto-properties and zero fields.
  • Whole-app compile ordering. CompileStage.OrderForCompilation topologically sorts the emitted .gs files so each type's base classes and interfaces are passed to gsc before it (Kahn's algorithm, stable on original order; a file declares its dependency through EmittedGsFile.BaseClassNames, which now includes implemented interfaces as well as the base class). This works around a gsc order-sensitivity in : base(...)/interface-satisfaction binding (§G). Ordering affects only the compiler command line, never file content, so golden .gs outputs are unchanged.

Cs2Gs.Pipeline runs four ordered stages per corpus app. Each stage has an explicit pass/fail gate; a failure short-circuits the remaining stages for that app, emits a triage artifact (section D), and is recorded in the run report. "Migration completed" ≡ all four stages green: clean compile + clean IL verification + test parity with the original C#.

# Stage Action Pass gate On failure
1 Translate C#→G# via Cs2Gs.Translator; round-trip parse each emitted .gs with the real G# parser. Every file parses; zero unsupported-construct records. category translation-unsupported; stop.
2 Compile Invoke gsc on the .gs set (slash-colon switches /out: /target: /reference: /targetframework: /nowarn:, per src/Compiler/Program.cs). gsc exit 0, zero error diagnostics. category compile-error, capturing every GSxxxx; stop.
3 IL-verify dotnet tool restore then dotnet tool run ilverify (the repo-pinned dotnet-ilverify, .config/dotnet-tools.json) over the emitted assembly + its references, with the two documented ilverify false-positive bundles ignored (-g; see §D). ilverify reports no errors. category ilverify-failure; stop.
4 Test-parity Build the ported @Fact/@Theory/@InlineData G# xUnit tests (the gsharp-xunit shape) and run dotnet test; compare pass/fail set — and, where applicable, captured program stdout against the repo's .golden convention — to the C# baseline oracle (section E). Ported tests reproduce the C# baseline (same tests pass) and optional stdout matches. category test-parity-failure; stop.

gsc selection / retry semantics. The pipeline takes a --gsc <path> override (defaulting to the repo build output) so a run can be re-executed against a freshly built compiler. When a stage fails because of a compiler gap (a missing feature or a bug, not a translator defect), the pipeline records the gap and its retry history; the external agent files an issue (section D); and the entire run can be re-executed from stage 1 against the updated gsc. Retry is whole-corpus and idempotent: because translation is deterministic (section B) and the parity oracle is fixed (section E), the only variable across retries is the compiler, so a previously-red app turning green is unambiguous evidence the gap is closed. Each artifact carries a retryHistory so closed-then-reopened regressions are visible.

D. Triage / issue-filing protocol (the "LLM hook")

The pipeline calls no LLM API and embeds no keys or network egress. Issue #914's "pass the output to an LLM" is realized as a hand-off, not an in-process call, preserving determinism and the repo's secret-handling rules. Each failing stage writes a structured, machine-readable triage artifact (JSON, one file per failure under the run directory). An external agent — GitHub Copilot, a human-in-the-loop, or a separate CI job — consumes these artifacts and files GitHub issues via gh, labeling each with Oats (the issue #914 program label) plus applicable labels such as cil-emit (stage 3 failures), bug, or enhancement.

D.1 Triage artifact JSON schema (v1.0)

{
  "schemaVersion": "1.0",
  "runId": "2026-06-21T20-00-00Z_3f9c1a",
  "timestamp": "2026-06-21T20:04:12Z",
  "corpusAppId": "corpus/03-generics-linq",
  "gscVersion": "0.9.0+build.482",
  "stage": "compile",
  "category": "compile-error",
  "diagnostic": {
    "id": "GS0313",
    "message": "switch expression not exhaustive over sealed type 'Shape'",
    "severity": "error"
  },
  "sourceLocation": {
    "gsFile": "out/03-generics-linq/Shapes.gs",
    "gsLine": 42,
    "gsColumn": 12,
    "csFile": "corpus/03-generics-linq/Shapes.cs",
    "csLine": 51,
    "csColumn": 9
  },
  "offendingCSharpConstruct": {
    "kind": "SwitchExpression",
    "snippet": "shape switch { Circle c => ..., Square s => ... }"
  },
  "suggestedIssue": {
    "title": "[cs2gs] GS0313 on exhaustive switch over imported sealed hierarchy",
    "body": "Translating corpus/03-generics-linq/Shapes.cs ... <reproduction, expected, actual>",
    "labels": ["Oats", "bug"]
  },
  "fingerprint": "sha256:1b9d…e7",
  "retryHistory": [
    { "runId": "2026-06-20T18-00-00Z_a1", "gscVersion": "0.8.9+build.470", "result": "fail" }
  ]
}

Fields:

  • schemaVersion, runId, timestamp, gscVersion, corpusAppId — provenance.
  • stage{translate, compile, ilverify, test-parity}; category{translation-unsupported, compile-error, ilverify-failure, test-parity-failure}.
  • diagnostic — the G# diagnostic id/message/severity (for stages 2–3; for stage 4 the failing test id and expected-vs-actual).
  • sourceLocation — both the emitted-.gs location and the originating C# location (the translator preserves a source map so a gap points back to the C# that triggered it).
  • offendingCSharpConstruct — the C# construct kind plus a minimal snippet.
  • suggestedIssue — pre-rendered title/body/labels the external agent can file as-is or refine.
  • retryHistory — prior {runId, gscVersion, result} records for this fingerprint.

Stages 1–3 implementation notes (what the pipeline emits today). Cs2Gs.Pipeline writes one artifact file per distinct fingerprint per app at <runsRoot>/<runId>/<appId-sanitized>/<stage>-<fingerprintShort>.json, plus a whole-run run.json summary (section F) the report step aggregates. The fidelity points worth recording:

  • diagnostic.id for stage 1. A translation-unsupported failure has no G# diagnostic (no .gs is produced for the construct), so the pipeline emits a stable synthetic id: CS2GS-UNSUPPORTED for an unmapped construct, or CS2GS-ROUNDTRIP for an emitted .gs that fails to re-parse. Stage 2 uses the real GSxxxx.
  • sourceLocation null rules. The translator does not yet carry a per-line C#↔G# position map, so the two ends of sourceLocation are filled by whichever side the failure is anchored to and the other sub-fields are left null (the schema permits this): a stage-1 translation-unsupported fills cs* from the Roslyn node location and leaves gs* null; a stage-2 compile-error fills gs* from the gsc diagnostic and leaves cs* null. Correspondingly, offendingCSharpConstruct for a stage-1 failure is the C# construct (kind = Roslyn syntax-kind, snippet = the C# line), while for a stage-2 failure it is the emitted G# construct gsc flagged (kind classified from the G# line, snippet = the G# line). Wiring a real source map so stage-2/stage-3 artifacts also point back to the originating C# is deferred to a later step.
  • Stage 3 (ilverify) artifacts and labels. IlVerifyStage runs only after a green stage-2 compile (it reads the emitted assembly path the CompileStage publishes on the shared context). It invokes the repo-pinned dotnet-ilverify (.config/dotnet-tools.json, tool dotnet-ilverify 10.0.8, command ilverify) via dotnet tool run ilverify <assembly> -s System.Private.CoreLib -r <eachReference> with the process working directory anchored at the repo root (so the local tool manifest is discovered) and all paths passed absolute; dotnet tool restore is run once, lazily, only when the tool probe fails. The reference set is the host runtime BCL (System.Private.CoreLib.dll, System.*.dll, mscorlib.dll, netstandard.dll from the shared-framework dir) plus the corpus app's ReferencedAssemblies, mirroring test/Compiler.Tests/IlVerifier.cs. A non-zero exit with real verification errors yields category ilverify-failure, one artifact per distinct error-code + failing-method skeleton, with diagnostic.id = the ilverify error code (e.g. StackUnexpected), severity = error, message = the trimmed [IL]: Error […] line, and offendingCSharpConstruct.kind = the failing Type::Method(sig) parsed from the line (documented fallback IlMethod when ilverify reports no method). sourceLocation.gsFile points at the emitted .gs; all positions are null (no source map yet). Labels are Oats + cil-emit (per the §D label list). The fingerprint reuses the §D.2 hash over category|stage|diagnostic.id|construct.kind|normalizedShape.
  • Stage 3 ilverify false-positive bundles (the only suppressions). dotnet-ilverify 10.0.8 has two documented FALSE POSITIVES that are verifier limitations, not emitter bugs — the same minimal pattern emitted by csc fails identically and the JIT accepts the IL. The pipeline passes these as ilverify -g <code> ignore flags (and filters them defensively from parsed output) via the named constant IlVerifyRunner.KnownIlVerifyFalsePositives, so no spurious ilverify-failure gap is filed: (1) ReturnPtrToStack — by-value returns of a user-declared ref struct (track dotnet/runtime#129030); (2) the static-virtual constrained. + call bundle CallAbstract + Constrained — ADR-0089 / issue #755 (track dotnet/runtime#49558). These cite the same upstream issues as IlVerifier.KnownIssues. Any other ilverify error in a green-compiling app is a genuine cil-emit compiler gap and is captured (never suppressed). An abnormal tool exit is instead verification-incomplete: the runner excludes the member named by verbose output and retries to recover findings from the remaining members, while the stage stays failed and emits a separate IlVerifyIncomplete artifact. The whole stage no-ops to PASS when GSHARP_SKIP_ILVERIFY=1 (for environments without the tool), matching IlVerifier.
  • Stage 4 (test-parity) modes, artifacts, and labels. TestParityStage runs only after a green stage-3 (it is appended last in MigrationPipeline.DefaultStages(), so it short-circuits with the rest — L2/L3, which stop at stage 1 today, never reach it). It proves the migrated program behaves identically to the original C# against the §E oracle, selecting one of two modes by the corpus app:
    • Executable apps with a captured stdout golden (e.g. L1) → stdout parity. The stage runs the stage-2/3 emitted assembly (the EmittedAssemblyPath the CompileStage publishes on the shared context) via dotnet <emitted>.dll, captures stdout, and compares it to baseline.stdout.golden with the L1 end-to-end normalization (CRLF→LF, single trailing newline). A divergence — or a non-zero exit — yields category test-parity-failure, one artifact with diagnostic.id = STDOUT-MISMATCH, message = the first differing line (expected-vs-actual), and offendingCSharpConstruct.kind = ProgramStdout. L1 is the first corpus app green end-to-end across all four stages.
    • Library apps with a sibling .Tests oracle (L2/L3) → xUnit pass/fail-set parity. The stage translates the C# .Tests project to a G# xUnit project (@Fact/@Theory/@InlineData/Assert.*), scaffolds an isolated test .gsproj consuming the locally-built Gsharp.NET.Sdk nupkg (copied into the repo .nugs feed and pinned), runs dotnet test producing a TRX, parses it into the {name, outcome} set, and compares it to baseline.tests.json. Any missing, extra, or outcome-mismatch test yields category test-parity-failure, one artifact per differing test, with diagnostic.id = TESTPARITY-<Missing|Extra|OutcomeMismatch>, message = expected-vs-actual outcome, and offendingCSharpConstruct.kind = the failing test name. The TRX parse mirrors corpus/trx-to-baseline.py (only UnitTestResult rows; namespace-agnostic by local name; sorted by name) so the comparison is apples-to-apples. Because C#-xUnit-test → G# translation is part of the not-yet-complete map-advanced step, the stage skips the library path with an explicit, recorded reason (test-parity.log: "test-translation pending map-advanced") when an unsupported construct, a round-trip-parse failure, or a G# build failure is hit — it never fabricates a pass and never emits an artifact except on a real outcome mismatch from a successful run. The live orchestration (GsharpTestProjectRunner) is proven on a minimal translated G# lib+test that builds and dotnet tests green against the local SDK.
    • Both modes label artifacts Oats + bug (per the §D label list) and reuse the §D.2 fingerprint hash over category|stage|diagnostic.id|construct.kind|normalizedShape (one fingerprint per differing test / per stdout-mismatch shape). sourceLocation.gsFile points at the emitted .gs; positions are null (the divergence is observed at runtime, not at a source position).

D.2 Dedup fingerprint

fingerprint = sha256( category + "|" + stage + "|" + diagnostic.id + "|" + offendingCSharpConstruct.kind + "|" + normalizedConstructShape ) where normalizedConstructShape strips identifiers/literals/line numbers down to the syntactic skeleton. The fingerprint deliberately excludes runId, corpusAppId, gscVersion, and concrete source positions, so the same gap hitting multiple corpus apps or recurring across runs collapses to one issue. The external agent keys on fingerprint: an artifact whose fingerprint already maps to an open issue updates that issue's occurrence list instead of filing a duplicate, and a fingerprint whose issue is closed but reappears reopens it.

The normalizedConstructShape normalizer (Cs2Gs.Pipeline.Fingerprint.NormalizeShape) applies, in order: string/char/interpolated literals → lit; numeric literals → lit; every remaining identifier or keyword → id; runs of whitespace collapsed to a single space and trimmed. Punctuation, operators, and brackets are preserved as the structural skeleton — e.g. both foo.Bar("hi", 42, baz) and qux.Zap('x', 7, other) normalize to id.id(lit, lit, id), so the same construct shape dedups across apps and runs regardless of names, numbers, or positions. gscVersion is sourced from the compiler assembly's informational/product version (e.g. 0.2.106+e2206d0c48), falling back to its file version.

E. Corpus and parity oracle

A curated C# corpus of increasing complexity lives under tools/cs2gs/corpus/, one directory per app (e.g. 01-hello, 02-classes-structs, 03-generics-linq, …). Every corpus app green-builds and green-tests in C# first; that captured C# state is the parity oracle:

  • The C# xUnit results (pass/fail set per test) are recorded as the baseline the G# port must reproduce in stage 4. This library oracle is committed as <App>.Tests/baseline.tests.json ({schemaVersion, app, framework, total, passed, failed, skipped, tests:[{name, outcome}]}, captured by corpus/capture-baselines.sh + trx-to-baseline.py); the live library-parity run is exercised once C#-xUnit-test → G# translation (map-advanced) lands, and is gated with a recorded reason until then (§D.1).
  • Where an app has deterministic console output, its stdout is captured as a .golden-style fixture (matching the repo's samples/*.golden convention) and compared after the G# build runs. This stdout oracle is live today: L1-Console passes stage-4 stdout parity (its emitted assembly's stdout matches corpus/L1-Console/baseline.stdout.golden), making it the first corpus app green across all four stages.

Corpus apps are ordered so early failures isolate the simplest possible gap. The oracle is regenerated only when the C# corpus itself changes — never as a side effect of a G# run — so retries (section C) compare against a fixed target.

F. Reporting

Cs2Gs.Report produces, per run, two distributable artifacts:

  1. A single self-contained HTML file (report.html; inlined CSS/JS, no external assets) with a per-app × per-stage status matrix, the discovered-gap list (grouped by fingerprint), and retry history — the human-facing dashboard.
  2. A machine-readable JSON summary (summary.json) aggregating the same data (per-app/per-stage status, gap list keyed by fingerprint, retry history) for CI consumption and trend tracking.

Both are written under the run directory alongside the per-failure triage artifacts of section D.

What the report step does today. Cs2Gs.Report.ReportModel aggregates one completed run from its run.json (section F schema, RunResult) plus every referenced triage artifact (section D.1): it builds the per-app × per-stage matrix in canonical execution order (translatecompileilverifytest-parity, including skipped stages), and the discovered-gap list grouped by fingerprint — each gap collapses every artifact sharing that fingerprint into one entry carrying the representative category/stage/diagnostic/offendingCSharpConstruct/suggestedIssue, the per-app occurrence list (appId + that app's sourceLocation), and the merged, deduped retryHistory. The same gap hitting multiple corpus apps (e.g. the fingerprint shared by L2 and L3 today) therefore renders as one gap with multiple occurrences, mirroring the section D.2 dedup contract. All ordering is deterministic — apps by id, gaps by fingerprint, stages in execution order — so both artifacts are byte-stable and diffable.

  • summary.json (written by JsonSummaryWriter, reusing TriageSerialization.Options so formatting matches the section D artifacts) carries run provenance (runId, timestamp, gscVersion, succeeded, totalApps, greenApps, stageOrder), the per-app rows (appId, succeeded, failureCategory, per-stage {stage,status,artifactCount}, the run-relative artifacts/fingerprints), and the gaps array keyed by fingerprint with occurrences and the merged retryHistory.
  • report.html (written by HtmlReportWriter) is a single file with all CSS and JS inlined and no external asset, CDN, font, or network reference of any kind. It renders the run header (runId/timestamp/gscVersion, overall verdict, green/total app count), the color-coded status matrix (cells are never color-only — each carries PASS/FAIL/SKIP text plus an aria-label), the discovered-gaps section (each gap shows category/stage/diagnostic, the affected apps, the suggestedIssue title + labels incl. Oats, a collapsible issue body, and retry history), and a per-app detail drill-down linking each app's stages and artifacts. Every value interpolated into the document is HTML-encoded through a single HtmlReportWriter.Encode helper (diagnostic messages, C# snippets, and issue titles/bodies originate from source code and must be escaped to prevent broken or injected markup); the minimal collapse/expand JS is dependency-free. Cs2Gs.Report depends only on Cs2Gs.Pipeline (for the RunResult/TriageArtifact models and serializer options) — no Roslyn.

CLI wiring. A cs2gs migrate run generates both artifacts under its external artifacts root automatically at the end (and prints their paths); --diagnostic-run retains the historical run directory. cs2gs report --run <runDir> [--out <file-or-dir>] regenerates both from an existing run.json without re-running the pipeline, so a CI job can re-render a report (e.g. against an updated report template) without a fresh corpus run.

G. Validation outcome (issue #914 capstone)

Frozen as a ledger (ADR-0138). This section remains the historical record of the #914 validation. The living gap ledger is tools/cs2gs/triage/gaps.json (fingerprint↔issue map + CI baseline), and new gaps are auto-filed via cs2gs triage file-issues — do not append new gap rows here.

The tool was validated end-to-end across the full L1–L3 corpus. The deterministic report (report.html / summary.json) is byte-stable and fully self-contained (zero external asset/network references), and every discovered gap deduplicates to a single fingerprinted entry that maps to a filed compiler issue. Final matrix:

App translate compile ilverify test-parity Blocking gap
corpus/L1-Console PASS PASS PASS PASS — (fully green E2E)
corpus/L2-Library PASS PASS PASS PASS — (#973 resolved → fully green E2E)
corpus/L3-Library PASS PASS PASS PASS — (#985 resolved → dual GetEnumerator / IEnumerable[T] now compiles; re-greens on next corpus run)
corpus/L4-Console PASS PASS PASS PASS — (fully green E2E; #975/#976/#977 resolved)
corpus/L5-Console PASS PASS PASS PASS — (fully green E2E; next gap batch #986…#994 surfaced and captured, see below)

Objective 1 (faithful migration) is demonstrated by L1 + L2 + L4 + L5 reaching full parity (L2 went fully green when #973/#974 were fixed; L5 is fully green E2E) and by L3 translating to canonical G#. L4 additionally proves the canonical mappings for exception handling (custom exception + base chaining, typed catch, finally, re-throw), Dictionary/HashSet, using/IDisposable, nullable value types (T?, .HasValue/.Value, ??), and operator overloading (receiver-clause operator funcs) — and, since #975/#976/#977 were fixed, the translator emits the canonical forms for an interpolated : base(...) argument, a struct interface clause, and an inline BCL out var x directly (no workaround). Objective 2 (gap discovery) is demonstrated by structured, reproduced, and filed compiler gaps surfaced purely by migration friction — most of which the compiler team has already fixed, re-greening earlier stages and surfacing the next layer of gaps:

Issue Construct Diagnostic Status
#938 owned-struct instance methods (no warning-free spelling) GS0314 resolved (in-body func binds on value types)
#939 for…in List[userType] element-type erasure GS0158 resolved
#940 static (shared) method overloads ignore arity GS0144 resolved
#941 binary ?? operator unsupported (only ??= existed) GS0005 resolved (ADR-0116)
#942 expr[i].Member mis-parses [i] as type arguments GS0005 resolved
#943 generic-interface constraint [T IComparable[T]] won't parse GS0005 resolved
#944 user-indexer declaration form crashes GS9998 resolved (ADR-0118)
#973 a class with a user value-type (struct/data struct) field — emit ICE GS9998 resolved (L2 now compiles)
#974 generic-interface impl: method returning a constructed generic over T (e.g. IEnumerator[T]) fails satisfaction GS0187 resolved (the generic GetEnumerator now satisfies IEnumerable[T])
#975 interpolated string in a : base(...) constructor-arg position — emit ICE GS9998 resolved (translator emits : base("…$n…") directly)
#976 a struct cannot declare a base / interface clause (struct S : I {…} won't parse) GS0005 resolved (struct interface clause parses; class/struct base → GS0382)
#977 BCL method invoked with an inline out var x declaration fails overload resolution GS0159 resolved (inline out var x binds for BCL calls)
#985 implementing IEnumerable[T] needs two GetEnumerator overloads differing only by return type (generic IEnumerator[T] + non-generic IEnumerator) GS0264 + GS0187 resolved (covariant-return interface bridge: GS0264 relaxed when two same-name/param methods satisfy distinct interface slots; inherited base-interface slots now required so a missing bridge still errors GS0187; emit writes the MethodImpl + non-generic IEnumerable InterfaceImpl rows)
#986 base.Method() virtual base-class call has no canonical G# form (base[Base].M is interface-only per ADR-0091) GS0157 / GS0338 resolved (issue #986 — base.M(...) / base[Base].M(...) emit a non-virtual base-class call; ADR-0091 extended)
#987 an abstract (no-body) method on an open class → emitter crash GS9998 (NRE) resolved (issue #987 — no-body open func F() R; emits a CLR abstract virtual slot; the declaring type becomes TypeAttributes.Abstract; new diagnostics GS0386 not-instantiable / GS0387 missing-override / GS0388 abstract-requires-open)
#988 new T() construction under a new() constraint has no canonical G# form GS0125 / GS0130 / GS0157 resolved (issue #988 — [T init()] declares an init() constraint (renamed from new() by issue #997) and T() constructs the parameter, lowered to a reified Activator.CreateInstance<T>(); new diagnostic GS0389 when constructing without the constraint; GS0152 still guards bad type arguments)
#989 a generic auto-property over T (prop Value T) cannot be member-accessed GS0158 resolved (issue #989 — StructSymbol construction now carries the property table across with the property/indexer type substituted, exactly like fields; the emitter parents the external accessor call at the constructed TypeSpec and routes the auto-accessor backing-field token through the self-TypeSpec MemberRef so read/write round-trips and IL-verifies)
#990 a user reference-type iterator (sequence[UserClass]) → emitter crash GS9998 resolved (user reference- and value-type element iterators emit, ilverify, and run)
#991 a when guard on a switch statement/expression arm won't parse GS0005 resolved (issue #991 — case <pattern> when <bool>: / case <pattern> when <bool> { … } parse a contextual when guard; an arm matches only when the pattern matches AND the guard is true; guarded arms never satisfy exhaustiveness; cs2gs now translates C# when guards to the new form)
#992 and/or binary patterns (> 0 and < 10) won't parse GS0005 resolved (issue #992 — and/or/not pattern combinators with C# precedence (not > and > or) and parentheses; compose with all pattern kinds; binding type patterns under or/not rejected with GS0390; smart-cast narrows under and, soundly under or, never under not; combined patterns never satisfy exhaustiveness; cs2gs now translates C# and/or/not patterns to the new form)
#993 an is/case type pattern with a binder (x is T t) leaves the binder unbound GS0125 open (L5 uses the no-binder form x is T)
#994 yield break has no canonical G# form GS0005 resolved by design (G# has no yield break; the translator jumps to the end of the nearest iterator body — §B.36)
#1002 a user reference-type async iterator (IAsyncEnumerable[UserClass]) → IL erases to IAsyncEnumerable<object> ilverify StackUnexpected resolved (user reference- and value-type element async iterators emit, ilverify, and run; await for s in seq recovers the user element type)
#1006 interface inheritance interface B : A { … } won't parse GS0005 resolved (parser/binder accept an interface base-interface clause; cs2gs now emits interface B : A, C { … } — Oahu.Foundation Types/Interfaces.cs)
#1007 a generic interface method signature func F[T](…) R; won't parse GS0005 resolved (parser/binder accept generic methods in interfaces; cs2gs now emits the [T] clause on interface methods — Oahu.Foundation Diagnostics/Interfaces.cs)

Earlier L3 not going fully green was the intended objective-2 outcome: the residual failure was a real compiler gap (#985), captured and filed rather than worked around or hidden. It closed as the cited compiler issue was fixed and the corpus run re-greens automatically — as already happened for #938–#944 (which advanced L3 from translate FAIL to translate PASS), for #973–#977 (which took L2 fully green and let L4 emit the canonical forms #975/#976/#977 instead of the former workarounds — an interpolated : base(...) argument, a struct interface clause, and an inline BCL out var x), and now for #985 (the covariant-return interface bridge, which takes L3 fully green by letting a generic IEnumerable[T] implementation declare both the generic and non-generic GetEnumerator).

Discovered gaps from L4 (minimal repros)

Each was reproduced directly with gsc and contrasted with a passing control. The orchestrator files the issue and replaces the #TBD-x placeholder.

#975 — interpolated string in a : base(...) argument → was GS9998 ICE; RESOLVED. Translation was already correct (canonical init(params) : base(args) form, §B.28); the emitter previously ICE'd on an interpolated string in base-constructor-argument position. The emitter now lowers it, so the translator emits the interpolated string directly in base position and the result compiles and runs.

// now compiles & runs (was GS9998 EmitDiagnosticException):
class E1 : Exception {
    init(n int32) : base("only $n left") {
    }
}

#976 — a struct may now declare an interface clause → was GS0005; RESOLVED. The parser now accepts a : interface clause after a struct name, so a C# struct S : IEquatable<S> maps to the canonical struct S(…) : IEquatable[S]; the value type's own typed Equals/GetHashCode satisfy the interface. A struct naming a class/struct base (not an interface) is now rejected with the dedicated GS0382 rather than the former generic GS0005.

// now compiles (was GS0005: Unexpected token <ColonToken>):
struct Money(Cents int32) : IEquatable[Money] {
}
func (self Money) Equals(other Money) bool { return self.Cents == other.Cents }
func (self Money) GetHashCode() int32 { return self.Cents }

#977 — BCL method with inline out var x → was GS0159; RESOLVED. A BCL method (e.g. Dictionary.TryGetValue) invoked with an inline out var x declaration now binds, so the translator emits the canonical inline out var x form for BCL and user calls alike. The pre-declared pass-by-address &x form remains canonical for a pre-declared out target (§B.30).

// now compiles (was GS0159: Cannot find function TryGetValue):
let d = Dictionary[string, int32]()
d["a"] = 5
if d.TryGetValue("a", out var v) {
    Console.WriteLine(v)
}

Discovered gaps from the L3 re-baseline and L5 (minimal repros)

Each was reproduced directly with gsc and contrasted with a passing control. The orchestrator files the issue and replaces the #TBD-x placeholder.

#985 — implementing IEnumerable[T] needs two GetEnumerator overloads → GS0264 + GS0187. Resolved (covariant-return interface bridge). A C# generic collection implements IEnumerable<T> by declaring the generic IEnumerator<T> GetEnumerator() and an explicit-interface non-generic IEnumerator IEnumerable.GetEnumerator(). G# has no explicit-interface-impl syntax, so both map to a method named GetEnumerator — but they differ only by return type (IEnumerator[T] vs IEnumerator), which G# used to forbid (GS0264). With only the generic overload, the non-generic IEnumerable.GetEnumerator stayed unimplemented (GS0187). #974 fixed the generic satisfaction; this residual gap blocked L3-Generics. The fix relaxes GS0264 precisely when two same-name / same-parameter methods satisfy two distinct interface slots (the generic IEnumerator[T] GetEnumerator() for IEnumerable[T] and the non-generic IEnumerator GetEnumerator() for the inherited IEnumerable), now requires the inherited base-interface slots so a missing bridge still errors GS0187, and emits the explicit MethodImpl row plus the non-generic System.Collections.IEnumerable InterfaceImpl row — matching the C# metadata shape and verifying clean under ilverify. The for-in/sequence[T] forms remain the canonical, ergonomic G# enumeration surface; a hand-written IEnumerable[T] implementation is now also expressible (e.g. for round-tripping C# collections).

// now compiles — generic + non-generic GetEnumerator both satisfied:
class Repo[T] : IEnumerable[T] {
    private let _items List[T] = List[T]()
    func GetEnumerator() IEnumerator[T] { return _items.GetEnumerator() }
    private func GetEnumerator() IEnumerator { return GetEnumerator() }
}
// control — a generator func yields a sequence[T] (the canonical enumeration form):
func numbers() sequence[int32] {
    yield 1
    yield 2
}
for n in numbers() { Console.WriteLine(n) }

L5's discovered gaps follow. L5 itself reaches full E2E parity (translate → compile → ilverify → test-parity all PASS) by using only the canonical/compiling forms; every construct below that has no canonical form or that ICEs/mis-compiles was held out of L5 and captured here as a verified triage record. The abstract class modifier has no G# spelling (abstract class Shape {…}GS0125 "Variable 'abstract' doesn't exist", the parser not recognising abstract as a modifier); the translator faithfully drops it and emits open class (§B.4, recorded as Info), so an abstract base does not block the compile — its non-instantiability is simply not enforced. The canonical polymorphism spelling requires open on both the base class and each overridable method:

// control — open class + open method + override (dynamic dispatch works):
open class Shape {
    open func Area() float64 { return 0.0 }
}
class Circle(Radius float64) : Shape {
    override func Area() float64 { return 3.14159 * Radius * Radius }
}

#986 — base.Method() virtual base-class call (RESOLVED, issue #986). A C# override that chains to the base implementation via base.Describe() now maps to the canonical G# base-class call base.Describe() (the faithful C# spelling) — and the explicit bracketed base[Shape].Describe() form — which binds to a BoundBaseClassCallExpression and emits ldarg.0 + a non-virtual call instance string Shape::Describe(), running the base implementation without re-dispatching (no infinite recursion). The translator (§B) emits base.M(...) directly for C# base.M(...). ADR-0091 is extended to cover class bases; misuse is diagnosed with GS0383–GS0385. The earlier GS0157/GS0338 failures no longer occur.

// now compiles — base.M() resolves to a non-virtual base-class call:
open class Shape {
    open func Describe() string { return "shape" }
}
class Circle() : Shape {
    override func Describe() string { return base.Describe() + " circle" }
}
// also valid — explicit bracketed base-class selector:
class Circle() : Shape {
    override func Describe() string { return base[Shape].Describe() + " circle" }
}

#987 — an abstract (no-body) method on an open class → abstract member (RESOLVED). The translator lowers a C# abstract method to a no-body open func F() T;. The parser accepts it, and (since issue #987) the binder and emitter now produce a CLR abstract virtual method slot (MethodAttributes.Abstract | Virtual | NewSlot, no IL body); the declaring class is emitted with TypeAttributes.Abstract. Constructing the abstract type is a clean compile error (GS0386), a concrete subclass that does not override every inherited abstract member errors (GS0387), and an abstract method that is not an open member of an open class errors (GS0388). A derived override func makes the type constructible and dispatches virtually. Classification: resolved. The earlier L5 workaround (a virtual method with a default body) is no longer required.

// now compiles — a no-body open func is an abstract member; Shape is an abstract type:
open class Shape {
    open func Area() float64;
}
// control — same method WITH a body still compiles:
open class Shape {
    open func Area() float64 { return 0.0 }
}

#988 — new T() construction under a new() constraint. RESOLVED (issue #988). A C# generic that constructs its type parameter (where T : new(), new T()) now has a canonical G# spelling: declare the constraint with [T init()] (renamed from new() by issue #997) and construct the parameter with the call-like form T(). The construction lowers to a reified System.Activator.CreateInstance<T>() (the standard C# new() lowering, ADR-0087), which produces a real instance for both reference types with a public parameterless constructor and value types. Constructing a type parameter without the init() constraint is a clean compile error (GS0389); a type argument that cannot satisfy init() is still rejected at the instantiation site (GS0152).

// now compiles, ilverifies, and runs:
class Factory[T init()] {
    func Make() T { return T() }     // T() constructs T
}
func make[T init()]() T { return T() } // also on generic functions
// control — the caller supplies the instance (still compiles):
class Box[T class](Value T) { }

#989 — a generic auto-property over T (prop Value T) can now be member-accessed (was GS0158). Declaring prop Value T on a generic class and then reading box.Value previously produced GS0158 "Cannot find member Value" because StructSymbol construction substituted only the field table, never the property table. Resolved: construction now carries properties (and indexer parameter/element types) across with T substituted — the same path generic fields already used — and the emitter parents the external accessor call at the constructed TypeSpec while the auto-accessor body addresses its backing field through the self-TypeSpec MemberRef. Read and write round-trip and IL-verify; L5 may now use a generic auto-property directly.

// now resolves — member access of a generic auto-property:
class Box[T class] {
    prop Value T { get; set; }
}
// ... box.Value  -> binds as the substituted type argument
// control — a generic field resolves (unchanged):
class Box[T class](Value T) { }   // ... box.Value works

#990 — a user reference-type iterator (sequence[UserClass]) → emitter crash (GS9998). RESOLVED. A yield-generator whose element type is a user reference type (func F() sequence[Shape]) previously emitted GS9998 "Conversion from '<T>' to 'object' is not yet supported by the emitter". The root cause was twofold: (1) the synthesized non-generic IEnumerator.Current converts the strongly-typed <>2__current field to object, but IsReferenceCompatible did not recognise a user class (null ClrType during emit) as widening to object; (2) the SM class's IEnumerable<T>/IEnumerator<T> interface rows and GetEnumerator signature erased the user element type to object (elementType.ClrType ?? typeof(object)), producing generic-invariance-invalid IL. Both are now fixed; user reference- and value-type element iterators compile, ilverify, and run. L5 may now yield the user type directly.

// now compiles, ilverifies, and runs:
open class Shape { func Tag() string { return "shape" } }
func shapes() sequence[Shape] { yield Shape() }
// control — a BCL element type compiles:
func names() sequence[string] { yield "a" }

#1002 — a user reference-type async iterator (IAsyncEnumerable[UserClass]) → IL erases to IAsyncEnumerable<object>. RESOLVED. Parallel to #990 but for the async-iterator path. The synthesized state machine's IAsyncEnumerable<T> / IAsyncEnumerator<T> interface rows and the GetAsyncEnumerator return signature erased the user element type to object (elementType.ClrType ?? typeof(object)), making the kickoff's IAsyncEnumerable<Shape> return type generic-invariance-invalid (ilverify StackUnexpected). The consumer side (await for s in seq) suffered a parallel erasure: the lowering read Current off the closed IAsyncEnumerator<object> shape, yielding an object assigned into a strongly-typed Shape loop slot. Both are now fixed by routing user element types through the symbolic TypeSpec encoder (interface rows + GetAsyncEnumerator signature) and through a symbolic IAsyncEnumerator[ElementSym] enumerator type in the await for lowering, mirroring the synchronous symbolic-open path. User reference- and value-type element async iterators now compile, ilverify, and run.

// now compiles, ilverifies, and runs:
open class Shape { func Tag() string { return "shape" } }
async func shapes() IAsyncEnumerable[Shape] {
    yield Shape()
    await Task.Delay(1)
    yield Shape()
}
async func Run() {
    await for s in shapes() { Console.WriteLine(s.Tag()) }
}
Run().Wait()
// control — a BCL element type compiles:
async func nums() IAsyncEnumerable[int32] { yield 1 }

#991 — a when guard (resolved). A when guard on a switch statement or expression arm (case > 0 when cond:) is now supported end-to-end in G# (issue #991): the contextual when keyword introduces a boolean guard, an arm matches only when its pattern matches AND the guard is true, and guarded arms never satisfy exhaustiveness. cs2gs translates C# when guards directly to the new G# form.

// now compiles — when guard on a relational pattern:
let r = switch n {
    case > 0 when n < 10: "small"
    default: "other"
}
// control — relational patterns without a guard parse:
let r = switch n {
    case > 0: "positive"
    default: "other"
}

#992 — and/or/not pattern combinators (resolved). Combined patterns (case > 0 and < 10:, case < 0 or > 100:, case not > 0:) are now supported end-to-end in G# (issue #992): and/or/not are contextual keywords with C# precedence (not > and > or) and parentheses override grouping. They compose with every pattern kind. A binding type pattern under or/not is rejected with GS0390 (use _); smart-cast narrowing applies under and, soundly under or (only when both branches agree), and never under not; combined patterns never satisfy exhaustiveness. cs2gs now translates C# BinaryPatternSyntax (and/or) and UnaryPatternSyntax (not) to the canonical G# combinator forms.

// now compiles — combined relational patterns:
let r = switch n { case > 0 and < 10: "x" case < 0 or > 100: "y" default: "z" }

#993 — an is/case type pattern WITH a binder leaves the binder unbound (GS0125). if x is Shape s (a type pattern that binds s) → GS0125 "Variable 's' doesn't exist" when s is used. Classification: compile-error. The no-binder form if x is Shape works, so L5 tests the type without binding.

// repro — GS0125: the pattern binder is not in scope:
if shape is Circle c { Console.WriteLine(c.Area().ToString()) }
// control — the no-binder type test works:
if shape is Circle { Console.WriteLine("a circle") }

#994 — yield break has no canonical G# form (GS0005). A yield break; in a generator → GS0005. Classification: translation-unsupported. L5's iterator runs to natural completion.

// repro — GS0005 on yield break:
func f() sequence[int32] { yield 1; yield break }
// control — an iterator that simply runs to completion:
func f() sequence[int32] { yield 1; yield 2 }

Two translator faithfulness fixes the L5 pipeline surfaced (no compiler change): an integer literal that C# implicitly promotes to a floating-point parameter is now emitted as a float literal (M(30) where the parameter is doubleM(30.0)) so the emitter does not push an int32 where a float64 is expected — without this the assembly compiled but failed ilverify with StackUnexpected (found Int32, expected Double) (§B.12); and a parameterless constructor that initializes a property to a constant keeps its explicit init() body, because G# has a field member initializer (var Name T = expr) but no property member initializer (prop Name T = exprGS0288/GS0113) (§B.3).

Discovered gaps from the Oahu.Foundation round (minimal repros)

Translating the external Oahu.Foundation project drove its translation-unsupported count from 105 (25 construct kinds) to 9 diagnostics across 3 files, and a follow-up round (this PR, leveraging the now-merged compiler fixes #1006 and #1007) drove that to 0Oahu.Foundation now reaches translate = passed (every file round-trips, 0 unsupported). The three residual gaps were each reproduced directly with gsc (version 0.2.132+417bdb25ec) and contrasted with a passing control; the first two were genuine G# compiler/language gaps that the compiler team has since fixed (#1006 interface inheritance, #1007 generic interface methods), and the translator now emits the canonical G# form for all three (the pointer surface is the excepted unsafe-interop surface — see OF-3).

OF-1 — interface inheritance interface B : A { … } (RESOLVED, #1006). A C# interface that extends another interface originally had no canonical G# spelling: the parser's ParseInterfaceDeclarationNew had no base-clause handling and went straight to MatchToken(OpenBraceToken). Issue #1006 added an interface base-interface clause to the parser/binder, so interface B : A, C { … } is now legal G#. The translator now emits the base list through the same base-clause path as a class (no Unsupported diagnostic). Source: Types/Interfaces.cs → emits interface IBookMeta : IAudioQuality { … }.

// now compiles (#1006 merged):
package T
interface A { func F() int32; }
interface B : A { func G() int32; }

OF-2 — a generic interface method signature func IsPrim[T]() bool; (RESOLVED, #1007). A type-parameterized method signature inside an interface originally had no G# spelling: the parser rejected the [T] type-parameter list on an interface method. Issue #1007 added generic methods in interfaces, so the bodyless form func F[T](…) R; is now legal G#. The translator now retains the type-parameter list on interface methods (no Unsupported diagnostic). Source: Diagnostics/Interfaces.cs → emits func IsPrimitiveType[T]() bool;.

// now compiles (#1007 merged):
package T
interface A { func Echo[T](x T) T; }

OF-3 — unsafe pointer types (void* / byte* / int*) → canonical prefix *T (excepted unsafe-interop surface). Win32/Win32FileIO.cs is an unsafe class using raw pointer types throughout. G# has a byref/pointer prefix form *T (spec §"Byref/pointer syntax exists as *T"; grammar '*' TypeClause '?'?), so a C# postfix T* translates to the canonical G# prefix *Tbyte**uint8, int**int32, and void* (no managed pointee) → *uint8 (a raw byte pointer). The emitted form round-trips through the parser, so the translate stage passes with no Unsupported diagnostic. This is the excepted unsafe-interop surface: the G# binder (not the parser) steers callers to ref/out/in (GS0243) and rejects pointer fields (GS9006), so the compile stage still fails on Win32FileIO.gs — this is expected and accepted (managed G# has no unsafe pointer-deref/field semantics). A C# function pointer still has no canonical managed form and remains a PointerType Unsupported diagnostic. Source: Win32/Win32FileIO.cs.

// repro (C#) — postfix T* maps to prefix *T (parses; binder flags GS0243):
public unsafe bool ReadFile(byte* buffer, int* bytesRead, void* pBuf);

Two translator faithfulness additions the Oahu.Foundation round surfaced (no compiler change): a suffix-less integer literal whose bound type is wider/unsigned than int32 is emitted with the matching UL/L/U suffix so the round-trip parser infers the same width (§B.35); and every emitted identifier is sanitized against the G# reserved-word set with the C# verbatim @ prefix stripped (§B.35), so corpus identifiers such as @default, @In, type, and func round-trip.

Discovered gaps from the Oahu.Decrypt round (minimal repros)

Translating the external Oahu.Decrypt project drove its translation-unsupported count from 109 (19 construct kinds) to 0: the app now reaches translate = PASS (every emitted .gs round-trips; zero Unsupported diagnostics). The conversion-operator (#1017), stackalloc (#1024), fixed (#1026), and inc/dec-as-expression (#1027) features are now translated to faithful canonical G# (§B), and the C# unsafe modifiers they require are mapped to G#. Two further pre-existing translator round-trip residuals surfaced once the former unsafe-interop diagnostics stopped masking them (a data struct with an explicit constructor needing a primary-constructor lift, and a tuple-deconstruction assignment (a, b) = (x, y)) and were also fixed (§B). The remaining OD-1/OD-3 notes below record genuine gsc gaps the translator works around; the compile stage may still fail on the OF-3 managed pointer surface, which is expected.

OD-1 — range operator a[i..j] has no canonical G# form (gsc gap). G# has no range/.. operator; an a[i..j] index does not parse. The translator therefore lowers every corpus range index over a Span/Memory to .Slice(start, length) (§B.36), so all 7 corpus range uses translate. Filed as a gap for a first-class range surface.

// repro (C#) — no G# `..` range operator; lowered to .Slice(...) by the translator:
System.Span<byte> Middle(System.Span<byte> s) => s[1..3];
// gsc 0.2.137+31ced6cfb7 rejects a literal `..` range index:
package p
class C { func F(s Span[uint8]) Span[uint8] { return s[1..3] } }
// error GS0005: Unexpected token, the `..` range form is not in the grammar.

OD-2 — user-defined conversion operator implicit/explicit operator T (RESOLVED, #1017). G# now accepts a user-defined conversion member func operator implicit(x U) T / func operator explicit(x U) T. The translator emits the canonical in-body form (§B): public static implicit operator TrackNumber((int, int) tn)func operator implicit(tn (int32, int32)) TrackNumber. Source: Mpeg4/IAppleData.cs.

// gsc accepts the in-body conversion member:
class TrackNumber {
    func operator implicit(tn (int32, int32)) TrackNumber { return TrackNumber() }
}

OD-3 — static-abstract property in an interface has no canonical G# form (gsc gap, ADR-0089). An interface shared { … } block accepts only func members; static interface state (a prop/field) is not supported in this release (ADR-0089). C# public static abstract int SizeInBytes { get; } therefore has no G# spelling. Source: Mpeg4/IAppleData.cs.

// gsc 0.2.137+31ced6cfb7:
package p
interface IData { shared { prop SizeInBytes int32 { get } } func Write() }
// error GS0330: Only 'func' members are allowed inside the 'shared' block of an interface;
//               interface static state is not supported in this release (ADR-0089).

Unsafe-interop surface now translated to faithful forms (issues #1024/#1026/ #1027). What were formerly four excepted residual diagnostics now emit faithful canonical G# and pass the translate gate: stackalloc T[n] (StackAllocArrayCreationExpression, Mpeg4/APICFrame.cs and Mpeg4/Stz2Box.cs) → stackalloc [n]gT (#1024/#1057); fixed (byte* p = …) { … } (FixedStatement, Cryptography/AesCtr.cs) → the paren-less fixed p *uint8 = … { … } inside an unsafe { } body (#1026); and the i-- post-decrement used inside the short-circuit condition of the unsafe class AesCtr do-while (PostDecrementExpression, Cryptography/AesCtr.cs) → the inline value-producing i-- expression (#1027). The C# unsafe modifiers required for these forms are mapped to G# (unsafe class, unsafe { } method body). These now round-trip; AesCtr.gs may still fail the compile stage on the same managed pointer-deref/field surface documented as OF-3, which is expected and accepted.

Two downstream CompilationUnit round-trip failures investigated. Two files failed the round-trip parse gate for reasons unrelated to the 19 target kinds: Mpeg4/ID3/EmptyFrame.cs emitted a string literal containing raw NUL bytes (C# "\0\0\0") → GS0003: Unterminated string literalfixed by the control-character \uXXXX re-escaping (§B.36); and Chunks/ChunkReader.cs emits a C-style for loop whose increment ends in an indexer immediately followed by the body brace (start += chunk.FrameSizes[f] {), which gsc parses as a composite literal (expr[i] { … }) → GS0005: Unexpected token <IfKeyword>, expected <CloseBraceToken>. The latter is a pre-existing translator limitation (multi-declarator/multi-incrementor C-style for loops are not fully rendered) compounded by a gsc parse ambiguity; it is not one of the 19 target kinds and is left for a follow-up (lower such for loops to while, or disambiguate the expr[i] { parse).

// gsc 0.2.137+31ced6cfb7 — `expr[i] {` parses as a composite literal:
package p
class C { func F(arr []int32) { for var s = 0; s < arr.Length; s += arr[s] { var x = 1 } } }
// error GS0005: Unexpected token <VarKeyword>, expected <CloseBraceToken>.

Consequences

Positive

  • Determinism and repeatability. No LLM in the loop, a fixed parity oracle, and a deterministic pretty-printer mean a run is reproducible; the only intended variable across retries is gsc.
  • Canonical output by construction. Section B is an enforceable contract; round-trip parsing (section A) guarantees emitted G# is at least syntactically real before a compile is attempted.
  • Gap discovery is structured, deduped, and actionable. Every failure becomes a fingerprinted artifact with a C#↔G# source map and a ready-to-file issue, so the compiler backlog is driven by real migration friction.
  • ADR-0027 boundary preserved. Roslyn stays out of gsc; the dependency is quarantined in Cs2Gs.Translator.

Negative

  • Roslyn dependency in the toolset. Cs2Gs.Translator carries Microsoft.CodeAnalysis.CSharp (and its transitive MSBuild/crypto pins already present in the scaffold). This is a tool-only cost, not a compiler cost, but it is real maintenance surface.
  • Corpus curation is ongoing work. The oracle's value scales with corpus breadth; building and maintaining green C# apps is a continuing investment.
  • "Canonical" must track the language. Every new G# surface ADR may add or change a section-B rule; the pretty-printer is a living contract, not a one-time write.
  • Constructs without a canonical form are deferred, not translated. inline struct newtype promotion, some using static shapes, and any not-yet-mapped construct are triaged rather than guessed — correct, but it means some apps will not migrate until the language or the tool grows. (C# member protected is now translated directly to G# protected since issue #950; the blended protected internal / private protected forms remain triaged to internal.)

Neutral

  • The four-project decomposition (CodeModel/Translator/Pipeline/Report, plus Cli/Tests) matches the existing scaffold; this ADR fixes responsibilities, not the project layout.
  • The triage schema is versioned (schemaVersion), so it can evolve without breaking older artifacts.

Alternatives considered

Hand-rolled C# parser + AST mapper. Rejected — re-implements Roslyn's lexer, parser, and (critically) semantic model; cannot cheaply answer the immutability/type-inference questions section B depends on; perpetually chases C# language evolution.

Roslyn analyzer that builds the compiler's own G# AST. Rejected — couples the tool to gsc's parse-oriented syntax tree and erodes the ADR-0027 boundary by giving the compiler's AST a Roslyn construction path. A dedicated, output-only emit AST (Cs2Gs.CodeModel) is cleaner and keeps "decide the shape" separate from "render the text."

Reuse the compiler's SyntaxTree as the emit AST. Rejected — an input contract (faithful round-trip, trivia, span invariants, SemanticModel backing) is the wrong shape for an output contract (normalize, drop, re-shape, render deterministically). Conflating them makes both jobs harder.

Call an LLM API directly from the pipeline. Rejected — embeds keys and network egress, introduces non-determinism, and breaks the repeatability requirement. The structured triage artifact + external gh-filing agent achieves the same outcome (issues get filed) while keeping the pipeline deterministic and secret-free.

Skip round-trip parse validation and let gsc be the first reader of generated G#. Rejected — it conflates translator defects (malformed G#) with compiler gaps (valid G# the compiler can't yet handle), polluting the gap signal. Re-parsing with the real G# parser before stage 2 cleanly separates the two.