From 270e5a9b9e372d001a54b2652566a14e4b6eff58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 20:47:48 +0000 Subject: [PATCH 1/3] Initial plan From 0f14e6fdfe77ed64543f57d8b723f95de634e6fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 21:07:53 +0000 Subject: [PATCH 2/3] feat: implement zip-contains rule for zip file entry validation Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- README.md | 17 + docs/design/file-assert.md | 9 +- docs/design/file-assert/modeling.md | 5 +- .../modeling/file-assert-zip-assert.md | 120 +++++++ docs/design/introduction.md | 7 +- docs/reqstream/file-assert.yaml | 17 + docs/reqstream/file-assert/modeling.yaml | 2 + .../modeling/file-assert-zip-assert.yaml | 50 +++ docs/verification/file-assert.md | 16 + docs/verification/file-assert/modeling.md | 2 + .../modeling/file-assert-zip-assert.md | 110 +++++++ requirements.yaml | 1 + .../Configuration/FileAssertData.cs | 42 +++ .../Modeling/FileAssertFile.cs | 17 +- .../Modeling/FileAssertZipAssert.cs | 181 +++++++++++ .../Modeling/FileAssertZipAssertTests.cs | 307 ++++++++++++++++++ 16 files changed, 896 insertions(+), 7 deletions(-) create mode 100644 docs/design/file-assert/modeling/file-assert-zip-assert.md create mode 100644 docs/reqstream/file-assert/modeling/file-assert-zip-assert.yaml create mode 100644 docs/verification/file-assert/modeling/file-assert-zip-assert.md create mode 100644 src/DemaConsulting.FileAssert/Modeling/FileAssertZipAssert.cs create mode 100644 test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs diff --git a/README.md b/README.md index f3fc589..69cc7ce 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,19 @@ tests: json: - query: "ConnectionStrings" count: 1 + + - name: TestProject_PackageValid + # Distribution zip contains required entries + tags: [package] + files: + - pattern: "output/package.zip" + zip: + entries: + - pattern: 'lib/net8.0/MyLib.dll' + min: 1 + max: 1 + - pattern: 'lib/**/*.dll' + min: 1 ``` ### Acceptance Criteria Reference @@ -219,6 +232,10 @@ tests: | `json[].count` | Exact number of matched JSON nodes | | `json[].min` | Minimum number of matched JSON nodes | | `json[].max` | Maximum number of matched JSON nodes | +| `zip:` | Zip archive entry assertions (fails if file is not a valid zip archive) | +| `zip.entries[].pattern` | Glob pattern selecting zip archive entry names | +| `zip.entries[].min` | Minimum number of matching zip entries | +| `zip.entries[].max` | Maximum number of matching zip entries | ## Self Validation diff --git a/docs/design/file-assert.md b/docs/design/file-assert.md index c5bc4c0..93de5f7 100644 --- a/docs/design/file-assert.md +++ b/docs/design/file-assert.md @@ -36,7 +36,8 @@ one or more units: | Configuration | Subsystem | FileAssertConfig, FileAssertData | | Modeling | Subsystem | FileAssertTest, FileAssertFile, FileAssertRule, | | | | FileAssertTextAssert, FileAssertPdfAssert, FileAssertXmlAssert, | -| | | FileAssertHtmlAssert, FileAssertYamlAssert, FileAssertJsonAssert | +| | | FileAssertHtmlAssert, FileAssertYamlAssert, FileAssertJsonAssert, | +| | | FileAssertZipAssert | | Utilities | Subsystem | PathHelpers | | SelfTest | Subsystem | Validation | @@ -73,6 +74,10 @@ The following sequence describes the normal execution path: immediate error if parsing fails, otherwise applies dot-notation path count assertions. g. If a `json:` block is defined, attempts to parse the file using `System.Text.Json`; reports an immediate error if parsing fails, otherwise applies dot-notation path count assertions. + h. If a `zip:` block is defined, attempts to open the file as a zip archive using + `System.IO.Compression.ZipFile`; reports an immediate error if the archive cannot be + opened, otherwise matches entry names against each configured glob pattern and enforces + the declared count constraints. 8. Rule violations and parse failures are recorded via `context.WriteError`. 9. After all tests complete, if `context.ResultsFile` is set, `FileAssertConfig.Run` writes TRX or JUnit XML results (format determined by the file extension) to the specified path. @@ -107,3 +112,5 @@ The following sequence describes the normal execution path: HtmlAgilityPack is chosen for HTML because it is the de-facto standard for lenient HTML parsing in .NET. YamlDotNet is already a project dependency and is reused for YAML parsing. `System.Text.Json` is part of the .NET BCL and is used for JSON parsing. +- **Zip archive inspection**: `System.IO.Compression.ZipFile` is part of the .NET BCL and is used + to open zip archives and enumerate their entries, requiring no additional dependencies. diff --git a/docs/design/file-assert/modeling.md b/docs/design/file-assert/modeling.md index d79cde1..dcab85b 100644 --- a/docs/design/file-assert/modeling.md +++ b/docs/design/file-assert/modeling.md @@ -19,6 +19,7 @@ executable domain objects and drives the assertion logic. | `FileAssertHtmlAssert` | `FileAssertHtmlAssert.cs` | Parses HTML; applies XPath node count assertions. | | `FileAssertYamlAssert` | `FileAssertYamlAssert.cs` | Parses YAML; applies dot-notation path assertions. | | `FileAssertJsonAssert` | `FileAssertJsonAssert.cs` | Parses JSON; applies dot-notation path assertions. | +| `FileAssertZipAssert` | `FileAssertZipAssert.cs` | Opens zip archive; applies entry glob count checks. | ### Subsystem Responsibilities @@ -30,6 +31,7 @@ executable domain objects and drives the assertion logic. - Parse matched files as PDF, XML, HTML, YAML, or JSON documents when the corresponding assertion block is declared. - Report an immediate error when a file cannot be parsed as the declared format. - Apply structured-document query assertions (XPath or dot-notation) to parsed document nodes. +- Open zip archives and match entry names against glob patterns, enforcing count constraints. - Report assertion failures via the `Context` from the Cli subsystem. ### Object Hierarchy @@ -47,7 +49,8 @@ FileAssertTest ├── FileAssertXmlAssert? (zero or one) ├── FileAssertHtmlAssert? (zero or one) ├── FileAssertYamlAssert? (zero or one) - └── FileAssertJsonAssert? (zero or one) + ├── FileAssertJsonAssert? (zero or one) + └── FileAssertZipAssert? (zero or one) ``` ### Interactions with Other Subsystems diff --git a/docs/design/file-assert/modeling/file-assert-zip-assert.md b/docs/design/file-assert/modeling/file-assert-zip-assert.md new file mode 100644 index 0000000..c5fb962 --- /dev/null +++ b/docs/design/file-assert/modeling/file-assert-zip-assert.md @@ -0,0 +1,120 @@ +### FileAssertZipAssert Design + +#### Overview + +The `FileAssertZipAssert` class validates the contents of a zip archive by matching entry names +against glob patterns and enforcing minimum and maximum count constraints. It is created from a +`FileAssertZipData` DTO and is invoked by `FileAssertFile` when a `zip:` assertion block is +declared. Wrapping zip entry validation in a dedicated unit keeps `FileAssertFile` free of +archive-inspection logic and makes the zip assertion pattern consistent with all other file-type +assert units. + +#### Class Structure + +##### Nested Class: Entry + +The `Entry` nested class holds the compiled state for a single entry constraint: + +| Property | Type | Description | +| :-------- | :------- | :------------------------------------------------------------------ | +| `Pattern` | `string` | Glob pattern used to match zip entry names. | +| `Min` | `int?` | Minimum number of entries that must match, or null for no bound. | +| `Max` | `int?` | Maximum number of entries that may match, or null for no bound. | + +##### Properties + +| Property | Type | Description | +| :-------- | :-------------------------- | :---------------------------------------------------- | +| `Entries` | `IReadOnlyList` | Entry constraints applied to the zip archive. | + +##### Factory Method + +```csharp +internal static FileAssertZipAssert Create(FileAssertZipData data) +``` + +Converts each `FileAssertZipEntryData` DTO into an `Entry` instance after validating that a +pattern is specified. + +| Parameter | Type | Description | +| :-------- | :-------------------- | :------------------------------------------------------- | +| `data` | `FileAssertZipData` | Zip assertion block data from YAML configuration. | + +| Return / Exception | Description | +| :--------------------------- | :---------------------------------------------------------- | +| Returns | A new `FileAssertZipAssert` instance. | +| `ArgumentNullException` | Thrown when `data` is null. | +| `InvalidOperationException` | Thrown when any entry does not specify a pattern. | + +##### Run Method + +```csharp +internal void Run(Context context, string fileName) +``` + +Opens the zip archive, collects all file entry names, and evaluates each entry constraint. + +Execution proceeds in the following steps: + +1. Attempts to open the zip archive with `ZipFile.OpenRead(fileName)`. +2. If an `IOException`, `InvalidDataException`, or `UnauthorizedAccessException` is thrown, + writes the error below and returns immediately. +3. Enumerates all archive entries, normalizing separators to forward slashes and excluding + directory entries (names ending with `/`). +4. For each configured `Entry`, uses `Matcher.Match(string.Empty, allEntries)` from + `Microsoft.Extensions.FileSystemGlobbing` to count matched entries. +5. Writes an error if the match count is below `Min` or above `Max`. + +###### Run Error Messages + +```text +File '' could not be read as a zip archive +``` + +```text +Zip '' entry pattern '' matched entry(s), +but expected at least +``` + +```text +Zip '' entry pattern '' matched entry(s), +but expected at most +``` + +| Parameter | Type | Description | +| :--------- | :-------- | :------------------------------------- | +| `context` | `Context` | Reporting sink used to record errors. | +| `fileName` | `string` | Full path to the zip file to validate. | + +#### YAML Configuration + +Zip entry constraints are declared under the `zip:` key of a file entry: + +```yaml +files: + - pattern: "output/package.zip" + zip: + entries: + - pattern: 'lib/net8.0/MyLib.dll' + min: 1 + max: 1 + - pattern: 'lib/**/*.dll' + min: 1 +``` + +#### Design Decisions + +- **Dedicated unit for zip validation**: Wrapping zip archive inspection in `FileAssertZipAssert` + keeps `FileAssertFile` free of archive-handling logic and makes the pattern consistent with all + other file-type assert units (`FileAssertTextAssert`, `FileAssertXmlAssert`, etc.). +- **Forward-slash normalization**: Zip entry names are normalized to forward slashes before + matching so that glob patterns work consistently regardless of the creating platform. +- **Directory entry exclusion**: Entries whose names end with `/` are directory markers and are + excluded from matching to avoid false counts from container entries. +- **Virtual root for Matcher**: `Matcher.Match(".", allEntries)` applies the glob + pattern directly to the normalized entry name list without any filesystem path manipulation, + because zip entry names are self-contained paths rather than paths relative to a directory root. + The `"."` root is required because `InMemoryDirectoryInfo` rejects empty or null root paths. +- **Immediate failure on parse error**: If the file cannot be opened as a zip archive, an error + is written immediately and no entry constraints are evaluated, consistent with the behavior of + all other file-type assert units. diff --git a/docs/design/introduction.md b/docs/design/introduction.md index aa40b70..3b991d7 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -27,6 +27,7 @@ This document covers the detailed design of the following software units: - **FileAssertHtmlAssert** — HTML document assertions (`FileAssertHtmlAssert.cs`) - **FileAssertYamlAssert** — YAML document assertions (`FileAssertYamlAssert.cs`) - **FileAssertJsonAssert** — JSON document assertions (`FileAssertJsonAssert.cs`) +- **FileAssertZipAssert** — zip archive entry assertions (`FileAssertZipAssert.cs`) - **PathHelpers** — safe path-combination utility (`PathHelpers.cs`) - **Validation** — self-validation test runner (`Validation.cs`) @@ -63,7 +64,8 @@ FileAssert (System) │ ├── FileAssertXmlAssert (Unit) │ ├── FileAssertHtmlAssert (Unit) │ ├── FileAssertYamlAssert (Unit) -│ └── FileAssertJsonAssert (Unit) +│ ├── FileAssertJsonAssert (Unit) +│ └── FileAssertZipAssert (Unit) ├── Utilities (Subsystem) │ └── PathHelpers (Unit) └── SelfTest (Subsystem) @@ -94,7 +96,8 @@ src/DemaConsulting.FileAssert/ │ ├── FileAssertXmlAssert.cs — XML document assertions (System.Xml.Linq/XPath) │ ├── FileAssertHtmlAssert.cs — HTML document assertions (HtmlAgilityPack) │ ├── FileAssertYamlAssert.cs — YAML document assertions (YamlDotNet) -│ └── FileAssertJsonAssert.cs — JSON document assertions (System.Text.Json) +│ ├── FileAssertJsonAssert.cs — JSON document assertions (System.Text.Json) +│ └── FileAssertZipAssert.cs — zip archive entry assertions (System.IO.Compression) ├── Utilities/ │ └── PathHelpers.cs — safe path-combination utility └── SelfTest/ diff --git a/docs/reqstream/file-assert.yaml b/docs/reqstream/file-assert.yaml index 1c7e211..f2f58d8 100644 --- a/docs/reqstream/file-assert.yaml +++ b/docs/reqstream/file-assert.yaml @@ -341,3 +341,20 @@ sections: - FileAssert-Modeling-QueryAssertions tests: - IntegrationTest_JsonAssert_PassingQuery_ReturnsZero + + - id: FileAssert-System-ZipAssertions + title: | + The FileAssert tool shall evaluate zip archive entry assertions declared in a `zip:` block + and return a non-zero exit code when any entry count constraint fails or when the file + cannot be opened as a valid zip archive. + justification: | + Zip archives are a common packaging format for build outputs, distribution packages, and + compliance artifacts. Entry count constraints allow users to assert that required files + are present in the archive without unpacking it. An immediate failure on an invalid zip + archive prevents misleading partial results. + children: + - FileAssert-Modeling-FileTypeParsing + - FileAssert-FileAssertZipAssert-EntryMatching + tests: + - FileAssertZipAssert_Run_MatchingEntriesMeetConstraints_NoError + - FileAssertZipAssert_Run_TooFewMatchingEntries_WritesError diff --git a/docs/reqstream/file-assert/modeling.yaml b/docs/reqstream/file-assert/modeling.yaml index 8d27991..4ed8537 100644 --- a/docs/reqstream/file-assert/modeling.yaml +++ b/docs/reqstream/file-assert/modeling.yaml @@ -51,6 +51,8 @@ sections: - FileAssert-FileAssertYamlAssert-ParseError - FileAssert-FileAssertJsonAssert-Creation - FileAssert-FileAssertJsonAssert-ParseError + - FileAssert-FileAssertZipAssert-Creation + - FileAssert-FileAssertZipAssert-ParseError tests: - Modeling_FileTypeParsing_InvalidXml_ReportsParseError diff --git a/docs/reqstream/file-assert/modeling/file-assert-zip-assert.yaml b/docs/reqstream/file-assert/modeling/file-assert-zip-assert.yaml new file mode 100644 index 0000000..a9d4eeb --- /dev/null +++ b/docs/reqstream/file-assert/modeling/file-assert-zip-assert.yaml @@ -0,0 +1,50 @@ +--- +# Software Unit Requirements for the FileAssertZipAssert Class +# +# The FileAssertZipAssert class validates zip archive contents by matching entry names against +# glob patterns and enforcing count constraints. It is invoked by FileAssertFile when a +# `zip:` assertion block is declared. + +sections: + - title: FileAssertZipAssert Unit Requirements + requirements: + - id: FileAssert-FileAssertZipAssert-Creation + title: | + The FileAssertZipAssert class shall be constructed from a zip assertion data object + containing a list of entry pattern constraints. + justification: | + Constructing entry constraints at creation time rather than at evaluation time avoids + repeated per-file construction overhead and allows validation errors (such as missing + patterns) to be reported before any file system operations are attempted. + tests: + - FileAssertZipAssert_Create_ValidData_CreatesZipAssert + - FileAssertZipAssert_Create_NullData_ThrowsArgumentNullException + - FileAssertZipAssert_Create_EntryMissingPattern_ThrowsInvalidOperationException + + - id: FileAssert-FileAssertZipAssert-EntryMatching + title: | + The FileAssertZipAssert class shall match zip archive entry names against each + configured glob pattern and report an error when the match count violates the + declared minimum or maximum constraint. + justification: | + Count constraints on zip entry patterns allow users to assert that required + artifacts are present in a package archive (minimum) or that unexpected extra + entries have not been included (maximum). Both bounds must be checked and reported + independently so that all violations are visible in a single pass. + tests: + - FileAssertZipAssert_Run_MatchingEntriesMeetConstraints_NoError + - FileAssertZipAssert_Run_GlobPatternMatchesMultipleEntries_NoError + - FileAssertZipAssert_Run_TooFewMatchingEntries_WritesError + - FileAssertZipAssert_Run_TooManyMatchingEntries_WritesError + + - id: FileAssert-FileAssertZipAssert-ParseError + title: | + The FileAssertZipAssert class shall report an immediate error when the target file + cannot be opened as a valid zip archive. + justification: | + I/O failures (file not found, access denied) and format errors (invalid zip data) + should be reported as clean assertion errors rather than unhandled exceptions, + consistent with the behavior of all other file-type assert units. + tests: + - FileAssertZipAssert_Run_InvalidZipFile_WritesError + - FileAssertZipAssert_Run_NonExistentFile_WritesError diff --git a/docs/verification/file-assert.md b/docs/verification/file-assert.md index 4ec79ae..47b77d5 100644 --- a/docs/verification/file-assert.md +++ b/docs/verification/file-assert.md @@ -223,6 +223,20 @@ a positional filter argument. **Expected**: Exit code non-zero. +### FileAssertZipAssert_Run_MatchingEntriesMeetConstraints_NoError + +**Scenario**: A zip assertion is configured and the archive contains entries that satisfy +the declared minimum and maximum count constraints. + +**Expected**: Exit code 0. + +### FileAssertZipAssert_Run_TooFewMatchingEntries_WritesError + +**Scenario**: A zip assertion is configured with a minimum count but the archive contains +fewer matching entries than required. + +**Expected**: Exit code non-zero. + ## Requirements Coverage - **Version display**: IntegrationTest_VersionFlag_OutputsVersion @@ -253,3 +267,5 @@ a positional filter argument. IntegrationTest_YamlAssert_PassingQuery_ReturnsZero, IntegrationTest_JsonAssert_PassingQuery_ReturnsZero, IntegrationTest_PdfAssert_InvalidFile_ReturnsNonZero +- **Zip archive assertions**: FileAssertZipAssert_Run_MatchingEntriesMeetConstraints_NoError, + FileAssertZipAssert_Run_TooFewMatchingEntries_WritesError diff --git a/docs/verification/file-assert/modeling.md b/docs/verification/file-assert/modeling.md index d68f31c..78ebaf7 100644 --- a/docs/verification/file-assert/modeling.md +++ b/docs/verification/file-assert/modeling.md @@ -53,3 +53,5 @@ satisfying the query and count constraints is provided. Modeling_ExecuteChain_ReportsFailuresThroughContext - **XML parsing error reporting**: Modeling_FileTypeParsing_InvalidXml_ReportsParseError - **XML query assertion**: Modeling_QueryAssertions_XmlQueryMeetsCount_NoError +- **Zip assert creation and parse error**: FileAssertZipAssert_Create_ValidData_CreatesZipAssert, + FileAssertZipAssert_Run_InvalidZipFile_WritesError diff --git a/docs/verification/file-assert/modeling/file-assert-zip-assert.md b/docs/verification/file-assert/modeling/file-assert-zip-assert.md new file mode 100644 index 0000000..82bc1b3 --- /dev/null +++ b/docs/verification/file-assert/modeling/file-assert-zip-assert.md @@ -0,0 +1,110 @@ +### FileAssertZipAssert Verification + +This document describes the unit-level verification design for the `FileAssertZipAssert` unit. It +defines the test scenarios, dependency usage, and requirement coverage for +`Modeling/FileAssertZipAssert.cs`. + +#### Verification Approach + +`FileAssertZipAssert` is verified with unit tests defined in `FileAssertZipAssertTests.cs`. Tests +create actual zip archives in a temporary file using `System.IO.Compression.ZipFile`, then invoke +`FileAssertZipAssert.Run` and assert on the resulting context state. + +#### Dependencies + +| Dependency | Usage in Tests | +|-------------------------|-------------------------------------------------------------| +| `Context` | Used directly (not mocked) — created with controlled flags. | +| `System.IO.Compression` | Used directly to create real zip archives for each test. | + +#### Test Scenarios + +##### FileAssertZipAssert_Create_ValidData_CreatesZipAssert + +**Scenario**: `FileAssertZipAssert.Create` is called with a valid `FileAssertZipData` containing +one entry. + +**Expected**: A non-null instance is returned with the correct pattern, min, and max values. + +**Requirement coverage**: Zip assert creation requirement. + +##### FileAssertZipAssert_Create_NullData_ThrowsArgumentNullException + +**Scenario**: `FileAssertZipAssert.Create` is called with `null` data. + +**Expected**: An `ArgumentNullException` is thrown. + +**Boundary / error path**: Null data guard. + +##### FileAssertZipAssert_Create_EntryMissingPattern_ThrowsInvalidOperationException + +**Scenario**: `FileAssertZipAssert.Create` is called with an entry that has no pattern. + +**Expected**: An `InvalidOperationException` is thrown. + +**Boundary / error path**: Missing pattern guard. + +##### FileAssertZipAssert_Run_MatchingEntriesMeetConstraints_NoError + +**Scenario**: `FileAssertZipAssert.Run` is called on a zip archive containing an entry that matches +the pattern, with both min and max set to 1. + +**Expected**: No errors are written to the context; exit code is 0. + +**Requirement coverage**: Entry matching pass requirement. + +##### FileAssertZipAssert_Run_GlobPatternMatchesMultipleEntries_NoError + +**Scenario**: `FileAssertZipAssert.Run` is called on a zip archive containing multiple entries that +match a wildcard glob pattern, with only a minimum count specified. + +**Expected**: No errors are written to the context; exit code is 0. + +**Requirement coverage**: Glob matching across multiple entries. + +##### FileAssertZipAssert_Run_TooFewMatchingEntries_WritesError + +**Scenario**: `FileAssertZipAssert.Run` is called on an empty zip archive where the minimum count +constraint requires at least one matching entry. + +**Expected**: An error is written to the context; exit code is non-zero. + +**Requirement coverage**: Minimum count violation reporting. + +##### FileAssertZipAssert_Run_TooManyMatchingEntries_WritesError + +**Scenario**: `FileAssertZipAssert.Run` is called on a zip archive with two matching entries where +the maximum count is set to 1. + +**Expected**: An error is written to the context; exit code is non-zero. + +**Requirement coverage**: Maximum count violation reporting. + +##### FileAssertZipAssert_Run_InvalidZipFile_WritesError + +**Scenario**: `FileAssertZipAssert.Run` is called on a file that contains arbitrary bytes and +cannot be parsed as a zip archive. + +**Expected**: A single error is written to the context; exit code is non-zero. + +**Boundary / error path**: Invalid zip data parse error. + +##### FileAssertZipAssert_Run_NonExistentFile_WritesError + +**Scenario**: `FileAssertZipAssert.Run` is called with a path that does not exist. + +**Expected**: A single error is written to the context; exit code is non-zero. + +**Boundary / error path**: Missing file I/O error. + +#### Requirements Coverage + +- **Zip assert creation**: FileAssertZipAssert_Create_ValidData_CreatesZipAssert +- **Null guard**: FileAssertZipAssert_Create_NullData_ThrowsArgumentNullException +- **Missing pattern guard**: FileAssertZipAssert_Create_EntryMissingPattern_ThrowsInvalidOperationException +- **Entry matching pass**: FileAssertZipAssert_Run_MatchingEntriesMeetConstraints_NoError, + FileAssertZipAssert_Run_GlobPatternMatchesMultipleEntries_NoError +- **Too few entries**: FileAssertZipAssert_Run_TooFewMatchingEntries_WritesError +- **Too many entries**: FileAssertZipAssert_Run_TooManyMatchingEntries_WritesError +- **Invalid zip**: FileAssertZipAssert_Run_InvalidZipFile_WritesError +- **Missing file**: FileAssertZipAssert_Run_NonExistentFile_WritesError diff --git a/requirements.yaml b/requirements.yaml index aa108db..cf4a649 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -18,6 +18,7 @@ includes: - docs/reqstream/file-assert/modeling/file-assert-html-assert.yaml - docs/reqstream/file-assert/modeling/file-assert-yaml-assert.yaml - docs/reqstream/file-assert/modeling/file-assert-json-assert.yaml + - docs/reqstream/file-assert/modeling/file-assert-zip-assert.yaml - docs/reqstream/file-assert/utilities.yaml - docs/reqstream/file-assert/utilities/path-helpers.yaml - docs/reqstream/file-assert/selftest.yaml diff --git a/src/DemaConsulting.FileAssert/Configuration/FileAssertData.cs b/src/DemaConsulting.FileAssert/Configuration/FileAssertData.cs index 5955680..1b4723d 100644 --- a/src/DemaConsulting.FileAssert/Configuration/FileAssertData.cs +++ b/src/DemaConsulting.FileAssert/Configuration/FileAssertData.cs @@ -128,6 +128,12 @@ internal sealed class FileAssertFileData /// [YamlMember(Alias = "json")] public List? Json { get; set; } + + /// + /// Gets or sets the zip archive entry assertion block for this file pattern. + /// + [YamlMember(Alias = "zip")] + public FileAssertZipData? Zip { get; set; } } /// @@ -196,6 +202,42 @@ internal sealed class FileAssertPdfData public List? Text { get; set; } } +/// +/// YAML data transfer object representing a single zip archive entry pattern with count constraints. +/// +internal sealed class FileAssertZipEntryData +{ + /// + /// Gets or sets the glob pattern used to match zip archive entry names. + /// + [YamlMember(Alias = "pattern")] + public string? Pattern { get; set; } + + /// + /// Gets or sets the minimum number of entries that must match the pattern. + /// + [YamlMember(Alias = "min")] + public int? Min { get; set; } + + /// + /// Gets or sets the maximum number of entries that may match the pattern. + /// + [YamlMember(Alias = "max")] + public int? Max { get; set; } +} + +/// +/// YAML data transfer object for the zip archive assertion block. +/// +internal sealed class FileAssertZipData +{ + /// + /// Gets or sets the list of entry pattern constraints to validate against the zip archive. + /// + [YamlMember(Alias = "entries")] + public List? Entries { get; set; } +} + /// /// YAML data transfer object for a structured-document query assertion (XML, HTML, YAML, JSON). /// diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs index 87c275d..6738972 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs @@ -45,6 +45,7 @@ internal sealed class FileAssertFile /// The HTML assert unit, or null when no html: block is declared. /// The YAML assert unit, or null when no yaml: block is declared. /// The JSON assert unit, or null when no json: block is declared. + /// The zip assert unit, or null when no zip: block is declared. private FileAssertFile( string pattern, int? min, @@ -57,7 +58,8 @@ private FileAssertFile( FileAssertXmlAssert? xmlAssert, FileAssertHtmlAssert? htmlAssert, FileAssertYamlAssert? yamlAssert, - FileAssertJsonAssert? jsonAssert) + FileAssertJsonAssert? jsonAssert, + FileAssertZipAssert? zipAssert) { // Store all validated properties for use during execution Pattern = pattern; @@ -72,6 +74,7 @@ private FileAssertFile( HtmlAssert = htmlAssert; YamlAssert = yamlAssert; JsonAssert = jsonAssert; + ZipAssert = zipAssert; } /// @@ -134,6 +137,11 @@ private FileAssertFile( /// internal FileAssertJsonAssert? JsonAssert { get; } + /// + /// Gets the zip assert unit, or null when no zip: block is declared. + /// + internal FileAssertZipAssert? ZipAssert { get; } + /// /// Creates a new from the provided YAML data. /// @@ -159,11 +167,12 @@ internal static FileAssertFile Create(FileAssertFileData data) var htmlAssert = data.Html != null ? FileAssertHtmlAssert.Create(data.Html) : null; var yamlAssert = data.Yaml != null ? FileAssertYamlAssert.Create(data.Yaml) : null; var jsonAssert = data.Json != null ? FileAssertJsonAssert.Create(data.Json) : null; + var zipAssert = data.Zip != null ? FileAssertZipAssert.Create(data.Zip) : null; // Return the fully constructed file assertion return new FileAssertFile( data.Pattern, data.Min, data.Max, data.Count, data.MinSize, data.MaxSize, - textAssert, pdfAssert, xmlAssert, htmlAssert, yamlAssert, jsonAssert); + textAssert, pdfAssert, xmlAssert, htmlAssert, yamlAssert, jsonAssert, zipAssert); } /// @@ -213,7 +222,8 @@ internal void Run(Context context, string basePath) var hasPerFileChecks = MinSize.HasValue || MaxSize.HasValue || TextAssert != null || PdfAssert != null || XmlAssert != null || HtmlAssert != null || - YamlAssert != null || JsonAssert != null; + YamlAssert != null || JsonAssert != null || + ZipAssert != null; if (hasPerFileChecks) { @@ -244,6 +254,7 @@ internal void Run(Context context, string basePath) HtmlAssert?.Run(context, fullPath); YamlAssert?.Run(context, fullPath); JsonAssert?.Run(context, fullPath); + ZipAssert?.Run(context, fullPath); } } } diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertZipAssert.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertZipAssert.cs new file mode 100644 index 0000000..ceda4f1 --- /dev/null +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertZipAssert.cs @@ -0,0 +1,181 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.IO.Compression; +using DemaConsulting.FileAssert.Cli; +using DemaConsulting.FileAssert.Configuration; +using Microsoft.Extensions.FileSystemGlobbing; + +namespace DemaConsulting.FileAssert.Modeling; + +/// +/// Validates zip archive contents by matching entry names against glob patterns and enforcing +/// count constraints. Invoked by when a zip: assertion +/// block is declared in the YAML configuration. +/// +internal sealed class FileAssertZipAssert +{ + /// + /// Represents a single glob-pattern entry constraint for a zip archive, carrying the + /// pattern and optional minimum and maximum match counts. + /// + internal sealed class Entry + { + /// + /// Initializes a new instance of the class. + /// + /// The glob pattern used to match zip entry names. + /// The minimum number of entries that must match, or null for no lower bound. + /// The maximum number of entries that may match, or null for no upper bound. + internal Entry(string pattern, int? min, int? max) + { + // Store the validated pattern and count constraints for use during zip inspection + Pattern = pattern; + Min = min; + Max = max; + } + + /// + /// Gets the glob pattern used to match zip entry names. + /// + internal string Pattern { get; } + + /// + /// Gets the minimum number of entries that must match the pattern, or null for no constraint. + /// + internal int? Min { get; } + + /// + /// Gets the maximum number of entries that may match the pattern, or null for no constraint. + /// + internal int? Max { get; } + } + + /// + /// Initializes a new instance of the class. + /// + /// The list of entry constraints to apply to the zip archive. + private FileAssertZipAssert(IReadOnlyList entries) + { + Entries = entries; + } + + /// + /// Gets the list of entry constraints applied to the zip archive. + /// + internal IReadOnlyList Entries { get; } + + /// + /// Creates a new from the provided YAML data. + /// + /// The zip assertion block data deserialized from YAML configuration. + /// A new instance. + /// Thrown when is null. + /// Thrown when any entry data does not specify a pattern. + internal static FileAssertZipAssert Create(FileAssertZipData data) + { + // Validate that data was provided + ArgumentNullException.ThrowIfNull(data); + + // Convert each entry DTO into a validated Entry domain object + var entries = (data.Entries ?? []) + .Select(e => + { + // Require every entry to specify a glob pattern before any I/O is attempted + if (string.IsNullOrWhiteSpace(e.Pattern)) + { + throw new InvalidOperationException("Zip entry assertion must specify a pattern"); + } + + return new Entry(e.Pattern, e.Min, e.Max); + }) + .ToList(); + + return new FileAssertZipAssert(entries.AsReadOnly()); + } + + /// + /// Opens the zip archive at , enumerates its entries, and + /// applies all configured entry constraints, reporting violations via the context. + /// + /// + /// Directory entries (whose names end with /) are excluded from matching because + /// they represent containers rather than file content. Entry names are normalized to + /// forward slashes so that glob patterns work consistently across platforms. + /// + /// If the file cannot be opened as a zip archive, a single error is written and the + /// method returns immediately without evaluating any entry constraints. + /// + /// The context used for reporting errors. Must not be null. + /// The full path to the zip file to validate. Must not be null. + internal void Run(Context context, string fileName) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(fileName); + + // Attempt to open the zip archive; report and abort on any I/O or format error + ZipArchive archive; + try + { + archive = ZipFile.OpenRead(fileName); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) + { + context.WriteError($"File '{fileName}' could not be read as a zip archive"); + return; + } + + using (archive) + { + // Collect all file entry names, normalizing to forward slashes and excluding directory markers + var allEntries = archive.Entries + .Select(e => e.FullName.Replace('\\', '/')) + .Where(name => !name.EndsWith('/')) + .ToList(); + + // Evaluate each entry constraint against the complete list of zip file entries + foreach (var entry in Entries) + { + // Use the FileSystemGlobbing Matcher with a virtual root "." so patterns are applied + // directly to the normalized entry names without any filesystem path manipulation + var matcher = new Matcher(); + matcher.AddInclude(entry.Pattern); + var result = matcher.Match(".", allEntries); + var count = result.Files.Count(); + + // Enforce the minimum entry count constraint if specified + if (entry.Min.HasValue && count < entry.Min.Value) + { + context.WriteError( + $"Zip '{fileName}' entry pattern '{entry.Pattern}' matched {count} " + + $"entry(s), but expected at least {entry.Min.Value}"); + } + + // Enforce the maximum entry count constraint if specified + if (entry.Max.HasValue && count > entry.Max.Value) + { + context.WriteError( + $"Zip '{fileName}' entry pattern '{entry.Pattern}' matched {count} " + + $"entry(s), but expected at most {entry.Max.Value}"); + } + } + } + } +} diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs new file mode 100644 index 0000000..e70b89b --- /dev/null +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs @@ -0,0 +1,307 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.IO.Compression; +using DemaConsulting.FileAssert.Cli; +using DemaConsulting.FileAssert.Configuration; +using DemaConsulting.FileAssert.Modeling; + +namespace DemaConsulting.FileAssert.Tests.Modeling; + +/// +/// Unit tests for the class. +/// +[Collection("Sequential")] +public sealed class FileAssertZipAssertTests +{ + /// + /// Creates a zip file at containing the specified entry names, + /// each with a single placeholder byte of content. + /// + /// Destination path for the zip file. Any existing file is removed first. + /// Entry names to add to the zip archive. + private static void CreateZipFile(string path, IEnumerable entries) + { + // Remove the file first because ZipFile.Open in Create mode requires a non-existent path, + // but Path.GetTempFileName() creates a zero-byte placeholder that must be deleted first. + File.Delete(path); + + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + foreach (var entry in entries) + { + var archiveEntry = archive.CreateEntry(entry); + using var stream = archiveEntry.Open(); + + // Write a single placeholder byte so the entry is not an empty-stream edge case + stream.WriteByte(0x00); + } + } + + /// + /// Verifies that Create succeeds given valid data. + /// + [Fact] + public void FileAssertZipAssert_Create_ValidData_CreatesZipAssert() + { + // Arrange + var data = new FileAssertZipData + { + Entries = + [ + new FileAssertZipEntryData { Pattern = "lib/**/*.dll", Min = 1 } + ] + }; + + // Act + var zipAssert = FileAssertZipAssert.Create(data); + + // Assert + Assert.NotNull(zipAssert); + Assert.Single(zipAssert.Entries); + Assert.Equal("lib/**/*.dll", zipAssert.Entries[0].Pattern); + Assert.Equal(1, zipAssert.Entries[0].Min); + Assert.Null(zipAssert.Entries[0].Max); + } + + /// + /// Verifies that Create throws when data is null. + /// + [Fact] + public void FileAssertZipAssert_Create_NullData_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => FileAssertZipAssert.Create(null!)); + } + + /// + /// Verifies that Create throws when an entry has + /// no pattern. + /// + [Fact] + public void FileAssertZipAssert_Create_EntryMissingPattern_ThrowsInvalidOperationException() + { + // Arrange + var data = new FileAssertZipData + { + Entries = [new FileAssertZipEntryData { Min = 1 }] + }; + + // Act / Assert + Assert.Throws(() => FileAssertZipAssert.Create(data)); + } + + /// + /// Verifies that Run produces no error when the zip archive contains entries that match + /// the pattern and satisfy the count constraints. + /// + [Fact] + public void FileAssertZipAssert_Run_MatchingEntriesMeetConstraints_NoError() + { + // Arrange - create a zip archive containing a matching entry + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFile(tempFile, ["lib/net8.0/MyLib.dll"]); + var data = new FileAssertZipData + { + Entries = + [ + new FileAssertZipEntryData { Pattern = "lib/net8.0/MyLib.dll", Min = 1, Max = 1 } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + // Act + zipAssert.Run(context, tempFile); + + // Assert + Assert.Equal(0, context.ExitCode); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run produces no error when a glob pattern matches multiple entries within + /// the zip archive and the count is within the declared bounds. + /// + [Fact] + public void FileAssertZipAssert_Run_GlobPatternMatchesMultipleEntries_NoError() + { + // Arrange - create a zip archive containing multiple dll entries under lib/ + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFile(tempFile, ["lib/net8.0/MyLib.dll", "lib/net8.0/MyOther.dll"]); + var data = new FileAssertZipData + { + Entries = + [ + new FileAssertZipEntryData { Pattern = "lib/**/*.dll", Min = 1 } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + // Act + zipAssert.Run(context, tempFile); + + // Assert + Assert.Equal(0, context.ExitCode); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run writes an error when the number of matching entries is below + /// the declared minimum count. + /// + [Fact] + public void FileAssertZipAssert_Run_TooFewMatchingEntries_WritesError() + { + // Arrange - create an empty zip archive; the min constraint will be violated + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFile(tempFile, []); + var data = new FileAssertZipData + { + Entries = + [ + new FileAssertZipEntryData { Pattern = "lib/**/*.dll", Min = 1 } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + // Act + zipAssert.Run(context, tempFile); + + // Assert + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run writes an error when the number of matching entries exceeds + /// the declared maximum count. + /// + [Fact] + public void FileAssertZipAssert_Run_TooManyMatchingEntries_WritesError() + { + // Arrange - create a zip archive with two dll entries; max is set to 1 + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFile(tempFile, ["lib/net8.0/MyLib.dll", "lib/net8.0/MyOther.dll"]); + var data = new FileAssertZipData + { + Entries = + [ + new FileAssertZipEntryData { Pattern = "lib/**/*.dll", Max = 1 } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + // Act + zipAssert.Run(context, tempFile); + + // Assert + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run writes an error when the file is not a valid zip archive. + /// + [Fact] + public void FileAssertZipAssert_Run_InvalidZipFile_WritesError() + { + // Arrange - write arbitrary bytes that are not a valid zip archive + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllBytes(tempFile, [0x00, 0x01, 0x02, 0x03]); + var data = new FileAssertZipData + { + Entries = + [ + new FileAssertZipEntryData { Pattern = "**/*.dll", Min = 1 } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + // Act + zipAssert.Run(context, tempFile); + + // Assert - a single parse error should be reported + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run writes an error when the zip file path does not exist. + /// + [Fact] + public void FileAssertZipAssert_Run_NonExistentFile_WritesError() + { + // Arrange - use a path guaranteed not to exist + var missingFile = Path.Combine(Path.GetTempPath(), $"does_not_exist_{Guid.NewGuid():N}.zip"); + var data = new FileAssertZipData + { + Entries = + [ + new FileAssertZipEntryData { Pattern = "**/*.dll", Min = 1 } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + // Act + zipAssert.Run(context, missingFile); + + // Assert - a single I/O error should be reported + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } +} From d3f830db25f68399bc13f8175a54ef453a2a6bc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 21:17:03 +0000 Subject: [PATCH 3/3] fix: address quality issues - reviewmark entry, doc consistency, README table alignment Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- .reviewmark.yaml | 10 ++++++++++ README.md | 2 +- .../file-assert/modeling/file-assert-zip-assert.md | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.reviewmark.yaml b/.reviewmark.yaml index e08e87c..8cad381 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -265,6 +265,16 @@ reviews: - "src/**/Modeling/FileAssertJsonAssert.cs" # implementation - "test/**/Modeling/FileAssertJsonAssertTests.cs" # unit tests + # FileAssert-Modeling-FileAssertZipAssert Review (one per unit) + - id: FileAssert-Modeling-FileAssertZipAssert + title: Review that FileAssert Modeling FileAssertZipAssert Implementation is Correct + paths: + - "docs/reqstream/file-assert/modeling/file-assert-zip-assert.yaml" # requirements + - "docs/design/file-assert/modeling/file-assert-zip-assert.md" # design + - "docs/verification/file-assert/modeling/file-assert-zip-assert.md" # verification + - "src/**/Modeling/FileAssertZipAssert.cs" # implementation + - "test/**/Modeling/FileAssertZipAssertTests.cs" # unit tests + # FileAssert-OTS-BuildMark Review - id: FileAssert-OTS-BuildMark title: Review FileAssert OTS BuildMark Requirements and Verification diff --git a/README.md b/README.md index 69cc7ce..13b5fca 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ tests: | `json[].count` | Exact number of matched JSON nodes | | `json[].min` | Minimum number of matched JSON nodes | | `json[].max` | Maximum number of matched JSON nodes | -| `zip:` | Zip archive entry assertions (fails if file is not a valid zip archive) | +| `zip:` | Zip archive entry assertions (fails if not a valid zip) | | `zip.entries[].pattern` | Glob pattern selecting zip archive entry names | | `zip.entries[].min` | Minimum number of matching zip entries | | `zip.entries[].max` | Maximum number of matching zip entries | diff --git a/docs/design/file-assert/modeling/file-assert-zip-assert.md b/docs/design/file-assert/modeling/file-assert-zip-assert.md index c5fb962..ec783f2 100644 --- a/docs/design/file-assert/modeling/file-assert-zip-assert.md +++ b/docs/design/file-assert/modeling/file-assert-zip-assert.md @@ -61,7 +61,7 @@ Execution proceeds in the following steps: writes the error below and returns immediately. 3. Enumerates all archive entries, normalizing separators to forward slashes and excluding directory entries (names ending with `/`). -4. For each configured `Entry`, uses `Matcher.Match(string.Empty, allEntries)` from +4. For each configured `Entry`, uses `Matcher.Match(".", allEntries)` from `Microsoft.Extensions.FileSystemGlobbing` to count matched entries. 5. Writes an error if the match count is below `Min` or above `Max`.