Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
Copy link
Copy Markdown
Owner

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.

Copy link
Copy Markdown
Contributor Author

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").

```

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
Expand Down Expand Up @@ -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`). |
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 RequiredFileDoesntExistOnSystem = 20 (SLNX020) and RequiredFileNotReferencedInSolution = 21 (SLNX021) to ValidationErrorCode. README updated accordingly.


## XSD Schema

Expand Down
36 changes: 34 additions & 2 deletions src/SLNX-validator/Program.cs
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;
Expand All @@ -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()
Expand All @@ -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)
{
Comment thread
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);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a9600b4. ValidatorRunner resolves the glob patterns via IRequiredFilesChecker.ResolveMatchedPaths, then passes the matched paths to ValidationCollector.CollectAsync. The collector adds SLNX020/SLNX021 errors directly into FileValidationResult, so they appear in both the console output and the SonarQube report.


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();
Expand Down
96 changes: 96 additions & 0 deletions src/SLNX-validator/RequiredFilesChecker.cs
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)
Comment thread
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>&lt;File Path="..."&gt;</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}");
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also print how it should look in the slnx (including html element)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a9600b4. The RequiredFileNotReferencedInSolution error message now includes the exact element to add, e.g.: Required file is not referenced in the solution: /path/to/readme.md — add: <File Path="doc/readme.md" />


return missing.Count > 0 ? 2 : 0;
}
}
1 change: 1 addition & 0 deletions src/SLNX-validator/SLNX-validator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.*" />
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="*" />
<PackageReference Include="System.CommandLine" Version="2.*" />
</ItemGroup>

Expand Down
122 changes: 122 additions & 0 deletions tests/SLNX-validator.Tests/ProgramIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,126 @@ public async Task Invoke_WithBinaryFile_ReturnsNonZeroExitCode()
File.Delete(path);
}
}

[Test]
[NotInParallel("CurrentDirectory")]
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a9600b4. ProgramIntegrationTests now has only 1 happy + 1 alt integration test for --required-files. The rest became unit tests: CheckInSlnx pure-string unit tests are in SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs, and ValidationCollectorTests.cs (new file) covers the collector logic with NSubstitute mocks for IRequiredFilesChecker and ISlnxValidator.

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>
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too many integration tests. Move to unit tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ec1f478. Removed both --required-files integration tests from ProgramIntegrationTests and replaced them with two unit tests in ValidatorRunnerTests (RunAsync_RequiredFiles_AllMatchedAndReferenced_ReturnsZero and RunAsync_RequiredFiles_NoMatchOnDisk_ReturnsOne) using mocked IRequiredFilesChecker and MockFileSystem.

<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);
}
}
}
Loading
Loading