diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d2baf6..6d9c7f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,9 @@ jobs: dotnet build FirstClassErrors.Usage/FirstClassErrors.Usage.csproj -c Release -f net8.0 # Run the net8 fce + worker on the .NET 8 RUNTIME (not rolled forward to the newer one the runner also - # carries) and document a net8 assembly end to end. + # carries) and document a net8 assembly end to end. GenDoc's own assembly (deployed next to fce) rides + # along as a second target: it documents its own GENDOC_-prefixed errors with the very pipeline it + # implements, so this run also proves the tool's self-catalog end to end. # # DOTNET_ROLL_FORWARD=LatestPatch overrides the roll-forward baked into each runtimeconfig (Major on the # CLI, LatestMajor on the worker): the environment variable wins over runtimeconfig, and LatestPatch stays @@ -116,6 +118,7 @@ jobs: set -euo pipefail dotnet FirstClassErrors.Cli/bin/Release/net8.0/fce.dll generate \ --assemblies FirstClassErrors.Usage/bin/Release/net8.0/FirstClassErrors.Usage.dll \ + --assemblies FirstClassErrors.Cli/bin/Release/net8.0/FirstClassErrors.GenDoc.dll \ --format json --verbose > floor-catalog.json # Positive proof, not just exit 0: the worker actually loaded the target and extracted documented errors. if ! grep -q '"code"' floor-catalog.json; then @@ -123,4 +126,10 @@ jobs: cat floor-catalog.json exit 1 fi - echo "ok: the net8 fce and worker documented a net8 target on the .NET 8 runtime" + # Dogfooding proof: the tool documented its own failure surface in the same catalog. + if ! grep -q '"GENDOC_' floor-catalog.json; then + echo "::error::the tool's own GENDOC_ errors are missing from the generated catalog" + cat floor-catalog.json + exit 1 + fi + echo "ok: the net8 fce and worker documented a net8 target and the tool's own errors on the .NET 8 runtime" diff --git a/FirstClassErrors.Cli.UnitTests/GenerateCommandExecutionTests.cs b/FirstClassErrors.Cli.UnitTests/GenerateCommandExecutionTests.cs index aa62807..8ed93d1 100644 --- a/FirstClassErrors.Cli.UnitTests/GenerateCommandExecutionTests.cs +++ b/FirstClassErrors.Cli.UnitTests/GenerateCommandExecutionTests.cs @@ -72,6 +72,37 @@ public void TheCommandBuildsItsLoggerFromTheVerboseFlag(bool verbose) { Check.That(capturedVerbose).IsEqualTo(verbose); } + [Fact(DisplayName = "A coded generation failure is reported with its stable error code and returns 1.")] + public void ACodedGenerationFailureIsReportedWithItsCode() { + // Setup: the generator raises the pipeline's own diagnosable failure (a GENDOC_ coded error). The error is + // built through the public core API: the GenDoc factories are internal, and this test only cares about how + // the command surfaces a DiagnosableException, whatever produced it. + PrimaryPortError error = PrimaryPortError + .Create(ErrorCode.Create("GENDOC_SOLUTION_NOT_FOUND"), + "Solution file not found: '/src/app/Application.sln'.", + Transience.NonTransient) + .WithPublicMessage("The solution file was not found."); + RecordingLogger logger = new(); + GenerateCommand command = new( + new FailingGenerator(new SolutionDocumentationGenerationException(error)), + new RecordingOutputSink(), + _ => logger); + GenerateSettings settings = new() { + ConfigPath = CliTestHelpers.NonExistentConfigPath(), + SolutionPath = "app.sln", + Format = "json" + }; + + // Exercise + int exitCode = command.Run(settings, CancellationToken.None); + + // Verify: the error line leads with the stable code, so it is grep-able and can be looked up in the catalog. + Check.That(exitCode).IsEqualTo(1); + Check.That(logger.Errors).HasSize(1); + Check.That(logger.Errors[0]).StartsWith("GENDOC_SOLUTION_NOT_FOUND: "); + Check.That(logger.Errors[0]).Contains("/src/app/Application.sln"); + } + [Fact(DisplayName = "A cancelled generation is reported and returns the SIGINT exit code (130).")] public void ACancelledGenerationReturnsTheSigintExitCode() { // Setup: a JSON generation from a solution reaches the generator (no service name needed for json), which diff --git a/FirstClassErrors.Cli/CatalogDiffCommand.cs b/FirstClassErrors.Cli/CatalogDiffCommand.cs index 97bd408..2382109 100644 --- a/FirstClassErrors.Cli/CatalogDiffCommand.cs +++ b/FirstClassErrors.Cli/CatalogDiffCommand.cs @@ -133,6 +133,8 @@ internal int Run(CatalogDiffSettings settings, CancellationToken cancellationTok logger.Error("Catalog diff canceled."); return 130; + } 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); diff --git a/FirstClassErrors.Cli/CatalogUpdateCommand.cs b/FirstClassErrors.Cli/CatalogUpdateCommand.cs index 781d818..95e3bb0 100644 --- a/FirstClassErrors.Cli/CatalogUpdateCommand.cs +++ b/FirstClassErrors.Cli/CatalogUpdateCommand.cs @@ -105,6 +105,8 @@ internal int Run(CatalogUpdateSettings settings, CancellationToken cancellationT logger.Error("Catalog update canceled."); return 130; + } 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); diff --git a/FirstClassErrors.Cli/FailureReporting.cs b/FirstClassErrors.Cli/FailureReporting.cs new file mode 100644 index 0000000..cf9d002 --- /dev/null +++ b/FirstClassErrors.Cli/FailureReporting.cs @@ -0,0 +1,32 @@ +#region Usings declarations + +using FirstClassErrors.GenDoc; + +#endregion + +namespace FirstClassErrors.Cli; + +/// +/// Shared rendering of coded pipeline failures, so every command reports a +/// with the exact same line format. That format is a contract: it is documented, test-asserted, and grep-able. +/// +internal static class FailureReporting { + + #region Statics members declarations + + /// + /// Reports a coded failure (GENDOC_… and friends): leads with the stable error code so the line is grep-able + /// 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 --verbose. + /// + /// The command exit code for a failure (1). + internal static int ReportCodedFailure(IGenerationLogger logger, DiagnosableException exception) { + logger.Error($"{exception.Error.Code}: {exception.Message}"); + logger.Debug(exception.ToString()); + + return 1; + } + + #endregion + +} diff --git a/FirstClassErrors.Cli/GenerateCommand.cs b/FirstClassErrors.Cli/GenerateCommand.cs index 1e33eea..d24d9e9 100644 --- a/FirstClassErrors.Cli/GenerateCommand.cs +++ b/FirstClassErrors.Cli/GenerateCommand.cs @@ -148,6 +148,8 @@ internal int Run(GenerateSettings settings, CancellationToken cancellationToken) logger.Error("Generation canceled."); return 130; + } 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. The full // exception (type, stack trace, inner exceptions) goes to the debug channel, which surfaces only under diff --git a/FirstClassErrors.GenDoc.UnitTests/DocumentationRequestErrorTests.cs b/FirstClassErrors.GenDoc.UnitTests/DocumentationRequestErrorTests.cs new file mode 100644 index 0000000..8202e91 --- /dev/null +++ b/FirstClassErrors.GenDoc.UnitTests/DocumentationRequestErrorTests.cs @@ -0,0 +1,84 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace FirstClassErrors.GenDoc.UnitTests; + +[TestSubject(typeof(DocumentationRequestError))] +public sealed class DocumentationRequestErrorTests { + + [Fact(DisplayName = "SolutionNotFound is a coded, non-transient incoming error carrying the solution path.")] + public void SolutionNotFoundCarriesItsFacts() { + // Exercise + PrimaryPortError error = DocumentationRequestError.SolutionNotFound("/src/app/Application.sln"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_SOLUTION_NOT_FOUND"); + Check.That(error.Transience).IsEqualTo(Transience.NonTransient); + Check.That(error.Direction).IsEqualTo(InteractionDirection.Incoming); + Check.That(error.DiagnosticMessage).Contains("/src/app/Application.sln"); + Check.That(error.Context.ToNameDictionary()["SolutionPath"]).IsEqualTo("/src/app/Application.sln"); + } + + [Fact(DisplayName = "SolutionPathUnsupported is a coded, non-transient incoming error carrying the path.")] + public void SolutionPathUnsupportedCarriesItsFacts() { + // Exercise + PrimaryPortError error = DocumentationRequestError.SolutionPathUnsupported("/src/app/Application.slnf"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_SOLUTION_PATH_UNSUPPORTED"); + Check.That(error.Transience).IsEqualTo(Transience.NonTransient); + Check.That(error.Context.ToNameDictionary()["SolutionPath"]).IsEqualTo("/src/app/Application.slnf"); + } + + [Fact(DisplayName = "AssemblyNotFound is a coded, non-transient incoming error carrying the assembly path.")] + public void AssemblyNotFoundCarriesItsFacts() { + // Exercise + PrimaryPortError error = DocumentationRequestError.AssemblyNotFound("/src/app/bin/Application.dll"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_ASSEMBLY_NOT_FOUND"); + Check.That(error.Transience).IsEqualTo(Transience.NonTransient); + Check.That(error.Context.ToNameDictionary()["AssemblyPath"]).IsEqualTo("/src/app/bin/Application.dll"); + } + + [Fact(DisplayName = "TargetAssemblyNotFound carries both the project path and the resolved target path.")] + public void TargetAssemblyNotFoundCarriesItsFacts() { + // Exercise + PrimaryPortError error = DocumentationRequestError.TargetAssemblyNotFound("/src/app/App.csproj", "/src/app/bin/App.dll"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_TARGET_ASSEMBLY_NOT_FOUND"); + Check.That(error.Context.ToNameDictionary()["ProjectPath"]).IsEqualTo("/src/app/App.csproj"); + Check.That(error.Context.ToNameDictionary()["TargetPath"]).IsEqualTo("/src/app/bin/App.dll"); + } + + [Fact(DisplayName = "OptInAmbiguous carries the project, the property name and the ambiguity reason.")] + public void OptInAmbiguousCarriesItsFacts() { + // Exercise + PrimaryPortError error = DocumentationRequestError.OptInAmbiguous("/src/app/App.csproj", "GenerateErrorDocumentation", "defined 2 times"); + + // Verify: the diagnostic message names the property and the reason, so Continue-mode warnings stay diagnosable. + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_OPT_IN_AMBIGUOUS"); + Check.That(error.DiagnosticMessage).Contains("GenerateErrorDocumentation"); + Check.That(error.DiagnosticMessage).Contains("defined 2 times"); + Check.That(error.Context.ToNameDictionary()["ProjectPath"]).IsEqualTo("/src/app/App.csproj"); + Check.That(error.Context.ToNameDictionary()["OptInProperty"]).IsEqualTo("GenerateErrorDocumentation"); + Check.That(error.Context.ToNameDictionary()["AmbiguityReason"]).IsEqualTo("defined 2 times"); + } + + [Fact(DisplayName = "WorkerPathInvalid carries the configured worker path.")] + public void WorkerPathInvalidCarriesItsFacts() { + // Exercise + PrimaryPortError error = DocumentationRequestError.WorkerPathInvalid("/tools/fce/FirstClassErrors.GenDoc.Worker.dll"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_WORKER_PATH_INVALID"); + Check.That(error.Context.ToNameDictionary()["WorkerPath"]).IsEqualTo("/tools/fce/FirstClassErrors.GenDoc.Worker.dll"); + } + +} diff --git a/FirstClassErrors.GenDoc.UnitTests/DocumentationToolchainErrorTests.cs b/FirstClassErrors.GenDoc.UnitTests/DocumentationToolchainErrorTests.cs new file mode 100644 index 0000000..9a89f7a --- /dev/null +++ b/FirstClassErrors.GenDoc.UnitTests/DocumentationToolchainErrorTests.cs @@ -0,0 +1,128 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace FirstClassErrors.GenDoc.UnitTests; + +[TestSubject(typeof(DocumentationToolchainError))] +public sealed class DocumentationToolchainErrorTests { + + [Fact(DisplayName = "ProjectEnumerationFailed carries the solution path and the exit code, and appends the error output.")] + public void ProjectEnumerationFailedCarriesItsFacts() { + // Exercise + SecondaryPortError error = DocumentationToolchainError.ProjectEnumerationFailed("/src/app/Application.sln", 1, "invalid solution"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_PROJECT_ENUMERATION_FAILED"); + Check.That(error.Transience).IsEqualTo(Transience.NonTransient); + Check.That(error.Direction).IsEqualTo(InteractionDirection.Outgoing); + Check.That(error.DiagnosticMessage).Contains("invalid solution"); + Check.That(error.Context.ToNameDictionary()["SolutionPath"]).IsEqualTo("/src/app/Application.sln"); + Check.That(error.Context.ToNameDictionary()["ExitCode"]).IsEqualTo(1); + } + + [Fact(DisplayName = "SolutionBuildFailed carries the solution path and the exit code, and appends the build output.")] + public void SolutionBuildFailedCarriesItsFacts() { + // Exercise + SecondaryPortError error = DocumentationToolchainError.SolutionBuildFailed("/src/app/Application.sln", 1, "CS1002: ; expected"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_SOLUTION_BUILD_FAILED"); + Check.That(error.Transience).IsEqualTo(Transience.NonTransient); + Check.That(error.DiagnosticMessage).Contains("CS1002"); + Check.That(error.Context.ToNameDictionary()["ExitCode"]).IsEqualTo(1); + } + + [Fact(DisplayName = "ProcessStartFailed carries the executable name.")] + public void ProcessStartFailedCarriesItsFacts() { + // Exercise + SecondaryPortError error = DocumentationToolchainError.ProcessStartFailed("dotnet"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_PROCESS_START_FAILED"); + Check.That(error.Context.ToNameDictionary()["ProcessFileName"]).IsEqualTo("dotnet"); + } + + [Fact(DisplayName = "ProcessTimedOut is the transient toolchain error, carrying the command, its target, the timeout and the captured output.")] + public void ProcessTimedOutIsTransientAndCarriesItsFacts() { + // Exercise + SecondaryPortError error = DocumentationToolchainError.ProcessTimedOut("dotnet build /src/app/Application.sln", "/src/app/Application.sln", TimeSpan.FromMinutes(10), "Determining projects to restore..."); + + // Verify: a timeout is the one toolchain failure a plain retry can fix — it must say so. The output captured + // before the kill is often the only trace of where the process stalled, so it must survive in the message. + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_PROCESS_TIMED_OUT"); + Check.That(error.Transience).IsEqualTo(Transience.Transient); + Check.That(error.DiagnosticMessage).Contains("Determining projects to restore..."); + Check.That(error.Context.ToNameDictionary()["Command"]).IsEqualTo("dotnet build /src/app/Application.sln"); + Check.That(error.Context.ToNameDictionary()["Target"]).IsEqualTo("/src/app/Application.sln"); + Check.That(error.Context.ToNameDictionary()["Timeout"]).IsEqualTo(TimeSpan.FromMinutes(10)); + } + + [Fact(DisplayName = "TargetPathResolutionFailed carries the project path.")] + public void TargetPathResolutionFailedCarriesItsFacts() { + // Exercise + SecondaryPortError error = DocumentationToolchainError.TargetPathResolutionFailed("/src/app/App.csproj"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_TARGET_PATH_RESOLUTION_FAILED"); + Check.That(error.Context.ToNameDictionary()["ProjectPath"]).IsEqualTo("/src/app/App.csproj"); + } + + [Fact(DisplayName = "WorkerNotDeployed carries the probed directory.")] + public void WorkerNotDeployedCarriesItsFacts() { + // Exercise + SecondaryPortError error = DocumentationToolchainError.WorkerNotDeployed("/tools/fce"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_WORKER_NOT_DEPLOYED"); + Check.That(error.Context.ToNameDictionary()["ProbedDirectory"]).IsEqualTo("/tools/fce"); + } + + [Fact(DisplayName = "WorkerFailed carries the assembly path and the exit code, and appends the worker's error output.")] + public void WorkerFailedCarriesItsFacts() { + // Exercise + SecondaryPortError error = DocumentationToolchainError.WorkerFailed("/src/app/bin/App.dll", 1, "load failure"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_WORKER_FAILED"); + Check.That(error.DiagnosticMessage).Contains("load failure"); + Check.That(error.Context.ToNameDictionary()["AssemblyPath"]).IsEqualTo("/src/app/bin/App.dll"); + Check.That(error.Context.ToNameDictionary()["ExitCode"]).IsEqualTo(1); + } + + [Fact(DisplayName = "WorkerOutputMissing carries the assembly path.")] + public void WorkerOutputMissingCarriesItsFacts() { + // Exercise + SecondaryPortError error = DocumentationToolchainError.WorkerOutputMissing("/src/app/bin/App.dll"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_WORKER_OUTPUT_MISSING"); + Check.That(error.Context.ToNameDictionary()["AssemblyPath"]).IsEqualTo("/src/app/bin/App.dll"); + } + + [Fact(DisplayName = "WorkerOutputUnreadable carries the assembly path.")] + public void WorkerOutputUnreadableCarriesItsFacts() { + // Exercise + SecondaryPortError error = DocumentationToolchainError.WorkerOutputUnreadable("/src/app/bin/App.dll"); + + // Verify + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_WORKER_OUTPUT_UNREADABLE"); + Check.That(error.Context.ToNameDictionary()["AssemblyPath"]).IsEqualTo("/src/app/bin/App.dll"); + } + + [Fact(DisplayName = "WorkerRunFailed has unknown transience and carries the assembly path.")] + public void WorkerRunFailedCarriesItsFacts() { + // Exercise + SecondaryPortError error = DocumentationToolchainError.WorkerRunFailed("/src/app/bin/App.dll"); + + // Verify: the cause is an arbitrary runtime exception, so no transience claim can be made. + Check.That(error.Code.ToString()).IsEqualTo("GENDOC_WORKER_RUN_FAILED"); + Check.That(error.Transience).IsEqualTo(Transience.Unknown); + Check.That(error.Context.ToNameDictionary()["AssemblyPath"]).IsEqualTo("/src/app/bin/App.dll"); + } + +} diff --git a/FirstClassErrors.GenDoc.UnitTests/GenDocSelfDocumentationTests.cs b/FirstClassErrors.GenDoc.UnitTests/GenDocSelfDocumentationTests.cs new file mode 100644 index 0000000..f5d63fa --- /dev/null +++ b/FirstClassErrors.GenDoc.UnitTests/GenDocSelfDocumentationTests.cs @@ -0,0 +1,80 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.GenDoc.UnitTests; + +/// +/// The dogfooding contract of the GenDoc project itself: its own failure surface is modeled with FirstClassErrors, +/// so the very pipeline it implements must extract a complete, failure-free catalog of GENDOC_-prefixed codes from +/// its assembly. Extraction executes every documentation method and every example factory in-process — this is the +/// end-to-end proof that GenDoc's own errors are documented, and that their factories are pure (no process spawn, +/// no file-system access). +/// +public sealed class GenDocSelfDocumentationTests { + + // Extraction reflects over the whole assembly and executes every documentation method and example factory; one + // shared run serves the three assertions below instead of paying that cost per test. + private static readonly Lazy SelfExtraction = + new(() => AssemblyErrorDocumentationReader.GetErrorDocumentationFrom(typeof(SolutionErrorDocumentationGenerator).Assembly)); + + private static ErrorDocumentationExtractionResult ExtractSelf() { + return SelfExtraction.Value; + } + + [Fact(DisplayName = "GenDoc's own assembly extracts without a single failure.")] + public void GenDocExtractsWithoutFailures() { + // Exercise + ErrorDocumentationExtractionResult extraction = ExtractSelf(); + + // Verify: every [DocumentedBy] wiring resolves and every example factory runs — a failure here means a + // documentation method or example of GenDoc's own errors is broken. + Check.That(extraction.Failures).IsEmpty(); + Check.That(extraction.HasFailures).IsFalse(); + } + + [Fact(DisplayName = "GenDoc documents its complete failure surface under the GENDOC_ prefix.")] + public void GenDocDocumentsItsCompleteFailureSurface() { + // Exercise + ErrorDocumentationExtractionResult extraction = ExtractSelf(); + + // Verify: the catalog holds exactly the declared failure surface — set equality, so a missing, extra, or + // duplicated code all fail by name. A code added or removed here is a contract change of the tool and must + // be deliberate (this list is the reviewable record of it). + Check.That(extraction.Documentation.Select(doc => doc.Code)).IsEquivalentTo( + "GENDOC_SOLUTION_NOT_FOUND", + "GENDOC_SOLUTION_PATH_UNSUPPORTED", + "GENDOC_ASSEMBLY_NOT_FOUND", + "GENDOC_TARGET_ASSEMBLY_NOT_FOUND", + "GENDOC_OPT_IN_AMBIGUOUS", + "GENDOC_WORKER_PATH_INVALID", + "GENDOC_PROJECT_ENUMERATION_FAILED", + "GENDOC_SOLUTION_BUILD_FAILED", + "GENDOC_PROCESS_START_FAILED", + "GENDOC_PROCESS_TIMED_OUT", + "GENDOC_TARGET_PATH_RESOLUTION_FAILED", + "GENDOC_WORKER_NOT_DEPLOYED", + "GENDOC_WORKER_FAILED", + "GENDOC_WORKER_OUTPUT_MISSING", + "GENDOC_WORKER_OUTPUT_UNREADABLE", + "GENDOC_WORKER_RUN_FAILED"); + } + + [Fact(DisplayName = "Every documented GenDoc error carries a title, a description, a diagnostic and an example.")] + public void EveryGenDocErrorIsFullyDocumented() { + // Exercise + ErrorDocumentationExtractionResult extraction = ExtractSelf(); + + // Verify: the catalog entries are usable by support and operations, not just present. + foreach (ErrorDocumentation doc in extraction.Documentation) { + Check.WithCustomMessage($"'{doc.Code}' must have a title.").That(doc.Title).IsNotEmpty(); + Check.WithCustomMessage($"'{doc.Code}' must have an explanation.").That(doc.Explanation).IsNotEmpty(); + Check.WithCustomMessage($"'{doc.Code}' must have at least one diagnostic.").That(doc.Diagnostics).Not.IsEmpty(); + Check.WithCustomMessage($"'{doc.Code}' must have at least one example.").That(doc.Examples).Not.IsEmpty(); + Check.WithCustomMessage($"'{doc.Code}' must document its context entries.").That(doc.Context).Not.IsEmpty(); + } + } + +} diff --git a/FirstClassErrors.GenDoc.UnitTests/SolutionDocumentationGenerationExceptionTests.cs b/FirstClassErrors.GenDoc.UnitTests/SolutionDocumentationGenerationExceptionTests.cs index 70ab5ad..ef8287d 100644 --- a/FirstClassErrors.GenDoc.UnitTests/SolutionDocumentationGenerationExceptionTests.cs +++ b/FirstClassErrors.GenDoc.UnitTests/SolutionDocumentationGenerationExceptionTests.cs @@ -11,26 +11,33 @@ namespace FirstClassErrors.GenDoc.UnitTests; [TestSubject(typeof(SolutionDocumentationGenerationException))] public sealed class SolutionDocumentationGenerationExceptionTests { - [Fact(DisplayName = "The exception preserves its message.")] - public void TheExceptionPreservesItsMessage() { + [Fact(DisplayName = "The exception carries the error and surfaces its diagnostic message.")] + public void TheExceptionCarriesTheErrorAndSurfacesItsDiagnosticMessage() { + // Setup + Error error = DocumentationRequestError.SolutionNotFound("/src/app/Application.sln"); + // Exercise - SolutionDocumentationGenerationException exception = new("Generation failed."); + SolutionDocumentationGenerationException exception = new(error); - // Verify - Check.That(exception.Message).IsEqualTo("Generation failed."); + // Verify: the exception is diagnosable — the full structured error is reachable, and the log-facing + // message is the error's diagnostic message. + Check.That(exception.Error).IsSameReferenceAs(error); + Check.That(exception.Message).IsEqualTo(error.DiagnosticMessage); Check.That(exception.InnerException).IsNull(); } - [Fact(DisplayName = "The exception preserves its message and inner exception.")] - public void TheExceptionPreservesItsMessageAndInnerException() { + [Fact(DisplayName = "The exception preserves the runtime cause as its inner exception.")] + public void TheExceptionPreservesTheRuntimeCauseAsItsInnerException() { // Setup + Error error = DocumentationToolchainError.WorkerRunFailed("/src/app/bin/Application.dll"); InvalidOperationException inner = new("root cause"); // Exercise - SolutionDocumentationGenerationException exception = new("Generation failed.", inner); + SolutionDocumentationGenerationException exception = new(error, inner); // Verify - Check.That(exception.Message).IsEqualTo("Generation failed."); + Check.That(exception.Error).IsSameReferenceAs(error); + Check.That(exception.Message).IsEqualTo(error.DiagnosticMessage); Check.That(exception.InnerException).IsSameReferenceAs(inner); } diff --git a/FirstClassErrors.GenDoc.UnitTests/SolutionErrorDocumentationGeneratorTests.cs b/FirstClassErrors.GenDoc.UnitTests/SolutionErrorDocumentationGeneratorTests.cs index e6a89db..380055c 100644 --- a/FirstClassErrors.GenDoc.UnitTests/SolutionErrorDocumentationGeneratorTests.cs +++ b/FirstClassErrors.GenDoc.UnitTests/SolutionErrorDocumentationGeneratorTests.cs @@ -29,22 +29,30 @@ public void GetErrorDocumentationFromRejectsNullOptions() { .Throws(); } - [Fact(DisplayName = "GetErrorDocumentationFrom fails when the solution file does not exist.")] + [Fact(DisplayName = "GetErrorDocumentationFrom fails with a coded error when the solution file does not exist.")] public void GetErrorDocumentationFromFailsWhenTheSolutionDoesNotExist() { - // Exercise & verify - Check.ThatCode(() => SolutionErrorDocumentationGenerator.GetErrorDocumentationFrom("this-solution-does-not-exist.sln", new SolutionGenerationOptions())) - .Throws(); + // Exercise + Exception? caught = Record.Exception( + () => SolutionErrorDocumentationGenerator.GetErrorDocumentationFrom("this-solution-does-not-exist.sln", new SolutionGenerationOptions())); + + // Verify: the failure is a first-class error with the stable code, not a bare framework exception. + Check.That(caught).IsInstanceOf(); + Check.That(((SolutionDocumentationGenerationException)caught!).Error.Code.ToString()).IsEqualTo("GENDOC_SOLUTION_NOT_FOUND"); } - [Fact(DisplayName = "GetErrorDocumentationFrom rejects a path that is not a .sln file.")] + [Fact(DisplayName = "GetErrorDocumentationFrom rejects a path that is not a .sln file with a coded error.")] public void GetErrorDocumentationFromRejectsANonSolutionFile() { // Setup: a real file whose extension is not .sln. string path = Path.GetTempFileName(); try { - // Exercise & verify - Check.ThatCode(() => SolutionErrorDocumentationGenerator.GetErrorDocumentationFrom(path, new SolutionGenerationOptions())) - .Throws(); + // Exercise + Exception? caught = Record.Exception( + () => SolutionErrorDocumentationGenerator.GetErrorDocumentationFrom(path, new SolutionGenerationOptions())); + + // Verify + Check.That(caught).IsInstanceOf(); + Check.That(((SolutionDocumentationGenerationException)caught!).Error.Code.ToString()).IsEqualTo("GENDOC_SOLUTION_PATH_UNSUPPORTED"); } finally { File.Delete(path); } @@ -56,8 +64,8 @@ public void GetErrorDocumentationFromRejectsANonSolutionFile() { public void GetErrorDocumentationFromAcceptsSolutionFormats(string extension) { // Setup: a real file carrying a solution extension. It is not a valid solution, so the enumeration further // down the pipeline will ultimately fail — but only *after* the extension validation, which is what this test - // guards. The rejected-extension path is the one that throws ArgumentException (see the sibling test); proving - // the format is accepted therefore means the call throws anything *but* an ArgumentException. + // guards. The rejected-extension path raises GENDOC_SOLUTION_PATH_UNSUPPORTED (see the sibling test); proving + // the format is accepted therefore means the failure, if any, carries any code but that one. // Compose the path instead of deriving it from GetTempFileName(): the latter CREATES a .tmp file on disk that // a mere extension rewrite would orphan on every run. string path = Path.Combine(Path.GetTempPath(), $"fce-gendoc-test-{Guid.NewGuid():N}{extension}"); @@ -68,8 +76,10 @@ public void GetErrorDocumentationFromAcceptsSolutionFormats(string extension) { Exception? caught = Record.Exception( () => SolutionErrorDocumentationGenerator.GetErrorDocumentationFrom(path, new SolutionGenerationOptions { BuildSolution = false }).ToList()); - // Verify: the extension was accepted, so no ArgumentException was raised for it. - Check.That(caught).IsNotInstanceOf(); + // Verify: the extension was accepted, so any failure comes from further down the pipeline. + if (caught is SolutionDocumentationGenerationException exception) { + Check.That(exception.Error.Code.ToString()).IsNotEqualTo("GENDOC_SOLUTION_PATH_UNSUPPORTED"); + } } finally { File.Delete(path); } diff --git a/FirstClassErrors.GenDoc/DocumentationRequestError.cs b/FirstClassErrors.GenDoc/DocumentationRequestError.cs new file mode 100644 index 0000000..fc57b6a --- /dev/null +++ b/FirstClassErrors.GenDoc/DocumentationRequestError.cs @@ -0,0 +1,195 @@ +namespace FirstClassErrors.GenDoc; + +/// +/// Provides the primary-port (incoming) errors raised when a documentation-generation request is invalid: the +/// solution, assemblies, worker path, or opt-in markers it designates cannot be used as requested. +/// +/// +/// These errors are GenDoc documenting itself with FirstClassErrors: each failure of the generator carries a +/// stable code, structured context, and generated documentation — exactly what the library asks of application +/// errors. Factories only assemble instances from already-computed facts; they never touch +/// the file system or spawn processes, so the documentation examples stay side-effect free. +/// +[ProvidesErrorsFor("DocumentationRequest", + Description = "Validation of a documentation-generation request: the solution, assemblies, worker path and opt-in markers it designates.")] +internal static class DocumentationRequestError { + + #region Statics members declarations + + /// The solution file designated by the request does not exist on disk. + [DocumentedBy(nameof(SolutionNotFoundDocumentation))] + internal static PrimaryPortError SolutionNotFound(string solutionPath) { + return PrimaryPortError.Create( + Code.SolutionNotFound, + $"Solution file not found: '{solutionPath}'.", + Transience.NonTransient, + ctx => ctx.Add(ErrCtxKey.SolutionPath, solutionPath)) + .WithPublicMessage( + "The solution file was not found.", + "The path passed to the documentation generator does not point to an existing solution file."); + } + + /// The request designates a path that is not a supported solution format (.sln or .slnx). + [DocumentedBy(nameof(SolutionPathUnsupportedDocumentation))] + internal static PrimaryPortError SolutionPathUnsupported(string solutionPath) { + return PrimaryPortError.Create( + Code.SolutionPathUnsupported, + $"Expected a .sln or .slnx file path, got: '{solutionPath}'.", + Transience.NonTransient, + ctx => ctx.Add(ErrCtxKey.SolutionPath, solutionPath)) + .WithPublicMessage( + "The solution path is not supported.", + "The documentation generator accepts .sln and .slnx solution files."); + } + + /// An assembly explicitly designated by the request does not exist on disk. + [DocumentedBy(nameof(AssemblyNotFoundDocumentation))] + internal static PrimaryPortError AssemblyNotFound(string assemblyPath) { + return PrimaryPortError.Create( + Code.AssemblyNotFound, + $"Assembly not found: '{assemblyPath}'.", + Transience.NonTransient, + ctx => ctx.Add(ErrCtxKey.AssemblyPath, assemblyPath)) + .WithPublicMessage( + "A requested assembly was not found.", + "One of the assemblies passed to the documentation generator does not exist on disk."); + } + + /// The build output resolved for a project does not exist on disk. + [DocumentedBy(nameof(TargetAssemblyNotFoundDocumentation))] + internal static PrimaryPortError TargetAssemblyNotFound(string projectPath, string targetPath) { + return PrimaryPortError.Create( + Code.TargetAssemblyNotFound, + $"Target assembly not found for project '{projectPath}'. Resolved TargetPath='{targetPath}'.", + Transience.NonTransient, + ctx => { + ctx.Add(ErrCtxKey.ProjectPath, projectPath); + ctx.Add(ErrCtxKey.TargetPath, targetPath); + }) + .WithPublicMessage( + "A project's build output was not found.", + "The project's resolved target assembly does not exist on disk."); + } + + /// + /// The opt-in property of a project is declared in a way whose effective value cannot be determined from the raw + /// project XML (defined more than once, or gated behind an MSBuild Condition). + /// + [DocumentedBy(nameof(OptInAmbiguousDocumentation))] + internal static PrimaryPortError OptInAmbiguous(string projectPath, string optInPropertyName, string ambiguityReason) { + return PrimaryPortError.Create( + Code.OptInAmbiguous, + $"Cannot determine the opt-in for project '{projectPath}': the '{optInPropertyName}' property is " + + $"{ambiguityReason} in the project XML, which GenDoc reads without MSBuild evaluation. Declare it once, " + + "literally and unconditionally in the project file, or document the built assembly explicitly.", + Transience.NonTransient, + ctx => { + ctx.Add(ErrCtxKey.ProjectPath, projectPath); + ctx.Add(ErrCtxKey.OptInProperty, optInPropertyName); + ctx.Add(ErrCtxKey.AmbiguityReason, ambiguityReason); + }) + .WithPublicMessage( + "A project's documentation opt-in is ambiguous.", + "Declare the opt-in property once, literally and unconditionally, in the project file — or document the assembly explicitly."); + } + + /// The worker path explicitly configured on the request does not point to an existing file. + [DocumentedBy(nameof(WorkerPathInvalidDocumentation))] + internal static PrimaryPortError WorkerPathInvalid(string workerPath) { + return PrimaryPortError.Create( + Code.WorkerPathInvalid, + $"The configured documentation worker was not found at '{workerPath}'.", + Transience.NonTransient, + ctx => ctx.Add(ErrCtxKey.WorkerPath, workerPath)) + .WithPublicMessage( + "The configured documentation worker was not found.", + "The configured worker path must point to an existing FirstClassErrors.GenDoc.Worker.dll."); + } + + private static ErrorDocumentation SolutionNotFoundDocumentation() { + return DescribeError.WithTitle("Solution file not found") + .WithDescription("A documentation-generation request designates a solution file that does not exist on disk. The full path, as resolved by the generator, is carried in the error context.") + .WithRule("A generation request must designate an existing solution file.") + .WithDiagnostic("The path is wrong or relative to an unexpected working directory (a CI step often runs from a different directory than a developer shell).", + ErrorOrigin.External, + "Compare the resolved path in the error context with the actual solution location; check the working directory of the caller.") + .WithExamples(() => SolutionNotFound("/src/app/Application.sln")); + } + + private static ErrorDocumentation SolutionPathUnsupportedDocumentation() { + return DescribeError.WithTitle("Solution path not supported") + .WithDescription("A documentation-generation request designates a file that is not a .sln or .slnx solution. Solution filters (.slnf) are deliberately not supported: the 'dotnet sln' commands the generator relies on do not process them.") + .WithRule("A generation request must designate a .sln or .slnx solution file.") + .WithDiagnostic("A project file, a solution filter (.slnf), or another artifact was passed instead of the solution.", + ErrorOrigin.External, + "Check the path in the error context; pass the .sln/.slnx file, or document built assemblies directly instead.") + .WithExamples(() => SolutionPathUnsupported("/src/app/Application.slnf")); + } + + private static ErrorDocumentation AssemblyNotFoundDocumentation() { + return DescribeError.WithTitle("Requested assembly not found") + .WithDescription("A documentation-generation request explicitly designates an assembly that does not exist on disk. The full path, as resolved by the generator, is carried in the error context.") + .WithRule("Every assembly explicitly designated by a generation request must exist on disk.") + .WithDiagnostic("The assembly was never built, or was built to a different configuration or target framework than the path assumes.", + ErrorOrigin.External, + "Build the project first and compare the path in the error context with the actual build output directory.") + .WithExamples(() => AssemblyNotFound("/src/app/bin/Release/net8.0/Application.dll")); + } + + private static ErrorDocumentation TargetAssemblyNotFoundDocumentation() { + return DescribeError.WithTitle("Project build output not found") + .WithDescription("The generator resolved a project's build output path (MSBuild TargetPath), but no assembly exists there. Both the project path and the resolved target path are carried in the error context.") + .WithRule("Every documented project must have been built for the requested configuration and target framework.") + .WithDiagnostic("The solution was not built before generation (for example the build step was skipped), so the output is missing.", + ErrorOrigin.External, + "Build the solution first, or let the generator build it by enabling its build step.") + .AndDiagnostic("The generation request targets a different configuration or framework than the one that was built.", + ErrorOrigin.External, + "Compare the resolved TargetPath in the error context with the directory that was actually built.") + .WithExamples(() => TargetAssemblyNotFound("/src/app/Application/Application.csproj", "/src/app/Application/bin/Release/net8.0/Application.dll")); + } + + private static ErrorDocumentation OptInAmbiguousDocumentation() { + return DescribeError.WithTitle("Documentation opt-in ambiguous") + .WithDescription("The generator reads the documentation opt-in property literally from the project XML, without MSBuild evaluation. When the property is defined more than once, or gated behind an MSBuild Condition (directly or via a Choose/When branch), its effective value cannot be known, so the generator refuses to guess and skips the project. The project path, the property name, and the reason are carried in the error context.") + .WithRule("The opt-in property must be declared at most once, literally and unconditionally, in each project file.") + .WithDiagnostic("The property is declared in several PropertyGroups, or under a Condition attribute or a Choose/When branch.", + ErrorOrigin.External, + "Inspect the project file named in the error context; keep a single unconditional declaration, or document the built assembly explicitly instead.") + .WithExamples(() => OptInAmbiguous("/src/app/Application/Application.csproj", "GenerateErrorDocumentation", "defined 2 times")); + } + + private static ErrorDocumentation WorkerPathInvalidDocumentation() { + return DescribeError.WithTitle("Configured worker path invalid") + .WithDescription("A documentation-generation request explicitly configures the path of the extraction worker, but no file exists there. The configured path is carried in the error context.") + .WithRule("A configured worker path must point to an existing FirstClassErrors.GenDoc.Worker.dll.") + .WithDiagnostic("The configured path is stale — the worker was moved, or the path was written for another machine or installation layout.", + ErrorOrigin.External, + "Check the path in the error context; remove the explicit setting to fall back to the worker deployed next to the tool.") + .WithExamples(() => WorkerPathInvalid("/tools/fce/FirstClassErrors.GenDoc.Worker.dll")); + } + + #endregion + + #region Nested types declarations + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Critical Code Smell", "S3218:Inner class members should not shadow outer class \"static\" members", + Justification = "Each error-code constant deliberately mirrors the name of its factory method; both are always qualified (Code.X versus the X(...) call), so there is no real ambiguity.")] + private static class Code { + + #region Statics members declarations + + public static readonly ErrorCode SolutionNotFound = ErrorCode.Create("GENDOC_SOLUTION_NOT_FOUND"); + public static readonly ErrorCode SolutionPathUnsupported = ErrorCode.Create("GENDOC_SOLUTION_PATH_UNSUPPORTED"); + public static readonly ErrorCode AssemblyNotFound = ErrorCode.Create("GENDOC_ASSEMBLY_NOT_FOUND"); + public static readonly ErrorCode TargetAssemblyNotFound = ErrorCode.Create("GENDOC_TARGET_ASSEMBLY_NOT_FOUND"); + public static readonly ErrorCode OptInAmbiguous = ErrorCode.Create("GENDOC_OPT_IN_AMBIGUOUS"); + public static readonly ErrorCode WorkerPathInvalid = ErrorCode.Create("GENDOC_WORKER_PATH_INVALID"); + + #endregion + + } + + #endregion + +} diff --git a/FirstClassErrors.GenDoc/DocumentationToolchainError.cs b/FirstClassErrors.GenDoc/DocumentationToolchainError.cs new file mode 100644 index 0000000..2ef116c --- /dev/null +++ b/FirstClassErrors.GenDoc/DocumentationToolchainError.cs @@ -0,0 +1,308 @@ +namespace FirstClassErrors.GenDoc; + +/// +/// Provides the secondary-port (outgoing) errors raised when the toolchain GenDoc drives fails: the .NET SDK +/// commands it spawns to enumerate and build solutions, and the extraction worker process it launches per +/// assembly. +/// +/// +/// These errors are GenDoc documenting itself with FirstClassErrors: each failure of the generator carries a +/// stable code, structured context, and generated documentation — exactly what the library asks of application +/// errors. Factories only assemble instances from already-computed facts (exit codes, +/// captured output, paths); they never touch the file system or spawn processes, so the documentation examples +/// stay side-effect free. +/// +[ProvidesErrorsFor("DocumentationToolchain", + Description = "The toolchain the documentation generator drives: the .NET SDK commands it spawns and the extraction worker process it launches per assembly.")] +internal static class DocumentationToolchainError { + + #region Statics members declarations + + /// 'dotnet sln list' failed, so the solution's projects could not be enumerated. + [DocumentedBy(nameof(ProjectEnumerationFailedDocumentation))] + internal static SecondaryPortError ProjectEnumerationFailed(string solutionPath, int exitCode, string errorOutput) { + return SecondaryPortError.Create( + Code.ProjectEnumerationFailed, + FormattableString.Invariant($"Failed to list the projects of solution '{solutionPath}' (exit code {exitCode}).\n{errorOutput}"), + Transience.NonTransient, + ctx => { + ctx.Add(ErrCtxKey.SolutionPath, solutionPath); + ctx.Add(ErrCtxKey.ExitCode, exitCode); + }) + .WithPublicMessage( + "The solution's projects could not be listed.", + "The 'dotnet sln list' command failed for the requested solution."); + } + + /// 'dotnet build' failed for the solution under documentation. + [DocumentedBy(nameof(SolutionBuildFailedDocumentation))] + internal static SecondaryPortError SolutionBuildFailed(string solutionPath, int exitCode, string buildOutput) { + return SecondaryPortError.Create( + Code.SolutionBuildFailed, + FormattableString.Invariant($"dotnet build failed for solution '{solutionPath}' (exit code {exitCode}).\n{buildOutput}"), + Transience.NonTransient, + ctx => { + ctx.Add(ErrCtxKey.SolutionPath, solutionPath); + ctx.Add(ErrCtxKey.ExitCode, exitCode); + }) + .WithPublicMessage( + "The solution build failed.", + "The solution under documentation did not build; the build output is in the diagnostic message."); + } + + /// A child process required by the generation (the 'dotnet' host) could not be started at all. + [DocumentedBy(nameof(ProcessStartFailedDocumentation))] + internal static SecondaryPortError ProcessStartFailed(string fileName) { + return SecondaryPortError.Create( + Code.ProcessStartFailed, + $"Failed to start process '{fileName}'.", + Transience.NonTransient, + ctx => ctx.Add(ErrCtxKey.ProcessFileName, fileName)) + .WithPublicMessage( + "A required process could not be started.", + "A child process required by the documentation generation could not be started."); + } + + /// A child process exceeded its configured timeout and was killed. + /// Short description of the command that was running. + /// Path of the solution, project or assembly the command was operating on. + /// The configured timeout that was exceeded. + /// The output captured before the kill; often the only trace of where the process stalled. + [DocumentedBy(nameof(ProcessTimedOutDocumentation))] + internal static SecondaryPortError ProcessTimedOut(string command, string target, TimeSpan timeout, string capturedOutput) { + return SecondaryPortError.Create( + Code.ProcessTimedOut, + FormattableString.Invariant($"Process '{command}' timed out after {timeout} and was killed.\n{capturedOutput}"), + Transience.Transient, + ctx => { + ctx.Add(ErrCtxKey.Command, command); + ctx.Add(ErrCtxKey.Target, target); + ctx.Add(ErrCtxKey.Timeout, timeout); + }) + .WithPublicMessage( + "A documentation process timed out.", + "The operation exceeded its configured timeout and was killed; it can be retried."); + } + + /// Resolving a project's build output path (MSBuild TargetPath) through the SDK failed. + [DocumentedBy(nameof(TargetPathResolutionFailedDocumentation))] + internal static SecondaryPortError TargetPathResolutionFailed(string projectPath) { + return SecondaryPortError.Create( + Code.TargetPathResolutionFailed, + $"Failed to resolve target path for project '{projectPath}'.", + Transience.NonTransient, + ctx => ctx.Add(ErrCtxKey.ProjectPath, projectPath)) + .WithPublicMessage( + "A project's target path could not be resolved.", + "The build output path of a project could not be resolved through the .NET SDK."); + } + + /// The extraction worker was not found next to the tool (and no explicit path was configured). + [DocumentedBy(nameof(WorkerNotDeployedDocumentation))] + internal static SecondaryPortError WorkerNotDeployed(string probedDirectory) { + return SecondaryPortError.Create( + Code.WorkerNotDeployed, + $"The documentation worker 'FirstClassErrors.GenDoc.Worker.dll' could not be located in '{probedDirectory}'. " + + $"Set {nameof(SolutionGenerationOptions)}.{nameof(SolutionGenerationOptions.WorkerAssemblyPath)}, or ensure the worker is deployed next to the tool.", + Transience.NonTransient, + ctx => ctx.Add(ErrCtxKey.ProbedDirectory, probedDirectory)) + .WithPublicMessage( + "The documentation worker is missing.", + "The extraction worker was not found next to the tool; the installation is incomplete."); + } + + /// The extraction worker ran but exited with a non-zero exit code. + [DocumentedBy(nameof(WorkerFailedDocumentation))] + internal static SecondaryPortError WorkerFailed(string assemblyPath, int exitCode, string errorOutput) { + return SecondaryPortError.Create( + Code.WorkerFailed, + FormattableString.Invariant($"The documentation worker failed (exit code {exitCode}) for '{assemblyPath}'.\n{errorOutput}"), + Transience.NonTransient, + ctx => { + ctx.Add(ErrCtxKey.AssemblyPath, assemblyPath); + ctx.Add(ErrCtxKey.ExitCode, exitCode); + }) + .WithPublicMessage( + "The documentation worker failed.", + "The extraction worker exited with an error for one of the documented assemblies."); + } + + /// The extraction worker exited successfully but its output file is missing. + [DocumentedBy(nameof(WorkerOutputMissingDocumentation))] + internal static SecondaryPortError WorkerOutputMissing(string assemblyPath) { + return SecondaryPortError.Create( + Code.WorkerOutputMissing, + $"The documentation worker produced no output for '{assemblyPath}'.", + Transience.NonTransient, + ctx => ctx.Add(ErrCtxKey.AssemblyPath, assemblyPath)) + .WithPublicMessage( + "The documentation worker produced no output.", + "The extraction worker exited successfully but its result file is missing."); + } + + /// The extraction worker's output file exists but does not deserialize into an extraction result. + [DocumentedBy(nameof(WorkerOutputUnreadableDocumentation))] + internal static SecondaryPortError WorkerOutputUnreadable(string assemblyPath) { + return SecondaryPortError.Create( + Code.WorkerOutputUnreadable, + $"The documentation worker produced unreadable output for '{assemblyPath}'.", + Transience.NonTransient, + ctx => ctx.Add(ErrCtxKey.AssemblyPath, assemblyPath)) + .WithPublicMessage( + "The documentation worker produced unreadable output.", + "The extraction worker's result file could not be read as an extraction result."); + } + + /// Launching or harvesting the extraction worker threw an unexpected runtime exception. + [DocumentedBy(nameof(WorkerRunFailedDocumentation))] + internal static SecondaryPortError WorkerRunFailed(string assemblyPath) { + return SecondaryPortError.Create( + Code.WorkerRunFailed, + $"Failed to run the documentation worker for '{assemblyPath}'.", + Transience.Unknown, + ctx => ctx.Add(ErrCtxKey.AssemblyPath, assemblyPath)) + .WithPublicMessage( + "The documentation worker could not be run.", + "Running the extraction worker failed unexpectedly for one of the documented assemblies."); + } + + private static ErrorDocumentation ProjectEnumerationFailedDocumentation() { + return DescribeError.WithTitle("Solution project enumeration failed") + .WithDescription("The generator enumerates a solution's projects by running 'dotnet sln list'. That command failed, so no project could be discovered. The solution path and the exit code are carried in the error context; the command's error output is appended to the diagnostic message.") + .WithoutRule() + .WithDiagnostic("The solution file is malformed or references projects that no longer exist.", + ErrorOrigin.External, + "Run 'dotnet sln list' by hand and read its error output.") + .AndDiagnostic("The .NET SDK on the machine is missing or too old for the solution format (for example .slnx support).", + ErrorOrigin.External, + "Check 'dotnet --version' against the solution format and the repository's SDK requirements.") + .WithExamples(() => ProjectEnumerationFailed("/src/app/Application.sln", 1, "The solution file is invalid.")); + } + + private static ErrorDocumentation SolutionBuildFailedDocumentation() { + return DescribeError.WithTitle("Solution build failed") + .WithDescription("The generator builds the solution before documenting it (unless the build step is disabled). The build failed, so no assembly could be documented. The solution path and the exit code are carried in the error context; the build output is appended to the diagnostic message.") + .WithoutRule() + .WithDiagnostic("The solution under documentation has compile errors.", + ErrorOrigin.External, + "Read the build output in the diagnostic message; build the solution by hand to reproduce.") + .AndDiagnostic("Package restore failed (offline machine, feed outage, or authentication).", + ErrorOrigin.InternalOrExternal, + "Check the restore section of the build output and the reachability of the configured NuGet feeds.") + .WithExamples(() => SolutionBuildFailed("/src/app/Application.sln", 1, "CS1002: ; expected")); + } + + private static ErrorDocumentation ProcessStartFailedDocumentation() { + return DescribeError.WithTitle("Toolchain process failed to start") + .WithDescription("The generator drives the .NET SDK and its extraction worker through child processes. One of them could not be started at all. The executable name is carried in the error context.") + .WithoutRule() + .WithDiagnostic("The 'dotnet' host is not installed or not on the PATH of the process running the generation.", + ErrorOrigin.External, + "Run 'dotnet --info' in the same environment (shell, CI step, service account) as the generation.") + .WithExamples(() => ProcessStartFailed("dotnet")); + } + + private static ErrorDocumentation ProcessTimedOutDocumentation() { + return DescribeError.WithTitle("Toolchain process timed out") + .WithDescription("A child process of the generation (an SDK command or the extraction worker) exceeded its configured timeout and was killed. The command, its target and the timeout are carried in the error context; the output captured before the kill is appended to the diagnostic message. It is transient: the run can be retried.") + .WithoutRule() + .WithDiagnostic("The machine is under load, or a cold NuGet cache made the first build far slower than usual.", + ErrorOrigin.External, + "Retry the run; compare its duration with the configured build, SDK-query and worker timeouts.") + .AndDiagnostic("A documented assembly's example factory hangs (extraction executes the documentation methods of the target).", + ErrorOrigin.InternalOrExternal, + "Check which assembly was being processed when the timeout hit; review its documentation examples for blocking calls.") + .WithExamples(() => ProcessTimedOut("dotnet build /src/app/Application.sln", "/src/app/Application.sln", TimeSpan.FromMinutes(10), "Determining projects to restore...")); + } + + private static ErrorDocumentation TargetPathResolutionFailedDocumentation() { + return DescribeError.WithTitle("Target path resolution failed") + .WithDescription("The generator resolves each project's build output path by querying the .NET SDK ('dotnet msbuild -getProperty:TargetPath'). That query threw, so the project cannot be located and is skipped (or the generation stops, per the configured failure behavior). The project path is carried in the error context.") + .WithoutRule() + .WithDiagnostic("The project file does not evaluate (broken import, missing SDK workload, malformed XML).", + ErrorOrigin.External, + "Run 'dotnet msbuild -getProperty:TargetPath' by hand and read its error output.") + .WithExamples(() => TargetPathResolutionFailed("/src/app/Application/Application.csproj")); + } + + private static ErrorDocumentation WorkerNotDeployedDocumentation() { + return DescribeError.WithTitle("Documentation worker not deployed") + .WithDescription("No explicit worker path is configured, and the extraction worker was not found next to the tool — the conventional location it is deployed to. The probed directory is carried in the error context.") + .WithRule("The extraction worker ships next to the tool; an installation without it cannot extract documentation.") + .WithDiagnostic("The tool was copied or repackaged without 'FirstClassErrors.GenDoc.Worker.dll' (an incomplete manual install).", + ErrorOrigin.Internal, + "Inspect the probed directory named in the error context; reinstall the tool, or configure an explicit worker path.") + .WithExamples(() => WorkerNotDeployed("/tools/fce")); + } + + private static ErrorDocumentation WorkerFailedDocumentation() { + return DescribeError.WithTitle("Documentation worker failed") + .WithDescription("The extraction worker runs once per documented assembly, in its own process, against that assembly's dependency graph. It exited with a non-zero code for one assembly. The assembly path and the exit code are carried in the error context; the worker's error output is appended to the diagnostic message.") + .WithoutRule() + .WithDiagnostic("The target assembly failed to load in the worker (missing dependency, mismatched FirstClassErrors version).", + ErrorOrigin.External, + "Read the worker's error output in the diagnostic message; check the target's deps.json next to the assembly.") + .AndDiagnostic("A documentation method or example factory of the target threw while the worker executed it.", + ErrorOrigin.External, + "Read the worker's error output; run the target's documentation methods in a unit test to reproduce.") + .WithExamples(() => WorkerFailed("/src/app/bin/Release/net8.0/Application.dll", 1, "Fatal error while extracting documentation.")); + } + + private static ErrorDocumentation WorkerOutputMissingDocumentation() { + return DescribeError.WithTitle("Documentation worker output missing") + .WithDescription("The extraction worker exited successfully but the result file it was asked to write does not exist. The assembly path is carried in the error context.") + .WithoutRule() + .WithDiagnostic("The temporary directory is not writable, or an antivirus or cleanup job removed the file between the worker's exit and its harvesting.", + ErrorOrigin.External, + "Check the permissions and free space of the temporary directory used by the generation.") + .WithExamples(() => WorkerOutputMissing("/src/app/bin/Release/net8.0/Application.dll")); + } + + private static ErrorDocumentation WorkerOutputUnreadableDocumentation() { + return DescribeError.WithTitle("Documentation worker output unreadable") + .WithDescription("The extraction worker exited successfully and wrote a result file, but the file does not deserialize into an extraction result. The assembly path is carried in the error context.") + .WithoutRule() + .WithDiagnostic("The worker and the generator come from different tool versions and no longer agree on the result format.", + ErrorOrigin.Internal, + "Check that the worker next to the tool belongs to the same installation; reinstall the tool if in doubt.") + .WithExamples(() => WorkerOutputUnreadable("/src/app/bin/Release/net8.0/Application.dll")); + } + + private static ErrorDocumentation WorkerRunFailedDocumentation() { + return DescribeError.WithTitle("Documentation worker run failed") + .WithDescription("Launching the extraction worker, or harvesting its result, threw an unexpected runtime exception (an I/O failure, a permission error…). The assembly path is carried in the error context; the runtime cause travels with the raised exception.") + .WithoutRule() + .WithDiagnostic("A file-system or permission problem around the temporary result file or the worker binary.", + ErrorOrigin.InternalOrExternal, + "Read the inner exception attached to the failure; check the temporary directory and the tool's installation directory.") + .WithExamples(() => WorkerRunFailed("/src/app/bin/Release/net8.0/Application.dll")); + } + + #endregion + + #region Nested types declarations + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Critical Code Smell", "S3218:Inner class members should not shadow outer class \"static\" members", + Justification = "Each error-code constant deliberately mirrors the name of its factory method; both are always qualified (Code.X versus the X(...) call), so there is no real ambiguity.")] + private static class Code { + + #region Statics members declarations + + public static readonly ErrorCode ProjectEnumerationFailed = ErrorCode.Create("GENDOC_PROJECT_ENUMERATION_FAILED"); + public static readonly ErrorCode SolutionBuildFailed = ErrorCode.Create("GENDOC_SOLUTION_BUILD_FAILED"); + public static readonly ErrorCode ProcessStartFailed = ErrorCode.Create("GENDOC_PROCESS_START_FAILED"); + public static readonly ErrorCode ProcessTimedOut = ErrorCode.Create("GENDOC_PROCESS_TIMED_OUT"); + public static readonly ErrorCode TargetPathResolutionFailed = ErrorCode.Create("GENDOC_TARGET_PATH_RESOLUTION_FAILED"); + public static readonly ErrorCode WorkerNotDeployed = ErrorCode.Create("GENDOC_WORKER_NOT_DEPLOYED"); + public static readonly ErrorCode WorkerFailed = ErrorCode.Create("GENDOC_WORKER_FAILED"); + public static readonly ErrorCode WorkerOutputMissing = ErrorCode.Create("GENDOC_WORKER_OUTPUT_MISSING"); + public static readonly ErrorCode WorkerOutputUnreadable = ErrorCode.Create("GENDOC_WORKER_OUTPUT_UNREADABLE"); + public static readonly ErrorCode WorkerRunFailed = ErrorCode.Create("GENDOC_WORKER_RUN_FAILED"); + + #endregion + + } + + #endregion + +} diff --git a/FirstClassErrors.GenDoc/ErrCtxKey.cs b/FirstClassErrors.GenDoc/ErrCtxKey.cs new file mode 100644 index 0000000..e319e7a --- /dev/null +++ b/FirstClassErrors.GenDoc/ErrCtxKey.cs @@ -0,0 +1,55 @@ +namespace FirstClassErrors.GenDoc; + +/// +/// Context keys carried by the errors GenDoc raises about its own pipeline (see +/// and ). Each key names one +/// fact of the failing occurrence, so the failing element is identifiable without parsing messages. The value +/// types below all stringify culture-invariantly (string, int, TimeSpan), so the rendered context stays +/// deterministic. +/// +internal static class ErrCtxKey { + + #region Statics members declarations + + public static readonly ErrorContextKey SolutionPath = + ErrorContextKey.Create("SolutionPath", "Full path of the solution file the generation request designates."); + + public static readonly ErrorContextKey ProjectPath = + ErrorContextKey.Create("ProjectPath", "Full path of the project file being processed."); + + public static readonly ErrorContextKey AssemblyPath = + ErrorContextKey.Create("AssemblyPath", "Full path of the assembly being documented."); + + public static readonly ErrorContextKey TargetPath = + ErrorContextKey.Create("TargetPath", "Build output path resolved for the project (MSBuild TargetPath)."); + + public static readonly ErrorContextKey WorkerPath = + ErrorContextKey.Create("WorkerPath", "Configured path of the documentation worker assembly."); + + public static readonly ErrorContextKey ProbedDirectory = + ErrorContextKey.Create("ProbedDirectory", "Directory probed for the documentation worker assembly."); + + public static readonly ErrorContextKey OptInProperty = + ErrorContextKey.Create("OptInProperty", "Name of the MSBuild property read as the documentation opt-in marker."); + + public static readonly ErrorContextKey AmbiguityReason = + ErrorContextKey.Create("AmbiguityReason", "Why the opt-in property's effective value cannot be determined from the project XML."); + + public static readonly ErrorContextKey ProcessFileName = + ErrorContextKey.Create("ProcessFileName", "Executable name of the child process the generator tried to run."); + + public static readonly ErrorContextKey Command = + ErrorContextKey.Create("Command", "Short description of the child-process command that was running."); + + public static readonly ErrorContextKey Target = + ErrorContextKey.Create("Target", "Path of the solution, project or assembly the timed-out command was operating on."); + + public static readonly ErrorContextKey ExitCode = + ErrorContextKey.Create("ExitCode", "Exit code returned by the child process."); + + public static readonly ErrorContextKey Timeout = + ErrorContextKey.Create("Timeout", "Configured timeout the child process exceeded."); + + #endregion + +} diff --git a/FirstClassErrors.GenDoc/FirstClassErrors.GenDoc.csproj b/FirstClassErrors.GenDoc/FirstClassErrors.GenDoc.csproj index 087a873..2464d54 100644 --- a/FirstClassErrors.GenDoc/FirstClassErrors.GenDoc.csproj +++ b/FirstClassErrors.GenDoc/FirstClassErrors.GenDoc.csproj @@ -10,8 +10,22 @@ latest + + + true + + + + diff --git a/FirstClassErrors.GenDoc/SolutionDocumentationGenerationException.cs b/FirstClassErrors.GenDoc/SolutionDocumentationGenerationException.cs index a93cfbe..46768e2 100644 --- a/FirstClassErrors.GenDoc/SolutionDocumentationGenerationException.cs +++ b/FirstClassErrors.GenDoc/SolutionDocumentationGenerationException.cs @@ -1,15 +1,31 @@ -namespace FirstClassErrors.GenDoc; +namespace FirstClassErrors.GenDoc; -public sealed class SolutionDocumentationGenerationException : Exception { +/// +/// Raised when the documentation generation fails. The failure is carried as a first-class +/// — with its stable GENDOC_-prefixed code, structured context and +/// generated documentation — accessible through : the tool that documents +/// errors reports its own failures with the model it promotes. +/// +public sealed class SolutionDocumentationGenerationException : DiagnosableException { #region Constructors declarations - public SolutionDocumentationGenerationException(string message) - : base(message) { } + /// + /// Initializes the exception with the describing the generation failure. + /// + /// The error describing the failure (see and ). + public SolutionDocumentationGenerationException(Error error) + : base(error) { } - public SolutionDocumentationGenerationException(string message, Exception innerException) - : base(message, innerException) { } + /// + /// Initializes the exception with the describing the generation failure and the + /// runtime that caused it. + /// + /// The error describing the failure (see and ). + /// The runtime exception that caused the failure, preserved with its stack trace. + public SolutionDocumentationGenerationException(Error error, Exception innerException) + : base(error, innerException) { } #endregion -} \ No newline at end of file +} diff --git a/FirstClassErrors.GenDoc/SolutionErrorDocumentationGenerator.cs b/FirstClassErrors.GenDoc/SolutionErrorDocumentationGenerator.cs index 52799fd..dcec667 100644 --- a/FirstClassErrors.GenDoc/SolutionErrorDocumentationGenerator.cs +++ b/FirstClassErrors.GenDoc/SolutionErrorDocumentationGenerator.cs @@ -33,7 +33,7 @@ public static IEnumerable GetErrorDocumentationFrom(string s options.Logger.Info($"Starting documentation generation for solution '{solutionPath}'"); string fullSolutionPath = Path.GetFullPath(solutionPath); - if (!File.Exists(fullSolutionPath)) { throw new FileNotFoundException($"Solution file not found: '{fullSolutionPath}'", fullSolutionPath); } + if (!File.Exists(fullSolutionPath)) { throw new SolutionDocumentationGenerationException(DocumentationRequestError.SolutionNotFound(fullSolutionPath)); } // Accept both the classic (.sln) and the XML (.slnx) solution formats: "dotnet sln list", used below to // enumerate the projects, handles the two uniformly. Solution filters (.slnf) are intentionally excluded — @@ -41,7 +41,7 @@ public static IEnumerable GetErrorDocumentationFrom(string s string extension = Path.GetExtension(fullSolutionPath); bool isSolution = string.Equals(extension, ".sln", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".slnx", StringComparison.OrdinalIgnoreCase); - if (!isSolution) { throw new ArgumentException($"Expected a .sln or .slnx file path, got: '{fullSolutionPath}'", nameof(solutionPath)); } + if (!isSolution) { throw new SolutionDocumentationGenerationException(DocumentationRequestError.SolutionPathUnsupported(fullSolutionPath)); } if (options.BuildSolution) { DotNetBuild(fullSolutionPath, options); @@ -61,14 +61,17 @@ public static IEnumerable GetErrorDocumentationFrom(string s if (string.IsNullOrWhiteSpace(targetPath)) { continue; } if (!File.Exists(targetPath)) { - HandleFailure(options, $"Target assembly not found for project '{projectPath}'. Resolved TargetPath='{targetPath}'."); + HandleFailure(options, DocumentationRequestError.TargetAssemblyNotFound(projectPath, targetPath!)); continue; } assemblyPaths.Add(targetPath); - } catch (Exception ex) { - HandleFailure(options, $"Failed to resolve target path for project '{projectPath}'.", ex); + } catch (Exception ex) when (ex is not SolutionDocumentationGenerationException and not OperationCanceledException) { + // A cancellation must abandon the whole generation, and an already-coded failure (e.g. the SDK-query + // timeout raised below) must keep its own code — both propagate rather than being rewired into a + // per-project resolution failure (and swallowed under FailureBehavior.Continue). + HandleFailure(options, DocumentationToolchainError.TargetPathResolutionFailed(projectPath), ex); } } @@ -95,7 +98,7 @@ public static IEnumerable GetErrorDocumentationFromAssemblie foreach (string assemblyPath in assemblyPaths) { string fullPath = Path.GetFullPath(assemblyPath); if (!File.Exists(fullPath)) { - HandleFailure(options, $"Assembly not found: '{fullPath}'."); + HandleFailure(options, DocumentationRequestError.AssemblyNotFound(fullPath)); continue; } @@ -120,7 +123,9 @@ private static List ReadSolutionProjects(string solutionPath, SolutionGe ProcessResult result = RunProcess(DotNetCli, ["sln", solutionPath, "list"], solutionDirectory, options.Logger, options.SdkQueryTimeout, options.CancellationToken); if (result.ExitCode != 0) { throw new SolutionDocumentationGenerationException( - $"Failed to list the projects of solution '{solutionPath}' (exit code {result.ExitCode}).\n{result.StandardError}"); + result.TimedOut + ? DocumentationToolchainError.ProcessTimedOut($"dotnet sln {solutionPath} list", solutionPath, options.SdkQueryTimeout, result.StandardError) + : DocumentationToolchainError.ProjectEnumerationFailed(solutionPath, result.ExitCode, result.StandardError)); } List projects = new(); @@ -171,11 +176,7 @@ internal static bool ShouldIncludeProject(string projectPath, SolutionGeneration // silently picking one occurrence keeps a library about first-class errors honest about its own opt-in: the // project is left out with a trace instead of being documented on a coin toss. if (optIn.IsAmbiguous) { - HandleFailure( - options, - $"Cannot determine the opt-in for project '{projectPath}': the '{options.OptInPropertyName}' property is " + - $"{optIn.AmbiguityReason} in the project XML, which GenDoc reads without MSBuild evaluation. Declare it once, " + - "literally and unconditionally in the .csproj, or document the assembly explicitly with --assemblies. The project is skipped."); + HandleFailure(options, DocumentationRequestError.OptInAmbiguous(projectPath, options.OptInPropertyName, optIn.AmbiguityReason!)); return false; } @@ -311,7 +312,9 @@ private static void DotNetBuild(string solutionPath, SolutionGenerationOptions o if (result.ExitCode != 0) { throw new SolutionDocumentationGenerationException( - $"dotnet build failed (exit code {result.ExitCode}).\n{result.StandardOutput}\n{result.StandardError}"); + result.TimedOut + ? DocumentationToolchainError.ProcessTimedOut($"dotnet build {solutionPath}", solutionPath, options.BuildTimeout, $"{result.StandardOutput}\n{result.StandardError}") + : DocumentationToolchainError.SolutionBuildFailed(solutionPath, result.ExitCode, $"{result.StandardOutput}\n{result.StandardError}")); } } @@ -327,6 +330,15 @@ private static void DotNetBuild(string solutionPath, SolutionGenerationOptions o ProcessResult result = RunProcess(DotNetCli, args, Path.GetDirectoryName(projectPath)!, options.Logger, options.SdkQueryTimeout, options.CancellationToken); + if (result.TimedOut) { + // Unlike an ordinary query failure (below), a timeout must not degrade into a silent project skip: it is + // an environment problem, not a property the project lacks, so it goes through the failure policy like + // every other toolchain timeout. + HandleFailure(options, DocumentationToolchainError.ProcessTimedOut($"dotnet msbuild {projectPath} -getProperty:{propertyName}", projectPath, options.SdkQueryTimeout, result.StandardError)); + + return null; + } + if (result.ExitCode != 0) { return null; } @@ -406,7 +418,7 @@ private static ProcessResult RunProcess(string fileName, IReadOnlyList a }; bool started = process.Start(); - if (!started) { throw new SolutionDocumentationGenerationException($"Failed to start process '{fileName}'."); } + if (!started) { throw new SolutionDocumentationGenerationException(DocumentationToolchainError.ProcessStartFailed(fileName)); } process.BeginOutputReadLine(); process.BeginErrorReadLine(); @@ -434,9 +446,12 @@ private static ProcessResult RunProcess(string fileName, IReadOnlyList a // A cancellation that raced the timeout is reported as cancellation, not as a spurious timeout. cancellationToken.ThrowIfCancellationRequested(); - logger.Error($"Process '{fileName}' timed out after {limit} and was killed."); - return new ProcessResult(-1, stdout.ToString(), stderr.ToString()); + // TimedOut lets the caller raise the dedicated (transient) timeout error instead of reading the synthetic + // -1 exit code as an ordinary process failure. Reporting is owned by the callers — every one of them + // raises GENDOC_PROCESS_TIMED_OUT from this flag — so no error is logged here, which would double-report + // the same kill on the same stream. + return new ProcessResult(-1, stdout.ToString(), stderr.ToString(), TimedOut: true); } // A final no-argument wait blocks until the async stdout/stderr handlers have flushed. @@ -448,19 +463,20 @@ private static ProcessResult RunProcess(string fileName, IReadOnlyList a return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString()); } - private static void HandleFailure(SolutionGenerationOptions options, string message, Exception? ex = null) { + private static void HandleFailure(SolutionGenerationOptions options, Error error, Exception? ex = null) { if (options.FailureBehavior == FailureBehavior.Continue) { // In Continue mode the failure is swallowed so the generation proceeds with the remaining assemblies, but it // must never be silent: log it as a warning (with the exception detail when present) so the skipped assembly - // leaves a trace the caller can diagnose. + // leaves a trace the caller can diagnose. The stable error code leads the line so logs stay grep-able. + string message = $"{error.Code}: {error.DiagnosticMessage}"; options.Logger.Warning(ex is not null ? $"{message} {ex}" : message); return; } - if (ex is not null) { throw new SolutionDocumentationGenerationException(message, ex); } + if (ex is not null) { throw new SolutionDocumentationGenerationException(error, ex); } - throw new SolutionDocumentationGenerationException(message); + throw new SolutionDocumentationGenerationException(error); } private static bool IsTrue(string? value) { @@ -530,7 +546,7 @@ internal static IReadOnlyList DeduplicateAcrossAssemblies(IR private static string ResolveWorkerAssemblyPath(SolutionGenerationOptions options) { if (!string.IsNullOrWhiteSpace(options.WorkerAssemblyPath)) { if (!File.Exists(options.WorkerAssemblyPath)) { - throw new SolutionDocumentationGenerationException($"The configured documentation worker was not found at '{options.WorkerAssemblyPath}'."); + throw new SolutionDocumentationGenerationException(DocumentationRequestError.WorkerPathInvalid(options.WorkerAssemblyPath!)); } return options.WorkerAssemblyPath!; @@ -540,9 +556,7 @@ private static string ResolveWorkerAssemblyPath(SolutionGenerationOptions option string candidate = Path.Combine(AppContext.BaseDirectory, "FirstClassErrors.GenDoc.Worker.dll"); if (File.Exists(candidate)) { return candidate; } - throw new SolutionDocumentationGenerationException( - "The documentation worker 'FirstClassErrors.GenDoc.Worker.dll' could not be located. Set " + - $"{nameof(SolutionGenerationOptions)}.{nameof(SolutionGenerationOptions.WorkerAssemblyPath)}, or ensure the worker is deployed next to the tool."); + throw new SolutionDocumentationGenerationException(DocumentationToolchainError.WorkerNotDeployed(AppContext.BaseDirectory)); } private static ErrorDocumentationExtractionResult RunWorker(string workerAssemblyPath, string targetAssemblyPath, SolutionGenerationOptions options) { @@ -552,7 +566,7 @@ private static ErrorDocumentationExtractionResult RunWorker(string workerAssembl try { string depsFile = Path.ChangeExtension(targetAssemblyPath, ".deps.json"); - // Run the worker on its own runtimeconfig (RollForward=Major) but against the TARGET's dependency graph, so + // Run the worker on its own runtimeconfig (RollForward=LatestMajor) but against the TARGET's dependency graph, so // the target and its own FirstClassErrors resolve from the target's deps.json. The worker's own directory is // added as a fallback probing path (consulted only for assemblies the target's deps.json does not provide). List args = ["exec"]; @@ -577,13 +591,15 @@ private static ErrorDocumentationExtractionResult RunWorker(string workerAssembl ProcessResult result = RunProcess(DotNetCli, args, workerDirectory, options.Logger, options.WorkerTimeout, options.CancellationToken); if (result.ExitCode != 0) { - HandleFailure(options, $"The documentation worker failed (exit code {result.ExitCode}) for '{targetAssemblyPath}'.\n{result.StandardError}"); + HandleFailure(options, result.TimedOut + ? DocumentationToolchainError.ProcessTimedOut($"documentation worker for {targetAssemblyPath}", targetAssemblyPath, options.WorkerTimeout, result.StandardError) + : DocumentationToolchainError.WorkerFailed(targetAssemblyPath, result.ExitCode, result.StandardError)); return new ErrorDocumentationExtractionResult([], []); } if (!File.Exists(outputPath)) { - HandleFailure(options, $"The documentation worker produced no output for '{targetAssemblyPath}'."); + HandleFailure(options, DocumentationToolchainError.WorkerOutputMissing(targetAssemblyPath)); return new ErrorDocumentationExtractionResult([], []); } @@ -592,7 +608,7 @@ private static ErrorDocumentationExtractionResult RunWorker(string workerAssembl ErrorDocumentationExtractionResult? extraction = JsonSerializer.Deserialize(json); if (extraction is null) { - HandleFailure(options, $"The documentation worker produced unreadable output for '{targetAssemblyPath}'."); + HandleFailure(options, DocumentationToolchainError.WorkerOutputUnreadable(targetAssemblyPath)); return new ErrorDocumentationExtractionResult([], []); } @@ -601,7 +617,7 @@ private static ErrorDocumentationExtractionResult RunWorker(string workerAssembl } catch (Exception ex) when (ex is not SolutionDocumentationGenerationException and not OperationCanceledException) { // A cancellation must abandon the whole generation, so it is allowed to propagate rather than being recorded // as a per-assembly failure and swallowed under FailureBehavior.Continue. - HandleFailure(options, $"Failed to run the documentation worker for '{targetAssemblyPath}'.", ex); + HandleFailure(options, DocumentationToolchainError.WorkerRunFailed(targetAssemblyPath), ex); return new ErrorDocumentationExtractionResult([], []); } finally { @@ -617,7 +633,7 @@ private static ErrorDocumentationExtractionResult RunWorker(string workerAssembl #region Nested types declarations - private sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError); + private sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError, bool TimedOut = false); private enum MsBuildPropertyReadKind { diff --git a/FirstClassErrors/DiagnosableException.cs b/FirstClassErrors/DiagnosableException.cs index c32838e..9ed385a 100644 --- a/FirstClassErrors/DiagnosableException.cs +++ b/FirstClassErrors/DiagnosableException.cs @@ -25,6 +25,24 @@ protected DiagnosableException(Error error) Error = error; } + /// + /// Initializes a new instance of the class with the specified + /// and the runtime that caused it. + /// + /// + /// An instance of the class that provides detailed information about the exception. + /// + /// The runtime exception that caused this failure. + /// + /// The is the technical cause captured at the throw site (an I/O failure, a + /// serialization error…), preserved with its stack trace on . It is distinct + /// from , which nests modeled errors, not runtime exceptions. + /// + protected DiagnosableException(Error error, Exception innerException) + : base(error.DiagnosticMessage, innerException) { + Error = error; + } + #endregion /// diff --git a/doc/DocumentationExtractionReference.en.md b/doc/DocumentationExtractionReference.en.md index 7308062..5cc1452 100644 --- a/doc/DocumentationExtractionReference.en.md +++ b/doc/DocumentationExtractionReference.en.md @@ -174,6 +174,8 @@ Two categories of failure behave differently, and the difference is exactly what - the opt-in marker is ambiguous (declared twice, or under a `Condition`); - the worker crashes, times out, or produces no readable output. +Process-level failures are themselves first-class errors: each carries a stable `GENDOC_`-prefixed code (for example `GENDOC_WORKER_FAILED`, `GENDOC_PROCESS_TIMED_OUT`), structured context, and generated documentation — the tool documents its own failure surface with the very pipeline it implements. Log lines lead with the code, and under `--strict` the raised `SolutionDocumentationGenerationException` exposes the full error through its `Error` property. + | Failure | Default | `--strict` | Exit code | Surfaced in | | --- | --- | --- | --- | --- | | `[DocumentedBy]` missing or bad signature | recorded, run continues | recorded, run continues | `0` | result `Failures` and error log | @@ -194,8 +196,7 @@ error: Documentation extraction issue in 'artifacts/MyApp.Application.dll': MyAp A worker timeout is a process-level failure; the default run records it and continues, and it becomes fatal under `--strict`. The default worker timeout is two minutes: ```text -error: Process 'dotnet' timed out after 00:02:00 and was killed. -warning: The documentation worker failed (exit code -1) for 'artifacts/MyApp.Application.dll'. +warning: GENDOC_PROCESS_TIMED_OUT: Process 'documentation worker for artifacts/MyApp.Application.dll' timed out after 00:02:00 and was killed. ``` ## Timeouts and process failures diff --git a/doc/DocumentationExtractionReference.fr.md b/doc/DocumentationExtractionReference.fr.md index d06a521..d769088 100644 --- a/doc/DocumentationExtractionReference.fr.md +++ b/doc/DocumentationExtractionReference.fr.md @@ -174,6 +174,8 @@ Les **échecs de processus** surviennent autour d’un worker, pas à l’intér - le marqueur d’opt-in est ambigu (déclaré deux fois, ou sous `Condition`) ; - le worker plante, dépasse son timeout, ou ne produit aucune sortie lisible. +Les échecs de processus sont eux-mêmes des erreurs de première classe : chacun porte un code stable préfixé `GENDOC_` (par exemple `GENDOC_WORKER_FAILED`, `GENDOC_PROCESS_TIMED_OUT`), un contexte structuré et une documentation générée — l’outil documente sa propre surface d’échec avec le pipeline même qu’il implémente. Les lignes de log commencent par le code, et avec `--strict` la `SolutionDocumentationGenerationException` levée expose l’erreur complète via sa propriété `Error`. + | Échec | Par défaut | `--strict` | Code de sortie | Présent dans | | --- | --- | --- | --- | --- | | `[DocumentedBy]` manquant ou signature invalide | enregistré, poursuite | enregistré, poursuite | `0` | `Failures` du résultat et logs d’erreur | @@ -194,8 +196,7 @@ error: Documentation extraction issue in 'artifacts/MyApp.Application.dll': MyAp Un timeout de worker est un échec de processus ; par défaut, l’exécution l’enregistre et poursuit, et il devient fatal avec `--strict`. Le timeout de worker par défaut est de deux minutes : ```text -error: Process 'dotnet' timed out after 00:02:00 and was killed. -warning: The documentation worker failed (exit code -1) for 'artifacts/MyApp.Application.dll'. +warning: GENDOC_PROCESS_TIMED_OUT: Process 'documentation worker for artifacts/MyApp.Application.dll' timed out after 00:02:00 and was killed. ``` ## Timeouts et crashs diff --git a/doc/LoggingIntegration.en.md b/doc/LoggingIntegration.en.md index 749d808..e546a4b 100644 --- a/doc/LoggingIntegration.en.md +++ b/doc/LoggingIntegration.en.md @@ -115,7 +115,7 @@ See [Error Context](ErrorContext.en.md) for key design and data-safety rules. Do not re-list error properties in every `catch`. Project an `Error` to a log model once, in one place, and reuse it everywhere a failure is logged. -The projection must be recursive: `DiagnosableException` does not use `Exception.InnerException` for the FirstClassErrors diagnostic tree — causes and aggregated failures live in `error.InnerErrors`. It must also add the infrastructure-specific fields when the error is an `InfrastructureError`: +The projection must be recursive: `DiagnosableException` does not use `Exception.InnerException` for the FirstClassErrors diagnostic tree — causes and aggregated failures live in `error.InnerErrors`. (A `DiagnosableException` may still carry a plain runtime exception — an I/O failure, for instance — on `Exception.InnerException`; that channel holds the technical cause with its stack trace, not modeled errors, so log it as you would any exception cause.) It must also add the infrastructure-specific fields when the error is an `InfrastructureError`: ```csharp public static class ErrorLogModel { diff --git a/doc/LoggingIntegration.fr.md b/doc/LoggingIntegration.fr.md index 66318e5..f960339 100644 --- a/doc/LoggingIntegration.fr.md +++ b/doc/LoggingIntegration.fr.md @@ -115,7 +115,7 @@ Voir [Contexte d’erreur](ErrorContext.fr.md) pour la conception des clés et l Ne réénumérez pas les propriétés de l’erreur dans chaque `catch`. Projetez une `Error` vers un modèle de log une seule fois, à un seul endroit, et réutilisez-le partout où un échec est loggé. -La projection doit être récursive : `DiagnosableException` n’utilise pas `Exception.InnerException` pour l’arbre diagnostique FirstClassErrors — les causes et erreurs agrégées vivent dans `error.InnerErrors`. Elle doit aussi ajouter les champs propres à l’infrastructure lorsque l’erreur est une `InfrastructureError` : +La projection doit être récursive : `DiagnosableException` n’utilise pas `Exception.InnerException` pour l’arbre diagnostique FirstClassErrors — les causes et erreurs agrégées vivent dans `error.InnerErrors`. (Une `DiagnosableException` peut néanmoins porter une exception runtime ordinaire — une erreur d’E/S, par exemple — sur `Exception.InnerException` ; ce canal transporte la cause technique avec sa stack trace, pas des erreurs modélisées : loggez-le comme n’importe quelle cause d’exception.) Elle doit aussi ajouter les champs propres à l’infrastructure lorsque l’erreur est une `InfrastructureError` : ```csharp public static class ErrorLogModel { diff --git a/maintainers/adr/0009-report-the-toolings-failures-as-first-class-errors.fr.md b/maintainers/adr/0009-report-the-toolings-failures-as-first-class-errors.fr.md new file mode 100644 index 0000000..d78a5fc --- /dev/null +++ b/maintainers/adr/0009-report-the-toolings-failures-as-first-class-errors.fr.md @@ -0,0 +1,139 @@ +# ADR-0009 | Rapporter les échecs de l'outillage comme des erreurs de première classe + +🌍 🇬🇧 [English](0009-report-the-toolings-failures-as-first-class-errors.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Proposé +**Date :** 2026-07-16 +**Décideurs :** Reefact + +## Contexte + +FirstClassErrors existe pour faire des erreurs applicatives des concepts de +première classe, documentés et diagnosticables : un code stable, un contexte +structuré, une classification de transience, et une documentation générée +depuis le code lui-même. Le dépôt livre le modèle (la bibliothèque cœur), sa +vérification à la compilation (les analyzers) et un pipeline de documentation +(GenDoc, son worker et la CLI `fce`). + +Avant cette décision, le pipeline de documentation rapportait ses propres +échecs en dehors de ce modèle : une sous-classe d'`Exception` nue portant un +message libre, des exceptions du framework (`FileNotFoundException`, +`ArgumentException`) sur les chemins de garde, et des chaînes de log non +structurées. Aucun de ces canaux ne portait de code d'erreur, de contexte +structuré ni de documentation. Le dépôt n'avait aucun consommateur interne +réel du modèle : le cœur ne définit aucun code émettable qui lui soit propre, +et les seules factories documentées vivaient dans l'exemple `Usage` et le +request binder (l'issue #140 consigne ce manque côté consommateur). + +La surface d'échec du générateur est opérationnelle par nature — requêtes de +génération invalides, commandes SDK en échec, worker d'extraction qui plante +ou ne répond plus — et elle est exercée par des appelants externes (la CLI, +les pipelines CI) qui ont besoin de distinguer, réessayer ou rapporter ces +échecs. Renommer un code d'erreur établi ou un type public est un changement +cassant selon les règles de compatibilité du dépôt. + +## Décision + +L'outillage de documentation modélise sa propre surface d'échec comme des +erreurs FirstClassErrors documentées — des codes stables préfixés `GENDOC_` +portés par `SolutionDocumentationGenerationException`, une +`DiagnosableException` — et son propre catalogue est généré et vérifié par le +pipeline même qu'il implémente. + +## Justification + +* **L'outil doit pratiquer ce que la bibliothèque prêche.** Une bibliothèque + dont la thèse est « les erreurs méritent un code, un contexte et une + documentation » se démontre le plus crédiblement par son propre outillage : + les échecs du générateur sont exactement le genre d'erreurs opérationnelles + que vise le modèle. Les laisser à l'état de chaînes nues contredisait la + revendication centrale du projet dans son propre code. +* **La surface d'échec est un contrat, elle a donc besoin d'identités + stables.** Les pipelines CI et les appelants programmatiques réagissent aux + échecs de génération. Un code `GENDOC_` stable leur donne un point d'appui + qui survit aux reformulations de message, et le préfixe propre à l'outil + évite toute collision entre ses codes et les codes applicatifs quand les + deux aboutissent dans un même catalogue généré. +* **Un seul type d'exception, désormais diagnosticable.** Les appelants + attrapaient déjà `SolutionDocumentationGenerationException` ; conserver ce + type unique en le rebasant sur `DiagnosableException` préserve les sites de + catch existants tout en exposant l'erreur structurée complète, plutôt que + d'introduire un second canal d'échec à apprendre. +* **L'auto-documentation double comme test de bout en bout.** Parce que + l'extraction exécute les méthodes de documentation et les factories + d'exemple, générer le catalogue de l'outil lui-même en CI prouve le + pipeline entier — attributs, lecteur, worker, moteur de rendu — contre une + surface d'erreur réelle et évolutive plutôt qu'un jeu de données figé. + +## Alternatives envisagées + +### Garder l'exception nue et se contenter de loguer les erreurs structurées + +Envisagée parce qu'elle n'exigeait aucun changement des types levés. Rejetée +parce que les appelants programmatiques n'auraient jamais reçu l'erreur +structurée : le modèle se serait arrêté à la ligne de log, et l'outil serait +resté absent de son propre catalogue. + +### Lever les exceptions de famille via `Error.ToException()` + +Envisagée parce que c'est le chemin de levée par défaut de la bibliothèque +(`PrimaryPortException`/`SecondaryPortException`). Rejetée parce qu'elle +remplace le type d'exception unique que les appelants attrapent déjà par une +famille de types choisie par échec — un contrat plus large et plus cassant +que conserver une exception unique, propre à la génération, qui porte +l'erreur. + +### Ne modéliser que les échecs du worker, garder les gardes sur les exceptions du framework + +Envisagée parce que les échecs de garde (fichier solution manquant, mauvaise +extension) se projettent naturellement sur +`FileNotFoundException`/`ArgumentException`. Rejetée parce qu'elle scinde la +surface d'échec entre deux conventions : les situations les plus visibles des +utilisateurs seraient restées sans code et sans documentation — exactement le +manque que la décision vise à combler. + +## Conséquences + +### Positives + +* Les échecs du générateur portent des codes stables, un contexte, une + transience et une direction, et sont documentés dans un catalogue généré + par l'outil lui-même. +* Le dépôt gagne un consommateur interne réel du modèle, qui sert d'exemple + de référence au-delà de l'exemple synthétique `Usage`. +* La CI vérifie le pipeline de bout en bout contre le catalogue de l'outil. + +### Négatives + +* Les codes `GENDOC_` et le contrat porteur d'erreur de l'exception + deviennent une surface de compatibilité : renommer un code est désormais un + changement cassant de l'outil. +* Les appelants qui construisaient `SolutionDocumentationGenerationException` + depuis un message doivent la construire depuis une `Error`. + +### Risques + +* Les exemples de documentation s'exécutent dans le worker d'extraction ; une + factory qui gagnerait un jour des effets de bord (lancement de processus, + accès au système de fichiers) les exécuterait à chaque génération. Les + factories doivent rester de purs assembleurs de faits déjà calculés. + +## Actions de suivi + +* Étendre le modèle aux surfaces d'échec du rendu et du versionnage de + catalogue (`LayoutNotSupportedException`, `ServiceNameRequiredException`, + `CatalogSchemaTooNewException`). +* Étudier une API de génération retournant `Outcome` (nommage selon + l'ADR-0005) comme variante non levante des points d'entrée du générateur. + +## Références + +* Issue [#142](https://github.com/Reefact/first-class-errors/issues/142) — la + demande à laquelle cette décision répond. +* Issue [#140](https://github.com/Reefact/first-class-errors/issues/140) — + documenter les codes des packages référencés dans le catalogue d'un + consommateur. +* ADR-0002 — le plancher de runtime de l'outillage sur lequel s'appuie le + modèle de worker. +* ADR-0005 — le nommage des factories pour une future API de génération + retournant `Outcome`. diff --git a/maintainers/adr/0009-report-the-toolings-failures-as-first-class-errors.md b/maintainers/adr/0009-report-the-toolings-failures-as-first-class-errors.md new file mode 100644 index 0000000..f771bb9 --- /dev/null +++ b/maintainers/adr/0009-report-the-toolings-failures-as-first-class-errors.md @@ -0,0 +1,127 @@ +# ADR-0009 | Report the tooling's failures as first-class errors + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0009-report-the-toolings-failures-as-first-class-errors.fr.md) + +**Status:** Proposed +**Date:** 2026-07-16 +**Decision Makers:** Reefact + +## Context + +FirstClassErrors exists to turn application errors into first-class, documented, +diagnosable concepts: a stable code, structured context, a transience +classification, and documentation generated from the code itself. The +repository ships the model (the core library), compile-time enforcement (the +analyzers), and a documentation pipeline (GenDoc, its worker, and the `fce` +CLI). + +Until this decision, the documentation pipeline reported its own failures +outside that model: a bare `Exception` subclass carrying a free-form message, +framework exceptions (`FileNotFoundException`, `ArgumentException`) on guard +paths, and unstructured log strings. None of these carried an error code, +structured context, or documentation. The repository had no real internal +consumer of the model: the core library defines no emittable codes of its own, +and the only documented factories lived in the `Usage` sample and the request +binder (issue #140 records this gap from the consumer side). + +The generator's failure surface is operational by nature — invalid generation +requests, failing SDK commands, a crashing or hanging extraction worker — and +is exercised by external callers (the CLI, CI pipelines) that need to +distinguish, retry, or report those failures. Renaming an established error +code or public type is a breaking change under the repository's compatibility +rules. + +## Decision + +The documentation tooling models its own failure surface as documented +FirstClassErrors errors — stable `GENDOC_`-prefixed codes carried by +`SolutionDocumentationGenerationException`, a `DiagnosableException` — and its +own catalog is generated and asserted by the pipeline it implements. + +## Rationale + +* **The tool must practice what the library preaches.** A library whose thesis + is "errors deserve a code, context and documentation" is most credibly + demonstrated by its own tooling: the generator's failures are exactly the + kind of operational errors the model targets. Keeping them as bare strings + contradicted the project's core claim in its own codebase. +* **The failure surface is a contract, so it needs stable identities.** CI + pipelines and programmatic callers react to generation failures. A stable + `GENDOC_` code gives them something to match on that survives message + rewording, and the tool-specific prefix keeps the tool's codes from + colliding with application codes when both land in one generated catalog. +* **One exception type, now diagnosable.** Callers already caught + `SolutionDocumentationGenerationException`; keeping that single type but + rebasing it on `DiagnosableException` preserves existing catch sites while + exposing the full structured error, instead of introducing a second failure + channel callers must learn. +* **Self-documentation doubles as an end-to-end test.** Because extraction + executes documentation methods and example factories, generating the tool's + own catalog in CI proves the whole pipeline — attributes, reader, worker, + renderer — against a real, evolving error surface rather than a fixture. + +## Alternatives Considered + +### Keep the bare exception and only log structured errors + +Considered because it avoids any change to the thrown types. Rejected because +programmatic callers would never receive the structured error: the model would +stop at the log line, and the tool would remain undocumented in its own +catalog. + +### Raise the family exceptions via `Error.ToException()` + +Considered because it is the library's default throwing path +(`PrimaryPortException`/`SecondaryPortException`). Rejected because it +replaces the single exception type callers already catch with a family of +types chosen per failure, a wider and more breaking contract than keeping one +generation-scoped exception that carries the error. + +### Model only the worker failures, keep guard paths on framework exceptions + +Considered because guard failures (missing solution file, wrong extension) +map naturally onto `FileNotFoundException`/`ArgumentException`. Rejected +because it splits the failure surface across two conventions: the situations +most visible to users would stay code-less and undocumented, exactly the gap +the decision exists to close. + +## Consequences + +### Positive + +* The generator's failures carry stable codes, context, transience and + direction, and are documented in a catalog generated by the tool itself. +* The repository gains a real internal consumer of the model, serving as a + reference example beyond the synthetic `Usage` sample. +* CI asserts the pipeline end to end against the tool's own catalog. + +### Negative + +* The `GENDOC_` codes and the exception's error-carrying contract become a + compatibility surface: renaming a code is now a breaking change of the tool. +* Callers that constructed `SolutionDocumentationGenerationException` from a + message string must construct it from an `Error`. + +### Risks + +* Documentation examples execute inside the extraction worker; a factory that + ever gains side effects (process spawning, file-system access) would run + them at every generation. The factories must stay pure assemblers of + already-computed facts. + +## Follow-up Actions + +* Extend the model to the rendering and catalog-versioning failure surfaces + (`LayoutNotSupportedException`, `ServiceNameRequiredException`, + `CatalogSchemaTooNewException`). +* Consider an `Outcome`-returning generation API (per ADR-0005 naming) as the + non-throwing variant of the generator's entry points. + +## References + +* Issue [#142](https://github.com/Reefact/first-class-errors/issues/142) — the + request this decision answers. +* Issue [#140](https://github.com/Reefact/first-class-errors/issues/140) — + documenting referenced packages' codes in a consumer's catalog. +* ADR-0002 — the tooling runtime floor the worker model builds on. +* ADR-0005 — factory naming for a future Outcome-returning generation API. diff --git a/maintainers/adr/README.md b/maintainers/adr/README.md index 76f53e5..ed79960 100644 --- a/maintainers/adr/README.md +++ b/maintainers/adr/README.md @@ -182,3 +182,4 @@ Optional supporting material: | [ADR-0006](0006-supply-arbitrary-test-values-from-a-seedable-source.md) | Supply arbitrary test values from a single seedable source | Accepted | | [ADR-0007](0007-name-the-binder-terminals-new-and-create.md) | Name the binder terminals New and Create | Accepted | | [ADR-0008](0008-bind-nullable-value-type-properties-through-a-struct-constrained-overload.md) | Bind nullable value-type properties through a struct-constrained overload | Proposed | +| [ADR-0009](0009-report-the-toolings-failures-as-first-class-errors.md) | Report the tooling's failures as first-class errors | Proposed |