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