cs2gs translates idiomatic C# into canonical G#, then drives the result
through a four-stage verification pipeline and produces distributable
HTML + JSON reports. It serves two objectives (issue #914):
- Migrate real C# applications to faithful, canonical G# (Roslyn-based front end → G# code model → printer).
- Discover G# compiler gaps — every construct that fails to translate, compile, IL-verify, or reach test parity is captured as a structured, deduplicated triage record that can be filed as a compiler issue.
The full design contract lives in
docs/adr/0115-csharp-to-gsharp-migration-tool.md
(§B canonical translation rules, §C pipeline, §D triage schema, §E corpus &
oracle, §F reporting). This README is the operational quick start.
| Project | Responsibility |
|---|---|
Cs2Gs.CodeModel |
The G# code model (AST under Ast/) and the canonical GSharpPrinter (golden-tested), independent of Roslyn. |
Cs2Gs.Translator |
Roslyn C# front end. CSharpToGSharpTranslator + CSharpTypeMapper map C# syntax/semantics to the code model (the only project that references Microsoft.CodeAnalysis.CSharp). |
Cs2Gs.Pipeline |
The four ordered, short-circuiting migration stages, the triage builder + fingerprint, corpus discovery, and the gsc invoker. |
Cs2Gs.Report |
Aggregates a run into a self-contained report.html + summary.json. |
Cs2Gs.Cli |
The cs2gs command-line front end (migrate / report verbs). |
Cs2Gs.Tests |
Unit + golden + live tests for all of the above. |
corpus/ |
The curated C# input corpus (L1–L5) plus xUnit oracles. Isolated from GSharp.sln; see corpus/README.md. |
Why Roslyn here does not contradict ADR-0027 (no Roslyn in the compiler):
cs2gs uses Roslyn as an external, offline C# reader in a separate process;
gsc gains no Roslyn dependency. See ADR-0115 §A.
dotnet build GSharp.sln -c Release -graph --no-restoreThis builds the compiler (gsc) and all cs2gs projects. Produced assemblies:
- CLI:
out/bin/Release/Cs2Gs.Cli/cs2gs.dll(AssemblyNamecs2gs) - Compiler:
out/bin/Release/Compiler/gsc.dll(AssemblyNamegsc)
IL verification (stage 3) uses the repo's local ilverify tool — restore it
once with dotnet tool restore at the repo root.
cs2gs ships as the Gsharp.Cs2Gs package (exposes the cs2gs command). A
.NET 10 runtime is required.
dotnet tool install --global Gsharp.Cs2Gs
cs2gs migrate --helpdotnet out/bin/Release/Cs2Gs.Cli/cs2gs.dll migrate \
--corpus /path/to/csharp-repository \
--out /path/to/gsharp-repository \
--config Release| Option | Meaning |
|---|---|
--corpus <dir> |
Source repository root (default tools/cs2gs/corpus). |
--out <dir> |
Empty destination repository (required by default). |
--artifacts <dir> |
Validation runs root (default <out>.cs2gs-runs). |
--diagnostic-run |
Preserve the historical timestamped corpus/CI layout. |
--app <id> |
Diagnostic-run only; migrate one app (repeatable). |
--gsc <path> |
Override gsc.dll (default out/bin/<Config>/Compiler/gsc.dll). |
--config <name> |
Build config used to locate gsc (default Release). |
Repository mode preserves relative directories, copies non-C# files, translates
checked-in .cs files to .gs, transforms .csproj files to .gsproj, and
replaces .sln files with .slnx. As a compatibility rewrite, literal
Nerdbank.GitVersioning versions below 3.11.13-beta are upgraded in projects
and shared MSBuild props. Build outputs, logs, triage records,
run.json, report.html, and summary.json stay outside the destination under
the artifacts root.
Compiler-gap CI uses the legacy layout explicitly:
cs2gs migrate --diagnostic-run \
--corpus tools/cs2gs/corpus \
--out cs2gs-runsdotnet out/bin/Release/Cs2Gs.Cli/cs2gs.dll report --run /tmp/cs2gs-run/<runId>--out <dir> overrides the output location (defaults to the run dir).
Stages run in order and short-circuit: a failure stops that app and records a
triage artifact; later stages are reported as skip.
- Translate — C#→G# via Roslyn; round-trips the printed G# back through the
parser. Failures → category
translation-unsupported. - Compile — invokes
gsc(/target:exe|library /out: /reference:). Failures →compile-error. - IlVerify — runs
ilverifyover the emitted assembly (ports the repo'sIlVerifierrecipe, with two cited false-positive ignore bundles). Failures →ilverify-failure. - TestParity — for executables, byte-compares stdout against the captured
C# baseline; for libraries, builds a G# xUnit test project (local
Gsharp.NET.Sdk) and compares the passing/failing test set against the C# xUnit oracle. Failures →test-parity-failure.
Stage 3 does not treat green CI or an application that happens to run as proof
that invalid non-unsafe IL is acceptable. An exception is method- and
diagnostic-scoped and requires a matching C#-built baseline. Issue #2671 adds
one such upstream exception for Avalonia 11.2.7 XamlIl-generated
XamlClosure_N::Build_N(IServiceProvider) methods. The filter requires all of:
StackUnexpected;- the exact generated closure/method/signature convention; and
- XamlIl's exact object-slot mismatch,
found ref 'object'to an expected reference type.
Other errors in the same assembly and other errors in a matching method remain gating failures.
The proof used Oahu commit
200e3b4c26c781368f74bbef098caea89bac8754 and its C# release artifact
8365085850. Both the original C# build and migrated G# build contain these
six identical diagnostics (paths normalized), hash
d467dfc517fa448a1cff59f934afc11bbcebf5901910629db36ad63e8264bf50:
| Method | Offsets | Triage fingerprint |
|---|---|---|
BookLibraryView+XamlClosure_1::Build_1 |
0x1B, 0x20, 0xB1 |
sha256:9d0322446fc5a1cc4b78e2a66d5d83354b6e527fcd49d9f907fe63ccbb034a53 |
ConversionView+XamlClosure_2::Build_1 |
0x1B, 0x20, 0xD7 |
sha256:94a053bb50ec2721c8600fe77dd02aa29a401f95bf174b39b459218f5dfea5a7 |
Token-resolved ILSpy method disassemblies are identical after removing only
the RVA comment: BookLibrary
d79a21c01d54baa12c9c7f689d1beba1b1dcd814160cfecf7048100f3f90cae4;
Conversion
66d3ea09ffba550e5d034135c085d9010c9ee85a6e003a2417132697e25ceeba.
The raw IL streams have the same sizes/maxstack (183/9 and 221/9) but different
hashes because metadata row tokens differ between csc and gsc assemblies:
| Method | Original raw IL SHA-256 | Migrated raw IL SHA-256 |
|---|---|---|
BookLibrary Build_1 |
9af2771d7ae3e81889e17959afe409fc31bd8bbb33a827ddc156e6ffd43bbfdc |
91db5378181a2bbcc720ad9d5018034cb960df3ef04cf827b73aba885d05b679 |
Conversion Build_1 |
ef14249dfeb99e2455b14a06459735e5312fdfad05402724c69185234ffe6e1d |
d81df7df85024b9ff4c5e5dd36fc3303d541687e75abcc57ba8917a0b723bfa7 |
Ownership is also observable before verification. Neither compiler's
obj/Release/net10.0/Oahu.UI.dll contains XamlClosure types. Avalonia's
CompileAvaloniaXamlTask writes
obj/Release/net10.0/Avalonia/Oahu.UI.dll; only that post-processed assembly
contains them, and it is byte-identical to the copied bin assembly. The
original pre/post hashes are 0f0d5e80…/f26eeb88…; the migrated hashes are
86f7e159…/f534dc90…. The relevant checks are:
sha256sum obj/Release/net10.0/{,Avalonia/}Oahu.UI.dll \
bin/Release/net10.0/Oahu.UI.dll
ilspycmd -l c obj/Release/net10.0/Oahu.UI.dll | grep XamlClosure
ilspycmd -l c obj/Release/net10.0/Avalonia/Oahu.UI.dll | grep XamlClosure
runtime=$(dotnet --list-runtimes | awk \
'$1=="Microsoft.NETCore.App" && $2~/^10[.]/{gsub(/[][]/,"",$3); p=$3"/"$2} END{print p}')
ilverify bin/Release/net10.0/Oahu.UI.dll -s System.Private.CoreLib \
$(find "$runtime" -maxdepth 1 -name '*.dll' -exec printf -- '-r %s ' {} \;) \
$(find bin/Release/net10.0 -maxdepth 1 -name '*.dll' \
! -name Oahu.UI.dll -exec printf -- '-r %s ' {} \;)
ilspycmd -il bin/Release/net10.0/Oahu.UI.dll > proof.ilInvoking both exact methods from each assembly returns TextBlock and
ProgressBar, respectively. This establishes an upstream, runtime-valid
verifier exception; it does not excuse unrelated Avalonia or gsc diagnostics.
Applying the narrowed filter to either complete nine-error Oahu log leaves the
same AboutView.!XamlIlPopulate DelegateCtor error gating. That separate
diagnostic remains unsuppressed and is tracked by #2672.
The authoritative statement of C# 14 coverage is
coverage/csharp-construct-inventory.json:
one row per Roslyn node SyntaxKind with a status of Translated, Lowered,
UnsupportedByDesign (+rationale), Gap (+issue), or Unclassified (capped
by a never-raise ratchet). The human-readable rollup is generated into
docs/cs2gs-coverage-matrix.md.
cs2gs coverage— report drift (exit 1) between the inventory, the Roslyn-surface golden, and the docs matrix.cs2gs coverage --write— after a Roslyn bump: appendUnclassifiedskeleton rows for new kinds, canonicalize, regenerate golden + docs.
ConstructInventoryGoldenTests enforces all of it in CI; the
UnsupportedByDesign registry (consulted by every translator rejection)
must stay equal to the inventory's unsupported rows
(TranslatorExhaustivenessTests), so an accidental fallthrough is minted
CS2GS-GAP instead of hiding behind CS2GS-UNSUPPORTED.
The corpus/grid/G01…G14 apps are the per-construct differential fixtures:
one construct per Constructs/<SyntaxKind>.cs, executable, stdout
byte-compared C#-vs-G# in stage 4.
The checked-in gap ledger triage/gaps.json is both the
fingerprint↔issue map and the CI baseline:
# classify a run against the ledger (read-only)
cs2gs triage list --run cs2gs-runs/<runId>
# file issues for NEW fingerprints (dry-run by default; --file creates them,
# clustered by root cause, capped via --limit)
cs2gs triage file-issues --run cs2gs-runs/<runId> --file
# reconcile ledger statuses with GitHub (closed issue -> resolved; requires an
# Issue<N>* regression test or --no-test-reason)
cs2gs triage sync --writecs2gs migrate --diagnostic-run --baseline tools/cs2gs/triage/gaps.json
gates on the ledger:
NEW or REGRESSED fingerprints fail; KNOWN open gaps are tolerated;
STALE entries warn (fail with --baseline-strict, the nightly mode); an
unverified app (skipped stage, no artifact) must be acknowledged in the
ledger's unverifiedApps. The cs2gs job in .github/workflows/build.yml
runs this on every PR; .github/workflows/cs2gs-nightly.yml runs strict mode
and auto-files issues for new gaps, opening a ledger-update PR.
Every gap is written as a JSON triage artifact with a stable fingerprint:
fingerprint = sha256(category | stage | diagnostic.id | construct.kind | normalizedShape)
The fingerprint deliberately excludes runId, appId, and source positions
so the same gap deduplicates across apps and runs. The report groups
occurrences by fingerprint, so one compiler gap surfaced by three apps shows as a
single entry with three occurrences.
report.html— a single self-contained file (no external CSS/JS/network references), deterministic, HTML-encoded; the per-app matrix plus gaps grouped by fingerprint.summary.json— the machine-readable aggregate (run.json+ triage records).
The corpus (corpus/) is ordinary, idiomatic C# — the migration input — and
is intentionally ordered by increasing complexity (L1 simplest first) so the
smallest possible gap is isolated first. It is isolated from GSharp.sln
(its own self-contained Directory.Build.props), so changing it does not affect
the product build. To add an app:
- Add a C# project under
corpus/<Level>-<Name>/. - For executables, capture the stdout baseline; for libraries, capture the
xUnit oracle into
<App>.Tests/baseline.tests.json(seecorpus/capture-baselines.sh/corpus/trx-to-baseline.py). - Re-run
migrateto translate and verify it.
See corpus/README.md for the level matrix and oracle
details.
| App | translate | compile | ilverify | test-parity |
|---|---|---|---|---|
corpus/L1-Console |
PASS | PASS | PASS | PASS |
corpus/L2-Library |
PASS | PASS | PASS | PASS |
corpus/L3-Library |
PASS | PASS | PASS | skip (unverified, ledgered; #1924) |
corpus/L4-Console |
PASS | PASS | PASS | PASS |
corpus/L5-Console |
PASS | PASS | PASS | PASS |
corpus/grid/G01…G14 (14 apps) |
PASS except deliberate G07 unsupported operator | PASS | PASS | PASS |
corpus/CompileGap-Library |
PASS | FAIL (deliberate, ledgered wontfix) | skip | skip |
With --baseline tools/cs2gs/triage/gaps.json the run above gates green
(2 known, 0 new, 0 regressed, 0 stale): every residual failure is a
ledgered, issue-linked gap. The grid build-out filed the coverage backlog as
issues #1879–#1934
(37 translator gaps, 17 gsc gaps, 1 pipeline policy), each with a quarantined
fixture next to its grid app.
L1, L2, L4, and L5 migrate fully green end-to-end. L2 went green when #973/#974 were
fixed. L4 emits the canonical forms for an interpolated : base(...) argument,
a struct interface clause, and an inline BCL out var x now that #975/#976/#977
are fixed. L3 translates in full but is blocked at compile by a residual gap
(#985: IEnumerable[T] needs two GetEnumerator overloads differing only by
return type — GS0264 + GS0187). L3's Advanced.cs translates and compiles
standalone. L5 (inheritance/polymorphism, is/switch pattern matching, a
yield return iterator → sequence[T], generic constraints) reaches full
parity by using only the canonical/compiling forms, and surfaces the next batch
of gaps (#986…#994) — captured as verified triage records (the constructs with
no canonical form, or that ICE/mis-compile, are held out of L5). As gaps are fixed
the frontier advances automatically — #938–#944 moved L3 to translate PASS, and
#973–#977 took L2 green and let L4 use the canonical forms.
| Issue | Construct | Diagnostic | Status |
|---|---|---|---|
| #938 | Owned-struct methods (receiver-clause warning) |
GS0314 | resolved |
| #939 | for…in List[userType] erases element type |
GS0158 | resolved |
| #940 | Static (shared) method overloads don't resolve by arity |
GS0144 | resolved |
| #941 | Binary ?? operator unsupported (only ??= existed) |
GS0005 | resolved |
| #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 | No user-indexer declaration form; attempts crash | GS9998 | resolved |
| #973 | A class with a user struct/data struct field — emit ICE |
GS9998 | resolved (L2 green) |
| #974 | Generic-interface impl: method returning a constructed generic over T (e.g. IEnumerator[T]) fails satisfaction |
GS0187 | resolved |
| #975 | Interpolated string in a : base(...) constructor-arg position — emit ICE |
GS9998 | resolved (emitted directly) |
| #976 | A struct cannot declare a base / interface clause (struct S : I {…}) |
GS0005 | resolved (interface clause parses; class base → GS0382) |
| #977 | BCL method with an inline out var x declaration fails overload resolution |
GS0159 | resolved (inline out var binds) |
| #985 | Implementing IEnumerable[T] needs two GetEnumerator overloads differing only by return type |
GS0264 + GS0187 | open (blocks L3-Generics) |
| #986 | base.Method() virtual base-class call has no canonical G# form |
GS0157 / GS0338 | open (translation-unsupported) |
| #987 | An abstract (no-body) method on an open class crashes the emitter |
GS9998 (NRE) | open |
| #988 | new T() construction under a new() constraint has no canonical G# form |
GS0125 / GS0130 / GS0157 | open (translation-unsupported) |
| #989 | A generic auto-property over T (prop Value T) cannot be member-accessed |
GS0158 | resolved (construction substitutes the property table like fields) |
| #990 | A user reference-type iterator (sequence[UserClass]) crashes the emitter |
GS9998 | resolved (user reference- and value-type element iterators emit, ilverify, and run) |
| #991 | A when guard on a switch arm won't parse |
GS0005 | open (translation-unsupported) |
| #992 | and/or binary patterns (> 0 and < 10) won't parse |
GS0005 | open (translation-unsupported) |
| #993 | An is/case type pattern with a binder (x is T t) leaves the binder unbound |
GS0125 | open |
| #994 | yield break has no canonical G# form |
GS0005 | open (translation-unsupported) |
Gaps #975/#976/#977 — discovered by L4 — are now resolved, so the translator
emits the canonical forms directly: the interpolated : base("…$n…") argument, the
struct Money(Cents int32) : IEquatable[Money] interface clause, and the inline
BCL out var x. The residual L3 gap and the L5 batch (#986…#994) are each a
verified minimal repro (with a contrasting passing control) documented in
ADR-0115 §G for the compiler backlog. L5 also drove two translator faithfulness
fixes (no compiler change): an int literal implicitly promoted to a double
parameter is emitted as a float literal (avoiding an ilverify StackUnexpected),
and a parameterless constructor that initializes a property keeps its explicit
init() body (G# has no property member initializer).
Non-test cs2gs projects enforce StyleCop + TreatWarningsAsErrors
(AssemblyName → GSharp.<Project>, Nullable=disable, ImplicitUsings):
copyright headers, XML docs on public members, SA1201 member ordering, SA1649
(filename = first type), and this. qualification are required. The solution
must build with 0 warnings / 0 errors. The corpus and the local NuGet feeds
(.nugs/, out/bin/Release/nupkgs/) are kept clean and out of GSharp.sln.