diff --git a/FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs b/FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs
new file mode 100644
index 0000000..2369fe4
--- /dev/null
+++ b/FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs
@@ -0,0 +1,385 @@
+#region Usings declarations
+
+using System.Text.Json;
+
+using FirstClassErrors.GenDoc.Versioning;
+
+using JetBrains.Annotations;
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.Cli.UnitTests;
+
+///
+/// Drives the catalog diff command end to end through the real configuration store, baseline store and
+/// differ, but with a fake snapshot source (no process spawned) and a string writer for standard output. This
+/// exercises the command wiring — option validation, exit-code policy, report routing, source-vs-`--against`
+/// selection and cancellation — without touching the console or the build pipeline.
+///
+[TestSubject(typeof(CatalogDiffCommand))]
+public sealed class CatalogDiffCommandEndToEndTests {
+
+ [Fact(DisplayName = "A missing baseline is an execution error (exit 1), not drift.")]
+ public void AMissingBaselineIsAnExecutionError() {
+ using TempDir dir = new();
+ RecordingSnapshotSource source = new(Snapshot("A"));
+ (int exit, string _, RecordingLogger logger) = RunDiff(source, new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = dir.File("errors-baseline.json")
+ });
+
+ Check.That(exit).IsEqualTo(1);
+ Check.That(source.WasInvoked).IsFalse();
+ Check.That(string.Join("\n", logger.Errors)).Contains("No baseline");
+ }
+
+ [Fact(DisplayName = "No change against the baseline reports nothing and exits 0.")]
+ public void NoChangeExitsZero() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A"));
+
+ (int exit, string output, RecordingLogger _) = RunDiff(new RecordingSnapshotSource(Snapshot("A")), new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline
+ });
+
+ Check.That(exit).IsEqualTo(0);
+ Check.That(output).Contains("No catalog changes.");
+ }
+
+ [Fact(DisplayName = "A removed code is a breaking change: exit 2 with a report.")]
+ public void ARemovedCodeExitsTwo() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A", "B"));
+
+ (int exit, string output, RecordingLogger logger) = RunDiff(new RecordingSnapshotSource(Snapshot("A")), new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline
+ });
+
+ Check.That(exit).IsEqualTo(2);
+ Check.That(output).Contains("[removed] B");
+ Check.That(string.Join("\n", logger.Errors)).Contains("1 breaking change(s)");
+ }
+
+ [Fact(DisplayName = "--fail-on any turns a compatible-only change into a failure (exit 2).")]
+ public void FailOnAnyFailsOnCompatibleChange() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A"));
+
+ (int exit, string _, RecordingLogger __) = RunDiff(new RecordingSnapshotSource(Snapshot("A", "B")), new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline,
+ FailOn = "any"
+ });
+
+ Check.That(exit).IsEqualTo(2);
+ }
+
+ [Fact(DisplayName = "The default --fail-on breaking passes a compatible-only change (exit 0).")]
+ public void DefaultFailOnBreakingPassesCompatibleChange() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A"));
+
+ (int exit, string output, RecordingLogger _) = RunDiff(new RecordingSnapshotSource(Snapshot("A", "B")), new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline
+ });
+
+ Check.That(exit).IsEqualTo(0);
+ Check.That(output).Contains("[added] B");
+ }
+
+ [Fact(DisplayName = "--fail-on none never fails, even on a breaking change (exit 0).")]
+ public void FailOnNoneNeverFails() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A", "B"));
+
+ (int exit, string _, RecordingLogger __) = RunDiff(new RecordingSnapshotSource(Snapshot("A")), new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline,
+ FailOn = "none"
+ });
+
+ Check.That(exit).IsEqualTo(0);
+ }
+
+ [Fact(DisplayName = "--report json writes a machine-readable document.")]
+ public void ReportJsonWritesJson() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A", "B"));
+
+ (int exit, string output, RecordingLogger _) = RunDiff(new RecordingSnapshotSource(Snapshot("A")), new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline,
+ Report = "json"
+ });
+
+ Check.That(exit).IsEqualTo(2);
+ using JsonDocument parsed = JsonDocument.Parse(output);
+ Check.That(parsed.RootElement.GetProperty("hasBreakingChanges").GetBoolean()).IsTrue();
+ }
+
+ [Fact(DisplayName = "--report markdown writes a PR-comment-ready document.")]
+ public void ReportMarkdownWritesMarkdown() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A", "B"));
+
+ (int exit, string output, RecordingLogger _) = RunDiff(new RecordingSnapshotSource(Snapshot("A")), new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline,
+ Report = "md"
+ });
+
+ Check.That(exit).IsEqualTo(2);
+ Check.That(output).StartsWith("## Error catalog changes");
+ }
+
+ [Fact(DisplayName = "An invalid --fail-on fails fast (exit 1) before any extraction.")]
+ public void InvalidFailOnFailsFast() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A"));
+ RecordingSnapshotSource source = new(Snapshot("A"));
+
+ (int exit, string _, RecordingLogger logger) = RunDiff(source, new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline,
+ FailOn = "bogus"
+ });
+
+ Check.That(exit).IsEqualTo(1);
+ Check.That(source.WasInvoked).IsFalse();
+ Check.That(string.Join("\n", logger.Errors)).Contains("--fail-on");
+ }
+
+ [Fact(DisplayName = "--against compares a snapshot file instead of extracting from the source.")]
+ public void AgainstComparesAFileNotTheSource() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ string against = dir.File("current.json");
+ BaselineStore.Save(baseline, Snapshot("A"));
+ BaselineStore.Save(against, Snapshot("A"));
+ RecordingSnapshotSource source = new(Snapshot("A"));
+
+ (int exit, string output, RecordingLogger _) = RunDiff(source, new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline,
+ AgainstPath = against
+ });
+
+ Check.That(exit).IsEqualTo(0);
+ Check.That(output).Contains("No catalog changes.");
+ Check.That(source.WasInvoked).IsFalse();
+ }
+
+ [Fact(DisplayName = "Cancellation during extraction exits 130.")]
+ public void CancellationExitsOneThirty() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A"));
+
+ (int exit, string _, RecordingLogger __) = RunDiff(new CancellingSnapshotSource(), new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline
+ });
+
+ Check.That(exit).IsEqualTo(130);
+ }
+
+ [Fact(DisplayName = "An extraction failure is reported tersely (exit 1).")]
+ public void ExtractionFailureExitsOne() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A"));
+
+ (int exit, string _, RecordingLogger logger) = RunDiff(new FailingSnapshotSource(new InvalidOperationException("boom")), new CatalogDiffSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline
+ });
+
+ Check.That(exit).IsEqualTo(1);
+ Check.That(string.Join("\n", logger.Errors)).Contains("boom");
+ }
+
+ private static (int Exit, string Output, RecordingLogger Logger) RunDiff(ICatalogSnapshotSource source, CatalogDiffSettings settings) {
+ StringWriter output = new();
+ RecordingLogger logger = new();
+ int exit = new CatalogDiffCommand(source, _ => logger, output).Run(settings, CancellationToken.None);
+
+ return (exit, output.ToString(), logger);
+ }
+
+ private static CatalogSnapshot Snapshot(params string[] codes) {
+ return new CatalogSnapshot {
+ Errors = codes.Select(code => new CatalogSnapshotEntry { Code = code, Title = code }).ToList()
+ };
+ }
+
+}
+
+///
+/// Drives the catalog update command end to end (real configuration and baseline stores, fake snapshot
+/// source, string writer) — baseline creation, idempotency, refresh with a change summary, self-healing over an
+/// unreadable baseline, config-relative baseline resolution, and cancellation.
+///
+[TestSubject(typeof(CatalogUpdateCommand))]
+public sealed class CatalogUpdateCommandEndToEndTests {
+
+ [Fact(DisplayName = "A first run creates the baseline file and reports it.")]
+ public void FirstRunCreatesTheBaseline() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+
+ (int exit, string output, RecordingLogger _) = RunUpdate(new RecordingSnapshotSource(Snapshot("A", "B")), new CatalogUpdateSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline
+ });
+
+ Check.That(exit).IsEqualTo(0);
+ Check.That(output).Contains("Baseline created");
+ Check.That(File.Exists(baseline)).IsTrue();
+ }
+
+ [Fact(DisplayName = "A second run with the same catalog reports the baseline is already up to date.")]
+ public void SecondRunIsUpToDate() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A"));
+
+ (int exit, string output, RecordingLogger _) = RunUpdate(new RecordingSnapshotSource(Snapshot("A")), new CatalogUpdateSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline
+ });
+
+ Check.That(exit).IsEqualTo(0);
+ Check.That(output).Contains("already up to date");
+ }
+
+ [Fact(DisplayName = "A changed catalog rewrites the baseline and summarizes the accepted change.")]
+ public void ChangedCatalogRewritesAndSummarizes() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ BaselineStore.Save(baseline, Snapshot("A", "B"));
+
+ (int exit, string output, RecordingLogger _) = RunUpdate(new RecordingSnapshotSource(Snapshot("A")), new CatalogUpdateSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline
+ });
+
+ Check.That(exit).IsEqualTo(0);
+ Check.That(output).Contains("1 breaking");
+ Check.That(File.ReadAllText(baseline)).IsEqualTo(CatalogSnapshotSerializer.Serialize(Snapshot("A")));
+ }
+
+ [Fact(DisplayName = "An unreadable existing baseline is rewritten with a warning, not left broken.")]
+ public void UnreadableBaselineIsRewritten() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ File.WriteAllText(baseline, "this is not a valid snapshot");
+
+ (int exit, string output, RecordingLogger logger) = RunUpdate(new RecordingSnapshotSource(Snapshot("A")), new CatalogUpdateSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline
+ });
+
+ Check.That(exit).IsEqualTo(0);
+ Check.That(string.Join("\n", logger.Warnings)).Contains("could not be read");
+ Check.That(output).Contains("rewritten");
+ // The file is now a valid, canonical snapshot.
+ Check.That(File.ReadAllText(baseline)).IsEqualTo(CatalogSnapshotSerializer.Serialize(Snapshot("A")));
+ }
+
+ [Fact(DisplayName = "A configured baseline resolves relative to the configuration file, not the current directory.")]
+ public void ConfiguredBaselineResolvesRelativeToConfig() {
+ using TempDir dir = new();
+ string configPath = dir.File("fce.json");
+ File.WriteAllText(configPath, """{ "baseline": "errors-baseline.json" }""");
+
+ (int exit, string _, RecordingLogger __) = RunUpdate(new RecordingSnapshotSource(Snapshot("A")), new CatalogUpdateSettings {
+ ConfigPath = configPath
+ });
+
+ Check.That(exit).IsEqualTo(0);
+ Check.That(File.Exists(dir.File("errors-baseline.json"))).IsTrue();
+ Check.That(File.Exists(Path.Combine(Directory.GetCurrentDirectory(), "errors-baseline.json"))).IsFalse();
+ }
+
+ [Fact(DisplayName = "A baseline written by a newer schema is refused (exit 1), never silently downgraded.")]
+ public void NewerSchemaBaselineIsRefused() {
+ using TempDir dir = new();
+ string baseline = dir.File("errors-baseline.json");
+ File.WriteAllText(baseline, """{ "schema": 999, "errors": [] }""");
+ string before = File.ReadAllText(baseline);
+
+ (int exit, string _, RecordingLogger logger) = RunUpdate(new RecordingSnapshotSource(Snapshot("A")), new CatalogUpdateSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = baseline
+ });
+
+ // Refused, not healed: exit 1, the file is left untouched, and the user is told to upgrade the tool.
+ Check.That(exit).IsEqualTo(1);
+ Check.That(File.ReadAllText(baseline)).IsEqualTo(before);
+ Check.That(string.Join("\n", logger.Errors)).Contains("Update the tool");
+ }
+
+ [Fact(DisplayName = "Cancellation during extraction exits 130.")]
+ public void CancellationExitsOneThirty() {
+ using TempDir dir = new();
+
+ (int exit, string _, RecordingLogger __) = RunUpdate(new CancellingSnapshotSource(), new CatalogUpdateSettings {
+ ConfigPath = CliTestHelpers.NonExistentConfigPath(),
+ BaselinePath = dir.File("errors-baseline.json")
+ });
+
+ Check.That(exit).IsEqualTo(130);
+ }
+
+ private static (int Exit, string Output, RecordingLogger Logger) RunUpdate(ICatalogSnapshotSource source, CatalogUpdateSettings settings) {
+ StringWriter output = new();
+ RecordingLogger logger = new();
+ int exit = new CatalogUpdateCommand(source, _ => logger, output).Run(settings, CancellationToken.None);
+
+ return (exit, output.ToString(), logger);
+ }
+
+ private static CatalogSnapshot Snapshot(params string[] codes) {
+ return new CatalogSnapshot {
+ Errors = codes.Select(code => new CatalogSnapshotEntry { Code = code, Title = code }).ToList()
+ };
+ }
+
+}
+
+/// A throwaway temp directory that cleans itself up, for tests that touch real baseline files.
+internal sealed class TempDir : IDisposable {
+
+ private readonly string _path;
+
+ public TempDir() {
+ _path = Path.Combine(Path.GetTempPath(), $"fce-catalog-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_path);
+ }
+
+ public string File(string name) {
+ return Path.Combine(_path, name);
+ }
+
+ public void Dispose() {
+ try {
+ Directory.Delete(_path, recursive: true);
+ } catch (IOException) {
+ // Best-effort cleanup; a leaked temp directory must never fail a test.
+ }
+ }
+
+}
diff --git a/FirstClassErrors.Cli.UnitTests/GenerateCommandEndToEndTests.cs b/FirstClassErrors.Cli.UnitTests/GenerateCommandEndToEndTests.cs
index e34fa99..d08b97f 100644
--- a/FirstClassErrors.Cli.UnitTests/GenerateCommandEndToEndTests.cs
+++ b/FirstClassErrors.Cli.UnitTests/GenerateCommandEndToEndTests.cs
@@ -195,6 +195,36 @@ public void AFailingGenerationIsReportedWithFullDetailOnDebug() {
Check.That(logger.Debugs).HasSize(1);
}
+ [Fact(DisplayName = "A configured snapshot path is written relative to the configuration file, not the current directory.")]
+ public void AConfiguredSnapshotPathResolvesRelativeToTheConfigFile() {
+ // Setup: a nested fce.json declaring a RELATIVE snapshot path. The configured path must resolve against that
+ // file's directory (like renderer references and the catalog baseline), not the process working directory.
+ string directory = Path.Combine(Path.GetTempPath(), $"fce-snapshot-cfg-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(directory);
+ try {
+ string configPath = Path.Combine(directory, "fce.json");
+ File.WriteAllText(configPath, """{ "snapshot": "errors-baseline.json" }""");
+
+ RecordingGenerator generator = new(Sample());
+ GenerateCommand command = new(generator, new RecordingOutputSink(), _ => new RecordingLogger());
+ GenerateSettings settings = new() {
+ ConfigPath = configPath,
+ AssemblyPaths = ["a.dll"],
+ Format = "json"
+ };
+
+ // Exercise
+ int exitCode = command.Run(settings, CancellationToken.None);
+
+ // Verify: the snapshot lands beside fce.json, never in the process working directory.
+ Check.That(exitCode).IsEqualTo(0);
+ Check.That(File.Exists(Path.Combine(directory, "errors-baseline.json"))).IsTrue();
+ Check.That(File.Exists(Path.Combine(Directory.GetCurrentDirectory(), "errors-baseline.json"))).IsFalse();
+ } finally {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
#region Helpers
private static ErrorDocumentation Sample() {
diff --git a/FirstClassErrors.Cli.UnitTests/TestDoubles.cs b/FirstClassErrors.Cli.UnitTests/TestDoubles.cs
index 2d01001..4846d92 100644
--- a/FirstClassErrors.Cli.UnitTests/TestDoubles.cs
+++ b/FirstClassErrors.Cli.UnitTests/TestDoubles.cs
@@ -1,6 +1,7 @@
#region Usings declarations
using FirstClassErrors.GenDoc;
+using FirstClassErrors.GenDoc.Versioning;
#endregion
@@ -103,6 +104,56 @@ public IEnumerable GetErrorDocumentationFromAssemblies(IRead
}
+///
+/// A catalog snapshot source that returns a fixed snapshot and records how it was called (the settings and the
+/// cancellation token), so catalog-command tests can assert wiring without spawning a real extraction.
+///
+internal sealed class RecordingSnapshotSource : ICatalogSnapshotSource {
+
+ private readonly CatalogSnapshot _snapshot;
+
+ public RecordingSnapshotSource(CatalogSnapshot snapshot) {
+ _snapshot = snapshot;
+ }
+
+ public bool WasInvoked { get; private set; }
+ public CatalogSettings? Settings { get; private set; }
+ public CancellationToken CancellationToken { get; private set; }
+
+ public CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration configuration, IGenerationLogger logger, CancellationToken cancellationToken) {
+ WasInvoked = true;
+ Settings = settings;
+ CancellationToken = cancellationToken;
+
+ return _snapshot;
+ }
+
+}
+
+/// A snapshot source that throws an arbitrary (non-cancellation) failure, to exercise the error path.
+internal sealed class FailingSnapshotSource : ICatalogSnapshotSource {
+
+ private readonly Exception _failure;
+
+ public FailingSnapshotSource(Exception failure) {
+ _failure = failure;
+ }
+
+ public CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration configuration, IGenerationLogger logger, CancellationToken cancellationToken) {
+ throw _failure;
+ }
+
+}
+
+/// A snapshot source that always reports the run was cancelled, to exercise cancellation handling.
+internal sealed class CancellingSnapshotSource : ICatalogSnapshotSource {
+
+ public CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration configuration, IGenerationLogger logger, CancellationToken cancellationToken) {
+ throw new OperationCanceledException();
+ }
+
+}
+
/// A logger that records each message per level, so tests can assert what the command reported.
internal sealed class RecordingLogger : IGenerationLogger {
diff --git a/FirstClassErrors.Cli/BaselineStore.cs b/FirstClassErrors.Cli/BaselineStore.cs
new file mode 100644
index 0000000..8352700
--- /dev/null
+++ b/FirstClassErrors.Cli/BaselineStore.cs
@@ -0,0 +1,55 @@
+#region Usings declarations
+
+using FirstClassErrors.GenDoc.Versioning;
+
+#endregion
+
+namespace FirstClassErrors.Cli;
+
+///
+/// Reads and writes the catalog baseline file (errors-baseline.json by default) — the committed snapshot
+/// of the error contract that the catalog commands compare against.
+///
+internal static class BaselineStore {
+
+ #region Statics members declarations
+
+ public const string DefaultFileName = "errors-baseline.json";
+
+ ///
+ /// Resolves the baseline path with the same portability contract as the rest of the configuration: a
+ /// command-line value wins and is resolved against the current directory (the caller's vantage point); a value
+ /// from fce.json is resolved against the configuration file's directory, so a configuration checked in
+ /// under, say, ci/fce.json keeps pointing at ci/errors-baseline.json wherever it is invoked from.
+ /// With neither, in the current directory is used.
+ ///
+ /// The --baseline command-line value, if any.
+ /// The baseline value from the configuration file, if any.
+ /// The directory containing the configuration file, configured paths resolve against it.
+ public static string Resolve(string? optionPath, string? configuredPath, string configDirectory) {
+ return ConfigRelativePath.Resolve(optionPath, configuredPath, configDirectory)
+ ?? Path.GetFullPath(DefaultFileName);
+ }
+
+ /// Determines whether a baseline exists at the given (already resolved) path.
+ public static bool Exists(string path) {
+ return File.Exists(path);
+ }
+
+ /// Loads and validates the baseline at the given path.
+ /// Thrown when the file is not a valid snapshot.
+ public static CatalogSnapshot Load(string path) {
+ return CatalogSnapshotSerializer.Deserialize(File.ReadAllText(path));
+ }
+
+ /// Writes the snapshot to the given path in its canonical form (creating parent directories as needed).
+ public static void Save(string path, CatalogSnapshot snapshot) {
+ string? directory = Path.GetDirectoryName(path);
+ if (string.IsNullOrEmpty(directory) is false) { Directory.CreateDirectory(directory); }
+
+ File.WriteAllText(path, CatalogSnapshotSerializer.Serialize(snapshot));
+ }
+
+ #endregion
+
+}
diff --git a/FirstClassErrors.Cli/CatalogDiffCommand.cs b/FirstClassErrors.Cli/CatalogDiffCommand.cs
new file mode 100644
index 0000000..97bd408
--- /dev/null
+++ b/FirstClassErrors.Cli/CatalogDiffCommand.cs
@@ -0,0 +1,151 @@
+#region Usings declarations
+
+using System.ComponentModel;
+
+using FirstClassErrors.GenDoc;
+using FirstClassErrors.GenDoc.Versioning;
+
+using Spectre.Console.Cli;
+
+#endregion
+
+namespace FirstClassErrors.Cli;
+
+/// Settings for fce catalog diff.
+internal sealed class CatalogDiffSettings : CatalogSettings {
+
+ [CommandOption("--against ")]
+ [Description("Compare the baseline against this snapshot file instead of extracting the catalog from the source.")]
+ public string? AgainstPath { get; set; }
+
+ [CommandOption("--fail-on ")]
+ [Description("Exit with code 2 when changes at or above this impact exist: breaking, any or none. Default: breaking.")]
+ public string? FailOn { get; set; }
+
+ [CommandOption("--report ")]
+ [Description("Report format written to standard output: text, markdown (alias: md) or json. Default: text.")]
+ public string? Report { get; set; }
+
+}
+
+///
+/// Compares the current catalog against the committed baseline and reports every change, classified by impact
+/// (breaking, compatible, informational). The report goes to standard output — so it can be piped or posted as a
+/// pull-request comment — and diagnostics go to standard error.
+///
+///
+/// Exit codes: 0 when no change reaches the --fail-on threshold, 2 when at least one does,
+/// and 1 on an execution error (missing baseline, failed extraction, …); 130 on cancellation. CI
+/// pipelines can therefore distinguish "the contract changed" from "the tool failed".
+///
+internal sealed class CatalogDiffCommand : Command {
+
+ #region Fields
+
+ private readonly ICatalogSnapshotSource _snapshotSource;
+ private readonly Func _loggerFactory;
+ private readonly TextWriter _output;
+
+ #endregion
+
+ #region Constructors & Destructor
+
+ /// Production constructor used by the CLI host: wires the real extraction pipeline, console logger and stdout.
+ public CatalogDiffCommand() : this(
+ new SolutionCatalogSnapshotSource(),
+ verbose => new ConsoleGenerationLogger(verbose),
+ Console.Out) { }
+
+ /// Test seam: injects the collaborators so they can be substituted by fakes.
+ internal CatalogDiffCommand(ICatalogSnapshotSource snapshotSource, Func loggerFactory, TextWriter output) {
+ _snapshotSource = snapshotSource;
+ _loggerFactory = loggerFactory;
+ _output = output;
+ }
+
+ #endregion
+
+ protected override int Execute(CommandContext context, CatalogDiffSettings settings, CancellationToken cancellationToken) {
+ // The command body uses no CommandContext state, so it lives in a context-free seam that tests drive directly.
+ return Run(settings, cancellationToken);
+ }
+
+ internal int Run(CatalogDiffSettings settings, CancellationToken cancellationToken) {
+ IGenerationLogger logger = _loggerFactory(settings.Verbose);
+
+ try {
+ string configPath = ConfigurationStore.Resolve(settings.ConfigPath);
+ CliConfiguration configuration = ConfigurationStore.Load(configPath);
+ string configDir = Path.GetDirectoryName(configPath) ?? Directory.GetCurrentDirectory();
+
+ // Validate the policy and report options before the (expensive) extraction runs, so a typo fails fast.
+ string failOn = (settings.FailOn ?? "breaking").Trim().ToLowerInvariant();
+ if (failOn is not ("breaking" or "any" or "none")) {
+ logger.Error($"Unknown --fail-on value '{settings.FailOn}'. Use breaking, any or none.");
+
+ return 1;
+ }
+
+ string report = NormalizeReport(settings.Report ?? "text");
+ if (report is not ("text" or "markdown" or "json")) {
+ logger.Error($"Unknown --report value '{settings.Report}'. Use text, markdown (alias: md) or json.");
+
+ return 1;
+ }
+
+ string baselinePath = BaselineStore.Resolve(settings.BaselinePath, configuration.Baseline, configDir);
+ if (BaselineStore.Exists(baselinePath) is false) {
+ logger.Error($"No baseline at '{baselinePath}'. Run 'fce catalog update' to create it.");
+
+ return 1;
+ }
+
+ CatalogSnapshot baseline = BaselineStore.Load(baselinePath);
+ CatalogSnapshot current = string.IsNullOrWhiteSpace(settings.AgainstPath)
+ ? _snapshotSource.Extract(settings, configuration, logger, cancellationToken)
+ : BaselineStore.Load(Path.GetFullPath(settings.AgainstPath));
+
+ CatalogDiff diff = CatalogDiffer.Diff(baseline, current);
+
+ _output.Write(report switch {
+ "markdown" => CatalogDiffFormatter.ToMarkdown(diff),
+ "json" => CatalogDiffFormatter.ToJson(diff),
+ _ => CatalogDiffFormatter.ToText(diff)
+ });
+
+ bool violated = failOn switch {
+ "breaking" => diff.HasChangesAtOrAbove(CatalogChangeImpact.Breaking),
+ "any" => diff.IsEmpty is false,
+ _ => false
+ };
+
+ if (violated) {
+ logger.Error(failOn == "breaking"
+ ? $"The catalog has {diff.BreakingChanges.Count} breaking change(s) against the baseline. Fix them, or accept them deliberately with 'fce catalog update'."
+ : $"The catalog has {diff.Changes.Count} change(s) against the baseline. Accept them with 'fce catalog update'.");
+
+ return 2;
+ }
+
+ return 0;
+ } catch (OperationCanceledException) {
+ // Cancellation (Ctrl+C) is an abort, not a failure: report it with the conventional SIGINT exit code.
+ logger.Error("Catalog diff canceled.");
+
+ return 130;
+ } catch (Exception exception) {
+ // Report expected failures (missing solution, worker crash, invalid baseline, …) as a terse line.
+ logger.Error(exception.Message);
+ logger.Debug(exception.ToString());
+
+ return 1;
+ }
+ }
+
+ private static string NormalizeReport(string report) {
+ string normalized = report.Trim().ToLowerInvariant();
+
+ return normalized == "md" ? "markdown" : normalized;
+ }
+
+}
diff --git a/FirstClassErrors.Cli/CatalogSettings.cs b/FirstClassErrors.Cli/CatalogSettings.cs
new file mode 100644
index 0000000..adda047
--- /dev/null
+++ b/FirstClassErrors.Cli/CatalogSettings.cs
@@ -0,0 +1,60 @@
+#region Usings declarations
+
+using System.ComponentModel;
+
+using Spectre.Console.Cli;
+
+#endregion
+
+namespace FirstClassErrors.Cli;
+
+///
+/// Common options for the catalog commands (update, diff): the documentation source and
+/// build options shared with generate, plus the location of the baseline file. Every value is optional:
+/// when omitted, the command falls back to the configuration file (fce.json) and then to a built-in
+/// default.
+///
+///
+/// The catalog commands have no --format, --layout or --language: they operate on the
+/// canonical contract snapshot, which is renderer-independent and always extracted under the en culture so
+/// the baseline does not depend on the machine that produces it.
+///
+internal class CatalogSettings : ConfigScopedSettings {
+
+ [CommandOption("-s|--solution ")]
+ [Description("Path to the .sln file to snapshot (overrides 'solution' in the configuration).")]
+ public string? SolutionPath { get; set; }
+
+ [CommandOption("-a|--assemblies ")]
+ [Description("A built assembly (.dll) to snapshot; repeat to snapshot several (overrides 'assemblies').")]
+ public string[] AssemblyPaths { get; set; } = [];
+
+ [CommandOption("--baseline ")]
+ [Description("Path of the baseline file (falls back to 'baseline' in the configuration, then errors-baseline.json).")]
+ public string? BaselinePath { get; set; }
+
+ [CommandOption("-c|--configuration ")]
+ [Description("Build configuration used when building a solution. Falls back to the configuration, then Debug.")]
+ public string? Configuration { get; set; }
+
+ [CommandOption("--framework ")]
+ [Description("Restrict a multi-target solution to a single target framework.")]
+ public string? Framework { get; set; }
+
+ [CommandOption("--no-build")]
+ [Description("Do not build the solution; snapshot the existing binaries.")]
+ public bool NoBuild { get; set; }
+
+ [CommandOption("--strict")]
+ [Description("Abort on the first extraction failure (default: continue and report).")]
+ public bool Strict { get; set; }
+
+ [CommandOption("--worker ")]
+ [Description("Path to FirstClassErrors.GenDoc.Worker.dll (default: next to this tool).")]
+ public string? WorkerPath { get; set; }
+
+ [CommandOption("--verbose")] // Long-only to stay clear of Spectre's built-in --version/-h help options.
+ [Description("Emit diagnostic logging to standard error.")]
+ public bool Verbose { get; set; }
+
+}
diff --git a/FirstClassErrors.Cli/CatalogSnapshotSource.cs b/FirstClassErrors.Cli/CatalogSnapshotSource.cs
new file mode 100644
index 0000000..ca92fb8
--- /dev/null
+++ b/FirstClassErrors.Cli/CatalogSnapshotSource.cs
@@ -0,0 +1,63 @@
+#region Usings declarations
+
+using System.Globalization;
+
+using FirstClassErrors.GenDoc;
+using FirstClassErrors.GenDoc.Versioning;
+
+#endregion
+
+namespace FirstClassErrors.Cli;
+
+///
+/// The production : resolves the source (command line first, then
+/// configuration), runs the real documentation extraction through
+/// , and projects the resulting catalog into its contract
+/// snapshot. It holds no state; it only places the real pipeline behind the port the commands depend on.
+///
+internal sealed class SolutionCatalogSnapshotSource : ICatalogSnapshotSource {
+
+ #region Statics members declarations
+
+ // The snapshot is always extracted under the "en" culture (the same built-in default as `generate`) so a
+ // committed baseline never depends on the ambient culture of the machine that produced it.
+ private static readonly CultureInfo SnapshotCulture = CultureInfo.GetCultureInfo("en");
+
+ #endregion
+
+ ///
+ public CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration configuration, IGenerationLogger logger, CancellationToken cancellationToken) {
+ (string? solution, string[] assemblies) = CatalogSourceResolver.Resolve(settings.SolutionPath, settings.AssemblyPaths, configuration);
+
+ string buildConfig = CatalogSourceResolver.FirstNonEmpty(settings.Configuration, configuration.Configuration) ?? "Debug";
+ string? framework = CatalogSourceResolver.FirstNonEmpty(settings.Framework, configuration.Framework);
+ string? worker = CatalogSourceResolver.FirstNonEmpty(settings.WorkerPath, configuration.Worker);
+ bool noBuild = settings.NoBuild || (configuration.NoBuild ?? false);
+ bool strict = settings.Strict || (configuration.Strict ?? false);
+
+ SolutionGenerationOptions options = new() {
+ BuildSolution = noBuild is false,
+ Configuration = buildConfig,
+ TargetFramework = framework,
+ FailureBehavior = strict ? FailureBehavior.Stop : FailureBehavior.Continue,
+ WorkerAssemblyPath = worker,
+ Culture = SnapshotCulture,
+ Logger = logger,
+ CancellationToken = cancellationToken
+ };
+
+ List catalog =
+ (solution is not null
+ ? SolutionErrorDocumentationGenerator.GetErrorDocumentationFrom(solution, options)
+ : SolutionErrorDocumentationGenerator.GetErrorDocumentationFromAssemblies(assemblies, options))
+ .ToList();
+
+ int uncoded = catalog.Count(error => string.IsNullOrWhiteSpace(error.Code));
+ if (uncoded > 0) {
+ logger.Warning($"{uncoded} documented error(s) have no code and cannot be tracked by the baseline.");
+ }
+
+ return CatalogSnapshot.FromCatalog(catalog);
+ }
+
+}
diff --git a/FirstClassErrors.Cli/CatalogSourceResolver.cs b/FirstClassErrors.Cli/CatalogSourceResolver.cs
new file mode 100644
index 0000000..9032875
--- /dev/null
+++ b/FirstClassErrors.Cli/CatalogSourceResolver.cs
@@ -0,0 +1,62 @@
+namespace FirstClassErrors.Cli;
+
+///
+/// Resolves the effective documentation source — a solution or a set of assemblies — from the command line and
+/// the configuration file, applying the same precedence rules for every command that extracts a catalog
+/// (generate, catalog update, catalog diff).
+///
+internal static class CatalogSourceResolver {
+
+ #region Statics members declarations
+
+ ///
+ /// Resolves the effective source. A command-line source overrides the configured one wholesale, so the two
+ /// are never mixed (passing --assemblies does not combine with a configured solution).
+ ///
+ /// The --solution value, if any.
+ /// The --assemblies values, possibly empty.
+ /// The loaded configuration file.
+ /// The effective solution path (or null) and the effective assembly paths (possibly empty).
+ ///
+ /// Thrown when both a solution and assemblies are specified, or when no source is specified at all.
+ ///
+ public static (string? Solution, string[] Assemblies) Resolve(string? solutionOption, IReadOnlyList assemblyOptions, CliConfiguration configuration) {
+ string? solution;
+ string[] assemblies;
+ if (string.IsNullOrWhiteSpace(solutionOption) is false || assemblyOptions.Count > 0) {
+ solution = solutionOption;
+ assemblies = assemblyOptions.ToArray();
+ } else {
+ solution = configuration.Solution;
+ assemblies = configuration.Assemblies?.ToArray() ?? [];
+ }
+
+ bool hasSolution = string.IsNullOrWhiteSpace(solution) is false;
+ bool hasAssemblies = assemblies.Length > 0;
+ if (hasSolution && hasAssemblies) {
+ throw new InvalidOperationException("Specify either a solution or assemblies, not both.");
+ }
+
+ if (hasSolution is false && hasAssemblies is false) {
+ throw new InvalidOperationException("No source: pass --solution/--assemblies, or set 'solution'/'assemblies' in the configuration.");
+ }
+
+ // Normalize a whitespace-only solution to null, so callers can dispatch on `solution is not null` and never
+ // route an empty string into the solution path when assemblies were the effective source.
+ return (hasSolution ? solution : null, assemblies);
+ }
+
+ ///
+ /// Returns the first value that is not null, empty or whitespace — the "command line first, then
+ /// configuration" precedence applied to a single option.
+ ///
+ public static string? FirstNonEmpty(string? primary, string? fallback) {
+ if (string.IsNullOrWhiteSpace(primary) is false) { return primary; }
+ if (string.IsNullOrWhiteSpace(fallback) is false) { return fallback; }
+
+ return null;
+ }
+
+ #endregion
+
+}
diff --git a/FirstClassErrors.Cli/CatalogUpdateCommand.cs b/FirstClassErrors.Cli/CatalogUpdateCommand.cs
new file mode 100644
index 0000000..781d818
--- /dev/null
+++ b/FirstClassErrors.Cli/CatalogUpdateCommand.cs
@@ -0,0 +1,117 @@
+#region Usings declarations
+
+using FirstClassErrors.GenDoc;
+using FirstClassErrors.GenDoc.Versioning;
+
+using Spectre.Console.Cli;
+
+#endregion
+
+namespace FirstClassErrors.Cli;
+
+/// Settings for fce catalog update.
+internal sealed class CatalogUpdateSettings : CatalogSettings { }
+
+///
+/// Creates or refreshes the catalog baseline: extracts the current catalog, projects it into its canonical
+/// contract snapshot, and writes it to the baseline file. Updating the baseline is the deliberate act of
+/// accepting the current contract — including any breaking change — so the file is meant to be committed and the
+/// change reviewed like any other contract change.
+///
+internal sealed class CatalogUpdateCommand : Command {
+
+ #region Fields
+
+ private readonly ICatalogSnapshotSource _snapshotSource;
+ private readonly Func _loggerFactory;
+ private readonly TextWriter _output;
+
+ #endregion
+
+ #region Constructors & Destructor
+
+ /// Production constructor used by the CLI host: wires the real extraction pipeline, console logger and stdout.
+ public CatalogUpdateCommand() : this(
+ new SolutionCatalogSnapshotSource(),
+ verbose => new ConsoleGenerationLogger(verbose),
+ Console.Out) { }
+
+ /// Test seam: injects the collaborators so they can be substituted by fakes.
+ internal CatalogUpdateCommand(ICatalogSnapshotSource snapshotSource, Func loggerFactory, TextWriter output) {
+ _snapshotSource = snapshotSource;
+ _loggerFactory = loggerFactory;
+ _output = output;
+ }
+
+ #endregion
+
+ protected override int Execute(CommandContext context, CatalogUpdateSettings settings, CancellationToken cancellationToken) {
+ // The command body uses no CommandContext state, so it lives in a context-free seam that tests drive directly.
+ return Run(settings, cancellationToken);
+ }
+
+ internal int Run(CatalogUpdateSettings settings, CancellationToken cancellationToken) {
+ IGenerationLogger logger = _loggerFactory(settings.Verbose);
+
+ try {
+ string configPath = ConfigurationStore.Resolve(settings.ConfigPath);
+ CliConfiguration configuration = ConfigurationStore.Load(configPath);
+ string configDir = Path.GetDirectoryName(configPath) ?? Directory.GetCurrentDirectory();
+
+ CatalogSnapshot current = _snapshotSource.Extract(settings, configuration, logger, cancellationToken);
+ string baselinePath = BaselineStore.Resolve(settings.BaselinePath, configuration.Baseline, configDir);
+
+ if (BaselineStore.Exists(baselinePath) is false) {
+ BaselineStore.Save(baselinePath, current);
+ _output.WriteLine($"Baseline created at '{baselinePath}', tracking {current.Errors.Count} error(s).");
+
+ return 0;
+ }
+
+ string existingText = File.ReadAllText(baselinePath);
+ string canonical = CatalogSnapshotSerializer.Serialize(current);
+ if (string.Equals(existingText, canonical, StringComparison.Ordinal)) {
+ _output.WriteLine($"Baseline at '{baselinePath}' is already up to date ({current.Errors.Count} error(s)).");
+
+ return 0;
+ }
+
+ // Summarize what the refresh absorbs, so accepting a breaking change is a visible, reviewable act. A
+ // corrupt existing baseline must not block the rewrite — updating is precisely how you regenerate it — so
+ // fall back to a plain rewrite with a warning. A baseline written by a NEWER schema is the exception: it is
+ // not corrupt, and overwriting it here would silently downgrade a teammate's contract, so let it propagate
+ // to a hard error (as `catalog diff` already does) telling the user to upgrade the tool.
+ CatalogDiff? diff = null;
+ try {
+ diff = CatalogDiffer.Diff(CatalogSnapshotSerializer.Deserialize(existingText), current);
+ } catch (InvalidOperationException exception) when (exception is not CatalogSchemaTooNewException) {
+ logger.Warning($"The existing baseline at '{baselinePath}' could not be read ({exception.Message}); rewriting it.");
+ }
+
+ BaselineStore.Save(baselinePath, current);
+
+ if (diff is null) {
+ _output.WriteLine($"Baseline rewritten at '{baselinePath}' ({current.Errors.Count} error(s)); the previous file was unreadable.");
+ } else if (diff.IsEmpty) {
+ _output.WriteLine($"Baseline rewritten in canonical form at '{baselinePath}' ({current.Errors.Count} error(s)).");
+ } else {
+ _output.WriteLine($"Baseline updated at '{baselinePath}': {diff.BreakingChanges.Count} breaking, {diff.CompatibleChanges.Count} compatible and {diff.InformationalChanges.Count} documentation change(s) accepted.");
+ }
+
+ return 0;
+ } catch (OperationCanceledException) {
+ // Cancellation (Ctrl+C) is an abort, not a failure: the child processes are already killed through the
+ // token, so report it with the conventional SIGINT exit code (128 + 2) rather than a generic error.
+ logger.Error("Catalog update canceled.");
+
+ return 130;
+ } catch (Exception exception) {
+ // Report expected failures (missing solution, worker crash, …) as a terse line, not a stack trace.
+ logger.Error(exception.Message);
+ logger.Debug(exception.ToString());
+
+ return 1;
+ }
+ }
+
+}
diff --git a/FirstClassErrors.Cli/CliConfiguration.cs b/FirstClassErrors.Cli/CliConfiguration.cs
index de6e606..e83030b 100644
--- a/FirstClassErrors.Cli/CliConfiguration.cs
+++ b/FirstClassErrors.Cli/CliConfiguration.cs
@@ -51,6 +51,12 @@ internal sealed class CliConfiguration {
///
public string? ServiceName { get; set; }
+ /// Default path of the catalog baseline file used by the catalog commands.
+ public string? Baseline { get; set; }
+
+ /// Default path where generate also writes the canonical catalog snapshot.
+ public string? Snapshot { get; set; }
+
/// Paths of the custom renderer assemblies to load.
public List Renderers { get; set; } = [];
diff --git a/FirstClassErrors.Cli/ConfigRelativePath.cs b/FirstClassErrors.Cli/ConfigRelativePath.cs
new file mode 100644
index 0000000..62adc8c
--- /dev/null
+++ b/FirstClassErrors.Cli/ConfigRelativePath.cs
@@ -0,0 +1,38 @@
+namespace FirstClassErrors.Cli;
+
+///
+/// Resolves an optional path with the repository's portability contract, shared by every option that can be set
+/// both on the command line and in fce.json (the catalog baseline, the generate snapshot): a command-line
+/// value wins and is resolved against the current directory — the caller's vantage point — while a value read
+/// from the configuration file is resolved against the configuration file's directory, so a configuration checked
+/// in under, say, ci/fce.json keeps pointing at ci/… wherever the tool is invoked from. This mirrors
+/// how renderer references resolve.
+///
+internal static class ConfigRelativePath {
+
+ #region Statics members declarations
+
+ ///
+ /// Resolves the effective path, or returns null when neither a command-line nor a configured value is
+ /// provided.
+ ///
+ /// The command-line value, if any (resolved against the current directory).
+ /// The value from the configuration file, if any (resolved against ).
+ /// The directory containing the configuration file.
+ public static string? Resolve(string? optionPath, string? configuredPath, string configDirectory) {
+ if (string.IsNullOrWhiteSpace(optionPath) is false) {
+ return Path.GetFullPath(optionPath!);
+ }
+
+ if (string.IsNullOrWhiteSpace(configuredPath) is false) {
+ return Path.IsPathRooted(configuredPath)
+ ? Path.GetFullPath(configuredPath!)
+ : Path.GetFullPath(Path.Combine(configDirectory, configuredPath!));
+ }
+
+ return null;
+ }
+
+ #endregion
+
+}
diff --git a/FirstClassErrors.Cli/GenerateCommand.cs b/FirstClassErrors.Cli/GenerateCommand.cs
index 1c45022..1e33eea 100644
--- a/FirstClassErrors.Cli/GenerateCommand.cs
+++ b/FirstClassErrors.Cli/GenerateCommand.cs
@@ -5,6 +5,7 @@
using FirstClassErrors;
using FirstClassErrors.GenDoc;
using FirstClassErrors.GenDoc.Rendering;
+using FirstClassErrors.GenDoc.Versioning;
using Spectre.Console.Cli;
@@ -110,17 +111,35 @@ internal int Run(GenerateSettings settings, CancellationToken cancellationToken)
CancellationToken = cancellationToken
};
- IEnumerable catalog =
- resolved.HasSolution
- ? _generator.GetErrorDocumentationFrom(resolved.Solution!, options)
- : _generator.GetErrorDocumentationFromAssemblies(resolved.Assemblies, options);
+ // The catalog is materialized here, so generation failures surface as a clean error — and the same
+ // catalog can feed both the renderer and the optional contract snapshot below.
+ List catalog =
+ (resolved.HasSolution
+ ? _generator.GetErrorDocumentationFrom(resolved.Solution!, options)
+ : _generator.GetErrorDocumentationFromAssemblies(resolved.Assemblies, options))
+ .ToList();
- // The catalog is enumerated here (by the renderer), so generation failures surface as a clean error.
RenderRequest request = new(resolved.Layout, culture, resolved.ServiceName);
IReadOnlyList documents = renderer.Render(catalog, request);
_outputWriter.Write(documents, renderer.Format, resolved.Output, logger);
+ // The canonical snapshot is renderer-independent: whatever format is published for humans, the same
+ // contract file can be produced for `fce catalog diff` and CI drift detection. A configured `snapshot`
+ // path resolves relative to fce.json (like the baseline and the renderer references), while a --snapshot
+ // command-line value resolves against the current directory. It reflects the render language; a
+ // culture-independent baseline should come from `fce catalog update` (which always pins `en`), so warn
+ // when the two would diverge.
+ string? snapshotPath = ConfigRelativePath.Resolve(settings.SnapshotPath, configuration.Snapshot, configDir);
+ if (snapshotPath is not null) {
+ if (!IsEnglish(culture)) {
+ logger.Warning($"The snapshot reflects the '{culture.Name}' language (localized titles/sources). For a culture-independent baseline, use 'fce catalog update' or generate with --language en.");
+ }
+
+ WriteSnapshotFile(snapshotPath, CatalogSnapshotSerializer.Serialize(CatalogSnapshot.FromCatalog(catalog)));
+ logger.Info($"Catalog snapshot written to '{snapshotPath}'.");
+ }
+
return 0;
} catch (OperationCanceledException) {
// Cancellation (Ctrl+C) is an abort, not a failure: the child processes are already killed through the
@@ -150,6 +169,19 @@ internal static CultureInfo ResolveCulture(string language) {
}
}
+ private static bool IsEnglish(CultureInfo culture) {
+ // The canonical baseline (fce catalog update) pins "en"; a snapshot produced under English (or the invariant
+ // culture) matches it, so no warning is needed. Any other language localizes titles/sources.
+ return string.IsNullOrEmpty(culture.Name) || string.Equals(culture.TwoLetterISOLanguageName, "en", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static void WriteSnapshotFile(string path, string content) {
+ string? directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); }
+
+ File.WriteAllText(path, content);
+ }
+
#endregion
}
diff --git a/FirstClassErrors.Cli/GenerateSettings.cs b/FirstClassErrors.Cli/GenerateSettings.cs
index c8dac86..e424fc3 100644
--- a/FirstClassErrors.Cli/GenerateSettings.cs
+++ b/FirstClassErrors.Cli/GenerateSettings.cs
@@ -55,6 +55,10 @@ internal sealed class GenerateSettings : ConfigScopedSettings {
[Description("Abort on the first extraction failure (default: continue and report).")]
public bool Strict { get; set; }
+ [CommandOption("--snapshot ")]
+ [Description("Also write the canonical catalog snapshot to this file, whatever the chosen format. It reflects --language; for a culture-independent committed baseline use 'fce catalog update'. Falls back to 'snapshot' in the configuration.")]
+ public string? SnapshotPath { get; set; }
+
[CommandOption("--worker ")]
[Description("Path to FirstClassErrors.GenDoc.Worker.dll (default: next to this tool).")]
public string? WorkerPath { get; set; }
diff --git a/FirstClassErrors.Cli/ICatalogSnapshotSource.cs b/FirstClassErrors.Cli/ICatalogSnapshotSource.cs
new file mode 100644
index 0000000..2c9a185
--- /dev/null
+++ b/FirstClassErrors.Cli/ICatalogSnapshotSource.cs
@@ -0,0 +1,28 @@
+#region Usings declarations
+
+using FirstClassErrors.GenDoc;
+using FirstClassErrors.GenDoc.Versioning;
+
+#endregion
+
+namespace FirstClassErrors.Cli;
+
+///
+/// The catalog commands' port over the extraction-plus-projection pipeline: it turns the configured source into
+/// the canonical . The commands depend on this abstraction rather than on the static
+/// generator, so tests substitute a fake and exercise the command wiring (exit codes, report routing, baseline
+/// handling) without spawning real dotnet processes.
+///
+internal interface ICatalogSnapshotSource {
+
+ ///
+ /// Extracts the snapshot of the catalog as it currently stands in the configured source.
+ ///
+ /// The catalog command options.
+ /// The loaded configuration file.
+ /// The logger diagnostics are reported to.
+ /// A token observed while the (out-of-process) extraction runs.
+ /// The canonical snapshot of the current catalog.
+ CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration configuration, IGenerationLogger logger, CancellationToken cancellationToken);
+
+}
diff --git a/FirstClassErrors.Cli/Program.cs b/FirstClassErrors.Cli/Program.cs
index 2ac5949..bba2bda 100644
--- a/FirstClassErrors.Cli/Program.cs
+++ b/FirstClassErrors.Cli/Program.cs
@@ -14,6 +14,12 @@
config.AddCommand("generate")
.WithDescription("Generate error documentation from a solution or from assemblies.");
+ config.AddBranch("catalog", catalog => {
+ catalog.SetDescription("Track the error catalog as a versioned contract (baseline + diff).");
+ catalog.AddCommand("update").WithDescription("Create or refresh the catalog baseline (deliberately accept the current contract).");
+ catalog.AddCommand("diff").WithDescription("Compare the current catalog against the baseline and report the changes.");
+ });
+
config.AddBranch("config", configuration => {
configuration.SetDescription("Manage the configuration file (fce.json).");
configuration.AddCommand("init").WithDescription("Create the configuration file.");
diff --git a/FirstClassErrors.GenDoc.UnitTests/CatalogDiffFormatterTests.cs b/FirstClassErrors.GenDoc.UnitTests/CatalogDiffFormatterTests.cs
new file mode 100644
index 0000000..462820d
--- /dev/null
+++ b/FirstClassErrors.GenDoc.UnitTests/CatalogDiffFormatterTests.cs
@@ -0,0 +1,121 @@
+#region Usings declarations
+
+using System.Text.Json;
+
+using JetBrains.Annotations;
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.GenDoc.Versioning.UnitTests;
+
+[TestSubject(typeof(CatalogDiffFormatter))]
+public sealed class CatalogDiffFormatterTests {
+
+ private static CatalogDiff SampleDiff() {
+ CatalogSnapshot baseline = new() {
+ Errors = [
+ new CatalogSnapshotEntry { Code = "PAYMENT.DECLINED", Title = "Payment declined" },
+ new CatalogSnapshotEntry { Code = "SOME_CODE", Title = "Old title" }
+ ]
+ };
+ CatalogSnapshot current = new() {
+ Errors = [
+ new CatalogSnapshotEntry { Code = "PAYMENT.REFUSED", Title = "Payment declined" },
+ new CatalogSnapshotEntry { Code = "SOME_CODE", Title = "New title" }
+ ]
+ };
+
+ return CatalogDiffer.Diff(baseline, current);
+ }
+
+ private static CatalogDiff EmptyDiff() {
+ return CatalogDiffer.Diff(new CatalogSnapshot(), new CatalogSnapshot());
+ }
+
+ [Fact(DisplayName = "The text report groups the changes by impact with their counts.")]
+ public void TheTextReportGroupsTheChangesByImpact() {
+ // Exercise
+ string report = CatalogDiffFormatter.ToText(SampleDiff());
+
+ // Verify
+ Check.That(report).Contains("Breaking changes (1):");
+ Check.That(report).Contains("Compatible changes (1):");
+ Check.That(report).Contains("Documentation changes (1):");
+ Check.That(report).Contains("[removed] PAYMENT.DECLINED");
+ Check.That(report).Contains("possibly renamed to 'PAYMENT.REFUSED'");
+ Check.That(report).Contains("[added] PAYMENT.REFUSED");
+ Check.That(report).Contains("[title] SOME_CODE");
+ }
+
+ [Fact(DisplayName = "The text report of an empty diff is a single 'no changes' line.")]
+ public void TheTextReportOfAnEmptyDiffIsASingleLine() {
+ // Exercise & verify
+ Check.That(CatalogDiffFormatter.ToText(EmptyDiff())).IsEqualTo("No catalog changes.\n");
+ }
+
+ [Fact(DisplayName = "The Markdown report has one section per impact, ready for a pull-request comment.")]
+ public void TheMarkdownReportHasOneSectionPerImpact() {
+ // Exercise
+ string report = CatalogDiffFormatter.ToMarkdown(SampleDiff());
+
+ // Verify
+ Check.That(report).StartsWith("## Error catalog changes");
+ Check.That(report).Contains("### 💥 Breaking (1)");
+ Check.That(report).Contains("### ✅ Compatible (1)");
+ Check.That(report).Contains("### ℹ️ Documentation (1)");
+ Check.That(report).Contains("- **`PAYMENT.DECLINED`**");
+ }
+
+ [Fact(DisplayName = "The Markdown report of an empty diff says so under the heading.")]
+ public void TheMarkdownReportOfAnEmptyDiffSaysSo() {
+ // Exercise
+ string report = CatalogDiffFormatter.ToMarkdown(EmptyDiff());
+
+ // Verify
+ Check.That(report).Contains("## Error catalog changes");
+ Check.That(report).Contains("No catalog changes.");
+ }
+
+ [Fact(DisplayName = "The JSON report exposes the counts, the breaking flag and camelCase change entries.")]
+ public void TheJsonReportExposesCountsFlagAndChanges() {
+ // Exercise
+ string json = CatalogDiffFormatter.ToJson(SampleDiff());
+
+ // Verify
+ using JsonDocument parsed = JsonDocument.Parse(json);
+ JsonElement root = parsed.RootElement;
+
+ Check.That(root.GetProperty("hasBreakingChanges").GetBoolean()).IsTrue();
+ Check.That(root.GetProperty("counts").GetProperty("breaking").GetInt32()).IsEqualTo(1);
+ Check.That(root.GetProperty("counts").GetProperty("compatible").GetInt32()).IsEqualTo(1);
+ Check.That(root.GetProperty("counts").GetProperty("informational").GetInt32()).IsEqualTo(1);
+
+ JsonElement changes = root.GetProperty("changes");
+ Check.That(changes.GetArrayLength()).IsEqualTo(3);
+ JsonElement removal = changes.EnumerateArray().Single(change => change.GetProperty("kind").GetString() == "errorRemoved");
+ Check.That(removal.GetProperty("impact").GetString()).IsEqualTo("breaking");
+ Check.That(removal.GetProperty("code").GetString()).IsEqualTo("PAYMENT.DECLINED");
+ }
+
+ [Fact(DisplayName = "The JSON report of an empty diff has zero counts and no changes.")]
+ public void TheJsonReportOfAnEmptyDiffHasZeroCounts() {
+ // Exercise
+ string json = CatalogDiffFormatter.ToJson(EmptyDiff());
+
+ // Verify
+ using JsonDocument parsed = JsonDocument.Parse(json);
+ Check.That(parsed.RootElement.GetProperty("hasBreakingChanges").GetBoolean()).IsFalse();
+ Check.That(parsed.RootElement.GetProperty("changes").GetArrayLength()).IsEqualTo(0);
+ }
+
+ [Fact(DisplayName = "A null diff is rejected by every formatter.")]
+ public void ANullDiffIsRejected() {
+ // Exercise & verify
+ Check.ThatCode(() => CatalogDiffFormatter.ToText(null!)).Throws();
+ Check.ThatCode(() => CatalogDiffFormatter.ToMarkdown(null!)).Throws();
+ Check.ThatCode(() => CatalogDiffFormatter.ToJson(null!)).Throws();
+ }
+
+}
diff --git a/FirstClassErrors.GenDoc.UnitTests/CatalogDifferTests.cs b/FirstClassErrors.GenDoc.UnitTests/CatalogDifferTests.cs
new file mode 100644
index 0000000..af8d71d
--- /dev/null
+++ b/FirstClassErrors.GenDoc.UnitTests/CatalogDifferTests.cs
@@ -0,0 +1,215 @@
+#region Usings declarations
+
+using JetBrains.Annotations;
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.GenDoc.Versioning.UnitTests;
+
+[TestSubject(typeof(CatalogDiffer))]
+public sealed class CatalogDifferTests {
+
+ private static CatalogSnapshot Snapshot(params CatalogSnapshotEntry[] entries) {
+ return new CatalogSnapshot { Errors = entries };
+ }
+
+ private static CatalogSnapshotEntry Entry(string code, string? title = null, string? source = null, params CatalogSnapshotContextKey[] context) {
+ return new CatalogSnapshotEntry { Code = code, Title = title, Source = source, Context = context };
+ }
+
+ private static CatalogSnapshotContextKey Key(string key, string valueType) {
+ return new CatalogSnapshotContextKey { Key = key, ValueType = valueType };
+ }
+
+ [Fact(DisplayName = "Two identical snapshots produce an empty diff.")]
+ public void TwoIdenticalSnapshotsProduceAnEmptyDiff() {
+ // Setup
+ CatalogSnapshot baseline = Snapshot(Entry("PAYMENT.DECLINED", "Payment declined", "Payment", Key("DealId", "System.String")));
+ CatalogSnapshot current = Snapshot(Entry("PAYMENT.DECLINED", "Payment declined", "Payment", Key("DealId", "System.String")));
+
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(baseline, current);
+
+ // Verify
+ Check.That(diff.IsEmpty).IsTrue();
+ Check.That(diff.HasChangesAtOrAbove(CatalogChangeImpact.Informational)).IsFalse();
+ }
+
+ [Fact(DisplayName = "A removed error code is a breaking change.")]
+ public void ARemovedErrorCodeIsABreakingChange() {
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(Snapshot(Entry("PAYMENT.DECLINED")), Snapshot());
+
+ // Verify
+ Check.That(diff.BreakingChanges).HasSize(1);
+ CatalogChange change = diff.BreakingChanges[0];
+ Check.That(change.Kind).IsEqualTo(CatalogChangeKind.ErrorRemoved);
+ Check.That(change.Code).IsEqualTo("PAYMENT.DECLINED");
+ }
+
+ [Fact(DisplayName = "A removal whose title matches a single added error carries a 'possibly renamed' hint.")]
+ public void ARemovalMatchingASingleAdditionCarriesARenameHint() {
+ // Setup
+ CatalogSnapshot baseline = Snapshot(Entry("PAYMENT.DECLINED", "Payment declined"));
+ CatalogSnapshot current = Snapshot(Entry("PAYMENT.REFUSED", "Payment declined"));
+
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(baseline, current);
+
+ // Verify: the removal stays breaking — consumers know the old code — but the hint points at the new one.
+ CatalogChange removal = diff.Changes.Single(change => change.Kind == CatalogChangeKind.ErrorRemoved);
+ Check.That(removal.Impact).IsEqualTo(CatalogChangeImpact.Breaking);
+ Check.That(removal.Description).Contains("possibly renamed to 'PAYMENT.REFUSED'");
+ }
+
+ [Fact(DisplayName = "No rename hint is emitted when several added errors share the removed error's title.")]
+ public void NoRenameHintIsEmittedWhenSeveralAdditionsShareTheTitle() {
+ // Setup
+ CatalogSnapshot baseline = Snapshot(Entry("OLD", "Same title"));
+ CatalogSnapshot current = Snapshot(Entry("NEW_1", "Same title"), Entry("NEW_2", "Same title"));
+
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(baseline, current);
+
+ // Verify
+ CatalogChange removal = diff.Changes.Single(change => change.Kind == CatalogChangeKind.ErrorRemoved);
+ Check.That(removal.Description).Not.Contains("possibly renamed");
+ }
+
+ [Fact(DisplayName = "A new error code is a compatible change.")]
+ public void ANewErrorCodeIsACompatibleChange() {
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(Snapshot(), Snapshot(Entry("INVENTORY.OUT_OF_STOCK", "Out of stock", "Inventory")));
+
+ // Verify
+ Check.That(diff.CompatibleChanges).HasSize(1);
+ CatalogChange change = diff.CompatibleChanges[0];
+ Check.That(change.Kind).IsEqualTo(CatalogChangeKind.ErrorAdded);
+ Check.That(change.Description).Contains("Out of stock").And.Contains("Inventory");
+ }
+
+ [Fact(DisplayName = "A removed context key is a breaking change.")]
+ public void ARemovedContextKeyIsABreakingChange() {
+ // Setup
+ CatalogSnapshot baseline = Snapshot(Entry("SOME_CODE", null, null, Key("DealId", "System.String")));
+ CatalogSnapshot current = Snapshot(Entry("SOME_CODE"));
+
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(baseline, current);
+
+ // Verify
+ CatalogChange change = diff.Changes.Single();
+ Check.That(change.Kind).IsEqualTo(CatalogChangeKind.ContextKeyRemoved);
+ Check.That(change.Impact).IsEqualTo(CatalogChangeImpact.Breaking);
+ Check.That(change.Description).Contains("DealId");
+ }
+
+ [Fact(DisplayName = "A context key changing its value type is a breaking change.")]
+ public void AContextKeyChangingItsValueTypeIsABreakingChange() {
+ // Setup
+ CatalogSnapshot baseline = Snapshot(Entry("SOME_CODE", null, null, Key("DealId", "System.String")));
+ CatalogSnapshot current = Snapshot(Entry("SOME_CODE", null, null, Key("DealId", "System.Guid")));
+
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(baseline, current);
+
+ // Verify
+ CatalogChange change = diff.Changes.Single();
+ Check.That(change.Kind).IsEqualTo(CatalogChangeKind.ContextKeyValueTypeChanged);
+ Check.That(change.Impact).IsEqualTo(CatalogChangeImpact.Breaking);
+ Check.That(change.Description).Contains("System.String").And.Contains("System.Guid");
+ }
+
+ [Fact(DisplayName = "A new context key is a compatible change.")]
+ public void ANewContextKeyIsACompatibleChange() {
+ // Setup
+ CatalogSnapshot baseline = Snapshot(Entry("SOME_CODE"));
+ CatalogSnapshot current = Snapshot(Entry("SOME_CODE", null, null, Key("DealId", "System.String")));
+
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(baseline, current);
+
+ // Verify
+ CatalogChange change = diff.Changes.Single();
+ Check.That(change.Kind).IsEqualTo(CatalogChangeKind.ContextKeyAdded);
+ Check.That(change.Impact).IsEqualTo(CatalogChangeImpact.Compatible);
+ }
+
+ [Fact(DisplayName = "Title and source changes are informational.")]
+ public void TitleAndSourceChangesAreInformational() {
+ // Setup
+ CatalogSnapshot baseline = Snapshot(Entry("SOME_CODE", "Old title", "OldSource"));
+ CatalogSnapshot current = Snapshot(Entry("SOME_CODE", "New title", "NewSource"));
+
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(baseline, current);
+
+ // Verify
+ Check.That(diff.InformationalChanges).HasSize(2);
+ Check.That(diff.Changes.Select(change => change.Kind))
+ .Contains(CatalogChangeKind.TitleChanged, CatalogChangeKind.SourceChanged);
+ Check.That(diff.HasChangesAtOrAbove(CatalogChangeImpact.Breaking)).IsFalse();
+ Check.That(diff.HasChangesAtOrAbove(CatalogChangeImpact.Informational)).IsTrue();
+ }
+
+ [Fact(DisplayName = "The impact threshold treats compatible changes as above informational and below breaking.")]
+ public void TheImpactThresholdRanksCompatibleBetweenInformationalAndBreaking() {
+ // Setup: one compatible change only.
+ CatalogDiff diff = CatalogDiffer.Diff(Snapshot(), Snapshot(Entry("SOME_CODE")));
+
+ // Exercise & verify
+ Check.That(diff.HasChangesAtOrAbove(CatalogChangeImpact.Breaking)).IsFalse();
+ Check.That(diff.HasChangesAtOrAbove(CatalogChangeImpact.Compatible)).IsTrue();
+ Check.That(diff.HasChangesAtOrAbove(CatalogChangeImpact.Informational)).IsTrue();
+ }
+
+ [Fact(DisplayName = "Changes are ordered by error code, whatever the snapshot order.")]
+ public void ChangesAreOrderedByErrorCode() {
+ // Setup
+ CatalogSnapshot baseline = Snapshot(Entry("Z_CODE"), Entry("A_CODE"));
+ CatalogSnapshot current = Snapshot();
+
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(baseline, current);
+
+ // Verify
+ Check.That(diff.Changes.Select(change => change.Code)).ContainsExactly("A_CODE", "Z_CODE");
+ }
+
+ [Fact(DisplayName = "Identities and titles are compared normalized: whitespace-only differences produce no change.")]
+ public void WhitespaceOnlyDifferencesProduceNoChange() {
+ // Setup: same contract, but every identity and the title differ only by surrounding whitespace.
+ CatalogSnapshot baseline = Snapshot(Entry("PAYMENT.DECLINED", "Payment declined", "Payment", Key("DealId", "System.String")));
+ CatalogSnapshot current = Snapshot(Entry(" PAYMENT.DECLINED ", " Payment declined ", " Payment ", Key(" DealId ", "System.String")));
+
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(baseline, current);
+
+ // Verify
+ Check.That(diff.IsEmpty).IsTrue();
+ }
+
+ [Fact(DisplayName = "The rename hint matches titles that differ only by whitespace.")]
+ public void TheRenameHintMatchesTitlesThatDifferOnlyByWhitespace() {
+ // Setup
+ CatalogSnapshot baseline = Snapshot(Entry("PAYMENT.DECLINED", "Payment declined"));
+ CatalogSnapshot current = Snapshot(Entry("PAYMENT.REFUSED", " Payment declined "));
+
+ // Exercise
+ CatalogDiff diff = CatalogDiffer.Diff(baseline, current);
+
+ // Verify
+ CatalogChange removal = diff.Changes.Single(change => change.Kind == CatalogChangeKind.ErrorRemoved);
+ Check.That(removal.Description).Contains("possibly renamed to 'PAYMENT.REFUSED'");
+ }
+
+ [Fact(DisplayName = "Null snapshots are rejected.")]
+ public void NullSnapshotsAreRejected() {
+ // Exercise & verify
+ Check.ThatCode(() => CatalogDiffer.Diff(null!, Snapshot())).Throws();
+ Check.ThatCode(() => CatalogDiffer.Diff(Snapshot(), null!)).Throws();
+ }
+
+}
diff --git a/FirstClassErrors.GenDoc.UnitTests/CatalogSnapshotTests.cs b/FirstClassErrors.GenDoc.UnitTests/CatalogSnapshotTests.cs
new file mode 100644
index 0000000..4ba6417
--- /dev/null
+++ b/FirstClassErrors.GenDoc.UnitTests/CatalogSnapshotTests.cs
@@ -0,0 +1,253 @@
+#region Usings declarations
+
+using JetBrains.Annotations;
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.GenDoc.Versioning.UnitTests;
+
+[TestSubject(typeof(CatalogSnapshot))]
+public sealed class CatalogSnapshotTests {
+
+ [Fact(DisplayName = "The snapshot projects the code, title, source and context keys of every documented error.")]
+ public void TheSnapshotProjectsTheContractOfEveryDocumentedError() {
+ // Setup
+ ErrorDocumentation documentation = new() {
+ Code = "TEMPERATURE_BELOW_ABSOLUTE_ZERO",
+ Title = "Temperature below absolute zero",
+ Source = "Temperature",
+ Context = new[] {
+ new ErrorContextEntryDocumentation { Key = "AttemptedValue", ValueType = "System.Double" }
+ }
+ };
+
+ // Exercise
+ CatalogSnapshot snapshot = CatalogSnapshot.FromCatalog([documentation]);
+
+ // Verify
+ Check.That(snapshot.Schema).IsEqualTo(CatalogSnapshot.CurrentSchema);
+ Check.That(snapshot.Errors).HasSize(1);
+ CatalogSnapshotEntry entry = snapshot.Errors[0];
+ Check.That(entry.Code).IsEqualTo("TEMPERATURE_BELOW_ABSOLUTE_ZERO");
+ Check.That(entry.Title).IsEqualTo("Temperature below absolute zero");
+ Check.That(entry.Source).IsEqualTo("Temperature");
+ Check.That(entry.Context).HasSize(1);
+ Check.That(entry.Context[0].Key).IsEqualTo("AttemptedValue");
+ Check.That(entry.Context[0].ValueType).IsEqualTo("System.Double");
+ }
+
+ [Fact(DisplayName = "The snapshot orders errors by code and context keys by name, whatever the input order.")]
+ public void TheSnapshotOrdersErrorsAndContextKeysDeterministically() {
+ // Setup
+ ErrorDocumentation second = new() { Code = "B_CODE" };
+ ErrorDocumentation first = new() {
+ Code = "A_CODE",
+ Context = new[] {
+ new ErrorContextEntryDocumentation { Key = "Zulu", ValueType = "System.String" },
+ new ErrorContextEntryDocumentation { Key = "Alpha", ValueType = "System.Int32" }
+ }
+ };
+
+ // Exercise
+ CatalogSnapshot snapshot = CatalogSnapshot.FromCatalog([second, first]);
+
+ // Verify
+ Check.That(snapshot.Errors.Select(entry => entry.Code)).ContainsExactly("A_CODE", "B_CODE");
+ Check.That(snapshot.Errors[0].Context.Select(key => key.Key)).ContainsExactly("Alpha", "Zulu");
+ }
+
+ [Fact(DisplayName = "An entry without a code is skipped: it has no identity to track.")]
+ public void AnEntryWithoutACodeIsSkipped() {
+ // Setup
+ ErrorDocumentation uncoded = new() { Title = "Orphan documentation" };
+ ErrorDocumentation coded = new() { Code = "SOME_CODE" };
+
+ // Exercise
+ CatalogSnapshot snapshot = CatalogSnapshot.FromCatalog([uncoded, coded]);
+
+ // Verify
+ Check.That(snapshot.Errors).HasSize(1);
+ Check.That(snapshot.Errors[0].Code).IsEqualTo("SOME_CODE");
+ }
+
+ [Fact(DisplayName = "Duplicate context key names collapse to a single tracked key.")]
+ public void DuplicateContextKeyNamesCollapseToASingleTrackedKey() {
+ // Setup
+ ErrorDocumentation documentation = new() {
+ Code = "SOME_CODE",
+ Context = new[] {
+ new ErrorContextEntryDocumentation { Key = "DealId", ValueType = "System.String" },
+ new ErrorContextEntryDocumentation { Key = "DealId", ValueType = "System.Guid" }
+ }
+ };
+
+ // Exercise
+ CatalogSnapshot snapshot = CatalogSnapshot.FromCatalog([documentation]);
+
+ // Verify: the first declaration wins, deterministically.
+ Check.That(snapshot.Errors[0].Context).HasSize(1);
+ Check.That(snapshot.Errors[0].Context[0].ValueType).IsEqualTo("System.String");
+ }
+
+ [Fact(DisplayName = "Two entries sharing a code collapse to one, keeping the first in code order.")]
+ public void TwoEntriesSharingACodeCollapseToTheFirstInCodeOrder() {
+ // Setup
+ ErrorDocumentation first = new() { Code = "SHARED", Title = "First", Source = "A" };
+ ErrorDocumentation second = new() { Code = "SHARED", Title = "Second", Source = "B" };
+
+ // Exercise
+ CatalogSnapshot snapshot = CatalogSnapshot.FromCatalog([second, first]);
+
+ // Verify: a single entry survives, deterministically the first one.
+ Check.That(snapshot.Errors).HasSize(1);
+ Check.That(snapshot.Errors[0].Title).IsEqualTo("First");
+ Check.That(snapshot.Errors[0].Source).IsEqualTo("A");
+ }
+
+ [Fact(DisplayName = "A whitespace-padded code is trimmed in the snapshot.")]
+ public void AWhitespacePaddedCodeIsTrimmed() {
+ // Exercise
+ CatalogSnapshot snapshot = CatalogSnapshot.FromCatalog([new ErrorDocumentation { Code = " PADDED_CODE " }]);
+
+ // Verify
+ Check.That(snapshot.Errors[0].Code).IsEqualTo("PADDED_CODE");
+ }
+
+ [Fact(DisplayName = "A null catalog is rejected.")]
+ public void ANullCatalogIsRejected() {
+ // Exercise & verify
+ Check.ThatCode(() => CatalogSnapshot.FromCatalog(null!)).Throws();
+ }
+
+}
+
+[TestSubject(typeof(CatalogSnapshotSerializer))]
+public sealed class CatalogSnapshotSerializerTests {
+
+ private static CatalogSnapshot SampleSnapshot() {
+ return CatalogSnapshot.FromCatalog([
+ new ErrorDocumentation {
+ Code = "AMOUNT_CURRENCY_MISMATCH",
+ Title = "Amount currency mismatch",
+ Source = "Amount",
+ Context = new[] {
+ new ErrorContextEntryDocumentation { Key = "AttemptedValue", ValueType = "System.Decimal" }
+ }
+ }
+ ]);
+ }
+
+ [Fact(DisplayName = "A snapshot serializes to camelCase JSON with \\n line endings and a single trailing newline.")]
+ public void ASnapshotSerializesToItsCanonicalForm() {
+ // Exercise
+ string json = CatalogSnapshotSerializer.Serialize(SampleSnapshot());
+
+ // Verify
+ Check.That(json).Contains("\"schema\": 1");
+ Check.That(json).Contains("\"code\": \"AMOUNT_CURRENCY_MISMATCH\"");
+ Check.That(json).Not.Contains("\r");
+ Check.That(json.EndsWith("}\n", StringComparison.Ordinal)).IsTrue();
+ }
+
+ [Fact(DisplayName = "Serializing the same catalog twice produces byte-identical text.")]
+ public void SerializingTheSameCatalogTwiceProducesIdenticalText() {
+ // Exercise & verify
+ Check.That(CatalogSnapshotSerializer.Serialize(SampleSnapshot())).IsEqualTo(CatalogSnapshotSerializer.Serialize(SampleSnapshot()));
+ }
+
+ [Fact(DisplayName = "A snapshot round-trips through its JSON form, preserving every tracked field.")]
+ public void ASnapshotRoundTripsThroughItsJsonForm() {
+ // Setup
+ CatalogSnapshot original = SampleSnapshot();
+
+ // Exercise
+ CatalogSnapshot parsed = CatalogSnapshotSerializer.Deserialize(CatalogSnapshotSerializer.Serialize(original));
+
+ // Verify
+ Check.That(parsed.Schema).IsEqualTo(original.Schema);
+ Check.That(parsed.Errors).HasSize(1);
+ CatalogSnapshotEntry entry = parsed.Errors[0];
+ Check.That(entry.Code).IsEqualTo("AMOUNT_CURRENCY_MISMATCH");
+ Check.That(entry.Title).IsEqualTo("Amount currency mismatch");
+ Check.That(entry.Source).IsEqualTo("Amount");
+ Check.That(entry.Context).HasSize(1);
+ Check.That(entry.Context[0].Key).IsEqualTo("AttemptedValue");
+ Check.That(entry.Context[0].ValueType).IsEqualTo("System.Decimal");
+ }
+
+ [Fact(DisplayName = "A document that omits 'schema' entirely is rejected, not silently accepted.")]
+ public void ADocumentThatOmitsSchemaIsRejected() {
+ // Exercise & verify
+ Check.ThatCode(() => CatalogSnapshotSerializer.Deserialize("""{ "errors": [] }"""))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "Deserialize trims padded codes and context-key names.")]
+ public void DeserializeTrimsPaddedCodesAndContextKeyNames() {
+ // Exercise
+ CatalogSnapshot parsed = CatalogSnapshotSerializer.Deserialize(
+ """{ "schema": 1, "errors": [ { "code": " PADDED ", "context": [ { "key": " DealId ", "valueType": "System.String" } ] } ] }""");
+
+ // Verify
+ Check.That(parsed.Errors[0].Code).IsEqualTo("PADDED");
+ Check.That(parsed.Errors[0].Context[0].Key).IsEqualTo("DealId");
+ }
+
+ [Fact(DisplayName = "Null arguments are rejected by both Serialize and Deserialize.")]
+ public void NullArgumentsAreRejected() {
+ // Exercise & verify
+ Check.ThatCode(() => CatalogSnapshotSerializer.Serialize(null!)).Throws();
+ Check.ThatCode(() => CatalogSnapshotSerializer.Deserialize(null!)).Throws();
+ }
+
+ [Fact(DisplayName = "A snapshot declaring a newer schema is rejected as a distinct CatalogSchemaTooNewException.")]
+ public void ASnapshotDeclaringANewerSchemaIsRejected() {
+ // Exercise
+ CatalogSchemaTooNewException? caught = null;
+ try {
+ CatalogSnapshotSerializer.Deserialize("""{ "schema": 999, "errors": [] }""");
+ } catch (CatalogSchemaTooNewException exception) {
+ caught = exception;
+ }
+
+ // Verify: a distinct, catchable type carrying the versions — yet still an InvalidOperationException.
+ Check.That(caught).IsNotNull();
+ Check.That(caught!.DeclaredSchema).IsEqualTo(999);
+ Check.That(caught.SupportedSchema).IsEqualTo(CatalogSnapshot.CurrentSchema);
+ Check.That(caught is InvalidOperationException).IsTrue();
+ }
+
+ [Fact(DisplayName = "A snapshot without a valid schema version is rejected.")]
+ public void ASnapshotWithoutAValidSchemaVersionIsRejected() {
+ // Exercise & verify
+ Check.ThatCode(() => CatalogSnapshotSerializer.Deserialize("""{ "schema": 0, "errors": [] }"""))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "An entry without a code is rejected at parse time.")]
+ public void AnEntryWithoutACodeIsRejectedAtParseTime() {
+ // Exercise & verify
+ Check.ThatCode(() => CatalogSnapshotSerializer.Deserialize("""{ "schema": 1, "errors": [ { "title": "No code" } ] }"""))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "Invalid JSON is reported as a clear error.")]
+ public void InvalidJsonIsReportedAsAClearError() {
+ // Exercise & verify
+ Check.ThatCode(() => CatalogSnapshotSerializer.Deserialize("not json"))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "A hand-edited null 'errors' list is normalized to an empty one.")]
+ public void ANullErrorsListIsNormalizedToAnEmptyOne() {
+ // Exercise
+ CatalogSnapshot parsed = CatalogSnapshotSerializer.Deserialize("""{ "schema": 1, "errors": null }""");
+
+ // Verify
+ Check.That(parsed.Errors).IsNotNull();
+ Check.That(parsed.Errors).IsEmpty();
+ }
+
+}
diff --git a/FirstClassErrors.GenDoc/Versioning/CatalogChange.cs b/FirstClassErrors.GenDoc/Versioning/CatalogChange.cs
new file mode 100644
index 0000000..493ba02
--- /dev/null
+++ b/FirstClassErrors.GenDoc/Versioning/CatalogChange.cs
@@ -0,0 +1,91 @@
+namespace FirstClassErrors.GenDoc.Versioning;
+
+///
+/// The impact of a single catalog change on the consumers of the error contract.
+///
+public enum CatalogChangeImpact {
+
+ ///
+ /// The change can break a consumer of the contract: a code or a context key that external systems may rely on
+ /// (client branching, dashboards, log pipelines, support procedures) disappeared or changed shape.
+ ///
+ Breaking,
+
+ ///
+ /// The change is additive: existing consumers keep working, new capability appears (a new error code, a new
+ /// context key).
+ ///
+ Compatible,
+
+ ///
+ /// The change only affects the documentation identity (title, source); no consumer contract is involved. The
+ /// machine-readable JSON report surfaces this as the enum name (informational); the human-readable text
+ /// and Markdown reports label the same group "Documentation changes".
+ ///
+ Informational
+
+}
+
+///
+/// The kind of a single catalog change.
+///
+public enum CatalogChangeKind {
+
+ /// A new error code appeared in the catalog.
+ ErrorAdded,
+
+ /// An error code disappeared from the catalog.
+ ErrorRemoved,
+
+ /// An existing error declares a new context key.
+ ContextKeyAdded,
+
+ /// An existing error no longer declares a context key it used to.
+ ContextKeyRemoved,
+
+ /// A context key of an existing error changed its value type.
+ ContextKeyValueTypeChanged,
+
+ /// The title of an existing error changed.
+ TitleChanged,
+
+ /// The source of an existing error changed.
+ SourceChanged
+
+}
+
+///
+/// A single difference between two catalog snapshots: what changed (), how much it matters
+/// (), on which error (), and a human-readable .
+///
+public sealed class CatalogChange {
+
+ #region Constructors declarations
+
+ internal CatalogChange(CatalogChangeKind kind, CatalogChangeImpact impact, string code, string description) {
+ Kind = kind;
+ Impact = impact;
+ Code = code;
+ Description = description;
+ }
+
+ #endregion
+
+ /// Gets the kind of the change.
+ public CatalogChangeKind Kind { get; }
+
+ /// Gets the impact of the change on the consumers of the contract.
+ public CatalogChangeImpact Impact { get; }
+
+ /// Gets the error code the change applies to.
+ public string Code { get; }
+
+ /// Gets the human-readable description of the change.
+ public string Description { get; }
+
+ ///
+ public override string ToString() {
+ return $"[{Kind}] {Code} — {Description}";
+ }
+
+}
diff --git a/FirstClassErrors.GenDoc/Versioning/CatalogDiff.cs b/FirstClassErrors.GenDoc/Versioning/CatalogDiff.cs
new file mode 100644
index 0000000..8c38c6c
--- /dev/null
+++ b/FirstClassErrors.GenDoc/Versioning/CatalogDiff.cs
@@ -0,0 +1,66 @@
+namespace FirstClassErrors.GenDoc.Versioning;
+
+///
+/// The result of comparing two catalog snapshots: the ordered list of , plus convenience
+/// views per and the policy helper used to
+/// turn a diff into a CI verdict.
+///
+public sealed class CatalogDiff {
+
+ #region Statics members declarations
+
+ private static int Rank(CatalogChangeImpact impact) {
+ // Breaking is the most severe; the enum declaration order already encodes the ranking, but the mapping is
+ // explicit here so reordering the enum can never silently change the policy semantics.
+ return impact switch {
+ CatalogChangeImpact.Breaking => 2,
+ CatalogChangeImpact.Compatible => 1,
+ CatalogChangeImpact.Informational => 0,
+ _ => 0
+ };
+ }
+
+ #endregion
+
+ #region Constructors declarations
+
+ internal CatalogDiff(IReadOnlyList changes) {
+ Changes = changes;
+ }
+
+ #endregion
+
+ ///
+ /// Gets every change, ordered deterministically (by error code, then by kind).
+ ///
+ public IReadOnlyList Changes { get; }
+
+ /// Gets a value indicating whether the two snapshots are identical.
+ public bool IsEmpty => Changes.Count == 0;
+
+ /// Gets the changes that can break a consumer of the contract.
+ public IReadOnlyList BreakingChanges => Of(CatalogChangeImpact.Breaking);
+
+ /// Gets the additive changes.
+ public IReadOnlyList CompatibleChanges => Of(CatalogChangeImpact.Compatible);
+
+ /// Gets the documentation-only changes.
+ public IReadOnlyList InformationalChanges => Of(CatalogChangeImpact.Informational);
+
+ ///
+ /// Determines whether the diff contains at least one change whose impact is at least as severe as
+ /// (severity order: informational < compatible < breaking).
+ ///
+ /// The impact threshold.
+ /// true when at least one change reaches the threshold; otherwise false.
+ public bool HasChangesAtOrAbove(CatalogChangeImpact impact) {
+ int threshold = Rank(impact);
+
+ return Changes.Any(change => Rank(change.Impact) >= threshold);
+ }
+
+ private IReadOnlyList Of(CatalogChangeImpact impact) {
+ return Changes.Where(change => change.Impact == impact).ToList();
+ }
+
+}
diff --git a/FirstClassErrors.GenDoc/Versioning/CatalogDiffFormatter.cs b/FirstClassErrors.GenDoc/Versioning/CatalogDiffFormatter.cs
new file mode 100644
index 0000000..5bf8ea6
--- /dev/null
+++ b/FirstClassErrors.GenDoc/Versioning/CatalogDiffFormatter.cs
@@ -0,0 +1,141 @@
+#region Usings declarations
+
+using System.Text;
+using System.Text.Json;
+
+#endregion
+
+namespace FirstClassErrors.GenDoc.Versioning;
+
+///
+/// Renders a as a human-readable report (plain text or Markdown, the latter suited to
+/// a pull-request comment) or as a machine-readable JSON document for CI tooling.
+///
+///
+/// Every output is deterministic (the diff itself is ordered, and no timestamp is emitted), so a report can be
+/// committed, posted or compared verbatim.
+///
+public static class CatalogDiffFormatter {
+
+ #region Statics members declarations
+
+ private static readonly JsonSerializerOptions JsonOptions = new() {
+ WriteIndented = true
+ };
+
+ ///
+ /// Renders the diff as a plain-text report, grouped by impact.
+ ///
+ /// The diff to render.
+ /// The plain-text report; a single line when the diff is empty.
+ /// Thrown when is null.
+ public static string ToText(CatalogDiff diff) {
+ if (diff is null) { throw new ArgumentNullException(nameof(diff)); }
+
+ if (diff.IsEmpty) { return "No catalog changes." + "\n"; }
+
+ StringBuilder report = new();
+ AppendTextSection(report, "Breaking changes", diff.BreakingChanges);
+ AppendTextSection(report, "Compatible changes", diff.CompatibleChanges);
+ AppendTextSection(report, "Documentation changes", diff.InformationalChanges);
+
+ return report.ToString();
+ }
+
+ ///
+ /// Renders the diff as a Markdown report, grouped by impact — ready to be posted as a pull-request comment.
+ ///
+ /// The diff to render.
+ /// The Markdown report; a single line when the diff is empty.
+ /// Thrown when is null.
+ public static string ToMarkdown(CatalogDiff diff) {
+ if (diff is null) { throw new ArgumentNullException(nameof(diff)); }
+
+ StringBuilder report = new();
+ report.Append("## Error catalog changes\n");
+
+ if (diff.IsEmpty) {
+ report.Append("\nNo catalog changes.\n");
+
+ return report.ToString();
+ }
+
+ AppendMarkdownSection(report, "💥 Breaking", diff.BreakingChanges);
+ AppendMarkdownSection(report, "✅ Compatible", diff.CompatibleChanges);
+ AppendMarkdownSection(report, "ℹ️ Documentation", diff.InformationalChanges);
+
+ return report.ToString();
+ }
+
+ ///
+ /// Renders the diff as a machine-readable JSON document (camelCase properties, enum values as camelCase
+ /// strings).
+ ///
+ /// The diff to render.
+ /// The JSON report, ending with a single newline.
+ /// Thrown when is null.
+ public static string ToJson(CatalogDiff diff) {
+ if (diff is null) { throw new ArgumentNullException(nameof(diff)); }
+
+ // A curated projection: the anonymous shape fixes exactly which fields are published and their names,
+ // without exposing the internal model (same approach as the JSON documentation renderer).
+ var document = new {
+ hasBreakingChanges = diff.BreakingChanges.Count > 0,
+ counts = new {
+ breaking = diff.BreakingChanges.Count,
+ compatible = diff.CompatibleChanges.Count,
+ informational = diff.InformationalChanges.Count
+ },
+ changes = diff.Changes.Select(change => new {
+ impact = CamelCase(change.Impact.ToString()),
+ kind = CamelCase(change.Kind.ToString()),
+ code = change.Code,
+ description = change.Description
+ })
+ };
+
+ // Normalize line endings to \n rather than JsonSerializerOptions.NewLine (.NET 9+ only): the tooling must
+ // also build on the .NET 8 floor, and a deterministic report diffs and posts identically on every platform.
+ return JsonSerializer.Serialize(document, JsonOptions).Replace("\r\n", "\n") + "\n";
+ }
+
+ private static void AppendTextSection(StringBuilder report, string heading, IReadOnlyList changes) {
+ if (changes.Count == 0) { return; }
+
+ if (report.Length > 0) { report.Append('\n'); }
+
+ report.Append($"{heading} ({changes.Count}):\n");
+ foreach (CatalogChange change in changes) {
+ report.Append($" - [{Label(change.Kind)}] {change.Code} — {change.Description}\n");
+ }
+ }
+
+ private static void AppendMarkdownSection(StringBuilder report, string heading, IReadOnlyList changes) {
+ if (changes.Count == 0) { return; }
+
+ report.Append($"\n### {heading} ({changes.Count})\n\n");
+ foreach (CatalogChange change in changes) {
+ report.Append($"- **`{change.Code}`** — {change.Description}\n");
+ }
+ }
+
+ private static string Label(CatalogChangeKind kind) {
+ return kind switch {
+ CatalogChangeKind.ErrorAdded => "added",
+ CatalogChangeKind.ErrorRemoved => "removed",
+ CatalogChangeKind.ContextKeyAdded => "context-added",
+ CatalogChangeKind.ContextKeyRemoved => "context-removed",
+ CatalogChangeKind.ContextKeyValueTypeChanged => "context-retyped",
+ CatalogChangeKind.TitleChanged => "title",
+ CatalogChangeKind.SourceChanged => "source",
+ _ => kind.ToString()
+ };
+ }
+
+ private static string CamelCase(string value) {
+ return char.ToLowerInvariant(value[0]) + value.Substring(1);
+ }
+
+ #endregion
+
+}
diff --git a/FirstClassErrors.GenDoc/Versioning/CatalogDiffer.cs b/FirstClassErrors.GenDoc/Versioning/CatalogDiffer.cs
new file mode 100644
index 0000000..144780e
--- /dev/null
+++ b/FirstClassErrors.GenDoc/Versioning/CatalogDiffer.cs
@@ -0,0 +1,173 @@
+namespace FirstClassErrors.GenDoc.Versioning;
+
+///
+/// Compares two instances — a committed baseline and the freshly generated
+/// catalog — and classifies every difference by its impact on the consumers of the error contract.
+///
+///
+/// Classification rules:
+///
+/// -
+/// Breaking — an error code was removed (a rename is a removal plus an addition; when a removed and
+/// an added error share the same title, the removal carries a "possibly renamed" hint), a context key was
+/// removed, or a context key changed its value type.
+///
+/// - Compatible — an error code or a context key was added.
+/// - Informational — the title or the source of an existing error changed (documentation identity).
+///
+///
+/// Identities are compared normalized (trimmed, ordinal), so a hand-edited or programmatically built snapshot
+/// with padded codes or key names never produces phantom differences.
+///
+///
+public static class CatalogDiffer {
+
+ #region Statics members declarations
+
+ ///
+ /// Computes the differences between and .
+ ///
+ /// The committed reference snapshot (the accepted contract).
+ /// The snapshot of the catalog as it stands now.
+ /// The classified, deterministically ordered differences.
+ ///
+ /// Thrown when or is null.
+ ///
+ public static CatalogDiff Diff(CatalogSnapshot baseline, CatalogSnapshot current) {
+ if (baseline is null) { throw new ArgumentNullException(nameof(baseline)); }
+ if (current is null) { throw new ArgumentNullException(nameof(current)); }
+
+ // Every comparison below goes through the indexes, whose keys are the normalized identities — never through
+ // the raw entry properties — so trimming rules apply symmetrically to both sides.
+ Dictionary baselineByCode = IndexByCode(baseline);
+ Dictionary currentByCode = IndexByCode(current);
+
+ List changes = [];
+
+ List> removed = baselineByCode.Where(pair => currentByCode.ContainsKey(pair.Key) is false).ToList();
+ List> added = currentByCode.Where(pair => baselineByCode.ContainsKey(pair.Key) is false).ToList();
+
+ foreach (KeyValuePair pair in removed) {
+ changes.Add(new CatalogChange(CatalogChangeKind.ErrorRemoved, CatalogChangeImpact.Breaking, pair.Key, DescribeRemoval(pair.Value, added)));
+ }
+
+ foreach (KeyValuePair pair in added) {
+ changes.Add(new CatalogChange(CatalogChangeKind.ErrorAdded, CatalogChangeImpact.Compatible, pair.Key, DescribeAddition(pair.Value)));
+ }
+
+ foreach (KeyValuePair pair in baselineByCode) {
+ if (currentByCode.TryGetValue(pair.Key, out CatalogSnapshotEntry? after) is false) { continue; }
+
+ CompareEntry(pair.Key, pair.Value, after, changes);
+ }
+
+ List ordered = changes
+ .OrderBy(change => change.Code, StringComparer.Ordinal)
+ .ThenBy(change => change.Kind)
+ .ToList();
+
+ return new CatalogDiff(ordered);
+ }
+
+ private static Dictionary IndexByCode(CatalogSnapshot snapshot) {
+ Dictionary byCode = new(StringComparer.Ordinal);
+
+ // Same identity rules as CatalogSnapshot.FromCatalog: entries without a code are skipped (nothing to track),
+ // codes are trimmed, and the first entry wins on a (defensive) duplicate.
+ foreach (CatalogSnapshotEntry entry in snapshot.Errors) {
+ if (string.IsNullOrWhiteSpace(entry.Code)) { continue; }
+
+ string code = entry.Code!.Trim();
+ if (byCode.ContainsKey(code) is false) { byCode.Add(code, entry); }
+ }
+
+ return byCode;
+ }
+
+ private static void CompareEntry(string code, CatalogSnapshotEntry before, CatalogSnapshotEntry after, List changes) {
+ if (Differs(before.Title, after.Title)) {
+ changes.Add(new CatalogChange(CatalogChangeKind.TitleChanged, CatalogChangeImpact.Informational, code,
+ $"title changed from {Quote(before.Title)} to {Quote(after.Title)}"));
+ }
+
+ if (Differs(before.Source, after.Source)) {
+ changes.Add(new CatalogChange(CatalogChangeKind.SourceChanged, CatalogChangeImpact.Informational, code,
+ $"source changed from {Quote(before.Source)} to {Quote(after.Source)}"));
+ }
+
+ Dictionary beforeKeys = IndexByKey(before);
+ Dictionary afterKeys = IndexByKey(after);
+
+ foreach (KeyValuePair pair in beforeKeys) {
+ if (afterKeys.TryGetValue(pair.Key, out CatalogSnapshotContextKey? counterpart) is false) {
+ changes.Add(new CatalogChange(CatalogChangeKind.ContextKeyRemoved, CatalogChangeImpact.Breaking, code,
+ $"context key '{pair.Key}' removed"));
+
+ continue;
+ }
+
+ if (Differs(pair.Value.ValueType, counterpart.ValueType)) {
+ changes.Add(new CatalogChange(CatalogChangeKind.ContextKeyValueTypeChanged, CatalogChangeImpact.Breaking, code,
+ $"context key '{pair.Key}' changed its value type from {Quote(pair.Value.ValueType)} to {Quote(counterpart.ValueType)}"));
+ }
+ }
+
+ foreach (KeyValuePair pair in afterKeys) {
+ if (beforeKeys.ContainsKey(pair.Key) is false) {
+ changes.Add(new CatalogChange(CatalogChangeKind.ContextKeyAdded, CatalogChangeImpact.Compatible, code,
+ $"context key '{pair.Key}' added ({pair.Value.ValueType ?? "unknown type"})"));
+ }
+ }
+ }
+
+ private static Dictionary IndexByKey(CatalogSnapshotEntry entry) {
+ Dictionary byKey = new(StringComparer.Ordinal);
+
+ foreach (CatalogSnapshotContextKey key in entry.Context) {
+ if (string.IsNullOrWhiteSpace(key.Key)) { continue; }
+
+ string name = key.Key!.Trim();
+ if (byKey.ContainsKey(name) is false) { byKey.Add(name, key); }
+ }
+
+ return byKey;
+ }
+
+ private static string DescribeRemoval(CatalogSnapshotEntry entry, IReadOnlyList> added) {
+ // A rename shows up as a removal plus an addition. When exactly one added error shares the removed error's
+ // title (compared normalized, like every other comparison), surface it as a hint — the removal stays breaking
+ // either way (consumers know the old code).
+ string? title = Normalize(entry.Title);
+ List> sameTitle = title is null
+ ? []
+ : added.Where(candidate => string.Equals(Normalize(candidate.Value.Title), title, StringComparison.Ordinal)).ToList();
+
+ return sameTitle.Count == 1
+ ? $"error removed (possibly renamed to '{sameTitle[0].Key}', which has the same title)"
+ : "error removed";
+ }
+
+ private static string DescribeAddition(CatalogSnapshotEntry entry) {
+ string title = string.IsNullOrWhiteSpace(entry.Title) ? "new error" : $"new error {Quote(entry.Title)}";
+ string source = string.IsNullOrWhiteSpace(entry.Source) ? string.Empty : $" (source: {entry.Source.Trim()})";
+
+ return title + source;
+ }
+
+ private static bool Differs(string? before, string? after) {
+ return string.Equals(Normalize(before), Normalize(after), StringComparison.Ordinal) is false;
+ }
+
+ private static string? Normalize(string? value) {
+ return string.IsNullOrWhiteSpace(value) ? null : value!.Trim();
+ }
+
+ private static string Quote(string? value) {
+ string? normalized = Normalize(value);
+
+ return normalized is null ? "(none)" : $"'{normalized}'";
+ }
+
+ #endregion
+
+}
diff --git a/FirstClassErrors.GenDoc/Versioning/CatalogSchemaTooNewException.cs b/FirstClassErrors.GenDoc/Versioning/CatalogSchemaTooNewException.cs
new file mode 100644
index 0000000..b2d5897
--- /dev/null
+++ b/FirstClassErrors.GenDoc/Versioning/CatalogSchemaTooNewException.cs
@@ -0,0 +1,31 @@
+namespace FirstClassErrors.GenDoc.Versioning;
+
+///
+/// Thrown when a snapshot declares a schema newer than : it was
+/// produced by a newer version of the tooling and cannot be read safely.
+///
+///
+/// It derives from , so callers that treat every invalid snapshot alike
+/// keep working. It is nonetheless a distinct type so a caller can tell "your tool is too old" apart from a
+/// genuinely corrupt file — for example so that catalog update refuses a newer baseline (rather than
+/// silently downgrading it) while still self-healing an unreadable one.
+///
+public sealed class CatalogSchemaTooNewException : InvalidOperationException {
+
+ #region Constructors declarations
+
+ internal CatalogSchemaTooNewException(int declaredSchema, int supportedSchema)
+ : base($"The snapshot declares schema {declaredSchema}, but this tool only understands schema {supportedSchema} or lower. Update the tool to read it.") {
+ DeclaredSchema = declaredSchema;
+ SupportedSchema = supportedSchema;
+ }
+
+ #endregion
+
+ /// Gets the schema version the snapshot declares.
+ public int DeclaredSchema { get; }
+
+ /// Gets the highest schema version this tool understands.
+ public int SupportedSchema { get; }
+
+}
diff --git a/FirstClassErrors.GenDoc/Versioning/CatalogSnapshot.cs b/FirstClassErrors.GenDoc/Versioning/CatalogSnapshot.cs
new file mode 100644
index 0000000..a72fb47
--- /dev/null
+++ b/FirstClassErrors.GenDoc/Versioning/CatalogSnapshot.cs
@@ -0,0 +1,149 @@
+namespace FirstClassErrors.GenDoc.Versioning;
+
+///
+/// The canonical, machine-comparable projection of an error catalog: the part of the catalog that constitutes a
+/// public contract — error codes and their declared context keys — plus the light documentation identity
+/// (title, source) used to explain changes. It is the unit of comparison for catalog versioning, independent of
+/// whatever renderer (JSON, Markdown, HTML, custom) is used to publish the human-facing documentation.
+///
+///
+///
+/// Error codes leak outside the producing system: client applications branch on them, dashboards alert on
+/// them, support procedures reference them. Removing or renaming a code is therefore a breaking change of the
+/// same nature as removing a public API member. The snapshot materializes that contract as a small,
+/// deterministic JSON document that can be committed as a baseline and diffed in CI (see
+/// ).
+///
+///
+/// The snapshot is deterministic: errors are ordered by code and context keys by name (ordinal), so the same
+/// catalog always serializes to the same bytes and the committed baseline diffs cleanly in source control.
+///
+///
+public sealed class CatalogSnapshot {
+
+ #region Constants declarations
+
+ ///
+ /// The schema version written by this library. A snapshot declaring a higher schema was produced by a newer
+ /// version of the tooling and is rejected at parse time rather than silently misread.
+ ///
+ public const int CurrentSchema = 1;
+
+ #endregion
+
+ #region Statics members declarations
+
+ ///
+ /// Builds the canonical snapshot of the given catalog.
+ ///
+ /// The aggregated, deduplicated error documentation to project.
+ /// The deterministic snapshot of the catalog's contract.
+ ///
+ /// Entries without a code are skipped: a code is the identity under which a contract can be tracked, so an
+ /// uncoded entry has nothing to version. Should two entries still share a code (the extraction pipeline
+ /// already deduplicates), the first one in (code, source, title) order wins — a deterministic
+ /// tie-breaker independent of the reflection-driven input order. Context keys are deduplicated by name within
+ /// each error, keeping the first value type seen in name order.
+ ///
+ /// Thrown when is null.
+ public static CatalogSnapshot FromCatalog(IEnumerable catalog) {
+ if (catalog is null) { throw new ArgumentNullException(nameof(catalog)); }
+
+ List entries = catalog
+ .Where(error => string.IsNullOrWhiteSpace(error.Code) is false)
+ // Order by code, then a deterministic tie-breaker (source, then title), so
+ // that when several factories share a code the surviving entry — group.First()
+ // below — is chosen independently of the (reflection-driven) input order.
+ .OrderBy(error => error.Code, StringComparer.Ordinal)
+ .ThenBy(error => error.Source, StringComparer.Ordinal)
+ .ThenBy(error => error.Title, StringComparer.Ordinal)
+ .GroupBy(error => error.Code!.Trim(), StringComparer.Ordinal)
+ .Select(group => ToEntry(group.Key, group.First()))
+ .OrderBy(entry => entry.Code, StringComparer.Ordinal)
+ .ToList();
+
+ return new CatalogSnapshot { Schema = CurrentSchema, Errors = entries };
+ }
+
+ private static CatalogSnapshotEntry ToEntry(string code, ErrorDocumentation error) {
+ List contextKeys = error.Context
+ .Where(entry => string.IsNullOrWhiteSpace(entry.Key) is false)
+ .OrderBy(entry => entry.Key, StringComparer.Ordinal)
+ .GroupBy(entry => entry.Key!.Trim(), StringComparer.Ordinal)
+ .Select(group => new CatalogSnapshotContextKey {
+ Key = group.Key,
+ ValueType = group.First().ValueType
+ })
+ // Re-sort on the trimmed names: a padded key must not
+ // break the documented "ordered by Key (ordinal)" invariant.
+ .OrderBy(key => key.Key, StringComparer.Ordinal)
+ .ToList();
+
+ return new CatalogSnapshotEntry {
+ Code = code,
+ Source = error.Source,
+ Title = error.Title,
+ Context = contextKeys
+ };
+ }
+
+ #endregion
+
+ ///
+ /// Gets or sets the schema version of the snapshot document (see ).
+ ///
+ public int Schema { get; set; } = CurrentSchema;
+
+ ///
+ /// Gets or sets the tracked errors, ordered by (ordinal).
+ ///
+ public IReadOnlyList Errors { get; set; } = [];
+
+}
+
+///
+/// The contract of a single documented error inside a .
+///
+public sealed class CatalogSnapshotEntry {
+
+ ///
+ /// Gets or sets the stable error code — the identity under which the error is tracked across versions.
+ ///
+ public string? Code { get; set; }
+
+ ///
+ /// Gets or sets the source of the error (the [ProvidesErrorsFor] target). Documentation-level identity:
+ /// a change is reported as informational, never as breaking.
+ ///
+ public string? Source { get; set; }
+
+ ///
+ /// Gets or sets the human-readable title of the error. Documentation-level identity: a change is reported as
+ /// informational, and a matching title is used to hint at probable renames.
+ ///
+ public string? Title { get; set; }
+
+ ///
+ /// Gets or sets the declared context keys of the error, ordered by
+ /// (ordinal). Context keys are part of the contract: log pipelines and dashboards read them by name.
+ ///
+ public IReadOnlyList Context { get; set; } = [];
+
+}
+
+///
+/// A context key declared by a documented error: its name and the (fully qualified) name of its value type.
+///
+public sealed class CatalogSnapshotContextKey {
+
+ ///
+ /// Gets or sets the unique name of the context key.
+ ///
+ public string? Key { get; set; }
+
+ ///
+ /// Gets or sets the (fully qualified) name of the value type associated with the key.
+ ///
+ public string? ValueType { get; set; }
+
+}
diff --git a/FirstClassErrors.GenDoc/Versioning/CatalogSnapshotSerializer.cs b/FirstClassErrors.GenDoc/Versioning/CatalogSnapshotSerializer.cs
new file mode 100644
index 0000000..8ea694d
--- /dev/null
+++ b/FirstClassErrors.GenDoc/Versioning/CatalogSnapshotSerializer.cs
@@ -0,0 +1,135 @@
+#region Usings declarations
+
+using System.Text.Json;
+
+#endregion
+
+namespace FirstClassErrors.GenDoc.Versioning;
+
+///
+/// Reads and writes documents as deterministic JSON, suitable for committing as a
+/// baseline file and diffing in source control.
+///
+///
+/// Serialization is pinned so the same snapshot always produces the same bytes on every platform: camelCase
+/// property names, two-space indentation, \n line endings and a single trailing newline. Parsing is
+/// tolerant of property-name casing and normalizes identities (codes and context-key names are trimmed, missing
+/// lists become empty), but strict about the contract: a document that does not declare a schema, declares
+/// an invalid one, or declares one newer than is rejected rather
+/// than silently misread.
+///
+public static class CatalogSnapshotSerializer {
+
+ #region Statics members declarations
+
+ private static readonly JsonSerializerOptions WriteOptions = new() {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ private static readonly JsonSerializerOptions ReadOptions = new() {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ ///
+ /// Serializes the snapshot to its canonical JSON form.
+ ///
+ /// The snapshot to serialize.
+ /// The canonical JSON text, ending with a single newline.
+ /// Thrown when is null.
+ public static string Serialize(CatalogSnapshot snapshot) {
+ if (snapshot is null) { throw new ArgumentNullException(nameof(snapshot)); }
+
+ // Normalize line endings to \n rather than relying on JsonSerializerOptions.NewLine, which is .NET 9+ only:
+ // the documentation tooling must also build on the .NET 8 floor, and the committed baseline must stay
+ // byte-stable across platforms (a CRLF host would otherwise churn the file).
+ string json = JsonSerializer.Serialize(snapshot, WriteOptions).Replace("\r\n", "\n");
+
+ return json + "\n";
+ }
+
+ ///
+ /// Parses a snapshot from its JSON form.
+ ///
+ /// The JSON text of the snapshot.
+ ///
+ /// The parsed snapshot, with trimmed codes and context-key names and a never-null
+ /// list.
+ ///
+ /// Thrown when is null.
+ ///
+ /// Thrown when the text is not valid JSON, when the document does not declare a valid schema version,
+ /// or when an entry has no code.
+ ///
+ ///
+ /// Thrown when the document declares a schema newer than (a
+ /// subtype of ).
+ ///
+ public static CatalogSnapshot Deserialize(string json) {
+ if (json is null) { throw new ArgumentNullException(nameof(json)); }
+
+ SnapshotDocument? document;
+ try {
+ document = JsonSerializer.Deserialize(json, ReadOptions);
+ } catch (JsonException exception) {
+ throw new InvalidOperationException($"The snapshot is not valid JSON: {exception.Message}", exception);
+ }
+
+ if (document is null) {
+ throw new InvalidOperationException("The snapshot is empty.");
+ }
+
+ // The schema is bound through a nullable so a document that omits it entirely is detected, instead of
+ // silently inheriting the model's default and bypassing this guard.
+ if (document.Schema is null || document.Schema < 1) {
+ throw new InvalidOperationException("The snapshot does not declare a valid 'schema' version.");
+ }
+
+ if (document.Schema > CatalogSnapshot.CurrentSchema) {
+ // A distinct type (not a plain InvalidOperationException) so a caller can refuse a too-new baseline
+ // instead of mistaking it for a corrupt one — see CatalogSchemaTooNewException.
+ throw new CatalogSchemaTooNewException(document.Schema.Value, CatalogSnapshot.CurrentSchema);
+ }
+
+ // A hand-edited "errors": null (or a missing property) deserializes to null; keep the never-null invariant.
+ List errors = document.Errors ?? [];
+
+ foreach (CatalogSnapshotEntry entry in errors) {
+ if (string.IsNullOrWhiteSpace(entry.Code)) {
+ throw new InvalidOperationException("The snapshot contains an entry without a 'code'; every tracked error must have one.");
+ }
+
+ // Identities are normalized here, once, so the differ can rely on trimmed codes and key names even for
+ // hand-edited baselines.
+ entry.Code = entry.Code!.Trim();
+
+ // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
+ if (entry.Context is null) { entry.Context = []; }
+
+ foreach (CatalogSnapshotContextKey key in entry.Context) {
+ if (key.Key is not null) { key.Key = key.Key.Trim(); }
+ }
+ }
+
+ return new CatalogSnapshot { Schema = document.Schema.Value, Errors = errors };
+ }
+
+ #endregion
+
+ #region Nested types declarations
+
+ ///
+ /// The raw shape of a snapshot document, with a nullable schema so its absence is detectable at parse time.
+ ///
+ private sealed class SnapshotDocument {
+
+ public int? Schema { get; set; }
+
+ public List? Errors { get; set; }
+
+ }
+
+ #endregion
+
+}
diff --git a/README.md b/README.md
index a56ae7c..264a7eb 100644
--- a/README.md
+++ b/README.md
@@ -249,6 +249,7 @@ See the full documentation:
- [Best Practices](doc/BestPractices.en.md)
- [Testing Guide](doc/Testing.en.md)
- [CI/CD and Operational Integration](doc/OperationalIntegration.en.md)
+- [Catalog Versioning](doc/CatalogVersioning.en.md)
- [Architecture of the Documentation Pipeline](doc/ArchitectureOfTheDocumentationPipeline.en.md)
- [Writing a custom renderer](doc/WritingACustomRenderer.en.md)
- [Internationalization](doc/Internationalization.en.md)
diff --git a/doc/ArchitectureOfTheDocumentationPipeline.en.md b/doc/ArchitectureOfTheDocumentationPipeline.en.md
index debe093..b9c236d 100644
--- a/doc/ArchitectureOfTheDocumentationPipeline.en.md
+++ b/doc/ArchitectureOfTheDocumentationPipeline.en.md
@@ -179,7 +179,7 @@ The code is the source of truth.
---
---
diff --git a/doc/ArchitectureOfTheDocumentationPipeline.fr.md b/doc/ArchitectureOfTheDocumentationPipeline.fr.md
index 3f1f157..79eb45e 100644
--- a/doc/ArchitectureOfTheDocumentationPipeline.fr.md
+++ b/doc/ArchitectureOfTheDocumentationPipeline.fr.md
@@ -179,7 +179,7 @@ Le code est la source de vérité.
---
---
diff --git a/doc/CatalogVersioning.en.md b/doc/CatalogVersioning.en.md
new file mode 100644
index 0000000..300c7b7
--- /dev/null
+++ b/doc/CatalogVersioning.en.md
@@ -0,0 +1,165 @@
+# Catalog Versioning
+
+🌍 **Languages:**
+🇬🇧 English (this file) | 🇫🇷 [Français](./CatalogVersioning.fr.md)
+
+An error code does not stay inside the system that emits it. Client applications branch on it, dashboards alert on it, support procedures reference it. Removing or renaming a code is therefore a **breaking change** — of the same nature as removing a public API member — and it deserves the same guardrail: a committed reference, and a CI step that fails when the contract drifts by accident.
+
+FirstClassErrors provides that guardrail through two commands: `fce catalog update` and `fce catalog diff`.
+
+## 🧾 The contract snapshot
+
+The unit of comparison is the **canonical snapshot**: a small, deterministic JSON projection of the catalog containing only what constitutes the contract.
+
+| Tracked | Role |
+| --- | --- |
+| `code` | The identity of the error. Its removal is breaking. |
+| `context` (key name + value type) | The structured data attached to occurrences. Log pipelines and dashboards read these by name; a removal or a type change is breaking. |
+| `title`, `source` | Documentation identity — changes are reported as informational, and matching titles are used to hint at probable renames. |
+
+Messages, explanations, business rules and diagnostics are deliberately **not** tracked: they are documentation, extracted from live examples, and free to evolve without touching the contract.
+
+The snapshot is independent of the renderer: whether you publish the human-facing catalog as HTML, Markdown, JSON or a custom format, the same contract file drives versioning. It is deterministic — errors ordered by code, context keys by name, pinned line endings — so the committed file never depends on the machine that produced it. The `fce catalog` commands always extract it under the `en` culture, so the baseline stays culture-independent even when your catalog is localized (see [Internationalization](Internationalization.en.md)).
+
+## 📌 Create the baseline
+
+```bash
+fce catalog update --solution MyApp.sln
+```
+
+This extracts the catalog, projects it into its snapshot, and writes `errors-baseline.json` (override with `--baseline`, or set `baseline` in `fce.json`). **Commit this file**: it is the accepted contract, and every change to it goes through code review like any other contract change.
+
+## 🔍 Detect drift in CI
+
+```bash
+fce catalog diff --solution MyApp.sln
+```
+
+The command extracts the current catalog, compares it against the baseline, and writes a report to standard output. Its exit code is designed for pipelines:
+
+| Exit code | Meaning |
+| --- | --- |
+| `0` | No change at or above the `--fail-on` threshold. |
+| `2` | The contract drifted: at least one change reaches the threshold. |
+| `1` | Execution error (missing baseline, failed extraction, …). |
+
+`--fail-on` selects the policy: `breaking` (default), `any` (any change at all fails, including additions), or `none` (report only). `--report` selects the output: `text` (default), `markdown` (ready to post as a pull-request comment) or `json` (for tooling).
+
+## 🧮 How changes are classified
+
+| Change | Impact |
+| --- | --- |
+| Error code removed | 💥 Breaking |
+| Context key removed | 💥 Breaking |
+| Context key value type changed | 💥 Breaking |
+| Error code added | ✅ Compatible |
+| Context key added | ✅ Compatible |
+| Title or source changed | ℹ️ Informational |
+
+A **rename** is a removal plus an addition — and stays breaking, because consumers know the old code. When exactly one added error shares the removed error's title, the report adds a hint: *"possibly renamed to 'NEW_CODE', which has the same title"*.
+
+## ✍️ Accepting a change deliberately
+
+When a contract change is intentional, refresh the baseline and commit it:
+
+```bash
+fce catalog update --solution MyApp.sln
+```
+
+The command summarizes what it absorbs (`1 breaking, 2 compatible and 0 documentation change(s) accepted`), and the pull request then shows the baseline diff — a removed code appears as a removed line. The accident becomes impossible; the deliberate change becomes visible and reviewable. This is the same discipline as a public-API baseline file, applied to the error catalog.
+
+## ⚙️ Snapshots without a baseline
+
+Two more ways to produce and compare snapshots:
+
+* `fce generate --snapshot ` also writes the canonical snapshot next to whatever format you render — one generation, both the human catalog and the contract file. It reflects the render `--language`; when that is not English the command warns, because a committed baseline should stay culture-independent — use `fce catalog update` (or `--language en`) for that.
+* `fce catalog diff --against ` compares the baseline against a snapshot **file** instead of extracting from the source — useful for comparing two release artifacts.
+
+Both `baseline` and `snapshot` can be set in `fce.json` so the paths need not be repeated on every run.
+
+## 🚦 Wiring it into CI/CD: a complete example
+
+The goal of the CI integration is simple: **contract drift must be visible where the change is reviewed** — in the pull request itself, not in a log nobody reads. The loop looks like this:
+
+1. Every pull request runs `fce catalog diff` against the committed baseline.
+2. **No change** → the job passes silently.
+3. **Compatible or documentation changes** → the job still passes (with the default `--fail-on breaking`); the report can be posted for awareness.
+4. **Breaking change** → exit code `2` fails the job, and the Markdown report lands as a pull-request comment. The author then has exactly two honest ways out: fix the accidental removal, or accept it deliberately with `fce catalog update` — in which case the reviewer sees the baseline diff (the removed code appears as a removed line) and approves a breaking change *knowingly*.
+
+Walked through on a concrete scenario: a developer renames `PAYMENT.DECLINED` to `PAYMENT.REFUSED` while refactoring. Without the guardrail, the rename ships silently and every dashboard and client branching on the old code breaks in production. With it, the pull request fails with:
+
+```
+Breaking changes (1):
+ - [removed] PAYMENT.DECLINED — error removed (possibly renamed to 'PAYMENT.REFUSED', which has the same title)
+Compatible changes (1):
+ - [added] PAYMENT.REFUSED — new error 'Payment declined' (source: Payment)
+```
+
+If the rename was accidental, the developer reverts it. If it was deliberate, they run `fce catalog update`, commit `errors-baseline.json`, and the contract change becomes an explicit, reviewable part of the pull request.
+
+### GitHub Actions
+
+```yaml
+name: error-catalog
+
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ catalog-diff:
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write # needed to post the report as a comment
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ # Make fce available on the runner (dotnet tool install, a cached
+ # build from source, or your internal distribution).
+
+ - name: Compare the catalog against the baseline
+ run: fce catalog diff --solution MyApp.sln --report markdown > catalog-diff.md
+ # Exit code 2 (breaking change) fails this step, and therefore the job.
+
+ - name: Post the report on the pull request
+ if: failure() # comment only when the contract drifted
+ run: gh pr comment ${{ github.event.pull_request.number }} --body-file catalog-diff.md
+ env:
+ GH_TOKEN: ${{ github.token }}
+```
+
+Two behaviors worth noting: the report is generated *before* the step fails (the redirection captures standard output, the exit code fails the step afterwards), and the comment step runs only `if: failure()` — a quiet pipeline stays quiet.
+
+### GitLab CI
+
+```yaml
+error-catalog:
+ stage: test
+ image: mcr.microsoft.com/dotnet/sdk:10.0
+ script:
+ # Make fce available on the runner (dotnet tool install, a cached
+ # build from source, or your internal distribution).
+ - fce catalog diff --solution MyApp.sln --report markdown > catalog-diff.md
+ artifacts:
+ when: always # keep the report even when the job fails on a breaking change
+ paths:
+ - catalog-diff.md
+```
+
+The exit code drives the job result exactly as on GitHub; `artifacts: when: always` keeps the report downloadable from the failed pipeline, and it can be posted on the merge request with a call to the [notes API](https://docs.gitlab.com/ee/api/notes.html) if you want the comment automation too.
+
+### Beyond pull requests
+
+Running the same `fce catalog diff` on the main branch (on push or on a schedule) catches drift that bypassed the pull-request flow, and `fce catalog diff --against` lets a release pipeline compare two published snapshots — for example, the snapshot shipped with the previous release against the candidate one — to generate release notes for the error contract.
+
+---
+
+
+
+---
diff --git a/doc/CatalogVersioning.fr.md b/doc/CatalogVersioning.fr.md
new file mode 100644
index 0000000..fbb8484
--- /dev/null
+++ b/doc/CatalogVersioning.fr.md
@@ -0,0 +1,165 @@
+# Versionnage du catalogue
+
+🌍 **Langues:**
+🇬🇧 [English](./CatalogVersioning.en.md) | 🇫🇷 Français (ce fichier)
+
+Un code d'erreur ne reste pas à l'intérieur du système qui l'émet. Des applications clientes branchent leur logique dessus, des tableaux de bord déclenchent des alertes dessus, des procédures de support y font référence. Supprimer ou renommer un code est donc un **changement cassant** — de même nature que la suppression d'un membre d'API publique — et mérite le même garde-fou : une référence commitée, et une étape de CI qui échoue quand le contrat dérive par accident.
+
+FirstClassErrors fournit ce garde-fou avec deux commandes : `fce catalog update` et `fce catalog diff`.
+
+## 🧾 Le snapshot de contrat
+
+L'unité de comparaison est le **snapshot canonique** : une petite projection JSON déterministe du catalogue, contenant uniquement ce qui constitue le contrat.
+
+| Suivi | Rôle |
+| --- | --- |
+| `code` | L'identité de l'erreur. Sa suppression est cassante. |
+| `context` (nom de clé + type de valeur) | Les données structurées attachées aux occurrences. Les pipelines de logs et les tableaux de bord les lisent par leur nom ; une suppression ou un changement de type est cassant. |
+| `title`, `source` | Identité documentaire — les changements sont signalés à titre informatif, et les titres identiques servent d'indice de renommage probable. |
+
+Les messages, explications, règles métier et diagnostics ne sont délibérément **pas** suivis : c'est de la documentation, extraite d'exemples vivants, libre d'évoluer sans toucher au contrat.
+
+Le snapshot est indépendant du renderer : que le catalogue destiné aux humains soit publié en HTML, Markdown, JSON ou dans un format personnalisé, c'est le même fichier de contrat qui pilote le versionnage. Il est déterministe — erreurs triées par code, clés de contexte par nom, fins de ligne fixées — si bien que le fichier commité ne dépend jamais de la machine qui l'a produit. Les commandes `fce catalog` l'extraient toujours sous la culture `en`, de sorte que la baseline reste indépendante de la langue même quand votre catalogue est localisé (voir [Internationalisation](Internationalisation.fr.md)).
+
+## 📌 Créer la baseline
+
+```bash
+fce catalog update --solution MyApp.sln
+```
+
+Cette commande extrait le catalogue, le projette en snapshot et écrit `errors-baseline.json` (modifiable avec `--baseline`, ou via `baseline` dans `fce.json`). **Commitez ce fichier** : c'est le contrat accepté, et chaque modification passe en revue de code comme n'importe quel autre changement de contrat.
+
+## 🔍 Détecter la dérive en CI
+
+```bash
+fce catalog diff --solution MyApp.sln
+```
+
+La commande extrait le catalogue courant, le compare à la baseline et écrit un rapport sur la sortie standard. Son code de sortie est conçu pour les pipelines :
+
+| Code de sortie | Signification |
+| --- | --- |
+| `0` | Aucun changement au niveau du seuil `--fail-on` ou au-dessus. |
+| `2` | Le contrat a dérivé : au moins un changement atteint le seuil. |
+| `1` | Erreur d'exécution (baseline manquante, extraction en échec, …). |
+
+`--fail-on` choisit la politique : `breaking` (défaut), `any` (tout changement fait échouer, y compris les ajouts) ou `none` (rapport seul). `--report` choisit la sortie : `text` (défaut), `markdown` (prêt à poster en commentaire de pull request) ou `json` (pour l'outillage).
+
+## 🧮 Classification des changements
+
+| Changement | Impact |
+| --- | --- |
+| Code d'erreur supprimé | 💥 Cassant |
+| Clé de contexte supprimée | 💥 Cassant |
+| Type de valeur d'une clé de contexte modifié | 💥 Cassant |
+| Code d'erreur ajouté | ✅ Compatible |
+| Clé de contexte ajoutée | ✅ Compatible |
+| Titre ou source modifié | ℹ️ Informatif |
+
+Un **renommage** est une suppression plus un ajout — et reste cassant, car les consommateurs connaissent l'ancien code. Quand exactement une erreur ajoutée porte le même titre que l'erreur supprimée, le rapport ajoute un indice : *« possibly renamed to 'NEW_CODE', which has the same title »*.
+
+## ✍️ Accepter un changement délibérément
+
+Quand un changement de contrat est intentionnel, rafraîchissez la baseline et commitez-la :
+
+```bash
+fce catalog update --solution MyApp.sln
+```
+
+La commande résume ce qu'elle absorbe (`1 breaking, 2 compatible and 0 documentation change(s) accepted`), et la pull request montre alors le diff de la baseline — un code supprimé apparaît comme une ligne supprimée. L'accident devient impossible ; le changement délibéré devient visible et relisible. C'est la même discipline qu'un fichier de baseline d'API publique, appliquée au catalogue d'erreurs.
+
+## ⚙️ Des snapshots sans baseline
+
+Deux autres façons de produire et comparer des snapshots :
+
+* `fce generate --snapshot ` écrit aussi le snapshot canonique à côté du format rendu, quel qu'il soit — une seule génération produit à la fois le catalogue pour les humains et le fichier de contrat. Il reflète la langue de rendu `--language` ; lorsqu'elle n'est pas l'anglais, la commande émet un avertissement, car une baseline commitée doit rester indépendante de la langue — utilisez `fce catalog update` (ou `--language en`) pour cela.
+* `fce catalog diff --against ` compare la baseline à un **fichier** de snapshot au lieu d'extraire depuis la source — utile pour comparer deux artefacts de release.
+
+`baseline` et `snapshot` peuvent être définis dans `fce.json` pour ne pas répéter les chemins à chaque exécution.
+
+## 🚦 Intégration CI/CD : un exemple complet
+
+L'objectif de l'intégration CI est simple : **la dérive du contrat doit être visible là où le changement est relu** — dans la pull request elle-même, pas dans un log que personne ne lit. La boucle est la suivante :
+
+1. Chaque pull request exécute `fce catalog diff` contre la baseline commitée.
+2. **Aucun changement** → le job passe silencieusement.
+3. **Changements compatibles ou documentaires** → le job passe quand même (avec le `--fail-on breaking` par défaut) ; le rapport peut être posté pour information.
+4. **Changement cassant** → le code de sortie `2` fait échouer le job, et le rapport Markdown atterrit en commentaire de la pull request. L'auteur n'a alors que deux issues honnêtes : corriger la suppression accidentelle, ou l'accepter délibérément avec `fce catalog update` — auquel cas le relecteur voit le diff de la baseline (le code supprimé apparaît comme une ligne supprimée) et approuve un changement cassant *en connaissance de cause*.
+
+Déroulé sur un scénario concret : un développeur renomme `PAYMENT.DECLINED` en `PAYMENT.REFUSED` au cours d'un refactoring. Sans le garde-fou, le renommage part silencieusement et chaque tableau de bord ou client branché sur l'ancien code casse en production. Avec lui, la pull request échoue avec :
+
+```
+Breaking changes (1):
+ - [removed] PAYMENT.DECLINED — error removed (possibly renamed to 'PAYMENT.REFUSED', which has the same title)
+Compatible changes (1):
+ - [added] PAYMENT.REFUSED — new error 'Payment declined' (source: Payment)
+```
+
+Si le renommage était accidentel, le développeur le corrige. S'il était délibéré, il exécute `fce catalog update`, committe `errors-baseline.json`, et le changement de contrat devient une partie explicite et relisible de la pull request.
+
+### GitHub Actions
+
+```yaml
+name: error-catalog
+
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ catalog-diff:
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write # needed to post the report as a comment
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ # Make fce available on the runner (dotnet tool install, a cached
+ # build from source, or your internal distribution).
+
+ - name: Compare the catalog against the baseline
+ run: fce catalog diff --solution MyApp.sln --report markdown > catalog-diff.md
+ # Exit code 2 (breaking change) fails this step, and therefore the job.
+
+ - name: Post the report on the pull request
+ if: failure() # comment only when the contract drifted
+ run: gh pr comment ${{ github.event.pull_request.number }} --body-file catalog-diff.md
+ env:
+ GH_TOKEN: ${{ github.token }}
+```
+
+Deux comportements à noter : le rapport est généré *avant* que l'étape n'échoue (la redirection capture la sortie standard, puis le code de sortie fait échouer l'étape), et l'étape de commentaire ne s'exécute que sur `if: failure()` — un pipeline sain reste silencieux.
+
+### GitLab CI
+
+```yaml
+error-catalog:
+ stage: test
+ image: mcr.microsoft.com/dotnet/sdk:10.0
+ script:
+ # Make fce available on the runner (dotnet tool install, a cached
+ # build from source, or your internal distribution).
+ - fce catalog diff --solution MyApp.sln --report markdown > catalog-diff.md
+ artifacts:
+ when: always # keep the report even when the job fails on a breaking change
+ paths:
+ - catalog-diff.md
+```
+
+Le code de sortie pilote le résultat du job exactement comme sur GitHub ; `artifacts: when: always` garde le rapport téléchargeable depuis le pipeline en échec, et il peut être posté sur la merge request via l'[API notes](https://docs.gitlab.com/ee/api/notes.html) si vous souhaitez aussi automatiser le commentaire.
+
+### Au-delà des pull requests
+
+Exécuter le même `fce catalog diff` sur la branche principale (à chaque push ou de façon planifiée) attrape les dérives qui auraient contourné le flux de pull request, et `fce catalog diff --against` permet à un pipeline de release de comparer deux snapshots publiés — par exemple celui livré avec la release précédente contre celui du candidat — pour générer des notes de version du contrat d'erreurs.
+
+---
+
+
+
+---
diff --git a/doc/OperationalIntegration.en.md b/doc/OperationalIntegration.en.md
index 17739da..d76f122 100644
--- a/doc/OperationalIntegration.en.md
+++ b/doc/OperationalIntegration.en.md
@@ -35,6 +35,16 @@ You can emit the catalog per locale by adding `--language <…>` (e.g. a CI matr
The public RFC 9457 examples carry a problem `type` of the shape `urn:problem:{service}:{code}`. Provide the service segment with `--service-name ` (or `serviceName` in `fce.json`); it is required for the `markdown` and `html` formats — `fce generate` fails with a clear message when it is missing — while `json` (which carries no such example) does not need it.
+## 🛡️ Guarding the error contract
+
+Error codes and context keys are a public contract: clients branch on them, dashboards alert on them, support procedures reference them. A CI step can guard that contract by comparing the current catalog against a committed baseline and failing the build when a code disappears by accident:
+
+```bash
+fce catalog diff --solution MyApp.sln
+```
+
+See [Catalog Versioning](CatalogVersioning.en.md) for the baseline workflow, the change classification and the exit-code semantics.
+
## 🌍 Publishing documentation
The generated documentation can be:
@@ -114,7 +124,7 @@ Errors become:
---
---
\ No newline at end of file
diff --git a/doc/OperationalIntegration.fr.md b/doc/OperationalIntegration.fr.md
index 17067ac..bcbfe9c 100644
--- a/doc/OperationalIntegration.fr.md
+++ b/doc/OperationalIntegration.fr.md
@@ -35,6 +35,16 @@ Vous pouvez produire le catalogue par langue en ajoutant `--language <…>` (par
Les exemples publics RFC 9457 portent un `type` de problème de la forme `urn:problem:{service}:{code}`. Fournissez le segment de service avec `--service-name ` (ou `serviceName` dans `fce.json`) ; il est requis pour les formats `markdown` et `html` — `fce generate` échoue avec un message clair lorsqu’il est absent — tandis que `json` (qui ne contient pas cet exemple) n’en a pas besoin.
+## 🛡️ Protéger le contrat d'erreurs
+
+Les codes d'erreur et les clés de contexte sont un contrat public : des clients branchent leur logique dessus, des tableaux de bord alertent dessus, des procédures de support y font référence. Une étape de CI peut protéger ce contrat en comparant le catalogue courant à une baseline commitée et en faisant échouer le build quand un code disparaît par accident :
+
+```bash
+fce catalog diff --solution MyApp.sln
+```
+
+Voir [Versionnage du catalogue](CatalogVersioning.fr.md) pour le workflow de baseline, la classification des changements et la sémantique des codes de sortie.
+
## 🌍 Publication de la documentation
La documentation générée peut être :
@@ -114,7 +124,7 @@ Les erreurs deviennent :
---
---
\ No newline at end of file
diff --git a/doc/README.fr.md b/doc/README.fr.md
index 79c3f55..c360509 100644
--- a/doc/README.fr.md
+++ b/doc/README.fr.md
@@ -249,6 +249,7 @@ Consultez la documentation complète :
- [Bonnes pratiques](BestPractices.fr.md)
- [Guide des tests](Testing.fr.md)
- [Intégration CI/CD et exploitation](OperationalIntegration.fr.md)
+- [Versionnage du catalogue](CatalogVersioning.fr.md)
- [Architecture du pipeline de documentation](ArchitectureOfTheDocumentationPipeline.fr.md)
- [Écrire son propre renderer](WritingACustomRenderer.fr.md)
- [Internationalisation](Internationalisation.fr.md)