diff --git a/README.md b/README.md index 9bbb3fc..9347585 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Always exits with code `0`, even when validation errors are found. Useful in CI Verify that a set of files or directories matching glob patterns exist on disk **and** are referenced as `` entries in the solution file(s) being validated. Any failure is reported as a normal validation error (exit code `1`) that also appears in SonarQube reports. -- **Disk check** — if no files match the glob patterns, a `SLNX020` (`RequiredFileDoesntExistOnSystem`) error is added to the solution result. +- **Disk check** — if a **literal** (non-wildcard) pattern matches no files on disk, a `SLNX020` (`RequiredFileDoesntExistOnSystem`) error is added. If the pattern contains wildcards (`*` or `?`) and matches no files, the check is skipped silently — zero matches is not an error. - **Reference check** — for each matched file that is not referenced as `` in the `.slnx`, a `SLNX021` (`RequiredFileNotReferencedInSolution`) error is added. The error message shows the exact `` element that should be added. Relative paths in the `.slnx` are resolved relative to the solution file's location. @@ -150,8 +150,8 @@ slnx-validator MySolution.slnx --required-files "appsettings.json;docs/" | Code | Description | |------|-------------| -| `0` | All patterns matched and all matched files are referenced in the solution. | -| `1` | Any validation error — including required files not existing or not referenced. | +| `0` | All matched files are referenced in the solution (or a wildcard pattern matched no files). | +| `1` | Any validation error — including a literal required file not existing or matched files not referenced. | ### Severity override flags @@ -403,7 +403,7 @@ The following are **intentionally out of scope** because the toolchain already h | `SLNX011` | `ReferencedFileNotFound` | A file referenced in `` does not exist on disk. | | `SLNX012` | `InvalidWildcardUsage` | A `` contains a wildcard pattern (see [`examples/invalid-wildcard.slnx`](examples/invalid-wildcard.slnx)). | | `SLNX013` | `XsdViolation` | The XML structure violates the schema, e.g. `` inside `` (see [`examples/invalid-xsd.slnx`](examples/invalid-xsd.slnx)). | -| `SLNX020` | `RequiredFileDoesntExistOnSystem` | A `--required-files` pattern matched no files on the file system. | +| `SLNX020` | `RequiredFileDoesntExistOnSystem` | A literal (non-wildcard) `--required-files` pattern matched no files on the file system. | | `SLNX021` | `RequiredFileNotReferencedInSolution` | A `--required-files` matched file exists on disk but is not referenced as a `` element in the solution. | ## XSD Schema diff --git a/src/SLNX-validator/RequiredFilesOptions.cs b/src/SLNX-validator/RequiredFilesOptions.cs index 8998f93..ecba320 100644 --- a/src/SLNX-validator/RequiredFilesOptions.cs +++ b/src/SLNX-validator/RequiredFilesOptions.cs @@ -6,10 +6,18 @@ namespace JulianVerdurmen.SlnxValidator; /// /// /// Absolute disk paths that were matched by . -/// An empty list means the pattern matched no files. +/// An empty list with a wildcard pattern means no files to check — not an error. +/// An empty list with a literal (non-wildcard) pattern means the file does not exist — emits SLNX020. /// means the --required-files option was not used. /// /// The raw semicolon-separated pattern string supplied by the user. internal sealed record RequiredFilesOptions( IReadOnlyList? MatchedPaths, - string? Pattern); + string? Pattern) +{ + /// + /// Returns when contains wildcard characters + /// (* or ?), meaning zero matches is not an error. + /// + internal bool HasWildcard => Pattern is not null && (Pattern.Contains('*') || Pattern.Contains('?')); +} diff --git a/src/SLNX-validator/SlnxCollector.cs b/src/SLNX-validator/SlnxCollector.cs index 1954f67..69d36fb 100644 --- a/src/SLNX-validator/SlnxCollector.cs +++ b/src/SLNX-validator/SlnxCollector.cs @@ -63,9 +63,12 @@ public async Task> CollectAsync( var matched = requiredFilesOptions.MatchedPaths; if (matched is null || matched.Count == 0) { - allErrors.Add(new ValidationError( - ValidationErrorCode.RequiredFileDoesntExistOnSystem, - $"Required file does not exist on the system. No files matched: {requiredFilesOptions.Pattern}")); + if (!requiredFilesOptions.HasWildcard) + { + allErrors.Add(new ValidationError( + ValidationErrorCode.RequiredFileDoesntExistOnSystem, + $"Required file does not exist on the system. No files matched: {requiredFilesOptions.Pattern}")); + } } else { diff --git a/tests/SLNX-validator.Tests/SlnxCollectorTests.cs b/tests/SLNX-validator.Tests/SlnxCollectorTests.cs index 5145315..9149365 100644 --- a/tests/SLNX-validator.Tests/SlnxCollectorTests.cs +++ b/tests/SLNX-validator.Tests/SlnxCollectorTests.cs @@ -31,7 +31,7 @@ private static (SlnxCollector collector, IRequiredFilesChecker checker) CreateCo #region CollectAsync [Test] - public async Task CollectAsync_RequiredFilesPatternNoMatch_AddsRequiredFileDoesntExistOnSystemError() + public async Task CollectAsync_WildcardPatternNoMatch_NoError() { // Arrange var (collector, _) = CreateCollector(); @@ -40,6 +40,21 @@ public async Task CollectAsync_RequiredFilesPatternNoMatch_AddsRequiredFileDoesn // Act var results = await collector.CollectAsync(SlnxPath, options, CancellationToken.None); + // Assert + results.Should().HaveCount(1); + results[0].HasErrors.Should().BeFalse(); + } + + [Test] + public async Task CollectAsync_LiteralPatternNoMatch_AddsRequiredFileDoesntExistOnSystemError() + { + // Arrange + var (collector, _) = CreateCollector(); + var options = new RequiredFilesOptions(MatchedPaths: [], Pattern: "unknownfile.md"); + + // Act + var results = await collector.CollectAsync(SlnxPath, options, CancellationToken.None); + // Assert results.Should().HaveCount(1); results[0].HasErrors.Should().BeTrue(); diff --git a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs index 24bcb39..11b9d06 100644 --- a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs +++ b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs @@ -125,14 +125,14 @@ public async Task RunAsync_RequiredFiles_AllMatchedAndReferenced_ReturnsZero() } [Test] - public async Task RunAsync_RequiredFiles_NoMatchOnDisk_ReturnsOne() + public async Task RunAsync_RequiredFiles_WildcardNoMatchOnDisk_ReturnsZero() { // Arrange var slnxPath = Path.GetFullPath("test.slnx"); var checker = Substitute.For(); checker.ResolveMatchedPaths(Arg.Any(), Arg.Any()) - .Returns([]); // nothing matched on disk + .Returns([]); // nothing matched on disk — wildcard, so no error expected var runner = CreateRunnerWithSlnx(slnxPath, "", checker); @@ -140,6 +140,26 @@ public async Task RunAsync_RequiredFiles_NoMatchOnDisk_ReturnsOne() var exitCode = await runner.RunAsync( Options(slnxPath, requiredFilesPattern: "nonexistent/**/*.md"), CancellationToken.None); + // Assert + exitCode.Should().Be(0); + } + + [Test] + public async Task RunAsync_RequiredFiles_LiteralNoMatchOnDisk_ReturnsOne() + { + // Arrange + var slnxPath = Path.GetFullPath("test.slnx"); + + var checker = Substitute.For(); + checker.ResolveMatchedPaths(Arg.Any(), Arg.Any()) + .Returns([]); // nothing matched on disk — literal path, so SLNX020 expected + + var runner = CreateRunnerWithSlnx(slnxPath, "", checker); + + // Act + var exitCode = await runner.RunAsync( + Options(slnxPath, requiredFilesPattern: "unknownfile.md"), CancellationToken.None); + // Assert exitCode.Should().Be(1); }