-
Notifications
You must be signed in to change notification settings - Fork 0
Add --required-files CLI argument with glob pattern matching and solution reference check
#43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
1564a23
4d22767
6e1405e
a9600b4
ec1f478
e956c34
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,6 +65,59 @@ slnx-validator MySolution.slnx --sonarqube-report-file sonar-issues.json --conti | |
|
|
||
| Always exits with code `0`, even when validation errors are found. Useful in CI pipelines where SonarQube handles the failure decision. Default: `false`. | ||
|
|
||
| ### `--required-files` | ||
|
|
||
| Verify that a set of files or directories matching glob patterns exist before the tool runs **and** are referenced as `<File>` entries in the solution file(s) being validated. The check runs in two stages: | ||
|
|
||
| 1. **Pre-check** — the glob patterns must match at least one file on disk (runs before validation). | ||
| 2. **Last check** — every matched file must appear as a `<File Path="...">` element inside the `.slnx` file(s) (runs after validation). Relative paths in the solution file are resolved relative to the solution file's location. | ||
|
|
||
| If either check fails, the tool exits with code `2`. | ||
|
|
||
| **Syntax** | ||
|
|
||
| ``` | ||
| --required-files "<pattern1>;<pattern2>;..." | ||
| ``` | ||
|
|
||
| Patterns are separated by `;`. Patterns starting with `!` are exclusions. Pattern order matters: a later pattern can override an earlier one. | ||
|
|
||
| **Supported glob syntax** | ||
|
|
||
| | Pattern | Meaning | Example | | ||
| |---|---|---| | ||
| | `*` | Any file in the current directory (no path separator) | `doc/*.md` | | ||
| | `**` | Any depth of subdirectories | `src/**/*.cs` | | ||
| | `!pattern` | Exclude matching paths | `!**/bin/**` | | ||
| | `dir/` | Match a directory and its contents | `docs/` | | ||
|
|
||
| > **Note:** `{a,b}` alternation and `[abc]` character classes are not supported by this library. Use multiple patterns separated by `;` instead. | ||
| > For example, instead of `*.{cs,fs}`, use `**/*.cs;**/*.fs`. | ||
|
|
||
| **Examples** | ||
|
|
||
| Require all `.md` files under `doc/`: | ||
| ``` | ||
| slnx-validator MySolution.slnx --required-files "doc/*.md" | ||
| ``` | ||
|
|
||
| Require all `.cs` files under `src/`, excluding the `bin` and `obj` folders: | ||
| ``` | ||
| slnx-validator MySolution.slnx --required-files "src/**/*.cs;!**/bin/**;!**/obj/**" | ||
| ``` | ||
|
|
||
| Require a specific config file and the entire `docs/` directory: | ||
| ``` | ||
| slnx-validator MySolution.slnx --required-files "appsettings.json;docs/" | ||
| ``` | ||
|
|
||
| **Exit codes** | ||
|
|
||
| | Code | Description | | ||
| |------|-------------| | ||
| | `0` | All patterns matched, all matched files exist on disk, and all are referenced in the solution. | | ||
| | `2` | A pattern produced no disk matches, or a matched file is not referenced as `<File>` in the solution. | | ||
|
|
||
| ## SonarQube integration example | ||
|
|
||
| ```powershell | ||
|
|
@@ -185,6 +238,7 @@ The following are **intentionally out of scope** because the toolchain already h | |
| | `SLNX011` | `ReferencedFileNotFound` | A file referenced in `<File Path="...">` does not exist on disk. | | ||
| | `SLNX012` | `InvalidWildcardUsage` | A `<File Path="...">` contains a wildcard pattern (see [`examples/invalid-wildcard.slnx`](examples/invalid-wildcard.slnx)). | | ||
| | `SLNX013` | `XsdViolation` | The XML structure violates the schema, e.g. `<Folder>` inside `<Folder>` (see [`examples/invalid-xsd.slnx`](examples/invalid-xsd.slnx)). | | ||
| | `SLNX020` | `RequiredFilesNotFound` | A `--required-files` pattern produced no disk matches, or a matched file is not referenced as `<File>` in the solution (exits with code `2`). | | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The special exit code is not needed for this. Also add the error requiredFileDoesntExistOnSystem (spelled correctly)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in a9600b4. Removed the special exit code 2 — required-files failures use exit code 1 like all other validation errors. Added |
||
|
|
||
| ## XSD Schema | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| using System.CommandLine; | ||
| using JulianVerdurmen.SlnxValidator.Core; | ||
| using JulianVerdurmen.SlnxValidator.Core.FileSystem; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
|
|
||
| namespace JulianVerdurmen.SlnxValidator; | ||
|
|
@@ -23,11 +24,17 @@ public static async Task<int> Main(string[] args) | |
| Description = "Continue and exit with code 0 even when validation errors are found." | ||
| }; | ||
|
|
||
| var requiredFilesOption = new Option<string?>("--required-files") | ||
| { | ||
| Description = "Semicolon-separated glob patterns for required files and directories. The tool exits with code 2 if any pattern produces no match or a matched path does not exist." | ||
| }; | ||
|
|
||
| var rootCommand = new RootCommand("Validates .slnx solution files.") | ||
| { | ||
| inputArgument, | ||
| sonarqubeReportOption, | ||
| continueOnErrorOption | ||
| continueOnErrorOption, | ||
| requiredFilesOption | ||
| }; | ||
|
|
||
| var services = new ServiceCollection() | ||
|
|
@@ -38,10 +45,35 @@ public static async Task<int> Main(string[] args) | |
|
|
||
| rootCommand.SetAction(async (parseResult, cancellationToken) => | ||
| { | ||
| var requiredFiles = parseResult.GetValue(requiredFilesOption); | ||
| IReadOnlyList<string>? matchedRequiredPaths = null; | ||
|
|
||
| if (requiredFiles is not null) | ||
| { | ||
|
304NotModified marked this conversation as resolved.
Outdated
|
||
| // Pre-check: glob patterns must match at least one file on disk. | ||
| matchedRequiredPaths = RequiredFilesChecker.ResolveMatchedPaths(requiredFiles, Environment.CurrentDirectory); | ||
| if (matchedRequiredPaths.Count == 0) | ||
| { | ||
| await Console.Error.WriteLineAsync($"[SLNX020] Required files check failed: no files matched the patterns: {requiredFiles}"); | ||
| return 2; | ||
| } | ||
| } | ||
|
|
||
| var input = parseResult.GetValue(inputArgument); | ||
| var sonarqubeReport = parseResult.GetValue(sonarqubeReportOption); | ||
| var continueOnError = parseResult.GetValue(continueOnErrorOption); | ||
| return await services.GetRequiredService<ValidatorRunner>().RunAsync(input!, sonarqubeReport, continueOnError, cancellationToken); | ||
| var runResult = await services.GetRequiredService<ValidatorRunner>().RunAsync(input!, sonarqubeReport, continueOnError, cancellationToken); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The checks should run in the runner. So it's also create errors that will be in the sonarqube report (of enabled)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in a9600b4. |
||
|
|
||
| if (matchedRequiredPaths is not null) | ||
| { | ||
| // Last check: every required file must be referenced as a <File> in the .slnx. | ||
| var slnxFiles = services.GetRequiredService<ISlnxFileResolver>().Resolve(input!); | ||
| var slnxCheckResult = await RequiredFilesChecker.CheckInSlnxAsync(matchedRequiredPaths, slnxFiles); | ||
| if (slnxCheckResult != 0) | ||
| return slnxCheckResult; | ||
| } | ||
|
|
||
| return runResult; | ||
| }); | ||
|
|
||
| return await rootCommand.Parse(args).InvokeAsync(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| using System.Xml.Linq; | ||
| using Microsoft.Extensions.FileSystemGlobbing; | ||
| using Microsoft.Extensions.FileSystemGlobbing.Abstractions; | ||
|
|
||
| namespace JulianVerdurmen.SlnxValidator; | ||
|
|
||
| internal static class RequiredFilesChecker | ||
| { | ||
| /// <summary> | ||
| /// Resolves glob patterns against <paramref name="rootDirectory"/> and returns | ||
| /// the matched paths as absolute paths. Returns an empty list when nothing matches. | ||
| /// </summary> | ||
| public static IReadOnlyList<string> ResolveMatchedPaths(string patternsRaw, string rootDirectory) | ||
|
304NotModified marked this conversation as resolved.
Outdated
|
||
| { | ||
| var patterns = patternsRaw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); | ||
|
|
||
| var matcher = new Matcher(StringComparison.OrdinalIgnoreCase, preserveFilterOrder: true); | ||
|
|
||
| foreach (var pattern in patterns) | ||
| { | ||
| if (pattern.StartsWith('!')) | ||
| matcher.AddExclude(pattern[1..]); | ||
| else | ||
| matcher.AddInclude(pattern); | ||
| } | ||
|
|
||
| var directoryInfo = new DirectoryInfoWrapper(new DirectoryInfo(rootDirectory)); | ||
| var result = matcher.Execute(directoryInfo); | ||
|
|
||
| return result.HasMatches | ||
| ? result.Files.Select(f => Path.GetFullPath(Path.Combine(rootDirectory, f.Path))).ToList() | ||
| : []; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Pre-check: verifies that at least one file on disk matches the glob patterns. | ||
| /// Returns exit code 2 when no files match; 0 otherwise. | ||
| /// </summary> | ||
| public static async Task<int> CheckAsync(string patternsRaw, string rootDirectory) | ||
| { | ||
| var matched = ResolveMatchedPaths(patternsRaw, rootDirectory); | ||
|
|
||
| if (matched.Count == 0) | ||
| { | ||
| await Console.Error.WriteLineAsync($"[SLNX020] Required files check failed: no files matched the patterns: {patternsRaw}"); | ||
| return 2; | ||
| } | ||
|
|
||
| return 0; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Last check: verifies that every path in <paramref name="requiredAbsolutePaths"/> is | ||
| /// referenced as a <c><File Path="..."></c> element in at least one of the | ||
| /// <paramref name="slnxFilePaths"/> solution files. Paths in the .slnx are resolved | ||
| /// relative to each solution file's directory before comparison. | ||
| /// Returns exit code 2 when any required file is missing; 0 otherwise. | ||
| /// </summary> | ||
| public static async Task<int> CheckInSlnxAsync(IReadOnlyList<string> requiredAbsolutePaths, IReadOnlyList<string> slnxFilePaths) | ||
| { | ||
| // Collect all <File> paths declared in the .slnx files, normalised to absolute paths. | ||
| var slnxFileRefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||
| foreach (var slnxFile in slnxFilePaths) | ||
| { | ||
| if (!File.Exists(slnxFile)) | ||
| continue; | ||
|
|
||
| var slnxDir = Path.GetDirectoryName(slnxFile)!; | ||
| try | ||
| { | ||
| var content = await File.ReadAllTextAsync(slnxFile); | ||
| var doc = XDocument.Parse(content); | ||
| foreach (var fileElement in doc.Descendants("File")) | ||
| { | ||
| var path = fileElement.Attribute("Path")?.Value; | ||
| if (path is null) | ||
| continue; | ||
| var fullPath = Path.IsPathRooted(path) | ||
| ? Path.GetFullPath(path) | ||
| : Path.GetFullPath(Path.Combine(slnxDir, path)); | ||
| slnxFileRefs.Add(fullPath); | ||
| } | ||
| } | ||
| catch (Exception) | ||
| { | ||
| // Malformed .slnx files are already reported by the normal validation step. | ||
| } | ||
| } | ||
|
|
||
| var missing = requiredAbsolutePaths.Where(p => !slnxFileRefs.Contains(p)).ToList(); | ||
| foreach (var m in missing) | ||
| await Console.Error.WriteLineAsync($"[SLNX020] Required file not referenced in solution: {m}"); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also print how it should look in the slnx (including html element)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in a9600b4. The |
||
|
|
||
| return missing.Count > 0 ? 2 : 0; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -139,4 +139,126 @@ public async Task Invoke_WithBinaryFile_ReturnsNonZeroExitCode() | |
| File.Delete(path); | ||
| } | ||
| } | ||
|
|
||
| [Test] | ||
| [NotInParallel("CurrentDirectory")] | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only one happy and alternative integration test. Others should tests to unit tests (with mocked file system)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in a9600b4. |
||
| public async Task Invoke_WithRequiredFiles_AllMatchAndReferencedInSlnx_ReturnsZeroExitCode() | ||
| { | ||
| var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); | ||
| Directory.CreateDirectory(tempDir); | ||
|
|
||
| var csprojPath = Path.Combine(tempDir, "App.csproj"); | ||
| var slnxPath = Path.Combine(tempDir, "test.slnx"); | ||
| var docDir = Path.Combine(tempDir, "doc"); | ||
| Directory.CreateDirectory(docDir); | ||
|
|
||
| await File.WriteAllTextAsync(csprojPath, "<Project />"); | ||
| await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); | ||
| // The .slnx references doc/readme.md as a <File> so the last check passes. | ||
| await File.WriteAllTextAsync(slnxPath, """ | ||
| <Solution> | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Too many integration tests. Move to unit tests
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in ec1f478. Removed both |
||
| <Project Path="App.csproj" /> | ||
| <Folder Name="docs"> | ||
| <File Path="doc/readme.md" /> | ||
| </Folder> | ||
| </Solution> | ||
| """); | ||
|
|
||
| try | ||
| { | ||
| var previousDir = Environment.CurrentDirectory; | ||
| Environment.CurrentDirectory = tempDir; | ||
| try | ||
| { | ||
| var exitCode = await Program.Main([slnxPath, "--required-files", "doc/*.md"]); | ||
| exitCode.Should().Be(0); | ||
| } | ||
| finally | ||
| { | ||
| Environment.CurrentDirectory = previousDir; | ||
| } | ||
| } | ||
| finally | ||
| { | ||
| Directory.Delete(tempDir, recursive: true); | ||
| } | ||
| } | ||
|
|
||
| [Test] | ||
| [NotInParallel("CurrentDirectory")] | ||
| public async Task Invoke_WithRequiredFiles_MatchesButNotInSlnx_ReturnsTwoExitCode() | ||
| { | ||
| var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); | ||
| Directory.CreateDirectory(tempDir); | ||
|
|
||
| var csprojPath = Path.Combine(tempDir, "App.csproj"); | ||
| var slnxPath = Path.Combine(tempDir, "test.slnx"); | ||
| var docDir = Path.Combine(tempDir, "doc"); | ||
| Directory.CreateDirectory(docDir); | ||
|
|
||
| await File.WriteAllTextAsync(csprojPath, "<Project />"); | ||
| await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); | ||
| // The .slnx does NOT reference doc/readme.md — so the last check fails. | ||
| await File.WriteAllTextAsync(slnxPath, """ | ||
| <Solution> | ||
| <Project Path="App.csproj" /> | ||
| </Solution> | ||
| """); | ||
|
|
||
| try | ||
| { | ||
| var previousDir = Environment.CurrentDirectory; | ||
| Environment.CurrentDirectory = tempDir; | ||
| try | ||
| { | ||
| var exitCode = await Program.Main([slnxPath, "--required-files", "doc/*.md"]); | ||
| exitCode.Should().Be(2); | ||
| } | ||
| finally | ||
| { | ||
| Environment.CurrentDirectory = previousDir; | ||
| } | ||
| } | ||
| finally | ||
| { | ||
| Directory.Delete(tempDir, recursive: true); | ||
| } | ||
| } | ||
|
|
||
| [Test] | ||
| [NotInParallel("CurrentDirectory")] | ||
| public async Task Invoke_WithRequiredFiles_NoMatch_ReturnsTwoExitCode() | ||
| { | ||
| var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); | ||
| Directory.CreateDirectory(tempDir); | ||
|
|
||
| var csprojPath = Path.Combine(tempDir, "App.csproj"); | ||
| var slnxPath = Path.Combine(tempDir, "test.slnx"); | ||
|
|
||
| await File.WriteAllTextAsync(csprojPath, "<Project />"); | ||
| await File.WriteAllTextAsync(slnxPath, """ | ||
| <Solution> | ||
| <Project Path="App.csproj" /> | ||
| </Solution> | ||
| """); | ||
|
|
||
| try | ||
| { | ||
| var previousDir = Environment.CurrentDirectory; | ||
| Environment.CurrentDirectory = tempDir; | ||
| try | ||
| { | ||
| var exitCode = await Program.Main([slnxPath, "--required-files", "nonexistent/**/*.md"]); | ||
| exitCode.Should().Be(2); | ||
| } | ||
| finally | ||
| { | ||
| Environment.CurrentDirectory = previousDir; | ||
| } | ||
| } | ||
| finally | ||
| { | ||
| Directory.Delete(tempDir, recursive: true); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Change this example to: all yaml files except in src folder.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in ec1f478. Changed the example to
**/*.yaml;!src/**("all yaml files except in src folder").