Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions FirstClassErrors.Cli/CatalogDiffCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,21 +91,21 @@ internal int Run(CatalogDiffSettings settings, CancellationToken cancellationTok
if (failOn is not (FailOnBreaking or "any" or "none")) {
logger.Error($"Unknown --fail-on value '{settings.FailOn}'. Use breaking, any or none.");

return 1;
return ExitCodes.Failure;
}

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;
return ExitCodes.Failure;
}

string baselinePath = BaselineStore.Resolve(settings.BaselinePath, configuration.Baseline, configDir);
if (!BaselineStore.Exists(baselinePath)) {
logger.Error($"No baseline at '{baselinePath}'. Run 'fce catalog update' to create it.");

return 1;
return ExitCodes.Failure;
}

CatalogSnapshot baseline = BaselineStore.Load(baselinePath);
Expand All @@ -132,23 +132,23 @@ internal int Run(CatalogDiffSettings settings, CancellationToken cancellationTok
? $"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 ExitCodes.ChangesDetected;
}

return 0;
return ExitCodes.Success;
} 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;
return ExitCodes.Canceled;
} catch (DiagnosableException exception) {
return FailureReporting.ReportCodedFailure(logger, exception);
} 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;
return ExitCodes.Failure;
}
}

Expand Down
10 changes: 5 additions & 5 deletions FirstClassErrors.Cli/CatalogUpdateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,15 @@ internal int Run(CatalogUpdateSettings settings, CancellationToken cancellationT
BaselineStore.Save(baselinePath, current);
_output.WriteLine($"Baseline created at '{baselinePath}', tracking {current.Errors.Count} error(s).");

return 0;
return ExitCodes.Success;
}

string existingText = File.ReadAllText(baselinePath);
string canonical = CatalogSnapshotSerializer.Serialize(current);
if (string.Equals(existingText, canonical, StringComparison.Ordinal)) {
_output.WriteLine($"Baseline at '{baselinePath}' is already up to date ({current.Errors.Count} error(s)).");

return 0;
return ExitCodes.Success;
}

// Summarize what the refresh absorbs, so accepting a breaking change is a visible, reviewable act. A
Expand All @@ -103,21 +103,21 @@ internal int Run(CatalogUpdateSettings settings, CancellationToken cancellationT
_output.WriteLine($"Baseline updated at '{baselinePath}': {diff.BreakingChanges.Count} breaking, {diff.CompatibleChanges.Count} compatible and {diff.InformationalChanges.Count} documentation change(s) accepted.");
}

return 0;
return ExitCodes.Success;
} 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;
return ExitCodes.Canceled;
} catch (DiagnosableException exception) {
return FailureReporting.ReportCodedFailure(logger, exception);
} catch (Exception exception) {
// Report expected failures (missing solution, worker crash, …) as a terse line, not a stack trace.
logger.Error(exception.Message);
logger.Debug(exception.ToString());

return 1;
return ExitCodes.Failure;
}
}

Expand Down
4 changes: 2 additions & 2 deletions FirstClassErrors.Cli/ConfigShowCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ protected override int Execute(CommandContext context, ConfigScopedSettings sett
if (!ConfigurationStore.Exists(path)) {
Console.Out.WriteLine($"No configuration at '{path}'. Run 'fce config init' to create one.");

return 0;
return ExitCodes.Success;
}

Console.Out.WriteLine($"# {path}");
Console.Out.WriteLine(File.ReadAllText(path));

return 0;
return ExitCodes.Success;
}

}
33 changes: 33 additions & 0 deletions FirstClassErrors.Cli/ExitCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace FirstClassErrors.Cli;

/// <summary>
/// The exit codes every <c>fce</c> command returns, named once. They are a contract rather than an internal
/// detail: a build script branches on them, the README documents them, and the command tests assert them — so
/// the set is closed, and a command answering with a number outside it would be a defect no compiler could see.
/// </summary>
/// <remarks>
/// Written out here because a bare <c>return 130</c> at the end of a <c>catch</c> block says nothing about what
/// 130 means, and the answer was previously carried by a prose comment repeated at each of the three sites that
/// returned it. A comment cannot keep three literals in step; a constant they all read can.
/// </remarks>
internal static class ExitCodes {

/// <summary>The command did what it was asked.</summary>
internal const int Success = 0;

/// <summary>The command failed: an unusable argument, a missing input, a coded pipeline failure.</summary>
internal const int Failure = 1;

/// <summary>
/// <c>fce catalog diff</c> found changes at or above the impact it was told to fail on. Distinct from
/// <see cref="Failure" /> on purpose: the command worked, and the catalog is what the caller must look at.
/// </summary>
internal const int ChangesDetected = 2;

/// <summary>
/// The run was cancelled (Ctrl+C). The conventional value for a process killed by a signal is
/// <c>128 + signal</c>, and SIGINT is signal 2 — an abort, not a failure.
/// </summary>
internal const int Canceled = 130;

}
4 changes: 2 additions & 2 deletions FirstClassErrors.Cli/FailureReporting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ internal static class FailureReporting {
/// and can be looked up in the generated catalog of the tool's own errors. The full exception goes to the
/// debug channel, which surfaces only under <c>--verbose</c>.
/// </summary>
/// <returns>The command exit code for a failure (1).</returns>
/// <returns><see cref="ExitCodes.Failure" />, so every caller reports a coded failure with the same code.</returns>
internal static int ReportCodedFailure(IGenerationLogger logger, DiagnosableException exception) {
logger.Error($"{exception.Error.Code}: {exception.Message}");
logger.Debug(exception.ToString());

return 1;
return ExitCodes.Failure;
}

#endregion
Expand Down
10 changes: 5 additions & 5 deletions FirstClassErrors.Cli/GenerateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ internal int Run(GenerateSettings settings, CancellationToken cancellationToken)
// Effective options: command line first, then configuration, then the built-in default.
ResolvedGenerateOptions resolved = GenerateOptionsResolver.Resolve(settings, configuration);

if (ReportUnusableSource(resolved, logger)) { return 1; }
if (ReportUnusableSource(resolved, logger)) { return ExitCodes.Failure; }

// The language drives both the extraction (localized error descriptions) and the rendering (localized
// template boilerplate). It defaults to English.
Expand All @@ -75,7 +75,7 @@ internal int Run(GenerateSettings settings, CancellationToken cancellationToken)
IReadOnlyList<IErrorDocumentationRenderer> customRenderers = RendererLoader.Load(configuration.Renderers, configDir, logger);
IErrorDocumentationRenderer renderer = RendererCatalog.Create(resolved.Format, customRenderers);

if (ReportUnusableOutput(resolved, renderer, logger)) { return 1; }
if (ReportUnusableOutput(resolved, renderer, logger)) { return ExitCodes.Failure; }

SolutionGenerationOptions options = new() {
BuildSolution = !resolved.NoBuild,
Expand Down Expand Up @@ -117,14 +117,14 @@ internal int Run(GenerateSettings settings, CancellationToken cancellationToken)
logger.Info($"Catalog snapshot written to '{snapshotPath}'.");
}

return 0;
return ExitCodes.Success;
} catch (OperationCanceledException) {
// Cancellation (Ctrl+C) is an abort, not a failure: the child processes are already killed through the
// token, so report it as its own concise line and the conventional SIGINT exit code (128 + 2) rather than a
// generic error.
logger.Error("Generation canceled.");

return 130;
return ExitCodes.Canceled;
} catch (DiagnosableException exception) {
return FailureReporting.ReportCodedFailure(logger, exception);
} catch (Exception exception) {
Expand All @@ -134,7 +134,7 @@ internal int Run(GenerateSettings settings, CancellationToken cancellationToken)
logger.Error(exception.Message);
logger.Debug(exception.ToString());

return 1;
return ExitCodes.Failure;
}
}

Expand Down
4 changes: 2 additions & 2 deletions FirstClassErrors.Cli/InitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ protected override int Execute(CommandContext context, InitSettings settings, Ca
if (ConfigurationStore.Exists(path) && !settings.Force) {
Console.Error.WriteLine($"error: a configuration already exists at '{path}'. Use --force to overwrite.");

return 1;
return ExitCodes.Failure;
}

ConfigurationStore.Save(path, new CliConfiguration());
Console.Out.WriteLine($"Created configuration at '{path}'.");

return 0;
return ExitCodes.Success;
}

}
10 changes: 5 additions & 5 deletions FirstClassErrors.Cli/RendererAddCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ protected override int Execute(CommandContext context, RendererReferenceSettings
if (!File.Exists(library)) {
Console.Error.WriteLine($"error: renderer library not found: '{library}'.");

return 1;
return ExitCodes.Failure;
}

IReadOnlyList<IErrorDocumentationRenderer> renderers;
Expand All @@ -48,13 +48,13 @@ protected override int Execute(CommandContext context, RendererReferenceSettings
} catch (Exception exception) {
Console.Error.WriteLine($"error: could not load '{library}': {exception.Message}");

return 1;
return ExitCodes.Failure;
}

if (renderers.Count == 0) {
Console.Error.WriteLine($"error: no IErrorDocumentationRenderer found in '{library}'.");

return 1;
return ExitCodes.Failure;
}

CliConfiguration configuration = ConfigurationStore.Load(path);
Expand All @@ -64,7 +64,7 @@ protected override int Execute(CommandContext context, RendererReferenceSettings
if (alreadyReferenced) {
Console.Out.WriteLine($"'{settings.LibraryPath}' is already referenced.");

return 0;
return ExitCodes.Success;
}

configuration.Renderers.Add(settings.LibraryPath);
Expand All @@ -73,7 +73,7 @@ protected override int Execute(CommandContext context, RendererReferenceSettings
string formats = string.Join(", ", renderers.Select(renderer => renderer.Format));
Console.Out.WriteLine($"Added '{settings.LibraryPath}' (formats: {formats}).");

return 0;
return ExitCodes.Success;
}

}
4 changes: 2 additions & 2 deletions FirstClassErrors.Cli/RendererListCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ protected override int Execute(CommandContext context, ConfigScopedSettings sett
if (configuration.Renderers.Count == 0) {
Console.Out.WriteLine($"No custom renderers configured ({path}).");

return 0;
return ExitCodes.Success;
}

string configDir = Path.GetDirectoryName(path) ?? Directory.GetCurrentDirectory();
Expand All @@ -56,7 +56,7 @@ protected override int Execute(CommandContext context, ConfigScopedSettings sett
}
}

return 0;
return ExitCodes.Success;
}

}
6 changes: 3 additions & 3 deletions FirstClassErrors.Cli/RendererRemoveCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ protected override int Execute(CommandContext context, RendererReferenceSettings
if (!ConfigurationStore.Exists(path)) {
Console.Error.WriteLine($"error: no configuration at '{path}'. Run 'fce config init' first.");

return 1;
return ExitCodes.Failure;
}

string configDir = Path.GetDirectoryName(path) ?? Directory.GetCurrentDirectory();
Expand All @@ -31,13 +31,13 @@ protected override int Execute(CommandContext context, RendererReferenceSettings
if (removed == 0) {
Console.Error.WriteLine($"error: '{settings.LibraryPath}' is not referenced.");

return 1;
return ExitCodes.Failure;
}

ConfigurationStore.Save(path, configuration);
Console.Out.WriteLine($"Removed '{settings.LibraryPath}'.");

return 0;
return ExitCodes.Success;
}

}
19 changes: 19 additions & 0 deletions FirstClassErrors.GenDoc.Worker/ExitCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace FirstClassErrors.GenDoc.Worker;

/// <summary>
/// The exit codes this worker answers with, named once so the table in <c>Program.cs</c>'s header note and the
/// numbers the code actually returns cannot say different things. The generator that launches the worker reads
/// them back to tell a bad call from a failed extraction, which makes the set a contract rather than a detail.
/// </summary>
internal static class ExitCodes {

/// <summary>The documentation model was extracted and written.</summary>
internal const int Success = 0;

/// <summary>The extraction failed: the target would not load, or a documentation factory threw.</summary>
internal const int ExtractionError = 1;

/// <summary>The worker was called wrongly: a missing assembly path, or an unusable <c>--culture</c>.</summary>
internal const int BadUsage = 2;

}
11 changes: 6 additions & 5 deletions FirstClassErrors.GenDoc.Worker/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Text.Json.Serialization;

using FirstClassErrors.GenDoc;
using FirstClassErrors.GenDoc.Worker;

#endregion

Expand All @@ -33,13 +34,13 @@
if (parseError is not null) {
await Console.Error.WriteLineAsync(parseError);

return 2;
return ExitCodes.BadUsage;
}

if (string.IsNullOrWhiteSpace(assemblyPath)) {
await Console.Error.WriteLineAsync("Usage: FirstClassErrors.GenDoc.Worker <assembly-path> [output-json-path] [--culture <name>]");

return 2;
return ExitCodes.BadUsage;
}

if (cultureName is not null) {
Expand All @@ -52,7 +53,7 @@
} catch (CultureNotFoundException) {
await Console.Error.WriteLineAsync($"Unknown culture '{cultureName}'.");

return 2;
return ExitCodes.BadUsage;
}
}

Expand Down Expand Up @@ -84,11 +85,11 @@
await File.WriteAllTextAsync(outputPath, json);
}

return 0;
return ExitCodes.Success;
} catch (Exception ex) {
await Console.Error.WriteLineAsync($"Fatal error while extracting documentation from '{assemblyPath}': {ex}");

return 1;
return ExitCodes.ExtractionError;
}

// Parses the positional <assembly-path> [output-json-path] and the optional --culture <name>. Returns the parsed
Expand Down
8 changes: 7 additions & 1 deletion FirstClassErrors.Testing/InstanceIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ namespace FirstClassErrors.Testing;
/// </remarks>
public static class InstanceIds {

/// <summary>
/// How many bytes the trailing segment of a <see cref="Guid" /> holds — the <c>d</c> argument of the
/// <c>Guid(int, short, short, byte[])</c> constructor, which rejects an array of any other length.
/// </summary>
private const int GuidTrailingByteCount = 8;

/// <summary>
/// Pins every error created within the scope to the same fixed identifier.
/// </summary>
Expand Down Expand Up @@ -56,7 +62,7 @@ public static IDisposable Use(Func<Guid> next) {
public static IDisposable UseSequential() {
int counter = 0;

return Use(() => new Guid(++counter, 0, 0, new byte[8]));
return Use(() => new Guid(++counter, 0, 0, new byte[GuidTrailingByteCount]));
}

/// <summary>
Expand Down
Loading
Loading