diff --git a/requirements.yaml b/requirements.yaml
index 2536894..06e361a 100644
--- a/requirements.yaml
+++ b/requirements.yaml
@@ -244,6 +244,28 @@ sections:
- Program_Run_WithElaborateFlag_UnknownId_ReportsError
- ReviewMark_Elaborate
+ - id: ReviewMark-Cmd-Lint
+ title: The tool shall support --lint flag to validate the definition file and report issues.
+ justification: |
+ Users need a way to verify that the .reviewmark.yaml configuration file is valid
+ before running the main tool, providing clear error messages about the cause and
+ location of any issues.
+ tests:
+ - Context_Create_LintFlag_SetsLintTrue
+ - Context_Create_NoArguments_LintIsFalse
+ - Program_Run_WithHelpFlag_IncludesLintOption
+ - Program_Run_WithLintFlag_ValidConfig_ReportsSuccess
+ - Program_Run_WithLintFlag_MissingConfig_ReportsError
+ - Program_Run_WithLintFlag_DuplicateIds_ReportsError
+ - Program_Run_WithLintFlag_UnknownSourceType_ReportsError
+ - Program_Run_WithLintFlag_CorruptedYaml_ReportsError
+ - Program_Run_WithLintFlag_MissingEvidenceSource_ReportsError
+ - Program_Run_WithLintFlag_MultipleErrors_ReportsAll
+ - ReviewMarkConfiguration_Load_InvalidYaml_ErrorIncludesFilenameAndLine
+ - ReviewMarkConfiguration_Load_MissingEvidenceSource_ErrorIncludesFilename
+ - ReviewMarkConfiguration_Lint_MultipleErrors_ReturnsAll
+ - ReviewMark_Lint
+
- title: Configuration Reading
requirements:
- id: ReviewMark-Config-Reading
@@ -321,6 +343,7 @@ sections:
- "windows@ReviewMark_Enforce"
- "windows@ReviewMark_WorkingDirectoryOverride"
- "windows@ReviewMark_Elaborate"
+ - "windows@ReviewMark_Lint"
- id: ReviewMark-Platform-Linux
title: The tool shall build and run on Linux platforms.
@@ -336,6 +359,7 @@ sections:
- "ubuntu@ReviewMark_Enforce"
- "ubuntu@ReviewMark_WorkingDirectoryOverride"
- "ubuntu@ReviewMark_Elaborate"
+ - "ubuntu@ReviewMark_Lint"
- id: ReviewMark-Platform-MacOS
title: The tool shall build and run on macOS platforms.
@@ -351,6 +375,7 @@ sections:
- "macos@ReviewMark_Enforce"
- "macos@ReviewMark_WorkingDirectoryOverride"
- "macos@ReviewMark_Elaborate"
+ - "macos@ReviewMark_Lint"
- id: ReviewMark-Platform-Net8
title: The tool shall support .NET 8 runtime.
@@ -365,6 +390,7 @@ sections:
- "dotnet8.x@ReviewMark_Enforce"
- "dotnet8.x@ReviewMark_WorkingDirectoryOverride"
- "dotnet8.x@ReviewMark_Elaborate"
+ - "dotnet8.x@ReviewMark_Lint"
- id: ReviewMark-Platform-Net9
title: The tool shall support .NET 9 runtime.
@@ -379,6 +405,7 @@ sections:
- "dotnet9.x@ReviewMark_Enforce"
- "dotnet9.x@ReviewMark_WorkingDirectoryOverride"
- "dotnet9.x@ReviewMark_Elaborate"
+ - "dotnet9.x@ReviewMark_Lint"
- id: ReviewMark-Platform-Net10
title: The tool shall support .NET 10 runtime.
@@ -393,6 +420,7 @@ sections:
- "dotnet10.x@ReviewMark_Enforce"
- "dotnet10.x@ReviewMark_WorkingDirectoryOverride"
- "dotnet10.x@ReviewMark_Elaborate"
+ - "dotnet10.x@ReviewMark_Lint"
- title: OTS Software
requirements:
diff --git a/src/DemaConsulting.ReviewMark/Context.cs b/src/DemaConsulting.ReviewMark/Context.cs
index bf7fa11..e2315e7 100644
--- a/src/DemaConsulting.ReviewMark/Context.cs
+++ b/src/DemaConsulting.ReviewMark/Context.cs
@@ -60,6 +60,11 @@ internal sealed class Context : IDisposable
///
public bool Validate { get; private init; }
+ ///
+ /// Gets a value indicating whether the lint flag was specified.
+ ///
+ public bool Lint { get; private init; }
+
///
/// Gets the validation results file path.
///
@@ -159,6 +164,7 @@ public static Context Create(string[] args)
Help = parser.Help,
Silent = parser.Silent,
Validate = parser.Validate,
+ Lint = parser.Lint,
ResultsFile = parser.ResultsFile,
DefinitionFile = parser.DefinitionFile,
PlanFile = parser.PlanFile,
@@ -226,6 +232,11 @@ private sealed class ArgumentParser
///
public bool Validate { get; private set; }
+ ///
+ /// Gets a value indicating whether the lint flag was specified.
+ ///
+ public bool Lint { get; private set; }
+
///
/// Gets the log file path.
///
@@ -328,6 +339,10 @@ private int ParseArgument(string arg, string[] args, int index)
Validate = true;
return index;
+ case "--lint":
+ Lint = true;
+ return index;
+
case "--log":
LogFile = GetRequiredStringArgument(arg, args, index, FilenameArgument);
return index + 1;
diff --git a/src/DemaConsulting.ReviewMark/Program.cs b/src/DemaConsulting.ReviewMark/Program.cs
index a87a942..5276a11 100644
--- a/src/DemaConsulting.ReviewMark/Program.cs
+++ b/src/DemaConsulting.ReviewMark/Program.cs
@@ -112,7 +112,14 @@ public static void Run(Context context)
return;
}
- // Priority 4: Main tool functionality
+ // Priority 4: Lint
+ if (context.Lint)
+ {
+ RunLintLogic(context);
+ return;
+ }
+
+ // Priority 5: Main tool functionality
RunToolLogic(context);
}
@@ -140,6 +147,7 @@ private static void PrintHelp(Context context)
context.WriteLine(" -?, -h, --help Display this help message");
context.WriteLine(" --silent Suppress console output");
context.WriteLine(" --validate Run self-validation");
+ context.WriteLine(" --lint Lint the definition file and report issues");
context.WriteLine(" --results Write validation results to file (.trx or .xml)");
context.WriteLine(" --log Write output to log file");
context.WriteLine(" --definition Specify the definition YAML file (default: .reviewmark.yaml)");
@@ -154,6 +162,32 @@ private static void PrintHelp(Context context)
context.WriteLine(" --elaborate Print a Markdown elaboration of the specified review set");
}
+ ///
+ /// Runs the lint logic to validate the definition file.
+ ///
+ /// The context containing command line arguments and program state.
+ private static void RunLintLogic(Context context)
+ {
+ // Determine the definition file path (explicit or default)
+ var directory = context.WorkingDirectory ?? Directory.GetCurrentDirectory();
+ var definitionFile = context.DefinitionFile ?? PathHelpers.SafePathCombine(directory, ".reviewmark.yaml");
+
+ context.WriteLine($"Linting '{definitionFile}'...");
+
+ // Lint the file, collecting all detectable errors in one pass.
+ var errors = ReviewMarkConfiguration.Lint(definitionFile);
+ foreach (var error in errors)
+ {
+ context.WriteError($"Error: {error}");
+ }
+
+ // Report overall result
+ if (errors.Count == 0)
+ {
+ context.WriteLine($"'{definitionFile}' is valid.");
+ }
+ }
+
///
/// Runs the main tool logic.
///
diff --git a/src/DemaConsulting.ReviewMark/ReviewMarkConfiguration.cs b/src/DemaConsulting.ReviewMark/ReviewMarkConfiguration.cs
index 6800b5a..b47e2ec 100644
--- a/src/DemaConsulting.ReviewMark/ReviewMarkConfiguration.cs
+++ b/src/DemaConsulting.ReviewMark/ReviewMarkConfiguration.cs
@@ -123,6 +123,136 @@ file sealed class ReviewSetYaml
public List? Paths { get; set; }
}
+// ---------------------------------------------------------------------------
+// File-local helpers — use file-local YAML types
+// ---------------------------------------------------------------------------
+
+///
+/// File-local static helper that encapsulates YAML deserialization and model validation
+/// on behalf of . Because both this class and
+/// are file-local, C# allows them to appear in the
+/// method signatures here.
+///
+file static class ReviewMarkConfigurationHelpers
+{
+ ///
+ /// Deserializes a YAML string into the raw model.
+ ///
+ /// YAML content to parse.
+ ///
+ /// Optional file path used to produce actionable error messages. When null,
+ /// YAML errors are thrown as (preserving the
+ /// contract). When non-null,
+ /// they are thrown as and include the
+ /// file name, line, and column.
+ ///
+ /// The deserialized .
+ ///
+ /// Thrown when is null and the YAML is invalid.
+ ///
+ ///
+ /// Thrown when is set and the YAML is invalid.
+ ///
+ public static ReviewMarkYaml DeserializeRaw(string yaml, string? filePath)
+ {
+ var deserializer = new DeserializerBuilder()
+ .WithNamingConvention(NullNamingConvention.Instance)
+ .IgnoreUnmatchedProperties()
+ .Build();
+
+ try
+ {
+ if (filePath != null)
+ {
+ return deserializer.Deserialize(yaml)
+ ?? throw new InvalidOperationException(
+ $"Configuration file '{filePath}' is empty or null.");
+ }
+
+ return deserializer.Deserialize(yaml)
+ ?? throw new ArgumentException("YAML content is empty or invalid.", nameof(yaml));
+ }
+ catch (YamlException ex)
+ {
+ if (filePath != null)
+ {
+ throw new InvalidOperationException(
+ $"Failed to parse '{filePath}' at line {ex.Start.Line}, column {ex.Start.Column}: {ex.Message}",
+ ex);
+ }
+
+ throw new ArgumentException($"Invalid YAML content: {ex.Message}", nameof(yaml), ex);
+ }
+ }
+
+ ///
+ /// Validates a raw model and builds a
+ /// from it.
+ ///
+ /// The deserialized raw model to validate.
+ /// A validated .
+ ///
+ /// Thrown when required fields are absent or malformed.
+ ///
+ public static ReviewMarkConfiguration BuildConfiguration(ReviewMarkYaml raw)
+ {
+ // Map needs-review patterns (default to empty list if absent)
+ var needsReviewPatterns = (IReadOnlyList)(raw.NeedsReview ?? []);
+
+ // Map evidence-source (required: evidence-source block, type, and location)
+ if (raw.EvidenceSource is not { } es)
+ {
+ throw new ArgumentException("Configuration is missing required 'evidence-source' block.");
+ }
+
+ if (string.IsNullOrWhiteSpace(es.Type))
+ {
+ throw new ArgumentException("Configuration 'evidence-source' is missing a required 'type' field.");
+ }
+
+ if (string.IsNullOrWhiteSpace(es.Location))
+ {
+ throw new ArgumentException("Configuration 'evidence-source' is missing a required 'location' field.");
+ }
+
+ var evidenceSource = new EvidenceSource(
+ Type: es.Type,
+ Location: es.Location,
+ UsernameEnv: es.Credentials?.UsernameEnv,
+ PasswordEnv: es.Credentials?.PasswordEnv);
+
+ // Map review sets, requiring id, title, and paths for each entry
+ var reviews = (raw.Reviews ?? [])
+ .Select((r, i) =>
+ {
+ // Each review set must have an id
+ if (string.IsNullOrWhiteSpace(r.Id))
+ {
+ throw new ArgumentException($"Review set at index {i} is missing a required 'id' field.");
+ }
+
+ // Each review set must have a title
+ if (string.IsNullOrWhiteSpace(r.Title))
+ {
+ throw new ArgumentException($"Review set '{r.Id}' is missing a required 'title' field.");
+ }
+
+ // Each review set must have at least one non-empty path pattern
+ var paths = r.Paths;
+ if (paths is null || !paths.Any(p => !string.IsNullOrWhiteSpace(p)))
+ {
+ throw new ArgumentException(
+ $"Review set '{r.Id}' at index {i} is missing required 'paths' entries.");
+ }
+
+ return new ReviewSet(r.Id, r.Title, paths);
+ })
+ .ToList();
+
+ return new ReviewMarkConfiguration(needsReviewPatterns, evidenceSource, reviews);
+ }
+}
+
// ---------------------------------------------------------------------------
// Public API — internal to the assembly
// ---------------------------------------------------------------------------
@@ -281,7 +411,7 @@ internal sealed class ReviewMarkConfiguration
/// Glob patterns for files requiring review.
/// Evidence-source configuration.
/// Review set definitions.
- private ReviewMarkConfiguration(
+ internal ReviewMarkConfiguration(
IReadOnlyList needsReviewPatterns,
EvidenceSource evidenceSource,
IReadOnlyList reviews)
@@ -297,7 +427,11 @@ private ReviewMarkConfiguration(
/// Absolute or relative path to the configuration file.
/// A populated instance.
/// Thrown when is null or empty.
- /// Thrown when the file cannot be read.
+ ///
+ /// Thrown when the file cannot be read, the YAML is invalid, or required configuration fields are
+ /// missing. The exception message always identifies the problematic file and, for YAML syntax
+ /// errors, the line and column number.
+ ///
internal static ReviewMarkConfiguration Load(string filePath)
{
// Validate the file path argument
@@ -321,8 +455,23 @@ internal static ReviewMarkConfiguration Load(string filePath)
throw new InvalidOperationException($"Failed to read configuration file '{filePath}': {ex.Message}", ex);
}
- // Delegate to Parse for deserialization and apply path resolution
- var config = Parse(yaml);
+ // Deserialize the raw YAML model, embedding the file path and line number in any parse error.
+ var raw = ReviewMarkConfigurationHelpers.DeserializeRaw(yaml, filePath);
+
+ // Determine the base directory for resolving relative fileshare locations.
+ var baseDirectory = Path.GetDirectoryName(Path.GetFullPath(filePath))
+ ?? throw new InvalidOperationException($"Cannot determine base directory for configuration file '{filePath}'.");
+
+ // Validate the raw model, embedding the file path in any semantic error.
+ ReviewMarkConfiguration config;
+ try
+ {
+ config = ReviewMarkConfigurationHelpers.BuildConfiguration(raw);
+ }
+ catch (ArgumentException ex)
+ {
+ throw new InvalidOperationException($"Invalid configuration in '{filePath}': {ex.Message}", ex);
+ }
// Resolve relative fileshare locations against the config file's directory so that
// a relative location (e.g., "index.json") works correctly regardless of the process
@@ -330,8 +479,6 @@ internal static ReviewMarkConfiguration Load(string filePath)
if (string.Equals(config.EvidenceSource.Type, "fileshare", StringComparison.OrdinalIgnoreCase) &&
!Path.IsPathRooted(config.EvidenceSource.Location))
{
- var baseDirectory = Path.GetDirectoryName(Path.GetFullPath(filePath))
- ?? throw new InvalidOperationException($"Cannot determine base directory for configuration file '{filePath}'.");
var absoluteLocation = Path.GetFullPath(config.EvidenceSource.Location, baseDirectory);
return new ReviewMarkConfiguration(
config.NeedsReviewPatterns,
@@ -343,88 +490,137 @@ internal static ReviewMarkConfiguration Load(string filePath)
}
///
- /// Parses a YAML string into a .
+ /// Lints a .reviewmark.yaml file and returns all detected issues.
+ /// Unlike , this method does not stop at the first error;
+ /// it accumulates every detectable problem and returns them all so the caller
+ /// can report a complete list in a single pass.
///
- /// The YAML content to parse.
- /// A populated instance.
- /// Thrown when is null.
- /// Thrown when the YAML is invalid or missing required fields.
- internal static ReviewMarkConfiguration Parse(string yaml)
+ /// Absolute or relative path to the configuration file.
+ ///
+ /// A read-only list of error messages. The list is empty when the file is
+ /// structurally and semantically valid.
+ ///
+ /// Thrown when is null or empty.
+ internal static IReadOnlyList Lint(string filePath)
{
- // Validate the yaml input
- ArgumentNullException.ThrowIfNull(yaml);
+ // Validate the file path argument
+ if (string.IsNullOrWhiteSpace(filePath))
+ {
+ throw new ArgumentException("File path must not be null or empty.", nameof(filePath));
+ }
- // Build a YamlDotNet deserializer that ignores unmatched fields
- var deserializer = new DeserializerBuilder()
- .WithNamingConvention(NullNamingConvention.Instance)
- .IgnoreUnmatchedProperties()
- .Build();
+ var errors = new List();
- // Deserialize the raw YAML into the internal model
- ReviewMarkYaml raw;
+ // Try to read the file; if this fails we cannot continue.
+ string yaml;
try
{
- raw = deserializer.Deserialize(yaml)
- ?? throw new ArgumentException("YAML content is empty or invalid.", nameof(yaml));
+ yaml = File.ReadAllText(filePath);
}
- catch (YamlException ex)
+ catch (Exception ex) when (ex is not InvalidOperationException)
{
- throw new ArgumentException($"Invalid YAML content: {ex.Message}", nameof(yaml), ex);
+ errors.Add($"Failed to read configuration file '{filePath}': {ex.Message}");
+ return errors;
}
- // Map needs-review patterns (default to empty list if absent)
- var needsReviewPatterns = (IReadOnlyList)(raw.NeedsReview ?? []);
-
- // Map evidence-source (required: evidence-source block, type, and location)
- if (raw.EvidenceSource is not { } es)
+ // Try to parse the raw YAML model; if this fails we cannot do semantic checks.
+ ReviewMarkYaml raw;
+ try
{
- throw new ArgumentException("Configuration is missing required 'evidence-source' block.", nameof(yaml));
+ raw = ReviewMarkConfigurationHelpers.DeserializeRaw(yaml, filePath);
}
-
- if (string.IsNullOrWhiteSpace(es.Type))
+ catch (InvalidOperationException ex)
{
- throw new ArgumentException("Configuration 'evidence-source' is missing a required 'type' field.", nameof(yaml));
+ errors.Add(ex.Message);
+ return errors;
}
- if (string.IsNullOrWhiteSpace(es.Location))
+ // Validate the evidence-source block, collecting all field-level errors.
+ var es = raw.EvidenceSource;
+ if (es == null)
{
- throw new ArgumentException("Configuration 'evidence-source' is missing a required 'location' field.", nameof(yaml));
+ errors.Add(
+ $"Invalid configuration in '{filePath}': Configuration is missing required 'evidence-source' block.");
}
+ else
+ {
+ if (string.IsNullOrWhiteSpace(es.Type))
+ {
+ errors.Add(
+ $"Invalid configuration in '{filePath}': 'evidence-source' is missing a required 'type' field.");
+ }
+ else if (!string.Equals(es.Type, "url", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(es.Type, "fileshare", StringComparison.OrdinalIgnoreCase))
+ {
+ errors.Add(
+ $"evidence-source type '{es.Type}' is not supported (must be 'url' or 'fileshare').");
+ }
- var evidenceSource = new EvidenceSource(
- Type: es.Type,
- Location: es.Location,
- UsernameEnv: es.Credentials?.UsernameEnv,
- PasswordEnv: es.Credentials?.PasswordEnv);
- // Map review sets, requiring id, title, and paths for each entry
- var reviews = (raw.Reviews ?? [])
- .Select((r, i) =>
+ if (string.IsNullOrWhiteSpace(es.Location))
{
- // Each review set must have an id
- if (string.IsNullOrWhiteSpace(r.Id))
- {
- throw new ArgumentException($"Review set at index {i} is missing a required 'id' field.");
- }
+ errors.Add(
+ $"Invalid configuration in '{filePath}': 'evidence-source' is missing a required 'location' field.");
+ }
+ }
- // Each review set must have a title
- if (string.IsNullOrWhiteSpace(r.Title))
- {
- throw new ArgumentException($"Review set '{r.Id}' is missing a required 'title' field.");
- }
+ // Validate each review set, accumulating all structural and uniqueness errors.
+ // Review IDs are treated as case-sensitive identifiers (Ordinal), which is intentional:
+ // "Core-Logic" and "core-logic" are distinct IDs. Evidence-source type uses OrdinalIgnoreCase
+ // because YAML convention allows any casing for keyword values like "url" or "fileshare".
+ var seenIds = new Dictionary(StringComparer.Ordinal);
+ var reviews = raw.Reviews ?? [];
+ for (var i = 0; i < reviews.Count; i++)
+ {
+ var r = reviews[i];
- // Each review set must have at least one non-empty path pattern
- var paths = r.Paths;
- if (paths is null || !paths.Any(p => !string.IsNullOrWhiteSpace(p)))
- {
- throw new ArgumentException(
- $"Review set '{r.Id}' at index {i} is missing required 'paths' entries.");
- }
+ if (string.IsNullOrWhiteSpace(r.Id))
+ {
+ errors.Add(
+ $"Invalid configuration in '{filePath}': Review set at index {i} is missing a required 'id' field.");
+ }
+ else if (seenIds.TryGetValue(r.Id, out var firstIndex))
+ {
+ errors.Add(
+ $"reviews[{i}] has duplicate ID '{r.Id}' (first defined at reviews[{firstIndex}]).");
+ }
+ else
+ {
+ seenIds[r.Id] = i;
+ }
- return new ReviewSet(r.Id, r.Title, paths);
- })
- .ToList();
+ if (string.IsNullOrWhiteSpace(r.Title))
+ {
+ errors.Add(
+ $"Invalid configuration in '{filePath}': Review set at index {i} is missing a required 'title' field.");
+ }
- return new ReviewMarkConfiguration(needsReviewPatterns, evidenceSource, reviews);
+ if (r.Paths == null || !r.Paths.Any(p => !string.IsNullOrWhiteSpace(p)))
+ {
+ errors.Add(
+ $"Invalid configuration in '{filePath}': Review set at index {i} is missing required 'paths' entries.");
+ }
+ }
+
+ return errors;
+ }
+
+ ///
+ /// Parses a YAML string into a .
+ ///
+ /// The YAML content to parse.
+ /// A populated instance.
+ /// Thrown when is null.
+ /// Thrown when the YAML is invalid or missing required fields.
+ internal static ReviewMarkConfiguration Parse(string yaml)
+ {
+ // Validate the yaml input
+ ArgumentNullException.ThrowIfNull(yaml);
+
+ // Deserialize without a file path so YAML errors are wrapped as ArgumentException (not
+ // InvalidOperationException) which is what callers of Parse (unit tests) expect.
+ var raw = ReviewMarkConfigurationHelpers.DeserializeRaw(yaml, filePath: null);
+
+ return ReviewMarkConfigurationHelpers.BuildConfiguration(raw);
}
///
diff --git a/src/DemaConsulting.ReviewMark/Validation.cs b/src/DemaConsulting.ReviewMark/Validation.cs
index ce93b16..41a2950 100644
--- a/src/DemaConsulting.ReviewMark/Validation.cs
+++ b/src/DemaConsulting.ReviewMark/Validation.cs
@@ -56,6 +56,7 @@ public static void Run(Context context)
RunDirTest(context, testResults);
RunEnforceTest(context, testResults);
RunElaborateTest(context, testResults);
+ RunLintTest(context, testResults);
// Calculate totals
var totalTests = testResults.Results.Count;
@@ -378,6 +379,38 @@ private static void RunElaborateTest(Context context, DemaConsulting.TestResults
});
}
+ ///
+ /// Runs a test for lint functionality.
+ ///
+ /// The context for output.
+ /// The test results collection.
+ private static void RunLintTest(Context context, DemaConsulting.TestResults.TestResults testResults)
+ {
+ RunValidationTest(context, testResults, "ReviewMark_Lint", () =>
+ {
+ using var tempDir = new TemporaryDirectory();
+ var (definitionFile, _) = CreateTestDefinitionFixtures(tempDir.DirectoryPath);
+ var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "lint-test.log");
+
+ // Run the program to lint the definition file
+ int exitCode;
+ using (var testContext = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]))
+ {
+ Program.Run(testContext);
+ exitCode = testContext.ExitCode;
+ }
+
+ if (exitCode != 0)
+ {
+ return $"Program exited with code {exitCode}";
+ }
+
+ // Verify the log contains a success message
+ var logContent = File.ReadAllText(logFile);
+ return logContent.Contains("is valid") ? null : "Lint output does not contain 'is valid'";
+ });
+ }
+
///
/// Runs a single validation test, recording the outcome in the test results collection.
///
diff --git a/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs b/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs
index 9df417d..e360756 100644
--- a/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs
+++ b/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs
@@ -712,5 +712,34 @@ public void Context_Create_ElaborateFlag_WithoutValue_ThrowsArgumentException()
// Act & Assert - --elaborate without an ID argument should throw
Assert.Throws(() => Context.Create(["--elaborate"]));
}
+
+ ///
+ /// Test that --lint flag sets Lint to true.
+ ///
+ [TestMethod]
+ public void Context_Create_LintFlag_SetsLintTrue()
+ {
+ // Act
+ using var context = Context.Create(["--lint"]);
+
+ // Assert — Lint is true, other flags remain false, and exit code is zero
+ Assert.IsTrue(context.Lint);
+ Assert.IsFalse(context.Version);
+ Assert.IsFalse(context.Help);
+ Assert.AreEqual(0, context.ExitCode);
+ }
+
+ ///
+ /// Test that Lint is false when --lint is not specified.
+ ///
+ [TestMethod]
+ public void Context_Create_NoArguments_LintIsFalse()
+ {
+ // Act
+ using var context = Context.Create([]);
+
+ // Assert — Lint is false when --lint is not specified
+ Assert.IsFalse(context.Lint);
+ }
}
diff --git a/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs b/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs
index 18ab989..692e80d 100644
--- a/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs
+++ b/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs
@@ -26,6 +26,10 @@ namespace DemaConsulting.ReviewMark.Tests;
[TestClass]
public class ProgramTests
{
+ ///
+ /// Log file name used across lint tests.
+ ///
+ private const string LintLogFile = "lint.log";
///
/// Test that Run with version flag displays version only.
///
@@ -187,63 +191,51 @@ public void Program_Run_WithHelpFlag_IncludesElaborateOption()
public void Program_Run_WithElaborateFlag_OutputsElaboration()
{
// Arrange — create temp directory with a definition file and source file
- var testDirectory = PathHelpers.SafePathCombine(
- Path.GetTempPath(), $"ProgramTests_Elaborate_{Guid.NewGuid()}");
+ using var tempDir = new TestDirectory();
+ var srcDir = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "src");
+ Directory.CreateDirectory(srcDir);
+ File.WriteAllText(PathHelpers.SafePathCombine(srcDir, "A.cs"), "class A {}");
+
+ var indexFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "index.json");
+ File.WriteAllText(indexFile, """{"reviews":[]}""");
+
+ var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml");
+ File.WriteAllText(definitionFile, $"""
+ needs-review:
+ - "src/**/*.cs"
+ evidence-source:
+ type: fileshare
+ location: {indexFile}
+ reviews:
+ - id: Core-Logic
+ title: Review of core business logic
+ paths:
+ - "src/**/*.cs"
+ """);
+
+ var originalOut = Console.Out;
try
{
- Directory.CreateDirectory(testDirectory);
- var srcDir = PathHelpers.SafePathCombine(testDirectory, "src");
- Directory.CreateDirectory(srcDir);
- File.WriteAllText(PathHelpers.SafePathCombine(srcDir, "A.cs"), "class A {}");
+ using var outWriter = new StringWriter();
+ Console.SetOut(outWriter);
+ using var context = Context.Create([
+ "--definition", definitionFile,
+ "--dir", tempDir.DirectoryPath,
+ "--elaborate", "Core-Logic"]);
- var indexFile = PathHelpers.SafePathCombine(testDirectory, "index.json");
- File.WriteAllText(indexFile, """{"reviews":[]}""");
+ // Act
+ Program.Run(context);
- var definitionFile = PathHelpers.SafePathCombine(testDirectory, "definition.yaml");
- File.WriteAllText(definitionFile, $"""
- needs-review:
- - "src/**/*.cs"
- evidence-source:
- type: fileshare
- location: {indexFile}
- reviews:
- - id: Core-Logic
- title: Review of core business logic
- paths:
- - "src/**/*.cs"
- """);
-
- var originalOut = Console.Out;
- try
- {
- using var outWriter = new StringWriter();
- Console.SetOut(outWriter);
- using var context = Context.Create([
- "--definition", definitionFile,
- "--dir", testDirectory,
- "--elaborate", "Core-Logic"]);
-
- // Act
- Program.Run(context);
-
- // Assert — output contains the review set ID and fingerprint heading
- var output = outWriter.ToString();
- Assert.Contains("Core-Logic", output);
- Assert.Contains("Fingerprint", output);
- Assert.Contains("Files", output);
- Assert.AreEqual(0, context.ExitCode);
- }
- finally
- {
- Console.SetOut(originalOut);
- }
+ // Assert — output contains the review set ID and fingerprint heading
+ var output = outWriter.ToString();
+ Assert.Contains("Core-Logic", output);
+ Assert.Contains("Fingerprint", output);
+ Assert.Contains("Files", output);
+ Assert.AreEqual(0, context.ExitCode);
}
finally
{
- if (Directory.Exists(testDirectory))
- {
- Directory.Delete(testDirectory, recursive: true);
- }
+ Console.SetOut(originalOut);
}
}
@@ -254,56 +246,308 @@ public void Program_Run_WithElaborateFlag_OutputsElaboration()
public void Program_Run_WithElaborateFlag_UnknownId_ReportsError()
{
// Arrange — create temp directory with a definition file
- var testDirectory = PathHelpers.SafePathCombine(
- Path.GetTempPath(), $"ProgramTests_ElaborateUnknown_{Guid.NewGuid()}");
+ using var tempDir = new TestDirectory();
+
+ var indexFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "index.json");
+ File.WriteAllText(indexFile, """{"reviews":[]}""");
+
+ var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml");
+ File.WriteAllText(definitionFile, $"""
+ needs-review:
+ - "src/**/*.cs"
+ evidence-source:
+ type: fileshare
+ location: {indexFile}
+ reviews:
+ - id: Core-Logic
+ title: Review of core business logic
+ paths:
+ - "src/**/*.cs"
+ """);
+
+ var originalError = Console.Error;
try
{
- Directory.CreateDirectory(testDirectory);
+ using var errWriter = new StringWriter();
+ Console.SetError(errWriter);
+ using var context = Context.Create([
+ "--silent",
+ "--definition", definitionFile,
+ "--elaborate", "Unknown-Id"]);
- var indexFile = PathHelpers.SafePathCombine(testDirectory, "index.json");
- File.WriteAllText(indexFile, """{"reviews":[]}""");
+ // Act
+ Program.Run(context);
- var definitionFile = PathHelpers.SafePathCombine(testDirectory, "definition.yaml");
- File.WriteAllText(definitionFile, $"""
- needs-review:
- - "src/**/*.cs"
- evidence-source:
- type: fileshare
- location: {indexFile}
- reviews:
- - id: Core-Logic
- title: Review of core business logic
- paths:
- - "src/**/*.cs"
- """);
-
- var originalError = Console.Error;
- try
- {
- using var errWriter = new StringWriter();
- Console.SetError(errWriter);
- using var context = Context.Create([
- "--silent",
- "--definition", definitionFile,
- "--elaborate", "Unknown-Id"]);
-
- // Act
- Program.Run(context);
-
- // Assert — non-zero exit code when the review-set ID is not found
- Assert.AreEqual(1, context.ExitCode);
- }
- finally
- {
- Console.SetError(originalError);
- }
+ // Assert — non-zero exit code when the review-set ID is not found
+ Assert.AreEqual(1, context.ExitCode);
}
finally
{
- if (Directory.Exists(testDirectory))
- {
- Directory.Delete(testDirectory, recursive: true);
- }
+ Console.SetError(originalError);
}
}
+
+ ///
+ /// Test that Run with --help flag includes --lint in the usage information.
+ ///
+ [TestMethod]
+ public void Program_Run_WithHelpFlag_IncludesLintOption()
+ {
+ // Arrange
+ var originalOut = Console.Out;
+ try
+ {
+ using var outWriter = new StringWriter();
+ Console.SetOut(outWriter);
+ using var context = Context.Create(["--help"]);
+
+ // Act
+ Program.Run(context);
+
+ // Assert — help text includes the --lint option
+ var output = outWriter.ToString();
+ Assert.Contains("--lint", output);
+ }
+ finally
+ {
+ Console.SetOut(originalOut);
+ }
+ }
+
+ ///
+ /// Test that Run with --lint flag on a valid definition file reports success.
+ ///
+ [TestMethod]
+ public void Program_Run_WithLintFlag_ValidConfig_ReportsSuccess()
+ {
+ // Arrange — create temp directory with a valid definition file
+ using var tempDir = new TestDirectory();
+ var indexFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "index.json");
+ File.WriteAllText(indexFile, """{"reviews":[]}""");
+
+ var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml");
+ File.WriteAllText(definitionFile, $"""
+ needs-review:
+ - "src/**/*.cs"
+ evidence-source:
+ type: fileshare
+ location: {indexFile}
+ reviews:
+ - id: Core-Logic
+ title: Review of core business logic
+ paths:
+ - "src/**/*.cs"
+ """);
+
+ var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile);
+ using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]);
+
+ // Act
+ Program.Run(context);
+
+ // Assert — exit code is zero and log contains success message
+ var logContent = File.ReadAllText(logFile);
+ Assert.AreEqual(0, context.ExitCode);
+ Assert.Contains("is valid", logContent);
+ }
+
+ ///
+ /// Test that Run with --lint flag on a missing definition file reports an error.
+ ///
+ [TestMethod]
+ public void Program_Run_WithLintFlag_MissingConfig_ReportsError()
+ {
+ // Arrange — use a non-existent definition file
+ using var tempDir = new TestDirectory();
+ var nonExistentFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "nonexistent.yaml");
+ var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile);
+ using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", nonExistentFile]);
+
+ // Act
+ Program.Run(context);
+
+ // Assert — non-zero exit code and log contains an error mentioning the missing file
+ var logContent = File.ReadAllText(logFile);
+ Assert.AreEqual(1, context.ExitCode);
+ Assert.Contains("Error:", logContent);
+ Assert.Contains("nonexistent.yaml", logContent);
+ }
+
+ ///
+ /// Test that Run with --lint flag detects duplicate review set IDs and reports an error.
+ ///
+ [TestMethod]
+ public void Program_Run_WithLintFlag_DuplicateIds_ReportsError()
+ {
+ // Arrange — create temp directory with a definition file containing duplicate IDs
+ using var tempDir = new TestDirectory();
+ var indexFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "index.json");
+ File.WriteAllText(indexFile, """{"reviews":[]}""");
+
+ var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml");
+ File.WriteAllText(definitionFile, $"""
+ needs-review:
+ - "src/**/*.cs"
+ evidence-source:
+ type: fileshare
+ location: {indexFile}
+ reviews:
+ - id: Core-Logic
+ title: Review of core business logic
+ paths:
+ - "src/**/*.cs"
+ - id: Core-Logic
+ title: Duplicate review set
+ paths:
+ - "src/**/*.cs"
+ """);
+
+ var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile);
+ using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]);
+
+ // Act
+ Program.Run(context);
+
+ // Assert — non-zero exit code and log contains a clear duplicate-ID error message
+ var logContent = File.ReadAllText(logFile);
+ Assert.AreEqual(1, context.ExitCode);
+ Assert.Contains("Error:", logContent);
+ Assert.Contains("duplicate ID", logContent);
+ Assert.Contains("Core-Logic", logContent);
+ }
+
+ ///
+ /// Test that Run with --lint flag detects unknown evidence-source type and reports an error.
+ ///
+ [TestMethod]
+ public void Program_Run_WithLintFlag_UnknownSourceType_ReportsError()
+ {
+ // Arrange — create temp directory with a definition file having an unknown source type
+ using var tempDir = new TestDirectory();
+ var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml");
+ File.WriteAllText(definitionFile, """
+ needs-review:
+ - "src/**/*.cs"
+ evidence-source:
+ type: ftp
+ location: ftp://example.com/index.json
+ reviews:
+ - id: Core-Logic
+ title: Review of core business logic
+ paths:
+ - "src/**/*.cs"
+ """);
+
+ var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile);
+ using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]);
+
+ // Act
+ Program.Run(context);
+
+ // Assert — non-zero exit code and log contains a clear unsupported-type error message
+ var logContent = File.ReadAllText(logFile);
+ Assert.AreEqual(1, context.ExitCode);
+ Assert.Contains("Error:", logContent);
+ Assert.Contains("ftp", logContent);
+ Assert.Contains("not supported", logContent);
+ }
+
+ ///
+ /// Test that Run with --lint flag reports a clear error for corrupted (invalid) YAML.
+ ///
+ [TestMethod]
+ public void Program_Run_WithLintFlag_CorruptedYaml_ReportsError()
+ {
+ // Arrange — create a definition file with invalid YAML syntax
+ using var tempDir = new TestDirectory();
+ var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml");
+ File.WriteAllText(definitionFile, """
+ {{{this is not valid yaml
+ """);
+
+ var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile);
+ using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]);
+
+ // Act
+ Program.Run(context);
+
+ // Assert — non-zero exit code and log contains an error naming the definition file and a line number
+ var logContent = File.ReadAllText(logFile);
+ Assert.AreEqual(1, context.ExitCode);
+ Assert.Contains("Error:", logContent);
+ Assert.Contains("definition.yaml", logContent);
+ Assert.Contains("at line", logContent);
+ }
+
+ ///
+ /// Test that Run with --lint flag reports a clear error when required fields are missing.
+ ///
+ [TestMethod]
+ public void Program_Run_WithLintFlag_MissingEvidenceSource_ReportsError()
+ {
+ // Arrange — create a definition file with no evidence-source block
+ using var tempDir = new TestDirectory();
+ var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml");
+ File.WriteAllText(definitionFile, """
+ needs-review:
+ - "src/**/*.cs"
+ reviews:
+ - id: Core-Logic
+ title: Review of core business logic
+ paths:
+ - "src/**/*.cs"
+ """);
+
+ var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile);
+ using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]);
+
+ // Act
+ Program.Run(context);
+
+ // Assert — non-zero exit code and log names the file and the missing field
+ var logContent = File.ReadAllText(logFile);
+ Assert.AreEqual(1, context.ExitCode);
+ Assert.Contains("Error:", logContent);
+ Assert.Contains("definition.yaml", logContent);
+ Assert.Contains("evidence-source", logContent);
+ }
+
+ ///
+ /// Test that Run with --lint flag reports ALL errors in one pass when the file has
+ /// multiple detectable issues (missing evidence-source AND duplicate review IDs).
+ ///
+ [TestMethod]
+ public void Program_Run_WithLintFlag_MultipleErrors_ReportsAll()
+ {
+ // Arrange — create a definition file that is missing evidence-source AND has duplicate IDs
+ using var tempDir = new TestDirectory();
+ var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml");
+ File.WriteAllText(definitionFile, """
+ needs-review:
+ - "src/**/*.cs"
+ reviews:
+ - id: Core-Logic
+ title: Review of core business logic
+ paths:
+ - "src/**/*.cs"
+ - id: Core-Logic
+ title: Duplicate review set
+ paths:
+ - "src/**/*.cs"
+ """);
+
+ var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile);
+ using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]);
+
+ // Act
+ Program.Run(context);
+
+ // Assert — non-zero exit code and log contains BOTH the missing evidence-source error
+ // AND the duplicate ID error, proving all errors are accumulated in one pass.
+ var logContent = File.ReadAllText(logFile);
+ Assert.AreEqual(1, context.ExitCode);
+ Assert.Contains("evidence-source", logContent);
+ Assert.Contains("duplicate ID", logContent);
+ Assert.Contains("Core-Logic", logContent);
+ }
}
diff --git a/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs b/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs
index 1836039..26ab57a 100644
--- a/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs
+++ b/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs
@@ -301,6 +301,82 @@ public void ReviewMarkConfiguration_Load_NonExistentFile_ThrowsException()
ReviewMarkConfiguration.Load(nonExistentPath));
}
+ ///
+ /// Test that Load includes the file name in the error message when YAML is invalid.
+ ///
+ [TestMethod]
+ public void ReviewMarkConfiguration_Load_InvalidYaml_ErrorIncludesFilenameAndLine()
+ {
+ // Arrange — write a configuration file with invalid YAML syntax
+ var configPath = PathHelpers.SafePathCombine(_testDirectory, ".reviewmark.yaml");
+ File.WriteAllText(configPath, "{{{invalid yaml");
+
+ // Act & Assert
+ var ex = Assert.Throws(() =>
+ ReviewMarkConfiguration.Load(configPath));
+ Assert.Contains(".reviewmark.yaml", ex.Message);
+ Assert.Contains("at line", ex.Message);
+ }
+
+ ///
+ /// Test that Load includes the file name in the error message when required fields are missing.
+ ///
+ [TestMethod]
+ public void ReviewMarkConfiguration_Load_MissingEvidenceSource_ErrorIncludesFilename()
+ {
+ // Arrange — write a valid YAML file that is missing the required evidence-source block
+ var configPath = PathHelpers.SafePathCombine(_testDirectory, ".reviewmark.yaml");
+ File.WriteAllText(configPath, """
+ needs-review:
+ - "src/**/*.cs"
+ reviews:
+ - id: Core-Logic
+ title: Review of core business logic
+ paths:
+ - "src/**/*.cs"
+ """);
+
+ // Act & Assert
+ var ex = Assert.Throws(() =>
+ ReviewMarkConfiguration.Load(configPath));
+ Assert.Contains(".reviewmark.yaml", ex.Message);
+ Assert.Contains("evidence-source", ex.Message);
+ }
+
+ ///
+ /// Test that Lint returns all errors from a file with multiple detectable issues
+ /// (missing evidence-source AND duplicate review IDs) without stopping at the first.
+ ///
+ [TestMethod]
+ public void ReviewMarkConfiguration_Lint_MultipleErrors_ReturnsAll()
+ {
+ // Arrange — write a YAML file missing evidence-source and containing duplicate IDs
+ var configPath = PathHelpers.SafePathCombine(_testDirectory, ".reviewmark.yaml");
+ File.WriteAllText(configPath, """
+ needs-review:
+ - "src/**/*.cs"
+ reviews:
+ - id: Core-Logic
+ title: Review of core business logic
+ paths:
+ - "src/**/*.cs"
+ - id: Core-Logic
+ title: Duplicate review set
+ paths:
+ - "src/**/*.cs"
+ """);
+
+ // Act
+ var errors = ReviewMarkConfiguration.Lint(configPath);
+
+ // Assert — both the missing evidence-source error and the duplicate ID error are returned
+ Assert.AreEqual(2, errors.Count);
+ Assert.IsTrue(errors.Any(e => e.Contains("evidence-source")),
+ "Expected an error about missing evidence-source.");
+ Assert.IsTrue(errors.Any(e => e.Contains("duplicate ID") && e.Contains("Core-Logic")),
+ "Expected an error about duplicate ID 'Core-Logic'.");
+ }
+
///
/// Test that Load resolves a relative fileshare location against the config file's directory.
///
diff --git a/test/DemaConsulting.ReviewMark.Tests/TestDirectory.cs b/test/DemaConsulting.ReviewMark.Tests/TestDirectory.cs
new file mode 100644
index 0000000..926a38d
--- /dev/null
+++ b/test/DemaConsulting.ReviewMark.Tests/TestDirectory.cs
@@ -0,0 +1,52 @@
+// Copyright (c) DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+namespace DemaConsulting.ReviewMark.Tests;
+
+///
+/// Represents a temporary directory that is automatically deleted when disposed.
+///
+internal sealed class TestDirectory : IDisposable
+{
+ ///
+ /// Gets the path to the temporary directory.
+ ///
+ public string DirectoryPath { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public TestDirectory()
+ {
+ DirectoryPath = PathHelpers.SafePathCombine(Path.GetTempPath(), $"reviewmark_test_{Guid.NewGuid()}");
+ Directory.CreateDirectory(DirectoryPath);
+ }
+
+ ///
+ /// Deletes the temporary directory and all its contents.
+ ///
+ public void Dispose()
+ {
+ if (Directory.Exists(DirectoryPath))
+ {
+ Directory.Delete(DirectoryPath, recursive: true);
+ }
+ }
+}