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