From 93e42f19139968aa24c2e26203067fbbdbc11096 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:34:10 +0000 Subject: [PATCH 1/5] feat(cli,gendoc): track the error catalog as a versioned contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error codes and context keys are a public contract — clients branch on them, dashboards alert on them, support procedures reference them — so removing or renaming one is a breaking change. Add catalog versioning. - CatalogSnapshot: a deterministic, renderer-independent JSON projection of the contract (codes, context keys + value types, doc identity), ordered by code with a deterministic tie-breaker. Its serializer is strict and schema-versioned and normalizes line endings without the .NET 9+ JsonSerializerOptions.NewLine, so the tooling still builds on the net8 floor and the baseline stays byte-stable across platforms. - CatalogDiffer/CatalogDiff classify each change as breaking (a code or context key removed or retyped), compatible (added) or informational (title/source), with a "possibly renamed" hint; CatalogDiffFormatter emits text, markdown and json reports. - fce catalog update/diff create-or-refresh the committed baseline and compare against it with --fail-on breaking|any|none, exit code 2 on drift; fce generate --snapshot also writes the canonical snapshot. A configured baseline resolves relative to fce.json like renderer paths; snapshot extraction pins the en culture. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J12ghrzM1xiAKw4LETJDqr --- FirstClassErrors.Cli/BaselineStore.cs | 64 +++++ FirstClassErrors.Cli/CatalogDiffCommand.cs | 114 ++++++++ FirstClassErrors.Cli/CatalogSettings.cs | 60 +++++ FirstClassErrors.Cli/CatalogSnapshotSource.cs | 67 +++++ FirstClassErrors.Cli/CatalogSourceResolver.cs | 62 +++++ FirstClassErrors.Cli/CatalogUpdateCommand.cs | 65 +++++ FirstClassErrors.Cli/CliConfiguration.cs | 6 + FirstClassErrors.Cli/GenerateCommand.cs | 40 ++- .../GenerateOptionsResolver.cs | 2 + FirstClassErrors.Cli/GenerateSettings.cs | 4 + FirstClassErrors.Cli/Program.cs | 6 + .../CatalogDiffFormatterTests.cs | 121 +++++++++ .../CatalogDifferTests.cs | 215 ++++++++++++++++ .../CatalogSnapshotTests.cs | 243 ++++++++++++++++++ .../Versioning/CatalogChange.cs | 89 +++++++ .../Versioning/CatalogDiff.cs | 66 +++++ .../Versioning/CatalogDiffFormatter.cs | 141 ++++++++++ .../Versioning/CatalogDiffer.cs | 173 +++++++++++++ .../Versioning/CatalogSnapshot.cs | 148 +++++++++++ .../Versioning/CatalogSnapshotSerializer.cs | 130 ++++++++++ 20 files changed, 1811 insertions(+), 5 deletions(-) create mode 100644 FirstClassErrors.Cli/BaselineStore.cs create mode 100644 FirstClassErrors.Cli/CatalogDiffCommand.cs create mode 100644 FirstClassErrors.Cli/CatalogSettings.cs create mode 100644 FirstClassErrors.Cli/CatalogSnapshotSource.cs create mode 100644 FirstClassErrors.Cli/CatalogSourceResolver.cs create mode 100644 FirstClassErrors.Cli/CatalogUpdateCommand.cs create mode 100644 FirstClassErrors.GenDoc.UnitTests/CatalogDiffFormatterTests.cs create mode 100644 FirstClassErrors.GenDoc.UnitTests/CatalogDifferTests.cs create mode 100644 FirstClassErrors.GenDoc.UnitTests/CatalogSnapshotTests.cs create mode 100644 FirstClassErrors.GenDoc/Versioning/CatalogChange.cs create mode 100644 FirstClassErrors.GenDoc/Versioning/CatalogDiff.cs create mode 100644 FirstClassErrors.GenDoc/Versioning/CatalogDiffFormatter.cs create mode 100644 FirstClassErrors.GenDoc/Versioning/CatalogDiffer.cs create mode 100644 FirstClassErrors.GenDoc/Versioning/CatalogSnapshot.cs create mode 100644 FirstClassErrors.GenDoc/Versioning/CatalogSnapshotSerializer.cs diff --git a/FirstClassErrors.Cli/BaselineStore.cs b/FirstClassErrors.Cli/BaselineStore.cs new file mode 100644 index 0000000..ac8e23d --- /dev/null +++ b/FirstClassErrors.Cli/BaselineStore.cs @@ -0,0 +1,64 @@ +#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) { + 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 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..af85efd --- /dev/null +++ b/FirstClassErrors.Cli/CatalogDiffCommand.cs @@ -0,0 +1,114 @@ +#region Usings declarations + +using System.ComponentModel; + +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, …). CI pipelines can therefore +/// distinguish "the contract changed" from "the tool failed". +/// +internal sealed class CatalogDiffCommand : Command { + + protected override int Execute(CommandContext context, CatalogDiffSettings settings, CancellationToken cancellationToken) { + ConsoleGenerationLogger logger = new(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) + ? CatalogSnapshotSource.Extract(settings, configuration, logger) + : BaselineStore.Load(Path.GetFullPath(settings.AgainstPath)); + + CatalogDiff diff = CatalogDiffer.Diff(baseline, current); + + Console.Out.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 (Exception exception) { + // Report expected failures (missing solution, worker crash, invalid baseline, …) as a terse line. + logger.Error(exception.Message); + + 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..fad3c85 --- /dev/null +++ b/FirstClassErrors.Cli/CatalogSnapshotSource.cs @@ -0,0 +1,67 @@ +#region Usings declarations + +using System.Globalization; + +using FirstClassErrors.GenDoc; +using FirstClassErrors.GenDoc.Versioning; + +#endregion + +namespace FirstClassErrors.Cli; + +/// +/// Extracts the canonical for the catalog commands: resolves the source +/// (command line first, then configuration), runs the documentation extraction, and projects the resulting +/// catalog into its contract snapshot. +/// +internal static class CatalogSnapshotSource { + + #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"); + + /// + /// 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. + /// The canonical snapshot of the current catalog. + public static CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration configuration, ConsoleGenerationLogger logger) { + (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 + }; + + 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); + } + + #endregion + +} 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..a410d32 --- /dev/null +++ b/FirstClassErrors.Cli/CatalogUpdateCommand.cs @@ -0,0 +1,65 @@ +#region Usings declarations + +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 { + + protected override int Execute(CommandContext context, CatalogUpdateSettings settings, CancellationToken cancellationToken) { + ConsoleGenerationLogger logger = new(settings.Verbose); + + try { + string configPath = ConfigurationStore.Resolve(settings.ConfigPath); + CliConfiguration configuration = ConfigurationStore.Load(configPath); + string configDir = Path.GetDirectoryName(configPath) ?? Directory.GetCurrentDirectory(); + + CatalogSnapshot current = CatalogSnapshotSource.Extract(settings, configuration, logger); + string baselinePath = BaselineStore.Resolve(settings.BaselinePath, configuration.Baseline, configDir); + + if (BaselineStore.Exists(baselinePath) is false) { + BaselineStore.Save(baselinePath, current); + Console.Out.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)) { + Console.Out.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. + CatalogDiff diff = CatalogDiffer.Diff(CatalogSnapshotSerializer.Deserialize(existingText), current); + BaselineStore.Save(baselinePath, current); + + Console.Out.WriteLine(diff.IsEmpty + ? $"Baseline rewritten in canonical form at '{baselinePath}' ({current.Errors.Count} error(s))." + : $"Baseline updated at '{baselinePath}': {diff.BreakingChanges.Count} breaking, {diff.CompatibleChanges.Count} compatible and {diff.InformationalChanges.Count} documentation change(s) accepted."); + + return 0; + } catch (Exception exception) { + // Report expected failures (missing solution, worker crash, invalid baseline, …) as a terse line. + logger.Error(exception.Message); + + 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/GenerateCommand.cs b/FirstClassErrors.Cli/GenerateCommand.cs index 1c45022..d3e6f50 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,33 @@ 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. 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. + if (resolved.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."); + } + + string fullSnapshotPath = Path.GetFullPath(resolved.SnapshotPath); + WriteSnapshotFile(fullSnapshotPath, CatalogSnapshotSerializer.Serialize(CatalogSnapshot.FromCatalog(catalog))); + logger.Info($"Catalog snapshot written to '{fullSnapshotPath}'."); + } + return 0; } catch (OperationCanceledException) { // Cancellation (Ctrl+C) is an abort, not a failure: the child processes are already killed through the @@ -150,6 +167,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/GenerateOptionsResolver.cs b/FirstClassErrors.Cli/GenerateOptionsResolver.cs index 3fcf276..cf008d7 100644 --- a/FirstClassErrors.Cli/GenerateOptionsResolver.cs +++ b/FirstClassErrors.Cli/GenerateOptionsResolver.cs @@ -19,6 +19,7 @@ internal sealed record ResolvedGenerateOptions( string BuildConfiguration, string? Framework, string? WorkerPath, + string? SnapshotPath, bool NoBuild, bool Strict); @@ -57,6 +58,7 @@ public static ResolvedGenerateOptions Resolve(GenerateSettings settings, CliConf BuildConfiguration: FirstNonEmpty(settings.Configuration, configuration.Configuration) ?? "Debug", Framework: FirstNonEmpty(settings.Framework, configuration.Framework), WorkerPath: FirstNonEmpty(settings.WorkerPath, configuration.Worker), + SnapshotPath: FirstNonEmpty(settings.SnapshotPath, configuration.Snapshot), NoBuild: settings.NoBuild || (configuration.NoBuild ?? false), Strict: settings.Strict || (configuration.Strict ?? false)); } 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/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..313c1af --- /dev/null +++ b/FirstClassErrors.GenDoc.UnitTests/CatalogSnapshotTests.cs @@ -0,0 +1,243 @@ +#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 rather than silently misread.")] + public void ASnapshotDeclaringANewerSchemaIsRejected() { + // Exercise & verify + Check.ThatCode(() => CatalogSnapshotSerializer.Deserialize("""{ "schema": 999, "errors": [] }""")) + .Throws(); + } + + [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..77247e8 --- /dev/null +++ b/FirstClassErrors.GenDoc/Versioning/CatalogChange.cs @@ -0,0 +1,89 @@ +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. + /// + 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/CatalogSnapshot.cs b/FirstClassErrors.GenDoc/Versioning/CatalogSnapshot.cs new file mode 100644 index 0000000..e16f188 --- /dev/null +++ b/FirstClassErrors.GenDoc/Versioning/CatalogSnapshot.cs @@ -0,0 +1,148 @@ +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 order wins, deterministically. 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..4abed5c --- /dev/null +++ b/FirstClassErrors.GenDoc/Versioning/CatalogSnapshotSerializer.cs @@ -0,0 +1,130 @@ +#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, + /// when it declares one newer than , or when an entry has no code. + /// + 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) { + throw new InvalidOperationException( + $"The snapshot declares schema {document.Schema}, but this tool only understands schema {CatalogSnapshot.CurrentSchema} or lower. Update the tool to read it."); + } + + // 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 + +} From 7541bea44ecda28cf327b010bc65e5faa7e3ba05 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:34:10 +0000 Subject: [PATCH 2/5] docs: document catalog versioning with a CI/CD example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add doc/CatalogVersioning.{en,fr}.md — the contract snapshot, the baseline workflow, the change classification, the exit-code semantics and a complete GitHub Actions plus GitLab CI walkthrough — and link it from the READMEs, the CI/CD integration guide and the pipeline architecture navigation in both languages. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J12ghrzM1xiAKw4LETJDqr --- README.md | 1 + ...chitectureOfTheDocumentationPipeline.en.md | 2 +- ...chitectureOfTheDocumentationPipeline.fr.md | 2 +- doc/CatalogVersioning.en.md | 165 ++++++++++++++++++ doc/CatalogVersioning.fr.md | 165 ++++++++++++++++++ doc/OperationalIntegration.en.md | 12 +- doc/OperationalIntegration.fr.md | 12 +- doc/README.fr.md | 1 + 8 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 doc/CatalogVersioning.en.md create mode 100644 doc/CatalogVersioning.fr.md 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) From 7f48eb4209825f87f79b0ff0446edd13d4c1e58d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:56:36 +0000 Subject: [PATCH 3/5] fix(cli): resolve configured generate snapshot path against fce.json fce generate resolved a snapshot value from fce.json against the process working directory, unlike the catalog baseline and the renderer references, so `fce --config ci/fce.json generate` wrote the snapshot to the current directory instead of ci/. Resolve it through a shared ConfigRelativePath helper: a --snapshot command-line value still resolves against the current directory, a configured value against the configuration file's directory. BaselineStore delegates to the same helper, and an end-to-end test pins the configured-path behaviour. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J12ghrzM1xiAKw4LETJDqr --- .../GenerateCommandEndToEndTests.cs | 30 +++++++++++++++ FirstClassErrors.Cli/BaselineStore.cs | 13 +------ FirstClassErrors.Cli/ConfigRelativePath.cs | 38 +++++++++++++++++++ FirstClassErrors.Cli/GenerateCommand.cs | 16 ++++---- .../GenerateOptionsResolver.cs | 2 - 5 files changed, 79 insertions(+), 20 deletions(-) create mode 100644 FirstClassErrors.Cli/ConfigRelativePath.cs 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/BaselineStore.cs b/FirstClassErrors.Cli/BaselineStore.cs index ac8e23d..8352700 100644 --- a/FirstClassErrors.Cli/BaselineStore.cs +++ b/FirstClassErrors.Cli/BaselineStore.cs @@ -27,17 +27,8 @@ internal static class BaselineStore { /// 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) { - 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 Path.GetFullPath(DefaultFileName); + return ConfigRelativePath.Resolve(optionPath, configuredPath, configDirectory) + ?? Path.GetFullPath(DefaultFileName); } /// Determines whether a baseline exists at the given (already resolved) path. 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 d3e6f50..1e33eea 100644 --- a/FirstClassErrors.Cli/GenerateCommand.cs +++ b/FirstClassErrors.Cli/GenerateCommand.cs @@ -125,17 +125,19 @@ internal int Run(GenerateSettings settings, CancellationToken cancellationToken) _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. 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. - if (resolved.SnapshotPath is not null) { + // 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."); } - string fullSnapshotPath = Path.GetFullPath(resolved.SnapshotPath); - WriteSnapshotFile(fullSnapshotPath, CatalogSnapshotSerializer.Serialize(CatalogSnapshot.FromCatalog(catalog))); - logger.Info($"Catalog snapshot written to '{fullSnapshotPath}'."); + WriteSnapshotFile(snapshotPath, CatalogSnapshotSerializer.Serialize(CatalogSnapshot.FromCatalog(catalog))); + logger.Info($"Catalog snapshot written to '{snapshotPath}'."); } return 0; diff --git a/FirstClassErrors.Cli/GenerateOptionsResolver.cs b/FirstClassErrors.Cli/GenerateOptionsResolver.cs index cf008d7..3fcf276 100644 --- a/FirstClassErrors.Cli/GenerateOptionsResolver.cs +++ b/FirstClassErrors.Cli/GenerateOptionsResolver.cs @@ -19,7 +19,6 @@ internal sealed record ResolvedGenerateOptions( string BuildConfiguration, string? Framework, string? WorkerPath, - string? SnapshotPath, bool NoBuild, bool Strict); @@ -58,7 +57,6 @@ public static ResolvedGenerateOptions Resolve(GenerateSettings settings, CliConf BuildConfiguration: FirstNonEmpty(settings.Configuration, configuration.Configuration) ?? "Debug", Framework: FirstNonEmpty(settings.Framework, configuration.Framework), WorkerPath: FirstNonEmpty(settings.WorkerPath, configuration.Worker), - SnapshotPath: FirstNonEmpty(settings.SnapshotPath, configuration.Snapshot), NoBuild: settings.NoBuild || (configuration.NoBuild ?? false), Strict: settings.Strict || (configuration.Strict ?? false)); } From 384417e8fef8bcede9e9a348cf7ce4cc3b9c383f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 23:10:23 +0000 Subject: [PATCH 4/5] refactor(cli,gendoc): make the catalog commands unit-testable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generate command was already structured for unit testing (an injected IErrorDocumentationGenerator, an output sink, a logger factory); the catalog commands were not, so their exit codes, --fail-on/--report routing and baseline handling were only covered by manual end-to-end runs. This brings them to the same standard and folds in three review findings: 1. Testability: extract catalog snapshotting behind ICatalogSnapshotSource (production impl SolutionCatalogSnapshotSource), and give both commands a public production constructor delegating to an internal DI constructor plus a context-free Run(settings, ct) seam — mirroring GenerateCommand. 18 end-to-end tests now cover exit codes, the --fail-on policy, report routing, --against, and cancellation without spawning a process. 2. Cancellation: thread the CancellationToken into the extraction (SolutionGenerationOptions.CancellationToken) and map OperationCanceledException to exit code 130, as generate already does. 3. Self-healing update: 'catalog update' now rewrites an unreadable existing baseline with a warning instead of failing, since updating is precisely how a baseline is regenerated. 4. Doc-comment accuracy: CatalogSnapshot.FromCatalog documents the (code, source, title) tie-breaker; CatalogChangeImpact.Informational notes the "Documentation" label used in the human reports. Verified: Release build with the CI warning ratchet (0 warnings), the full suite (667 tests, Cli 44 -> 62), and a real net8 fce smoke test of catalog update/diff/--against. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J12ghrzM1xiAKw4LETJDqr --- .../CatalogCommandsEndToEndTests.cs | 367 ++++++++++++++++++ FirstClassErrors.Cli.UnitTests/TestDoubles.cs | 51 +++ FirstClassErrors.Cli/CatalogDiffCommand.cs | 47 ++- FirstClassErrors.Cli/CatalogSnapshotSource.cs | 26 +- FirstClassErrors.Cli/CatalogUpdateCommand.cs | 70 +++- .../ICatalogSnapshotSource.cs | 28 ++ .../Versioning/CatalogChange.cs | 4 +- .../Versioning/CatalogSnapshot.cs | 5 +- 8 files changed, 565 insertions(+), 33 deletions(-) create mode 100644 FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs create mode 100644 FirstClassErrors.Cli/ICatalogSnapshotSource.cs diff --git a/FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs b/FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs new file mode 100644 index 0000000..5898bad --- /dev/null +++ b/FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs @@ -0,0 +1,367 @@ +#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 = "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/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/CatalogDiffCommand.cs b/FirstClassErrors.Cli/CatalogDiffCommand.cs index af85efd..97bd408 100644 --- a/FirstClassErrors.Cli/CatalogDiffCommand.cs +++ b/FirstClassErrors.Cli/CatalogDiffCommand.cs @@ -2,6 +2,7 @@ using System.ComponentModel; +using FirstClassErrors.GenDoc; using FirstClassErrors.GenDoc.Versioning; using Spectre.Console.Cli; @@ -34,13 +35,43 @@ internal sealed class CatalogDiffSettings : CatalogSettings { /// /// /// 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, …). CI pipelines can therefore -/// distinguish "the contract changed" from "the tool failed". +/// 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) { - ConsoleGenerationLogger logger = new(settings.Verbose); + // 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); @@ -71,12 +102,12 @@ protected override int Execute(CommandContext context, CatalogDiffSettings setti CatalogSnapshot baseline = BaselineStore.Load(baselinePath); CatalogSnapshot current = string.IsNullOrWhiteSpace(settings.AgainstPath) - ? CatalogSnapshotSource.Extract(settings, configuration, logger) + ? _snapshotSource.Extract(settings, configuration, logger, cancellationToken) : BaselineStore.Load(Path.GetFullPath(settings.AgainstPath)); CatalogDiff diff = CatalogDiffer.Diff(baseline, current); - Console.Out.Write(report switch { + _output.Write(report switch { "markdown" => CatalogDiffFormatter.ToMarkdown(diff), "json" => CatalogDiffFormatter.ToJson(diff), _ => CatalogDiffFormatter.ToText(diff) @@ -97,9 +128,15 @@ protected override int Execute(CommandContext context, CatalogDiffSettings setti } 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; } diff --git a/FirstClassErrors.Cli/CatalogSnapshotSource.cs b/FirstClassErrors.Cli/CatalogSnapshotSource.cs index fad3c85..ca92fb8 100644 --- a/FirstClassErrors.Cli/CatalogSnapshotSource.cs +++ b/FirstClassErrors.Cli/CatalogSnapshotSource.cs @@ -10,11 +10,12 @@ namespace FirstClassErrors.Cli; /// -/// Extracts the canonical for the catalog commands: resolves the source -/// (command line first, then configuration), runs the documentation extraction, and projects the resulting -/// catalog into its contract snapshot. +/// 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 static class CatalogSnapshotSource { +internal sealed class SolutionCatalogSnapshotSource : ICatalogSnapshotSource { #region Statics members declarations @@ -22,14 +23,10 @@ internal static class CatalogSnapshotSource { // committed baseline never depends on the ambient culture of the machine that produced it. private static readonly CultureInfo SnapshotCulture = CultureInfo.GetCultureInfo("en"); - /// - /// 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. - /// The canonical snapshot of the current catalog. - public static CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration configuration, ConsoleGenerationLogger logger) { + #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"; @@ -45,7 +42,8 @@ public static CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration FailureBehavior = strict ? FailureBehavior.Stop : FailureBehavior.Continue, WorkerAssemblyPath = worker, Culture = SnapshotCulture, - Logger = logger + Logger = logger, + CancellationToken = cancellationToken }; List catalog = @@ -62,6 +60,4 @@ public static CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration return CatalogSnapshot.FromCatalog(catalog); } - #endregion - } diff --git a/FirstClassErrors.Cli/CatalogUpdateCommand.cs b/FirstClassErrors.Cli/CatalogUpdateCommand.cs index a410d32..c33a378 100644 --- a/FirstClassErrors.Cli/CatalogUpdateCommand.cs +++ b/FirstClassErrors.Cli/CatalogUpdateCommand.cs @@ -1,5 +1,6 @@ #region Usings declarations +using FirstClassErrors.GenDoc; using FirstClassErrors.GenDoc.Versioning; using Spectre.Console.Cli; @@ -19,20 +20,50 @@ internal sealed class CatalogUpdateSettings : CatalogSettings { } /// 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) { - ConsoleGenerationLogger logger = new(settings.Verbose); + // 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 = CatalogSnapshotSource.Extract(settings, configuration, logger); + 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); - Console.Out.WriteLine($"Baseline created at '{baselinePath}', tracking {current.Errors.Count} error(s)."); + _output.WriteLine($"Baseline created at '{baselinePath}', tracking {current.Errors.Count} error(s)."); return 0; } @@ -40,23 +71,42 @@ protected override int Execute(CommandContext context, CatalogUpdateSettings set string existingText = File.ReadAllText(baselinePath); string canonical = CatalogSnapshotSerializer.Serialize(current); if (string.Equals(existingText, canonical, StringComparison.Ordinal)) { - Console.Out.WriteLine($"Baseline at '{baselinePath}' is already up to date ({current.Errors.Count} error(s))."); + _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. - CatalogDiff diff = CatalogDiffer.Diff(CatalogSnapshotSerializer.Deserialize(existingText), current); + // Summarize what the refresh absorbs, so accepting a breaking change is a visible, reviewable act. An + // existing baseline that cannot be read (corrupt, or written by a newer schema) must not block the + // rewrite — updating is precisely how you regenerate it — so fall back to a plain rewrite with a warning. + CatalogDiff? diff = null; + try { + diff = CatalogDiffer.Diff(CatalogSnapshotSerializer.Deserialize(existingText), current); + } catch (InvalidOperationException exception) { + logger.Warning($"The existing baseline at '{baselinePath}' could not be read ({exception.Message}); rewriting it."); + } + BaselineStore.Save(baselinePath, current); - Console.Out.WriteLine(diff.IsEmpty - ? $"Baseline rewritten in canonical form at '{baselinePath}' ({current.Errors.Count} error(s))." - : $"Baseline updated at '{baselinePath}': {diff.BreakingChanges.Count} breaking, {diff.CompatibleChanges.Count} compatible and {diff.InformationalChanges.Count} documentation change(s) accepted."); + 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, invalid baseline, …) as a terse line. + // 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/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.GenDoc/Versioning/CatalogChange.cs b/FirstClassErrors.GenDoc/Versioning/CatalogChange.cs index 77247e8..493ba02 100644 --- a/FirstClassErrors.GenDoc/Versioning/CatalogChange.cs +++ b/FirstClassErrors.GenDoc/Versioning/CatalogChange.cs @@ -18,7 +18,9 @@ public enum CatalogChangeImpact { Compatible, /// - /// The change only affects the documentation identity (title, source); no consumer contract is involved. + /// 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 diff --git a/FirstClassErrors.GenDoc/Versioning/CatalogSnapshot.cs b/FirstClassErrors.GenDoc/Versioning/CatalogSnapshot.cs index e16f188..a72fb47 100644 --- a/FirstClassErrors.GenDoc/Versioning/CatalogSnapshot.cs +++ b/FirstClassErrors.GenDoc/Versioning/CatalogSnapshot.cs @@ -41,8 +41,9 @@ public sealed class CatalogSnapshot { /// /// 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 order wins, deterministically. Context keys are deduplicated - /// by name within each error, keeping the first value type seen in name order. + /// 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) { From b5f0f69e4f2d31a610191a8b3d7fa1b128564bc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 06:02:18 +0000 Subject: [PATCH 5/5] fix(cli,gendoc): refuse a newer-schema baseline, don't downgrade it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-healing 'catalog update' rewrites an unreadable baseline, but a baseline written by a newer schema is not corrupt — overwriting it with the current (older) schema would silently downgrade a teammate's contract. Deserialize now throws a distinct CatalogSchemaTooNewException (a subtype of InvalidOperationException, carrying the declared and supported versions); 'catalog update' lets it propagate to a hard error that tells the user to upgrade the tool, exactly as 'catalog diff' already does, while still self-healing a genuinely corrupt baseline. Verified: 0-warning Release build, full suite (668 tests), and a real net8 fce run — a schema-999 baseline is refused (exit 1, file untouched) while a corrupt one is still rewritten (exit 0). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J12ghrzM1xiAKw4LETJDqr --- .../CatalogCommandsEndToEndTests.cs | 18 +++++++++++ FirstClassErrors.Cli/CatalogUpdateCommand.cs | 10 +++--- .../CatalogSnapshotTests.cs | 18 ++++++++--- .../CatalogSchemaTooNewException.cs | 31 +++++++++++++++++++ .../Versioning/CatalogSnapshotSerializer.cs | 11 +++++-- 5 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 FirstClassErrors.GenDoc/Versioning/CatalogSchemaTooNewException.cs diff --git a/FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs b/FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs index 5898bad..2369fe4 100644 --- a/FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs +++ b/FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs @@ -314,6 +314,24 @@ public void ConfiguredBaselineResolvesRelativeToConfig() { 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(); diff --git a/FirstClassErrors.Cli/CatalogUpdateCommand.cs b/FirstClassErrors.Cli/CatalogUpdateCommand.cs index c33a378..781d818 100644 --- a/FirstClassErrors.Cli/CatalogUpdateCommand.cs +++ b/FirstClassErrors.Cli/CatalogUpdateCommand.cs @@ -76,13 +76,15 @@ internal int Run(CatalogUpdateSettings settings, CancellationToken cancellationT return 0; } - // Summarize what the refresh absorbs, so accepting a breaking change is a visible, reviewable act. An - // existing baseline that cannot be read (corrupt, or written by a newer schema) must not block the - // rewrite — updating is precisely how you regenerate it — so fall back to a plain rewrite with a warning. + // 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) { + } catch (InvalidOperationException exception) when (exception is not CatalogSchemaTooNewException) { logger.Warning($"The existing baseline at '{baselinePath}' could not be read ({exception.Message}); rewriting it."); } diff --git a/FirstClassErrors.GenDoc.UnitTests/CatalogSnapshotTests.cs b/FirstClassErrors.GenDoc.UnitTests/CatalogSnapshotTests.cs index 313c1af..4ba6417 100644 --- a/FirstClassErrors.GenDoc.UnitTests/CatalogSnapshotTests.cs +++ b/FirstClassErrors.GenDoc.UnitTests/CatalogSnapshotTests.cs @@ -202,11 +202,21 @@ public void NullArgumentsAreRejected() { Check.ThatCode(() => CatalogSnapshotSerializer.Deserialize(null!)).Throws(); } - [Fact(DisplayName = "A snapshot declaring a newer schema is rejected rather than silently misread.")] + [Fact(DisplayName = "A snapshot declaring a newer schema is rejected as a distinct CatalogSchemaTooNewException.")] public void ASnapshotDeclaringANewerSchemaIsRejected() { - // Exercise & verify - Check.ThatCode(() => CatalogSnapshotSerializer.Deserialize("""{ "schema": 999, "errors": [] }""")) - .Throws(); + // 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.")] 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/CatalogSnapshotSerializer.cs b/FirstClassErrors.GenDoc/Versioning/CatalogSnapshotSerializer.cs index 4abed5c..8ea694d 100644 --- a/FirstClassErrors.GenDoc/Versioning/CatalogSnapshotSerializer.cs +++ b/FirstClassErrors.GenDoc/Versioning/CatalogSnapshotSerializer.cs @@ -60,7 +60,11 @@ public static string Serialize(CatalogSnapshot snapshot) { /// Thrown when is null. /// /// Thrown when the text is not valid JSON, when the document does not declare a valid schema version, - /// when it declares one newer than , or when an entry has no code. + /// 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)); } @@ -83,8 +87,9 @@ public static CatalogSnapshot Deserialize(string json) { } if (document.Schema > CatalogSnapshot.CurrentSchema) { - throw new InvalidOperationException( - $"The snapshot declares schema {document.Schema}, but this tool only understands schema {CatalogSnapshot.CurrentSchema} or lower. Update the tool to read it."); + // 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.