Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 `.yaml` files except those in the `src/` folder:
```
slnx-validator MySolution.slnx --required-files "**/*.yaml;!src/**"
```

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` | 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
2 changes: 2 additions & 0 deletions src/SLNX-validator.Core/FileSystem/IFileSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ public interface IFileSystem
IEnumerable<string> GetFiles(string directory, string searchPattern);
void CreateDirectory(string path);
Stream CreateFile(string path);
Stream OpenRead(string path);
Task<string> ReadAllTextAsync(string path, CancellationToken cancellationToken = default);
}
3 changes: 3 additions & 0 deletions src/SLNX-validator.Core/FileSystem/RealFileSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ public IEnumerable<string> GetFiles(string directory, string searchPattern) =>
Directory.GetFiles(directory, searchPattern);
public void CreateDirectory(string path) => Directory.CreateDirectory(path);
public Stream CreateFile(string path) => File.Create(path);
public Stream OpenRead(string path) => File.OpenRead(path);
public Task<string> ReadAllTextAsync(string path, CancellationToken cancellationToken = default) =>
File.ReadAllTextAsync(path, cancellationToken);
}
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
22 changes: 22 additions & 0 deletions src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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 present in
/// <paramref name="slnxFile"/>.
/// Returns a <see cref="ValidationError"/> for each missing file.
/// </summary>
IReadOnlyList<ValidationError> CheckInSlnx(
IReadOnlyList<string> requiredAbsolutePaths,
SlnxFile slnxFile);
}

53 changes: 53 additions & 0 deletions src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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,
SlnxFile slnxFile)
{
var errors = new List<ValidationError>();
foreach (var requiredPath in requiredAbsolutePaths)

Check warning on line 38 in src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs

View workflow job for this annotation

GitHub Actions / sonarcloud

Loops should be simplified using the "Where" LINQ method

Check warning on line 38 in src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs

View workflow job for this annotation

GitHub Actions / sonarcloud

Loops should be simplified using the "Where" LINQ method
{
if (!slnxFile.Files.Contains(requiredPath, StringComparer.OrdinalIgnoreCase))
{
var relativePath = Path.GetRelativePath(slnxFile.SlnxDirectory, requiredPath).Replace('\\', '/');
errors.Add(new ValidationError(
ValidationErrorCode.RequiredFileNotReferencedInSolution,
$"Required file is not referenced in the solution: {requiredPath}" +
$" — add: <File Path=\"{relativePath}\" />"));
}
}

return errors;
}
}

57 changes: 57 additions & 0 deletions src/SLNX-validator.Core/Validation/SlnxFile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Xml.Linq;

namespace JulianVerdurmen.SlnxValidator.Core.Validation;

/// <summary>
/// Represents the set of absolute file paths that are referenced as
/// <c>&lt;File Path="..."&gt;</c> elements inside a .slnx solution file.
/// </summary>
public sealed class SlnxFile
{
/// <summary>The directory that contains the .slnx file.</summary>
public string SlnxDirectory { get; }

/// <summary>Absolute, normalised paths for every <c>&lt;File&gt;</c> entry in the solution.</summary>
public IReadOnlyList<string> Files { get; }

private SlnxFile(string slnxDirectory, IReadOnlyList<string> files)
{
SlnxDirectory = slnxDirectory;
Files = files;
}

/// <summary>
/// Parses <paramref name="slnxContent"/> and returns the resolved absolute paths of all
/// <c>&lt;File Path="..."&gt;</c> elements. Relative paths are resolved against
/// <paramref name="slnxDirectory"/>.
/// </summary>
/// <returns>The parsed <see cref="SlnxFile"/>, or <see langword="null"/> when the XML is malformed.</returns>
public static SlnxFile? Parse(string slnxContent, string slnxDirectory)
{
XDocument doc;
try
{
doc = XDocument.Parse(slnxContent);
}
catch (Exception)
{
return null;
}

var refs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
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));

refs.Add(fullPath);
}

return new SlnxFile(slnxDirectory, [.. refs]);
}
}
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,
}
20 changes: 15 additions & 5 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 @@ -38,10 +44,14 @@ public static async Task<int> Main(string[] args)

rootCommand.SetAction(async (parseResult, cancellationToken) =>
{
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 options = new ValidatorRunnerOptions(
Input: parseResult.GetValue(inputArgument)!,
SonarqubeReportPath: parseResult.GetValue(sonarqubeReportOption),
ContinueOnError: parseResult.GetValue(continueOnErrorOption),
RequiredFilesPattern: parseResult.GetValue(requiredFilesOption),
WorkingDirectory: Environment.CurrentDirectory);

return await services.GetRequiredService<ValidatorRunner>().RunAsync(options, cancellationToken);
});

return await rootCommand.Parse(args).InvokeAsync();
Expand Down
15 changes: 15 additions & 0 deletions src/SLNX-validator/RequiredFilesOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace JulianVerdurmen.SlnxValidator;

/// <summary>
/// Bundled options for the <c>--required-files</c> feature passed to
/// <see cref="ValidationCollector.CollectAsync"/>.
/// </summary>
/// <param name="MatchedPaths">
/// Absolute disk paths that were matched by <see cref="Pattern"/>.
/// An empty list means the pattern matched no files.
/// <see langword="null"/> means the <c>--required-files</c> option was not used.
/// </param>
/// <param name="Pattern">The raw semicolon-separated pattern string supplied by the user.</param>
internal sealed record RequiredFilesOptions(
IReadOnlyList<string>? MatchedPaths,
string? Pattern);
37 changes: 30 additions & 7 deletions src/SLNX-validator/ValidationCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

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(

Check warning on line 9 in src/SLNX-validator/ValidationCollector.cs

View workflow job for this annotation

GitHub Actions / sonarcloud

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 9 in src/SLNX-validator/ValidationCollector.cs

View workflow job for this annotation

GitHub Actions / sonarcloud

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 9 in src/SLNX-validator/ValidationCollector.cs

View workflow job for this annotation

GitHub Actions / sonarcloud

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 9 in src/SLNX-validator/ValidationCollector.cs

View workflow job for this annotation

GitHub Actions / sonarcloud

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 9 in src/SLNX-validator/ValidationCollector.cs

View workflow job for this annotation

GitHub Actions / sonarcloud

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 9 in src/SLNX-validator/ValidationCollector.cs

View workflow job for this annotation

GitHub Actions / sonarcloud

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.
IReadOnlyList<string> files,
Copy link
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
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.

RequiredFilesOptions? requiredFilesOptions,
CancellationToken cancellationToken)
{
var results = new List<FileValidationResult>(files.Count);

Expand All @@ -32,15 +35,34 @@
continue;
}

var content = await File.ReadAllTextAsync(file, cancellationToken);
var content = await fileSystem.ReadAllTextAsync(file, cancellationToken);
var directory = Path.GetDirectoryName(file)!;
var result = await validator.ValidateAsync(content, directory, cancellationToken);

var allErrors = result.Errors.ToList();

if (requiredFilesOptions is not null)
{
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}"));
}
else
{
var slnxFile = SlnxFile.Parse(content, directory);
if (slnxFile is not null)
allErrors.AddRange(requiredFilesChecker.CheckInSlnx(matched, slnxFile));
}
}

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

Expand All @@ -55,11 +77,12 @@
Errors = [new ValidationError(code, message)],
};

private static bool IsBinaryFile(string path)
private bool IsBinaryFile(string path)
{
Span<byte> buffer = stackalloc byte[8000];
using var stream = File.OpenRead(path);
using var stream = fileSystem.OpenRead(path);
var bytesRead = stream.Read(buffer);
return buffer[..bytesRead].Contains((byte)0);
}
}

Loading
Loading