Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
55 changes: 55 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 on disk **and** are referenced as `<File>` 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.
- **Reference check** — for each matched file that is not referenced as `<File Path="...">` in the `.slnx`, a `SLNX021` (`RequiredFileNotReferencedInSolution`) error is added. The error message shows the exact `<File>` element that should be added.

Relative paths in the `.slnx` are resolved relative to the solution file's location.

**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 and all matched files are referenced in the solution. |
| `1` | One or more required files don't exist on disk or are not referenced in the solution (same as any other validation error). |
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.

1 should ne any validation error

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 to: 1 — Any validation error — including required files not existing or not referenced.


## SonarQube integration example

```powershell
Expand Down Expand Up @@ -185,6 +238,8 @@ 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` | `RequiredFileDoesntExistOnSystem` | A `--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 `<File>` element in the solution. |

## XSD Schema

Expand Down
1 change: 1 addition & 0 deletions src/SLNX-validator.Core/SLNX-validator.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

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

</Project>
1 change: 1 addition & 0 deletions src/SLNX-validator.Core/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public static IServiceCollection AddSlnxValidator(this IServiceCollection servic
services.AddSingleton<IXsdValidator, XsdValidator>();
services.AddSingleton<ISlnxValidator, Validation.SlnxValidator>();
services.AddSingleton<ISlnxFileResolver, SlnxFileResolver>();
services.AddSingleton<IRequiredFilesChecker, Validation.RequiredFilesChecker>();
return services;
}
}
10 changes: 10 additions & 0 deletions src/SLNX-validator.Core/SonarQubeReporting/SonarReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ public async Task WriteReportAsync(IReadOnlyList<FileValidationResult> results,
"The XML structure violates the .slnx schema.",
SonarRuleType.BUG, SonarRuleSeverity.MAJOR, SonarCleanCodeAttribute.COMPLETE, SonarImpactSeverity.MEDIUM),

ValidationErrorCode.RequiredFileDoesntExistOnSystem => CreateRule(code,
"Required file does not exist on the system",
"A file required by '--required-files' does not exist on the file system.",
SonarRuleType.BUG, SonarRuleSeverity.MAJOR, SonarCleanCodeAttribute.COMPLETE, SonarImpactSeverity.HIGH),

ValidationErrorCode.RequiredFileNotReferencedInSolution => CreateRule(code,
"Required file not referenced in solution",
"A file required by '--required-files' exists on the file system but is not referenced as a <File> element in the solution.",
SonarRuleType.BUG, SonarRuleSeverity.MAJOR, SonarCleanCodeAttribute.COMPLETE, SonarImpactSeverity.HIGH),

_ => throw new ArgumentOutOfRangeException(nameof(code), code, null)
};

Expand Down
23 changes: 23 additions & 0 deletions src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using JulianVerdurmen.SlnxValidator.Core.ValidationResults;

namespace JulianVerdurmen.SlnxValidator.Core.Validation;

public interface IRequiredFilesChecker
{
/// <summary>
/// Resolves semicolon-separated glob patterns against <paramref name="rootDirectory"/>
/// and returns the matched absolute paths. Returns an empty list when no files match.
/// </summary>
IReadOnlyList<string> ResolveMatchedPaths(string patternsRaw, string rootDirectory);

/// <summary>
/// Checks which of the <paramref name="requiredAbsolutePaths"/> are NOT referenced as a
/// <c>&lt;File Path="..."&gt;</c> element in the given .slnx content.
/// Paths in the .slnx are resolved relative to <paramref name="slnxDirectory"/>.
/// Returns a <see cref="ValidationError"/> for each missing file.
/// </summary>
IReadOnlyList<ValidationError> CheckInSlnx(
IReadOnlyList<string> requiredAbsolutePaths,
string slnxContent,
string slnxDirectory);
}
77 changes: 77 additions & 0 deletions src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System.Xml.Linq;
using JulianVerdurmen.SlnxValidator.Core.ValidationResults;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;

namespace JulianVerdurmen.SlnxValidator.Core.Validation;

internal sealed class RequiredFilesChecker : IRequiredFilesChecker
{
/// <inheritdoc />
public IReadOnlyList<string> ResolveMatchedPaths(string patternsRaw, string rootDirectory)
{
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()
: [];
}

/// <inheritdoc />
public IReadOnlyList<ValidationError> CheckInSlnx(
IReadOnlyList<string> requiredAbsolutePaths,
string slnxContent,
string slnxDirectory)
{
var slnxFileRefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

try
{
var doc = XDocument.Parse(slnxContent);
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.

Deduplicate the parse of the slnx, create a dto

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. Created SlnxFileRefs DTO with a Parse(slnxContent, slnxDirectory) factory that owns the single XDocument.Parse call and resolves all <File> paths to absolute normalised strings. ValidationCollector calls Parse once and passes the result to CheckInSlnx.

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(slnxDirectory, path));

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.

Deduplicate

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 — both the XDocument.Parse and the Path.IsPathRooted / Path.GetFullPath path-resolution logic now live exclusively in SlnxFileRefs.Parse; CheckInSlnx just compares against slnxFileRefs.AbsoluteFilePaths.

slnxFileRefs.Add(fullPath);
}
}
catch (Exception)
{
// Malformed XML is already reported by the XML validator.
}

var errors = new List<ValidationError>();
foreach (var requiredPath in requiredAbsolutePaths)
{
if (!slnxFileRefs.Contains(requiredPath))
{
var relativePath = Path.GetRelativePath(slnxDirectory, requiredPath).Replace('\\', '/');
errors.Add(new ValidationError(
ValidationErrorCode.RequiredFileNotReferencedInSolution,
$"Required file is not referenced in the solution: {requiredPath}" +
$" — add: <File Path=\"{relativePath}\" />"));
}
}

return errors;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ public enum ValidationErrorCode
ReferencedFileNotFound = 11,
InvalidWildcardUsage = 12,
XsdViolation = 13,

// Required-files errors
RequiredFileDoesntExistOnSystem = 20,
RequiredFileNotReferencedInSolution = 21,
}
12 changes: 10 additions & 2 deletions src/SLNX-validator/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,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 files that must exist on disk and be referenced as <File> elements in the solution."
};

var rootCommand = new RootCommand("Validates .slnx solution files.")
{
inputArgument,
sonarqubeReportOption,
continueOnErrorOption
continueOnErrorOption,
requiredFilesOption
};

var services = new ServiceCollection()
Expand All @@ -41,7 +47,9 @@ public static async Task<int> Main(string[] args)
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 requiredFiles = parseResult.GetValue(requiredFilesOption);
return await services.GetRequiredService<ValidatorRunner>()
.RunAsync(input!, sonarqubeReport, continueOnError, requiredFiles, Environment.CurrentDirectory, cancellationToken);
});

return await rootCommand.Parse(args).InvokeAsync();
Expand Down
29 changes: 25 additions & 4 deletions src/SLNX-validator/ValidationCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@

namespace JulianVerdurmen.SlnxValidator;

internal sealed class ValidationCollector(IFileSystem fileSystem, ISlnxValidator validator)
internal sealed class ValidationCollector(IFileSystem fileSystem, ISlnxValidator validator, IRequiredFilesChecker requiredFilesChecker)
{
public async Task<IReadOnlyList<FileValidationResult>> CollectAsync(IReadOnlyList<string> files, CancellationToken cancellationToken)
public async Task<IReadOnlyList<FileValidationResult>> CollectAsync(
IReadOnlyList<string> files,
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.

Create options type for the parameters

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. Created RequiredFilesOptions(IReadOnlyList<string>? MatchedPaths, string? Pattern) record. CollectAsync now takes RequiredFilesOptions? requiredFilesOptions instead of two separate nullable parameters.

IReadOnlyList<string>? matchedRequiredPaths,
string? requiredFilesPattern,
CancellationToken cancellationToken)
{
var results = new List<FileValidationResult>(files.Count);

Expand Down Expand Up @@ -36,11 +40,27 @@ public async Task<IReadOnlyList<FileValidationResult>> CollectAsync(IReadOnlyLis
var directory = Path.GetDirectoryName(file)!;
var result = await validator.ValidateAsync(content, directory, cancellationToken);

var allErrors = result.Errors.ToList();

if (matchedRequiredPaths is not null)
{
if (matchedRequiredPaths.Count == 0)
{
allErrors.Add(new ValidationError(
ValidationErrorCode.RequiredFileDoesntExistOnSystem,
$"Required file does not exist on the system. No files matched: {requiredFilesPattern}"));
}
else
{
allErrors.AddRange(requiredFilesChecker.CheckInSlnx(matchedRequiredPaths, content, directory));
}
}

results.Add(new FileValidationResult
{
File = file,
HasErrors = !result.IsValid,
Errors = result.Errors,
HasErrors = allErrors.Count > 0,
Errors = allErrors,
});
}

Expand All @@ -63,3 +83,4 @@ private static bool IsBinaryFile(string path)
return buffer[..bytesRead].Contains((byte)0);
}
}

13 changes: 10 additions & 3 deletions src/SLNX-validator/ValidatorRunner.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
using JulianVerdurmen.SlnxValidator.Core;
using JulianVerdurmen.SlnxValidator.Core.FileSystem;
using JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting;
using JulianVerdurmen.SlnxValidator.Core.Validation;

namespace JulianVerdurmen.SlnxValidator;

internal sealed class ValidatorRunner(ISlnxFileResolver resolver, ValidationCollector collector, ISonarReporter sonarReporter)
internal sealed class ValidatorRunner(ISlnxFileResolver resolver, ValidationCollector collector, ISonarReporter sonarReporter, IRequiredFilesChecker requiredFilesChecker)
{
public async Task<int> RunAsync(string input, string? sonarqubeReportPath, bool continueOnError, CancellationToken cancellationToken)
public async Task<int> RunAsync(string input, string? sonarqubeReportPath, bool continueOnError,
string? requiredFilesPattern, string workingDirectory, CancellationToken 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.

Create options type for the parameters (record)

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. Created ValidatorRunnerOptions(string Input, string? SonarqubeReportPath, bool ContinueOnError, string? RequiredFilesPattern, string WorkingDirectory) record. RunAsync now takes ValidatorRunnerOptions options, CancellationToken cancellationToken.

{
var files = resolver.Resolve(input);

Expand All @@ -16,7 +18,12 @@ public async Task<int> RunAsync(string input, string? sonarqubeReportPath, bool
return continueOnError ? 0 : 1;
}

var results = await collector.CollectAsync(files, cancellationToken);
// Resolve required file glob patterns to absolute disk paths (once for all .slnx files).
IReadOnlyList<string>? matchedRequiredPaths = null;
if (requiredFilesPattern is not null)
matchedRequiredPaths = requiredFilesChecker.ResolveMatchedPaths(requiredFilesPattern, workingDirectory);

var results = await collector.CollectAsync(files, matchedRequiredPaths, requiredFilesPattern, cancellationToken);

await ValidationReporter.Report(results);

Expand Down
Loading
Loading