From 9d416af9fc0e9308dff67c40c653c22b072ceb47 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 8 May 2026 17:29:23 +0000
Subject: [PATCH 1/8] Initial plan
From f3a6524bcbf3e6a0d40a3e215f2bb3b0d2147b23 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 8 May 2026 17:39:05 +0000
Subject: [PATCH 2/8] fix: absolute glob-paths now work in FindMatchingFiles
Refactored FindMatchingFiles to detect absolute path patterns via
Path.IsPathRooted() and split them into a root directory plus a
relative pattern (SplitAbsolutePattern). The Matcher now executes
against the correct root directory instead of silently finding nothing.
Also added test Publishing_Run_WithAbsoluteGlobPattern_ReadsMatchingFiles
which verifies that absolute patterns work independently of the cwd.
Agent-Logs-Url: https://github.com/demaconsulting/VersionMark/sessions/75c92412-4558-4f71-b78b-611acc0f2b57
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
src/DemaConsulting.VersionMark/Program.cs | 96 +++++++++++++++++--
.../Publishing/PublishingTests.cs | 44 +++++++++
2 files changed, 130 insertions(+), 10 deletions(-)
diff --git a/src/DemaConsulting.VersionMark/Program.cs b/src/DemaConsulting.VersionMark/Program.cs
index 2c47d9d..016cd61 100644
--- a/src/DemaConsulting.VersionMark/Program.cs
+++ b/src/DemaConsulting.VersionMark/Program.cs
@@ -337,22 +337,98 @@ private static void RunPublish(Context context)
/// List of matching file paths.
private static List FindMatchingFiles(string[] globPatterns)
{
- var matcher = new Matcher();
+ var files = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var relativePatterns = new List();
- // Add all glob patterns to the matcher
foreach (var pattern in globPatterns)
{
- matcher.AddInclude(pattern);
+ if (Path.IsPathRooted(pattern))
+ {
+ // Handle absolute path by extracting the root directory and relative pattern
+ var (rootDir, relativePattern) = SplitAbsolutePattern(pattern);
+ if (Directory.Exists(rootDir))
+ {
+ var matcher = new Matcher();
+ matcher.AddInclude(relativePattern);
+ var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(rootDir)));
+ foreach (var file in result.Files)
+ {
+ files.Add(Path.GetFullPath(Path.Combine(rootDir, file.Path)));
+ }
+ }
+ }
+ else
+ {
+ relativePatterns.Add(pattern);
+ }
+ }
+
+ // Handle all relative patterns together against the current directory
+ if (relativePatterns.Count > 0)
+ {
+ var matcher = new Matcher();
+ foreach (var pattern in relativePatterns)
+ {
+ matcher.AddInclude(pattern);
+ }
+
+ var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(Directory.GetCurrentDirectory())));
+ foreach (var file in result.Files)
+ {
+ files.Add(Path.GetFullPath(file.Path));
+ }
+ }
+
+ return files.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ ///
+ /// Splits an absolute glob pattern into a root directory and a relative pattern.
+ ///
+ /// The absolute glob pattern to split.
+ /// A tuple of (rootDirectory, relativePattern).
+ private static (string rootDir, string relativePattern) SplitAbsolutePattern(string absolutePattern)
+ {
+ var pathRoot = Path.GetPathRoot(absolutePattern) ?? string.Empty;
+
+ // Find the index of the first wildcard character in the pattern
+ var wildcardIndex = absolutePattern.IndexOfAny(['*', '?', '[', '{']);
+
+ if (wildcardIndex < 0)
+ {
+ // No wildcard - treat as a specific file path
+ return (
+ Path.GetDirectoryName(absolutePattern) ?? pathRoot,
+ Path.GetFileName(absolutePattern));
+ }
+
+ // Find the last directory separator before the first wildcard
+ var lastSepIndex = absolutePattern.LastIndexOfAny(
+ [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
+ wildcardIndex);
+
+ if (lastSepIndex < 0)
+ {
+ // No separator before wildcard - use the path root
+ return (pathRoot, absolutePattern[pathRoot.Length..]);
+ }
+
+ var rootDir = absolutePattern[..lastSepIndex];
+ var relativePattern = absolutePattern[(lastSepIndex + 1)..];
+
+ // Handle empty root (Unix paths like /file.json where separator is the very first char)
+ if (string.IsNullOrEmpty(rootDir))
+ {
+ return (pathRoot, relativePattern);
}
- // Execute the match against the current directory
- var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(Directory.GetCurrentDirectory())));
+ // Ensure drive/volume root includes trailing separator (e.g., "C:" → "C:\")
+ if (rootDir == pathRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
+ {
+ return (pathRoot, relativePattern);
+ }
- // Return the full paths of matched files
- return result.Files
- .Select(f => Path.GetFullPath(f.Path))
- .OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
- .ToList();
+ return (rootDir, relativePattern);
}
///
diff --git a/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs b/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs
index 51405d8..cc04a04 100644
--- a/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs
+++ b/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs
@@ -198,6 +198,50 @@ public void Publishing_Run_WithGlobPattern_ReadsMatchingFiles()
}
}
+ ///
+ /// Test that the publishing pipeline accepts absolute glob patterns and reads all matching files.
+ ///
+ [Fact]
+ public void Publishing_Run_WithAbsoluteGlobPattern_ReadsMatchingFiles()
+ {
+ // Arrange - Create a temp directory with JSON files and use an absolute glob pattern to match them
+ var currentDir = Directory.GetCurrentDirectory();
+ var tempDir = PathHelpers.SafePathCombine(Path.GetTempPath(), Path.GetRandomFileName());
+ var reportFile = PathHelpers.SafePathCombine(tempDir, "report.md");
+ try
+ {
+ Directory.CreateDirectory(tempDir);
+ var versionInfo = new VersionInfo("job-abs", new Dictionary { ["dotnet"] = "8.0.100" });
+ versionInfo.SaveToFile(PathHelpers.SafePathCombine(tempDir, "versionmark-abs-job.json"));
+
+ // Use a different working directory to confirm the absolute pattern is not relative to cwd
+ Directory.SetCurrentDirectory(Path.GetTempPath());
+
+ // Build an absolute glob pattern pointing directly into tempDir
+ var absolutePattern = PathHelpers.SafePathCombine(tempDir, "versionmark-*.json");
+ using var context = Context.Create([
+ "--publish", "--report", reportFile, "--silent", "--", absolutePattern
+ ]);
+
+ // Act - Run the publish pipeline with an absolute glob pattern
+ Program.Run(context);
+
+ // Assert - The report should have been generated from the matched file
+ Assert.Equal(0, context.ExitCode);
+ Assert.True(File.Exists(reportFile),
+ "Report file should be created when absolute glob pattern matches files");
+ Assert.Contains("dotnet", File.ReadAllText(reportFile));
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(currentDir);
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+ }
+
///
/// Test that the publishing pipeline reports an error when a JSON file is malformed.
///
From a96f6c8448c32ffd8b266b57600a65da040751c1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 8 May 2026 17:42:26 +0000
Subject: [PATCH 3/8] fix: address code review - remove { wildcard, improve
comments
Agent-Logs-Url: https://github.com/demaconsulting/VersionMark/sessions/75c92412-4558-4f71-b78b-611acc0f2b57
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
src/DemaConsulting.VersionMark/Program.cs | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/src/DemaConsulting.VersionMark/Program.cs b/src/DemaConsulting.VersionMark/Program.cs
index 016cd61..82af7fa 100644
--- a/src/DemaConsulting.VersionMark/Program.cs
+++ b/src/DemaConsulting.VersionMark/Program.cs
@@ -392,7 +392,8 @@ private static (string rootDir, string relativePattern) SplitAbsolutePattern(str
var pathRoot = Path.GetPathRoot(absolutePattern) ?? string.Empty;
// Find the index of the first wildcard character in the pattern
- var wildcardIndex = absolutePattern.IndexOfAny(['*', '?', '[', '{']);
+ // Microsoft.Extensions.FileSystemGlobbing supports *, **, ?, and [abc] ranges
+ var wildcardIndex = absolutePattern.IndexOfAny(['*', '?', '[']);
if (wildcardIndex < 0)
{
@@ -402,7 +403,9 @@ private static (string rootDir, string relativePattern) SplitAbsolutePattern(str
Path.GetFileName(absolutePattern));
}
- // Find the last directory separator before the first wildcard
+ // Find the last directory separator before the first wildcard.
+ // LastIndexOfAny with a start index searches backwards from that position toward the start,
+ // so this finds the rightmost separator that precedes the wildcard character.
var lastSepIndex = absolutePattern.LastIndexOfAny(
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
wildcardIndex);
@@ -422,7 +425,9 @@ private static (string rootDir, string relativePattern) SplitAbsolutePattern(str
return (pathRoot, relativePattern);
}
- // Ensure drive/volume root includes trailing separator (e.g., "C:" → "C:\")
+ // Ensure drive/volume root includes trailing separator.
+ // On Windows, splitting "C:\*.json" at the backslash yields rootDir = "C:" (no trailing
+ // backslash), but DirectoryInfo requires "C:\" to refer to the drive root.
if (rootDir == pathRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
{
return (pathRoot, relativePattern);
From d20c0f3111a06c51fd8240f933175565b9034cc8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 8 May 2026 18:20:03 +0000
Subject: [PATCH 4/8] refactor: move glob-matching logic to
Utilities/GlobMatcher subsystem
- Add Utilities subsystem with GlobMatcher unit
(src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs)
- Update Program.cs to delegate to GlobMatcher.FindMatchingFiles
- Add 8 unit tests in test/.../Utilities/GlobMatcherTests.cs
- Add requirements: docs/reqstream/version-mark/utilities.yaml
and docs/reqstream/version-mark/utilities/glob-matcher.yaml
- Add design docs: docs/design/version-mark/utilities.md
and docs/design/version-mark/utilities/glob-matcher.md
- Add verification docs: docs/verification/version-mark/utilities.md
and docs/verification/version-mark/utilities/glob-matcher.md
- Update requirements.yaml with new includes
- Update docs/design/introduction.md software structure tree
- Update docs/design/version-mark.md publish mode interaction
- Update docs/verification/version-mark.md subsystems list
- Add VersionMark-Utilities review sets to .reviewmark.yaml
Agent-Logs-Url: https://github.com/demaconsulting/VersionMark/sessions/201cbd9d-683b-48b4-a394-9ce761661f2c
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
.reviewmark.yaml | 18 ++
docs/design/introduction.md | 19 +-
docs/design/version-mark.md | 15 +-
docs/design/version-mark/utilities.md | 27 ++
.../version-mark/utilities/glob-matcher.md | 65 +++++
docs/reqstream/version-mark/utilities.yaml | 15 ++
.../version-mark/utilities/glob-matcher.yaml | 31 +++
docs/verification/version-mark.md | 3 +-
docs/verification/version-mark/utilities.md | 51 ++++
.../version-mark/utilities/glob-matcher.md | 47 ++++
requirements.yaml | 2 +
src/DemaConsulting.VersionMark/Program.cs | 111 +--------
.../Utilities/GlobMatcher.cs | 151 ++++++++++++
.../Utilities/GlobMatcherTests.cs | 232 ++++++++++++++++++
14 files changed, 664 insertions(+), 123 deletions(-)
create mode 100644 docs/design/version-mark/utilities.md
create mode 100644 docs/design/version-mark/utilities/glob-matcher.md
create mode 100644 docs/reqstream/version-mark/utilities.yaml
create mode 100644 docs/reqstream/version-mark/utilities/glob-matcher.yaml
create mode 100644 docs/verification/version-mark/utilities.md
create mode 100644 docs/verification/version-mark/utilities/glob-matcher.md
create mode 100644 src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs
create mode 100644 test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
diff --git a/.reviewmark.yaml b/.reviewmark.yaml
index 29d990c..3ceb537 100644
--- a/.reviewmark.yaml
+++ b/.reviewmark.yaml
@@ -191,6 +191,24 @@ reviews:
- "src/**/SelfTest/PathHelpers.cs" # implementation
- "test/**/SelfTest/PathHelpersTests.cs" # unit tests
+ # VersionMark - Utilities
+ - id: VersionMark-Utilities
+ title: Review that VersionMark Utilities Satisfies Subsystem Requirements
+ paths:
+ - "docs/reqstream/version-mark/utilities.yaml"
+ - "docs/design/version-mark/utilities.md"
+ - "docs/verification/version-mark/utilities.md"
+ - "test/**/Utilities/GlobMatcherTests.cs" # subsystem tests (no separate subsystem test file needed)
+
+ - id: VersionMark-Utilities-GlobMatcher
+ title: Review that VersionMark Utilities GlobMatcher Implementation is Correct
+ paths:
+ - "docs/reqstream/version-mark/utilities/glob-matcher.yaml"
+ - "docs/design/version-mark/utilities/glob-matcher.md"
+ - "docs/verification/version-mark/utilities/glob-matcher.md"
+ - "src/**/Utilities/GlobMatcher.cs"
+ - "test/**/Utilities/GlobMatcherTests.cs"
+
# OTS Items
- id: OTS-BuildMark
title: Review of BuildMark OTS verification evidence
diff --git a/docs/design/introduction.md b/docs/design/introduction.md
index 9828179..a160a20 100644
--- a/docs/design/introduction.md
+++ b/docs/design/introduction.md
@@ -15,7 +15,7 @@ The purpose of this document is to:
## Scope
-This document covers the design of five subsystems within VersionMark:
+This document covers the design of six subsystems within VersionMark:
- The **Cli Subsystem**: the `Program` entry point and `Context` class
that handle argument parsing, output routing, and program flow control
@@ -27,6 +27,7 @@ This document covers the design of five subsystems within VersionMark:
version report from captured data
- The **SelfTest Subsystem**: the `Validation` class and `PathHelpers` utility that
together provide built-in verification of the tool's core functionality
+- The **Utilities Subsystem**: the `GlobMatcher` class that provides glob-pattern file matching for use by other subsystems
This document does not cover installation, end-user usage patterns, or the CI/CD pipeline
configuration. Those topics are addressed in the *VersionMark User Guide* and the
@@ -50,9 +51,11 @@ VersionMark (System) Version capture/publish tool
│ └── VersionInfo (Unit) JSON version data record
├── Publishing (Subsystem) Markdown report publishing
│ └── MarkdownFormatter (Unit) Version report formatter
-└── SelfTest (Subsystem) Built-in self-validation
- ├── Validation (Unit) Self-validation runner
- └── PathHelpers (Unit) Safe path combination
+├── SelfTest (Subsystem) Built-in self-validation
+│ ├── Validation (Unit) Self-validation runner
+│ └── PathHelpers (Unit) Safe path combination
+└── Utilities (Subsystem) General-purpose helper utilities
+ └── GlobMatcher (Unit) Glob-pattern file matching
```
Each unit is described in detail in its own chapter within this document.
@@ -74,9 +77,11 @@ src/DemaConsulting.VersionMark/
│ └── VersionInfo.cs — captured version data record
├── Publishing/
│ └── MarkdownFormatter.cs — markdown report generation
-└── SelfTest/
- ├── Validation.cs — self-validation test runner
- └── PathHelpers.cs — safe path utilities
+├── SelfTest/
+│ ├── Validation.cs — self-validation test runner
+│ └── PathHelpers.cs — safe path utilities
+└── Utilities/
+ └── GlobMatcher.cs — glob-pattern file matching
```
The test project mirrors the same layout under `test/DemaConsulting.VersionMark.Tests/`.
diff --git a/docs/design/version-mark.md b/docs/design/version-mark.md
index ee656a9..f7fb723 100644
--- a/docs/design/version-mark.md
+++ b/docs/design/version-mark.md
@@ -59,15 +59,18 @@ Cli Subsystem → Configuration Subsystem → (shell)
### Publish Mode
```text
-Cli Subsystem → Capture Subsystem (VersionInfo.LoadFromFile) → Publishing Subsystem
- ↓
- markdown report file
+Cli Subsystem → Utilities Subsystem (GlobMatcher.FindMatchingFiles)
+ ↓
+ Capture Subsystem (VersionInfo.LoadFromFile) → Publishing Subsystem
+ ↓
+ markdown report file
```
1. The Cli Subsystem (Program) parses arguments and calls `RunPublish`.
-2. `RunPublish` resolves glob patterns, then uses the Capture Subsystem to load each
- JSON file via `VersionInfo.LoadFromFile`.
-3. The Publishing Subsystem (`MarkdownFormatter.Format`) converts the loaded records into
+2. `RunPublish` uses `GlobMatcher.FindMatchingFiles` (Utilities Subsystem) to resolve glob
+ patterns into a concrete list of JSON file paths.
+3. The Capture Subsystem loads each JSON file via `VersionInfo.LoadFromFile`.
+4. The Publishing Subsystem (`MarkdownFormatter.Format`) converts the loaded records into
a markdown string, which is written to the report file.
### Lint Mode
diff --git a/docs/design/version-mark/utilities.md b/docs/design/version-mark/utilities.md
new file mode 100644
index 0000000..714f734
--- /dev/null
+++ b/docs/design/version-mark/utilities.md
@@ -0,0 +1,27 @@
+## Utilities Subsystem
+
+### Overview
+
+The Utilities subsystem provides general-purpose helper classes used by other subsystems
+within VersionMark. It currently consists of one unit: `GlobMatcher`, which implements
+glob-pattern file matching for the Publish mode.
+
+This subsystem satisfies requirement `VersionMark-Utilities-GlobMatch`.
+
+### Units
+
+#### GlobMatcher
+
+The `GlobMatcher` class (`GlobMatcher.cs`) provides glob-pattern file matching. It exposes
+two methods: `FindMatchingFiles`, which accepts an array of glob patterns and returns a
+sorted, deduplicated list of matching file paths; and `SplitAbsolutePattern`, which splits
+an absolute glob pattern into its root directory and relative pattern components.
+
+See *GlobMatcher Unit Design* for the full unit design.
+
+### Subsystem Interactions
+
+`GlobMatcher.FindMatchingFiles` is called by the Cli Subsystem (`Program.RunPublish`) to
+resolve the glob patterns supplied on the command line into a concrete list of JSON capture
+files. The Utilities subsystem has no dependencies on other VersionMark subsystems; it
+depends only on `Microsoft.Extensions.FileSystemGlobbing` for pattern evaluation.
diff --git a/docs/design/version-mark/utilities/glob-matcher.md b/docs/design/version-mark/utilities/glob-matcher.md
new file mode 100644
index 0000000..00d0302
--- /dev/null
+++ b/docs/design/version-mark/utilities/glob-matcher.md
@@ -0,0 +1,65 @@
+### GlobMatcher Unit
+
+#### Overview
+
+`GlobMatcher` is a static utility class that provides glob-pattern file matching. It
+supports both relative patterns (evaluated against the current directory) and absolute
+patterns (evaluated from their own root directory), and returns a sorted, deduplicated
+list of full file paths. It uses `Microsoft.Extensions.FileSystemGlobbing` for pattern
+evaluation.
+
+#### FindMatchingFiles Method
+
+```csharp
+internal static List FindMatchingFiles(string[] globPatterns)
+```
+
+Finds all files matching the specified glob patterns and returns them as a sorted list of
+full paths.
+
+**Processing steps:**
+
+1. Iterate over each pattern in `globPatterns`.
+2. If a pattern is rooted (`Path.IsPathRooted`), call `SplitAbsolutePattern` to obtain the
+ root directory and relative pattern, then use a `Matcher` against that directory.
+3. If the pattern is relative, collect it into a separate list.
+4. After iterating, if any relative patterns were collected, run a single `Matcher` against
+ `Directory.GetCurrentDirectory()` covering all relative patterns.
+5. Combine all matches into a `HashSet` (case-insensitive) to deduplicate, then
+ return the sorted result.
+
+#### SplitAbsolutePattern Helper
+
+```csharp
+internal static (string rootDir, string relativePattern) SplitAbsolutePattern(string absolutePattern)
+```
+
+Splits an absolute glob pattern into its root directory and the relative pattern to be
+passed to the `Matcher`.
+
+**Algorithm:**
+
+1. Determine the path root via `Path.GetPathRoot`.
+2. Find the index of the first wildcard character (`*`, `?`, or `[`).
+3. If no wildcard is found, return `(Path.GetDirectoryName, Path.GetFileName)`.
+4. Find the last directory separator before the wildcard using `LastIndexOfAny` searching
+ backwards from the wildcard position.
+5. Split at that separator, handling the drive-root edge case where the separator is the
+ first character (e.g. `/`) or where the root segment lacks a trailing separator (e.g.
+ `C:` on Windows).
+
+#### Design Decisions
+
+- **Separate absolute and relative handling**: Absolute patterns are rooted at a specific
+ directory and must be evaluated there, while relative patterns are evaluated relative to
+ the current directory. Separating the two cases avoids incorrect matches.
+- **Single Matcher for relative patterns**: Collecting all relative patterns into one
+ `Matcher` run reduces directory enumeration overhead compared to one run per pattern.
+- **Case-insensitive deduplication**: Using a case-insensitive `HashSet` prevents
+ duplicates when patterns overlap or when the file system is case-insensitive.
+- **Sorted output**: Returning a sorted list makes the output deterministic, simplifying
+ testing and producing a consistent report order.
+
+`GlobMatcher` is used by `Program.RunPublish` to resolve command-line glob patterns into
+a concrete file list. This satisfies requirements `VersionMark-GlobMatcher-FindFiles` and
+`VersionMark-GlobMatcher-AbsolutePaths`.
diff --git a/docs/reqstream/version-mark/utilities.yaml b/docs/reqstream/version-mark/utilities.yaml
new file mode 100644
index 0000000..e8495bf
--- /dev/null
+++ b/docs/reqstream/version-mark/utilities.yaml
@@ -0,0 +1,15 @@
+---
+sections:
+ - title: VersionMark Requirements
+ sections:
+ - title: Utilities
+ requirements:
+ - id: VersionMark-Utilities-GlobMatch
+ title: The Utilities subsystem shall provide glob-pattern file matching.
+ justification: |
+ Centralizing glob-pattern file matching in a dedicated subsystem separates
+ the matching concern from the CLI dispatch logic, making both easier to test
+ and maintain independently.
+ children:
+ - VersionMark-GlobMatcher-FindFiles
+ - VersionMark-GlobMatcher-AbsolutePaths
diff --git a/docs/reqstream/version-mark/utilities/glob-matcher.yaml b/docs/reqstream/version-mark/utilities/glob-matcher.yaml
new file mode 100644
index 0000000..a274fb4
--- /dev/null
+++ b/docs/reqstream/version-mark/utilities/glob-matcher.yaml
@@ -0,0 +1,31 @@
+---
+sections:
+ - title: GlobMatcher Unit Requirements
+ requirements:
+ - id: VersionMark-GlobMatcher-FindFiles
+ title: The GlobMatcher class shall find files matching relative glob patterns relative to the current directory.
+ justification: |
+ Publish mode accepts glob patterns supplied on the command line, which are
+ typically relative to the working directory. GlobMatcher must evaluate these
+ relative patterns against the current directory so callers do not need to
+ resolve them manually.
+ tests:
+ - GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles
+ - GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList
+ - GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList
+ - GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles
+
+ - id: VersionMark-GlobMatcher-AbsolutePaths
+ title: >-
+ The GlobMatcher class shall find files matching absolute glob patterns
+ regardless of the current working directory.
+ justification: |
+ CI/CD pipelines frequently pass fully-qualified artifact paths to VersionMark.
+ GlobMatcher must evaluate absolute patterns from their own root directory so
+ that the caller's current working directory does not affect the result.
+ tests:
+ - GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles
+ - GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile
+ - GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles
+ - GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly
+ - GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator
diff --git a/docs/verification/version-mark.md b/docs/verification/version-mark.md
index 162699b..e28ec6c 100644
--- a/docs/verification/version-mark.md
+++ b/docs/verification/version-mark.md
@@ -6,13 +6,14 @@ This section documents the verification design for the VersionMark system. Versi
a .NET global tool that captures tool version information from CI/CD job environments and
publishes consolidated version reports as markdown.
-The verification strategy is organized around five subsystems:
+The verification strategy is organized around six subsystems:
- **Cli** - command-line argument parsing and program dispatch
- **Configuration** - YAML configuration loading and validation
- **Capture** - tool version capture and JSON serialization
- **Publishing** - markdown report generation
- **SelfTest** - built-in self-validation
+- **Utilities** - glob-pattern file matching
## Verification Approach
diff --git a/docs/verification/version-mark/utilities.md b/docs/verification/version-mark/utilities.md
new file mode 100644
index 0000000..e486c03
--- /dev/null
+++ b/docs/verification/version-mark/utilities.md
@@ -0,0 +1,51 @@
+## Utilities Subsystem Verification
+
+### Overview
+
+The Utilities subsystem provides general-purpose helper classes for use within VersionMark.
+It currently consists of one unit: `GlobMatcher` (the glob-pattern file matcher).
+
+Unit-level verification for `GlobMatcher` is in the chapter that follows.
+
+### Verification Approach
+
+Unit tests invoke `GlobMatcher` directly with various pattern and file-system inputs and
+assert on the returned file list. Tests use temporary directories for file-system
+scenarios, ensuring isolation and repeatability across platforms. No external mocks are
+required.
+
+### Test Scenarios
+
+The following test scenarios verify Utilities subsystem requirements:
+
+- **`GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles`**:
+ Relative glob pattern matches files in the current directory.
+- **`GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles`**:
+ Absolute glob pattern matches files regardless of the working directory.
+- **`GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile`**:
+ Absolute path without wildcard returns that single file.
+- **`GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`**:
+ Mixed absolute and relative patterns are combined correctly.
+- **`GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList`**:
+ Empty pattern array returns an empty list.
+- **`GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList`**:
+ Pattern with no matches returns an empty list.
+- **`GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly`**:
+ Pattern with wildcard is split at the last separator before the wildcard.
+- **`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`**:
+ Pattern without wildcard is split at the last separator.
+
+### Dependencies
+
+Tests use temporary directories for file-system scenarios. No external mocks are required.
+
+### Requirements Coverage
+
+The following list maps Utilities subsystem requirements to test scenarios:
+
+- **`VersionMark-Utilities-GlobMatch`**: `GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles`,
+ `GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles`,
+ `GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile`,
+ `GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`,
+ `GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList`,
+ `GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList`
diff --git a/docs/verification/version-mark/utilities/glob-matcher.md b/docs/verification/version-mark/utilities/glob-matcher.md
new file mode 100644
index 0000000..a0019a7
--- /dev/null
+++ b/docs/verification/version-mark/utilities/glob-matcher.md
@@ -0,0 +1,47 @@
+### GlobMatcher Unit Verification
+
+#### Overview
+
+The `GlobMatcher` unit provides `FindMatchingFiles` and `SplitAbsolutePattern` methods for
+glob-pattern file matching. It supports relative and absolute patterns and returns a sorted,
+deduplicated list of full paths. Tests are in `Utilities/GlobMatcherTests.cs`.
+
+#### Test Scenarios
+
+The following test scenarios verify `GlobMatcher`:
+
+- **`GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList`**:
+ An empty pattern array returns an empty list.
+- **`GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList`**:
+ A pattern that matches no files returns an empty list.
+- **`GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles`**:
+ A relative glob pattern is matched against the current directory.
+- **`GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles`**:
+ An absolute glob pattern is matched from its root directory.
+- **`GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile`**:
+ An absolute path with no wildcard returns that single file.
+- **`GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`**:
+ Mixed absolute and relative patterns produce a combined deduplicated result.
+- **`GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly`**:
+ A pattern with a wildcard is split at the last separator before the wildcard.
+- **`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`**:
+ A pattern without a wildcard is split at the final separator.
+
+#### Dependencies
+
+Tests use temporary directories created with `Path.GetTempPath()` for all file-system
+scenarios. No external mocks are required. Tests call `GlobMatcher` methods directly.
+
+#### Requirements Coverage
+
+The following list maps `GlobMatcher` unit requirements to test scenarios:
+
+- **`VersionMark-GlobMatcher-FindFiles`**: `GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles`,
+ `GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList`,
+ `GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList`,
+ `GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`
+- **`VersionMark-GlobMatcher-AbsolutePaths`**: `GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles`,
+ `GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile`,
+ `GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`,
+ `GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly`,
+ `GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`
diff --git a/requirements.yaml b/requirements.yaml
index f932540..242c4a7 100644
--- a/requirements.yaml
+++ b/requirements.yaml
@@ -21,6 +21,8 @@ includes:
- docs/reqstream/version-mark/self-test.yaml
- docs/reqstream/version-mark/self-test/validation.yaml
- docs/reqstream/version-mark/self-test/path-helpers.yaml
+ - docs/reqstream/version-mark/utilities.yaml
+ - docs/reqstream/version-mark/utilities/glob-matcher.yaml
- docs/reqstream/ots/xunit.yaml
- docs/reqstream/ots/reqstream.yaml
- docs/reqstream/ots/buildmark.yaml
diff --git a/src/DemaConsulting.VersionMark/Program.cs b/src/DemaConsulting.VersionMark/Program.cs
index 82af7fa..bdcd7a9 100644
--- a/src/DemaConsulting.VersionMark/Program.cs
+++ b/src/DemaConsulting.VersionMark/Program.cs
@@ -24,8 +24,7 @@
using DemaConsulting.VersionMark.Configuration;
using DemaConsulting.VersionMark.Publishing;
using DemaConsulting.VersionMark.SelfTest;
-using Microsoft.Extensions.FileSystemGlobbing;
-using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
+using DemaConsulting.VersionMark.Utilities;
namespace DemaConsulting.VersionMark;
@@ -299,7 +298,7 @@ private static void RunPublish(Context context)
context.WriteLine($"Searching for JSON files with patterns: {string.Join(", ", globPatterns)}");
// Find matching JSON files using glob patterns
- var jsonFiles = FindMatchingFiles(globPatterns);
+ var jsonFiles = GlobMatcher.FindMatchingFiles(globPatterns);
// Check if any files were found
if (jsonFiles.Count == 0)
@@ -330,112 +329,6 @@ private static void RunPublish(Context context)
}
}
- ///
- /// Finds files matching the specified glob patterns.
- ///
- /// Array of glob patterns to match.
- /// List of matching file paths.
- private static List FindMatchingFiles(string[] globPatterns)
- {
- var files = new HashSet(StringComparer.OrdinalIgnoreCase);
- var relativePatterns = new List();
-
- foreach (var pattern in globPatterns)
- {
- if (Path.IsPathRooted(pattern))
- {
- // Handle absolute path by extracting the root directory and relative pattern
- var (rootDir, relativePattern) = SplitAbsolutePattern(pattern);
- if (Directory.Exists(rootDir))
- {
- var matcher = new Matcher();
- matcher.AddInclude(relativePattern);
- var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(rootDir)));
- foreach (var file in result.Files)
- {
- files.Add(Path.GetFullPath(Path.Combine(rootDir, file.Path)));
- }
- }
- }
- else
- {
- relativePatterns.Add(pattern);
- }
- }
-
- // Handle all relative patterns together against the current directory
- if (relativePatterns.Count > 0)
- {
- var matcher = new Matcher();
- foreach (var pattern in relativePatterns)
- {
- matcher.AddInclude(pattern);
- }
-
- var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(Directory.GetCurrentDirectory())));
- foreach (var file in result.Files)
- {
- files.Add(Path.GetFullPath(file.Path));
- }
- }
-
- return files.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
- }
-
- ///
- /// Splits an absolute glob pattern into a root directory and a relative pattern.
- ///
- /// The absolute glob pattern to split.
- /// A tuple of (rootDirectory, relativePattern).
- private static (string rootDir, string relativePattern) SplitAbsolutePattern(string absolutePattern)
- {
- var pathRoot = Path.GetPathRoot(absolutePattern) ?? string.Empty;
-
- // Find the index of the first wildcard character in the pattern
- // Microsoft.Extensions.FileSystemGlobbing supports *, **, ?, and [abc] ranges
- var wildcardIndex = absolutePattern.IndexOfAny(['*', '?', '[']);
-
- if (wildcardIndex < 0)
- {
- // No wildcard - treat as a specific file path
- return (
- Path.GetDirectoryName(absolutePattern) ?? pathRoot,
- Path.GetFileName(absolutePattern));
- }
-
- // Find the last directory separator before the first wildcard.
- // LastIndexOfAny with a start index searches backwards from that position toward the start,
- // so this finds the rightmost separator that precedes the wildcard character.
- var lastSepIndex = absolutePattern.LastIndexOfAny(
- [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
- wildcardIndex);
-
- if (lastSepIndex < 0)
- {
- // No separator before wildcard - use the path root
- return (pathRoot, absolutePattern[pathRoot.Length..]);
- }
-
- var rootDir = absolutePattern[..lastSepIndex];
- var relativePattern = absolutePattern[(lastSepIndex + 1)..];
-
- // Handle empty root (Unix paths like /file.json where separator is the very first char)
- if (string.IsNullOrEmpty(rootDir))
- {
- return (pathRoot, relativePattern);
- }
-
- // Ensure drive/volume root includes trailing separator.
- // On Windows, splitting "C:\*.json" at the backslash yields rootDir = "C:" (no trailing
- // backslash), but DirectoryInfo requires "C:\" to refer to the drive root.
- if (rootDir == pathRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
- {
- return (pathRoot, relativePattern);
- }
-
- return (rootDir, relativePattern);
- }
-
///
/// Loads VersionInfo instances from the specified JSON files.
///
diff --git a/src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs b/src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs
new file mode 100644
index 0000000..074ae8f
--- /dev/null
+++ b/src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs
@@ -0,0 +1,151 @@
+// 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 Microsoft.Extensions.FileSystemGlobbing;
+using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
+
+namespace DemaConsulting.VersionMark.Utilities;
+
+///
+/// Provides glob-pattern file matching utilities.
+///
+internal static class GlobMatcher
+{
+ ///
+ /// Finds all files matching the specified glob patterns.
+ ///
+ /// Array of glob patterns to match. Patterns may be relative
+ /// (matched against the current directory) or absolute (matched from their root
+ /// directory).
+ ///
+ /// Sorted list of full file paths matching any of the supplied patterns.
+ /// The list is case-insensitively sorted and contains no duplicates.
+ ///
+ internal static List FindMatchingFiles(string[] globPatterns)
+ {
+ var files = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var relativePatterns = new List();
+
+ foreach (var pattern in globPatterns)
+ {
+ if (Path.IsPathRooted(pattern))
+ {
+ // Handle absolute path by extracting the root directory and relative pattern
+ var (rootDir, relativePattern) = SplitAbsolutePattern(pattern);
+ if (Directory.Exists(rootDir))
+ {
+ var matcher = new Matcher();
+ matcher.AddInclude(relativePattern);
+ var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(rootDir)));
+ foreach (var file in result.Files)
+ {
+ files.Add(Path.GetFullPath(Path.Combine(rootDir, file.Path)));
+ }
+ }
+ }
+ else
+ {
+ relativePatterns.Add(pattern);
+ }
+ }
+
+ // Handle all relative patterns together against the current directory
+ if (relativePatterns.Count > 0)
+ {
+ var matcher = new Matcher();
+ foreach (var pattern in relativePatterns)
+ {
+ matcher.AddInclude(pattern);
+ }
+
+ var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(Directory.GetCurrentDirectory())));
+ foreach (var file in result.Files)
+ {
+ files.Add(Path.GetFullPath(file.Path));
+ }
+ }
+
+ return files.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ ///
+ /// Splits an absolute glob pattern into a root directory and a relative pattern.
+ ///
+ /// The absolute glob pattern to split.
+ ///
+ /// A tuple of (rootDir, relativePattern) where rootDir is the
+ /// deepest directory segment before the first wildcard character and
+ /// relativePattern is the remainder of the pattern relative to that directory.
+ ///
+ ///
+ /// The method locates the last directory separator that precedes the first wildcard
+ /// character (*, ?, or [) and uses that position as the
+ /// split point. If no wildcard is present the pattern is treated as a literal file
+ /// path and split at the final separator.
+ ///
+ internal static (string rootDir, string relativePattern) SplitAbsolutePattern(string absolutePattern)
+ {
+ var pathRoot = Path.GetPathRoot(absolutePattern) ?? string.Empty;
+
+ // Find the index of the first wildcard character in the pattern
+ // Microsoft.Extensions.FileSystemGlobbing supports *, **, ?, and [abc] ranges
+ var wildcardIndex = absolutePattern.IndexOfAny(['*', '?', '[']);
+
+ if (wildcardIndex < 0)
+ {
+ // No wildcard - treat as a specific file path
+ return (
+ Path.GetDirectoryName(absolutePattern) ?? pathRoot,
+ Path.GetFileName(absolutePattern));
+ }
+
+ // Find the last directory separator before the first wildcard.
+ // LastIndexOfAny with a start index searches backwards from that position toward the start,
+ // so this finds the rightmost separator that precedes the wildcard character.
+ var lastSepIndex = absolutePattern.LastIndexOfAny(
+ [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
+ wildcardIndex);
+
+ if (lastSepIndex < 0)
+ {
+ // No separator before wildcard - use the path root
+ return (pathRoot, absolutePattern[pathRoot.Length..]);
+ }
+
+ var rootDir = absolutePattern[..lastSepIndex];
+ var relativePattern = absolutePattern[(lastSepIndex + 1)..];
+
+ // Handle empty root (Unix paths like /file.json where separator is the very first char)
+ if (string.IsNullOrEmpty(rootDir))
+ {
+ return (pathRoot, relativePattern);
+ }
+
+ // Ensure drive/volume root includes trailing separator.
+ // On Windows, splitting "C:\*.json" at the backslash yields rootDir = "C:" (no trailing
+ // backslash), but DirectoryInfo requires "C:\" to refer to the drive root.
+ if (rootDir == pathRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
+ {
+ return (pathRoot, relativePattern);
+ }
+
+ return (rootDir, relativePattern);
+ }
+}
diff --git a/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs b/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
new file mode 100644
index 0000000..049744a
--- /dev/null
+++ b/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
@@ -0,0 +1,232 @@
+// 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 DemaConsulting.VersionMark.Utilities;
+
+namespace DemaConsulting.VersionMark.Tests.Utilities;
+
+///
+/// Tests for the GlobMatcher class.
+///
+public class GlobMatcherTests
+{
+ ///
+ /// Test that FindMatchingFiles returns an empty list when given an empty pattern array.
+ ///
+ [Fact]
+ public void GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList()
+ {
+ // Arrange
+ var patterns = Array.Empty();
+
+ // Act
+ var result = GlobMatcher.FindMatchingFiles(patterns);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ ///
+ /// Test that FindMatchingFiles returns an empty list when no files match the pattern.
+ ///
+ [Fact]
+ public void GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList()
+ {
+ // Arrange
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var pattern = Path.Combine(tempDir, "*.nonexistent");
+
+ // Act
+ var result = GlobMatcher.FindMatchingFiles([pattern]);
+
+ // Assert
+ Assert.Empty(result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+
+ ///
+ /// Test that FindMatchingFiles returns matching files when given a relative pattern.
+ ///
+ [Fact]
+ public void GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles()
+ {
+ // Arrange
+ var originalDir = Environment.CurrentDirectory;
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, "test.json"), "{}");
+ Environment.CurrentDirectory = tempDir;
+
+ // Act
+ var result = GlobMatcher.FindMatchingFiles(["*.json"]);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Contains(result, f => f.EndsWith("test.json", StringComparison.OrdinalIgnoreCase));
+ }
+ finally
+ {
+ Environment.CurrentDirectory = originalDir;
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+
+ ///
+ /// Test that FindMatchingFiles returns matching files when given an absolute pattern.
+ ///
+ [Fact]
+ public void GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles()
+ {
+ // Arrange
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, "a.json"), "{}");
+ File.WriteAllText(Path.Combine(tempDir, "b.json"), "{}");
+ var pattern = Path.Combine(tempDir, "*.json");
+
+ // Act
+ var result = GlobMatcher.FindMatchingFiles([pattern]);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, f => f.EndsWith("a.json", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(result, f => f.EndsWith("b.json", StringComparison.OrdinalIgnoreCase));
+ }
+ finally
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+
+ ///
+ /// Test that FindMatchingFiles returns a single file when given an absolute path without a wildcard.
+ ///
+ [Fact]
+ public void GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile()
+ {
+ // Arrange
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var filePath = Path.Combine(tempDir, "single.json");
+ File.WriteAllText(filePath, "{}");
+
+ // Act
+ var result = GlobMatcher.FindMatchingFiles([filePath]);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(Path.GetFullPath(filePath), result[0]);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+
+ ///
+ /// Test that FindMatchingFiles combines results from both absolute and relative patterns.
+ ///
+ [Fact]
+ public void GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles()
+ {
+ // Arrange
+ var originalDir = Environment.CurrentDirectory;
+ var tempDir1 = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ var tempDir2 = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir1);
+ Directory.CreateDirectory(tempDir2);
+ try
+ {
+ // Absolute pattern directory
+ File.WriteAllText(Path.Combine(tempDir1, "abs.json"), "{}");
+
+ // Relative pattern directory (becomes the working directory)
+ File.WriteAllText(Path.Combine(tempDir2, "rel.json"), "{}");
+ Environment.CurrentDirectory = tempDir2;
+
+ var absolutePattern = Path.Combine(tempDir1, "*.json");
+
+ // Act
+ var result = GlobMatcher.FindMatchingFiles([absolutePattern, "*.json"]);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, f => f.EndsWith("abs.json", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(result, f => f.EndsWith("rel.json", StringComparison.OrdinalIgnoreCase));
+ }
+ finally
+ {
+ Environment.CurrentDirectory = originalDir;
+ Directory.Delete(tempDir1, recursive: true);
+ Directory.Delete(tempDir2, recursive: true);
+ }
+ }
+
+ ///
+ /// Test that SplitAbsolutePattern correctly splits a pattern with a wildcard at the last
+ /// separator before the wildcard.
+ ///
+ [Fact]
+ public void GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly()
+ {
+ // Arrange
+ var tempDir = Path.Combine(Path.GetTempPath(), "subdir");
+ var pattern = Path.Combine(tempDir, "*.json");
+
+ // Act
+ var (rootDir, relativePattern) = GlobMatcher.SplitAbsolutePattern(pattern);
+
+ // Assert
+ Assert.Equal(tempDir, rootDir);
+ Assert.Equal("*.json", relativePattern);
+ }
+
+ ///
+ /// Test that SplitAbsolutePattern correctly splits a pattern without a wildcard at the
+ /// final separator.
+ ///
+ [Fact]
+ public void GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator()
+ {
+ // Arrange
+ var tempDir = Path.Combine(Path.GetTempPath(), "subdir");
+ var pattern = Path.Combine(tempDir, "file.json");
+
+ // Act
+ var (rootDir, relativePattern) = GlobMatcher.SplitAbsolutePattern(pattern);
+
+ // Assert
+ Assert.Equal(tempDir, rootDir);
+ Assert.Equal("file.json", relativePattern);
+ }
+}
From ede720ab164f77287f0f8b61197f1a2c617987f9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 8 May 2026 18:38:55 +0000
Subject: [PATCH 5/8] refactor: move PathHelpers from SelfTest to Utilities
subsystem
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Move src/.../SelfTest/PathHelpers.cs → src/.../Utilities/PathHelpers.cs
(namespace: DemaConsulting.VersionMark.Utilities)
- Move test/.../SelfTest/PathHelpersTests.cs → test/.../Utilities/PathHelpersTests.cs
- Update Validation.cs and 5 test files to use new namespace
- Move docs/reqstream/version-mark/self-test/path-helpers.yaml
→ docs/reqstream/version-mark/utilities/path-helpers.yaml
- Add VersionMark-Utilities-SafePath requirement to utilities.yaml
- Update requirements.yaml include path
- Update design/self-test.md, design/utilities.md, design/path-helpers.md
- Update verification/self-test.md, verification/utilities.md, verification/path-helpers.md
- Update introduction.md (tree, folder layout, scope)
- Update .reviewmark.yaml (new VersionMark-Utilities-PathHelpers set)
Agent-Logs-Url: https://github.com/demaconsulting/VersionMark/sessions/136dcc6b-0dbe-4bbd-a8bd-9ce44abfa395
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
.reviewmark.yaml | 19 ++++-----
docs/design/introduction.md | 19 ++++-----
docs/design/version-mark/self-test.md | 20 ++--------
docs/design/version-mark/utilities.md | 23 ++++++++---
.../{self-test => utilities}/path-helpers.md | 5 ++-
docs/reqstream/version-mark/utilities.yaml | 9 +++++
.../path-helpers.yaml | 0
docs/verification/version-mark/self-test.md | 14 ++-----
docs/verification/version-mark/utilities.md | 40 ++++++++++++++++---
.../{self-test => utilities}/path-helpers.md | 2 +-
requirements.yaml | 2 +-
.../SelfTest/Validation.cs | 1 +
.../{SelfTest => Utilities}/PathHelpers.cs | 4 +-
.../Capture/CaptureTests.cs | 2 +-
.../IntegrationTests.cs | 2 +-
.../ProgramTests.cs | 2 +-
.../Publishing/PublishingTests.cs | 2 +-
.../SelfTest/SelfTestTests.cs | 1 +
.../PathHelpersTests.cs | 4 +-
19 files changed, 103 insertions(+), 68 deletions(-)
rename docs/design/version-mark/{self-test => utilities}/path-helpers.md (92%)
rename docs/reqstream/version-mark/{self-test => utilities}/path-helpers.yaml (100%)
rename docs/verification/version-mark/{self-test => utilities}/path-helpers.md (98%)
rename src/DemaConsulting.VersionMark/{SelfTest => Utilities}/PathHelpers.cs (96%)
rename test/DemaConsulting.VersionMark.Tests/{SelfTest => Utilities}/PathHelpersTests.cs (98%)
diff --git a/.reviewmark.yaml b/.reviewmark.yaml
index 3ceb537..7baa139 100644
--- a/.reviewmark.yaml
+++ b/.reviewmark.yaml
@@ -182,15 +182,6 @@ reviews:
- "src/**/SelfTest/Validation.cs" # implementation
- "test/**/SelfTest/SelfTestTests.cs" # subsystem tests (no separate unit test file)
- - id: VersionMark-SelfTest-PathHelpers
- title: Review that VersionMark SelfTest PathHelpers Implementation is Correct
- paths:
- - "docs/reqstream/version-mark/self-test/path-helpers.yaml" # requirements
- - "docs/design/version-mark/self-test/path-helpers.md" # design
- - "docs/verification/version-mark/self-test/path-helpers.md" # verification
- - "src/**/SelfTest/PathHelpers.cs" # implementation
- - "test/**/SelfTest/PathHelpersTests.cs" # unit tests
-
# VersionMark - Utilities
- id: VersionMark-Utilities
title: Review that VersionMark Utilities Satisfies Subsystem Requirements
@@ -199,6 +190,7 @@ reviews:
- "docs/design/version-mark/utilities.md"
- "docs/verification/version-mark/utilities.md"
- "test/**/Utilities/GlobMatcherTests.cs" # subsystem tests (no separate subsystem test file needed)
+ - "test/**/Utilities/PathHelpersTests.cs" # subsystem tests
- id: VersionMark-Utilities-GlobMatcher
title: Review that VersionMark Utilities GlobMatcher Implementation is Correct
@@ -209,6 +201,15 @@ reviews:
- "src/**/Utilities/GlobMatcher.cs"
- "test/**/Utilities/GlobMatcherTests.cs"
+ - id: VersionMark-Utilities-PathHelpers
+ title: Review that VersionMark Utilities PathHelpers Implementation is Correct
+ paths:
+ - "docs/reqstream/version-mark/utilities/path-helpers.yaml" # requirements
+ - "docs/design/version-mark/utilities/path-helpers.md" # design
+ - "docs/verification/version-mark/utilities/path-helpers.md" # verification
+ - "src/**/Utilities/PathHelpers.cs" # implementation
+ - "test/**/Utilities/PathHelpersTests.cs" # unit tests
+
# OTS Items
- id: OTS-BuildMark
title: Review of BuildMark OTS verification evidence
diff --git a/docs/design/introduction.md b/docs/design/introduction.md
index a160a20..e56fad0 100644
--- a/docs/design/introduction.md
+++ b/docs/design/introduction.md
@@ -25,9 +25,10 @@ This document covers the design of six subsystems within VersionMark:
captured version data to and from JSON
- The **Publishing Subsystem**: the `MarkdownFormatter` class that generates the markdown
version report from captured data
-- The **SelfTest Subsystem**: the `Validation` class and `PathHelpers` utility that
- together provide built-in verification of the tool's core functionality
-- The **Utilities Subsystem**: the `GlobMatcher` class that provides glob-pattern file matching for use by other subsystems
+- The **SelfTest Subsystem**: the `Validation` class that provides built-in verification
+ of the tool's core functionality
+- The **Utilities Subsystem**: the `GlobMatcher` class that provides glob-pattern file matching
+ and the `PathHelpers` class that provides safe path combination for use by other subsystems
This document does not cover installation, end-user usage patterns, or the CI/CD pipeline
configuration. Those topics are addressed in the *VersionMark User Guide* and the
@@ -52,10 +53,10 @@ VersionMark (System) Version capture/publish tool
├── Publishing (Subsystem) Markdown report publishing
│ └── MarkdownFormatter (Unit) Version report formatter
├── SelfTest (Subsystem) Built-in self-validation
-│ ├── Validation (Unit) Self-validation runner
-│ └── PathHelpers (Unit) Safe path combination
+│ └── Validation (Unit) Self-validation runner
└── Utilities (Subsystem) General-purpose helper utilities
- └── GlobMatcher (Unit) Glob-pattern file matching
+ ├── GlobMatcher (Unit) Glob-pattern file matching
+ └── PathHelpers (Unit) Safe path combination
```
Each unit is described in detail in its own chapter within this document.
@@ -78,10 +79,10 @@ src/DemaConsulting.VersionMark/
├── Publishing/
│ └── MarkdownFormatter.cs — markdown report generation
├── SelfTest/
-│ ├── Validation.cs — self-validation test runner
-│ └── PathHelpers.cs — safe path utilities
+│ └── Validation.cs — self-validation test runner
└── Utilities/
- └── GlobMatcher.cs — glob-pattern file matching
+ ├── GlobMatcher.cs — glob-pattern file matching
+ └── PathHelpers.cs — safe path utilities
```
The test project mirrors the same layout under `test/DemaConsulting.VersionMark.Tests/`.
diff --git a/docs/design/version-mark/self-test.md b/docs/design/version-mark/self-test.md
index c11ce1b..47f81c6 100644
--- a/docs/design/version-mark/self-test.md
+++ b/docs/design/version-mark/self-test.md
@@ -2,10 +2,8 @@
### Overview
-The SelfTest subsystem provides built-in verification of the tool's core functionality
-and safe path construction for use within that verification. It consists of two units:
-`Validation` (the self-validation test runner) and `PathHelpers` (a safe path combination
-utility used internally by `Validation`).
+The SelfTest subsystem provides built-in verification of the tool's core functionality.
+It consists of one unit: `Validation` (the self-validation test runner).
The validation subsystem is invoked when the `--validate` flag is passed and can write
results to a TRX or JUnit XML file when `--results` is also provided. This satisfies
@@ -22,21 +20,11 @@ writes a structured results file.
See *Validation Unit Design* for the full unit design.
-#### PathHelpers
-
-The `PathHelpers` class (`PathHelpers.cs`) provides a single static method,
-`SafePathCombine`, used internally by `Validation` when constructing paths inside temporary
-directories. It protects against path-traversal attacks by ensuring the resolved combined
-path stays within the intended base directory.
-
-See *PathHelpers Unit Design* for the full unit design.
-
### Subsystem Interactions
`Validation.Run` creates temporary directories via the private `TemporaryDirectory` helper
-class and uses `PathHelpers.SafePathCombine` for all path construction within those
-directories. `PathHelpers` has no dependency on `Validation` and may be considered a pure
-utility within the subsystem.
+class and uses `PathHelpers.SafePathCombine` from the Utilities subsystem for all path
+construction within those directories.
The subsystem depends on:
diff --git a/docs/design/version-mark/utilities.md b/docs/design/version-mark/utilities.md
index 714f734..d7ba00a 100644
--- a/docs/design/version-mark/utilities.md
+++ b/docs/design/version-mark/utilities.md
@@ -3,10 +3,12 @@
### Overview
The Utilities subsystem provides general-purpose helper classes used by other subsystems
-within VersionMark. It currently consists of one unit: `GlobMatcher`, which implements
-glob-pattern file matching for the Publish mode.
+within VersionMark. It consists of two units: `GlobMatcher`, which implements glob-pattern
+file matching for the Publish mode, and `PathHelpers`, which provides safe path combination
+to protect against path-traversal attacks.
-This subsystem satisfies requirement `VersionMark-Utilities-GlobMatch`.
+This subsystem satisfies requirements `VersionMark-Utilities-GlobMatch` and
+`VersionMark-Utilities-SafePath`.
### Units
@@ -19,9 +21,20 @@ an absolute glob pattern into its root directory and relative pattern components
See *GlobMatcher Unit Design* for the full unit design.
+#### PathHelpers
+
+The `PathHelpers` class (`PathHelpers.cs`) provides a single static method,
+`SafePathCombine`, which safely combines a base path and a relative path while
+preventing path-traversal attacks. It is used by `SelfTest.Validation` when
+constructing paths inside temporary directories.
+
+See *PathHelpers Unit Design* for the full unit design.
+
### Subsystem Interactions
`GlobMatcher.FindMatchingFiles` is called by the Cli Subsystem (`Program.RunPublish`) to
resolve the glob patterns supplied on the command line into a concrete list of JSON capture
-files. The Utilities subsystem has no dependencies on other VersionMark subsystems; it
-depends only on `Microsoft.Extensions.FileSystemGlobbing` for pattern evaluation.
+files. `PathHelpers.SafePathCombine` is called by the SelfTest subsystem (`Validation.Run`)
+when constructing paths inside temporary directories. The Utilities subsystem has no
+dependencies on other VersionMark subsystems; it depends only on
+`Microsoft.Extensions.FileSystemGlobbing` for pattern evaluation.
diff --git a/docs/design/version-mark/self-test/path-helpers.md b/docs/design/version-mark/utilities/path-helpers.md
similarity index 92%
rename from docs/design/version-mark/self-test/path-helpers.md
rename to docs/design/version-mark/utilities/path-helpers.md
index 7ff8908..4d41df9 100644
--- a/docs/design/version-mark/self-test/path-helpers.md
+++ b/docs/design/version-mark/utilities/path-helpers.md
@@ -43,5 +43,6 @@ the base directory.
- **No logging or error accumulation**: `SafePathCombine` is a pure utility method that throws
on invalid input; it does not interact with the `Context` or any output mechanism.
-`PathHelpers` is used by `Validation` when constructing paths inside temporary directories
-for self-validation tests. This satisfies requirement `VersionMark-PathHelpers-SafeCombine`.
+`PathHelpers` is used by `SelfTest.Validation` when constructing paths inside temporary
+directories for self-validation tests. This satisfies requirement
+`VersionMark-PathHelpers-SafeCombine`.
diff --git a/docs/reqstream/version-mark/utilities.yaml b/docs/reqstream/version-mark/utilities.yaml
index e8495bf..0a67b5e 100644
--- a/docs/reqstream/version-mark/utilities.yaml
+++ b/docs/reqstream/version-mark/utilities.yaml
@@ -13,3 +13,12 @@ sections:
children:
- VersionMark-GlobMatcher-FindFiles
- VersionMark-GlobMatcher-AbsolutePaths
+
+ - id: VersionMark-Utilities-SafePath
+ title: The Utilities subsystem shall provide safe path combination.
+ justification: |
+ Centralizing safe path combination in a dedicated subsystem makes the
+ path-traversal protection reusable across all subsystems that construct
+ file paths from partially-trusted input.
+ children:
+ - VersionMark-PathHelpers-SafeCombine
diff --git a/docs/reqstream/version-mark/self-test/path-helpers.yaml b/docs/reqstream/version-mark/utilities/path-helpers.yaml
similarity index 100%
rename from docs/reqstream/version-mark/self-test/path-helpers.yaml
rename to docs/reqstream/version-mark/utilities/path-helpers.yaml
diff --git a/docs/verification/version-mark/self-test.md b/docs/verification/version-mark/self-test.md
index b44effd..8bc1785 100644
--- a/docs/verification/version-mark/self-test.md
+++ b/docs/verification/version-mark/self-test.md
@@ -3,13 +3,11 @@
### Overview
The SelfTest subsystem provides built-in self-validation for the VersionMark tool. It
-consists of two units: `Validation` (the self-validation test runner) and `PathHelpers`
-(the safe path combination utility).
+consists of one unit: `Validation` (the self-validation test runner).
Subsystem-level integration tests are in `SelfTest/SelfTestTests.cs` and cover the full
-self-validation workflow including TRX/JUnit results file writing, heading depth handling,
-and path safety verification. Unit-level verification for `Validation` and `PathHelpers`
-is in the chapters that follow.
+self-validation workflow including TRX/JUnit results file writing and heading depth
+handling. Unit-level verification for `Validation` is in the chapter that follows.
### Verification Approach
@@ -21,9 +19,6 @@ files. No external mocks are required.
The following integration test scenarios verify SelfTest subsystem requirements:
-- **`SelfTest_PathHelpers_PathTraversal_ThrowsArgumentException`**: Path traversal attempt is rejected with ArgumentException.
-- **`SelfTest_PathHelpers_ValidRelativePath_ProducesExpectedPath`**: Valid relative path combines correctly.
-- **`SelfTest_PathHelpers_FindsDllInBaseDirectory_FileExists`**: Tool DLL is found in the base directory.
- **`SelfTest_Run_WithResultsFlag_WritesResultsFile`**: `--results` flag writes a TRX results file.
- **`SelfTest_Run_WithResultsXmlFlag_WritesJUnitResultsFile`**: `--results-xml` flag writes a JUnit results file.
- **`SelfTest_Run_WithDepthTwo_WritesHashHashHeader`**: Depth 2 produces a `##` heading in the output.
@@ -41,7 +36,4 @@ The following list maps SelfTest subsystem requirements to test scenarios:
- **`VersionMark-Validate-Lint`**: `SelfTest_Run_WithResultsFlag_WritesResultsFile`
- **`VersionMark-Validate-Results`**: `SelfTest_Run_WithResultsFlag_WritesResultsFile`,
`SelfTest_Run_WithResultsXmlFlag_WritesJUnitResultsFile`
-- **`VersionMark-PathHelpers-SafeCombine`**: `SelfTest_PathHelpers_PathTraversal_ThrowsArgumentException`,
- `SelfTest_PathHelpers_ValidRelativePath_ProducesExpectedPath`,
- `SelfTest_PathHelpers_FindsDllInBaseDirectory_FileExists`
- **`VersionMark-Validation-HeaderDepth`**: `SelfTest_Run_WithDepthTwo_WritesHashHashHeader`
diff --git a/docs/verification/version-mark/utilities.md b/docs/verification/version-mark/utilities.md
index e486c03..7d153e7 100644
--- a/docs/verification/version-mark/utilities.md
+++ b/docs/verification/version-mark/utilities.md
@@ -3,16 +3,16 @@
### Overview
The Utilities subsystem provides general-purpose helper classes for use within VersionMark.
-It currently consists of one unit: `GlobMatcher` (the glob-pattern file matcher).
+It consists of two units: `GlobMatcher` (the glob-pattern file matcher) and `PathHelpers`
+(the safe path combination utility).
-Unit-level verification for `GlobMatcher` is in the chapter that follows.
+Unit-level verification for `GlobMatcher` and `PathHelpers` is in the chapters that follow.
### Verification Approach
-Unit tests invoke `GlobMatcher` directly with various pattern and file-system inputs and
-assert on the returned file list. Tests use temporary directories for file-system
-scenarios, ensuring isolation and repeatability across platforms. No external mocks are
-required.
+Unit tests invoke `GlobMatcher` and `PathHelpers` directly with various inputs and assert
+on the returned results. Tests use temporary directories for file-system scenarios,
+ensuring isolation and repeatability across platforms. No external mocks are required.
### Test Scenarios
@@ -34,6 +34,24 @@ The following test scenarios verify Utilities subsystem requirements:
Pattern with wildcard is split at the last separator before the wildcard.
- **`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`**:
Pattern without wildcard is split at the last separator.
+- **`PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly`**: A simple relative path is combined with the base path.
+- **`PathHelpers_SafePathCombine_PathTraversalWithDoubleDots_ThrowsArgumentException`**:
+ A path beginning with `../` throws ArgumentException.
+- **`PathHelpers_SafePathCombine_DoubleDotsInMiddle_ThrowsArgumentException`**:
+ A path containing `..` in the middle throws ArgumentException.
+- **`PathHelpers_SafePathCombine_AbsolutePath_ThrowsArgumentException`**:
+ A rooted absolute path throws ArgumentException.
+- **`PathHelpers_SafePathCombine_CurrentDirectoryReference_CombinesCorrectly`**:
+ A path containing `.` (current directory) combines correctly.
+- **`PathHelpers_SafePathCombine_NestedPaths_CombinesCorrectly`**: A nested relative path combines correctly.
+- **`PathHelpers_SafePathCombine_EmptyRelativePath_ReturnsBasePath`**:
+ An empty relative path returns the base path unchanged.
+- **`PathHelpers_SafePathCombine_DotDotAsNamePrefix_CombinesCorrectly`**:
+ A filename that starts with `..` but is not a traversal combines correctly.
+- **`PathHelpers_SafePathCombine_NullBasePath_ThrowsArgumentNullException`**:
+ A null base path throws ArgumentNullException.
+- **`PathHelpers_SafePathCombine_NullRelativePath_ThrowsArgumentNullException`**:
+ A null relative path throws ArgumentNullException.
### Dependencies
@@ -49,3 +67,13 @@ The following list maps Utilities subsystem requirements to test scenarios:
`GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`,
`GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList`,
`GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList`
+- **`VersionMark-Utilities-SafePath`**: `PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly`,
+ `PathHelpers_SafePathCombine_PathTraversalWithDoubleDots_ThrowsArgumentException`,
+ `PathHelpers_SafePathCombine_DoubleDotsInMiddle_ThrowsArgumentException`,
+ `PathHelpers_SafePathCombine_AbsolutePath_ThrowsArgumentException`,
+ `PathHelpers_SafePathCombine_CurrentDirectoryReference_CombinesCorrectly`,
+ `PathHelpers_SafePathCombine_NestedPaths_CombinesCorrectly`,
+ `PathHelpers_SafePathCombine_EmptyRelativePath_ReturnsBasePath`,
+ `PathHelpers_SafePathCombine_DotDotAsNamePrefix_CombinesCorrectly`,
+ `PathHelpers_SafePathCombine_NullBasePath_ThrowsArgumentNullException`,
+ `PathHelpers_SafePathCombine_NullRelativePath_ThrowsArgumentNullException`
diff --git a/docs/verification/version-mark/self-test/path-helpers.md b/docs/verification/version-mark/utilities/path-helpers.md
similarity index 98%
rename from docs/verification/version-mark/self-test/path-helpers.md
rename to docs/verification/version-mark/utilities/path-helpers.md
index 80b821d..42e9808 100644
--- a/docs/verification/version-mark/self-test/path-helpers.md
+++ b/docs/verification/version-mark/utilities/path-helpers.md
@@ -5,7 +5,7 @@
The `PathHelpers` unit provides a `SafePathCombine` method that combines a base path and
a relative path while preventing path traversal attacks. It rejects relative paths that
contain `..` components that would escape the base directory, as well as absolute paths.
-Tests are in `SelfTest/PathHelpersTests.cs`.
+Tests are in `Utilities/PathHelpersTests.cs`.
#### Test Scenarios
diff --git a/requirements.yaml b/requirements.yaml
index 242c4a7..18feba9 100644
--- a/requirements.yaml
+++ b/requirements.yaml
@@ -20,9 +20,9 @@ includes:
- docs/reqstream/version-mark/configuration/load.yaml
- docs/reqstream/version-mark/self-test.yaml
- docs/reqstream/version-mark/self-test/validation.yaml
- - docs/reqstream/version-mark/self-test/path-helpers.yaml
- docs/reqstream/version-mark/utilities.yaml
- docs/reqstream/version-mark/utilities/glob-matcher.yaml
+ - docs/reqstream/version-mark/utilities/path-helpers.yaml
- docs/reqstream/ots/xunit.yaml
- docs/reqstream/ots/reqstream.yaml
- docs/reqstream/ots/buildmark.yaml
diff --git a/src/DemaConsulting.VersionMark/SelfTest/Validation.cs b/src/DemaConsulting.VersionMark/SelfTest/Validation.cs
index 5fd63b4..451a167 100644
--- a/src/DemaConsulting.VersionMark/SelfTest/Validation.cs
+++ b/src/DemaConsulting.VersionMark/SelfTest/Validation.cs
@@ -22,6 +22,7 @@
using DemaConsulting.TestResults.IO;
using DemaConsulting.VersionMark.Capture;
using DemaConsulting.VersionMark.Cli;
+using DemaConsulting.VersionMark.Utilities;
namespace DemaConsulting.VersionMark.SelfTest;
diff --git a/src/DemaConsulting.VersionMark/SelfTest/PathHelpers.cs b/src/DemaConsulting.VersionMark/Utilities/PathHelpers.cs
similarity index 96%
rename from src/DemaConsulting.VersionMark/SelfTest/PathHelpers.cs
rename to src/DemaConsulting.VersionMark/Utilities/PathHelpers.cs
index 2cd4380..2014d72 100644
--- a/src/DemaConsulting.VersionMark/SelfTest/PathHelpers.cs
+++ b/src/DemaConsulting.VersionMark/Utilities/PathHelpers.cs
@@ -18,10 +18,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
-namespace DemaConsulting.VersionMark.SelfTest;
+namespace DemaConsulting.VersionMark.Utilities;
///
-/// Helper utilities for safe path operations within the SelfTest subsystem.
+/// Helper utilities for safe path operations.
/// Protects against path-traversal attacks by ensuring combined paths remain
/// within the intended base directory.
///
diff --git a/test/DemaConsulting.VersionMark.Tests/Capture/CaptureTests.cs b/test/DemaConsulting.VersionMark.Tests/Capture/CaptureTests.cs
index b5164c2..0f36ae7 100644
--- a/test/DemaConsulting.VersionMark.Tests/Capture/CaptureTests.cs
+++ b/test/DemaConsulting.VersionMark.Tests/Capture/CaptureTests.cs
@@ -21,7 +21,7 @@
using DemaConsulting.VersionMark.Capture;
using DemaConsulting.VersionMark.Cli;
using DemaConsulting.VersionMark.Configuration;
-using DemaConsulting.VersionMark.SelfTest;
+using DemaConsulting.VersionMark.Utilities;
namespace DemaConsulting.VersionMark.Tests.Capture;
diff --git a/test/DemaConsulting.VersionMark.Tests/IntegrationTests.cs b/test/DemaConsulting.VersionMark.Tests/IntegrationTests.cs
index 3cdbc2e..e9707e2 100644
--- a/test/DemaConsulting.VersionMark.Tests/IntegrationTests.cs
+++ b/test/DemaConsulting.VersionMark.Tests/IntegrationTests.cs
@@ -19,7 +19,7 @@
// SOFTWARE.
using DemaConsulting.VersionMark.Capture;
-using DemaConsulting.VersionMark.SelfTest;
+using DemaConsulting.VersionMark.Utilities;
namespace DemaConsulting.VersionMark.Tests;
diff --git a/test/DemaConsulting.VersionMark.Tests/ProgramTests.cs b/test/DemaConsulting.VersionMark.Tests/ProgramTests.cs
index 5fa94a6..f6f2ab3 100644
--- a/test/DemaConsulting.VersionMark.Tests/ProgramTests.cs
+++ b/test/DemaConsulting.VersionMark.Tests/ProgramTests.cs
@@ -20,7 +20,7 @@
using DemaConsulting.VersionMark.Capture;
using DemaConsulting.VersionMark.Cli;
-using DemaConsulting.VersionMark.SelfTest;
+using DemaConsulting.VersionMark.Utilities;
namespace DemaConsulting.VersionMark.Tests;
diff --git a/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs b/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs
index cc04a04..458aa79 100644
--- a/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs
+++ b/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs
@@ -21,7 +21,7 @@
using DemaConsulting.VersionMark.Capture;
using DemaConsulting.VersionMark.Cli;
using DemaConsulting.VersionMark.Publishing;
-using DemaConsulting.VersionMark.SelfTest;
+using DemaConsulting.VersionMark.Utilities;
namespace DemaConsulting.VersionMark.Tests.Publishing;
diff --git a/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs b/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs
index 286b548..5b8c4b5 100644
--- a/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs
+++ b/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs
@@ -20,6 +20,7 @@
using DemaConsulting.VersionMark.Cli;
using DemaConsulting.VersionMark.SelfTest;
+using DemaConsulting.VersionMark.Utilities;
namespace DemaConsulting.VersionMark.Tests.SelfTest;
diff --git a/test/DemaConsulting.VersionMark.Tests/SelfTest/PathHelpersTests.cs b/test/DemaConsulting.VersionMark.Tests/Utilities/PathHelpersTests.cs
similarity index 98%
rename from test/DemaConsulting.VersionMark.Tests/SelfTest/PathHelpersTests.cs
rename to test/DemaConsulting.VersionMark.Tests/Utilities/PathHelpersTests.cs
index ff86e1e..5074605 100644
--- a/test/DemaConsulting.VersionMark.Tests/SelfTest/PathHelpersTests.cs
+++ b/test/DemaConsulting.VersionMark.Tests/Utilities/PathHelpersTests.cs
@@ -18,9 +18,9 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
-using DemaConsulting.VersionMark.SelfTest;
+using DemaConsulting.VersionMark.Utilities;
-namespace DemaConsulting.VersionMark.Tests.SelfTest;
+namespace DemaConsulting.VersionMark.Tests.Utilities;
///
/// Tests for the PathHelpers class.
From 1227063e5ef667138ca68a7bb9bd8d868a55b9b5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 8 May 2026 19:13:06 +0000
Subject: [PATCH 6/8] fix: apply code review feedback - definition yamls, root
pattern tests, doc fixes
- Update docs/design/definition.yaml: remove stale self-test/path-helpers.md,
add Utilities chapters (utilities.md, glob-matcher.md, path-helpers.md)
- Update docs/verification/definition.yaml: same
- Add GlobMatcher_SplitAbsolutePattern_UnixRootPattern test (non-Windows)
- Add GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern test (Windows-only)
- Update docs/verification/version-mark.md Utilities bullet (GlobMatcher + PathHelpers)
- Fix SelfTestTests.cs class summary (remove stale PathHelpers mention)
- Update verification docs with new test entries
Agent-Logs-Url: https://github.com/demaconsulting/VersionMark/sessions/0780fb23-d543-4557-8c6a-9f63992255a0
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
docs/design/definition.yaml | 4 +-
docs/verification/definition.yaml | 4 +-
docs/verification/version-mark.md | 2 +-
docs/verification/version-mark/utilities.md | 4 ++
.../version-mark/utilities/glob-matcher.md | 8 +++-
.../SelfTest/SelfTestTests.cs | 2 +-
.../Utilities/GlobMatcherTests.cs | 38 +++++++++++++++++++
7 files changed, 57 insertions(+), 5 deletions(-)
diff --git a/docs/design/definition.yaml b/docs/design/definition.yaml
index 378d38e..4874a70 100644
--- a/docs/design/definition.yaml
+++ b/docs/design/definition.yaml
@@ -19,7 +19,9 @@ input-files:
- docs/design/version-mark/publishing/markdown-formatter.md
- docs/design/version-mark/self-test.md
- docs/design/version-mark/self-test/validation.md
- - docs/design/version-mark/self-test/path-helpers.md
+ - docs/design/version-mark/utilities.md
+ - docs/design/version-mark/utilities/glob-matcher.md
+ - docs/design/version-mark/utilities/path-helpers.md
template: template.html
table-of-contents: true
number-sections: true
diff --git a/docs/verification/definition.yaml b/docs/verification/definition.yaml
index 03019e1..d5795ee 100644
--- a/docs/verification/definition.yaml
+++ b/docs/verification/definition.yaml
@@ -27,7 +27,9 @@ input-files:
- docs/verification/version-mark/publishing/markdown-formatter.md
- docs/verification/version-mark/self-test.md
- docs/verification/version-mark/self-test/validation.md
- - docs/verification/version-mark/self-test/path-helpers.md
+ - docs/verification/version-mark/utilities.md
+ - docs/verification/version-mark/utilities/glob-matcher.md
+ - docs/verification/version-mark/utilities/path-helpers.md
- docs/verification/ots.md
- docs/verification/ots/buildmark.md
- docs/verification/ots/fileassert.md
diff --git a/docs/verification/version-mark.md b/docs/verification/version-mark.md
index e28ec6c..9b8e703 100644
--- a/docs/verification/version-mark.md
+++ b/docs/verification/version-mark.md
@@ -13,7 +13,7 @@ The verification strategy is organized around six subsystems:
- **Capture** - tool version capture and JSON serialization
- **Publishing** - markdown report generation
- **SelfTest** - built-in self-validation
-- **Utilities** - glob-pattern file matching
+- **Utilities** - glob-pattern file matching and safe path combination
## Verification Approach
diff --git a/docs/verification/version-mark/utilities.md b/docs/verification/version-mark/utilities.md
index 7d153e7..1f4f624 100644
--- a/docs/verification/version-mark/utilities.md
+++ b/docs/verification/version-mark/utilities.md
@@ -34,6 +34,10 @@ The following test scenarios verify Utilities subsystem requirements:
Pattern with wildcard is split at the last separator before the wildcard.
- **`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`**:
Pattern without wildcard is split at the last separator.
+- **`GlobMatcher_SplitAbsolutePattern_UnixRootPattern_SplitsToRootAndRelative`**:
+ Unix root-level pattern (e.g. `/*.json`) splits to `/` root and relative pattern (non-Windows only).
+- **`GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative`**:
+ Windows drive-root pattern (e.g. `C:\*.json`) splits to `C:\` root and relative pattern (Windows only).
- **`PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly`**: A simple relative path is combined with the base path.
- **`PathHelpers_SafePathCombine_PathTraversalWithDoubleDots_ThrowsArgumentException`**:
A path beginning with `../` throws ArgumentException.
diff --git a/docs/verification/version-mark/utilities/glob-matcher.md b/docs/verification/version-mark/utilities/glob-matcher.md
index a0019a7..d055fbf 100644
--- a/docs/verification/version-mark/utilities/glob-matcher.md
+++ b/docs/verification/version-mark/utilities/glob-matcher.md
@@ -26,6 +26,10 @@ The following test scenarios verify `GlobMatcher`:
A pattern with a wildcard is split at the last separator before the wildcard.
- **`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`**:
A pattern without a wildcard is split at the final separator.
+- **`GlobMatcher_SplitAbsolutePattern_UnixRootPattern_SplitsToRootAndRelative`**:
+ A Unix root-level pattern (e.g. `/*.json`) splits to `/` root and relative pattern (non-Windows only).
+- **`GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative`**:
+ A Windows drive-root pattern (e.g. `C:\*.json`) splits to `C:\` root and relative pattern (Windows only).
#### Dependencies
@@ -44,4 +48,6 @@ The following list maps `GlobMatcher` unit requirements to test scenarios:
`GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile`,
`GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`,
`GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly`,
- `GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`
+ `GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`,
+ `GlobMatcher_SplitAbsolutePattern_UnixRootPattern_SplitsToRootAndRelative`,
+ `GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative`
diff --git a/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs b/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs
index 5b8c4b5..f05f053 100644
--- a/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs
+++ b/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs
@@ -25,7 +25,7 @@
namespace DemaConsulting.VersionMark.Tests.SelfTest;
///
-/// Subsystem tests for the SelfTest subsystem (Validation and PathHelpers working together).
+/// Subsystem tests for the SelfTest subsystem.
///
public class SelfTestTests
{
diff --git a/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs b/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
index 049744a..fe76194 100644
--- a/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
+++ b/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
@@ -229,4 +229,42 @@ public void GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLast
Assert.Equal(tempDir, rootDir);
Assert.Equal("file.json", relativePattern);
}
+
+ ///
+ /// Test that SplitAbsolutePattern correctly handles a Unix root-level pattern like /*.json,
+ /// returning "/" as the root directory and "*.json" as the relative pattern.
+ ///
+ [Fact]
+ public void GlobMatcher_SplitAbsolutePattern_UnixRootPattern_SplitsToRootAndRelative()
+ {
+ // Arrange
+ Assert.SkipUnless(!OperatingSystem.IsWindows(), "Unix root paths are not applicable on Windows");
+ const string pattern = "/*.json";
+
+ // Act
+ var (rootDir, relativePattern) = GlobMatcher.SplitAbsolutePattern(pattern);
+
+ // Assert
+ Assert.Equal("/", rootDir);
+ Assert.Equal("*.json", relativePattern);
+ }
+
+ ///
+ /// Test that SplitAbsolutePattern correctly handles a Windows drive-root pattern like C:\*.json,
+ /// returning "C:\" as the root directory and "*.json" as the relative pattern.
+ ///
+ [Fact]
+ public void GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative()
+ {
+ // Arrange
+ Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows drive-root paths are only applicable on Windows");
+ const string pattern = @"C:\*.json";
+
+ // Act
+ var (rootDir, relativePattern) = GlobMatcher.SplitAbsolutePattern(pattern);
+
+ // Assert
+ Assert.Equal(@"C:\", rootDir);
+ Assert.Equal("*.json", relativePattern);
+ }
}
From d894e845130af6086cdb146002f16d2b1fa43181 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 8 May 2026 19:35:29 +0000
Subject: [PATCH 7/8] test: replace skipped Unix-only root pattern test with
cross-platform equivalent
/*.json is rooted on both Unix and Windows (.NET returns "/" as the path
root on both platforms), so no skip is needed. Rename the test method from
UnixRootPattern to ForwardSlashRootPattern to make the intent clear. Update
verification docs to match.
Agent-Logs-Url: https://github.com/demaconsulting/VersionMark/sessions/53701e75-7c7d-4d89-a994-c535fecdc7fc
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
docs/verification/version-mark/utilities.md | 4 ++--
docs/verification/version-mark/utilities/glob-matcher.md | 6 +++---
.../Utilities/GlobMatcherTests.cs | 8 ++++----
3 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/docs/verification/version-mark/utilities.md b/docs/verification/version-mark/utilities.md
index 1f4f624..78fde97 100644
--- a/docs/verification/version-mark/utilities.md
+++ b/docs/verification/version-mark/utilities.md
@@ -34,8 +34,8 @@ The following test scenarios verify Utilities subsystem requirements:
Pattern with wildcard is split at the last separator before the wildcard.
- **`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`**:
Pattern without wildcard is split at the last separator.
-- **`GlobMatcher_SplitAbsolutePattern_UnixRootPattern_SplitsToRootAndRelative`**:
- Unix root-level pattern (e.g. `/*.json`) splits to `/` root and relative pattern (non-Windows only).
+- **`GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRootAndRelative`**:
+ Root-relative forward-slash pattern (e.g. `/*.json`) splits to `/` root and relative pattern on all platforms.
- **`GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative`**:
Windows drive-root pattern (e.g. `C:\*.json`) splits to `C:\` root and relative pattern (Windows only).
- **`PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly`**: A simple relative path is combined with the base path.
diff --git a/docs/verification/version-mark/utilities/glob-matcher.md b/docs/verification/version-mark/utilities/glob-matcher.md
index d055fbf..7e18820 100644
--- a/docs/verification/version-mark/utilities/glob-matcher.md
+++ b/docs/verification/version-mark/utilities/glob-matcher.md
@@ -26,8 +26,8 @@ The following test scenarios verify `GlobMatcher`:
A pattern with a wildcard is split at the last separator before the wildcard.
- **`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`**:
A pattern without a wildcard is split at the final separator.
-- **`GlobMatcher_SplitAbsolutePattern_UnixRootPattern_SplitsToRootAndRelative`**:
- A Unix root-level pattern (e.g. `/*.json`) splits to `/` root and relative pattern (non-Windows only).
+- **`GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRootAndRelative`**:
+ A root-relative forward-slash pattern (e.g. `/*.json`) splits to `/` root and relative pattern on all platforms.
- **`GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative`**:
A Windows drive-root pattern (e.g. `C:\*.json`) splits to `C:\` root and relative pattern (Windows only).
@@ -49,5 +49,5 @@ The following list maps `GlobMatcher` unit requirements to test scenarios:
`GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`,
`GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly`,
`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`,
- `GlobMatcher_SplitAbsolutePattern_UnixRootPattern_SplitsToRootAndRelative`,
+ `GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRootAndRelative`,
`GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative`
diff --git a/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs b/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
index fe76194..846acbc 100644
--- a/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
+++ b/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
@@ -231,14 +231,14 @@ public void GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLast
}
///
- /// Test that SplitAbsolutePattern correctly handles a Unix root-level pattern like /*.json,
- /// returning "/" as the root directory and "*.json" as the relative pattern.
+ /// Test that SplitAbsolutePattern correctly handles a root-relative pattern using a forward slash
+ /// (e.g. /*.json), returning the path root as the directory and "*.json" as the relative pattern.
+ /// This covers the empty-rootDir fallback branch and runs on all platforms.
///
[Fact]
- public void GlobMatcher_SplitAbsolutePattern_UnixRootPattern_SplitsToRootAndRelative()
+ public void GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRootAndRelative()
{
// Arrange
- Assert.SkipUnless(!OperatingSystem.IsWindows(), "Unix root paths are not applicable on Windows");
const string pattern = "/*.json";
// Act
From 1b51ebca63730c5291c1816ba58b2cea25b6c7de Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 8 May 2026 20:50:14 +0000
Subject: [PATCH 8/8] fix: correct platform-specific path root in forward-slash
pattern test; use fs-aware comparer
On Windows, Path.GetPathRoot("/*.json") returns "\" (backslash), not "/".
Fix the ForwardSlashRootPattern test to assert the platform-specific expected
root so it passes on both Unix and Windows.
Also fix FindMatchingFiles to use a file-system-appropriate comparer for
the dedup HashSet and sort: OrdinalIgnoreCase on Windows (case-insensitive
FS), Ordinal on Linux/macOS (case-sensitive FS). This prevents incorrectly
deduplicating distinct files like a.json and A.json on Linux.
Update verification docs to document the platform-specific root behaviour.
Agent-Logs-Url: https://github.com/demaconsulting/VersionMark/sessions/c33a127e-f75e-4364-93c4-034a38823555
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
docs/verification/version-mark/utilities.md | 3 ++-
.../version-mark/utilities/glob-matcher.md | 3 ++-
.../Utilities/GlobMatcher.cs | 12 +++++++++---
.../Utilities/GlobMatcherTests.cs | 10 +++++++---
4 files changed, 20 insertions(+), 8 deletions(-)
diff --git a/docs/verification/version-mark/utilities.md b/docs/verification/version-mark/utilities.md
index 78fde97..03ad757 100644
--- a/docs/verification/version-mark/utilities.md
+++ b/docs/verification/version-mark/utilities.md
@@ -35,7 +35,8 @@ The following test scenarios verify Utilities subsystem requirements:
- **`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`**:
Pattern without wildcard is split at the last separator.
- **`GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRootAndRelative`**:
- Root-relative forward-slash pattern (e.g. `/*.json`) splits to `/` root and relative pattern on all platforms.
+ Root-relative forward-slash pattern (e.g. `/*.json`) splits to the platform path root (`/` on Unix,
+ `\` on Windows) and relative pattern on all platforms.
- **`GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative`**:
Windows drive-root pattern (e.g. `C:\*.json`) splits to `C:\` root and relative pattern (Windows only).
- **`PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly`**: A simple relative path is combined with the base path.
diff --git a/docs/verification/version-mark/utilities/glob-matcher.md b/docs/verification/version-mark/utilities/glob-matcher.md
index 7e18820..5d16127 100644
--- a/docs/verification/version-mark/utilities/glob-matcher.md
+++ b/docs/verification/version-mark/utilities/glob-matcher.md
@@ -27,7 +27,8 @@ The following test scenarios verify `GlobMatcher`:
- **`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`**:
A pattern without a wildcard is split at the final separator.
- **`GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRootAndRelative`**:
- A root-relative forward-slash pattern (e.g. `/*.json`) splits to `/` root and relative pattern on all platforms.
+ A root-relative forward-slash pattern (e.g. `/*.json`) splits to the platform path root (`/` on Unix,
+ `\` on Windows) and relative pattern on all platforms.
- **`GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative`**:
A Windows drive-root pattern (e.g. `C:\*.json`) splits to `C:\` root and relative pattern (Windows only).
diff --git a/src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs b/src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs
index 074ae8f..42c7502 100644
--- a/src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs
+++ b/src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs
@@ -36,11 +36,17 @@ internal static class GlobMatcher
/// directory).
///
/// Sorted list of full file paths matching any of the supplied patterns.
- /// The list is case-insensitively sorted and contains no duplicates.
+ /// Deduplication and sort use the file-system-appropriate comparer (ordinal
+ /// ignore-case on Windows, ordinal on case-sensitive systems).
///
internal static List FindMatchingFiles(string[] globPatterns)
{
- var files = new HashSet(StringComparer.OrdinalIgnoreCase);
+ // Use a comparer that matches the underlying file-system's case-sensitivity so that
+ // deduplication is correct: case-insensitive on Windows, case-sensitive elsewhere.
+ var fsComparer = OperatingSystem.IsWindows()
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal;
+ var files = new HashSet(fsComparer);
var relativePatterns = new List();
foreach (var pattern in globPatterns)
@@ -82,7 +88,7 @@ internal static List FindMatchingFiles(string[] globPatterns)
}
}
- return files.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
+ return files.OrderBy(f => f, fsComparer).ToList();
}
///
diff --git a/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs b/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
index 846acbc..76eccb8 100644
--- a/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
+++ b/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs
@@ -232,8 +232,8 @@ public void GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLast
///
/// Test that SplitAbsolutePattern correctly handles a root-relative pattern using a forward slash
- /// (e.g. /*.json), returning the path root as the directory and "*.json" as the relative pattern.
- /// This covers the empty-rootDir fallback branch and runs on all platforms.
+ /// (e.g. /*.json), returning the platform path root as the directory and "*.json" as the relative
+ /// pattern. This covers the empty-rootDir fallback branch and runs on all platforms.
///
[Fact]
public void GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRootAndRelative()
@@ -241,11 +241,15 @@ public void GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRoo
// Arrange
const string pattern = "/*.json";
+ // The path root is platform-dependent: "/" on Unix, "\" on Windows (where a leading forward
+ // slash is treated as a drive-relative absolute path rooted at the current drive's root).
+ var expectedRoot = OperatingSystem.IsWindows() ? @"\" : "/";
+
// Act
var (rootDir, relativePattern) = GlobMatcher.SplitAbsolutePattern(pattern);
// Assert
- Assert.Equal("/", rootDir);
+ Assert.Equal(expectedRoot, rootDir);
Assert.Equal("*.json", relativePattern);
}