Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1c42ea9
ci: replace scorecard read-all with an explicit read scope
claude Jul 11, 2026
a69c3d4
ci: stop reporting the deliberate 'is false' idiom (S1125)
claude Jul 11, 2026
91dd0d4
ci: add default cases and an explicit return in commit-lint
claude Jul 11, 2026
c617463
refactor(core,testing): resolve Sonar code smells
claude Jul 11, 2026
6cb2686
refactor(analyzers): resolve Sonar code smells
claude Jul 11, 2026
8a1a460
refactor(gendoc): resolve Sonar code smells
claude Jul 11, 2026
b8044bf
refactor(gendoc): modernize the emitted client script
claude Jul 11, 2026
c23002b
refactor(cli): resolve Sonar code smells
claude Jul 11, 2026
2026bb8
refactor: resolve Sonar code smells in the usage examples
claude Jul 11, 2026
cba7005
test(core,gendoc): resolve Sonar findings in the test fixtures
claude Jul 11, 2026
6f3eea6
refactor: replace the 'is false' idiom with '!' (S1125)
claude Jul 11, 2026
d00e9ab
refactor: keep Amount minimal and suppress S1210
claude Jul 11, 2026
e29e202
refactor(analyzers,gendoc): avoid single-iteration loops (S1751)
claude Jul 11, 2026
1ad8352
ci: exclude Verify snapshot fixtures from Sonar analysis
claude Jul 11, 2026
6f3c1fd
refactor(gendoc): handle the localStorage error in emitted JS (S2486)
claude Jul 11, 2026
fe76eaf
test(cli): cover the config-load and output-sink file branches
claude Jul 11, 2026
204ce73
refactor(gendoc): keep Assembly.LoadFrom in the worker (S3885)
claude Jul 11, 2026
7efcb88
test(gendoc): assert generated HTML is well-formed (AngleSharp)
claude Jul 11, 2026
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
6 changes: 4 additions & 2 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ concurrency:
group: scorecard-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Read-only by default; the single job widens only the two write scopes it needs.
permissions: read-all
# Minimal read at the workflow level; the single job overrides with only the two write scopes it
# needs. Enumerated rather than `read-all` so the default token grants exactly one explicit scope.
permissions:
contents: read

jobs:
analysis:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ jobs:

# The build must sit between `begin` and `end`: the scanner hooks MSBuild to
# observe the compilation, so it cannot use a pre-built or --no-build output.
# *.verified.* are Verify snapshot oracles — generated test fixtures, not production source. Excluding them
# keeps Sonar from analysing the emitted HTML/JS/Markdown as if it were hand-written code (and from counting
# the near-identical snapshots as duplication).
- name: Begin analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
Expand All @@ -76,6 +79,7 @@ jobs:
/d:sonar.token="$SONAR_TOKEN"
/d:sonar.host.url="https://sonarcloud.io"
/d:sonar.cs.opencover.reportsPaths="artifacts/coverage/**/coverage.opencover.xml"
/d:sonar.exclusions="**/*.verified.*"

- name: Build
# Disable the CI warning ratchet (Directory.Build.props) for the analysis build only. The scanner
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
-->

<ItemGroup>
<PackageVersion Include="AngleSharp" Version="1.5.2" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="FsCheck" Version="3.3.3" />
<PackageVersion Include="JetBrains.Annotations" Version="2026.2.0" />
Expand Down
4 changes: 2 additions & 2 deletions FirstClassErrors.Analyzers/Descriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ internal static class Descriptors {
isEnabledByDefault: true,
description: "An ErrorCode is compared by value, so the same literal code created more than once yields equal instances that documentation extraction and lookups collapse into a single identity. Detection is per-compilation and limited to literal codes.",
helpLinkUri: HelpLinks.For(DiagnosticIds.DuplicateErrorCode),
customTags: new[] { WellKnownDiagnosticTags.CompilationEnd });
WellKnownDiagnosticTags.CompilationEnd);

public static readonly DiagnosticDescriptor EmptyErrorCode = new(
id: DiagnosticIds.EmptyErrorCode,
Expand Down Expand Up @@ -138,7 +138,7 @@ internal static class Descriptors {
isEnabledByDefault: true,
description: "Documentation extraction groups by error code and keeps a single entry per code. Two documented factories that share the same code field silently collapse to one in the catalog.",
helpLinkUri: HelpLinks.For(DiagnosticIds.DuplicateDocumentedCode),
customTags: new[] { WellKnownDiagnosticTags.CompilationEnd });
WellKnownDiagnosticTags.CompilationEnd);

public static readonly DiagnosticDescriptor ExampleDoesNotCallDocumentedFactory = new(
id: DiagnosticIds.ExampleDoesNotCallDocumentedFactory,
Expand Down
36 changes: 25 additions & 11 deletions FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,35 @@ private static void Collect(
if (context.OwningSymbol is not IMethodSymbol method) { return; }
if (!SymbolFacts.HasAttribute(method, symbols.DocumentedByAttribute!)) { return; }

foreach (IOperation block in context.OperationBlocks) {
foreach (IOperation operation in OperationFacts.EnumerateOperations(block)) {
if (operation is not IInvocationOperation invocation) { continue; }
if (invocation.TargetMethod.Name != CreateMethodName) { continue; }
if (!SymbolFacts.IsOrInheritsFrom(invocation.TargetMethod.ContainingType, symbols.Error!)) { continue; }
if (invocation.Arguments.Length == 0) { continue; }
// The outermost error-factory Create identifies the produced code.
IInvocationOperation? create = FindErrorFactoryCreate(context.OperationBlocks, symbols);
if (create is null) { return; }

if (TryGetCodeField(invocation.Arguments[0].Value, out ISymbol? codeField)) {
usagesByCodeField.GetOrAdd(codeField!, _ => new ConcurrentBag<Location>())
.Add(method.Locations.FirstOrDefault() ?? Location.None);
}
if (TryGetCodeField(create.Arguments[0].Value, out ISymbol? codeField)) {
usagesByCodeField.GetOrAdd(codeField!, _ => new ConcurrentBag<Location>())
.Add(method.Locations.FirstOrDefault() ?? Location.None);
}
}

return; // the outermost error-factory Create identifies the produced code
/// <summary>Finds the first <c>Error.Create(...)</c> invocation carrying at least one argument, across the blocks.</summary>
private static IInvocationOperation? FindErrorFactoryCreate(ImmutableArray<IOperation> blocks, KnownSymbols symbols) {
foreach (IOperation block in blocks) {
foreach (IOperation operation in OperationFacts.EnumerateOperations(block)) {
IInvocationOperation? create = AsErrorFactoryCreate(operation, symbols);
if (create is not null) { return create; }
}
}

return null;
}

/// <summary>Returns <paramref name="operation" /> as an <c>Error.Create(...)</c> invocation with arguments, or <c>null</c>.</summary>
private static IInvocationOperation? AsErrorFactoryCreate(IOperation operation, KnownSymbols symbols) {
if (operation is not IInvocationOperation invocation) { return null; }
if (invocation.TargetMethod.Name != CreateMethodName) { return null; }
if (!SymbolFacts.IsOrInheritsFrom(invocation.TargetMethod.ContainingType, symbols.Error!)) { return null; }

return invocation.Arguments.Length == 0 ? null : invocation;
}

private static bool TryGetCodeField(IOperation codeArgument, out ISymbol? codeField) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,8 @@ private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol e
INamedTypeSymbol? documentingType = context.ContainingSymbol.ContainingType;
if (documentingType is null) { return; }

foreach (IOperation example in GetExampleOperations(invocation)) {
if (!ExampleInvokesMemberOf(example, documentingType)) {
context.ReportDiagnostic(Diagnostic.Create(Descriptors.ExampleDoesNotCallDocumentedFactory, example.Syntax.GetLocation(), documentingType.Name));
}
foreach (IOperation example in GetExampleOperations(invocation).Where(example => !ExampleInvokesMemberOf(example, documentingType))) {
context.ReportDiagnostic(Diagnostic.Create(Descriptors.ExampleDoesNotCallDocumentedFactory, example.Syntax.GetLocation(), documentingType.Name));
}
}

Expand Down
26 changes: 10 additions & 16 deletions FirstClassErrors.Analyzers/SymbolFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ public static bool TryGetDocumentedBy(
out AttributeData? attribute,
out string? targetMethodName) {

foreach (AttributeData candidate in method.GetAttributes()) {
if (SymbolEqualityComparer.Default.Equals(candidate.AttributeClass, documentedByAttributeType)) {
attribute = candidate;
targetMethodName = candidate.ConstructorArguments.Length == 1
? candidate.ConstructorArguments[0].Value as string
: null;

return true;
}
AttributeData? candidate = method.GetAttributes()
.FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, documentedByAttributeType));
if (candidate is not null) {
attribute = candidate;
targetMethodName = candidate.ConstructorArguments.Length == 1
? candidate.ConstructorArguments[0].Value as string
: null;

return true;
}

attribute = null;
Expand All @@ -35,13 +35,7 @@ public static bool TryGetDocumentedBy(
}

public static bool HasAttribute(ISymbol symbol, INamedTypeSymbol attributeType) {
foreach (AttributeData attribute in symbol.GetAttributes()) {
if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType)) {
return true;
}
}

return false;
return symbol.GetAttributes().Any(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType));
}

public static bool IsOrInheritsFrom(ITypeSymbol type, INamedTypeSymbol target) {
Expand Down
45 changes: 45 additions & 0 deletions FirstClassErrors.Cli.UnitTests/ConfigurationStoreTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#region Usings declarations

using JetBrains.Annotations;

using NFluent;

#endregion

namespace FirstClassErrors.Cli.UnitTests;

[TestSubject(typeof(ConfigurationStore))]
public sealed class ConfigurationStoreTests {

[Fact(DisplayName = "Loading a configuration from a missing file returns an empty configuration.")]
public void LoadingAMissingFileReturnsAnEmptyConfiguration() {
// Setup: a path guaranteed not to exist.
string path = CliTestHelpers.NonExistentConfigPath();

// Exercise
CliConfiguration configuration = ConfigurationStore.Load(path);

// Verify: a fresh configuration carries none of the persisted defaults.
Check.That(configuration.Solution).IsNull();
Check.That(configuration.Format).IsNull();
Check.That(configuration.NoBuild).IsNull();
}

[Fact(DisplayName = "Loading a configuration from a blank file returns an empty configuration.")]
public void LoadingABlankFileReturnsAnEmptyConfiguration() {
// Setup: an existing but blank file exercises the whitespace short-circuit rather than the missing-file one.
string path = Path.Combine(Path.GetTempPath(), $"fce-blank-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path, " \n");

try {
// Exercise
CliConfiguration configuration = ConfigurationStore.Load(path);

// Verify
Check.That(configuration.Solution).IsNull();
} finally {
File.Delete(path);
}
}

}
33 changes: 33 additions & 0 deletions FirstClassErrors.Cli.UnitTests/ConsoleAndFileOutputSinkTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#region Usings declarations

using JetBrains.Annotations;

using NFluent;

#endregion

namespace FirstClassErrors.Cli.UnitTests;

[TestSubject(typeof(ConsoleAndFileOutputSink))]
public sealed class ConsoleAndFileOutputSinkTests {

[Fact(DisplayName = "Writing a file into a not-yet-existing nested directory creates the directory and the file.")]
public void WritingIntoANestedDirectoryCreatesItAndTheFile() {
// Setup: a target whose parent directory does not exist yet, so WriteFile must create it.
ConsoleAndFileOutputSink sink = new();
string rootDir = Path.Combine(Path.GetTempPath(), $"fce-sink-{Guid.NewGuid():N}");
string filePath = Path.Combine(rootDir, "nested", "out.txt");

try {
// Exercise
sink.WriteFile(filePath, "content");

// Verify: the nested directory was created and the content written.
Check.That(File.Exists(filePath)).IsTrue();
Check.That(File.ReadAllText(filePath)).IsEqualTo("content");
} finally {
if (Directory.Exists(rootDir)) { Directory.Delete(rootDir, recursive: true); }
}
}

}
2 changes: 1 addition & 1 deletion FirstClassErrors.Cli/ConfigShowCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal sealed class ConfigShowCommand : Command<ConfigScopedSettings> {
protected override int Execute(CommandContext context, ConfigScopedSettings settings, CancellationToken cancellationToken) {
string path = ConfigurationStore.Resolve(settings.ConfigPath);

if (ConfigurationStore.Exists(path) is false) {
if (!ConfigurationStore.Exists(path)) {
Console.Out.WriteLine($"No configuration at '{path}'. Run 'fce config init' to create one.");

return 0;
Expand Down
4 changes: 2 additions & 2 deletions FirstClassErrors.Cli/ConfigurationStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public static bool Exists(string path) {

/// <summary>Loads the configuration, returning an empty one when the file is missing or blank.</summary>
public static CliConfiguration Load(string path) {
if (File.Exists(path) is false) { return new CliConfiguration(); }
if (!File.Exists(path)) { return new CliConfiguration(); }

string json = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(json)) { return new CliConfiguration(); }
Expand All @@ -58,7 +58,7 @@ public static CliConfiguration Load(string path) {
/// </summary>
public static void Save(string path, CliConfiguration configuration) {
string? directory = Path.GetDirectoryName(path);
if (string.IsNullOrEmpty(directory) is false) { Directory.CreateDirectory(directory); }
if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); }

string json = JsonSerializer.Serialize(configuration, SerializerOptions);
string tempPath = path + ".tmp";
Expand Down
2 changes: 1 addition & 1 deletion FirstClassErrors.Cli/ConsoleAndFileOutputSink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public void WriteStandardOutput(string content) {

public void WriteFile(string fullPath, string content) {
string? directory = Path.GetDirectoryName(fullPath);
if (string.IsNullOrEmpty(directory) is false) {
if (!string.IsNullOrEmpty(directory)) {
Directory.CreateDirectory(directory);
}

Expand Down
8 changes: 4 additions & 4 deletions FirstClassErrors.Cli/DocumentationOutputWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ public void Write(IReadOnlyList<RenderedDocument> documents, string format, stri
throw new InvalidOperationException($"The '{format}' renderer produced no documents; a renderer must return at least one document.");
}

bool hasOutput = string.IsNullOrWhiteSpace(outputPath) is false;
bool hasOutput = !string.IsNullOrWhiteSpace(outputPath);

// No target: only a single document can go to standard output.
if (hasOutput is false) {
if (!hasOutput) {
if (documents.Count > 1) {
throw new InvalidOperationException("This layout produces several files; specify an output directory with --output (or 'output' in the configuration).");
}
Expand All @@ -54,7 +54,7 @@ public void Write(IReadOnlyList<RenderedDocument> documents, string format, stri
// Treat the target as a directory when there are several files, when it already exists as one, or when the
// path ends with a separator. Otherwise a single document is written to the given file path verbatim.
bool asDirectory = documents.Count > 1 || Directory.Exists(fullOutput) || EndsWithSeparator(outputPath!);
if (asDirectory is false) {
if (!asDirectory) {
_sink.WriteFile(fullOutput, documents[0].Content);
logger.Info($"Documentation written to '{fullOutput}'.");

Expand Down Expand Up @@ -86,7 +86,7 @@ internal static string ResolveWithinOutput(string outputDirectory, string relati
? outputDirectory
: outputDirectory + Path.DirectorySeparatorChar;

if (target.StartsWith(root, StringComparison.Ordinal) is false) {
if (!target.StartsWith(root, StringComparison.Ordinal)) {
throw new InvalidOperationException(
$"The renderer produced a document whose path '{relativePath}' escapes the output directory '{outputDirectory}'.");
}
Expand Down
6 changes: 3 additions & 3 deletions FirstClassErrors.Cli/GenerateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ internal int Run(GenerateSettings settings, CancellationToken cancellationToken)
return 1;
}

if (resolved.HasSolution is false && resolved.HasAssemblies is false) {
if (!resolved.HasSolution && !resolved.HasAssemblies) {
logger.Error("No source: pass --solution/--assemblies, or set 'solution'/'assemblies' in the configuration.");

return 1;
Expand All @@ -84,7 +84,7 @@ internal int Run(GenerateSettings settings, CancellationToken cancellationToken)
IReadOnlyList<IErrorDocumentationRenderer> customRenderers = RendererLoader.Load(configuration.Renderers, configDir, logger);
IErrorDocumentationRenderer renderer = RendererCatalog.Create(resolved.Format, customRenderers);

if (renderer.SupportedLayouts.Contains(resolved.Layout, StringComparer.OrdinalIgnoreCase) is false) {
if (!renderer.SupportedLayouts.Contains(resolved.Layout, StringComparer.OrdinalIgnoreCase)) {
logger.Error($"The '{resolved.Format}' format does not support the '{resolved.Layout}' layout. Supported layouts: {string.Join(", ", renderer.SupportedLayouts)}.");

return 1;
Expand All @@ -100,7 +100,7 @@ internal int Run(GenerateSettings settings, CancellationToken cancellationToken)
}

SolutionGenerationOptions options = new() {
BuildSolution = resolved.NoBuild is false,
BuildSolution = !resolved.NoBuild,
Configuration = resolved.BuildConfiguration,
TargetFramework = resolved.Framework,
FailureBehavior = resolved.Strict ? FailureBehavior.Stop : FailureBehavior.Continue,
Expand Down
8 changes: 4 additions & 4 deletions FirstClassErrors.Cli/GenerateOptionsResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public static ResolvedGenerateOptions Resolve(GenerateSettings settings, CliConf
// combine with a configured 'solution').
string? solution;
string[] assemblies;
if (string.IsNullOrWhiteSpace(settings.SolutionPath) is false || settings.AssemblyPaths.Length > 0) {
if (!string.IsNullOrWhiteSpace(settings.SolutionPath) || settings.AssemblyPaths.Length > 0) {
solution = settings.SolutionPath;
assemblies = settings.AssemblyPaths;
} else {
Expand All @@ -47,7 +47,7 @@ public static ResolvedGenerateOptions Resolve(GenerateSettings settings, CliConf
return new ResolvedGenerateOptions(
Solution: solution,
Assemblies: assemblies,
HasSolution: string.IsNullOrWhiteSpace(solution) is false,
HasSolution: !string.IsNullOrWhiteSpace(solution),
HasAssemblies: assemblies.Length > 0,
Format: NormalizeFormat(FirstNonEmpty(settings.Format, configuration.Format) ?? "json"),
Layout: (FirstNonEmpty(settings.Layout, configuration.Layout) ?? "single").Trim().ToLowerInvariant(),
Expand All @@ -62,8 +62,8 @@ public static ResolvedGenerateOptions Resolve(GenerateSettings settings, CliConf
}

internal static string? FirstNonEmpty(string? primary, string? fallback) {
if (string.IsNullOrWhiteSpace(primary) is false) { return primary; }
if (string.IsNullOrWhiteSpace(fallback) is false) { return fallback; }
if (!string.IsNullOrWhiteSpace(primary)) { return primary; }
if (!string.IsNullOrWhiteSpace(fallback)) { return fallback; }

return null;
}
Expand Down
2 changes: 1 addition & 1 deletion FirstClassErrors.Cli/InitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ internal sealed class InitCommand : Command<InitSettings> {
protected override int Execute(CommandContext context, InitSettings settings, CancellationToken cancellationToken) {
string path = ConfigurationStore.Resolve(settings.ConfigPath);

if (ConfigurationStore.Exists(path) && settings.Force is false) {
if (ConfigurationStore.Exists(path) && !settings.Force) {
Console.Error.WriteLine($"error: a configuration already exists at '{path}'. Use --force to overwrite.");

return 1;
Expand Down
2 changes: 1 addition & 1 deletion FirstClassErrors.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@

// Spectre handles argument parsing, validation errors and --help. Runtime failures are handled inside each command
// so the tool reports them as a terse "error: …" line rather than a stack trace.
return app.Run(args);
return await app.RunAsync(args);
Loading