diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 1447205..9cad65f 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -325,6 +325,13 @@ reviews: - docs/design/ots/test-results.md - docs/verification/ots/test-results.md + - id: OTS-FileSystemGlobbing + title: Review that FileSystemGlobbing Provides Required Functionality + paths: + - docs/reqstream/ots/filesystem-globbing.yaml + - docs/design/ots/filesystem-globbing.md + - docs/verification/ots/filesystem-globbing.md + # Shared Packages - id: Shared-SarifMark title: Review that Shared SarifMark Package Provides Required Functionality diff --git a/README.md b/README.md index 54f960c..2684b81 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,9 @@ Options: --enforce Return non-zero exit code if issues found --log Write output to log file --sarif SARIF file to process + --exclude Exclude findings whose location matches glob (repeatable) --report Export analysis results to markdown file - --depth Markdown header depth for report (default: 1) + --depth Markdown header depth for report (1-6, default: 1) --heading Custom heading for report (default: [ToolName] Analysis) ``` @@ -133,6 +134,12 @@ sarifmark --sarif analysis.sarif --report report.md sarifmark --sarif analysis.sarif --report report.md --heading "Code Quality Analysis" ``` +**Exclude generated code from a report:** + +```bash +sarifmark --sarif analysis.sarif --report report.md --exclude "**/bin/**" --exclude "**/obj/**" +``` + **Enforce quality gate in CI/CD:** ```bash @@ -273,3 +280,4 @@ SarifMark is built with the following open-source projects: - [.NET](https://dotnet.microsoft.com/) - Cross-platform framework for building applications - [SARIF](https://sarifweb.azurewebsites.net/) - Static Analysis Results Interchange Format specification - [DemaConsulting.TestResults](https://github.com/demaconsulting/TestResults) - Test results parsing library +- [Microsoft.Extensions.FileSystemGlobbing](https://www.nuget.org/packages/Microsoft.Extensions.FileSystemGlobbing) - Glob pattern matching used by `--exclude` diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 3dca8cf..87b73f3 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -26,9 +26,9 @@ Local items: OTS items: -- **BuildMark**, **DemaConsulting.TestResults**, **FileAssert**, **Pandoc**, **ReqStream**, **ReviewMark**, **SonarMark**, - **SysML2Tools**, **VersionMark**, **WeasyPrint**, **xUnit v3**: integration and usage design for each OTS - software item used in the project pipeline. +- **BuildMark**, **DemaConsulting.TestResults**, **FileAssert**, **Microsoft.Extensions.FileSystemGlobbing**, + **Pandoc**, **ReqStream**, **ReviewMark**, **SonarMark**, **SysML2Tools**, **VersionMark**, **WeasyPrint**, + **xUnit v3**: integration and usage design for each OTS software item used in the project pipeline. Shared packages: diff --git a/docs/design/ots.md b/docs/design/ots.md index 1c73cda..c06d2df 100644 --- a/docs/design/ots.md +++ b/docs/design/ots.md @@ -1,9 +1,10 @@ # OTS Dependencies -SarifMark uses eleven OTS software items: ten DEMA Consulting pipeline tools and the -xUnit v3 testing framework. All eleven items are consumed as .NET tools or NuGet packages -and are managed through the local tool manifest and the project dependency lock files. -Per-item integration designs are documented in the `ots/` sub-folder. +SarifMark uses twelve OTS software items: ten DEMA Consulting pipeline tools, the +xUnit v3 testing framework, and Microsoft.Extensions.FileSystemGlobbing. All twelve items +are consumed as .NET tools or NuGet packages and are managed through the local tool +manifest and the project dependency lock files. Per-item integration designs are +documented in the `ots/` sub-folder. ## Selection Criteria @@ -22,6 +23,11 @@ Testing framework selection (xUnit v3) is based on native TRX output support, co with the VSTest adapter required by ReqStream, and the established ecosystem around xUnit in the .NET community. +Microsoft.Extensions.FileSystemGlobbing is selected because it is the Microsoft-owned +globbing engine already used throughout the .NET ecosystem, giving users glob semantics +they are already familiar with and avoiding hand-rolled glob parsing logic within +SarifMark. + ## Version Management Policy OTS package versions are managed through Dependabot pull requests for NuGet and Node.js @@ -39,7 +45,8 @@ versions are pinned in the local tool manifest. All OTS items are consumed as CLI tools invoked from CI/CD pipeline scripts, as a NuGet package referenced directly by the main project (DemaConsulting.TestResults, used by the -SelfTest subsystem to collect and serialize self-validation results), or as a NuGet package +SelfTest subsystem to collect and serialize self-validation results; Microsoft.Extensions.FileSystemGlobbing, +used by `SarifResults.Exclude` to filter findings by glob pattern), or as a NuGet package referenced by the test project (xUnit v3). No wrapper classes are introduced at the application level; tools are invoked directly via `dotnet tool run` or their shell command, and packages are referenced through standard NuGet project references. diff --git a/docs/design/ots/filesystem-globbing.md b/docs/design/ots/filesystem-globbing.md new file mode 100644 index 0000000..4456485 --- /dev/null +++ b/docs/design/ots/filesystem-globbing.md @@ -0,0 +1,58 @@ +## Microsoft.Extensions.FileSystemGlobbing + +### Purpose + +`Microsoft.Extensions.FileSystemGlobbing` is a NuGet package produced by Microsoft. It provides a +glob-pattern matching engine (`Matcher`) that determines whether a candidate path matches one or +more include patterns (including `*`, `?`, and recursive `**` wildcards). SarifMark uses it to +implement the `--exclude` CLI option, which removes SARIF findings whose `Uri` matches a +user-supplied glob pattern before enforcement and report generation. + +The package was chosen because it is the Microsoft-owned globbing engine already used throughout +the .NET ecosystem (MSBuild item globs, `dotnet watch`, ASP.NET Core static file providers), which +avoids hand-rolling glob parsing and matching logic and gives users glob semantics they are +already familiar with. + +### Classification + +`Microsoft.Extensions.FileSystemGlobbing` is produced and released independently by Microsoft as +part of the `Microsoft.Extensions` family of packages. It is not part of the SarifMark product and +its internal design requirements do not drive SarifMark requirements. It is therefore classified +as an OTS software item. + +### Features Used + +- **`Matcher`** — the matching engine. One instance is constructed per call to + `SarifResults.Exclude`, with one `AddInclude(pattern)` call per supplied `--exclude` pattern. +- **`AddInclude(string pattern)`** — registers a glob pattern that a candidate path must match. +- **`MatcherExtensions.Match(this Matcher, string searchDirectory, IEnumerable files)`** — + the in-memory matching overload used to test a single candidate `Uri` against the registered + patterns. This overload never touches the real filesystem; it evaluates the supplied strings + directly, so SARIF `Uri` values do not need to correspond to files that exist on disk. + +### Integration Pattern + +`Microsoft.Extensions.FileSystemGlobbing` is referenced as a `PackageReference` in the production +project `src/DemaConsulting.SarifMark/DemaConsulting.SarifMark.csproj`. No initialization or +configuration is required; the `Matcher` type is used directly. + +Inside `SarifResults.Exclude`, a single `Matcher` is constructed and one `AddInclude` call is made +per pattern in the supplied glob list. Each finding's non-null `Uri` is then tested individually +against the matcher using a fixed root of `"/"` (via `matcher.Match("/", [uri]).HasMatches`) — +this root value was chosen because it was empirically observed to consistently match relative +paths, `file://` URIs, Unix absolute paths, and Windows drive-letter absolute paths (with either +`/` or `\` separators), whereas the no-root overload resolves paths relative to the current +working directory and silently fails to match absolute paths outside it. Findings are matched one +at a time (rather than batched) because batch matching was observed to mangle Windows +drive-letter prefixes in the returned `Path`/`Stem` values, making result correlation unreliable. +Findings with a `null` `Uri` are always retained and are never passed to the matcher. Matching is +case-insensitive by default (the default `Matcher()` constructor does not specify a +`StringComparison`), which was confirmed during implementation and is documented as the observed +behavior rather than assumed. + +`Program.ProcessSarifAnalysis` invokes `SarifResults.Exclude` only when `context.ExcludeGlobs` is +non-empty. No error handling is required beyond what `Matcher`/`AddInclude` already provide; +malformed glob patterns do not throw during registration, and unmatched patterns simply result in +no findings being excluded. + +There are no global initialization, thread-affinity, or disposal requirements. diff --git a/docs/design/sarifmark.md b/docs/design/sarifmark.md index 299dce7..f12fd74 100644 --- a/docs/design/sarifmark.md +++ b/docs/design/sarifmark.md @@ -51,7 +51,9 @@ provides path-safety helpers shared across the system. - *Type*: CLI - *Role*: Provider (the tool accepts arguments from the shell) - *Contract*: Accepts flags and parameters (`--sarif`, `--report`, `--depth`, `--heading`, - `--validate`, `--results`, `--enforce`, `--log`, `--silent`, `--version`, `--help`). + `--exclude`, `--validate`, `--results`, `--enforce`, `--log`, `--silent`, `--version`, `--help`). + `--exclude ` is repeatable and removes findings whose `Uri` matches the supplied + glob pattern before enforcement and report generation. `--report-depth` is a **deprecated** alias for `--depth` and `--result` is a **deprecated** alias for `--results`; both are accepted identically to their canonical forms but are intentionally omitted from the `--help` output. @@ -122,6 +124,8 @@ provides path-safety helpers shared across the system. see *WeasyPrint Integration Design* - **DemaConsulting.TestResults**: the OTS package used by the self-validation subsystem to collect, format, and serialize test results — see *TestResults Integration Design* +- **Microsoft.Extensions.FileSystemGlobbing**: the OTS package used by `SarifResults.Exclude` to match finding + `Uri` values against user-supplied `--exclude` glob patterns — see *FileSystemGlobbing Integration Design* - **SarifMark**: a released version of SarifMark itself, invoked as a shared package in the CI pipeline to generate the CodeQL quality report — see *SarifMark Shared Package Integration Design* @@ -139,11 +143,13 @@ The primary analysis data flow from SARIF input to markdown output: 4. In analysis mode, `SarifResults.Read` validates the file path, parses the JSON, validates the SARIF structure, and constructs an immutable graph of `SarifRun` and `SarifFinding` records. -5. `SarifResults.ToMarkdown` traverses the record graph and produces a UTF-8 markdown string. -6. If `--report` was supplied, the markdown string is written to the specified file with +5. If `--exclude` glob patterns were supplied, `SarifResults.Exclude` removes findings whose + `Uri` matches any of the patterns before enforcement or report generation. +6. `SarifResults.ToMarkdown` traverses the record graph and produces a UTF-8 markdown string. +7. If `--report` was supplied, the markdown string is written to the specified file with `File.WriteAllText`. -7. If `--enforce` is set and issues were found, `Context.WriteError` sets the exit code to 1. -8. `Program.Main` returns `Context.ExitCode` to the shell. +8. If `--enforce` is set and issues were found, `Context.WriteError` sets the exit code to 1. +9. `Program.Main` returns `Context.ExitCode` to the shell. The self-validation flow is a separate path in step 3 where `Validation.Run` exercises the analysis flow end-to-end using a mock SARIF file and verifies the output. diff --git a/docs/design/sarifmark/cli.md b/docs/design/sarifmark/cli.md index c3a13c4..20ffe06 100644 --- a/docs/design/sarifmark/cli.md +++ b/docs/design/sarifmark/cli.md @@ -49,7 +49,8 @@ command-line arguments. - *Type*: In-process .NET instance properties - *Role*: Provider - *Contract*: Exposes `Version`, `Help`, `Silent`, `Validate`, `Enforce` (bool); - `SarifFile`, `ReportFile`, `Heading`, `ResultsFile` (string?); `Depth` (int); `ExitCode` (int). + `SarifFile`, `ReportFile`, `Heading`, `ResultsFile` (string?); `Depth` (int); + `ExcludeGlobs` (`IReadOnlyList`, default empty); `ExitCode` (int). All values are set during `Create` and are immutable after construction. - *Constraints*: Properties are read-only after construction; `Depth` must be a positive integer supplied after `--depth` (or legacy `--report-depth`). diff --git a/docs/design/sarifmark/cli/context.md b/docs/design/sarifmark/cli/context.md index 2efac5f..4715941 100644 --- a/docs/design/sarifmark/cli/context.md +++ b/docs/design/sarifmark/cli/context.md @@ -44,6 +44,11 @@ When null, the report heading defaults to `"[ToolName] Analysis"`. deprecated alias `--result`; `null` when not provided. The deprecated alias is accepted for backwards compatibility but is intentionally omitted from `--help` output. +**ExcludeGlobs**: `IReadOnlyList` — File-glob exclusion patterns supplied via one or +more `--exclude` parameters; default empty list. One entry is appended per occurrence of the +flag. Consumed by `SarifResults.Exclude` to remove matching findings before enforcement and +report generation. + **ExitCode**: `int` — Returns `0` until `WriteError` is called; returns `1` thereafter. Derived from the internal `_hasErrors` flag. @@ -55,7 +60,8 @@ Derived from the internal `_hasErrors` flag. - *Returns*: `Context` — fully initialized instance - *Preconditions*: `args` is not null. - *Postconditions*: All properties are set from `args`; if `--log` was specified the - log `StreamWriter` is open and `AutoFlush` is `true`. + log `StreamWriter` is open and `AutoFlush` is `true`; `ExcludeGlobs` contains one entry + per `--exclude` occurrence, in the order supplied. `Create` validates that `args` is non-null, constructs an `ArgumentParser`, calls `ParseArguments`, copies parsed values into the new `Context` via `init`-only setters, @@ -89,6 +95,8 @@ and calls `OpenLogFile` when a log path was specified. `Create` throws `ArgumentException` for unrecognized tokens and for malformed value-bearing flags (e.g., `--depth` not followed by an integer between 1 and 6 inclusive, or a string flag at end of args). +This includes `--exclude`, which throws `ArgumentException` when supplied without a value, +identically to the other value-bearing string flags. It throws `InvalidOperationException` if the log file cannot be opened. `ArgumentNullException` is thrown immediately if `args` is null. These exceptions propagate to `Program.Main`, which translates them to exit code 1. diff --git a/docs/design/sarifmark/program.md b/docs/design/sarifmark/program.md index b6bb33d..8cb5e3e 100644 --- a/docs/design/sarifmark/program.md +++ b/docs/design/sarifmark/program.md @@ -46,13 +46,18 @@ then always print the banner; help flag → print help and return; validate flag - *Returns*: `void` - *Preconditions*: `context.Version`, `context.Help`, and `context.Validate` are all false. - *Postconditions*: If `context.SarifFile` is a valid path to an existing SARIF file, the - results have been reported to the context output; if `context.ReportFile` is set, the - markdown report has been written to disk. + results have been reported to the context output; if `context.ExcludeGlobs` is non-empty, + matching findings have been removed and an excluded-count summary line has been reported + to the context output; if `context.ReportFile` is set, the markdown report has been + written to disk. The method validates that `context.SarifFile` is non-null and non-whitespace; calls -`SarifResults.Read`; checks `context.Enforce` against `sarifResults.HasIssues`; and -conditionally writes the markdown report using `sarifResults.ToMarkdown` and -`File.WriteAllText`. +`SarifResults.Read`; reports the tool name, version, and result count; if +`context.ExcludeGlobs` is non-empty, calls `sarifResults.Exclude(context.ExcludeGlobs)` and +writes an `"Excluded {N} finding(s) matching --exclude patterns."` summary line where `N` is +the number of findings removed across all runs; checks `context.Enforce` against +`sarifResults.HasIssues`; and conditionally writes the markdown report using +`sarifResults.ToMarkdown` and `File.WriteAllText`. ### Error Handling diff --git a/docs/design/sarifmark/sarif.md b/docs/design/sarifmark/sarif.md index dc5fc52..d7d908e 100644 --- a/docs/design/sarifmark/sarif.md +++ b/docs/design/sarifmark/sarif.md @@ -50,6 +50,15 @@ contains three units: - *Contract*: Returns `true` if any run contains at least one result. - *Constraints*: None. +**SarifResults.Exclude**: Removes findings whose `Uri` matches a user-supplied glob pattern. + +- *Type*: In-process .NET instance method +- *Role*: Provider +- *Contract*: Accepts `IReadOnlyList? globPatterns`; returns a new `SarifResults` + with matching findings removed from every run. Findings with a `null` `Uri` are always + retained. Returns the same instance unchanged when `globPatterns` is null or empty. +- *Constraints*: None; malformed patterns simply match nothing. + ### Design The SARIF reading and reporting pipeline flows through the three units in sequence: @@ -64,5 +73,7 @@ The SARIF reading and reporting pipeline flows through the three units in sequen produce a `SarifRun` record. `ParseResults` uses `IsSuppressed` to filter suppressed results before constructing `SarifFinding` records. 5. The completed `SarifResults` record is returned to `Program`. -6. `Program` calls `SarifResults.ToMarkdown` when `--report` is specified; the markdown +6. If `--exclude` glob patterns were supplied, `Program` calls `SarifResults.Exclude` to + remove matching findings from every run before enforcement or report generation. +7. `Program` calls `SarifResults.ToMarkdown` when `--report` is specified; the markdown string is written to disk with `File.WriteAllText`. diff --git a/docs/design/sarifmark/sarif/sarif-results.md b/docs/design/sarifmark/sarif/sarif-results.md index be35c2c..063f55a 100644 --- a/docs/design/sarifmark/sarif/sarif-results.md +++ b/docs/design/sarifmark/sarif/sarif-results.md @@ -70,6 +70,27 @@ are skipped. **ExtractFileCount**: Returns the length of the `artifacts` array in the run element; `0` when the array is absent or not an array. +**Exclude**: Removes findings whose `Uri` matches a user-supplied glob pattern. + +- *Parameters*: `IReadOnlyList? globPatterns` — glob patterns to match against each + finding's `Uri` +- *Returns*: `SarifResults` — a new instance with matching findings removed from every run +- *Preconditions*: None. +- *Postconditions*: Returns `this` unchanged when `globPatterns` is null or empty. Otherwise + returns a new `SarifResults` in which each `SarifRun` retains only findings whose `Uri` is + `null`, or whose `Uri` does not match any supplied pattern. Each new `SarifRun` preserves + the original `ToolName`, `ToolVersion`, and `FileCount`. + +`Exclude` builds a single `Microsoft.Extensions.FileSystemGlobbing.Matcher`, registering one +`AddInclude` call per supplied pattern. For each run, it filters `Results` by testing each +non-null `Uri` individually against the matcher using a fixed search root of `"/"` (chosen +because it was found to consistently match relative paths, `file://` URIs, and both Unix and +Windows absolute paths — the no-root overload instead resolves relative to the current +working directory and silently fails to match absolute paths outside it). Findings with a +`null` `Uri` are always retained and never passed to the matcher. Each filtered run is +reconstructed via the internal `SarifRun` constructor, preserving `ToolName`, `ToolVersion`, +and `FileCount` from the original run. + #### Error Handling `Read` throws `ArgumentException` when `filePath` is null, empty, or whitespace. @@ -79,6 +100,9 @@ violations (missing `version`, missing or empty `runs`, missing `tool` or `drive `ToMarkdown` throws `ArgumentOutOfRangeException` when `depth` is outside `[1, 6]`. +`Exclude` does not throw for malformed glob patterns; `Matcher.AddInclude` does not validate +pattern syntax and a pattern that matches nothing simply excludes no findings. + The project file includes `` to allow the test assembly to construct instances directly for unit testing. @@ -87,10 +111,13 @@ to allow the test assembly to construct instances directly for unit testing. - **SarifRun** — each element of `Runs` is a `SarifRun` instance produced during `Read`. - **SarifFinding** — each run's results contain `SarifFinding` instances. - **System.Text.Json** — `JsonDocument.Parse` is used for all JSON parsing. +- **Microsoft.Extensions.FileSystemGlobbing** — `Matcher` is used by `Exclude` to match + finding `Uri` values against glob patterns — see *FileSystemGlobbing Integration Design*. #### Callers -- **Program** — calls `SarifResults.Read(context.SarifFile)` and +- **Program** — calls `SarifResults.Read(context.SarifFile)`, + `sarifResults.Exclude(context.ExcludeGlobs)` (when `ExcludeGlobs` is non-empty), and `sarifResults.ToMarkdown(depth, heading)` from `ProcessSarifAnalysis`. - **Validation** — calls `SarifResults.Read` indirectly via `Program.Run` during self-validation tests. diff --git a/docs/reqstream/ots/filesystem-globbing.yaml b/docs/reqstream/ots/filesystem-globbing.yaml new file mode 100644 index 0000000..16b380a --- /dev/null +++ b/docs/reqstream/ots/filesystem-globbing.yaml @@ -0,0 +1,68 @@ +--- +# Microsoft.Extensions.FileSystemGlobbing OTS requirements +sections: + - title: OTS Software Requirements + sections: + - title: Microsoft.Extensions.FileSystemGlobbing Requirements + requirements: + - id: SarifMark-OTS-FileSystemGlobbing-MatchInclude + title: Microsoft.Extensions.FileSystemGlobbing shall match a candidate + Uri against one or more registered include glob patterns. + justification: | + SarifMark's --exclude option needs a reliable way to determine whether a finding's + Uri matches a user-supplied glob pattern. The Matcher type's AddInclude/Match + mechanism provides this without requiring SarifMark to implement its own glob + parsing and matching logic. + tags: [ots] + tests: + - SarifResults_Exclude_SinglePatternMatch_RemovesMatchingFinding + - SarifResults_Exclude_MultiplePatterns_RemovesAnyMatchingFinding + + - id: SarifMark-OTS-FileSystemGlobbing-RetainNonMatching + title: Microsoft.Extensions.FileSystemGlobbing shall report no match for + a candidate Uri that does not satisfy any registered include pattern. + justification: | + Findings whose Uri does not match any --exclude pattern must remain in the + report and be eligible for --enforce. The Matcher must reliably distinguish + non-matching candidates from matching ones so SarifMark can retain them. + tags: [ots] + tests: + - SarifResults_Exclude_NoMatch_RetainsAllFindings + + - id: SarifMark-OTS-FileSystemGlobbing-RecursiveWildcard + title: Microsoft.Extensions.FileSystemGlobbing shall match nested paths + at any depth using a recursive double-asterisk (**) wildcard pattern. + justification: | + Users need to exclude entire generated-code directory trees (for example + **/bin/** or **/obj/**) without enumerating every nested path individually. + The Matcher's recursive wildcard support satisfies this without SarifMark + implementing directory-tree traversal itself. + tags: [ots] + tests: + - SarifResults_Exclude_RecursiveDoubleStarGlob_MatchesNestedPaths + + - id: SarifMark-OTS-FileSystemGlobbing-InMemoryMatching + title: Microsoft.Extensions.FileSystemGlobbing shall match candidate paths + without requiring the corresponding files to exist on disk. + justification: | + SARIF Uri values reference source files that may not be present in the + environment performing --exclude filtering (for example, a CI pipeline that + only has the SARIF log, not the analyzed source tree). The Matcher's in-memory + matching overload evaluates supplied strings directly, so filtering does not + depend on filesystem access. + tags: [ots] + tests: + - SarifResults_Exclude_NullUri_RetainsFinding + - SarifResults_Exclude_MultiRun_FiltersEachRunIndependently + + - id: SarifMark-OTS-FileSystemGlobbing-CaseInsensitive + title: Microsoft.Extensions.FileSystemGlobbing shall match candidate Uri + values case-insensitively by default. + justification: | + SARIF Uri values and user-supplied glob patterns may differ in character case + across platforms (for example Windows paths). Case-insensitive matching by + default avoids surprising users with missed exclusions caused solely by case + differences. + tags: [ots] + tests: + - SarifResults_Exclude_DifferentCase_StillMatches diff --git a/docs/reqstream/sarifmark.yaml b/docs/reqstream/sarifmark.yaml index 3c157fb..6f3d8ec 100644 --- a/docs/reqstream/sarifmark.yaml +++ b/docs/reqstream/sarifmark.yaml @@ -107,6 +107,24 @@ sections: tests: - SarifMark_EnforceFlagWithIssues_ReturnsError + - id: SarifMark-System-ExcludeFiltering + title: The tool shall exclude findings whose file location matches a user-supplied + glob pattern before enforcement and report generation. + tags: [public] + justification: >- + Letting users drop findings from generated or vendored code (such as build + output directories) without post-processing the report makes the tool easier + to integrate into CI/CD pipelines that also analyze generated code. + children: + - SarifMark-Cli-Exclude + - SarifMark-Sarif-ExcludeFiltering + - SarifMark-Program-ExcludeFiltering + tests: + - SarifMark_ExcludeFlag_FiltersMatchingFindings + - Program_Main_ExcludeFlag_FiltersMatchingFindingsFromReport + - Program_Main_ExcludeAndEnforce_ExcludedFindingsDoNotTriggerEnforcement + - Program_Main_ExcludeFlag_PrintsExcludedCountSummary + - id: SarifMark-System-Silent title: The tool shall support silent mode to suppress console output. tags: [public] diff --git a/docs/reqstream/sarifmark/cli.yaml b/docs/reqstream/sarifmark/cli.yaml index b2eadb3..4f9b1a4 100644 --- a/docs/reqstream/sarifmark/cli.yaml +++ b/docs/reqstream/sarifmark/cli.yaml @@ -251,3 +251,19 @@ sections: - SarifMark-Context-ResultLegacyAlias tests: - Cli_Create_ResultLegacyAlias_SetsResultsFilePath + + - id: SarifMark-Cli-Exclude + title: The CLI shall accept one or more file-glob exclusion patterns via + the repeatable --exclude parameter. + justification: >- + A repeatable exclusion parameter lets users drop findings from generated + or vendored code (such as build output directories) without post-processing + the report, making the tool easier to integrate into CI/CD pipelines + that also run against generated code. + tags: [public] + children: + - SarifMark-Context-ExcludeParam + - SarifMark-Context-ExcludeParam-MissingValue + tests: + - Cli_Create_ExcludeParameter_SetsExcludeGlobs + - Cli_Create_ExcludeParameter_Repeated_AccumulatesExcludeGlobs diff --git a/docs/reqstream/sarifmark/cli/context.yaml b/docs/reqstream/sarifmark/cli/context.yaml index ad0b08b..572e7a6 100644 --- a/docs/reqstream/sarifmark/cli/context.yaml +++ b/docs/reqstream/sarifmark/cli/context.yaml @@ -114,6 +114,7 @@ sections: tags: [internal] tests: - Context_Create_SarifWithoutValue_ThrowsArgumentException + - Context_Create_SarifFollowedByOption_ThrowsArgumentException - id: SarifMark-Context-ReportParam title: The Context unit shall capture the --report parameter value @@ -153,6 +154,7 @@ sections: tests: - Context_Create_DepthParameter_SetsDepth - Context_Create_DepthWithoutValue_ThrowsArgumentException + - Context_Create_DepthFollowedByOption_ThrowsArgumentException - id: SarifMark-Context-ReportDepthAlias title: The Context unit shall accept --report-depth as a legacy alias @@ -255,6 +257,33 @@ sections: tests: - Context_Create_ResultsWithoutValue_ThrowsArgumentException + - id: SarifMark-Context-ExcludeParam + title: >- + The Context unit shall accumulate one --exclude glob pattern per + occurrence of the parameter into an ordered, repeatable collection. + justification: >- + Accumulating one entry per occurrence, rather than overwriting a + single value, lets users supply multiple independent exclusion + patterns (for example one per generated-code directory) in a single + invocation. + tags: [internal] + tests: + - Context_Create_ExcludeParameter_AddsGlobToExcludeGlobs + - Context_Create_ExcludeParameter_RepeatedFlag_AccumulatesAllGlobs + - Context_Create_NoExcludeParameter_ReturnsEmptyExcludeGlobs + + - id: SarifMark-Context-ExcludeParam-MissingValue + title: The Context unit shall throw ArgumentException when --exclude + is provided without a value. + justification: >- + Detecting a missing value immediately prevents downstream filtering + from receiving a null or empty glob pattern, producing a clear diagnostic + message that identifies the exact flag that is missing its argument. + tags: [internal] + tests: + - Context_Create_ExcludeWithoutValue_ThrowsArgumentException + - Context_Create_ExcludeFollowedByOption_ThrowsArgumentException + - id: SarifMark-Context-LogParam title: >- The Context unit shall open the specified --log file for writing diff --git a/docs/reqstream/sarifmark/program.yaml b/docs/reqstream/sarifmark/program.yaml index 4022b30..2f59444 100644 --- a/docs/reqstream/sarifmark/program.yaml +++ b/docs/reqstream/sarifmark/program.yaml @@ -173,6 +173,23 @@ sections: tests: - Program_Main_EnforceFlagWithIssues_ReturnsError + - id: SarifMark-Program-ExcludeFiltering + title: The Program unit shall apply --exclude glob filtering to the parsed + SARIF results after reading and before the enforcement check and report + generation. + justification: >- + Filtering before enforcement and report generation ensures excluded + findings never trigger --enforce failures and never appear in the generated + report, and printing the excluded-count summary line gives users visibility + into how many findings were removed. + tags: [internal] + children: + - SarifMark-SarifResults-Exclude + tests: + - Program_Main_ExcludeFlag_FiltersMatchingFindingsFromReport + - Program_Main_ExcludeAndEnforce_ExcludedFindingsDoNotTriggerEnforcement + - Program_Main_ExcludeFlag_PrintsExcludedCountSummary + - id: SarifMark-Program-ReportGeneration title: The Program unit shall write the markdown analysis report to the specified output file. diff --git a/docs/reqstream/sarifmark/sarif.yaml b/docs/reqstream/sarifmark/sarif.yaml index f1bee57..5f0054c 100644 --- a/docs/reqstream/sarifmark/sarif.yaml +++ b/docs/reqstream/sarifmark/sarif.yaml @@ -133,3 +133,18 @@ sections: - SarifMark-SarifResults-ToMarkdownMultiRun tests: - Sarif_Read_MultiRunSarifFile_ProcessesAllRuns + + - id: SarifMark-Sarif-ExcludeFiltering + title: The tool shall exclude findings whose file location matches a user-supplied + glob pattern before enforcement and report generation. + justification: >- + Excluding findings by file-location glob lets users drop generated or + vendored code (such as build output directories) from enforcement and + reports without post-processing the SARIF file or reconfiguring the + upstream analysis tool. + tags: [public] + children: + - SarifMark-SarifResults-Exclude + tests: + - Program_Main_ExcludeFlag_FiltersMatchingFindingsFromReport + - Program_Main_ExcludeAndEnforce_ExcludedFindingsDoNotTriggerEnforcement diff --git a/docs/reqstream/sarifmark/sarif/sarif-results.yaml b/docs/reqstream/sarifmark/sarif/sarif-results.yaml index d431c9b..74a3fed 100644 --- a/docs/reqstream/sarifmark/sarif/sarif-results.yaml +++ b/docs/reqstream/sarifmark/sarif/sarif-results.yaml @@ -278,3 +278,33 @@ sections: tags: [internal] tests: - SarifResults_ToMarkdown_MultipleRuns_IncludesRunIndices + + - id: SarifMark-SarifResults-Exclude + title: >- + The SarifResults unit shall return a new SarifResults with findings + removed whose non-null Uri matches any of the supplied glob patterns, + retaining findings with a null Uri and preserving run metadata + (ToolName, ToolVersion, FileCount). + justification: >- + Filtering findings by Uri glob pattern lets callers drop generated + or vendored code from enforcement and reports while leaving findings + without a location (which cannot be matched against a file-path + glob) + and each run's identifying metadata untouched. + tags: [internal] + children: + - SarifMark-OTS-FileSystemGlobbing-MatchInclude + - SarifMark-OTS-FileSystemGlobbing-RetainNonMatching + - SarifMark-OTS-FileSystemGlobbing-RecursiveWildcard + - SarifMark-OTS-FileSystemGlobbing-InMemoryMatching + - SarifMark-OTS-FileSystemGlobbing-CaseInsensitive + tests: + - SarifResults_Exclude_SinglePatternMatch_RemovesMatchingFinding + - SarifResults_Exclude_MultiplePatterns_RemovesAnyMatchingFinding + - SarifResults_Exclude_NoMatch_RetainsAllFindings + - SarifResults_Exclude_NullUri_RetainsFinding + - SarifResults_Exclude_RecursiveDoubleStarGlob_MatchesNestedPaths + - SarifResults_Exclude_EmptyGlobList_ReturnsAllFindings + - SarifResults_Exclude_MultiRun_FiltersEachRunIndependently + - SarifResults_Exclude_PreservesRunMetadata + - SarifResults_Exclude_DifferentCase_StillMatches diff --git a/docs/sysml2/model/ots.sysml b/docs/sysml2/model/ots.sysml index b4ea443..1c85441 100644 --- a/docs/sysml2/model/ots.sysml +++ b/docs/sysml2/model/ots.sysml @@ -16,4 +16,12 @@ package OtsDependencies { comment verificationRef /* Verification: docs/verification/ots/sysml2tools.md */ comment reqRef /* Requirements: docs/reqstream/ots/sysml2tools.yaml */ } + + part def FileSystemGlobbingPackage { + doc /* OTS: Microsoft.Extensions.FileSystemGlobbing. */ + + comment designRef /* Design: docs/design/ots/filesystem-globbing.md */ + comment verificationRef /* Verification: docs/verification/ots/filesystem-globbing.md */ + comment reqRef /* Requirements: docs/reqstream/ots/filesystem-globbing.yaml */ + } } diff --git a/docs/sysml2/model/sarifmark.sysml b/docs/sysml2/model/sarifmark.sysml index b1f4cc3..c09229d 100644 --- a/docs/sysml2/model/sarifmark.sysml +++ b/docs/sysml2/model/sarifmark.sysml @@ -24,6 +24,7 @@ package SarifMark { part testResults : TestResultsPackage; part sysml2tools : SysML2ToolsTool; + part filesystemGlobbing : FileSystemGlobbingPackage; part sarifMarkSharedPackage : SarifMarkSharedPackage; } diff --git a/docs/user_guide/faq.md b/docs/user_guide/faq.md index edebcee..5f3f172 100644 --- a/docs/user_guide/faq.md +++ b/docs/user_guide/faq.md @@ -30,6 +30,17 @@ The `--enforce` flag processes the SARIF file normally and generates the report, non-zero exit code if any issues are found. This allows pipelines to fail automatically when analysis detects problems. +### Why do I see findings for generated code (bin/obj) even though CodeQL has `paths-ignore` configured? + +CodeQL's `paths-ignore` configuration controls which files are *extracted and analyzed* during the +build, not which findings are later reported in the SARIF output. If your analysis (or a tool +upstream of SarifMark) still analyzes generated or vendored code — for example a `bin` or `obj` +directory produced by a previous build step — those findings will appear in the SARIF file +regardless of `paths-ignore`. Use one or more `--exclude` parameters to drop findings whose file +location matches a glob pattern (such as `**/bin/**` or `**/obj/**`) before they reach `--enforce` +or the generated report, without needing an extra build step or reconfiguring the upstream +analysis tool. See *Filtering Out Generated Code* in the Usage section for an example. + ### Can I customize the report output? Yes. Use `--heading` to specify a custom top-level heading and `--depth` to set the markdown header diff --git a/docs/user_guide/usage.md b/docs/user_guide/usage.md index 5812b00..bfcb57e 100644 --- a/docs/user_guide/usage.md +++ b/docs/user_guide/usage.md @@ -37,6 +37,7 @@ sarifmark --help | `--enforce` | Return a non-zero exit code if issues are found in the SARIF file | | `--log ` | Write console output to a log file | | `--sarif ` | SARIF file to process (required for analysis) | +| `--exclude ` | Exclude findings whose file location matches the given glob pattern; may be repeated | | `--report ` | Export analysis results to a markdown file | | `--depth ` | Markdown header depth for the report (default: `1`; accepted range: `1`–`6`, corresponding to Markdown heading levels `#` through `######`) | | `--heading ` | Custom heading for the report (default: `[ToolName] Analysis`) | @@ -73,6 +74,21 @@ Return a non-zero exit code when the SARIF file contains issues, causing the CI sarifmark --sarif analysis.sarif --report report.md --enforce ``` +### Filtering Out Generated Code + +Static analysis tools such as CodeQL often analyze generated or compiled output alongside +hand-written source (for example `bin`/`obj` directories produced by a build). Use one or more +`--exclude` parameters to drop findings whose file location matches a glob pattern, before the +findings reach `--enforce` or the generated report: + +```shell +sarifmark --sarif codeql-results.sarif --report quality-report.md --exclude "**/bin/**" --exclude "**/obj/**" +``` + +Each `--exclude` parameter accepts one glob pattern and may be repeated to supply multiple +patterns. A finding is excluded when its file location matches any of the supplied patterns. +Findings with no file location are never excluded, since they cannot be matched against a glob. + ### Self-Validation Run the built-in validation suite to confirm that SarifMark is working correctly in the current diff --git a/docs/verification/ots.md b/docs/verification/ots.md index 8356485..d8a63fb 100644 --- a/docs/verification/ots.md +++ b/docs/verification/ots.md @@ -5,10 +5,11 @@ Each OTS item is verified using one of three evidence categories matched to its role in the pipeline: 1. **Self-validation output**: Tools that expose a `--validate` flag (BuildMark, FileAssert, ReqStream, ReviewMark, - VersionMark — verified through SarifMark's self-validation tests) are exercised through the `--validate` - self-validation mechanism; passing output confirms the tool is installed and all advertised features are - operational. DemaConsulting.TestResults is a NuGet package (not a CLI tool) and does not expose `--validate`; - it is verified through SarifMark's integration and self-validation tests. + VersionMark) are exercised through their own `--validate` self-validation mechanism as separate CI pipeline + steps; passing output confirms the tool is installed and all advertised features are operational. + DemaConsulting.TestResults and Microsoft.Extensions.FileSystemGlobbing are NuGet packages + (not CLI tools) and do not expose `--validate`; they are verified through SarifMark's integration and + unit tests. 2. **Successful CI pipeline completion**: Tools verified by successful CI pipeline execution — each tool produces an artifact (document, report, or exit-code assertion) that confirms functional operation. Pandoc and WeasyPrint are verified via FileAssert assertions on generated HTML and PDF files. @@ -44,6 +45,9 @@ For each OTS item, the following evidence is collected during CI pipeline execut Design document. - **xUnit v3**: The test suite produces passing results across all test classes, and TRX result files are generated by `dotnet test --results-directory`, confirming test discovery, execution, and result serialization. +- **Microsoft.Extensions.FileSystemGlobbing**: Unit tests exercising `SarifResults.Exclude` construct real `Matcher` + instances with real glob patterns and real candidate `Uri` values; passing results confirm the package matches, + retains, and case-insensitively compares findings as documented in `docs/design/ots/filesystem-globbing.md`. ## Regression Approach diff --git a/docs/verification/ots/filesystem-globbing.md b/docs/verification/ots/filesystem-globbing.md new file mode 100644 index 0000000..b88fe8f --- /dev/null +++ b/docs/verification/ots/filesystem-globbing.md @@ -0,0 +1,74 @@ +## Microsoft.Extensions.FileSystemGlobbing + +### Verification Approach + +`Microsoft.Extensions.FileSystemGlobbing` is verified through unit tests in the SarifMark test +suite that exercise the package through `SarifResults.Exclude`. No mocking is applied; the +verification calls the real `Matcher` type to evaluate actual glob patterns against actual +finding URIs. + +The tests verify that: + +- A single `--exclude` glob pattern removes findings whose `Uri` matches it. +- Multiple `--exclude` glob patterns remove findings matching any one of them. +- Findings whose `Uri` does not match any supplied pattern are retained. +- Findings with a `null` `Uri` are always retained, regardless of supplied patterns. +- Recursive `**` wildcard patterns match nested paths at any depth. +- An empty or absent glob pattern list leaves all findings unchanged (and returns the same + `SarifResults` instance). +- Filtering is applied independently per `SarifRun` in a multi-run `SarifResults`. +- Run metadata (`ToolName`, `ToolVersion`, `FileCount`) is preserved across filtering. +- Matching is case-insensitive by default. + +### Test Environment + +Tests require: + +- No network access; verification is entirely in-process. +- No real files on disk; `SarifFinding`/`SarifRun`/`SarifResults` fixtures are constructed + directly via their internal constructors (available to the test assembly through + `InternalsVisibleTo`), and candidate URIs are arbitrary strings rather than paths to files that + must exist. +- The `Microsoft.Extensions.FileSystemGlobbing` NuGet package installed as a package reference in + the production project (`src/DemaConsulting.SarifMark/DemaConsulting.SarifMark.csproj`). + +### Acceptance Criteria + +The OTS integration is accepted when all linked tests pass with zero failures. + +### Test Scenarios + +**SinglePatternExclusion**: Constructs a `SarifResults` with one finding whose `Uri` matches a +single supplied glob pattern and confirms the finding is removed. Tested by +`SarifResults_Exclude_SinglePatternMatch_RemovesMatchingFinding`. + +**MultiplePatternExclusion**: Constructs a `SarifResults` with findings matching different glob +patterns among several supplied patterns and confirms any matching finding is removed. Tested by +`SarifResults_Exclude_MultiplePatterns_RemovesAnyMatchingFinding`. + +**NoMatchRetention**: Constructs a `SarifResults` with findings whose `Uri` values do not match +the supplied glob pattern and confirms all findings are retained. Tested by +`SarifResults_Exclude_NoMatch_RetainsAllFindings`. + +**NullUriRetention**: Constructs a `SarifResults` with a finding whose `Uri` is `null` and +confirms it is retained regardless of the supplied glob patterns. Tested by +`SarifResults_Exclude_NullUri_RetainsFinding`. + +**RecursiveWildcardMatching**: Constructs a `SarifResults` with a finding at a deeply nested path +and confirms a `**`-prefixed glob pattern matches it. Tested by +`SarifResults_Exclude_RecursiveDoubleStarGlob_MatchesNestedPaths`. + +**EmptyPatternListNoOp**: Calls `SarifResults.Exclude` with an empty pattern list and confirms the +same `SarifResults` instance is returned with all findings unchanged. Tested by +`SarifResults_Exclude_EmptyGlobList_ReturnsAllFindings`. + +**MultiRunIndependentFiltering**: Constructs a `SarifResults` with multiple `SarifRun` instances +and confirms filtering is applied independently to each run. Tested by +`SarifResults_Exclude_MultiRun_FiltersEachRunIndependently`. + +**RunMetadataPreservation**: Confirms `ToolName`, `ToolVersion`, and `FileCount` are unchanged on +each `SarifRun` after filtering. Tested by `SarifResults_Exclude_PreservesRunMetadata`. + +**CaseInsensitiveMatching**: Confirms a glob pattern matches a `Uri` that differs from it only in +character case, documenting the observed default `Matcher` behavior. Tested by +`SarifResults_Exclude_DifferentCase_StillMatches`. diff --git a/docs/verification/sarifmark.md b/docs/verification/sarifmark.md index f571c79..caa1e30 100644 --- a/docs/verification/sarifmark.md +++ b/docs/verification/sarifmark.md @@ -14,7 +14,11 @@ tests exercise individual classes directly with console streams redirected via `StringWriter`. The test framework is xUnit v3, executed via `dotnet test`. Three additional named scenarios (`SarifMark_SarifReading`, `SarifMark_MarkdownReportGeneration`, `SarifMark_Enforcement`) are self-validation tests invoked through the tool's own `--validate` flag; they are not xUnit test methods but named scenarios reported -in the self-validation output. +in the self-validation output. The `--exclude` glob-filtering behavior is verified at both the system level, via the +named integration scenario `SarifMark_ExcludeFlag_FiltersMatchingFindings`, and at the unit level, via the existing +`Program_Main_ExcludeFlag_*` and `SarifResults_Exclude_*` tests (see *Program Verification Design* and *SarifResults +Verification Design*); the `Microsoft.Extensions.FileSystemGlobbing` OTS dependency it relies on is verified +separately in *FileSystemGlobbing Verification Design*. ## Test Environment @@ -177,3 +181,10 @@ This scenario is tested by `SarifMark_ValidSarif_NoIssues_GeneratesReport`. assert exit code is 0 and the TRX results file is created and contains a ` public string? ResultsFile { get; private init; } + /// + /// Gets the collection of glob patterns supplied via one or more --exclude + /// parameters, used to filter out SARIF findings whose Uri matches any pattern + /// before enforcement and report generation. + /// + public IReadOnlyList ExcludeGlobs { get; private init; } = []; + /// /// Gets the proposed exit code for the application (0 for success, 1 for errors). /// @@ -133,7 +140,8 @@ public static Context Create(string[] args) ReportFile = parser.ReportFile, Depth = parser.Depth, Heading = parser.Heading, - ResultsFile = parser.ResultsFile + ResultsFile = parser.ResultsFile, + ExcludeGlobs = parser.ExcludeGlobs }; // Open log file if specified @@ -227,6 +235,43 @@ private sealed class ArgumentParser /// public string? ResultsFile { get; private set; } + /// + /// Gets the accumulated collection of glob patterns supplied via one or more + /// --exclude parameters, in the order they were encountered. + /// + public IReadOnlyList ExcludeGlobs => _excludeGlobs; + + /// + /// Backing accumulator for . A separate mutable field is used + /// because --exclude is repeatable: each occurrence appends to this list rather than + /// overwriting a single value, unlike the other value-bearing flags in this parser. + /// + private readonly List _excludeGlobs = []; + + /// + /// Recognized option tokens handled by . Shared with + /// and so that a + /// value-bearing option (for example --exclude) followed by another option token (for + /// example --enforce) is rejected as a missing value rather than silently consuming the + /// next option as its value. Kept as a single source of truth to avoid maintaining the option + /// list twice. + /// + private static readonly HashSet KnownOptionTokens = + [ + "-v", "--version", + "-?", "-h", "--help", + "--silent", + "--validate", + "--enforce", + "--log", + "--sarif", + "--report", + "--depth", "--report-depth", + "--heading", + "--result", "--results", + "--exclude" + ]; + /// /// Parses command-line arguments. /// @@ -306,6 +351,10 @@ private int ParseArgument(string arg, string[] args, int index) ResultsFile = GetRequiredStringArgument(arg, args, index, "a results filename argument"); return index + 1; + case "--exclude": + _excludeGlobs.Add(GetRequiredStringArgument(arg, args, index, "a glob pattern argument")); + return index + 1; + default: throw new ArgumentException($"Unsupported argument '{arg}'", nameof(args)); } @@ -319,10 +368,14 @@ private int ParseArgument(string arg, string[] args, int index) /// Current index. /// Description of what's required. /// The argument value. - /// Thrown when is the last token in the argument list and has no following value. + /// Thrown when is the last token in the argument list and has no following value, or the following token is itself a recognized option (for example --exclude --enforce), which would otherwise be silently consumed as the value instead of reporting the missing argument. private static string GetRequiredStringArgument(string arg, string[] args, int index, string description) { - if (index >= args.Length) + // A missing value and a value that is actually the next recognized option are both + // treated as "no value supplied" - otherwise an option like --enforce following + // --exclude would be silently consumed as the glob pattern rather than being + // recognized as its own flag, letting a misconfigured invocation succeed silently. + if (index >= args.Length || KnownOptionTokens.Contains(args[index])) { throw new ArgumentException($"{arg} requires {description}", nameof(args)); } @@ -341,7 +394,7 @@ private static string GetRequiredStringArgument(string arg, string[] args, int i /// Thrown when is the last token in the argument list, or its value is not an integer between 1 and 6. private static int GetRequiredIntArgument(string arg, string[] args, int index) { - if (index >= args.Length) + if (index >= args.Length || KnownOptionTokens.Contains(args[index])) { throw new ArgumentException($"{arg} requires a depth argument", nameof(args)); } diff --git a/src/DemaConsulting.SarifMark/DemaConsulting.SarifMark.csproj b/src/DemaConsulting.SarifMark/DemaConsulting.SarifMark.csproj index e8747bf..8e25b85 100644 --- a/src/DemaConsulting.SarifMark/DemaConsulting.SarifMark.csproj +++ b/src/DemaConsulting.SarifMark/DemaConsulting.SarifMark.csproj @@ -49,6 +49,7 @@ + diff --git a/src/DemaConsulting.SarifMark/Program.cs b/src/DemaConsulting.SarifMark/Program.cs index 2d4e62c..47ba097 100644 --- a/src/DemaConsulting.SarifMark/Program.cs +++ b/src/DemaConsulting.SarifMark/Program.cs @@ -169,6 +169,7 @@ private static void PrintHelp(Context context) context.WriteLine(" --enforce Return non-zero exit code if issues found"); context.WriteLine(" --log Write output to log file"); context.WriteLine(" --sarif SARIF file to process"); + context.WriteLine(" --exclude Exclude findings whose location matches glob (repeatable)"); context.WriteLine(" --report Export analysis results to markdown file"); context.WriteLine(" --depth Markdown header depth for report (1-6, default: 1)"); context.WriteLine(" --heading Custom heading for report (default: [ToolName] Analysis)"); @@ -180,7 +181,10 @@ private static void PrintHelp(Context context) /// /// This method performs file I/O by reading the SARIF file specified in /// and optionally writing a markdown report to - /// . The following exception types are absorbed and + /// . When is + /// non-empty, is applied immediately after reading + /// and before the enforcement check or report generation, so excluded findings never + /// influence either downstream step. The following exception types are absorbed and /// routed through rather than propagated: /// and (SARIF /// read failures — I/O and access errors are wrapped as @@ -223,6 +227,16 @@ private static void ProcessSarifAnalysis(Context context) return; } + // Apply --exclude glob filtering, if requested, before enforcement or report + // generation so that excluded findings never influence either downstream step + if (context.ExcludeGlobs.Count > 0) + { + var beforeCount = sarifResults.Runs.Sum(run => run.ResultCount); + sarifResults = sarifResults.Exclude(context.ExcludeGlobs); + var afterCount = sarifResults.Runs.Sum(run => run.ResultCount); + context.WriteLine($"Excluded {beforeCount - afterCount} finding(s) matching --exclude patterns."); + } + // Check enforcement if requested if (context.Enforce && sarifResults.HasIssues) { diff --git a/src/DemaConsulting.SarifMark/Sarif/SarifResults.cs b/src/DemaConsulting.SarifMark/Sarif/SarifResults.cs index 575c4c7..69e9cf1 100644 --- a/src/DemaConsulting.SarifMark/Sarif/SarifResults.cs +++ b/src/DemaConsulting.SarifMark/Sarif/SarifResults.cs @@ -20,6 +20,7 @@ using System.Text; using System.Text.Json; +using Microsoft.Extensions.FileSystemGlobbing; namespace DemaConsulting.SarifMark; @@ -353,6 +354,89 @@ private static (string? Uri, int? StartLine) ParseLocation(JsonElement resultEle return null; } + /// + /// Filters out findings whose matches any of the supplied + /// glob patterns. + /// + /// + /// This method exists so callers can drop findings from generated code or vendored + /// dependencies (e.g. `bin`/`obj` output) after a SARIF file has been parsed, without + /// needing to re-run the originating analysis tool with a narrower scope — several + /// analysis tools (notably CodeQL) do not offer a post-hoc way to exclude paths from an + /// already-produced SARIF file. Matching is performed with + /// against an in-memory file + /// list, so no path referenced by a finding needs to exist on disk. Findings with a + /// are always retained because there + /// is no path to test against the supplied patterns. + /// + /// + /// The collection of glob patterns to match against each finding's . + /// A finding is excluded when its Uri matches at least one pattern in this collection. + /// + /// + /// A new instance with matching findings removed from every run, + /// preserving each run's , , + /// and . Returns this same instance unchanged when + /// is or empty. + /// + public SarifResults Exclude(IReadOnlyList? globPatterns) + { + // No patterns means nothing to filter - return the same instance to avoid an + // unnecessary allocation when --exclude was not supplied + if (globPatterns is not { Count: > 0 }) + { + return this; + } + + // Build a single Matcher covering every supplied pattern; each pattern is added as an + // independent "include" rule so a finding is excluded if it matches any one of them + var matcher = new Matcher(); + foreach (var pattern in globPatterns) + { + matcher.AddInclude(pattern); + } + + var filteredRuns = new List(Runs.Count); + foreach (var run in Runs) + { + var filteredResults = run.Results + .Where(finding => !IsExcluded(matcher, finding.Uri)) + .ToList(); + + filteredRuns.Add(new SarifRun(run.ToolName, run.ToolVersion, filteredResults, run.FileCount)); + } + + return new SarifResults(filteredRuns); + } + + /// + /// Determines whether a finding's URI matches any pattern in the supplied matcher. + /// + /// The matcher configured with one or more exclusion glob patterns. + /// The finding's file URI, or null when no physical location is associated. + /// + /// when is (null-URI + /// findings are always retained); otherwise when + /// matches at least one pattern in . + /// + private static bool IsExcluded(Matcher matcher, string? uri) + { + // Findings with no location can never match a path-based glob pattern, so they are + // always retained rather than passed to the matcher + if (uri == null) + { + return false; + } + + // A fixed root of "/" is used (rather than the current working directory, which is the + // Matcher's implicit default) so that both relative URIs (e.g. "src/File.cs") and + // absolute URIs (Unix-style "/repo/..." or Windows-style "C:/repo/...") are matched + // consistently against the supplied patterns regardless of the process's actual working + // directory; this was confirmed empirically because the Matcher's default relative-path + // resolution silently drops absolute paths that fall outside the current directory. + return matcher.Match("/", [uri]).HasMatches; + } + /// /// Converts the SARIF results to markdown format. /// diff --git a/test/DemaConsulting.SarifMark.Tests/Cli/CliTests.cs b/test/DemaConsulting.SarifMark.Tests/Cli/CliTests.cs index 62745d0..5abd16b 100644 --- a/test/DemaConsulting.SarifMark.Tests/Cli/CliTests.cs +++ b/test/DemaConsulting.SarifMark.Tests/Cli/CliTests.cs @@ -392,4 +392,37 @@ public void Cli_Create_DepthNegative_ThrowsArgumentException() // Assert Assert.Contains("--depth requires an integer between 1 and 6", ex.Message); } + + /// + /// Test that a single --exclude parameter sets the ExcludeGlobs collection. + /// + [Fact] + public void Cli_Create_ExcludeParameter_SetsExcludeGlobs() + { + // Arrange - No special setup needed + + // Act + using var context = Context.Create(["--exclude", "**/bin/**"]); + + // Assert + Assert.Single(context.ExcludeGlobs); + Assert.Equal("**/bin/**", context.ExcludeGlobs[0]); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test that repeating --exclude accumulates every supplied glob pattern. + /// + [Fact] + public void Cli_Create_ExcludeParameter_Repeated_AccumulatesExcludeGlobs() + { + // Arrange - No special setup needed + + // Act + using var context = Context.Create(["--exclude", "**/bin/**", "--exclude", "**/obj/**"]); + + // Assert + Assert.Equal(["**/bin/**", "**/obj/**"], context.ExcludeGlobs); + Assert.Equal(0, context.ExitCode); + } } diff --git a/test/DemaConsulting.SarifMark.Tests/Cli/ContextTests.cs b/test/DemaConsulting.SarifMark.Tests/Cli/ContextTests.cs index 2b5ce2c..622c81a 100644 --- a/test/DemaConsulting.SarifMark.Tests/Cli/ContextTests.cs +++ b/test/DemaConsulting.SarifMark.Tests/Cli/ContextTests.cs @@ -839,4 +839,122 @@ public void Context_Create_DepthAtMaximum_SetsDepth() // Assert Assert.Equal(6, context.Depth); } + + /// + /// Test that creating a context with no --exclude parameter returns an empty ExcludeGlobs collection. + /// + [Fact] + public void Context_Create_NoExcludeParameter_ReturnsEmptyExcludeGlobs() + { + // Arrange + // (no setup required) + + // Act + using var context = Context.Create([]); + + // Assert + Assert.Empty(context.ExcludeGlobs); + } + + /// + /// Test that creating a context with a single --exclude parameter adds the glob to ExcludeGlobs. + /// + [Fact] + public void Context_Create_ExcludeParameter_AddsGlobToExcludeGlobs() + { + // Arrange + // (no setup required) + + // Act + using var context = Context.Create(["--exclude", "**/bin/**"]); + + // Assert + Assert.Single(context.ExcludeGlobs); + Assert.Equal("**/bin/**", context.ExcludeGlobs[0]); + } + + /// + /// Test that repeating the --exclude flag accumulates every glob pattern in order. + /// + [Fact] + public void Context_Create_ExcludeParameter_RepeatedFlag_AccumulatesAllGlobs() + { + // Arrange + // (no setup required) + + // Act + using var context = Context.Create(["--exclude", "**/bin/**", "--exclude", "**/obj/**"]); + + // Assert + Assert.Equal(["**/bin/**", "**/obj/**"], context.ExcludeGlobs); + } + + /// + /// Test that creating a context with --exclude but no value throws exception. + /// + [Fact] + public void Context_Create_ExcludeWithoutValue_ThrowsArgumentException() + { + // Arrange + // (no setup required) + + // Act + var exception = Assert.Throws(() => Context.Create(["--exclude"])); + + // Assert + Assert.Contains("--exclude requires", exception.Message); + } + + /// + /// Test that --exclude immediately followed by another recognized option throws an exception + /// rather than silently consuming the following option token as the glob pattern. + /// + [Fact] + public void Context_Create_ExcludeFollowedByOption_ThrowsArgumentException() + { + // Arrange + // (no setup required) + + // Act + var exception = Assert.Throws(() => Context.Create(["--exclude", "--enforce"])); + + // Assert + Assert.Contains("--exclude requires", exception.Message); + } + + /// + /// Test that --sarif immediately followed by another recognized option throws an exception + /// rather than silently consuming the following option token as the filename, confirming the + /// fix applies to every value-bearing option that shares GetRequiredStringArgument, not just --exclude. + /// + [Fact] + public void Context_Create_SarifFollowedByOption_ThrowsArgumentException() + { + // Arrange + // (no setup required) + + // Act + var exception = Assert.Throws(() => Context.Create(["--sarif", "--enforce"])); + + // Assert + Assert.Contains("--sarif requires", exception.Message); + } + + /// + /// Test that --depth immediately followed by another recognized option throws an exception + /// rather than silently attempting to parse the following option token as the depth value, + /// confirming the fix also applies to GetRequiredIntArgument, not just GetRequiredStringArgument. + /// + [Fact] + public void Context_Create_DepthFollowedByOption_ThrowsArgumentException() + { + // Arrange + // (no setup required) + + // Act + var exception = Assert.Throws(() => Context.Create(["--depth", "--enforce"])); + + // Assert + Assert.Contains("--depth requires a depth argument", exception.Message); + } } diff --git a/test/DemaConsulting.SarifMark.Tests/IntegrationTests.cs b/test/DemaConsulting.SarifMark.Tests/IntegrationTests.cs index 5b63069..668ba06 100644 --- a/test/DemaConsulting.SarifMark.Tests/IntegrationTests.cs +++ b/test/DemaConsulting.SarifMark.Tests/IntegrationTests.cs @@ -560,5 +560,49 @@ public void SarifMark_ValidSarif_NoIssues_GeneratesReport() } } } + + /// + /// Test that the --exclude flag filters matching findings from the generated report while + /// retaining non-matching findings, end-to-end through the compiled binary. + /// + [Fact] + public void SarifMark_ExcludeFlag_FiltersMatchingFindings() + { + // Arrange + var sarifFile = PathHelpers.SafePathCombine(_testDataPath, "multi-result.sarif"); + Assert.True(File.Exists(sarifFile), $"Test SARIF file not found at {sarifFile}"); + + var reportFile = PathHelpers.SafePathCombine(Path.GetTempPath(), $"test-exclude-report-{Guid.NewGuid()}.md"); + + try + { + // Act - multi-result.sarif's two findings are located at file:///path/to/first.cs + // and file:///path/to/second.cs; exclude only the first. + var exitCode = Runner.Run( + out _, + "dotnet", + _dllPath, + "--sarif", sarifFile, + "--exclude", "**/first.cs", + "--report", reportFile); + + // Assert + Assert.Equal(0, exitCode); + Assert.True(File.Exists(reportFile), "Report file was not created"); + + var reportContent = File.ReadAllText(reportFile); + Assert.DoesNotContain("first.cs", reportContent); + Assert.Contains("second.cs", reportContent); + Assert.Contains("Found 1 issue", reportContent); + } + finally + { + // Clean up the temporary report file + if (File.Exists(reportFile)) + { + File.Delete(reportFile); + } + } + } } diff --git a/test/DemaConsulting.SarifMark.Tests/ProgramTests.cs b/test/DemaConsulting.SarifMark.Tests/ProgramTests.cs index d0d33ca..94d400c 100644 --- a/test/DemaConsulting.SarifMark.Tests/ProgramTests.cs +++ b/test/DemaConsulting.SarifMark.Tests/ProgramTests.cs @@ -116,6 +116,7 @@ public void Program_Main_HelpFlag_DisplaysHelp() Assert.Matches(@"--report(?!-)", output); Assert.Contains("--depth", output); Assert.Contains("--heading", output); + Assert.Contains("--exclude ", output); } finally { @@ -298,4 +299,99 @@ public void Program_Main_ReportFile_CreatesReport() } } } + + /// + /// Test that a finding matching an --exclude glob pattern is absent from the generated + /// markdown report. + /// + [Fact] + public void Program_Main_ExcludeFlag_FiltersMatchingFindingsFromReport() + { + // Arrange + var sarifFile = Path.Combine(AppContext.BaseDirectory, "TestData", "sample.sarif"); + var reportFile = Path.Combine(Path.GetTempPath(), $"test-report-{Guid.NewGuid()}.md"); + var originalOut = Console.Out; + try + { + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + + // Act - sample.sarif's one finding is located at file:///path/to/file.cs + var result = Program.Main(["--sarif", sarifFile, "--report", reportFile, "--exclude", "**/file.cs"]); + + // Assert + Assert.Equal(0, result); + var reportContent = File.ReadAllText(reportFile); + Assert.Contains("Found no issues", reportContent); + Assert.DoesNotContain("TEST001", reportContent); + } + finally + { + Console.SetOut(originalOut); + if (File.Exists(reportFile)) + { + File.Delete(reportFile); + } + } + } + + /// + /// Test that when --exclude removes every finding, --enforce no longer signals a + /// non-zero exit code even though the un-filtered SARIF file contained findings. + /// + [Fact] + public void Program_Main_ExcludeAndEnforce_ExcludedFindingsDoNotTriggerEnforcement() + { + // Arrange + var sarifFile = Path.Combine(AppContext.BaseDirectory, "TestData", "sample.sarif"); + var originalOut = Console.Out; + var originalError = Console.Error; + try + { + using var outWriter = new StringWriter(); + using var errWriter = new StringWriter(); + Console.SetOut(outWriter); + Console.SetError(errWriter); + + // Act + var result = Program.Main(["--sarif", sarifFile, "--enforce", "--exclude", "**/file.cs"]); + + // Assert + Assert.Equal(0, result); + Assert.Equal(string.Empty, errWriter.ToString()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } + + /// + /// Test that supplying --exclude prints a summary line reporting how many findings were + /// excluded. + /// + [Fact] + public void Program_Main_ExcludeFlag_PrintsExcludedCountSummary() + { + // Arrange + var sarifFile = Path.Combine(AppContext.BaseDirectory, "TestData", "sample.sarif"); + var originalOut = Console.Out; + try + { + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + + // Act + var result = Program.Main(["--sarif", sarifFile, "--exclude", "**/file.cs"]); + + // Assert + Assert.Equal(0, result); + Assert.Contains("Excluded 1 finding(s) matching --exclude patterns.", outWriter.ToString()); + } + finally + { + Console.SetOut(originalOut); + } + } } diff --git a/test/DemaConsulting.SarifMark.Tests/Sarif/SarifResultsTests.cs b/test/DemaConsulting.SarifMark.Tests/Sarif/SarifResultsTests.cs index 432ace3..45d418a 100644 --- a/test/DemaConsulting.SarifMark.Tests/Sarif/SarifResultsTests.cs +++ b/test/DemaConsulting.SarifMark.Tests/Sarif/SarifResultsTests.cs @@ -1375,5 +1375,212 @@ public void SarifResults_ToMarkdown_MultipleRuns_IncludesRunIndices() Assert.Contains("(#2)", md); } + /// + /// Test that Exclude removes a finding whose Uri matches a single supplied glob pattern. + /// + [Fact] + public void SarifResults_Exclude_SinglePatternMatch_RemovesMatchingFinding() + { + // Arrange + var findings = new List + { + new("R1", "warning", "msg", "bin/Debug/File.cs", null), + new("R2", "warning", "msg", "src/File.cs", null) + }; + var results = new SarifResults([new SarifRun("TestTool", "1.0.0", findings)]); + + // Act + var filtered = results.Exclude(["**/bin/**"]); + + // Assert + var remaining = Assert.Single(filtered.Runs[0].Results); + Assert.Equal("src/File.cs", remaining.Uri); + } + + /// + /// Test that Exclude removes a finding matching any one of several supplied glob patterns. + /// + [Fact] + public void SarifResults_Exclude_MultiplePatterns_RemovesAnyMatchingFinding() + { + // Arrange + var findings = new List + { + new("R1", "warning", "msg", "bin/Debug/File.cs", null), + new("R2", "warning", "msg", "obj/Debug/File.cs", null), + new("R3", "warning", "msg", "src/File.cs", null) + }; + var results = new SarifResults([new SarifRun("TestTool", "1.0.0", findings)]); + + // Act + var filtered = results.Exclude(["**/bin/**", "**/obj/**"]); + + // Assert + var remaining = Assert.Single(filtered.Runs[0].Results); + Assert.Equal("src/File.cs", remaining.Uri); + } + + /// + /// Test that Exclude retains all findings when no glob pattern matches any Uri. + /// + [Fact] + public void SarifResults_Exclude_NoMatch_RetainsAllFindings() + { + // Arrange + var findings = new List + { + new("R1", "warning", "msg", "src/File.cs", null), + new("R2", "warning", "msg", "src/Other.cs", null) + }; + var results = new SarifResults([new SarifRun("TestTool", "1.0.0", findings)]); + + // Act + var filtered = results.Exclude(["**/bin/**"]); + + // Assert + Assert.Equal(2, filtered.Runs[0].Results.Count); + } + + /// + /// Test that Exclude always retains findings whose Uri is null, since there is no path to + /// test against the supplied glob patterns. + /// + [Fact] + public void SarifResults_Exclude_NullUri_RetainsFinding() + { + // Arrange + var findings = new List + { + new("R1", "warning", "msg", null, null) + }; + var results = new SarifResults([new SarifRun("TestTool", "1.0.0", findings)]); + + // Act + var filtered = results.Exclude(["**/bin/**"]); + + // Assert + var remaining = Assert.Single(filtered.Runs[0].Results); + Assert.Null(remaining.Uri); + } + + /// + /// Test that a recursive double-star glob pattern matches deeply nested paths. + /// + [Fact] + public void SarifResults_Exclude_RecursiveDoubleStarGlob_MatchesNestedPaths() + { + // Arrange + var findings = new List + { + new("R1", "warning", "msg", "a/b/c/d/File.generated.cs", null), + new("R2", "warning", "msg", "src/File.cs", null) + }; + var results = new SarifResults([new SarifRun("TestTool", "1.0.0", findings)]); + + // Act + var filtered = results.Exclude(["**/*.generated.cs"]); + + // Assert + var remaining = Assert.Single(filtered.Runs[0].Results); + Assert.Equal("src/File.cs", remaining.Uri); + } + + /// + /// Test that Exclude returns all findings unchanged when the glob pattern list is empty. + /// + [Fact] + public void SarifResults_Exclude_EmptyGlobList_ReturnsAllFindings() + { + // Arrange + var findings = new List + { + new("R1", "warning", "msg", "bin/Debug/File.cs", null) + }; + var results = new SarifResults([new SarifRun("TestTool", "1.0.0", findings)]); + + // Act + var filtered = results.Exclude([]); + + // Assert + Assert.Same(results, filtered); + Assert.Single(filtered.Runs[0].Results); + } + + /// + /// Test that Exclude filters each run independently in a multi-run SarifResults. + /// + [Fact] + public void SarifResults_Exclude_MultiRun_FiltersEachRunIndependently() + { + // Arrange + var run1Findings = new List + { + new("R1", "warning", "msg", "bin/Debug/File.cs", null), + new("R2", "warning", "msg", "src/File1.cs", null) + }; + var run2Findings = new List + { + new("R3", "warning", "msg", "src/File2.cs", null) + }; + var run1 = new SarifRun("Tool1", "1.0", run1Findings); + var run2 = new SarifRun("Tool2", "2.0", run2Findings); + var results = new SarifResults(new List { run1, run2 }); + + // Act + var filtered = results.Exclude(["**/bin/**"]); + + // Assert + var run1Remaining = Assert.Single(filtered.Runs[0].Results); + Assert.Equal("src/File1.cs", run1Remaining.Uri); + var run2Remaining = Assert.Single(filtered.Runs[1].Results); + Assert.Equal("src/File2.cs", run2Remaining.Uri); + } + + /// + /// Test that Exclude preserves each run's ToolName, ToolVersion, and FileCount metadata + /// unchanged after filtering. + /// + [Fact] + public void SarifResults_Exclude_PreservesRunMetadata() + { + // Arrange + var findings = new List + { + new("R1", "warning", "msg", "bin/Debug/File.cs", null) + }; + var results = new SarifResults([new SarifRun("TestTool", "1.2.3", findings, 5)]); + + // Act + var filtered = results.Exclude(["**/bin/**"]); + + // Assert + Assert.Equal("TestTool", filtered.Runs[0].ToolName); + Assert.Equal("1.2.3", filtered.Runs[0].ToolVersion); + Assert.Equal(5, filtered.Runs[0].FileCount); + } + + /// + /// Test that the default Matcher behavior observed in this environment matches glob + /// patterns case-insensitively (an uppercase Uri still matches a lowercase pattern), + /// documenting the actual + /// default rather than an assumed one. + /// + [Fact] + public void SarifResults_Exclude_DifferentCase_StillMatches() + { + // Arrange + var findings = new List + { + new("R1", "warning", "msg", "BIN/DEBUG/FILE.CS", null) + }; + var results = new SarifResults([new SarifRun("TestTool", "1.0.0", findings)]); + + // Act + var filtered = results.Exclude(["**/bin/**"]); + + // Assert + Assert.Empty(filtered.Runs[0].Results); + } + }