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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -116,11 +118,18 @@ 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
echo "::error::no documented errors were extracted on the .NET 8 floor"
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"
31 changes: 31 additions & 0 deletions FirstClassErrors.Cli.UnitTests/GenerateCommandExecutionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions FirstClassErrors.Cli/CatalogDiffCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
string configDir = Path.GetDirectoryName(configPath) ?? Directory.GetCurrentDirectory();

// Validate the policy and report options before the (expensive) extraction runs, so a typo fails fast.
string failOn = (settings.FailOn ?? "breaking").Trim().ToLowerInvariant();

Check warning on line 82 in FirstClassErrors.Cli/CatalogDiffCommand.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Define a constant instead of using this literal 'breaking' 4 times.
if (failOn is not ("breaking" or "any" or "none")) {
logger.Error($"Unknown --fail-on value '{settings.FailOn}'. Use breaking, any or none.");

Expand All @@ -94,7 +94,7 @@
}

string baselinePath = BaselineStore.Resolve(settings.BaselinePath, configuration.Baseline, configDir);
if (BaselineStore.Exists(baselinePath) is false) {

Check warning on line 97 in FirstClassErrors.Cli/CatalogDiffCommand.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Remove the unnecessary Boolean literal(s).
logger.Error($"No baseline at '{baselinePath}'. Run 'fce catalog update' to create it.");

return 1;
Expand All @@ -115,7 +115,7 @@

bool violated = failOn switch {
"breaking" => diff.HasChangesAtOrAbove(CatalogChangeImpact.Breaking),
"any" => diff.IsEmpty is false,

Check warning on line 118 in FirstClassErrors.Cli/CatalogDiffCommand.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Remove the unnecessary Boolean literal(s).
_ => false
};

Expand All @@ -133,6 +133,8 @@
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);
Expand Down
2 changes: 2 additions & 0 deletions FirstClassErrors.Cli/CatalogUpdateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
namespace FirstClassErrors.Cli;

/// <summary>Settings for <c>fce catalog update</c>.</summary>
internal sealed class CatalogUpdateSettings : CatalogSettings { }

Check warning on line 13 in FirstClassErrors.Cli/CatalogUpdateCommand.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Remove this empty class, write its code or make it an "interface".

/// <summary>
/// Creates or refreshes the catalog baseline: extracts the current catalog, projects it into its canonical
Expand Down Expand Up @@ -61,7 +61,7 @@
CatalogSnapshot current = _snapshotSource.Extract(settings, configuration, logger, cancellationToken);
string baselinePath = BaselineStore.Resolve(settings.BaselinePath, configuration.Baseline, configDir);

if (BaselineStore.Exists(baselinePath) is false) {

Check warning on line 64 in FirstClassErrors.Cli/CatalogUpdateCommand.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Remove the unnecessary Boolean literal(s).
BaselineStore.Save(baselinePath, current);
_output.WriteLine($"Baseline created at '{baselinePath}', tracking {current.Errors.Count} error(s).");

Expand Down Expand Up @@ -105,6 +105,8 @@
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);
Expand Down
32 changes: 32 additions & 0 deletions FirstClassErrors.Cli/FailureReporting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#region Usings declarations

using FirstClassErrors.GenDoc;

#endregion

namespace FirstClassErrors.Cli;

/// <summary>
/// Shared rendering of coded pipeline failures, so every command reports a <see cref="DiagnosableException" />
/// with the exact same line format. That format is a contract: it is documented, test-asserted, and grep-able.
/// </summary>
internal static class FailureReporting {

#region Statics members declarations

/// <summary>
/// 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 <c>--verbose</c>.
/// </summary>
/// <returns>The command exit code for a failure (1).</returns>
internal static int ReportCodedFailure(IGenerationLogger logger, DiagnosableException exception) {
logger.Error($"{exception.Error.Code}: {exception.Message}");
logger.Debug(exception.ToString());

return 1;
}

#endregion

}
2 changes: 2 additions & 0 deletions FirstClassErrors.Cli/GenerateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}

}
128 changes: 128 additions & 0 deletions FirstClassErrors.GenDoc.UnitTests/DocumentationToolchainErrorTests.cs
Original file line number Diff line number Diff line change
@@ -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");
}

}
Loading