- 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.
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:
- Transform existing C# applications into G# projects — producing canonical G#, not a literal transliteration.
- 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 + SemanticModel → Cs2Gs.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. |
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.
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,
vartype inference, definite-assignment, nullable flow — to know, e.g., whether a C#varlocal 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
SyntaxTreeis an input contract (it must round-trip source text faithfully, preserve trivia, and back aSemanticModel); 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-onlyCs2Gs.CodeModelkeeps 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.
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.
- One C# file → one
.gsfile. The first non-comment line ispackage <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,importblock directly underpackage.Systemis implicitly imported by the compiler, but the printer still emits an explicitimport Systemwhen the C# file used it, matchingsamples/*.gs(e.g.Class.gs), so the file is legible and/noimplicitimports-safe.global usingandusing staticmap toimportwhere a direct equivalent exists;using staticwith no G# equivalent for member-hoisting is triaged.
- 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.
| 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.
| 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 class → GS0125), 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 aList[T]whose elementTis a user type owned by the same compilation (adata struct/classdeclared 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 viaTypeSymbol.FromClrType(StatementBinder.cs~L2852,case ImportedTypeSymbol), which cannot resolve a same-compilation user type, soitem.Memberhas no type. Arrays ([]T) and lists of BCL/primitive elements (List[int32],List[string]) bind correctly; onlyList[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.NameThis is why T1 maps tuples to native positional tuples rather than a synthesized
data struct—for item in List[(string, int32, int32)]binds anditem.Item2resolves. 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, notList[T]instantiation in general.
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-
structmethods — RESOLVED (issue #938). ADR-0079 frames the in-body canonical form as applying to ownedclassandstructreceivers. As of issue #938 the compiler honours that for value types: afuncmember inside astruct/data structbody now binds as an instance method on the value type (synthesized by-refthis), reusing the receiver-clause owned-struct lowering and emission (Parser.csaccepts the member; theDeclarationBinderin-body path is no longer gated onsyntax.IsClass). The in-body form is therefore the canonical, warning-free spelling for an owned-structinstance method, exactly as for classes; the receiver-clause formfunc (r T) M(...) Rstill binds identically but is flagged withGS0314(DeclarationBinder.cs:3129) to steer authors to the in-body site. User-definedinit(...)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-structmethods 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.
- C# classes are sealed-by-default in G#; a base class that is subclassed must be emitted
open class, and the overriding member must carryoverride(ADR-0017). The translator uses Roslyn'sINamedTypeSymbol.IsSealed/IsAbstract/inheritance graph to decide: a class that any other corpus type derives from →open; a C#abstract/virtualmember that is overridden →open/overrideon the pair. C#sealed class→ plainclass(already the default) orsealed classwhen it participates in a closed hierarchy switched on exhaustively (ADR-0078). - Member
open/overrideopenness (Oahu.Decrypt round). G# only treats a member as overridable when it is explicitlyopen, and unlike C# this does not extend automatically to framework/metadata virtuals or to existingoverrides. 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) asopen, so emittingoverridefor 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 plainfunc/prop— which still binds as the override — instead ofoverride. - A non-sealed override is re-emitted
open. A C#overridemember is itself overridable unlesssealed, so a further subclass can override it again; G# requires the base member to beopenfor that (GS0184). A kept (non-external, non-sealed) override is therefore emitted asoverride open. - Member
openis gated on class openness. G# rejectsopenon a member whose enclosing class is not itselfopen(GS0190). The translator only marks a memberopenwhen the containing type will be emittedopen class(IsTypeEmittedOpenmirrors the class-declaration openness logic: abstract, has aprotectedmember, or subclassed-and-not-sealed), so an override in a leaf/sealed class stays a plainoverride func.
- External-base overrides drop
- 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 propertyprop X T { get; init; }(a bare{ get; }is read-only and the keptinitassignment would beGS0127). 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 { … }(specBaseClause = ":" QualifiedTypeName … { "," QualifiedTypeName };samples/Class.gs). Constructor chaining renders as: Base(args)on the base clause orinit(...) : Base(args) { … }(ADR-0065,samples/ExplicitConstructor.gs).
- 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-referentialIComparable[T]) plus repeatable flag constraintsclass,struct,init()(ADR-0097; the default-constructor constraint was renamed fromnew()toinit()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). Variancein/outis carried on type parameters of interfaces/delegates (ADR-0021). where T : notnullhas no precise G# constraint keyword; the translator drops it (records an Info diagnostic).comparable/anywould 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 onT) and the emitter (aconstrained. !!T callvirtplus aGenericParamConstraintmetadata row), so the constructed-generic constraint now parses, binds, emits verifiable IL, and is enforced (GS0152). The translator therefore emits the constraint directly —where 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 }
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).
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$identonly whenidentis a simple identifier; complex holes always use${…}. - Format/alignment specifiers inside holes are preserved (ADR-0055 rich holes).
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.
- Fields require
var/let(ADR-0067, §B.3). - Properties →
prop Name Tfor auto-properties, with{ get { … } set(v) { … } }bodies for computed/custom accessors (ADR-0051,samples/PropertyRef/Lib/Lib.gs).open prop/override propmirror method virtuality. A C#initaccessor maps to the first-class G#initaccessor (issue #946); an init-only auto-property{ get; init; }keeps its explicit accessors (it is not collapsed to the read-writeprop Name Tauto form, which would lose the init-only semantics). (Superseded note: earlier revisions mapped C#initto G#setwith an Info gap diagnostic because G# had noinitaccessor; that gap is now closed.) - Constructors →
init(params) { … }, chaining via: Base(args)(ADR-0065). C# primary constructors / positional records map to the G# primary-constructorName(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-entryshared { }block are emitted qualified (Geometry.Round(...)), since an unqualified sibling static call does not resolve there (GS0130). - Static constructor body (
static Type() { … }) → ashared { init { … } }static-initializer block (ADR-0140, issue #2131). The block's statements run in the type's.cctorafter the static-field initializers, and the type dropsbeforefieldinit— 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 whoseshared { }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 2Root cause was:
ExpressionBinder.Access.csBindUserTypeStaticCallusedStructSymbol.TryGetStaticMethod(name, out method)(single by-name) rather than building a static method group + runningOverloadResolver(as the instance path does). Fixed in #940; static overloads now resolve by arity, and the L2 corpus app (overloadedGeometry.TotalArea/Round) translates and compiles through this construct.
- Enums →
enum 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. foreach→for 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).
Canonical output uses width-bearing primitive names (ADR-0049): C# int→int32, uint→uint32, long→int64, ulong→uint64, short→int16, ushort→uint16, byte→uint8, sbyte→int8, float→float32, double→float64, bool→bool, string→string, char→char, object→object. 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.
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.
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.
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.)
A C# explicit cast (T)expr maps to the G# width-bearing conversion call T(expr) (e.g. (int)x → int32(x), (byte)n → uint8(n)). This is behaviour-faithful for numeric narrowing: the CLR truncates a float64→int32 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).
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).
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.
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).
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.
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 0 → case 0:; relational < 10.0 → case < 10.0:; type Circle c → case 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 (r → circle.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).
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: string → String, int → Int32, etc. (String.Concat(parts)). The lowercase keyword would not resolve as a static-call target in G#.
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 : b → let x = if cond { a } else { b }.
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 directly — init(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.)
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.
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.)
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).
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).
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).
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
int→double/floatconversion when an integer literal is passed where a floating-point value is expected (e.g.M(30)where the parameter isdouble). As of issue #1281 gsc applies the same implicit widening at a concrete numeric parameter, so a bareint32literal now compiles and verifies; the translator nonetheless emits the float literal spelling (30→30.0) as the canonical converted-type form — it consults the boundConvertedTypeso the emitted value is self-describing and matches its target type. (Historically a bareint32literal produced invalid IL —ilverifyStackUnexpected, 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 = exprconstant 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 = expr→GS0288/GS0113), so when the assigned member is a property, the explicitinit()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 memberprop 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 anIndexerDeclarationunsupported record.Null-coalescing operator
??— RESOLVED (#941).value ?? fallbacknow 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 emitsvalue ?? fallbackverbatim. No longer surfaced as atranslation-unsupportedrecord.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 emitsvalues[i].Memberthrough the normal member-access path with no placeholder.
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#ConditionalAccessExpressionmaps to the corresponding G# null-conditional chain:a?.b,a?.b(), anda?[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 nox is T tpattern binder (that is compiler gap #993). The translator therefore maps:x is null→x == nil;x is not null→x != nil; a bare type testx is T→ the booleanx is T; and a binding type patternif (x is T t) { …t… }→if x is T { …x… }, relying on G#'s in-block smart-cast ofxtoT(every use of the bindertis rewritten to the smart-cast subjectx). Predefined types keep their value-keyword spelling (x is string, notx 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 anew List<T>{ a, b }collection-initializer all map to the canonical G# composite/array literal forms.T[]as anArrayTypemaps 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 generictypeof(IEnumerable<>)drops the empty type-argument list →typeof(IEnumerable)(G# has no open-generictypeof).default/default(T)→default. Both theDefaultLiteralExpression(default) and theDefaultExpression(default(T)) map to G#'sdefaultzero-value expression.sizeof(T)→sizeof(T). Maps directly for the blittable primitive set.- Assignment as an expression / chained
a = b = c. ASimpleAssignmentExpressionused in expression position (inside another expression, or right-associatively chaineda = b = c) emits the G# assignment expression rather than only the statement form. out var xdeclaration expression → inlineout var x. ADeclarationExpressionin argument position emits the inlineout var xform (§B.30), with the binder name sanitized (below).: this(args)constructor delegation →init(params) : this(args). AThisConstructorInitializermaps to the G#this-chained initializer, mirroring the: base(args)mapping of §B.28.- Local function → nested
func. ALocalFunctionStatementmaps to a G# localfuncdeclaration in the enclosing body. break/continue→break/continue. Both map to their identical G# loop-control keywords (these had no prior translator case).do … while (c)→ G# do-while. ADoStatementmaps to the canonical G# do-while loop.lock (m) { … }→ G# lock. ALockStatementmaps to the canonical G#lockform over the monitor expression.unchecked { … }→ block body. AnUncheckedStatement(G# arithmetic is unchecked by default) flattens to its inner statements.~T()destructor →deinit. ADestructorDeclarationmaps to the G# finalizer/deinitmember.namespace N { … }→ G#package. A file-scoped or blockNamespaceDeclarationmaps to the G#packagedeclaration / membership; theCompilationUnit-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_(type→type_,func→func_). Applied at parameters, receivers, identifier references,foreach/for-invariables, local declarations, field/property/method/enum-case names, member-access member names, conditional-access bindings, andout varbinders. (Oahu.Foundation has a@defaultfield, an@Inenum member, andtype/funcidentifier sites.)Width-bearing integer literals (
NormalizeIntegerLiteralText). A suffix-less C# integer literal whose bound type is wider/unsigned thanint32is emitted with the matching G# suffix (UL/L/U) so the round-trip parser infers the same width — without this auint64constant such as the CRC seed0xD800000000000000parses as an overflowingint32/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 barecatch { },catch e { }, orcatch Exception { }all fail to parse. The translator synthesizescatch (ex Exception) { }for a C# bare catch-all (extending §B.27).
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# 12CollectionExpressiontargeting 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#throwis statement-only; a C#ThrowExpressionused in a value position is lowered to the uniform formif 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 typeTcomes from the converted type of the throw-expression. A bare arrow body=> throw emaps directly to a G#throwstatement. is-pattern combinators (constant / relational / not / and / or / recursive) → boolean lowering. G#isin expression position supports onlyexpr is Type; all other patterns are switch-only. So anis-pattern is lowered to a boolean:x is 0→x == 0;x is 1 or 2→x == 1 || x == 2;x is not T→!(x is T);x is >= 0 and < n→x >= 0 && x < n(C# precedencenot>and>orpreserved, parenthesized where needed); a recursive/property patternx 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 nativecasepattern forms.- Range index
a[i..j]/a[i..]/a[..j]→.Slice(start, length). G# has no range operator (gsc gap, §G OD-1); aRangeExpressionindex over aSpan/Memory/ReadOnlySpanlowers to a.Slicecall:s[i..j]→s.Slice(i, j - i),s[i..]→s.Slice(i),s[..j]→s.Slice(0, j). - Null-forgiving
expr!→ non-null assertionexpr!!. G#'s postfix!!asserts non-null (spec: "Postfix!!asserts non-null"), the direct analogue of the C# null-forgiving operator, so theSuppressNullableWarningExpressionoperand 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). APostIncrementExpression/PostDecrementExpressionused 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 trailingi++/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 anunsafedo-while (while data[i] == 0 && i-- > 0) — the faithful inlinei--/i++/--i/++iexpression 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 memberfunc operator implicit(x U) T(andexplicit→func operator explicit(x U) T); the single source parameter is preserved and the operator target becomes the return type.public staticis 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 (byte→uint8), and the grammar is G#-style (count first, then element type), sostackalloc 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 yieldsSpan<T>; as an unsafe pointer target it yieldsT*.fixed (T* p = src) { … }→fixed p *gT = src { … }(issue #1026). The paren-less G#fixedpins a managed array/string and is only legal inside anunsafecontext; multiple declarators emit nestedfixedblocks. The requiredunsafecontext is supplied by theunsafe-modifier mapping below.unsafemodifier mapping (issue #1026 / ADR-0122). A C#unsafe class C→ G#unsafe class C(theunsafemodifier precedes the visibility on a type declaration). A C#unsafemethod maps by wrapping its body in anunsafe { }block (the parser does not combine a visibility modifier withunsafe func). A C#unsafe { }block maps to a G#unsafe { }block. This mapping is required forfixed, pointer-targetstackalloc, 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 noyield 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. AForEachVariableStatementmaps 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>. AnEventFieldDeclarationmaps 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{ … }. AUtf8StringLiteralExpressionmaps to a G# byte-slice literal of the UTF-8 bytes ("AB"u8→[]uint8{ 0x41, 0x42 }). - Control characters in string literals →
\uXXXXescapes. The printer now re-escapes any character belowU+0020(andU+007F) — e.g. C#"\0\0\0"— as a\uXXXXescape so the emitted G# string stays terminated and round-trips (previously a raw NUL producedGS0003: 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)) byCoerceConstantToUnsigned. Call andbase(...)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 isuint8emits the bare literal4rather thanuint8(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. aconstfield) that gsc does not fold. new object()/ target-typednew()toobject→System.Object()(OD-T3). A construction ofSystem.Objecthas no bareobject()spelling (GS0130, "functionobjectdoesn't exist"); the translator emits the fully-qualifiedSystem.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 anobj/orbin/directory, plus generated files (*.AssemblyInfo.cs,*.AssemblyAttributes.cs,*.GlobalUsings.g.cs,*.Version.cs,*.g.cs,*.g.i.cs) — e.g. the Nerdbank.GitVersioningThisAssemblyfile. - Record property preservation (OD-T5, issue #2704). Body auto-properties on
a C#
record/record structremain 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.OrderForCompilationtopologically sorts the emitted.gsfiles so each type's base classes and interfaces are passed togscbefore it (Kahn's algorithm, stable on original order; a file declares its dependency throughEmittedGsFile.BaseClassNames, which now includes implemented interfaces as well as the base class). This works around agscorder-sensitivity in: base(...)/interface-satisfaction binding (§G). Ordering affects only the compiler command line, never file content, so golden.gsoutputs 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.
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.
{
"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-.gslocation 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.idfor stage 1. Atranslation-unsupportedfailure has no G# diagnostic (no.gsis produced for the construct), so the pipeline emits a stable synthetic id:CS2GS-UNSUPPORTEDfor an unmapped construct, orCS2GS-ROUNDTRIPfor an emitted.gsthat fails to re-parse. Stage 2 uses the realGSxxxx.sourceLocationnull rules. The translator does not yet carry a per-line C#↔G# position map, so the two ends ofsourceLocationare filled by whichever side the failure is anchored to and the other sub-fields are leftnull(the schema permits this): a stage-1translation-unsupportedfillscs*from the Roslyn node location and leavesgs*null; a stage-2compile-errorfillsgs*from thegscdiagnostic and leavescs*null. Correspondingly,offendingCSharpConstructfor 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# constructgscflagged (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.IlVerifyStageruns only after a green stage-2 compile (it reads the emitted assembly path theCompileStagepublishes on the shared context). It invokes the repo-pinneddotnet-ilverify(.config/dotnet-tools.json, tooldotnet-ilverify10.0.8, commandilverify) viadotnet 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 restoreis 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.dllfrom the shared-framework dir) plus the corpus app'sReferencedAssemblies, mirroringtest/Compiler.Tests/IlVerifier.cs. A non-zero exit with real verification errors yields categoryilverify-failure, one artifact per distinct error-code + failing-method skeleton, withdiagnostic.id= the ilverify error code (e.g.StackUnexpected),severity=error,message= the trimmed[IL]: Error […]line, andoffendingCSharpConstruct.kind= the failingType::Method(sig)parsed from the line (documented fallbackIlMethodwhen ilverify reports no method).sourceLocation.gsFilepoints at the emitted.gs; all positions arenull(no source map yet). Labels areOats+cil-emit(per the §D label list). The fingerprint reuses the §D.2 hash overcategory|stage|diagnostic.id|construct.kind|normalizedShape. - Stage 3 ilverify false-positive bundles (the only suppressions).
dotnet-ilverify10.0.8 has two documented FALSE POSITIVES that are verifier limitations, not emitter bugs — the same minimal pattern emitted bycscfails identically and the JIT accepts the IL. The pipeline passes these asilverify -g <code>ignore flags (and filters them defensively from parsed output) via the named constantIlVerifyRunner.KnownIlVerifyFalsePositives, so no spuriousilverify-failuregap is filed: (1)ReturnPtrToStack— by-value returns of a user-declaredref struct(track dotnet/runtime#129030); (2) the static-virtualconstrained.+callbundleCallAbstract+Constrained— ADR-0089 / issue #755 (track dotnet/runtime#49558). These cite the same upstream issues asIlVerifier.KnownIssues. Any other ilverify error in a green-compiling app is a genuinecil-emitcompiler gap and is captured (never suppressed). An abnormal tool exit is insteadverification-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 separateIlVerifyIncompleteartifact. The whole stage no-ops to PASS whenGSHARP_SKIP_ILVERIFY=1(for environments without the tool), matchingIlVerifier. - Stage 4 (
test-parity) modes, artifacts, and labels.TestParityStageruns only after a green stage-3 (it is appended last inMigrationPipeline.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
EmittedAssemblyPaththeCompileStagepublishes on the shared context) viadotnet <emitted>.dll, captures stdout, and compares it tobaseline.stdout.goldenwith the L1 end-to-end normalization (CRLF→LF, single trailing newline). A divergence — or a non-zero exit — yields categorytest-parity-failure, one artifact withdiagnostic.id=STDOUT-MISMATCH,message= the first differing line (expected-vs-actual), andoffendingCSharpConstruct.kind=ProgramStdout. L1 is the first corpus app green end-to-end across all four stages. - Library apps with a sibling
.Testsoracle (L2/L3) → xUnit pass/fail-set parity. The stage translates the C#.Testsproject to a G# xUnit project (@Fact/@Theory/@InlineData/Assert.*), scaffolds an isolated test.gsprojconsuming the locally-builtGsharp.NET.Sdknupkg (copied into the repo.nugsfeed and pinned), runsdotnet testproducing a TRX, parses it into the{name, outcome}set, and compares it tobaseline.tests.json. Any missing, extra, or outcome-mismatch test yields categorytest-parity-failure, one artifact per differing test, withdiagnostic.id=TESTPARITY-<Missing|Extra|OutcomeMismatch>,message= expected-vs-actual outcome, andoffendingCSharpConstruct.kind= the failing test name. The TRX parse mirrorscorpus/trx-to-baseline.py(onlyUnitTestResultrows; 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 anddotnet tests green against the local SDK. - Both modes label artifacts
Oats+bug(per the §D label list) and reuse the §D.2 fingerprint hash overcategory|stage|diagnostic.id|construct.kind|normalizedShape(one fingerprint per differing test / per stdout-mismatch shape).sourceLocation.gsFilepoints at the emitted.gs; positions arenull(the divergence is observed at runtime, not at a source position).
- Executable apps with a captured stdout golden (e.g. L1) → stdout parity. The stage runs the stage-2/3 emitted assembly (the
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.
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 bycorpus/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'ssamples/*.goldenconvention) 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 matchescorpus/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.
Cs2Gs.Report produces, per run, two distributable artifacts:
- 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 byfingerprint), and retry history — the human-facing dashboard. - 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 (translate→compile→ilverify→test-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 byJsonSummaryWriter, reusingTriageSerialization.Optionsso 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-relativeartifacts/fingerprints), and thegapsarray keyed byfingerprintwithoccurrencesand the mergedretryHistory.report.html(written byHtmlReportWriter) 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 carriesPASS/FAIL/SKIPtext plus anaria-label), the discovered-gaps section (each gap shows category/stage/diagnostic, the affected apps, thesuggestedIssuetitle + 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 singleHtmlReportWriter.Encodehelper (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.Reportdepends only onCs2Gs.Pipeline(for theRunResult/TriageArtifactmodels 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.
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 viacs2gs 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).
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)
}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 double → M(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 = expr →
GS0288/GS0113) (§B.3).
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 0 — Oahu.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 *T — byte* → *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.
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 literal — fixed 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>.- 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 inCs2Gs.Translator.
- Roslyn dependency in the toolset.
Cs2Gs.TranslatorcarriesMicrosoft.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 structnewtype promotion, someusing staticshapes, 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# memberprotectedis now translated directly to G#protectedsince issue #950; the blendedprotected internal/private protectedforms remain triaged tointernal.)
- 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.
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.