From 518d30a01821f99f199d5e601ecfa9ebcc3c03c0 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sun, 5 Jan 2025 17:46:26 +1000 Subject: [PATCH] Refactoring for splitting runs #2611 --- .github/workflows/build.yaml | 10 +- README.md | 3 + .../PSRule/en-US/about_PSRule_Options.md | 92 +++ docs/concepts/runs.md | 18 + docs/concepts/sarif-format.md | 69 ++- docs/examples/baseline/Baseline.Rule.yaml | 12 + docs/examples/sarif/basic-1.sarif | 60 ++ ps-rule-ci.yaml | 4 + ps-rule.yaml | 4 + schemas/PSRule-options.schema.json | 27 + src/PSRule.Types/Environment.cs | 14 +- src/PSRule.Types/Options/IRunOption.cs | 24 + src/PSRule.Types/Options/RunOption.cs | 124 +++++ src/PSRule/Configuration/PSRuleOption.cs | 18 +- .../Definitions/Baselines/BaselineFilter.cs | 10 +- src/PSRule/Pipeline/DefaultPipelineResult.cs | 10 +- .../Formatters/AssertFormatterBase.cs | 2 +- .../Pipeline/GetBaselinePipelineBuilder.cs | 10 +- .../Pipeline/InvokePipelineBuilderBase.cs | 8 +- src/PSRule/Pipeline/InvokeResult.cs | 13 +- src/PSRule/Pipeline/InvokeRulePipeline.cs | 183 +++--- src/PSRule/Pipeline/Output/SarifBuilder.cs | 215 +++++--- .../Pipeline/Output/SarifOutputWriter.cs | 31 +- src/PSRule/Pipeline/PipelineBuilderBase.cs | 81 +-- src/PSRule/Pipeline/PipelineContext.cs | 16 +- src/PSRule/Pipeline/ResourceCacheBuilder.cs | 4 +- .../Pipeline/ResourceCacheDiscoveryContext.cs | 4 +- src/PSRule/Pipeline/Runs/IRun.cs | 32 ++ src/PSRule/Pipeline/Runs/Run.cs | 104 ++++ src/PSRule/Pipeline/Runs/RunCollection.cs | 45 ++ .../Pipeline/Runs/RunCollectionBuilder.cs | 71 +++ .../Pipeline/SerializationOutputWriter.cs | 10 +- src/PSRule/Rules/RuleRecord.cs | 9 +- src/PSRule/Runtime/RunspaceContext.cs | 22 +- tests/PSRule.Tests/AssertFormatterTests.cs | 11 +- tests/PSRule.Tests/OutputWriterTests.cs | 522 ------------------ tests/PSRule.Tests/PSRule.Options.Tests.ps1 | 56 ++ tests/PSRule.Tests/PSRule.Tests.yml | 5 + .../Pipeline/Output/CsvOutputWriterTests.cs | 39 ++ .../Pipeline/Output/JobSummaryWriterTests.cs | 36 ++ .../Pipeline/Output/JsonOutputWriterTests.cs | 135 +++++ .../Output/NUnit3OutputWriterTests.cs | 40 ++ .../Pipeline/Output/OutputWriterBaseTests.cs | 102 ++++ .../Pipeline/Output/SarifOutputWriterTests.cs | 225 ++++++++ .../Pipeline/Output/YamlOutputWriterTests.cs | 112 ++++ 45 files changed, 1832 insertions(+), 810 deletions(-) create mode 100644 docs/concepts/runs.md create mode 100644 docs/examples/baseline/Baseline.Rule.yaml create mode 100644 docs/examples/sarif/basic-1.sarif create mode 100644 src/PSRule.Types/Options/IRunOption.cs create mode 100644 src/PSRule.Types/Options/RunOption.cs create mode 100644 src/PSRule/Pipeline/Runs/IRun.cs create mode 100644 src/PSRule/Pipeline/Runs/Run.cs create mode 100644 src/PSRule/Pipeline/Runs/RunCollection.cs create mode 100644 src/PSRule/Pipeline/Runs/RunCollectionBuilder.cs delete mode 100644 tests/PSRule.Tests/OutputWriterTests.cs create mode 100644 tests/PSRule.Tests/Pipeline/Output/CsvOutputWriterTests.cs create mode 100644 tests/PSRule.Tests/Pipeline/Output/JobSummaryWriterTests.cs create mode 100644 tests/PSRule.Tests/Pipeline/Output/JsonOutputWriterTests.cs create mode 100644 tests/PSRule.Tests/Pipeline/Output/NUnit3OutputWriterTests.cs create mode 100644 tests/PSRule.Tests/Pipeline/Output/OutputWriterBaseTests.cs create mode 100644 tests/PSRule.Tests/Pipeline/Output/SarifOutputWriterTests.cs create mode 100644 tests/PSRule.Tests/Pipeline/Output/YamlOutputWriterTests.cs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2461a7537a..41b4b34cac 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -27,8 +27,8 @@ env: permissions: {} jobs: - build: - name: Build + build_module: + name: Build module runs-on: ubuntu-latest permissions: contents: read @@ -76,10 +76,10 @@ jobs: retention-days: 3 if-no-files-found: error - test: - name: 🧪 Test (${{ matrix.rid }}-${{ matrix.shell }}) + test_module: + name: 🧪 Test module (${{ matrix.rid }}-${{ matrix.shell }}) runs-on: ${{ matrix.os }} - needs: build + needs: build_module permissions: contents: read diff --git a/README.md b/README.md index 3546b2402e..c6b2d3c63e 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,9 @@ The following conceptual topics exist in the `PSRule` module: - [Rule.IncludeLocal](https://aka.ms/ps-rule/options#ruleincludelocal) - [Rule.Exclude](https://aka.ms/ps-rule/options#ruleexclude) - [Rule.Tag](https://aka.ms/ps-rule/options#ruletag) + - [Run.Category](https://aka.ms/ps-rule/options##runcategory) + - [Run.Description](https://aka.ms/ps-rule/options##rundescription) + - [Run.Instance](https://aka.ms/ps-rule/options##runinstance) - [Suppression](https://aka.ms/ps-rule/options#suppression) - [Rules](https://microsoft.github.io/PSRule/v2/concepts/PSRule/en-US/about_PSRule_Rules/) - [Selectors](https://microsoft.github.io/PSRule/v2/concepts/PSRule/en-US/about_PSRule_Selectors/) diff --git a/docs/concepts/PSRule/en-US/about_PSRule_Options.md b/docs/concepts/PSRule/en-US/about_PSRule_Options.md index 05aba01d70..42b8f932aa 100644 --- a/docs/concepts/PSRule/en-US/about_PSRule_Options.md +++ b/docs/concepts/PSRule/en-US/about_PSRule_Options.md @@ -64,6 +64,9 @@ The following workspace options are available for use: - [Repository.BaseRef](#repositorybaseref) - [Repository.Url](#repositoryurl) - [Requires](#requires) +- [Run.Category](#runcategory) +- [Run.Description](#rundescription) +- [Run.Instance](#runinstance) - [Suppression](#suppression) Additionally the following baseline options can be included: @@ -3442,6 +3445,95 @@ rule: In the example above, rules must have a tag of `severity` set to either `Critical` or `Warning` to be included. +### Run.Category + + + +Configures the run category that is used as an identifier for output results. +By default, the run category is set to `PSRule`. + +This option can be specified using: + +```powershell +# PowerShell: Using the Run.Category hashtable key +$option = New-PSRuleOption -Option @{ 'Run.Category' = 'Custom run' }; +``` + +```yaml +# YAML: Using the run/category property +run: + category: Custom run +``` + +```bash +# Bash: Using environment variable +export PSRULE_RUN_CATEOGRY='Custom run' +``` + +```yaml +# GitHub Actions: Using environment variable +env: + PSRULE_RUN_CATEOGRY: 'Custom run' +``` + +```yaml +# Azure Pipelines: Using environment variable +variables: +- name: PSRULE_RUN_CATEOGRY + value: 'Custom run' +``` + +### Run.Description + + + +Configure the run description that is displayed in output. +By default, the run description is not set. + +This option can be specified using: + +```powershell +# PowerShell: Using the Run.Description hashtable key +$option = New-PSRuleOption -Option @{ 'Run.Description' = 'Custom run description.' }; +``` + +```yaml +# YAML: Using the run/description property +run: + description: Custom run description. +``` + +```bash +# Bash: Using environment variable +export PSRULE_RUN_DESCRIPTION='Custom run description.' +``` + +```yaml +# GitHub Actions: Using environment variable +env: + PSRULE_RUN_DESCRIPTION: 'Custom run description.' +``` + +```yaml +# Azure Pipelines: Using environment variable +variables: +- name: PSRULE_RUN_DESCRIPTION + value: 'Custom run description.' +``` + +### Run.Instance + + + +An unique identifier for the current parent environment instance that is displayed in output as a component of the run ID. +This is automatically set by PSRule when running in a GitHub Actions or Azure Pipeline pipeline. +Alternatively, this option can be set using environment variables. + +```bash +# Bash: Using environment variable +export PSRULE_RUN_INSTANCE='12345678' +``` + ### Suppression In certain circumstances it may be necessary to exclude or suppress rules from processing objects that are in a known failed state. diff --git a/docs/concepts/runs.md b/docs/concepts/runs.md new file mode 100644 index 0000000000..a4f43813c1 --- /dev/null +++ b/docs/concepts/runs.md @@ -0,0 +1,18 @@ +--- +title: About runs +description: This article describes how PSRule organizes and manages each logical run. +module: 3.0.0 +--- + +# Runs + +This article describes how PSRule organizes and manages each logical run. + +When PSRule is executed one or more runs will be automatically created. + +## Configuring runs + +PSRule allows the following options to be configured that affects runs: + +- [Execution.Category] +- [Execution.Description] diff --git a/docs/concepts/sarif-format.md b/docs/concepts/sarif-format.md index df39912577..46bcbce2dc 100644 --- a/docs/concepts/sarif-format.md +++ b/docs/concepts/sarif-format.md @@ -12,8 +12,75 @@ When running PSRule executed a run will be generated in `runs` containing detail ## Invocation -The `invocation` property reports runtime information about how the run started. +The `invocations` property reports runtime information about how the run started. ### RuleConfigurationOverrides When a rule has been overridden in configuration this invocation property will contain any level overrides. + +## Examples + +### Successful result + +```json +{ + "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "guid": "0130215d-58eb-4887-b6fa-31ed02500569", + "name": "PSRule", + "organization": "Microsoft Corporation", + "semanticVersion": "2.9.0", + "informationUri": "https://aka.ms/ps-rule" + }, + "extensions": [ + { + "guid": "7bfb5234-1648-4e52-956c-42f303d416cb", + "name": "PSRule.Rules.MSFT.OSS", + "organization": "Microsoft Corporation", + "version": "1.1.0", + "informationUri": "https://github.com/microsoft/PSRule.Rules.MSFT.OSS", + "associatedComponent": { + "name": "PSRule" + } + } + ] + }, + "automationDetails": { + "id": "CI repository scan/workspace/1", + "description": { + "text": "An analysis scan that checks repository files." + }, + "correlationGuid": "00000000-0000-0000-0000-000000000000" + }, + "invocations": [ + { + "executionSuccessful": true + } + ], + "versionControlProvenance": [ + { + "repositoryUri": "https://github.com/microsoft/PSRule", + "revisionId": "2a3671213e5768aad6af7f132e418d1a4368a3c5", + "branch": "main", + "mappedTo": { + "uriBaseId": "REPO_ROOT" + } + } + ], + "originalUriBaseIds": { + "REPO_ROOT": { + "description": { + "text": "The directory into which the repo was cloned." + } + } + }, + "results": [], + "columnKind": "utf16CodeUnits" + } + ] +} +``` diff --git a/docs/examples/baseline/Baseline.Rule.yaml b/docs/examples/baseline/Baseline.Rule.yaml new file mode 100644 index 0000000000..41517a377f --- /dev/null +++ b/docs/examples/baseline/Baseline.Rule.yaml @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +--- +# Synopsis: An example baseline. +apiVersion: github.com/microsoft/PSRule/v1 +kind: Baseline +metadata: + name: Baseline.Rule + displayName: Baseline.Rule + description: An example baseline. +spec: {} diff --git a/docs/examples/sarif/basic-1.sarif b/docs/examples/sarif/basic-1.sarif new file mode 100644 index 0000000000..df1f289b52 --- /dev/null +++ b/docs/examples/sarif/basic-1.sarif @@ -0,0 +1,60 @@ +{ + "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", + "version": "2.1.0", + "runs": [ + { + "automationDetails": { + "id": "CI repository scan/workspace/1", + "description": { + "text": "An analysis scan that checks repository files." + }, + "correlationGuid": "00000000-0000-0000-0000-000000000000" + }, + "tool": { + "driver": { + "guid": "0130215d-58eb-4887-b6fa-31ed02500569", + "name": "PSRule", + "organization": "Microsoft Corporation", + "semanticVersion": "2.9.0", + "informationUri": "https://aka.ms/ps-rule" + }, + "extensions": [ + { + "guid": "7bfb5234-1648-4e52-956c-42f303d416cb", + "name": "PSRule.Rules.MSFT.OSS", + "organization": "Microsoft Corporation", + "version": "1.1.0", + "informationUri": "https://github.com/microsoft/PSRule.Rules.MSFT.OSS", + "associatedComponent": { + "name": "PSRule" + } + } + ] + }, + "invocations": [ + { + "executionSuccessful": true + } + ], + "versionControlProvenance": [ + { + "repositoryUri": "https://github.com/microsoft/PSRule", + "revisionId": "2a3671213e5768aad6af7f132e418d1a4368a3c5", + "branch": "main", + "mappedTo": { + "uriBaseId": "REPO_ROOT" + } + } + ], + "originalUriBaseIds": { + "REPO_ROOT": { + "description": { + "text": "The directory into which the repo was cloned." + } + } + }, + "results": [], + "columnKind": "utf16CodeUnits" + } + ] +} diff --git a/ps-rule-ci.yaml b/ps-rule-ci.yaml index 33cd571300..8272d844c2 100644 --- a/ps-rule-ci.yaml +++ b/ps-rule-ci.yaml @@ -9,6 +9,10 @@ repository: url: https://github.com/microsoft/PSRule baseRef: main +run: + category: CI repository scan + description: An analysis scan that checks repository files aligned to expected format requirements. + output: culture: - en-US diff --git a/ps-rule.yaml b/ps-rule.yaml index febde34192..9c85680f04 100644 --- a/ps-rule.yaml +++ b/ps-rule.yaml @@ -9,6 +9,10 @@ repository: url: https://github.com/microsoft/PSRule baseRef: main +run: + category: Scan for tests + description: This is used for unit testing and should not be used directly. + output: culture: - en-US diff --git a/schemas/PSRule-options.schema.json b/schemas/PSRule-options.schema.json index 166c2c8c67..85e26f9cb3 100644 --- a/schemas/PSRule-options.schema.json +++ b/schemas/PSRule-options.schema.json @@ -784,6 +784,29 @@ }, "additionalProperties": false }, + "run-option": { + "type": "object", + "title": "Run", + "description": "Configures run options.", + "markdownDescription": "Configures run options.", + "properties": { + "category": { + "type": "string", + "title": "Category", + "description": "Configures the run category that is used as an identifier for output results. By default, the category is PSRule.", + "markdownDescription": "Configures the run category that is used as an identifier for output results. By default, the category is `PSRule`.\n\n[See help](https://microsoft.github.io/PSRule/v3/concepts/PSRule/en-US/about_PSRule_Options/#executioncategory)", + "default": "PSRule" + }, + "description": { + "type": "string", + "title": "Description", + "description": "Configure the run description that is displayed in output. By default, the description is empty.", + "markdownDescription": "Configure the run description that is displayed in output. By default, the description is empty.\n\n[See help](https://microsoft.github.io/PSRule/v3/concepts/PSRule/en-US/about_PSRule_Options/#executiondescription)", + "default": "" + } + }, + "additionalProperties": false + }, "suppression-option": { "type": "object", "title": "Suppress rules", @@ -1123,6 +1146,10 @@ } ] }, + "run": { + "type": "object", + "$ref": "#/definitions/run-option" + }, "suppression": { "type": "object", "oneOf": [ diff --git a/src/PSRule.Types/Environment.cs b/src/PSRule.Types/Environment.cs index 1b4ba04be4..f1efbac1d2 100644 --- a/src/PSRule.Types/Environment.cs +++ b/src/PSRule.Types/Environment.cs @@ -163,17 +163,13 @@ public static bool IsVisualStudioCode() } /// - /// Get the run identifier for the current environment. + /// Get the run instance identifier for the current environment. /// - public static string? GetRunId() + public static string? GetRunInstance() { - if (TryString("PSRULE_RUN_ID", out var runId) && runId != null) - return runId; - - return TryString("BUILD_REPOSITORY_NAME", out var prefix) && TryString("BUILD_BUILDID", out var suffix) || - TryString("GITHUB_REPOSITORY", out prefix) && TryString("GITHUB_RUN_ID", out suffix) - ? string.Concat(prefix, "/", suffix) - : null; + return TryString("PSRULE_RUN_INSTANCE", out var runId) || + TryString("BUILD_BUILDID", out runId) || + TryString("GITHUB_RUN_ID", out runId) ? runId : null; } /// diff --git a/src/PSRule.Types/Options/IRunOption.cs b/src/PSRule.Types/Options/IRunOption.cs new file mode 100644 index 0000000000..38bb9c7a58 --- /dev/null +++ b/src/PSRule.Types/Options/IRunOption.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + + +namespace PSRule.Options; + +/// +/// Options that configure runs. +/// +/// +/// See . +/// +public interface IRunOption : IOption +{ + /// + /// Configures the run category that is used as an identifier for output results. + /// + string? Category { get; } + + /// + /// Configure the run description that is displayed in output. + /// + string? Description { get; } +} diff --git a/src/PSRule.Types/Options/RunOption.cs b/src/PSRule.Types/Options/RunOption.cs new file mode 100644 index 0000000000..e4e0e01e2a --- /dev/null +++ b/src/PSRule.Types/Options/RunOption.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + + +using System.ComponentModel; + +namespace PSRule.Options; + +/// +/// Options that configure runs. +/// +/// +/// See . +/// +public sealed class RunOption : IRunOption, IEquatable +{ + private const string DEFAULT_CATEGORY = "PSRule"; + + /// + /// The default run option. + /// + public static readonly RunOption Default = new() + { + Category = DEFAULT_CATEGORY, + Description = string.Empty, + }; + + /// + /// Creates an empty run option. + /// + public RunOption() + { + Category = null; + Description = null; + } + + /// + /// Creates a run option by copying an existing instance. + /// + /// The option instance to copy. + public RunOption(RunOption? option) + { + if (option == null) + return; + + Category = option.Category; + Description = option.Description; + } + + /// + public override bool Equals(object obj) + { + return obj is RunOption option && Equals(option); + } + + /// + public bool Equals(RunOption other) + { + return other != null && + Category == other.Category && + Description == other.Description; + } + + /// + public override int GetHashCode() + { + unchecked // Overflow is fine + { + var hash = 17; + hash = hash * 23 + (Category != null ? Category.GetHashCode() : 0); + hash = hash * 23 + (Description != null ? Description.GetHashCode() : 0); + return hash; + } + } + + /// + /// Merge two option instances by replacing any unset properties from with values. + /// Values from that are set are not overridden. + /// + internal static RunOption Combine(RunOption? o1, RunOption? o2) + { + var result = new RunOption(o1) + { + Category = o1?.Category ?? o2?.Category, + Description = o1?.Description ?? o2?.Description, + }; + return result; + } + + /// + /// Configures the run category that is used as an identifier for output results. + /// + [DefaultValue(null)] + public string? Category { get; set; } + + /// + /// Configure the run description that is displayed in output. + /// + + [DefaultValue(null)] + public string? Description { get; set; } + + /// + /// Load from environment variables. + /// + internal void Load() + { + if (Environment.TryString("PSRULE_RUN_CATEGORY", out var category)) + Category = category; + + if (Environment.TryString("PSRULE_RUN_DESCRIPTION", out var description)) + Description = description; + } + + /// + public void Import(IDictionary dictionary) + { + if (dictionary.TryPopString("Run.Category", out var category)) + Category = category; + + if (dictionary.TryPopString("Run.Description", out var description)) + Description = description; + } +} diff --git a/src/PSRule/Configuration/PSRuleOption.cs b/src/PSRule/Configuration/PSRuleOption.cs index 340e775973..c68c0ac5a8 100644 --- a/src/PSRule/Configuration/PSRuleOption.cs +++ b/src/PSRule/Configuration/PSRuleOption.cs @@ -44,6 +44,7 @@ public sealed class PSRuleOption : IEquatable, IBaselineV1Spec Output = OutputOption.Default, Override = OverrideOption.Default, Rule = RuleOption.Default, + Run = RunOption.Default, }; /// @@ -66,6 +67,7 @@ public PSRuleOption() Repository = new RepositoryOption(); Requires = new RequiresOption(); Rule = new RuleOption(); + Run = new RunOption(); Suppression = new SuppressionOption(); } @@ -87,6 +89,7 @@ private PSRuleOption(string sourcePath, PSRuleOption option) Override = new OverrideOption(option?.Override); Repository = new RepositoryOption(option?.Repository); Requires = new RequiresOption(option?.Requires); + Run = new RunOption(option?.Run); Rule = new RuleOption(option?.Rule); Suppression = new SuppressionOption(option?.Suppression); } @@ -161,6 +164,11 @@ private PSRuleOption(string sourcePath, PSRuleOption option) /// public RuleOption Rule { get; set; } + /// + /// Options that configure runs. + /// + public RunOption Run { get; set; } + /// /// A set of suppression rules. /// @@ -229,6 +237,8 @@ private static PSRuleOption Combine(PSRuleOption o1, PSRuleOption o2) result.Output = OutputOption.Combine(result?.Output, o2?.Output); result.Override = OverrideOption.Combine(result?.Override, o2?.Override); result.Repository = RepositoryOption.Combine(result?.Repository, o2?.Repository); + result.Requires = RequiresOption.Combine(result?.Requires, o2?.Requires); + result.Run = RunOption.Combine(result?.Run, o2?.Run); return result; } @@ -362,6 +372,7 @@ private static PSRuleOption FromEnvironment(PSRuleOption option) option.Override.Load(); option.Repository.Load(); option.Requires.Load(); + option.Run.Load(); BaselineOption.Load(option); return option; } @@ -394,6 +405,7 @@ public static PSRuleOption FromHashtable(Hashtable hashtable) option.Override.Import(index); option.Repository.Load(index); option.Requires.Load(index); + option.Run.Import(index); BaselineOption.Load(option, index); return option; } @@ -461,7 +473,9 @@ public bool Equals(PSRuleOption other) Override == other.Override && Suppression == other.Suppression && Repository == other.Repository && - Rule == other.Rule; + Rule == other.Rule && + Requires == other.Requires && + Run == other.Run; } /// @@ -484,6 +498,8 @@ public override int GetHashCode() hash = hash * 23 + (Suppression != null ? Suppression.GetHashCode() : 0); hash = hash * 23 + (Repository != null ? Repository.GetHashCode() : 0); hash = hash * 23 + (Rule != null ? Rule.GetHashCode() : 0); + hash = hash * 23 + (Requires != null ? Requires.GetHashCode() : 0); + hash = hash * 23 + (Run != null ? Run.GetHashCode() : 0); return hash; } } diff --git a/src/PSRule/Definitions/Baselines/BaselineFilter.cs b/src/PSRule/Definitions/Baselines/BaselineFilter.cs index db2528ab47..7ca91995b3 100644 --- a/src/PSRule/Definitions/Baselines/BaselineFilter.cs +++ b/src/PSRule/Definitions/Baselines/BaselineFilter.cs @@ -6,12 +6,14 @@ namespace PSRule.Definitions.Baselines; +#nullable enable + internal sealed class BaselineFilter : IResourceFilter { - private readonly HashSet _Include; - private readonly WildcardPattern _WildcardMatch; + private readonly HashSet? _Include; + private readonly WildcardPattern? _WildcardMatch; - public BaselineFilter(string[] include) + public BaselineFilter(string[]? include) { _Include = include == null || include.Length == 0 ? null : new HashSet(include, StringComparer.OrdinalIgnoreCase); _WildcardMatch = null; @@ -36,3 +38,5 @@ private bool MatchWildcard(string name) return _WildcardMatch != null && _WildcardMatch.IsMatch(name); } } + +#nullable restore diff --git a/src/PSRule/Pipeline/DefaultPipelineResult.cs b/src/PSRule/Pipeline/DefaultPipelineResult.cs index c7d1bf64b0..5cb21e1c6f 100644 --- a/src/PSRule/Pipeline/DefaultPipelineResult.cs +++ b/src/PSRule/Pipeline/DefaultPipelineResult.cs @@ -6,10 +6,12 @@ namespace PSRule.Pipeline; -internal sealed class DefaultPipelineResult(IPipelineWriter writer, BreakLevel breakLevel) : IPipelineResult +#nullable enable + +internal sealed class DefaultPipelineResult(IPipelineWriter? writer, BreakLevel breakLevel) : IPipelineResult { - private readonly IPipelineWriter _Writer = writer; - private readonly BreakLevel _BreakLevel = breakLevel == BreakLevel.None ? ExecutionOption.Default.Break.Value : breakLevel; + private readonly IPipelineWriter? _Writer = writer; + private readonly BreakLevel _BreakLevel = breakLevel == BreakLevel.None ? ExecutionOption.Default.Break!.Value : breakLevel; private bool _HadErrors; private bool _HadFailures; private SeverityLevel _WorstCase = SeverityLevel.None; @@ -65,3 +67,5 @@ public void Fail(SeverityLevel level) } } } + +#nullable restore diff --git a/src/PSRule/Pipeline/Formatters/AssertFormatterBase.cs b/src/PSRule/Pipeline/Formatters/AssertFormatterBase.cs index 1e567a82d1..63e0bcc7fa 100644 --- a/src/PSRule/Pipeline/Formatters/AssertFormatterBase.cs +++ b/src/PSRule/Pipeline/Formatters/AssertFormatterBase.cs @@ -367,7 +367,7 @@ private void FooterRunInfo() return; var elapsed = PipelineContext.CurrentThread.RunTime.Elapsed; - WriteLineFormat(FormatterStrings.FooterRunInfo, PipelineContext.CurrentThread.RunId, elapsed.ToString("c", Thread.CurrentThread.CurrentCulture)); + WriteLineFormat(FormatterStrings.FooterRunInfo, PipelineContext.CurrentThread.RunInstance, elapsed.ToString("c", Thread.CurrentThread.CurrentCulture)); } protected void WriteStatus(string status, string statusIndent, ConsoleColor? statusForeground, ConsoleColor? statusBackground, ConsoleColor? messageForeground, ConsoleColor? messageBackground, string message, string suffix = null) diff --git a/src/PSRule/Pipeline/GetBaselinePipelineBuilder.cs b/src/PSRule/Pipeline/GetBaselinePipelineBuilder.cs index 0c2e708c69..603f324957 100644 --- a/src/PSRule/Pipeline/GetBaselinePipelineBuilder.cs +++ b/src/PSRule/Pipeline/GetBaselinePipelineBuilder.cs @@ -6,9 +6,11 @@ namespace PSRule.Pipeline; +#nullable enable + internal sealed class GetBaselinePipelineBuilder : PipelineBuilderBase { - private string[] _Name; + private string[]? _Name; internal GetBaselinePipelineBuilder(Source[] source, IHostContext hostContext) : base(source, hostContext) { } @@ -16,7 +18,7 @@ internal GetBaselinePipelineBuilder(Source[] source, IHostContext hostContext) /// /// Filter returned baselines by name. /// - public new void Name(string[] name) + public new void Name(string[]? name) { if (name == null || name.Length == 0) return; @@ -37,7 +39,7 @@ public override IPipelineBuilder Configure(PSRuleOption option) return this; } - public override IPipeline Build(IPipelineWriter writer = null) + public override IPipeline Build(IPipelineWriter? writer = null) { var filter = new BaselineFilter(ResolveBaselineGroup(_Name)); return new GetBaselinePipeline( @@ -54,3 +56,5 @@ private static OutputFormat SuppressFormat(OutputFormat? format) format == OutputFormat.Json) ? OutputFormat.None : format.Value; } } + +#nullable restore diff --git a/src/PSRule/Pipeline/InvokePipelineBuilderBase.cs b/src/PSRule/Pipeline/InvokePipelineBuilderBase.cs index ff25f5891b..87069dacff 100644 --- a/src/PSRule/Pipeline/InvokePipelineBuilderBase.cs +++ b/src/PSRule/Pipeline/InvokePipelineBuilderBase.cs @@ -74,15 +74,15 @@ public override IPipelineBuilder Configure(PSRuleOption option) Option.Output.JsonIndent = NormalizeJsonIndentRange(option.Output.JsonIndent); if (option.Rule != null) - Option.Rule = new RuleOption(option.Rule); + Option.Rule = new(option.Rule); if (option.Configuration != null) - Option.Configuration = new ConfigurationOption(option.Configuration); + Option.Configuration = new(option.Configuration); ConfigureBinding(option); - Option.Requires = new RequiresOption(option.Requires); + Option.Requires = [.. option.Requires]; if (option.Suppression.Count > 0) - Option.Suppression = new SuppressionOption(option.Suppression); + Option.Suppression = new(option.Suppression); return this; } diff --git a/src/PSRule/Pipeline/InvokeResult.cs b/src/PSRule/Pipeline/InvokeResult.cs index a95a27b132..783ef09e19 100644 --- a/src/PSRule/Pipeline/InvokeResult.cs +++ b/src/PSRule/Pipeline/InvokeResult.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using PSRule.Definitions.Rules; +using PSRule.Pipeline.Runs; using PSRule.Rules; namespace PSRule.Pipeline; @@ -20,9 +21,10 @@ public sealed class InvokeResult private int _Pass; private int _Fail; - internal InvokeResult() + internal InvokeResult(IRun run) { - _Record = new List(); + Run = run; + _Record = []; _Time = 0; _Total = 0; _Error = 0; @@ -30,6 +32,11 @@ internal InvokeResult() _Fail = 0; } + /// + /// The parent run that generated the result. + /// + internal IRun Run { get; } + /// /// The execution time of all rules in milliseconds. /// @@ -133,6 +140,8 @@ internal void Add(RuleRecord ruleRecord) _Fail++; _Level = _Level.GetWorstCase(ruleRecord.Level); } + + ruleRecord.RunId = Run.Id; _Record.Add(ruleRecord); } } diff --git a/src/PSRule/Pipeline/InvokeRulePipeline.cs b/src/PSRule/Pipeline/InvokeRulePipeline.cs index 40ad347f31..73dc8e617d 100644 --- a/src/PSRule/Pipeline/InvokeRulePipeline.cs +++ b/src/PSRule/Pipeline/InvokeRulePipeline.cs @@ -5,6 +5,7 @@ using PSRule.Configuration; using PSRule.Definitions; using PSRule.Host; +using PSRule.Pipeline.Runs; using PSRule.Rules; namespace PSRule.Pipeline; @@ -65,9 +66,11 @@ public override void Process(PSObject sourceObject) if (next is TargetObject to) { - var result = ProcessTargetObject(to); - _Completed.Add(result); - Pipeline.Writer.WriteObject(result, false); + // var result = ProcessTargetObject(to); + ProcessTargetObject(to); + + // _Completed.Add(result); + // Pipeline.Writer.WriteObject(result, false); } } } @@ -96,104 +99,120 @@ public override void End() Pipeline.Writer.End(Result); } - private InvokeResult ProcessTargetObject(TargetObject targetObject) + /// + /// Process each run with the target object. + /// + private void ProcessTargetObject(TargetObject targetObject) { try { Context.EnterTargetObject(targetObject); - var result = new InvokeResult(); - var ruleCounter = 0; - var suppressedRuleCounter = 0; - var suppressionGroupCounter = new Dictionary(new ISuppressionInfoComparer()); + foreach (var run in Context.Runs) + { + var result = InvokeRun(run, targetObject); + _Completed.Add(result); + Pipeline.Writer.WriteObject(result, false); + } + } + finally + { + Context.ExitTargetObject(); + } + } + + /// + /// Invoke the run for the target object. + /// + private InvokeResult InvokeRun(IRun run, TargetObject targetObject) + { + var result = new InvokeResult(run); + var ruleCounter = 0; + var suppressedRuleCounter = 0; + var suppressionGroupCounter = new Dictionary(new ISuppressionInfoComparer()); - // Process rule blocks ordered by dependency graph - foreach (var ruleBlockTarget in _RuleGraph.GetSingleTarget()) + // Process rule blocks ordered by dependency graph + foreach (var ruleBlockTarget in _RuleGraph.GetSingleTarget()) + { + // Enter rule block scope + var ruleRecord = Context.EnterRuleBlock(ruleBlock: ruleBlockTarget.Value); + ruleCounter++; + + try { - // Enter rule block scope - var ruleRecord = Context.EnterRuleBlock(ruleBlock: ruleBlockTarget.Value); - ruleCounter++; + if (Context.Binding != null && Context.Binding.ShouldFilter) + continue; - try + // Check if dependency failed + if (ruleBlockTarget.Skipped) + { + ruleRecord.OutcomeReason = RuleOutcomeReason.DependencyFail; + } + // Check for suppression + else if (_SuppressionFilter.Match(id: ruleBlockTarget.Value.Id, targetName: ruleRecord.TargetName)) { - if (Context.Binding != null && Context.Binding.ShouldFilter) - continue; - - // Check if dependency failed - if (ruleBlockTarget.Skipped) - { - ruleRecord.OutcomeReason = RuleOutcomeReason.DependencyFail; - } - // Check for suppression - else if (_SuppressionFilter.Match(id: ruleBlockTarget.Value.Id, targetName: ruleRecord.TargetName)) - { - ruleRecord.OutcomeReason = RuleOutcomeReason.Suppressed; - suppressedRuleCounter++; - - if (!_IsSummary) - Context.RuleSuppressed(ruleId: ruleRecord.RuleId); - } - // Check for suppression group - else if (_SuppressionGroupFilter.TrySuppressionGroup(ruleId: ruleRecord.RuleId, targetObject, out var suppression)) - { - ruleRecord.OutcomeReason = RuleOutcomeReason.Suppressed; - if (!_IsSummary) - Context.RuleSuppressionGroup(ruleId: ruleRecord.RuleId, suppression); - else - suppressionGroupCounter[suppression] = suppressionGroupCounter.TryGetValue(suppression, out var count) ? ++count : 1; - } + ruleRecord.OutcomeReason = RuleOutcomeReason.Suppressed; + suppressedRuleCounter++; + + if (!_IsSummary) + Context.RuleSuppressed(ruleId: ruleRecord.RuleId); + } + // Check for suppression group + else if (_SuppressionGroupFilter.TrySuppressionGroup(ruleId: ruleRecord.RuleId, targetObject, out var suppression)) + { + ruleRecord.OutcomeReason = RuleOutcomeReason.Suppressed; + if (!_IsSummary) + Context.RuleSuppressionGroup(ruleId: ruleRecord.RuleId, suppression); else - { - HostHelper.InvokeRuleBlock(context: Context, ruleBlock: ruleBlockTarget.Value, ruleRecord: ruleRecord); - if (ruleRecord.OutcomeReason == RuleOutcomeReason.PreconditionFail) - ruleCounter--; - } - - // Report outcome to dependency graph - if (ruleRecord.Outcome == RuleOutcome.Pass) - { - ruleBlockTarget.Pass(); - Context.Pass(); - } - else if (ruleRecord.Outcome == RuleOutcome.Fail) - { - Result.Fail(ruleRecord.Level); - ruleBlockTarget.Fail(); - Context.Fail(); - } - else if (ruleRecord.Outcome == RuleOutcome.Error) - { - Result.HadErrors = true; - ruleBlockTarget.Fail(); - } - - AddToSummary(ruleBlock: ruleBlockTarget.Value, outcome: ruleRecord.Outcome); + suppressionGroupCounter[suppression] = suppressionGroupCounter.TryGetValue(suppression, out var count) ? ++count : 1; } - finally + else { - // Exit rule block scope - Context.ExitRuleBlock(ruleBlock: ruleBlockTarget.Value); - if (ShouldOutput(ruleRecord.Outcome)) - result.Add(ruleRecord); + HostHelper.InvokeRuleBlock(context: Context, ruleBlock: ruleBlockTarget.Value, ruleRecord: ruleRecord); + if (ruleRecord.OutcomeReason == RuleOutcomeReason.PreconditionFail) + ruleCounter--; } - } - if (ruleCounter == 0) - Context.WarnObjectNotProcessed(); + // Report outcome to dependency graph + if (ruleRecord.Outcome == RuleOutcome.Pass) + { + ruleBlockTarget.Pass(); + Context.Pass(); + } + else if (ruleRecord.Outcome == RuleOutcome.Fail) + { + Result.Fail(ruleRecord.Level); + ruleBlockTarget.Fail(); + Context.Fail(); + } + else if (ruleRecord.Outcome == RuleOutcome.Error) + { + Result.HadErrors = true; + ruleBlockTarget.Fail(); + } - if (_IsSummary) + AddToSummary(ruleBlock: ruleBlockTarget.Value, outcome: ruleRecord.Outcome); + } + finally { - if (suppressedRuleCounter > 0) - Context.WarnRuleCountSuppressed(ruleCount: suppressedRuleCounter); - - foreach (var keyValuePair in suppressionGroupCounter) - Context.RuleSuppressionGroupCount(suppression: keyValuePair.Key, count: keyValuePair.Value); + // Exit rule block scope + Context.ExitRuleBlock(ruleBlock: ruleBlockTarget.Value); + if (ShouldOutput(ruleRecord.Outcome)) + result.Add(ruleRecord); } - return result; } - finally + + if (ruleCounter == 0) + Context.WarnObjectNotProcessed(); + + if (_IsSummary) { - Context.ExitTargetObject(); + if (suppressedRuleCounter > 0) + Context.WarnRuleCountSuppressed(ruleCount: suppressedRuleCounter); + + foreach (var keyValuePair in suppressionGroupCounter) + Context.RuleSuppressionGroupCount(suppression: keyValuePair.Key, count: keyValuePair.Value); } + return result; } private bool ShouldOutput(RuleOutcome outcome) diff --git a/src/PSRule/Pipeline/Output/SarifBuilder.cs b/src/PSRule/Pipeline/Output/SarifBuilder.cs index e51d5b1dd1..bf008c6b81 100644 --- a/src/PSRule/Pipeline/Output/SarifBuilder.cs +++ b/src/PSRule/Pipeline/Output/SarifBuilder.cs @@ -10,8 +10,10 @@ using PSRule.Definitions; using PSRule.Definitions.Rules; using PSRule.Options; +using PSRule.Pipeline.Runs; using PSRule.Resources; using PSRule.Rules; +using Run = Microsoft.CodeAnalysis.Sarif.Run; namespace PSRule.Pipeline.Output; @@ -29,30 +31,22 @@ internal sealed class SarifBuilder private const string LOCATION_KIND_OBJECT = "object"; private const string LOCATION_ID_REPOROOT = "REPO_ROOT"; - private readonly Run _Run; + private readonly Dictionary _Runs; + private readonly Source[]? _Source; private readonly System.Security.Cryptography.HashAlgorithm _ConfiguredHashAlgorithm; private readonly string _ConfiguredHashAlgorithmName; private readonly System.Security.Cryptography.HashAlgorithm? _SHA265; private readonly PSRuleOption _Option; - private readonly Dictionary _Rules; + private readonly Dictionary _Rules; private readonly Dictionary _Extensions; - private readonly Dictionary _Artifacts; - public SarifBuilder(Source[] source, PSRuleOption option) + public SarifBuilder(Source[]? source, PSRuleOption option) { _Option = option; _Rules = []; _Extensions = []; - _Artifacts = []; - _Run = new Run - { - Tool = GetTool(source), - Results = [], - Invocations = GetInvocation(), - AutomationDetails = GetAutomationDetails(), - OriginalUriBaseIds = GetBaseIds(), - VersionControlProvenance = GetVersionControl(option.Repository), - }; + _Runs = []; + _Source = source; var algorithm = option.Execution.HashAlgorithm.GetValueOrDefault(ExecutionOption.Default.HashAlgorithm!.Value); _ConfiguredHashAlgorithm = algorithm.GetHashAlgorithm(); _ConfiguredHashAlgorithmName = algorithm.GetHashAlgorithmName(); @@ -100,27 +94,25 @@ private static Dictionary GetBaseIds() public SarifLog Build() { - AddArtifacts(); - AddOptions(); - - var log = new SarifLog + return new SarifLog { - Runs = new List(1), + Runs = [.. _Runs.Values], }; - log.Runs.Add(_Run); - return log; } - public void Add(RuleRecord record) + public void Add(IRun run, RuleRecord record) { if (record == null) return; - var rule = GetRule(record); + // Get the run data structure. + var runData = GetRun(run); + + var descriptorReference = GetReportingDescriptorReference(runData, record); var result = new Result { - RuleId = rule.Id, - Rule = rule, + RuleId = descriptorReference.Id, + Rule = descriptorReference, Kind = GetKind(record), Level = GetLevel(record), Message = new Message { Text = record.Recommendation }, @@ -129,15 +121,41 @@ public void Add(RuleRecord record) AddFields(result, record); AddAnnotations(result, record); - AddArtifacts(record); + AddArtifacts(runData, record); // SARIF2004: Use the RuleId property instead of Rule for standalone rules. - if (rule.ToolComponent.Guid == TOOL_GUID) + if (descriptorReference.ToolComponent.Guid == TOOL_GUID) { - result.RuleId = rule.Id; + result.RuleId = descriptorReference.Id; result.Rule = null; } - _Run.Results.Add(result); + + // Add the result to the run. + runData.Results.Add(result); + } + + /// + /// Get the run data structure for the specified run. + /// + private Run GetRun(IRun run) + { + // Create the run if it doesn't exist. + if (!_Runs.TryGetValue(run.Id, out var runData)) + { + runData = new Run + { + Tool = GetTool(_Source), + Results = [], + Invocations = GetInvocation(), + AutomationDetails = GetAutomationDetails(run), + OriginalUriBaseIds = GetBaseIds(), + VersionControlProvenance = GetVersionControl(_Option.Repository), + }; + AddOptions(runData); + + _Runs.Add(run.Id, runData); + } + return runData; } /// @@ -178,18 +196,10 @@ private static void AddAnnotations(Result result, RuleRecord record) result.SetProperty("annotations", annotations); } - /// - /// Get collected artifacts. - /// - private void AddArtifacts() - { - _Run.Artifacts = _Artifacts.Values.OrderBy(item => item.Location.Index).ToList(); - } - /// /// Add options to the run. /// - private void AddOptions() + private void AddOptions(Run run) { var s = new JsonSerializer(); s.Converters.Add(new PSObjectJsonConverter()); @@ -201,30 +211,41 @@ private void AddOptions() ["workspace"] = localScope }; - _Run.SetProperty("options", options); + run.SetProperty("options", options); } - private void AddArtifacts(RuleRecord record) + /// + /// Add artifacts to the run. + /// + private void AddArtifacts(Run run, RuleRecord record) { if (record.Source == null || record.Source.Length == 0) return; foreach (var source in record.Source) - AddArtifact(source); + { + AddArtifact(run, source); + } } - private void AddArtifact(TargetSourceInfo source) + /// + /// Add an artifact to the run. + /// + private void AddArtifact(Run run, TargetSourceInfo source) { if (source == null || string.IsNullOrEmpty(source.File)) return; + run.Artifacts ??= []; + var relativePath = source.GetPath(useRelativePath: true); var fullPath = source.GetPath(useRelativePath: false); - if (relativePath == null || fullPath == null || _Artifacts.ContainsKey(relativePath)) return; + if (relativePath == null || fullPath == null || run.Artifacts.Any(item => item.Location.Uri == new Uri(relativePath, UriKind.Relative))) + return; var location = new ArtifactLocation ( uri: new Uri(relativePath, uriKind: UriKind.Relative), uriBaseId: LOCATION_ID_REPOROOT, - index: _Artifacts.Count, + index: run.Artifacts.Count, description: null, properties: null ); @@ -234,9 +255,12 @@ private void AddArtifact(TargetSourceInfo source) Hashes = GetArtifactHash(fullPath) }; - _Artifacts.Add(relativePath, artifact); + run.Artifacts.Add(artifact); } + /// + /// Get the hash of an artifact. + /// private Dictionary? GetArtifactHash(string path) { if (!File.Exists(path)) return null; @@ -253,39 +277,17 @@ private void AddArtifact(TargetSourceInfo source) return result; } - private ReportingDescriptorReference GetRule(RuleRecord record) + private ReportingDescriptorReference GetReportingDescriptorReference(Run run, RuleRecord record) { + // Get the rule descriptor. var id = record.Ref ?? record.RuleId; - if (!_Rules.TryGetValue(id, out var descriptorReference)) - descriptorReference = AddRule(record, id); + var descriptor = GetReportingDescriptor(record, id); - return descriptorReference; - } - - private ReportingDescriptorReference AddRule(RuleRecord record, string id) - { + // Get the tool component. if (string.IsNullOrEmpty(record.Info.ModuleName) || !_Extensions.TryGetValue(record.Info.ModuleName, out var toolComponent)) - toolComponent = _Run.Tool.Driver; + toolComponent = run.Tool.Driver; - // Add the rule to the component. - var descriptor = new ReportingDescriptor - { - Id = id, - Name = record.RuleName, - ShortDescription = GetMessageString(record.Info.Synopsis), - HelpUri = record.Info.GetOnlineHelpUri(), - FullDescription = GetMessageString(record.Info.Description), - MessageStrings = GetMessageStrings(record), - DefaultConfiguration = new ReportingConfiguration - { - Enabled = true, - Level = GetLevel(record.Default.Level), - }, - }; - - toolComponent.Rules.Add(descriptor); - - // Create a reference to the rule. + // Create a reference to the rule descriptor. var descriptorReference = new ReportingDescriptorReference { Id = descriptor.Id, @@ -293,36 +295,67 @@ private ReportingDescriptorReference AddRule(RuleRecord record, string id) { Guid = toolComponent.Guid, Name = toolComponent.Name, - Index = _Run.Tool.Extensions == null ? -1 : _Run.Tool.Extensions.IndexOf(toolComponent), + Index = run.Tool.Extensions == null ? -1 : run.Tool.Extensions.IndexOf(toolComponent), }, }; - _Rules.Add(id, descriptorReference); + toolComponent.Rules ??= []; - // Create a configuration override if applicable. - if (record.Override != null && record.Override.Level.HasValue && record.Override.Level.Value != SeverityLevel.None && record.Override.Level != record.Default.Level) + // Check that the rule is not already added to the tool component. + if (!toolComponent.Rules.Any(item => item.Id == descriptor.Id)) { - if (_Run.Invocations[0].RuleConfigurationOverrides == null) - _Run.Invocations[0].RuleConfigurationOverrides = []; + toolComponent.Rules.Add(descriptor); - _Run.Invocations[0].RuleConfigurationOverrides.Add(new ConfigurationOverride + // Add the rule configuration override. + if (record.Override != null && record.Override.Level.HasValue && record.Override.Level.Value != SeverityLevel.None && record.Override.Level != record.Default.Level) { - Descriptor = descriptorReference, - Configuration = new ReportingConfiguration + run.Invocations[0].RuleConfigurationOverrides ??= []; + run.Invocations[0].RuleConfigurationOverrides.Add(new ConfigurationOverride { - Level = GetLevel(record.Override.Level.Value), - } - }); + Descriptor = descriptorReference, + Configuration = new ReportingConfiguration + { + Level = GetLevel(record.Override.Level.Value), + } + }); + } } return descriptorReference; } - private static RunAutomationDetails? GetAutomationDetails() + private ReportingDescriptor GetReportingDescriptor(RuleRecord record, string id) + { + if (!_Rules.TryGetValue(id, out var descriptor)) + { + // Add the rule to the component. + descriptor = new ReportingDescriptor + { + Id = id, + Name = record.RuleName, + ShortDescription = GetMessageString(record.Info.Synopsis), + HelpUri = record.Info.GetOnlineHelpUri(), + FullDescription = GetMessageString(record.Info.Description), + MessageStrings = GetMessageStrings(record), + DefaultConfiguration = new ReportingConfiguration + { + Enabled = true, + Level = GetLevel(record.Default.Level), + }, + }; + + _Rules.Add(id, descriptor); + } + return descriptor; + } + + private static RunAutomationDetails? GetAutomationDetails(IRun run) { - return PipelineContext.CurrentThread == null ? null : new RunAutomationDetails + return new RunAutomationDetails { - Id = PipelineContext.CurrentThread.RunId, + Id = run.Id, + CorrelationGuid = run.CorrelationGuid, + Description = run.Description?.Text != null ? GetMessage(run.Description?.Text!) : null, }; } @@ -455,7 +488,7 @@ private static FailureLevel GetLevel(SeverityLevel level) }; } - private Tool GetTool(Source[] source) + private Tool GetTool(Source[]? source) { var version = Engine.GetVersion(); return new Tool @@ -466,14 +499,14 @@ private Tool GetTool(Source[] source) SemanticVersion = version, Organization = TOOL_ORG, Guid = TOOL_GUID, - Rules = new List(), + Rules = [], InformationUri = new Uri("https://aka.ms/ps-rule", UriKind.Absolute), }, Extensions = GetExtensions(source), }; } - private List? GetExtensions(Source[] source) + private List? GetExtensions(Source[]? source) { if (source == null || source.Length == 0) return null; diff --git a/src/PSRule/Pipeline/Output/SarifOutputWriter.cs b/src/PSRule/Pipeline/Output/SarifOutputWriter.cs index c1316f0c37..9b59290089 100644 --- a/src/PSRule/Pipeline/Output/SarifOutputWriter.cs +++ b/src/PSRule/Pipeline/Output/SarifOutputWriter.cs @@ -7,19 +7,17 @@ namespace PSRule.Pipeline.Output; -internal sealed class SarifOutputWriter : SerializationOutputWriter -{ - private readonly SarifBuilder _Builder; - private readonly Encoding _Encoding; - private readonly bool _ReportAll; +#nullable enable - internal SarifOutputWriter(Source[] source, PipelineWriter inner, PSRuleOption option, ShouldProcess shouldProcess) - : base(inner, option, shouldProcess) - { - _Builder = new SarifBuilder(source, option); - _Encoding = option.Output.GetEncoding(); - _ReportAll = !option.Output.SarifProblemsOnly.GetValueOrDefault(OutputOption.Default.SarifProblemsOnly.Value); - } +/// +/// An output writer that generates SARIF output. +/// +internal sealed class SarifOutputWriter(Source[]? source, PipelineWriter inner, PSRuleOption option, ShouldProcess? shouldProcess) + : SerializationOutputWriter(inner, option, shouldProcess) +{ + private readonly SarifBuilder _Builder = new(source, option); + private readonly Encoding _Encoding = option.Output.GetEncoding(); + private readonly bool _ReportAll = !option.Output.SarifProblemsOnly.GetValueOrDefault(OutputOption.Default.SarifProblemsOnly!.Value); public override void WriteObject(object sendToPipeline, bool enumerateCollection) { @@ -33,10 +31,15 @@ protected override string Serialize(InvokeResult[] o) { for (var i = 0; o != null && i < o.Length; i++) { + var run = o[i].Run; var records = o[i].AsRecord(); for (var j = 0; j < records.Length; j++) + { if (ShouldReport(records[j])) - _Builder.Add(records[j]); + { + _Builder.Add(run, records[j]); + } + } } var log = _Builder.Build(); using var stream = new MemoryStream(); @@ -53,3 +56,5 @@ private bool ShouldReport(RuleRecord record) (record.Outcome & RuleOutcome.Problem) != RuleOutcome.None; } } + +#nullable restore diff --git a/src/PSRule/Pipeline/PipelineBuilderBase.cs b/src/PSRule/Pipeline/PipelineBuilderBase.cs index 2f040cce09..d0cf4fea45 100644 --- a/src/PSRule/Pipeline/PipelineBuilderBase.cs +++ b/src/PSRule/Pipeline/PipelineBuilderBase.cs @@ -15,6 +15,8 @@ namespace PSRule.Pipeline; +#nullable enable + /// /// A base instance for a pipeline builder. /// @@ -26,13 +28,13 @@ internal abstract class PipelineBuilderBase : IPipelineBuilder protected readonly Source[] Source; protected readonly IHostContext HostContext; - private string[] _Include; - private Hashtable _Tag; - private Configuration.BaselineOption _Baseline; - private string[] _Convention; - private PathFilter _InputFilter; - private PipelineWriter _Writer; - private ILanguageScopeSet _LanguageScopeSet; + private string[]? _Include; + private Hashtable? _Tag; + private Configuration.BaselineOption? _Baseline; + private string[]? _Convention; + private PathFilter? _InputFilter; + private PipelineWriter? _Writer; + private ILanguageScopeSet? _LanguageScopeSet; private readonly HostPipelineWriter _Output; @@ -95,17 +97,18 @@ public virtual IPipelineBuilder Configure(PSRuleOption option) Option.Output = new OutputOption(option.Output); Option.Output.Outcome ??= OutputOption.Default.Outcome; Option.Output.Banner ??= OutputOption.Default.Banner; - Option.Output.Style = GetStyle(option.Output.Style ?? OutputOption.Default.Style.Value); + Option.Output.Style = GetStyle(option.Output.Style ?? OutputOption.Default.Style!.Value); Option.Override = new OverrideOption(option.Override); Option.Repository = GetRepository(option.Repository); + Option.Run = GetRun(option.Run); return this; } /// - public abstract IPipeline Build(IPipelineWriter writer = null); + public abstract IPipeline Build(IPipelineWriter? writer = null); /// - public void Baseline(Configuration.BaselineOption baseline) + public void Baseline(Configuration.BaselineOption? baseline) { if (baseline == null) return; @@ -171,7 +174,7 @@ private static bool TryModuleVersion(string moduleVersion, string requiredVersio /// /// Create a pipeline context. /// - protected PipelineContext PrepareContext((BindTargetMethod bindTargetName, BindTargetMethod bindTargetType, BindTargetMethod bindField) binding, IPipelineWriter writer = default) + protected PipelineContext PrepareContext((BindTargetMethod bindTargetName, BindTargetMethod bindTargetType, BindTargetMethod bindField) binding, IPipelineWriter? writer = default) { writer ??= PrepareWriter(); var unresolved = new List(); @@ -208,15 +211,21 @@ protected ILanguageScopeSet GetLanguageScopeSet() return _LanguageScopeSet = builder.Build(); } - protected string[] ResolveBaselineGroup(string[] name) + protected string[]? ResolveBaselineGroup(string[]? name) { + var result = new List(); for (var i = 0; name != null && i < name.Length; i++) - name[i] = ResolveBaselineGroup(name[i]); - - return name; + { + var n = ResolveBaselineGroup(name[i]); + if (n != null) + { + result.Add(n); + } + } + return result.Count == 0 ? null : [.. result]; } - protected string ResolveBaselineGroup(string name) + protected string? ResolveBaselineGroup(string? name) { if (name == null || name.Length < 2 || !name.StartsWith("@") || Option == null || Option.Baseline == null || Option.Baseline.Group == null || @@ -272,7 +281,7 @@ protected virtual PipelineWriter GetOutput(bool writeHost = false) : _Output; } - protected static string[] GetCulture(string[] culture) + protected static string[]? GetCulture(string[] culture) { var result = new List(); var parent = new List(); @@ -297,7 +306,7 @@ protected static string[] GetCulture(string[] culture) if (parent.Count > 0) result.AddRange(parent); - return result.Count == 0 ? null : result.ToArray(); + return result.Count == 0 ? null : [.. result]; } protected static RepositoryOption GetRepository(RepositoryOption option) @@ -312,6 +321,12 @@ protected static RepositoryOption GetRepository(RepositoryOption option) return result; } + protected static RunOption GetRun(RunOption option) + { + var result = RunOption.Combine(option, RunOption.Default); + return result; + } + /// /// Coalesce execution options with defaults. /// @@ -320,20 +335,20 @@ protected static ExecutionOption GetExecutionOption(ExecutionOption option) var result = ExecutionOption.Combine(option, ExecutionOption.Default); // Handle when preference is set to none. The default should be used. - result.AliasReference = result.AliasReference == ExecutionActionPreference.None ? ExecutionOption.Default.AliasReference.Value : result.AliasReference; - result.DuplicateResourceId = result.DuplicateResourceId == ExecutionActionPreference.None ? ExecutionOption.Default.DuplicateResourceId.Value : result.DuplicateResourceId; - result.InvariantCulture = result.InvariantCulture == ExecutionActionPreference.None ? ExecutionOption.Default.InvariantCulture.Value : result.InvariantCulture; - result.RuleExcluded = result.RuleExcluded == ExecutionActionPreference.None ? ExecutionOption.Default.RuleExcluded.Value : result.RuleExcluded; - result.RuleInconclusive = result.RuleInconclusive == ExecutionActionPreference.None ? ExecutionOption.Default.RuleInconclusive.Value : result.RuleInconclusive; - result.RuleSuppressed = result.RuleSuppressed == ExecutionActionPreference.None ? ExecutionOption.Default.RuleSuppressed.Value : result.RuleSuppressed; - result.SuppressionGroupExpired = result.SuppressionGroupExpired == ExecutionActionPreference.None ? ExecutionOption.Default.SuppressionGroupExpired.Value : result.SuppressionGroupExpired; - result.UnprocessedObject = result.UnprocessedObject == ExecutionActionPreference.None ? ExecutionOption.Default.UnprocessedObject.Value : result.UnprocessedObject; + result.AliasReference = result.AliasReference == ExecutionActionPreference.None ? ExecutionOption.Default.AliasReference!.Value : result.AliasReference; + result.DuplicateResourceId = result.DuplicateResourceId == ExecutionActionPreference.None ? ExecutionOption.Default.DuplicateResourceId!.Value : result.DuplicateResourceId; + result.InvariantCulture = result.InvariantCulture == ExecutionActionPreference.None ? ExecutionOption.Default.InvariantCulture!.Value : result.InvariantCulture; + result.RuleExcluded = result.RuleExcluded == ExecutionActionPreference.None ? ExecutionOption.Default.RuleExcluded!.Value : result.RuleExcluded; + result.RuleInconclusive = result.RuleInconclusive == ExecutionActionPreference.None ? ExecutionOption.Default.RuleInconclusive!.Value : result.RuleInconclusive; + result.RuleSuppressed = result.RuleSuppressed == ExecutionActionPreference.None ? ExecutionOption.Default.RuleSuppressed!.Value : result.RuleSuppressed; + result.SuppressionGroupExpired = result.SuppressionGroupExpired == ExecutionActionPreference.None ? ExecutionOption.Default.SuppressionGroupExpired!.Value : result.SuppressionGroupExpired; + result.UnprocessedObject = result.UnprocessedObject == ExecutionActionPreference.None ? ExecutionOption.Default.UnprocessedObject!.Value : result.UnprocessedObject; return result; } - protected PathFilter GetInputObjectSourceFilter() + protected PathFilter? GetInputObjectSourceFilter() { - return Option.Input.IgnoreObjectSource.GetValueOrDefault(InputOption.Default.IgnoreObjectSource.Value) ? GetInputFilter() : null; + return Option.Input.IgnoreObjectSource.GetValueOrDefault(InputOption.Default.IgnoreObjectSource!.Value) ? GetInputFilter() : null; } protected PathFilter GetInputFilter() @@ -341,8 +356,8 @@ protected PathFilter GetInputFilter() if (_InputFilter == null) { var basePath = Environment.GetWorkingPath(); - var ignoreGitPath = Option.Input.IgnoreGitPath ?? InputOption.Default.IgnoreGitPath.Value; - var ignoreRepositoryCommon = Option.Input.IgnoreRepositoryCommon ?? InputOption.Default.IgnoreRepositoryCommon.Value; + var ignoreGitPath = Option.Input.IgnoreGitPath ?? InputOption.Default.IgnoreGitPath!.Value; + var ignoreRepositoryCommon = Option.Input.IgnoreRepositoryCommon ?? InputOption.Default.IgnoreRepositoryCommon!.Value; var builder = PathFilterBuilder.Create(basePath, Option.Input.PathIgnore, ignoreGitPath, ignoreRepositoryCommon); builder.UseGitIgnore(); @@ -397,10 +412,10 @@ protected static int NormalizeJsonIndentRange(int? jsonIndent) return MIN_JSON_INDENT; } - protected bool TryChangedFiles(out string[] files) + protected bool TryChangedFiles(out string[]? files) { files = null; - if (!Option.Input.IgnoreUnchangedPath.GetValueOrDefault(InputOption.Default.IgnoreUnchangedPath.Value) || + if (!Option.Input.IgnoreUnchangedPath.GetValueOrDefault(InputOption.Default.IgnoreUnchangedPath!.Value) || !GitHelper.TryGetChangedFiles(Option.Repository.BaseRef, "d", null, out files)) return false; @@ -431,3 +446,5 @@ protected static OutputStyle GetStyle(OutputStyle style) OutputStyle.Client; } } + +#nullable restore diff --git a/src/PSRule/Pipeline/PipelineContext.cs b/src/PSRule/Pipeline/PipelineContext.cs index 3294e2e7c7..cba68d9dff 100644 --- a/src/PSRule/Pipeline/PipelineContext.cs +++ b/src/PSRule/Pipeline/PipelineContext.cs @@ -35,7 +35,7 @@ internal sealed class PipelineContext : IPipelineContext, IBindingContext [ThreadStatic] internal static PipelineContext? CurrentThread; - private readonly OptionContextBuilder _OptionBuilder; + internal readonly OptionContextBuilder OptionBuilder; // Configuration parameters @@ -61,7 +61,7 @@ internal sealed class PipelineContext : IPipelineContext, IBindingContext internal readonly IHostContext? HostContext; private readonly Func _GetReader; internal IPipelineReader? Reader { get; private set; } - internal readonly string RunId; + internal readonly string RunInstance; internal readonly Stopwatch RunTime; @@ -83,7 +83,7 @@ private PipelineContext(PSRuleOption option, IHostContext? hostContext, Func().Where(suppressionGroupFilter.Match).Select(i => i.ToSuppressionGroupVisitor(runspaceContext)).ToList(); - _DefaultOptionContext = _OptionBuilder.Build(null); - _OptionBuilder.CheckObsolete(runspaceContext); + _DefaultOptionContext = OptionBuilder.Build(null); + OptionBuilder.CheckObsolete(runspaceContext); Reader = _GetReader(); } internal void UpdateLanguageScope(ILanguageScope languageScope) { - var context = _OptionBuilder.Build(languageScope.Name); + var context = OptionBuilder.Build(languageScope.Name); languageScope.Configure(context); } diff --git a/src/PSRule/Pipeline/ResourceCacheBuilder.cs b/src/PSRule/Pipeline/ResourceCacheBuilder.cs index f94a3eca88..1ba45cf8ef 100644 --- a/src/PSRule/Pipeline/ResourceCacheBuilder.cs +++ b/src/PSRule/Pipeline/ResourceCacheBuilder.cs @@ -12,10 +12,10 @@ namespace PSRule.Pipeline; /// /// Defines a builder to create a resource cache. /// -internal sealed class ResourceCacheBuilder(IPipelineWriter writer, ILanguageScopeSet languageScopeSet) +internal sealed class ResourceCacheBuilder(IPipelineWriter? writer, ILanguageScopeSet languageScopeSet) { private IEnumerable? _Resources; - private readonly IPipelineWriter _Writer = writer; + private readonly IPipelineWriter? _Writer = writer; public ResourceCacheBuilder Import(Source[]? sources) { diff --git a/src/PSRule/Pipeline/ResourceCacheDiscoveryContext.cs b/src/PSRule/Pipeline/ResourceCacheDiscoveryContext.cs index 1c082cbb09..d91e3b851f 100644 --- a/src/PSRule/Pipeline/ResourceCacheDiscoveryContext.cs +++ b/src/PSRule/Pipeline/ResourceCacheDiscoveryContext.cs @@ -13,13 +13,13 @@ namespace PSRule.Pipeline; /// /// Define a context used for early stage resource discovery. /// -internal sealed class ResourceCacheDiscoveryContext(IPipelineWriter writer, ILanguageScopeSet languageScopeSet) : IResourceDiscoveryContext +internal sealed class ResourceCacheDiscoveryContext(IPipelineWriter? writer, ILanguageScopeSet languageScopeSet) : IResourceDiscoveryContext { private readonly ILanguageScopeSet _LanguageScopeSet = languageScopeSet; private ILanguageScope? _CurrentLanguageScope; - public IPipelineWriter Writer { get; } = writer; + public IPipelineWriter? Writer { get; } = writer; public ISourceFile? Source { get; private set; } diff --git a/src/PSRule/Pipeline/Runs/IRun.cs b/src/PSRule/Pipeline/Runs/IRun.cs new file mode 100644 index 0000000000..a7b9618e85 --- /dev/null +++ b/src/PSRule/Pipeline/Runs/IRun.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using PSRule.Definitions; +using PSRule.Runtime; + +namespace PSRule.Pipeline.Runs; + +#nullable enable + +/// +/// A logical run. +/// +public interface IRun : IConfiguration +{ + /// + /// A unique identifier for the run. + /// + string Id { get; } + + /// + /// A description of the logical run. + /// + InfoString? Description { get; } + + /// + /// A correlation identifier for all related runs. + /// + string CorrelationGuid { get; } +} + +#nullable restore diff --git a/src/PSRule/Pipeline/Runs/Run.cs b/src/PSRule/Pipeline/Runs/Run.cs new file mode 100644 index 0000000000..3d818a95c0 --- /dev/null +++ b/src/PSRule/Pipeline/Runs/Run.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using PSRule.Data; +using PSRule.Definitions; +using PSRule.Definitions.Rules; +using PSRule.Options; +using PSRule.Runtime.Binding; + +namespace PSRule.Pipeline.Runs; + +#nullable enable + +/// +/// A run. +/// +[DebuggerDisplay("{Id}")] +internal sealed class Run(string id, InfoString description, string correlationGuid) : IRun +{ + private ITargetBinder _TargetBinder; + private WildcardMap? _Override; + + /// + /// A unique identifier for the run. + /// + public string Id { get; } = id; + + /// + /// A correlation identifier for all related runs. + /// + public string CorrelationGuid { get; } = correlationGuid; + + /// + /// A description of the logical run. + /// + public InfoString Description { get; } = description; + + /// + /// The results from the run. + /// + public InvokeResult? Result { get; set; } + + /// + /// Get an ordered culture preference list which will be tries for finding help. + /// + public string[]? Culture { get; private set; } + + #region IConfiguration + + /// + public object? GetValueOrDefault(string configurationKey, object? defaultValue = null) + { + throw new NotImplementedException(); + } + + /// + public bool TryConfigurationValue(string configurationKey, out object? value) + { + throw new NotImplementedException(); + } + + #endregion IConfiguration + + /// + public bool TryGetOverride(ResourceId id, out RuleOverride? value) + { + value = default; + if (_Override == null) return false; + + return _Override.TryGetValue(id.Value, out value) || + _Override.TryGetValue(id.Name, out value); + } + + public void Configure(OptionContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + + // _Configuration = context.Configuration; + // _Configuration ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + // WithFilter(context.RuleFilter); + // WithFilter(context.ConventionFilter); + // _BindingComparer = context.Binding.GetComparer(); + Culture = context.Output.Culture; + + var builder = new TargetBinderBuilder(context.BindTargetName, context.BindTargetType, context.BindField, context.InputTargetType); + _TargetBinder = builder.Build(context.Binding); + _Override = WithOverride(context.Override); + } + + private static WildcardMap? WithOverride(OverrideOption option) + { + if (option == null || option.Level == null) + return default; + + var overrides = option.Level + .Where(l => l.Value != SeverityLevel.None) + .Select(l => new KeyValuePair(l.Key, new RuleOverride { Level = l.Value })); + + return new WildcardMap(overrides); + } +} + +#nullable restore diff --git a/src/PSRule/Pipeline/Runs/RunCollection.cs b/src/PSRule/Pipeline/Runs/RunCollection.cs new file mode 100644 index 0000000000..93cbd6aff2 --- /dev/null +++ b/src/PSRule/Pipeline/Runs/RunCollection.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; + +namespace PSRule.Pipeline.Runs; + +#nullable enable + +/// +/// A collection of runs. +/// +internal sealed class RunCollection : IEnumerable +{ + private readonly List _Runs; + + public RunCollection() + { + _Runs = []; + } + + /// + /// Add a run to the collection. + /// + public void Add(Run run) + { + _Runs.Add(run); + } + + #region IEnumerable + + public IEnumerator GetEnumerator() + { + return _Runs.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + #endregion IEnumerable +} + +#nullable restore diff --git a/src/PSRule/Pipeline/Runs/RunCollectionBuilder.cs b/src/PSRule/Pipeline/Runs/RunCollectionBuilder.cs new file mode 100644 index 0000000000..59226c7665 --- /dev/null +++ b/src/PSRule/Pipeline/Runs/RunCollectionBuilder.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using PSRule.Configuration; +using PSRule.Definitions; +using PSRule.Options; + +namespace PSRule.Pipeline.Runs; + +#nullable enable + +/// +/// A builder to create a . +/// +internal sealed class RunCollectionBuilder(PSRuleOption? option, string instance) +{ + private const char SLASH = '/'; + private const char SPACE = ' '; + private const char DOT = '.'; + + private readonly string _Category = NormalizeCategory(option?.Run?.Category); + private readonly string _Description = option?.Run?.Description ?? RunOption.Default.Description!; + private readonly string _Instance = instance ?? throw new ArgumentNullException(nameof(instance)); + + /// + /// A correlation identifier for all related runs. + /// + private readonly string _CorrelationGuid = Guid.NewGuid().ToString(); + + /// + /// Build a . + /// + public RunCollection Build() + { + var result = new RunCollection(); + + result.Add(new Run( + id: NormalizeId(_Category, string.Empty, _Instance), + description: new InfoString(_Description), + correlationGuid: _CorrelationGuid + )); + + return result; + } + + /// + /// Trim out any leading or trailing whitespace, slashes, or dots. + /// + private static string NormalizeCategory(string? category) + { + var result = category?.TrimStart(SPACE, SLASH)?.TrimEnd(SPACE, SLASH, DOT); + return string.IsNullOrWhiteSpace(result) ? RunOption.Default.Category! : result!; + } + + /// + /// Normalize the run identifier to remove segments that are not required. + /// For example: NormalizeId("Category", "Name", "Instance") => "Category/Name/Instance" + /// + /// The category of the run. + /// An optional name of the run. The name is ignored if it is empty, whitespace, or .. + /// The instance of the run. + /// A formatted string with each segment separated by a /. + private static string NormalizeId(string category, string name, string instance) + { + return name == "." || string.IsNullOrWhiteSpace(name) + ? string.Concat(category, SLASH, instance) + : string.Concat(category, SLASH, name, SLASH, instance); + } +} + +#nullable restore diff --git a/src/PSRule/Pipeline/SerializationOutputWriter.cs b/src/PSRule/Pipeline/SerializationOutputWriter.cs index d270febf00..9e2f6062d0 100644 --- a/src/PSRule/Pipeline/SerializationOutputWriter.cs +++ b/src/PSRule/Pipeline/SerializationOutputWriter.cs @@ -6,11 +6,11 @@ namespace PSRule.Pipeline; -internal abstract class SerializationOutputWriter : ResultOutputWriter -{ - protected SerializationOutputWriter(IPipelineWriter inner, PSRuleOption option, ShouldProcess shouldProcess) - : base(inner, option, shouldProcess) { } +#nullable enable +internal abstract class SerializationOutputWriter(IPipelineWriter inner, PSRuleOption option, ShouldProcess? shouldProcess) + : ResultOutputWriter(inner, option, shouldProcess) +{ public sealed override void End(IPipelineResult result) { var results = GetResults(); @@ -47,3 +47,5 @@ private void ProcessError(T[] results) } } } + +#nullable restore diff --git a/src/PSRule/Rules/RuleRecord.cs b/src/PSRule/Rules/RuleRecord.cs index e8305cdc4c..cec187e848 100644 --- a/src/PSRule/Rules/RuleRecord.cs +++ b/src/PSRule/Rules/RuleRecord.cs @@ -27,10 +27,9 @@ public sealed class RuleRecord : IDetailedRuleResultV2 internal readonly ResultDetail _Detail; - internal RuleRecord(string runId, ResourceId ruleId, string @ref, TargetObject targetObject, string targetName, string targetType, IResourceTags tag, RuleHelpInfo info, Hashtable? field, RuleProperties @default, ISourceExtent? extent, RuleOutcome outcome = RuleOutcome.None, RuleOutcomeReason reason = RuleOutcomeReason.None, RuleOverride? @override = null) + internal RuleRecord(ResourceId ruleId, string @ref, TargetObject targetObject, string targetName, string targetType, IResourceTags tag, RuleHelpInfo info, Hashtable? field, RuleProperties @default, ISourceExtent? extent, RuleOutcome outcome = RuleOutcome.None, RuleOutcomeReason reason = RuleOutcomeReason.None, RuleOverride? @override = null) { _TargetObject = targetObject; - RunId = runId; RuleId = ruleId.Value; RuleName = ruleId.Name; Ref = @ref; @@ -58,7 +57,7 @@ internal RuleRecord(string runId, ResourceId ruleId, string @ref, TargetObject t /// A unique identifier for the run. /// [JsonProperty(PropertyName = "runId")] - public string RunId { get; } + public string? RunId { get; internal set; } /// /// A unique identifier for the rule. @@ -156,7 +155,7 @@ internal RuleRecord(string runId, ResourceId ruleId, string @ref, TargetObject t /// [DefaultValue(null)] [JsonProperty(PropertyName = "tag")] - public Hashtable Tag { get; } + public Hashtable? Tag { get; } /// /// Help info for the rule. @@ -177,7 +176,7 @@ internal RuleRecord(string runId, ResourceId ruleId, string @ref, TargetObject t /// [DefaultValue(null)] [JsonProperty(PropertyName = "error")] - public ErrorInfo Error { get; internal set; } + public ErrorInfo? Error { get; internal set; } /// /// Source of target object. diff --git a/src/PSRule/Runtime/RunspaceContext.cs b/src/PSRule/Runtime/RunspaceContext.cs index 4445124cd8..ff680a5276 100644 --- a/src/PSRule/Runtime/RunspaceContext.cs +++ b/src/PSRule/Runtime/RunspaceContext.cs @@ -9,6 +9,7 @@ using PSRule.Definitions.Conventions; using PSRule.Options; using PSRule.Pipeline; +using PSRule.Pipeline.Runs; using PSRule.Resources; using PSRule.Rules; using PSRule.Runtime.Binding; @@ -100,16 +101,9 @@ internal RunspaceContext(PipelineContext pipeline) internal ITargetBinder? TargetBinder { get; private set; } - private ILanguageScope? _CurrentLanguageScope; + public IEnumerable Runs { get; private set; } = []; - internal ILanguageScope? LanguageScope - { - [DebuggerStepThrough] - get - { - return _CurrentLanguageScope; - } - } + public ILanguageScope? LanguageScope { get; private set; } internal bool IsScope(RunspaceScope scope) { @@ -524,7 +518,7 @@ public void EnterLanguageScope(ISourceFile file) if (!Pipeline.LanguageScope.TryScope(file.Module, out var scope)) throw new Exception("Language scope is unknown."); - _CurrentLanguageScope = scope; + LanguageScope = scope; if (TargetObject != null && LanguageScope != null) Binding = LanguageScope.Bind(TargetObject); @@ -535,7 +529,7 @@ public void EnterLanguageScope(ISourceFile file) public void ExitLanguageScope(ISourceFile file) { // Look at scope popping and validation. - _CurrentLanguageScope = null; + LanguageScope = null; Source = null; } @@ -590,7 +584,6 @@ public RuleRecord EnterRuleBlock(RuleBlock ruleBlock) _RuleErrors = 0; RuleBlock = ruleBlock; RuleRecord = new RuleRecord( - runId: Pipeline.RunId, ruleId: ruleBlock.Id, @ref: ruleBlock.Ref.GetValueOrDefault().Name, targetObject: TargetObject!, @@ -727,6 +720,11 @@ public void Initialize(Source[] source) Array.Sort(_Conventions, new ConventionComparer(Pipeline.GetConventionOrder)); RunConventionInitialize(); + + //Pipeline.OptionBuilder.Build() + + // Split each run based on baselines. + Runs = new RunCollectionBuilder(Pipeline.Option, Pipeline.RunInstance).Build(); } public void Begin() diff --git a/tests/PSRule.Tests/AssertFormatterTests.cs b/tests/PSRule.Tests/AssertFormatterTests.cs index 5a77712470..48cde5b726 100644 --- a/tests/PSRule.Tests/AssertFormatterTests.cs +++ b/tests/PSRule.Tests/AssertFormatterTests.cs @@ -1,12 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Management.Automation; using PSRule.Configuration; using PSRule.Definitions; using PSRule.Definitions.Rules; using PSRule.Pipeline; using PSRule.Pipeline.Formatters; +using PSRule.Pipeline.Runs; using PSRule.Rules; namespace PSRule; @@ -380,10 +382,10 @@ FAIL Test2 private static InvokeResult GetPassResult() { - var result = new InvokeResult(); + var run = new Run("run-001", new InfoString("Test run", null), Guid.Empty.ToString()); + var result = new InvokeResult(run); result.Add(new RuleRecord ( - runId: "run-001", ruleId: ResourceId.Parse(".\\Test"), @ref: "", targetObject: new TargetObject(new PSObject()), @@ -405,10 +407,10 @@ private static InvokeResult GetPassResult() private static InvokeResult GetFailResult(SeverityLevel level = SeverityLevel.Error) { - var result = new InvokeResult(); + var run = new Run("run-001", new InfoString("Test run", null), Guid.Empty.ToString()); + var result = new InvokeResult(run); result.Add(new RuleRecord ( - runId: "run-001", ruleId: ResourceId.Parse(".\\Test1"), @ref: "", targetObject: new TargetObject(new PSObject()), @@ -427,7 +429,6 @@ private static InvokeResult GetFailResult(SeverityLevel level = SeverityLevel.Er )); result.Add(new RuleRecord ( - runId: "run-001", ruleId: ResourceId.Parse(".\\Test2"), @ref: "", targetObject: new TargetObject(new PSObject()), diff --git a/tests/PSRule.Tests/OutputWriterTests.cs b/tests/PSRule.Tests/OutputWriterTests.cs deleted file mode 100644 index 7c56aceb94..0000000000 --- a/tests/PSRule.Tests/OutputWriterTests.cs +++ /dev/null @@ -1,522 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Collections; -using System.IO; -using System.Linq; -using System.Management.Automation; -using System.Xml; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using PSRule.Definitions; -using PSRule.Definitions.Rules; -using PSRule.Pipeline; -using PSRule.Pipeline.Output; -using PSRule.Rules; - -namespace PSRule; - -public sealed class OutputWriterTests : ContextBaseTests -{ - [Fact] - public void Sarif() - { - var option = GetOption(); - option.Output.SarifProblemsOnly = false; - option.Repository.Url = "https://github.com/microsoft/PSRule.UnitTest"; - var output = new TestWriter(option); - var result = new InvokeResult(); - result.Add(GetPass()); - result.Add(GetFail()); - result.Add(GetFail("rid-003", SeverityLevel.Warning)); - result.Add(GetFail("rid-004", SeverityLevel.Information)); - var writer = new SarifOutputWriter(null, output, option, null); - writer.Begin(); - writer.WriteObject(result, false); - writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); - - var actual = JsonConvert.DeserializeObject(output.Output.OfType().FirstOrDefault()); - Assert.NotNull(actual); - Assert.Equal("PSRule", actual["runs"][0]["tool"]["driver"]["name"].Value()); - Assert.Equal("0.0.1", actual["runs"][0]["tool"]["driver"]["semanticVersion"].Value().Split('+')[0]); - Assert.Equal("https://github.com/microsoft/PSRule.UnitTest", actual["runs"][0]["versionControlProvenance"][0]["repositoryUri"].Value()); - - // Pass - Assert.Equal("TestModule\\rule-001", actual["runs"][0]["results"][0]["ruleId"].Value()); - Assert.Equal("none", actual["runs"][0]["results"][0]["level"].Value()); - - // Fail with error - Assert.Equal("rid-002", actual["runs"][0]["results"][1]["ruleId"].Value()); - Assert.Equal("error", actual["runs"][0]["results"][1]["level"].Value()); - Assert.Equal("Custom annotation", actual["runs"][0]["results"][1]["properties"]["annotations"]["annotation-data"].Value()); - Assert.Equal("Custom field data", actual["runs"][0]["results"][1]["properties"]["fields"]["field-data"].Value()); - - // Fail with warning - Assert.Equal("rid-003", actual["runs"][0]["results"][2]["ruleId"].Value()); - Assert.Null(actual["runs"][0]["results"][2]["level"]); - - // Fail with note - Assert.Equal("rid-004", actual["runs"][0]["results"][3]["ruleId"].Value()); - Assert.Equal("note", actual["runs"][0]["results"][3]["level"].Value()); - - // Check options - Assert.Equal(option.Repository.Url, actual["runs"][0]["properties"]["options"]["workspace"]["Repository"]["Url"].Value()); - Assert.False(actual["runs"][0]["properties"]["options"]["workspace"]["Output"]["SarifProblemsOnly"].Value()); - } - - [Fact] - public void SarifProblemsOnly() - { - var option = GetOption(); - var output = new TestWriter(option); - var result = new InvokeResult(); - result.Add(GetPass()); - result.Add(GetFail()); - result.Add(GetFail("rid-003", SeverityLevel.Warning)); - result.Add(GetFail("rid-004", SeverityLevel.Information)); - var writer = new SarifOutputWriter(null, output, option, null); - writer.Begin(); - writer.WriteObject(result, false); - writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); - - var actual = JsonConvert.DeserializeObject(output.Output.OfType().FirstOrDefault()); - Assert.NotNull(actual); - Assert.Equal("PSRule", actual["runs"][0]["tool"]["driver"]["name"].Value()); - Assert.Equal("0.0.1", actual["runs"][0]["tool"]["driver"]["semanticVersion"].Value().Split('+')[0]); - - // Fail with error - Assert.Equal("rid-002", actual["runs"][0]["results"][0]["ruleId"].Value()); - Assert.Equal("error", actual["runs"][0]["results"][0]["level"].Value()); - - // Fail with warning (default value is omitted) - Assert.Equal("rid-003", actual["runs"][0]["results"][1]["ruleId"].Value()); - Assert.Null(actual["runs"][0]["results"][1]["level"]); - - // Fail with note - Assert.Equal("rid-004", actual["runs"][0]["results"][2]["ruleId"].Value()); - Assert.Equal("note", actual["runs"][0]["results"][2]["level"].Value()); - } - - [Fact] - public void Output_WhenRuleLevelIsOverridden_ShouldReturnOverrideInSarifFormat() - { - var option = GetOption(); - var output = new TestWriter(option); - var result = new InvokeResult(); - result.Add(GetPass()); - result.Add(GetFail()); - result.Add(GetFail("rid-003", SeverityLevel.Warning, overrideLevel: SeverityLevel.Information)); - result.Add(GetFail("rid-004", SeverityLevel.Information, overrideLevel: SeverityLevel.Warning)); - var writer = new SarifOutputWriter(null, output, option, null); - writer.Begin(); - writer.WriteObject(result, false); - writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); - - var doc = JsonConvert.DeserializeObject(output.Output.OfType().FirstOrDefault()); - Assert.NotNull(doc); - Assert.Equal("PSRule", doc["runs"][0]["tool"]["driver"]["name"].Value()); - Assert.Equal("0.0.1", doc["runs"][0]["tool"]["driver"]["semanticVersion"].Value().Split('+')[0]); - - // Fail with error - var actual = doc["runs"][0]["results"].Where(r => r["ruleId"].Value() == "rid-002").FirstOrDefault(); - Assert.Equal("error", actual["level"].Value()); - - // Fail with note - actual = doc["runs"][0]["results"].Where(r => r["ruleId"].Value() == "rid-003").FirstOrDefault(); - Assert.Equal("note", actual["level"].Value()); - - var ruleDefault = doc["runs"][0]["tool"]["driver"]["rules"].Where(r => r["id"].Value() == "rid-003").FirstOrDefault(); - Assert.Null(ruleDefault["defaultConfiguration"]); - - var ruleOverride = doc["runs"][0]["invocations"][0]["ruleConfigurationOverrides"].Where(r => r["descriptor"]["id"].Value() == "rid-003").FirstOrDefault(); - Assert.Equal("note", actual["level"].Value()); - - // Fail with warning (default value is omitted) - actual = doc["runs"][0]["results"].Where(r => r["ruleId"].Value() == "rid-004").FirstOrDefault(); - Assert.Null(actual["level"]); - - ruleDefault = doc["runs"][0]["tool"]["driver"]["rules"].Where(r => r["id"].Value() == "rid-004").FirstOrDefault(); - Assert.Equal("note", ruleDefault["defaultConfiguration"]["level"].Value()); - - ruleOverride = doc["runs"][0]["invocations"][0]["ruleConfigurationOverrides"].Where(r => r["descriptor"]["id"].Value() == "rid-004").FirstOrDefault(); - Assert.Null(actual["level"]); - } - - [Fact] - public void Yaml() - { - var option = GetOption(); - option.Repository.Url = "https://github.com/microsoft/PSRule.UnitTest"; - var output = new TestWriter(option); - var result = new InvokeResult(); - result.Add(GetPass()); - result.Add(GetFail()); - result.Add(GetFail("rid-003", SeverityLevel.Warning)); - result.Add(GetFail("rid-004", SeverityLevel.Information)); - var writer = new YamlOutputWriter(output, option, null); - writer.Begin(); - writer.WriteObject(result, false); - writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); - - Assert.Equal(@"- detail: - reason: [] - info: - moduleName: TestModule - recommendation: >- - Recommendation for rule 001 - - over two lines. - level: Error - outcome: Pass - outcomeReason: Processed - ruleName: rule-001 - runId: run-001 - source: [] - tag: {} - targetName: TestObject1 - targetType: TestType - time: 500 -- detail: - reason: [] - field: - field-data: Custom field data - info: - annotations: - annotation-data: Custom annotation - moduleName: TestModule - recommendation: Recommendation for rule 002 - level: Error - outcome: Fail - outcomeReason: Processed - ref: rid-002 - ruleName: rule-002 - runId: run-001 - source: [] - tag: {} - targetName: TestObject1 - targetType: TestType - time: 1000 -- detail: - reason: [] - field: - field-data: Custom field data - info: - annotations: - annotation-data: Custom annotation - moduleName: TestModule - recommendation: Recommendation for rule 002 - level: Warning - outcome: Fail - outcomeReason: Processed - ref: rid-003 - ruleName: rule-002 - runId: run-001 - source: [] - tag: {} - targetName: TestObject1 - targetType: TestType - time: 1000 -- detail: - reason: [] - field: - field-data: Custom field data - info: - annotations: - annotation-data: Custom annotation - moduleName: TestModule - recommendation: Recommendation for rule 002 - level: Information - outcome: Fail - outcomeReason: Processed - ref: rid-004 - ruleName: rule-002 - runId: run-001 - source: [] - tag: {} - targetName: TestObject1 - targetType: TestType - time: 1000 -", output.Output.OfType().FirstOrDefault()); - } - - [Fact] - public void Json() - { - var option = GetOption(); - option.Output.JsonIndent = 2; - option.Repository.Url = "https://github.com/microsoft/PSRule.UnitTest"; - var output = new TestWriter(option); - var result = new InvokeResult(); - result.Add(GetPass()); - result.Add(GetFail()); - result.Add(GetFail("rid-003", SeverityLevel.Warning)); - result.Add(GetFail("rid-004", SeverityLevel.Information)); - var writer = new JsonOutputWriter(output, option, null); - writer.Begin(); - writer.WriteObject(result, false); - writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); - - Assert.Equal(@"[ - { - ""detail"": {}, - ""info"": { - ""displayName"": ""Rule 001"", - ""moduleName"": ""TestModule"", - ""name"": ""rule-001"", - ""recommendation"": ""Recommendation for rule 001\r\nover two lines."", - ""synopsis"": ""This is rule 001."" - }, - ""level"": ""Error"", - ""outcome"": ""Pass"", - ""outcomeReason"": ""Processed"", - ""ruleName"": ""rule-001"", - ""runId"": ""run-001"", - ""source"": [], - ""tag"": {}, - ""targetName"": ""TestObject1"", - ""targetType"": ""TestType"", - ""time"": 500 - }, - { - ""detail"": {}, - ""field"": { - ""field-data"": ""Custom field data"" - }, - ""info"": { - ""annotations"": { - ""annotation-data"": ""Custom annotation"" - }, - ""displayName"": ""Rule 002"", - ""moduleName"": ""TestModule"", - ""name"": ""rule-002"", - ""recommendation"": ""Recommendation for rule 002"", - ""synopsis"": ""This is rule 002."" - }, - ""level"": ""Error"", - ""outcome"": ""Fail"", - ""outcomeReason"": ""Processed"", - ""ref"": ""rid-002"", - ""ruleName"": ""rule-002"", - ""runId"": ""run-001"", - ""source"": [], - ""tag"": {}, - ""targetName"": ""TestObject1"", - ""targetType"": ""TestType"", - ""time"": 1000 - }, - { - ""detail"": {}, - ""field"": { - ""field-data"": ""Custom field data"" - }, - ""info"": { - ""annotations"": { - ""annotation-data"": ""Custom annotation"" - }, - ""displayName"": ""Rule 002"", - ""moduleName"": ""TestModule"", - ""name"": ""rule-002"", - ""recommendation"": ""Recommendation for rule 002"", - ""synopsis"": ""This is rule 002."" - }, - ""level"": ""Warning"", - ""outcome"": ""Fail"", - ""outcomeReason"": ""Processed"", - ""ref"": ""rid-003"", - ""ruleName"": ""rule-002"", - ""runId"": ""run-001"", - ""source"": [], - ""tag"": {}, - ""targetName"": ""TestObject1"", - ""targetType"": ""TestType"", - ""time"": 1000 - }, - { - ""detail"": {}, - ""field"": { - ""field-data"": ""Custom field data"" - }, - ""info"": { - ""annotations"": { - ""annotation-data"": ""Custom annotation"" - }, - ""displayName"": ""Rule 002"", - ""moduleName"": ""TestModule"", - ""name"": ""rule-002"", - ""recommendation"": ""Recommendation for rule 002"", - ""synopsis"": ""This is rule 002."" - }, - ""level"": ""Information"", - ""outcome"": ""Fail"", - ""outcomeReason"": ""Processed"", - ""ref"": ""rid-004"", - ""ruleName"": ""rule-002"", - ""runId"": ""run-001"", - ""source"": [], - ""tag"": {}, - ""targetName"": ""TestObject1"", - ""targetType"": ""TestType"", - ""time"": 1000 - } -]", output.Output.OfType().FirstOrDefault()); - } - - [Fact] - public void NUnit3() - { - var option = GetOption(); - option.Repository.Url = "https://github.com/microsoft/PSRule.UnitTest"; - var output = new TestWriter(option); - var result = new InvokeResult(); - result.Add(GetPass()); - result.Add(GetFail()); - result.Add(GetFail("rid-003", SeverityLevel.Warning)); - result.Add(GetFail("rid-004", SeverityLevel.Information, synopsis: "Synopsis \"with quotes\".")); - var writer = new NUnit3OutputWriter(output, option, null); - writer.Begin(); - writer.WriteObject(result, false); - writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); - - var s = output.Output.OfType().FirstOrDefault(); - var doc = new XmlDocument(); - doc.LoadXml(s); - - var declaration = doc.ChildNodes.Item(0) as XmlDeclaration; - Assert.Equal("utf-8", declaration.Encoding); - var xml = doc["test-results"]["test-suite"].OuterXml.Replace(System.Environment.NewLine, "\r\n"); - Assert.Equal("", xml); - } - - [Fact] - public void Csv() - { - var option = GetOption(); - option.Repository.Url = "https://github.com/microsoft/PSRule.UnitTest"; - var output = new TestWriter(option); - var result = new InvokeResult(); - result.Add(GetPass()); - result.Add(GetFail()); - result.Add(GetFail("rid-003", SeverityLevel.Warning)); - result.Add(GetFail("rid-004", SeverityLevel.Information)); - var writer = new CsvOutputWriter(output, option, null); - writer.Begin(); - writer.WriteObject(result, false); - writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); - - var actual = output.Output.OfType().FirstOrDefault(); - - Assert.Equal(@"RuleName,TargetName,TargetType,Outcome,Synopsis,Recommendation -""rule-001"",""TestObject1"",""TestType"",""Pass"",""Processed"",""This is rule 001."",""Recommendation for rule 001 over two lines."" -""rule-002"",""TestObject1"",""TestType"",""Fail"",""Processed"",""This is rule 002."",""Recommendation for rule 002"" -""rule-002"",""TestObject1"",""TestType"",""Fail"",""Processed"",""This is rule 002."",""Recommendation for rule 002"" -""rule-002"",""TestObject1"",""TestType"",""Fail"",""Processed"",""This is rule 002."",""Recommendation for rule 002"" -", actual); - } - - [Fact] - public void JobSummary() - { - using var stream = new MemoryStream(); - var option = GetOption(); - var output = new TestWriter(option); - var result = new InvokeResult(); - var context = GetPipelineContext(option: option, writer: output, resourceCache: GetResourceCache()); - result.Add(GetPass()); - result.Add(GetFail()); - result.Add(GetFail("rid-003", SeverityLevel.Warning, ruleId: "TestModule\\Rule-003")); - var writer = new JobSummaryWriter(output, option, null, outputPath: "reports/summary.md", stream: stream); - writer.Begin(); - writer.WriteObject(result, false); - context.RunTime.Stop(); - writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); - - stream.Seek(0, SeekOrigin.Begin); - using var reader = new StreamReader(stream); - var s = reader.ReadToEnd().Replace(System.Environment.NewLine, "\r\n"); - Assert.Equal($"# PSRule result summary\r\n\r\n❌ PSRule completed with an overall result of 'Fail' with 3 rule(s) and 1 target(s) in {context.RunTime.Elapsed}.\r\n\r\n## Analysis\r\n\r\nThe following results were reported with fail or error results.\r\n\r\nName | Target name | Synopsis\r\n---- | ----------- | --------\r\nrule-002 | TestObject1 | This is rule 002.\r\nRule-003 | TestObject1 | This is rule 002.\r\n", s); - } - - #region Helper methods - - private static RuleRecord GetPass() - { - return new RuleRecord - ( - runId: "run-001", - ruleId: ResourceId.Parse("TestModule\\rule-001"), - @ref: null, - targetObject: new TargetObject(new PSObject()), - targetName: "TestObject1", - targetType: "TestType", - tag: new ResourceTags(), - info: new RuleHelpInfo - ( - "rule-001", - "Rule 001", - "TestModule", - synopsis: new InfoString("This is rule 001."), - recommendation: new InfoString("Recommendation for rule 001\r\nover two lines.") - ), - field: new Hashtable(), - @default: new RuleProperties - { - Level = SeverityLevel.Error - }, - extent: null, - outcome: RuleOutcome.Pass, - reason: RuleOutcomeReason.Processed - ) - { - Time = 500 - }; - } - - private static RuleRecord GetFail(string ruleRef = "rid-002", SeverityLevel level = SeverityLevel.Error, SeverityLevel? overrideLevel = null, string synopsis = "This is rule 002.", string ruleId = "TestModule\\rule-002") - { - var info = new RuleHelpInfo( - "rule-002", - "Rule 002", - "TestModule", - synopsis: new InfoString(synopsis), - recommendation: new InfoString("Recommendation for rule 002") - ); - - info.Annotations = new Hashtable - { - ["annotation-data"] = "Custom annotation" - }; - - var ruleOverride = overrideLevel == null ? null : new RuleOverride - { - Level = overrideLevel, - }; - - return new RuleRecord( - runId: "run-001", - ruleId: ResourceId.Parse(ruleId), - @ref: ruleRef, - targetObject: new TargetObject(new PSObject()), - targetName: "TestObject1", - targetType: "TestType", - tag: new ResourceTags(), - info: info, - field: new Hashtable - { - ["field-data"] = "Custom field data" - }, - @default: new RuleProperties - { - Level = level - }, - @override: ruleOverride, - extent: null, - outcome: RuleOutcome.Fail, - reason: RuleOutcomeReason.Processed - ) - { - Time = 1000 - }; - } - - #endregion Helper methods -} diff --git a/tests/PSRule.Tests/PSRule.Options.Tests.ps1 b/tests/PSRule.Tests/PSRule.Options.Tests.ps1 index 1898e76682..309a36aef4 100644 --- a/tests/PSRule.Tests/PSRule.Options.Tests.ps1 +++ b/tests/PSRule.Tests/PSRule.Options.Tests.ps1 @@ -2321,6 +2321,62 @@ Describe 'New-PSRuleOption' -Tag 'Option','New-PSRuleOption' { } } + Context 'Read Run.Category' { + It 'from default' { + $option = New-PSRuleOption -Default; + $option.Run.Category | Should -Be 'PSRule'; + } + + It 'from Hashtable' { + $option = New-PSRuleOption -Option @{ 'Run.Category' = 'Custom category' }; + $option.Run.Category | Should -Be 'Custom category'; + } + + It 'from YAML' { + $option = New-PSRuleOption -Option (Join-Path -Path $here -ChildPath 'PSRule.Tests.yml'); + $option.Run.Category | Should -Be 'Custom category'; + } + + It 'from Environment' { + try { + $Env:PSRULE_RUN_CATEGORY = 'Custom category'; + $option = New-PSRuleOption; + $option.Run.Category | Should -Be 'Custom category'; + } + finally { + Remove-Item 'Env:PSRULE_RUN_CATEGORY' -Force; + } + } + } + + Context 'Read Run.Description' { + It 'from default' { + $option = New-PSRuleOption -Default; + $option.Run.Description | Should -Be ''; + } + + It 'from Hashtable' { + $option = New-PSRuleOption -Option @{ 'Run.Description' = 'An custom run.' }; + $option.Run.Description | Should -Be 'An custom run.'; + } + + It 'from YAML' { + $option = New-PSRuleOption -Option (Join-Path -Path $here -ChildPath 'PSRule.Tests.yml'); + $option.Run.Description | Should -Be 'An custom run.'; + } + + It 'from Environment' { + try { + $Env:PSRULE_RUN_DESCRIPTION = 'An custom run.'; + $option = New-PSRuleOption; + $option.Run.Description | Should -Be 'An custom run.'; + } + finally { + Remove-Item 'Env:PSRULE_RUN_DESCRIPTION' -Force; + } + } + } + Context 'Read Suppression' { It 'from default' { $option = New-PSRuleOption -Default; diff --git a/tests/PSRule.Tests/PSRule.Tests.yml b/tests/PSRule.Tests/PSRule.Tests.yml index 13ac141ab9..5d1567e5bf 100644 --- a/tests/PSRule.Tests/PSRule.Tests.yml +++ b/tests/PSRule.Tests/PSRule.Tests.yml @@ -130,6 +130,11 @@ output: sarifProblemsOnly: false style: GitHubActions +# Configure run options +run: + category: Custom category + description: An custom run. + # Configure rule suppression suppression: SuppressionTest1: diff --git a/tests/PSRule.Tests/Pipeline/Output/CsvOutputWriterTests.cs b/tests/PSRule.Tests/Pipeline/Output/CsvOutputWriterTests.cs new file mode 100644 index 0000000000..e0b474d2c7 --- /dev/null +++ b/tests/PSRule.Tests/Pipeline/Output/CsvOutputWriterTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using PSRule.Definitions.Rules; + +namespace PSRule.Pipeline.Output; + +/// +/// Tests for . +/// +public sealed class CsvOutputWriterTests : OutputWriterBaseTests +{ + [Fact] + public void Csv() + { + var option = GetOption(); + option.Repository.Url = "https://github.com/microsoft/PSRule.UnitTest"; + var output = new TestWriter(option); + var result = new InvokeResult(GetRun()); + result.Add(GetPass()); + result.Add(GetFail()); + result.Add(GetFail("rid-003", SeverityLevel.Warning)); + result.Add(GetFail("rid-004", SeverityLevel.Information)); + var writer = new CsvOutputWriter(output, option, null); + writer.Begin(); + writer.WriteObject(result, false); + writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); + + var actual = output.Output.OfType().FirstOrDefault(); + + Assert.Equal(@"RuleName,TargetName,TargetType,Outcome,Synopsis,Recommendation +""rule-001"",""TestObject1"",""TestType"",""Pass"",""Processed"",""This is rule 001."",""Recommendation for rule 001 over two lines."" +""rule-002"",""TestObject1"",""TestType"",""Fail"",""Processed"",""This is rule 002."",""Recommendation for rule 002"" +""rule-002"",""TestObject1"",""TestType"",""Fail"",""Processed"",""This is rule 002."",""Recommendation for rule 002"" +""rule-002"",""TestObject1"",""TestType"",""Fail"",""Processed"",""This is rule 002."",""Recommendation for rule 002"" +", actual); + } +} diff --git a/tests/PSRule.Tests/Pipeline/Output/JobSummaryWriterTests.cs b/tests/PSRule.Tests/Pipeline/Output/JobSummaryWriterTests.cs new file mode 100644 index 0000000000..46e54df696 --- /dev/null +++ b/tests/PSRule.Tests/Pipeline/Output/JobSummaryWriterTests.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO; +using PSRule.Definitions.Rules; + +namespace PSRule.Pipeline.Output; + +/// +/// Tests for . +/// +public sealed class JobSummaryWriterTests : OutputWriterBaseTests +{ + [Fact] + public void JobSummary() + { + using var stream = new MemoryStream(); + var option = GetOption(); + var output = new TestWriter(option); + var result = new InvokeResult(GetRun()); + var context = GetPipelineContext(option: option, writer: output, resourceCache: GetResourceCache()); + result.Add(GetPass()); + result.Add(GetFail()); + result.Add(GetFail("rid-003", SeverityLevel.Warning, ruleId: "TestModule\\Rule-003")); + var writer = new JobSummaryWriter(output, option, null, outputPath: "reports/summary.md", stream: stream); + writer.Begin(); + writer.WriteObject(result, false); + context.RunTime.Stop(); + writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); + + stream.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + var s = reader.ReadToEnd().Replace(System.Environment.NewLine, "\r\n"); + Assert.Equal($"# PSRule result summary\r\n\r\n❌ PSRule completed with an overall result of 'Fail' with 3 rule(s) and 1 target(s) in {context.RunTime.Elapsed}.\r\n\r\n## Analysis\r\n\r\nThe following results were reported with fail or error results.\r\n\r\nName | Target name | Synopsis\r\n---- | ----------- | --------\r\nrule-002 | TestObject1 | This is rule 002.\r\nRule-003 | TestObject1 | This is rule 002.\r\n", s); + } +} diff --git a/tests/PSRule.Tests/Pipeline/Output/JsonOutputWriterTests.cs b/tests/PSRule.Tests/Pipeline/Output/JsonOutputWriterTests.cs new file mode 100644 index 0000000000..24e683126e --- /dev/null +++ b/tests/PSRule.Tests/Pipeline/Output/JsonOutputWriterTests.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using PSRule.Definitions.Rules; + +namespace PSRule.Pipeline.Output; + +/// +/// Tests for . +/// +public sealed class JsonOutputWriterTests : OutputWriterBaseTests +{ + [Fact] + public void Json() + { + var option = GetOption(); + option.Output.JsonIndent = 2; + option.Repository.Url = "https://github.com/microsoft/PSRule.UnitTest"; + var output = new TestWriter(option); + var result = new InvokeResult(GetRun()); + result.Add(GetPass()); + result.Add(GetFail()); + result.Add(GetFail("rid-003", SeverityLevel.Warning)); + result.Add(GetFail("rid-004", SeverityLevel.Information)); + var writer = new JsonOutputWriter(output, option, null); + writer.Begin(); + writer.WriteObject(result, false); + writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); + + Assert.Equal(@"[ + { + ""detail"": {}, + ""info"": { + ""displayName"": ""Rule 001"", + ""moduleName"": ""TestModule"", + ""name"": ""rule-001"", + ""recommendation"": ""Recommendation for rule 001\r\nover two lines."", + ""synopsis"": ""This is rule 001."" + }, + ""level"": ""Error"", + ""outcome"": ""Pass"", + ""outcomeReason"": ""Processed"", + ""ruleName"": ""rule-001"", + ""runId"": ""run-001"", + ""source"": [], + ""tag"": {}, + ""targetName"": ""TestObject1"", + ""targetType"": ""TestType"", + ""time"": 500 + }, + { + ""detail"": {}, + ""field"": { + ""field-data"": ""Custom field data"" + }, + ""info"": { + ""annotations"": { + ""annotation-data"": ""Custom annotation"" + }, + ""displayName"": ""Rule 002"", + ""moduleName"": ""TestModule"", + ""name"": ""rule-002"", + ""recommendation"": ""Recommendation for rule 002"", + ""synopsis"": ""This is rule 002."" + }, + ""level"": ""Error"", + ""outcome"": ""Fail"", + ""outcomeReason"": ""Processed"", + ""ref"": ""rid-002"", + ""ruleName"": ""rule-002"", + ""runId"": ""run-001"", + ""source"": [], + ""tag"": {}, + ""targetName"": ""TestObject1"", + ""targetType"": ""TestType"", + ""time"": 1000 + }, + { + ""detail"": {}, + ""field"": { + ""field-data"": ""Custom field data"" + }, + ""info"": { + ""annotations"": { + ""annotation-data"": ""Custom annotation"" + }, + ""displayName"": ""Rule 002"", + ""moduleName"": ""TestModule"", + ""name"": ""rule-002"", + ""recommendation"": ""Recommendation for rule 002"", + ""synopsis"": ""This is rule 002."" + }, + ""level"": ""Warning"", + ""outcome"": ""Fail"", + ""outcomeReason"": ""Processed"", + ""ref"": ""rid-003"", + ""ruleName"": ""rule-002"", + ""runId"": ""run-001"", + ""source"": [], + ""tag"": {}, + ""targetName"": ""TestObject1"", + ""targetType"": ""TestType"", + ""time"": 1000 + }, + { + ""detail"": {}, + ""field"": { + ""field-data"": ""Custom field data"" + }, + ""info"": { + ""annotations"": { + ""annotation-data"": ""Custom annotation"" + }, + ""displayName"": ""Rule 002"", + ""moduleName"": ""TestModule"", + ""name"": ""rule-002"", + ""recommendation"": ""Recommendation for rule 002"", + ""synopsis"": ""This is rule 002."" + }, + ""level"": ""Information"", + ""outcome"": ""Fail"", + ""outcomeReason"": ""Processed"", + ""ref"": ""rid-004"", + ""ruleName"": ""rule-002"", + ""runId"": ""run-001"", + ""source"": [], + ""tag"": {}, + ""targetName"": ""TestObject1"", + ""targetType"": ""TestType"", + ""time"": 1000 + } +]", output.Output.OfType().FirstOrDefault()); + } +} diff --git a/tests/PSRule.Tests/Pipeline/Output/NUnit3OutputWriterTests.cs b/tests/PSRule.Tests/Pipeline/Output/NUnit3OutputWriterTests.cs new file mode 100644 index 0000000000..32594b3017 --- /dev/null +++ b/tests/PSRule.Tests/Pipeline/Output/NUnit3OutputWriterTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using System.Xml; +using PSRule.Definitions.Rules; + +namespace PSRule.Pipeline.Output; + +/// +/// Tests for . +/// +public sealed class NUnit3OutputWriterTests : OutputWriterBaseTests +{ + [Fact] + public void NUnit3() + { + var option = GetOption(); + option.Repository.Url = "https://github.com/microsoft/PSRule.UnitTest"; + var output = new TestWriter(option); + var result = new InvokeResult(GetRun()); + result.Add(GetPass()); + result.Add(GetFail()); + result.Add(GetFail("rid-003", SeverityLevel.Warning)); + result.Add(GetFail("rid-004", SeverityLevel.Information, synopsis: "Synopsis \"with quotes\".")); + var writer = new NUnit3OutputWriter(output, option, null); + writer.Begin(); + writer.WriteObject(result, false); + writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); + + var s = output.Output.OfType().FirstOrDefault(); + var doc = new XmlDocument(); + doc.LoadXml(s); + + var declaration = doc.ChildNodes.Item(0) as XmlDeclaration; + Assert.Equal("utf-8", declaration.Encoding); + var xml = doc["test-results"]["test-suite"].OuterXml.Replace(System.Environment.NewLine, "\r\n"); + Assert.Equal("", xml); + } +} diff --git a/tests/PSRule.Tests/Pipeline/Output/OutputWriterBaseTests.cs b/tests/PSRule.Tests/Pipeline/Output/OutputWriterBaseTests.cs new file mode 100644 index 0000000000..75bbc48900 --- /dev/null +++ b/tests/PSRule.Tests/Pipeline/Output/OutputWriterBaseTests.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Management.Automation; +using PSRule.Definitions; +using PSRule.Definitions.Rules; +using PSRule.Pipeline.Runs; +using PSRule.Rules; + +namespace PSRule.Pipeline.Output; + +public abstract class OutputWriterBaseTests : ContextBaseTests +{ + protected internal static IRun GetRun() + { + return new Run("run-001", new InfoString("Test run", null), Guid.Empty.ToString()); + } + + protected static RuleRecord GetPass() + { + var run = GetRun(); + return new RuleRecord + ( + ruleId: ResourceId.Parse("TestModule\\rule-001"), + @ref: null, + targetObject: new TargetObject(new PSObject()), + targetName: "TestObject1", + targetType: "TestType", + tag: new ResourceTags(), + info: new RuleHelpInfo + ( + "rule-001", + "Rule 001", + "TestModule", + synopsis: new InfoString("This is rule 001."), + recommendation: new InfoString("Recommendation for rule 001\r\nover two lines.") + ), + field: [], + @default: new RuleProperties + { + Level = SeverityLevel.Error + }, + extent: null, + outcome: RuleOutcome.Pass, + reason: RuleOutcomeReason.Processed + ) + { + RunId = run.Id, + Time = 500 + }; + } + + protected static RuleRecord GetFail(string ruleRef = "rid-002", SeverityLevel level = SeverityLevel.Error, SeverityLevel? overrideLevel = null, string synopsis = "This is rule 002.", string ruleId = "TestModule\\rule-002") + { + var run = GetRun(); + var info = new RuleHelpInfo( + "rule-002", + "Rule 002", + "TestModule", + synopsis: new InfoString(synopsis), + recommendation: new InfoString("Recommendation for rule 002") + ); + + info.Annotations = new Hashtable + { + ["annotation-data"] = "Custom annotation" + }; + + var ruleOverride = overrideLevel == null ? null : new RuleOverride + { + Level = overrideLevel, + }; + + return new RuleRecord( + ruleId: ResourceId.Parse(ruleId), + @ref: ruleRef, + targetObject: new TargetObject(new PSObject()), + targetName: "TestObject1", + targetType: "TestType", + tag: new ResourceTags(), + info: info, + field: new Hashtable + { + ["field-data"] = "Custom field data" + }, + @default: new RuleProperties + { + Level = level + }, + @override: ruleOverride, + extent: null, + outcome: RuleOutcome.Fail, + reason: RuleOutcomeReason.Processed + ) + { + RunId = run.Id, + Time = 1000 + }; + } +} diff --git a/tests/PSRule.Tests/Pipeline/Output/SarifOutputWriterTests.cs b/tests/PSRule.Tests/Pipeline/Output/SarifOutputWriterTests.cs new file mode 100644 index 0000000000..c59d3b28fc --- /dev/null +++ b/tests/PSRule.Tests/Pipeline/Output/SarifOutputWriterTests.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using PSRule.Definitions.Rules; + +namespace PSRule.Pipeline.Output; + +#nullable enable + +/// +/// Tests for . +/// +public sealed class SarifOutputWriterTests : OutputWriterBaseTests +{ + [Fact] + public void Output_WhenDefaultOptions_ShouldReturnStandardProperties() + { + var option = GetOption(); + var output = new TestWriter(option); + var result = new InvokeResult(GetRun()); + result.Add(GetPass()); + result.Add(GetFail()); + + var writer = new SarifOutputWriter(null, output, option, null); + writer.Begin(); + writer.WriteObject(result, false); + writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); + + var actual = JsonConvert.DeserializeObject(output.Output.OfType().FirstOrDefault()!); + Assert.NotNull(actual); + + var driver = actual["runs"]?[0]?["tool"]?["driver"]; + Assert.NotNull(driver); + Assert.Equal("PSRule", driver["name"]?.Value()); + Assert.Equal("0.0.1", driver["semanticVersion"]?.Value()?.Split('+')[0]); + Assert.Equal("0130215d-58eb-4887-b6fa-31ed02500569", driver["guid"]?.Value()); + Assert.Equal("Microsoft Corporation", driver["organization"]?.Value()); + Assert.Equal("https://aka.ms/ps-rule", driver["informationUri"]?.Value()); + + var automationDetails = actual["runs"]?[0]?["automationDetails"]; + Assert.NotNull(automationDetails); + Assert.Equal("run-001", automationDetails["id"]?.Value()); + Assert.Equal("00000000-0000-0000-0000-000000000000", automationDetails["correlationGuid"]?.Value()); + Assert.Equal("Test run", automationDetails["description"]?["text"]?.Value()); + + var invocations = actual["runs"]?[0]?["invocations"]; + Assert.NotNull(invocations); + Assert.Single(invocations); + Assert.True(invocations[0]?["executionSuccessful"]?.Value()); + + var originalUriBaseIds = actual["runs"]?[0]?["originalUriBaseIds"]; + Assert.NotNull(originalUriBaseIds); + Assert.NotNull(originalUriBaseIds["REPO_ROOT"]); + + var rules = driver["rules"]; + Assert.NotNull(rules); + + // Does not contain pass rule + var rule = rules.Where(r => r["id"]?.Value() == "TestModule\\rule-001").FirstOrDefault(); + Assert.Null(rule); + + // Contains fail rule + rule = rules.Where(r => r["id"]?.Value() == "rid-002").FirstOrDefault(); + Assert.NotNull(rule); + } + + [Fact] + public void Output_WhenRepositoryOptionSet_ShouldReturnVersionControlProperties() + { + var option = GetOption(); + option.Repository.Url = "https://github.com/microsoft/PSRule.UnitTest"; + var output = new TestWriter(option); + var result = new InvokeResult(GetRun()); + result.Add(GetPass()); + result.Add(GetFail()); + + var writer = new SarifOutputWriter(null, output, option, null); + writer.Begin(); + writer.WriteObject(result, false); + writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); + + var actual = JsonConvert.DeserializeObject(output.Output.OfType().FirstOrDefault()!); + Assert.NotNull(actual); + + var driver = actual["runs"]?[0]?["tool"]?["driver"]; + Assert.NotNull(driver); + Assert.Equal("PSRule", driver["name"]?.Value()); + Assert.Equal("0.0.1", driver["semanticVersion"]?.Value()?.Split('+')[0]); + Assert.Equal("0130215d-58eb-4887-b6fa-31ed02500569", driver["guid"]?.Value()); + + var versionControl = actual["runs"]?[0]?["versionControlProvenance"]; + Assert.NotNull(versionControl); + Assert.Equal("https://github.com/microsoft/PSRule.UnitTest", versionControl[0]?["repositoryUri"]?.Value()); + } + + [Fact] + public void Output_WhenDefaultOptions_ShouldNotReturnPassingResults() + { + var option = GetOption(); + var output = new TestWriter(option); + var result = new InvokeResult(GetRun()); + result.Add(GetPass()); + result.Add(GetFail()); + result.Add(GetFail("rid-003", SeverityLevel.Warning)); + result.Add(GetFail("rid-004", SeverityLevel.Information)); + + var writer = new SarifOutputWriter(null, output, option, null); + writer.Begin(); + writer.WriteObject(result, false); + writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); + + var actual = JsonConvert.DeserializeObject(output.Output.OfType().FirstOrDefault()!); + Assert.NotNull(actual); + + // Fail with error + Assert.Equal("rid-002", actual["runs"]?[0]?["results"]?[0]?["ruleId"]?.Value()); + Assert.Equal("error", actual["runs"]?[0]?["results"]?[0]?["level"]?.Value()); + + // Fail with warning (default value is omitted) + Assert.Equal("rid-003", actual["runs"]?[0]?["results"]?[1]?["ruleId"]?.Value()); + Assert.Null(actual["runs"]?[0]?["results"]?[1]?["level"]); + + // Fail with note + Assert.Equal("rid-004", actual["runs"]?[0]?["results"]?[2]?["ruleId"]?.Value()); + Assert.Equal("note", actual["runs"]?[0]?["results"]?[2]?["level"]?.Value()); + } + + [Fact] + public void Output_WhenNotProblemsOnly_ShouldReturnAllResults() + { + var option = GetOption(); + option.Output.SarifProblemsOnly = false; + var output = new TestWriter(option); + var result = new InvokeResult(GetRun()); + result.Add(GetPass()); + result.Add(GetFail()); + result.Add(GetFail("rid-003", SeverityLevel.Warning)); + result.Add(GetFail("rid-004", SeverityLevel.Information)); + + var writer = new SarifOutputWriter(null, output, option, null); + writer.Begin(); + writer.WriteObject(result, false); + writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); + + var actual = JsonConvert.DeserializeObject(output.Output.OfType().FirstOrDefault()!); + Assert.NotNull(actual); + + // Pass + Assert.Equal("TestModule\\rule-001", actual["runs"]?[0]?["results"]?[0]?["ruleId"]?.Value()); + Assert.Equal("none", actual["runs"]?[0]?["results"]?[0]?["level"]?.Value()); + + // Fail with error + Assert.Equal("rid-002", actual["runs"]?[0]?["results"]?[1]?["ruleId"]?.Value()); + Assert.Equal("error", actual["runs"]?[0]?["results"]?[1]?["level"]?.Value()); + Assert.Equal("Custom annotation", actual["runs"]?[0]?["results"]?[1]?["properties"]?["annotations"]?["annotation-data"]?.Value()); + Assert.Equal("Custom field data", actual["runs"]?[0]?["results"]?[1]?["properties"]?["fields"]?["field-data"]?.Value()); + + // Fail with warning + Assert.Equal("rid-003", actual["runs"]?[0]?["results"]?[2]?["ruleId"]?.Value()); + Assert.Null(actual["runs"]?[0]?["results"]?[2]?["level"]); + + // Fail with note + Assert.Equal("rid-004", actual["runs"]?[0]?["results"]?[3]?["ruleId"]?.Value()); + Assert.Equal("note", actual["runs"]?[0]?["results"]?[3]?["level"]?.Value()); + + // Check options + Assert.Equal(option.Repository.Url, actual["runs"]?[0]?["properties"]?["options"]?["workspace"]?["Repository"]?["Url"]?.Value()); + Assert.False(actual["runs"]?[0]?["properties"]?["options"]?["workspace"]?["Output"]?["SarifProblemsOnly"]?.Value()); + } + + [Fact] + public void Output_WhenRuleLevelIsOverridden_ShouldReturnOverrideInSarifFormat() + { + var option = GetOption(); + var output = new TestWriter(option); + var result = new InvokeResult(GetRun()); + result.Add(GetPass()); + result.Add(GetFail()); + result.Add(GetFail("rid-003", SeverityLevel.Warning, overrideLevel: SeverityLevel.Information)); + result.Add(GetFail("rid-004", SeverityLevel.Information, overrideLevel: SeverityLevel.Warning)); + + var writer = new SarifOutputWriter(null, output, option, null); + writer.Begin(); + writer.WriteObject(result, false); + writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); + + var doc = JsonConvert.DeserializeObject(output.Output.OfType().FirstOrDefault()!); + Assert.NotNull(doc); + + // Fail with error + var actual = doc["runs"]?[0]?["results"]?.Where(r => r["ruleId"]?.Value() == "rid-002").FirstOrDefault(); + Assert.NotNull(actual); + Assert.Equal("error", actual["level"]?.Value()); + + // Fail with note + actual = doc["runs"]?[0]?["results"]?.Where(r => r["ruleId"]?.Value() == "rid-003").FirstOrDefault(); + Assert.NotNull(actual); + Assert.Equal("note", actual["level"]?.Value()); + + var ruleDefault = doc["runs"]?[0]?["tool"]?["driver"]?["rules"]?.Where(r => r["id"]?.Value() == "rid-003").FirstOrDefault(); + Assert.NotNull(ruleDefault); + Assert.Null(ruleDefault["defaultConfiguration"]); + + var ruleOverride = doc["runs"]?[0]?["invocations"]?[0]?["ruleConfigurationOverrides"]?.Where(r => r["descriptor"]?["id"]?.Value() == "rid-003").FirstOrDefault(); + Assert.Equal("note", actual["level"]?.Value()); + + // Fail with warning (default value is omitted) + actual = doc["runs"]?[0]?["results"]?.Where(r => r["ruleId"]?.Value() == "rid-004").FirstOrDefault(); + Assert.NotNull(actual); + Assert.Null(actual["level"]); + + ruleDefault = doc["runs"]?[0]?["tool"]?["driver"]?["rules"]?.Where(r => r["id"]?.Value() == "rid-004").FirstOrDefault(); + Assert.NotNull(ruleDefault); + Assert.Equal("note", ruleDefault["defaultConfiguration"]?["level"]?.Value()); + + ruleOverride = doc["runs"]?[0]?["invocations"]?[0]?["ruleConfigurationOverrides"]?.Where(r => r["descriptor"]?["id"]?.Value() == "rid-004").FirstOrDefault(); + Assert.NotNull(ruleOverride); + Assert.Null(actual["level"]); + } +} + +#nullable restore diff --git a/tests/PSRule.Tests/Pipeline/Output/YamlOutputWriterTests.cs b/tests/PSRule.Tests/Pipeline/Output/YamlOutputWriterTests.cs new file mode 100644 index 0000000000..686a76d41e --- /dev/null +++ b/tests/PSRule.Tests/Pipeline/Output/YamlOutputWriterTests.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using PSRule.Definitions.Rules; + +namespace PSRule.Pipeline.Output; + +/// +/// Tests for . +/// +public sealed class YamlOutputWriterTests : OutputWriterBaseTests +{ + + [Fact] + public void Yaml() + { + var option = GetOption(); + option.Repository.Url = "https://github.com/microsoft/PSRule.UnitTest"; + var output = new TestWriter(option); + var result = new InvokeResult(GetRun()); + result.Add(GetPass()); + result.Add(GetFail()); + result.Add(GetFail("rid-003", SeverityLevel.Warning)); + result.Add(GetFail("rid-004", SeverityLevel.Information)); + var writer = new YamlOutputWriter(output, option, null); + writer.Begin(); + writer.WriteObject(result, false); + writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); + + Assert.Equal(@"- detail: + reason: [] + info: + moduleName: TestModule + recommendation: >- + Recommendation for rule 001 + + over two lines. + level: Error + outcome: Pass + outcomeReason: Processed + ruleName: rule-001 + runId: run-001 + source: [] + tag: {} + targetName: TestObject1 + targetType: TestType + time: 500 +- detail: + reason: [] + field: + field-data: Custom field data + info: + annotations: + annotation-data: Custom annotation + moduleName: TestModule + recommendation: Recommendation for rule 002 + level: Error + outcome: Fail + outcomeReason: Processed + ref: rid-002 + ruleName: rule-002 + runId: run-001 + source: [] + tag: {} + targetName: TestObject1 + targetType: TestType + time: 1000 +- detail: + reason: [] + field: + field-data: Custom field data + info: + annotations: + annotation-data: Custom annotation + moduleName: TestModule + recommendation: Recommendation for rule 002 + level: Warning + outcome: Fail + outcomeReason: Processed + ref: rid-003 + ruleName: rule-002 + runId: run-001 + source: [] + tag: {} + targetName: TestObject1 + targetType: TestType + time: 1000 +- detail: + reason: [] + field: + field-data: Custom field data + info: + annotations: + annotation-data: Custom annotation + moduleName: TestModule + recommendation: Recommendation for rule 002 + level: Information + outcome: Fail + outcomeReason: Processed + ref: rid-004 + ruleName: rule-002 + runId: run-001 + source: [] + tag: {} + targetName: TestObject1 + targetType: TestType + time: 1000 +", output.Output.OfType().FirstOrDefault()); + } + +}