diff --git a/WrapGod.Cli/MigrateCommandBuilder.cs b/WrapGod.Cli/MigrateCommandBuilder.cs
index c0b4b98..10cdcb1 100644
--- a/WrapGod.Cli/MigrateCommandBuilder.cs
+++ b/WrapGod.Cli/MigrateCommandBuilder.cs
@@ -14,6 +14,7 @@ public static Command Build()
{
MigrateInitCommand.CreateSubCommand(),
MigrateGenerateCommand.Create(),
+ MigrateStatusCommand.Create(),
};
return migrateCommand;
diff --git a/WrapGod.Cli/MigrateStatusCommand.cs b/WrapGod.Cli/MigrateStatusCommand.cs
new file mode 100644
index 0000000..9e53c1a
--- /dev/null
+++ b/WrapGod.Cli/MigrateStatusCommand.cs
@@ -0,0 +1,405 @@
+using System.CommandLine;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using WrapGod.Migration;
+using WrapGod.Migration.Engine.State;
+
+namespace WrapGod.Cli;
+
+///
+/// Implements wrap-god migrate status — reads the state file sibling to a schema
+/// and reports progress without running any migration.
+///
+internal static class MigrateStatusCommand
+{
+ private static readonly JsonSerializerOptions JsonOutputOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ // NOTE: progressPct, library, from, to may be null and MUST be emitted as
+ // explicit JSON null so downstream tooling can distinguish "missing" from
+ // "not applicable" (e.g. progressPct == null means 0-rules schema).
+ };
+
+ public static Command Create()
+ {
+ var schemaOption = new Option(
+ ["--schema", "-s"],
+ "Path to the migration schema JSON file. The state file is the sibling .state.json.")
+ {
+ IsRequired = true,
+ };
+
+ var projectDirOption = new Option(
+ ["--project-dir", "-p"],
+ "Project directory used to resolve a relative --schema path (default: current directory).");
+
+ var jsonOption = new Option(
+ "--json",
+ "Emit output as JSON instead of human-readable text.");
+
+ var verboseOption = new Option(
+ ["--verbose", "-v"],
+ "Include per-rule details and per-file applied lists in human-readable mode.");
+
+ var command = new Command("status", "Report migration progress from the state file without running any migration")
+ {
+ schemaOption,
+ projectDirOption,
+ jsonOption,
+ verboseOption,
+ };
+
+ command.SetHandler((context) =>
+ {
+ var schema = context.ParseResult.GetValueForOption(schemaOption)!;
+ var projectDir = context.ParseResult.GetValueForOption(projectDirOption);
+ var json = context.ParseResult.GetValueForOption(jsonOption);
+ var verbose = context.ParseResult.GetValueForOption(verboseOption);
+
+ context.ExitCode = Handle(schema, projectDir, json, verbose);
+ });
+
+ return command;
+ }
+
+ private static int Handle(
+ string schemaArg,
+ string? projectDir,
+ bool jsonOutput,
+ bool verbose)
+ {
+ // ── Resolve schema path ──────────────────────────────────────────────────────────────
+ var baseDir = string.IsNullOrWhiteSpace(projectDir)
+ ? Directory.GetCurrentDirectory()
+ : projectDir;
+
+ var schemaPath = Path.IsPathRooted(schemaArg)
+ ? schemaArg
+ : Path.GetFullPath(Path.Combine(baseDir, schemaArg));
+
+ if (!File.Exists(schemaPath))
+ {
+ Console.Error.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Error: schema file not found: {0}", schemaPath));
+ return 1;
+ }
+
+ // ── Load schema (for library/from/to metadata + total rule count) ───────────────────
+ MigrationSchema? schema = null;
+ string? currentHash = null;
+ try
+ {
+ var schemaJson = File.ReadAllText(schemaPath, System.Text.Encoding.UTF8);
+ currentHash = MigrationStateStore.ComputeSchemaHash(schemaJson);
+ try
+ {
+ schema = MigrationSchemaSerializer.Deserialize(schemaJson);
+ }
+ catch (JsonException)
+ {
+ // Schema couldn't be parsed — non-fatal, we just don't show library/from/to or totalRules
+ schema = null;
+ }
+ }
+ catch (IOException)
+ {
+ // Non-fatal — schema read failed; skip hash and metadata
+ }
+
+ // ── Load state ───────────────────────────────────────────────────────────────────────
+ var state = MigrationStateStore.Load(schemaPath, out var wasCorrupt, out var backupPath);
+
+ if (wasCorrupt)
+ {
+ var bakMsg = backupPath is not null
+ ? string.Format(CultureInfo.InvariantCulture, "Corrupt state file was archived to: {0}", backupPath)
+ : "Corrupt state file could not be archived (file may be locked).";
+
+ Console.Error.WriteLine("Error: The migration state file is corrupt and could not be parsed.");
+ Console.Error.WriteLine(bakMsg);
+ return 1;
+ }
+
+ if (state is null)
+ {
+ // State file simply doesn't exist — info-only, not an error.
+ if (jsonOutput)
+ {
+ var sentinel = new
+ {
+ status = "no-runs-recorded",
+ library = schema?.Library,
+ from = schema?.From,
+ to = schema?.To,
+ };
+ Console.WriteLine(JsonSerializer.Serialize(sentinel, JsonOutputOptions));
+ }
+ else
+ {
+ Console.WriteLine("No migration runs recorded for this schema.");
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Run `wrap-god migrate apply --schema \"{0}\"` to apply migrations.",
+ Path.GetFileName(schemaPath)));
+ }
+ return 0;
+ }
+
+ var schemaChanged = currentHash is not null && state.SchemaHasChanged(currentHash);
+
+ // ── Detect synthetic SkippedRewrite (corruption recovery marker from #197) ──
+ var hasStateRecoveryEntry = state.Skipped.Any(s =>
+ string.Equals(s.RuleId, "", StringComparison.Ordinal));
+
+ // ── Progress calculation ─────────────────────────────────────────────────────────────
+ // totalRules from the schema (source of truth); appliedRules = distinct rule IDs in state.Applied
+ var totalRules = schema?.Rules.Count ?? 0;
+ var appliedRules = state.Applied
+ .Select(a => a.RuleId)
+ .Distinct(StringComparer.Ordinal)
+ .Count();
+ double? progressPct = totalRules == 0
+ ? (double?)null
+ : (double)appliedRules / totalRules;
+
+ // ── Emit output ──────────────────────────────────────────────────────────────────────
+ if (jsonOutput)
+ {
+ return EmitJson(state, schema, schemaChanged, currentHash, hasStateRecoveryEntry,
+ totalRules, appliedRules, progressPct);
+ }
+ else
+ {
+ return EmitHumanReadable(state, schema, schemaChanged, hasStateRecoveryEntry, verbose,
+ totalRules, appliedRules, progressPct);
+ }
+ }
+
+ // ── JSON output ──────────────────────────────────────────────────────────────────────────
+
+ private static int EmitJson(
+ MigrationState state,
+ MigrationSchema? schema,
+ bool schemaChanged,
+ string? currentHash,
+ bool hasStateRecoveryEntry,
+ int totalRules,
+ int appliedRules,
+ double? progressPct)
+ {
+ var appliedByRule = state.Applied
+ .GroupBy(a => a.RuleId, StringComparer.Ordinal)
+ .Select(g => new
+ {
+ ruleId = g.Key,
+ fileCount = g.Select(a => a.File).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
+ })
+ .ToList();
+
+ var skippedByReason = state.Skipped
+ .Where(s => !string.Equals(s.RuleId, "", StringComparison.Ordinal))
+ .GroupBy(s => s.Reason, StringComparer.OrdinalIgnoreCase)
+ .Select(g => new { reason = g.Key, count = g.Count() })
+ .ToList();
+
+ var manualEntries = state.Manual
+ .Select(m => new
+ {
+ ruleId = m.RuleId,
+ note = m.Note,
+ matchedFiles = m.MatchedFiles.Count == 0
+ ? (object)"(no files matched yet)"
+ : m.MatchedFiles,
+ })
+ .ToList();
+
+ var output = new
+ {
+ library = schema?.Library,
+ from = schema?.From,
+ to = schema?.To,
+ schema = state.Schema,
+ schemaHash = state.SchemaHash,
+ schemaChanged,
+ currentSchemaHash = currentHash,
+ startedAt = state.StartedAt,
+ lastRunAt = state.LastRunAt,
+ totalRules,
+ appliedRules,
+ progressPct,
+ summary = new
+ {
+ total = state.Summary.TotalRules,
+ applied = state.Summary.Applied,
+ skipped = state.Summary.Skipped,
+ manual = state.Summary.Manual,
+ },
+ applied = appliedByRule,
+ skipped = skippedByReason,
+ manual = manualEntries,
+ stateRecoveryOccurred = hasStateRecoveryEntry,
+ };
+
+ Console.WriteLine(JsonSerializer.Serialize(output, JsonOutputOptions));
+ return state.Manual.Count > 0 ? 2 : 0;
+ }
+
+ // ── Human-readable output ────────────────────────────────────────────────────────────────
+
+ private static int EmitHumanReadable(
+ MigrationState state,
+ MigrationSchema? schema,
+ bool schemaChanged,
+ bool hasStateRecoveryEntry,
+ bool verbose,
+ int totalRules,
+ int appliedRules,
+ double? progressPct)
+ {
+ Console.WriteLine("WrapGod migrate status");
+ Console.WriteLine(new string('-', 40));
+
+ // Migration metadata header (library + from → to)
+ if (schema is not null && !string.IsNullOrEmpty(schema.Library))
+ {
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Migration: {0} {1} -> {2}", schema.Library, schema.From, schema.To));
+ }
+
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "Schema: {0}", state.Schema));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Started: {0:yyyy-MM-dd HH:mm:ss} UTC", state.StartedAt.UtcDateTime));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Last run: {0:yyyy-MM-dd HH:mm:ss} UTC", state.LastRunAt.UtcDateTime));
+ Console.WriteLine();
+
+ // Progress ratio line
+ if (totalRules > 0 && progressPct.HasValue)
+ {
+ var pctInt = (int)Math.Round(progressPct.Value * 100.0, MidpointRounding.AwayFromZero);
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Progress: {0} / {1} rules applied ({2}%)",
+ appliedRules, totalRules, pctInt));
+ }
+ else
+ {
+ // Zero-rules edge case — display "n/a"
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Progress: {0} / {1} rules applied (n/a)",
+ appliedRules, totalRules));
+ }
+ Console.WriteLine();
+
+ // Schema hash line
+ if (schemaChanged)
+ {
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Schema hash: {0} (WARNING: schema has changed since last apply)", state.SchemaHash));
+ }
+ else if (!string.IsNullOrEmpty(state.SchemaHash))
+ {
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Schema hash: {0} (matches current schema)", state.SchemaHash));
+ }
+
+ Console.WriteLine();
+
+ // State recovery highlight
+ if (hasStateRecoveryEntry)
+ {
+ Console.WriteLine(" [!] State recovery occurred — a previous state was corrupt and this run started fresh.");
+ Console.WriteLine();
+ }
+
+ // Counts
+ var appliedFiles = state.Applied
+ .Select(a => a.File)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Count();
+
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Applied: {0,5} (across {1} file(s))", state.Summary.Applied, appliedFiles));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Skipped: {0,5}", state.Summary.Skipped));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ "Manual: {0,5}", state.Summary.Manual));
+
+ // ── Verbose: per-rule applied breakdown ─────────────────────────────────────
+ if (verbose && state.Applied.Count > 0)
+ {
+ Console.WriteLine();
+ Console.WriteLine("Applied rules:");
+ foreach (var group in state.Applied
+ .GroupBy(a => a.RuleId, StringComparer.Ordinal)
+ .OrderBy(g => g.Key, StringComparer.Ordinal))
+ {
+ var fileCount = group
+ .Select(a => a.File)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Count();
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ " {0}: {1} file(s)", group.Key, fileCount));
+ }
+ }
+
+ // ── Skipped rules ────────────────────────────────────────────────────────────
+ var visibleSkipped = state.Skipped
+ .Where(s => !string.Equals(s.RuleId, "", StringComparison.Ordinal))
+ .ToList();
+
+ if (visibleSkipped.Count > 0)
+ {
+ Console.WriteLine();
+ Console.WriteLine("Skipped rules:");
+ if (verbose)
+ {
+ foreach (var skip in visibleSkipped.OrderBy(s => s.RuleId, StringComparer.Ordinal))
+ {
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ " {0} {1}:{2} {3}", skip.RuleId, skip.File, skip.Line, skip.Reason));
+ }
+ }
+ else
+ {
+ // Group by reason
+ foreach (var group in visibleSkipped
+ .GroupBy(s => s.Reason, StringComparer.OrdinalIgnoreCase)
+ .OrderByDescending(g => g.Count()))
+ {
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ " {0}: {1}", group.Key, group.Count()));
+ }
+ }
+ }
+
+ // ── Manual rules ─────────────────────────────────────────────────────────────
+ if (state.Manual.Count > 0)
+ {
+ Console.WriteLine();
+ Console.WriteLine("Manual rules (require human intervention):");
+ foreach (var manual in state.Manual.OrderBy(m => m.RuleId, StringComparer.Ordinal))
+ {
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ " {0} {1}", manual.RuleId, manual.Note));
+ if (verbose)
+ {
+ if (manual.MatchedFiles.Count == 0)
+ {
+ Console.WriteLine(" (no files matched yet)");
+ }
+ else
+ {
+ foreach (var file in manual.MatchedFiles)
+ {
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
+ " {0}", file));
+ }
+ }
+ }
+ }
+ }
+
+ return state.Manual.Count > 0 ? 2 : 0;
+ }
+}
diff --git a/WrapGod.Cli/WrapGod.Cli.csproj b/WrapGod.Cli/WrapGod.Cli.csproj
index df7c481..3b7613c 100644
--- a/WrapGod.Cli/WrapGod.Cli.csproj
+++ b/WrapGod.Cli/WrapGod.Cli.csproj
@@ -21,6 +21,7 @@
+
diff --git a/WrapGod.Tests/CliCommandTests.cs b/WrapGod.Tests/CliCommandTests.cs
index 76ee22d..603f7b1 100644
--- a/WrapGod.Tests/CliCommandTests.cs
+++ b/WrapGod.Tests/CliCommandTests.cs
@@ -9,7 +9,9 @@ public sealed class CliCommandTests
private static readonly string[] ExpectedRootCommands =
["analyze", "ci", "doctor", "explain", "extract", "generate", "init", "migrate"];
- private static readonly string[] ExpectedMigrateCommands = ["generate", "init"];
+ // TODO: when #199 / PR #215 (`migrate apply`) lands on main, add "apply" to this list.
+ // The order is alphabetical to match the assertion in RootCommand_WiresExpectedCommands.
+ private static readonly string[] ExpectedMigrateCommands = ["generate", "init", "status"];
private static readonly string[] ExpectedCiCommands = ["bootstrap", "parity"];
diff --git a/WrapGod.Tests/MigrateStatusCliTests.cs b/WrapGod.Tests/MigrateStatusCliTests.cs
new file mode 100644
index 0000000..5f54dfe
--- /dev/null
+++ b/WrapGod.Tests/MigrateStatusCliTests.cs
@@ -0,0 +1,619 @@
+using System.CommandLine;
+using System.Text.Json;
+using TinyBDD;
+using WrapGod.Cli;
+using WrapGod.Migration.Engine;
+using WrapGod.Migration.Engine.State;
+
+namespace WrapGod.Tests;
+
+///
+/// In-process BDD-style tests for wrap-god migrate status.
+/// Uses the same helper pattern as MigrateGenerateCliTests (Console redirect + Command.InvokeAsync).
+///
+[Feature("CLI: migrate status command reports migration progress from state file")]
+[Collection("CLI")]
+public sealed class MigrateStatusCliTests
+{
+ // ── Helper: invoke via migrate parent ────────────────────────────────────────────────────
+
+ private static async Task<(int ExitCode, string StdOut, string StdErr)> InvokeAsync(string args)
+ {
+ var command = MigrateCommandBuilder.Build();
+ var previousOut = Console.Out;
+ var previousErr = Console.Error;
+ var previousExitCode = Environment.ExitCode;
+ using var stdOut = new StringWriter();
+ using var stdErr = new StringWriter();
+
+ try
+ {
+ Console.SetOut(stdOut);
+ Console.SetError(stdErr);
+ Environment.ExitCode = 0;
+
+ var invokeCode = await command.InvokeAsync(args);
+ var effectiveExitCode = Environment.ExitCode == 0 ? invokeCode : Environment.ExitCode;
+ return (effectiveExitCode, stdOut.ToString(), stdErr.ToString());
+ }
+ finally
+ {
+ Console.SetOut(previousOut);
+ Console.SetError(previousErr);
+ Environment.ExitCode = previousExitCode;
+ }
+ }
+
+ // ── Fixture helpers ──────────────────────────────────────────────────────────────────────
+
+ private static string CreateTempDir()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"wrapgod-status-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ private static void SafeDelete(string path)
+ {
+ try { Directory.Delete(path, recursive: true); } catch { /* best-effort */ }
+ }
+
+ /// Writes a minimal schema JSON file and returns its path.
+ private static async Task WriteSchemaAsync(string dir, string fileName = "schema.wrapgod-migration.json")
+ {
+ var schemaPath = Path.Combine(dir, fileName);
+ await File.WriteAllTextAsync(schemaPath, """
+ {
+ "schemaVersion": "1.0",
+ "library": "TestLib",
+ "from": "1.0.0",
+ "to": "2.0.0",
+ "rules": []
+ }
+ """);
+ return schemaPath;
+ }
+
+ /// Writes a state file sibling to the schema and returns the schema path.
+ private static async Task WriteStateAsync(
+ string dir,
+ MigrationState state,
+ string schemaFileName = "schema.wrapgod-migration.json")
+ {
+ var schemaPath = await WriteSchemaAsync(dir, schemaFileName);
+ MigrationStateStore.Save(schemaPath, state);
+ return schemaPath;
+ }
+
+ private static MigrationState BuildState(
+ int appliedCount = 38,
+ int skippedCount = 6,
+ int manualCount = 3,
+ string schemaHash = "sha256:aabbccdd")
+ {
+ var applied = Enumerable.Range(1, appliedCount)
+ .Select(i => new AppliedRewrite($"R-{i:D3}", $"src/File{i}.cs", i * 10, $"OldType{i}", $"NewType{i}"))
+ .ToList();
+
+ var skipped = Enumerable.Range(1, skippedCount)
+ .Select(i => new SkippedRewrite($"S-{i:D3}", $"src/Skip{i}.cs", i * 5, $"Ambiguous: reason {i}"))
+ .ToList();
+
+ var manual = Enumerable.Range(1, manualCount)
+ .Select(i => new ManualRewrite($"M-{i:D3}", $"Manual action {i}", [$"src/Manual{i}A.cs", $"src/Manual{i}B.cs"]))
+ .ToList();
+
+ return new MigrationState
+ {
+ Schema = "schema.wrapgod-migration.json",
+ SchemaHash = schemaHash,
+ StartedAt = new DateTimeOffset(2026, 4, 1, 12, 0, 0, TimeSpan.Zero),
+ LastRunAt = new DateTimeOffset(2026, 4, 2, 9, 14, 33, TimeSpan.Zero),
+ Summary = new MigrationStateSummary
+ {
+ TotalRules = appliedCount + skippedCount + manualCount,
+ Applied = appliedCount,
+ Skipped = skippedCount,
+ Manual = manualCount,
+ },
+ Applied = applied,
+ Skipped = skipped,
+ Manual = manual,
+ };
+ }
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // Group: happy
+ // ════════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// SCENARIO: Happy-01 — state with applied/skipped/manual entries exits 2 and shows counts.
+ ///
+ [Scenario("Happy-01: status with applied/skipped/manual entries exits 2, stdout has counts")]
+ [Fact]
+ public async Task Status_HappyPath_PrintsProgress()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var schemaPath = await WriteStateAsync(dir, BuildState(38, 6, 3));
+ var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\"");
+
+ Assert.Equal(2, exitCode);
+ Assert.Contains("38", stdout);
+ Assert.Contains("6", stdout);
+ Assert.Contains("3", stdout);
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Happy-02 — --json flag produces valid JSON with required fields.
+ ///
+ [Scenario("Happy-02: --json flag produces valid JSON with required fields")]
+ [Fact]
+ public async Task Status_Json_ParsesAsJson()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var schemaPath = await WriteStateAsync(dir, BuildState(38, 6, 3));
+ var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --json");
+
+ Assert.Equal(2, exitCode);
+ var doc = JsonDocument.Parse(stdout); // throws if not valid JSON
+ Assert.True(doc.RootElement.TryGetProperty("applied", out _), "JSON must have 'applied' field");
+ Assert.True(doc.RootElement.TryGetProperty("skipped", out _), "JSON must have 'skipped' field");
+ Assert.True(doc.RootElement.TryGetProperty("manual", out _), "JSON must have 'manual' field");
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Happy-03 — state with 0 manual entries exits 0.
+ ///
+ [Scenario("Happy-03: state with zero manual entries exits 0")]
+ [Fact]
+ public async Task Status_NoManual_ExitsZero()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var schemaPath = await WriteStateAsync(dir, BuildState(10, 2, 0));
+ var (exitCode, _, _) = await InvokeAsync($"status --schema \"{schemaPath}\"");
+
+ Assert.Equal(0, exitCode);
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Happy-04 — state with manual entries exits 2.
+ ///
+ [Scenario("Happy-04: state with manual entries exits 2")]
+ [Fact]
+ public async Task Status_ManualPresent_ExitsTwo()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var schemaPath = await WriteStateAsync(dir, BuildState(5, 1, 3));
+ var (exitCode, _, _) = await InvokeAsync($"status --schema \"{schemaPath}\"");
+
+ Assert.Equal(2, exitCode);
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Happy-05 — --verbose includes per-rule detail lines (rule IDs from applied/skipped/manual).
+ ///
+ [Scenario("Happy-05: --verbose includes per-rule detail lines")]
+ [Fact]
+ public async Task Status_Verbose_IncludesRuleDetails()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var schemaPath = await WriteStateAsync(dir, BuildState(3, 1, 1));
+ var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --verbose");
+
+ Assert.Equal(2, exitCode);
+ Assert.Contains("R-001", stdout); // applied rule
+ Assert.Contains("S-001", stdout); // skipped rule
+ Assert.Contains("M-001", stdout); // manual rule
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // Group: sad
+ // ════════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// SCENARIO: Sad-01 — no state file exits 0 with friendly message.
+ ///
+ [Scenario("Sad-01: missing state file exits 0 with friendly message")]
+ [Fact]
+ public async Task Status_NoState_PrintsFriendlyMessage()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var schemaPath = await WriteSchemaAsync(dir); // schema exists, but no .state.json
+ var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\"");
+
+ Assert.Equal(0, exitCode);
+ Assert.Contains("No migration runs recorded", stdout, StringComparison.OrdinalIgnoreCase);
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Sad-02 — corrupt state file exits 1.
+ ///
+ [Scenario("Sad-02: corrupt state file exits 1")]
+ [Fact]
+ public async Task Status_CorruptState_Fails()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var schemaPath = await WriteSchemaAsync(dir);
+ var statePath = MigrationStateStore.GetStatePath(schemaPath);
+ await File.WriteAllTextAsync(statePath, "{ this is not valid json !!!");
+
+ var (exitCode, stdout, stderr) = await InvokeAsync($"status --schema \"{schemaPath}\"");
+
+ Assert.Equal(1, exitCode);
+ // Must mention corrupt/invalid somewhere
+ var combined = stdout + stderr;
+ Assert.True(
+ combined.Contains("corrupt", StringComparison.OrdinalIgnoreCase) ||
+ combined.Contains("invalid", StringComparison.OrdinalIgnoreCase),
+ $"Expected 'corrupt' or 'invalid' in output. Actual output: {combined}");
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Sad-03 — schema file does not exist exits 1.
+ ///
+ [Scenario("Sad-03: non-existent schema path exits 1")]
+ [Fact]
+ public async Task Status_MissingSchema_Fails()
+ {
+ var (exitCode, _, _) = await InvokeAsync("status --schema does-not-exist-schema.json");
+ Assert.Equal(1, exitCode);
+ }
+
+ ///
+ /// SCENARIO: Sad-04 — missing --schema flag exits with non-zero (parse error).
+ ///
+ [Scenario("Sad-04: missing --schema flag causes parse error exit")]
+ [Fact]
+ public async Task Status_NoSchemaFlag_Fails()
+ {
+ var (exitCode, _, _) = await InvokeAsync("status");
+ Assert.NotEqual(0, exitCode);
+ }
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // Group: edge
+ // ════════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// SCENARIO: Edge-01 — schema hash mismatch warns user.
+ ///
+ [Scenario("Edge-01: schema hash mismatch produces schema-changed warning")]
+ [Fact]
+ public async Task Status_SchemaHashMismatch_Warns()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ // Write a state with a hash that won't match the actual schema content
+ var state = BuildState(10, 2, 0);
+ state.SchemaHash = "sha256:deadbeef00000000000000000000000000000000000000000000000000000000";
+ var schemaPath = await WriteStateAsync(dir, state);
+
+ var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\"");
+
+ Assert.Equal(0, exitCode); // no manual
+ Assert.True(
+ stdout.Contains("schema has changed", StringComparison.OrdinalIgnoreCase) ||
+ stdout.Contains("schema changed", StringComparison.OrdinalIgnoreCase),
+ $"Expected schema-change warning in output. Actual: {stdout}");
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Edge-02 — empty state (0 applied, 0 skipped, 0 manual) exits 0.
+ ///
+ [Scenario("Edge-02: empty state (all zero counts) exits 0")]
+ [Fact]
+ public async Task Status_EmptyState_ExitsZero()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var schemaPath = await WriteStateAsync(dir, BuildState(0, 0, 0));
+ var (exitCode, _, _) = await InvokeAsync($"status --schema \"{schemaPath}\"");
+
+ Assert.Equal(0, exitCode);
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Edge-03 — manual rule with empty MatchedFiles shows placeholder.
+ ///
+ [Scenario("Edge-03: manual rule with empty MatchedFiles shows placeholder text")]
+ [Fact]
+ public async Task Status_ManualWithEmptyMatched_PrintsPlaceholder()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var state = new MigrationState
+ {
+ Schema = "schema.wrapgod-migration.json",
+ SchemaHash = "sha256:aabbccdd",
+ StartedAt = DateTimeOffset.UtcNow,
+ LastRunAt = DateTimeOffset.UtcNow,
+ Summary = new MigrationStateSummary { TotalRules = 1, Applied = 0, Skipped = 0, Manual = 1 },
+ Applied = [],
+ Skipped = [],
+ Manual = [new ManualRewrite("M-001", "Manual action with no files", [])],
+ };
+
+ var schemaPath = await WriteStateAsync(dir, state);
+ var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --verbose");
+
+ Assert.Equal(2, exitCode);
+ Assert.Contains("no files matched", stdout, StringComparison.OrdinalIgnoreCase);
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Edge-04 — --json with no state file outputs sentinel JSON.
+ ///
+ [Scenario("Edge-04: --json with no state file outputs sentinel JSON { status: no-runs-recorded }")]
+ [Fact]
+ public async Task Status_Json_NoState_OutputsSentinel()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var schemaPath = await WriteSchemaAsync(dir);
+ var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --json");
+
+ Assert.Equal(0, exitCode);
+ var doc = JsonDocument.Parse(stdout);
+ Assert.True(
+ doc.RootElement.TryGetProperty("status", out var statusEl) &&
+ statusEl.GetString() == "no-runs-recorded",
+ $"Expected JSON {{ \"status\": \"no-runs-recorded\" }}. Actual: {stdout}");
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Edge-05 — state with synthetic <state> SkippedRewrite highlights recovery.
+ ///
+ [Scenario("Edge-05: state with synthetic SkippedRewrite highlights state recovery")]
+ [Fact]
+ public async Task Status_SyntheticStateSkippedRewrite_HighlightsRecovery()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var state = new MigrationState
+ {
+ Schema = "schema.wrapgod-migration.json",
+ SchemaHash = "sha256:aabbccdd",
+ StartedAt = DateTimeOffset.UtcNow,
+ LastRunAt = DateTimeOffset.UtcNow,
+ Summary = new MigrationStateSummary { TotalRules = 2, Applied = 1, Skipped = 1, Manual = 0 },
+ Applied = [new AppliedRewrite("R-001", "src/File.cs", 5, "OldType", "NewType")],
+ Skipped = [new SkippedRewrite("", "", 0, "Previous state was corrupt; recovery run from scratch.")],
+ Manual = [],
+ };
+
+ var schemaPath = await WriteStateAsync(dir, state);
+ var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\"");
+
+ Assert.Equal(0, exitCode); // no manual
+ Assert.True(
+ stdout.Contains("state recovery", StringComparison.OrdinalIgnoreCase) ||
+ stdout.Contains("recovery", StringComparison.OrdinalIgnoreCase) ||
+ stdout.Contains("", StringComparison.OrdinalIgnoreCase),
+ $"Expected recovery mention in output. Actual: {stdout}");
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Edge-06 — help text lists expected flags.
+ ///
+ [Scenario("Edge-06: help text lists --schema, --json, --verbose, --project-dir flags")]
+ [Fact]
+ public async Task Status_Help_ListsAllFlags()
+ {
+ var (exitCode, stdout, _) = await InvokeAsync("status --help");
+ Assert.Equal(0, exitCode);
+ Assert.Contains("--schema", stdout);
+ Assert.Contains("--json", stdout);
+ Assert.Contains("--verbose", stdout);
+ Assert.Contains("--project-dir", stdout);
+ }
+
+ ///
+ /// SCENARIO: Edge-07 — corrupt state archived to .bak is reported.
+ /// MigrationStateStore.Load archives corrupt state to a .bak file.
+ /// The status command should detect wasCorrupt==true and report it.
+ ///
+ [Scenario("Edge-07: corrupt state archived to .bak — exit 1 with backup/corrupt mention")]
+ [Fact]
+ public async Task Status_CorruptState_MentionsBackup()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var schemaPath = await WriteSchemaAsync(dir);
+ var statePath = MigrationStateStore.GetStatePath(schemaPath);
+ await File.WriteAllTextAsync(statePath, "{ CORRUPT JSON ");
+
+ var (exitCode, stdout, stderr) = await InvokeAsync($"status --schema \"{schemaPath}\"");
+
+ Assert.Equal(1, exitCode);
+ var combined = stdout + stderr;
+ Assert.True(
+ combined.Contains(".bak", StringComparison.OrdinalIgnoreCase) ||
+ combined.Contains("backup", StringComparison.OrdinalIgnoreCase) ||
+ combined.Contains("archived", StringComparison.OrdinalIgnoreCase) ||
+ combined.Contains("corrupt", StringComparison.OrdinalIgnoreCase),
+ $"Expected .bak/backup/corrupt mention. Actual: {combined}");
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Edge-08 — --project-dir resolves relative --schema.
+ ///
+ [Scenario("Edge-08: --project-dir resolves relative --schema path")]
+ [Fact]
+ public async Task Status_ProjectDir_ResolvesRelativeSchema()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var schemaPath = await WriteStateAsync(dir, BuildState(5, 1, 0));
+ var schemaFileName = Path.GetFileName(schemaPath);
+
+ var (exitCode, stdout, _) = await InvokeAsync(
+ $"status --schema \"{schemaFileName}\" --project-dir \"{dir}\"");
+
+ Assert.Equal(0, exitCode); // no manual
+ Assert.Contains("5", stdout);
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Edge-09 — schema with zero rules: human output shows "n/a", JSON
+ /// has progressPct: null. Per plan §200.c table row "Status_ZeroRules_HandlesNa".
+ ///
+ [Scenario("Edge-09: schema with zero rules -> human output 'n/a', JSON progressPct null")]
+ [Fact]
+ public async Task Status_ZeroRules_HandlesNa()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ // Default schema fixture in WriteSchemaAsync already has "rules": [] (0 rules)
+ // and the BuildState helper here uses (0,0,0) to match.
+ var schemaPath = await WriteStateAsync(dir, BuildState(0, 0, 0));
+
+ // Human-readable mode → "n/a"
+ var (humanExit, humanStdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\"");
+ Assert.Equal(0, humanExit);
+ Assert.Contains("n/a", humanStdout, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("0 / 0", humanStdout);
+
+ // JSON mode → progressPct is null
+ var (jsonExit, jsonStdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --json");
+ Assert.Equal(0, jsonExit);
+ using var doc = JsonDocument.Parse(jsonStdout);
+ Assert.True(doc.RootElement.TryGetProperty("progressPct", out var pctEl),
+ "JSON must have 'progressPct' field");
+ Assert.Equal(JsonValueKind.Null, pctEl.ValueKind);
+
+ // Schema metadata is included
+ Assert.True(doc.RootElement.TryGetProperty("library", out var libEl),
+ "JSON must have 'library' field");
+ Assert.Equal("TestLib", libEl.GetString());
+ Assert.True(doc.RootElement.TryGetProperty("from", out var fromEl),
+ "JSON must have 'from' field");
+ Assert.Equal("1.0.0", fromEl.GetString());
+ Assert.True(doc.RootElement.TryGetProperty("to", out var toEl),
+ "JSON must have 'to' field");
+ Assert.Equal("2.0.0", toEl.GetString());
+ }
+ finally { SafeDelete(dir); }
+ }
+
+ ///
+ /// SCENARIO: Edge-10 — progressPct + ratio line populated when totalRules > 0.
+ /// Belt-and-suspenders for plan §200.b Happy-01 + Happy-02. Verifies the
+ /// "N / M rules applied (P%)" human-mode line and the JSON progressPct numeric value.
+ ///
+ [Scenario("Edge-10: progressPct + ratio line — totalRules>0, applied 3.1.1", humanStdout);
+
+ // JSON mode
+ var (jsonExit, jsonStdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --json");
+ Assert.Equal(0, jsonExit);
+ using var doc = JsonDocument.Parse(jsonStdout);
+ Assert.True(doc.RootElement.TryGetProperty("progressPct", out var pctEl));
+ Assert.Equal(JsonValueKind.Number, pctEl.ValueKind);
+ Assert.Equal(0.6, pctEl.GetDouble(), precision: 5);
+ Assert.Equal("Serilog", doc.RootElement.GetProperty("library").GetString());
+ Assert.Equal("2.12.0", doc.RootElement.GetProperty("from").GetString());
+ Assert.Equal("3.1.1", doc.RootElement.GetProperty("to").GetString());
+ Assert.Equal(5, doc.RootElement.GetProperty("totalRules").GetInt32());
+ Assert.Equal(3, doc.RootElement.GetProperty("appliedRules").GetInt32());
+ }
+ finally { SafeDelete(dir); }
+ }
+}
diff --git a/docs/guide/cli.md b/docs/guide/cli.md
index 785a44d..e615874 100644
--- a/docs/guide/cli.md
+++ b/docs/guide/cli.md
@@ -20,6 +20,7 @@ wrap-god [command] [options]
- [`explain`](#explain) -- Show traceability info for a type or member
- [`migrate init`](#migrate-init) -- Analyze a project and generate a migration plan
- [`migrate generate`](#migrate-generate) -- Generate a draft migration schema from two library versions
+- [`migrate status`](#migrate-status) -- Report migration progress from the state file
- [`ci bootstrap`](#ci-bootstrap) -- Generate recommended CI workflow files
- [`ci parity`](#ci-parity) -- Compare CI config against the recommended baseline
@@ -577,6 +578,134 @@ Output: mudblazor.6.0.0-to-7.0.0.wrapgod-migration.json
---
+### `migrate status`
+
+**Synopsis:** Report migration progress from the state file without running any migration.
+
+**Usage:**
+```
+wrap-god migrate status --schema [options]
+```
+
+**Options:**
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `--schema`, `-s` | Path to the migration schema JSON file. The state file is the sibling `.state.json`. | **Required** |
+| `--project-dir`, `-p` | Project directory used to resolve a relative `--schema` path | Current directory |
+| `--json` | Emit output as JSON instead of human-readable text | `false` |
+| `--verbose`, `-v` | Include per-rule details and per-file applied lists in human-readable mode | `false` |
+
+**Exit codes:**
+
+| Code | Meaning |
+|------|---------|
+| `0` | State file exists and has no manual-confidence entries (or state file is missing) |
+| `1` | Schema file not found, or state file is corrupt and could not be parsed |
+| `2` | State has manual-confidence entries present — human review required |
+
+**Behavior:**
+
+1. Resolves `.state.json` as the sibling of `--schema`.
+2. If the state file does not exist, prints a friendly message and exits 0 — this is not an error.
+3. If the state file is corrupt, the `MigrationStateStore` archives it to `.state.json.bak` and exits 1 with a message referencing the backup path.
+4. Computes the current schema hash and warns if the schema has changed since the last `apply` run.
+5. Highlights any `` synthetic `SkippedRewrite` entries, which indicate a corruption-recovery run.
+6. Exits 2 when any `Manual`-confidence entries are present (signal that human work remains).
+
+**Examples:**
+
+```bash
+# Human-readable status
+wrap-god migrate status --schema mudblazor.6.0-to-7.0.wrapgod-migration.json
+
+# With project directory (for relative schema path)
+wrap-god migrate status \
+ --schema mudblazor.6.0-to-7.0.wrapgod-migration.json \
+ --project-dir ./src
+
+# JSON output (useful for tooling / CI)
+wrap-god migrate status \
+ --schema mudblazor.6.0-to-7.0.wrapgod-migration.json \
+ --json
+
+# Verbose: per-rule applied breakdown, matched files for manual rules
+wrap-god migrate status \
+ --schema mudblazor.6.0-to-7.0.wrapgod-migration.json \
+ --verbose
+```
+
+**Output (default human-readable):**
+
+```
+WrapGod migrate status
+----------------------------------------
+Migration: MudBlazor 6.0.0 -> 7.0.0
+Schema: mudblazor.6.0-to-7.0.wrapgod-migration.json
+Started: 2026-04-01 12:00:00 UTC
+Last run: 2026-04-02 09:14:33 UTC
+
+Progress: 38 / 47 rules applied (81%)
+
+Schema hash: sha256:ab12cd34... (matches current schema)
+
+Applied: 38 (across 22 file(s))
+Skipped: 6
+Manual: 3
+
+Skipped rules:
+ Ambiguous: two overloads of Show() in scope: 4
+ Conflict with existing declaration: 2
+
+Manual rules (require human intervention):
+ MUD-003 Parameters restructured — requires manual mapping
+ MUD-007 RemoveMember (obj.Deprecated): review and remove call sites
+ MUD-012 Namespace moved — update using directives
+```
+
+**Output (`--json`):**
+
+```json
+{
+ "library": "MudBlazor",
+ "from": "6.0.0",
+ "to": "7.0.0",
+ "schema": "mudblazor.6.0-to-7.0.wrapgod-migration.json",
+ "schemaHash": "sha256:ab12cd34...",
+ "schemaChanged": false,
+ "startedAt": "2026-04-01T12:00:00+00:00",
+ "lastRunAt": "2026-04-02T09:14:33+00:00",
+ "totalRules": 47,
+ "appliedRules": 38,
+ "progressPct": 0.81,
+ "summary": {
+ "total": 47,
+ "applied": 38,
+ "skipped": 6,
+ "manual": 3
+ },
+ "applied": [
+ { "ruleId": "MUD-001", "fileCount": 8 },
+ { "ruleId": "MUD-002", "fileCount": 4 }
+ ],
+ "skipped": [
+ { "reason": "Ambiguous: two overloads of Show() in scope", "count": 4 }
+ ],
+ "manual": [
+ {
+ "ruleId": "MUD-003",
+ "note": "Parameters restructured — requires manual mapping",
+ "matchedFiles": ["src/Dialogs/ConfirmDialog.cs", "src/Dialogs/EditDialog.cs"]
+ }
+ ],
+ "stateRecoveryOccurred": false
+}
+```
+
+> **See also:** [Migration state file format](../migration/state.md)
+
+---
+
### `ci bootstrap`
**Synopsis:** Generate recommended CI workflow files for a WrapGod project.
diff --git a/docs/migration/state.md b/docs/migration/state.md
index f1d8063..0d02d8f 100644
--- a/docs/migration/state.md
+++ b/docs/migration/state.md
@@ -189,3 +189,4 @@ representative state file.
- [Migration Engine](engine.md) — `MigrationEngine`, `IRuleRewriter`, `RewriteContext`
- [Migration Schema](schema.md) — schema model and rule kinds
+- [`migrate status` CLI command](../guide/cli.md#migrate-status) — read-only progress report from the state file