diff --git a/examples/ExampleJobSummaryConvention.cs b/examples/ExampleJobSummaryConvention.cs new file mode 100644 index 0000000000..5d61928f6e --- /dev/null +++ b/examples/ExampleJobSummaryConvention.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using PSRule.Definitions; +using PSRule.Definitions.Conventions; +using PSRule.Runtime; + +namespace PSRule.Examples; + +/// +/// Example convention that demonstrates how to contribute content to job summaries. +/// This shows how convention authors can implement IJobSummaryContributor to extend job summary output. +/// +internal sealed class ExampleJobSummaryConvention : BaseConvention, IConventionV1, IJobSummaryContributor +{ + private int _processedCount = 0; + private int _successCount = 0; + + public ExampleJobSummaryConvention(ISourceFile source, string name) : base(source, name) + { + } + + public override void Process(LegacyRunspaceContext context, IEnumerable input) + { + // Example: Track some metrics during processing + foreach (var item in input) + { + _processedCount++; + // Simulate some processing logic + if (_processedCount % 2 == 0) + _successCount++; + } + } + + public IEnumerable? GetJobSummaryContent() + { + // Return custom sections for the job summary + var sections = new List(); + + // Add a metrics section + var metricsContent = $@"The example convention processed {_processedCount} items with {_successCount} successful operations. + +### Breakdown +- Total items: {_processedCount} +- Successful: {_successCount} +- Success rate: {(_processedCount > 0 ? (_successCount * 100.0 / _processedCount):0):F1}%"; + + sections.Add(new JobSummarySection("Convention Metrics", new InfoString(metricsContent))); + + // Add an additional information section + var additionalInfo = @"This section demonstrates how conventions can contribute custom content to job summaries. + +Convention authors can: +- Add custom metrics and statistics +- Provide configuration summaries +- Include environment information +- Display custom analysis results + +For more information, see the [PSRule conventions documentation](https://microsoft.github.io/PSRule/concepts/conventions/)."; + + sections.Add(new JobSummarySection("Convention Information", new InfoString(additionalInfo))); + + return sections; + } + + #region IConventionV1 implementation (required but not relevant for this example) + + public IResourceHelpInfo Info => throw new NotImplementedException(); + public ResourceFlags Flags => ResourceFlags.None; + public ISourceExtent Extent => throw new NotImplementedException(); + public ResourceKind Kind => ResourceKind.Convention; + public string ApiVersion => "v1"; + public ResourceId? Ref => null; + public ResourceId[]? Alias => null; + public IResourceTags? Tags => null; + public IResourceLabels? Labels => null; + + #endregion +} \ No newline at end of file diff --git a/examples/JobSummaryContribution.md b/examples/JobSummaryContribution.md new file mode 100644 index 0000000000..8bee0eca43 --- /dev/null +++ b/examples/JobSummaryContribution.md @@ -0,0 +1,57 @@ +# PSRule Job Summary Convention Extension Example + +This example demonstrates how conventions can contribute additional information to PSRule job summaries. + +## Overview + +The new `IJobSummaryContributor` interface allows conventions to append custom sections to job summary output. This enables: + +- Custom metrics and statistics +- Environment information +- Configuration summaries +- Additional analysis results + +## Usage + +Conventions can implement the `IJobSummaryContributor` interface to contribute content: + +```csharp +public class MyConvention : BaseConvention, IConventionV1, IJobSummaryContributor +{ + public IEnumerable? GetJobSummaryContent() + { + return new[] + { + new JobSummarySection("Custom Metrics", "- Processed: 100 items\n- Success rate: 95%"), + new JobSummarySection("Environment", "- OS: Windows\n- Runtime: .NET 8.0") + }; + } + + // ... other convention implementation +} +``` + +## Example Output + +The job summary will include additional sections after the standard PSRule content: + +```markdown +# PSRule result summary + +āŒ PSRule completed with an overall result of 'Fail' with 10 rule(s) and 5 target(s) in 00:00:02.123. + +## Analysis +... + +## Custom Metrics +- Processed: 100 items +- Success rate: 95% + +## Environment +- OS: Windows +- Runtime: .NET 8.0 +``` + +## Backward Compatibility + +This feature is fully backward compatible. Existing conventions and job summaries will continue to work unchanged. Only conventions that explicitly implement `IJobSummaryContributor` will contribute additional content. \ No newline at end of file diff --git a/src/PSRule.Types/Runtime/IConventionContext.cs b/src/PSRule.Types/Runtime/IConventionContext.cs new file mode 100644 index 0000000000..3c67d40ea6 --- /dev/null +++ b/src/PSRule.Types/Runtime/IConventionContext.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PSRule.Runtime; + +/// +/// An interface for context available while executing a convention. +/// +public interface IConventionContext +{ + /// + /// A collection of items to add to the run summary. + /// + SummaryCollection Summary { get; } +} diff --git a/src/PSRule.Types/Runtime/IRuntimeServiceCollection.cs b/src/PSRule.Types/Runtime/IRuntimeServiceCollection.cs index 40c76d0de3..4201262ff2 100644 --- a/src/PSRule.Types/Runtime/IRuntimeServiceCollection.cs +++ b/src/PSRule.Types/Runtime/IRuntimeServiceCollection.cs @@ -33,4 +33,11 @@ void AddService() /// A unique name of the service instance. /// An instance of the service. void AddService(string instanceName, object instance); + + /// + /// Add a convention. + /// + /// The convention type to add. + void AddConvention() + where TConvention : class; } diff --git a/src/PSRule/Definitions/IJobSummaryContributor.cs b/src/PSRule/Definitions/IJobSummaryContributor.cs new file mode 100644 index 0000000000..ad4c01e992 --- /dev/null +++ b/src/PSRule/Definitions/IJobSummaryContributor.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PSRule.Definitions; + +/// +/// Defines an interface for conventions that can contribute additional information to job summaries. +/// +public interface IJobSummaryContributor +{ + /// + /// Gets additional content to include in the job summary. + /// This method is called during job summary generation to collect custom content from conventions. + /// + /// + /// A collection of job summary sections, where each section contains a title and content. + /// The content can be formatted differently for different output formats (plain text for console, markdown for files). + /// Returns null or empty collection if no additional content should be added. + /// + IEnumerable? GetJobSummaryContent(); +} diff --git a/src/PSRule/Definitions/JobSummarySection.cs b/src/PSRule/Definitions/JobSummarySection.cs new file mode 100644 index 0000000000..35d16c56cc --- /dev/null +++ b/src/PSRule/Definitions/JobSummarySection.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PSRule.Definitions; + +/// +/// Represents a section of content to be included in a job summary. +/// +/// The title for the section (will be rendered as H2 header). +/// The content for the section. +public sealed class JobSummarySection(string title, InfoString content) +{ + /// + /// Gets the title for this section. + /// + public string Title { get; } = title ?? throw new ArgumentNullException(nameof(title)); + + /// + /// Gets the content for this section. + /// + public InfoString Content { get; } = content ?? throw new ArgumentNullException(nameof(content)); +} diff --git a/src/PSRule/PSRule.csproj b/src/PSRule/PSRule.csproj index 471bd0c14b..0943a44679 100644 --- a/src/PSRule/PSRule.csproj +++ b/src/PSRule/PSRule.csproj @@ -33,7 +33,7 @@ - $(version) + 0.0.1 true diff --git a/src/PSRule/Pipeline/AssertPipelineBuilder.cs b/src/PSRule/Pipeline/AssertPipelineBuilder.cs index 805fc6c025..4e4c263cbf 100644 --- a/src/PSRule/Pipeline/AssertPipelineBuilder.cs +++ b/src/PSRule/Pipeline/AssertPipelineBuilder.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using PSRule.Configuration; +using PSRule.Definitions; using PSRule.Pipeline.Output; using PSRule.Rules; @@ -59,10 +60,13 @@ private bool ShouldOutput() if (!RequireModules() || !RequireWorkspaceCapabilities() || !RequireSources()) return null; - var context = PrepareContext(PipelineHookActions.Default, writer: HandleJobSummary(writer ?? PrepareWriter()), checkModuleCapabilities: true); + var context = PrepareContext(PipelineHookActions.Default, writer: writer, checkModuleCapabilities: true); if (context == null) return null; + // Update job summary writer to include conventions + writer = HandleJobSummary(writer, context); + return new InvokeRulePipeline ( context: context, @@ -71,17 +75,21 @@ private bool ShouldOutput() ); } - private IPipelineWriter HandleJobSummary(IPipelineWriter writer) + private IPipelineWriter HandleJobSummary(IPipelineWriter writer, PipelineContext context) { if (string.IsNullOrEmpty(Option.Output.JobSummaryPath)) return writer; + // Get conventions that contribute to job summaries + var contributors = context.ResourceCache.OfType().ToArray(); + return new JobSummaryWriter ( inner: writer, option: Option, shouldProcess: ShouldProcess, - source: Source + source: Source, + contributors: contributors.Length > 0 ? contributors : null ); } } diff --git a/src/PSRule/Pipeline/Output/JobSummaryWriter.cs b/src/PSRule/Pipeline/Output/JobSummaryWriter.cs index 7926e722d5..6b1e2241d1 100644 --- a/src/PSRule/Pipeline/Output/JobSummaryWriter.cs +++ b/src/PSRule/Pipeline/Output/JobSummaryWriter.cs @@ -3,6 +3,7 @@ using System.Text; using PSRule.Configuration; +using PSRule.Definitions; using PSRule.Resources; using PSRule.Rules; @@ -23,12 +24,13 @@ internal sealed class JobSummaryWriter : ResultOutputWriter private readonly Encoding _Encoding; private readonly JobSummaryFormat _JobSummary; private readonly Source[]? _Source; + private readonly IJobSummaryContributor[]? _Contributors; private Stream? _Stream; - private StreamWriter _Writer; + private StreamWriter? _Writer; private bool _IsDisposed; - public JobSummaryWriter(IPipelineWriter inner, PSRuleOption option, ShouldProcess shouldProcess, string? outputPath = null, Stream? stream = null, Source[]? source = null) + public JobSummaryWriter(IPipelineWriter inner, PSRuleOption option, ShouldProcess shouldProcess, string? outputPath = null, Stream? stream = null, Source[]? source = null, IJobSummaryContributor[]? contributors = null) : base(inner, option, shouldProcess) { _OutputPath = outputPath ?? Environment.GetRootedPath(Option.Output.JobSummaryPath); @@ -36,6 +38,7 @@ public JobSummaryWriter(IPipelineWriter inner, PSRuleOption option, ShouldProces _JobSummary = JobSummaryFormat.Default; _Stream = stream; _Source = source; + _Contributors = contributors; if (Option.Output.As == ResultFormat.Summary && inner != null) inner.WriteError(new PipelineConfigurationException("Output.As", PSRuleResources.PSR0002), "PSRule.Output.AsOutputSerialization", System.Management.Automation.ErrorCategory.InvalidOperation); @@ -135,6 +138,47 @@ private void Complete() FinalResult(results); Source(); Analysis(results); + AdditionalInformation(); + } + + private void AdditionalInformation() + { + if (_Contributors == null || _Contributors.Length == 0) + return; + + var sections = new List(); + + // Collect content from all contributors + for (var i = 0; i < _Contributors.Length; i++) + { + try + { + var contributorSections = _Contributors[i].GetJobSummaryContent(); + if (contributorSections != null) + { + sections.AddRange(contributorSections); + } + } + catch + { + // Ignore exceptions from individual contributors to prevent them from breaking the entire job summary + continue; + } + } + + // Write sections if any content was provided + if (sections.Count > 0) + { + foreach (var section in sections) + { + if (!string.IsNullOrWhiteSpace(section.Title) && section.Content != null && section.Content.HasValue) + { + H2(section.Title); + WriteLine(section.Content.Markdown); + WriteLine(); + } + } + } } private void Analysis(InvokeResult[] o) diff --git a/src/PSRule/Pipeline/ResultOutputWriter.cs b/src/PSRule/Pipeline/ResultOutputWriter.cs index 7714b14880..76e4af17e9 100644 --- a/src/PSRule/Pipeline/ResultOutputWriter.cs +++ b/src/PSRule/Pipeline/ResultOutputWriter.cs @@ -13,7 +13,7 @@ internal abstract class ResultOutputWriter : PipelineWriter protected ResultOutputWriter(IPipelineWriter inner, PSRuleOption option, ShouldProcess shouldProcess) : base(inner, option, shouldProcess) { - _Result = new List(); + _Result = []; } public override void WriteObject(object sendToPipeline, bool enumerateCollection) diff --git a/src/PSRule/Runtime/Assert.cs b/src/PSRule/Runtime/Assert.cs index 5ed4edc679..ba1edc6bfc 100644 --- a/src/PSRule/Runtime/Assert.cs +++ b/src/PSRule/Runtime/Assert.cs @@ -1523,7 +1523,7 @@ private static string GetFileType(string value) /// /// Determine line comment prefix by file extension /// - private static string DetectLinePrefix(string extension) + private static string DetectLinePrefix(string? extension) { extension = extension?.ToLower(); switch (extension) diff --git a/src/PSRule/Runtime/ILanguageScope.cs b/src/PSRule/Runtime/ILanguageScope.cs index 80ccc3bce6..f93682fe7c 100644 --- a/src/PSRule/Runtime/ILanguageScope.cs +++ b/src/PSRule/Runtime/ILanguageScope.cs @@ -67,6 +67,11 @@ internal interface ILanguageScope : IDisposable /// IEnumerable GetEmitters(); + /// + /// Get any conventions added to the scope. + /// + IEnumerable GetConventions(); + ITargetBindingResult? Bind(ITargetObject targetObject); /// diff --git a/src/PSRule/Runtime/LanguageScope.cs b/src/PSRule/Runtime/LanguageScope.cs index 86150de253..5c22010281 100644 --- a/src/PSRule/Runtime/LanguageScope.cs +++ b/src/PSRule/Runtime/LanguageScope.cs @@ -19,6 +19,7 @@ internal sealed class LanguageScope(string name, RuntimeFactoryContainer? contai private readonly RuntimeFactoryContainer? _Container = container; private readonly Dictionary _Service = []; private readonly List _EmitterTypes = []; + private readonly List _ConventionTypes = []; private readonly Dictionary _Filter = []; private IDictionary? _Configuration = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -138,6 +139,12 @@ public IEnumerable GetEmitters() return _EmitterTypes; } + /// + public IEnumerable GetConventions() + { + return _ConventionTypes; + } + public ITargetBindingResult? Bind(ITargetObject targetObject) { return _TargetBinder?.Bind(targetObject); @@ -236,5 +243,11 @@ void IRuntimeServiceCollection.AddService() _EmitterTypes.Add(typeof(TService)); } + /// + void IRuntimeServiceCollection.AddConvention() + { + _ConventionTypes.Add(typeof(TConvention)); + } + #endregion IRuntimeServiceCollection } diff --git a/tests/PSRule.Tests/MockLanguageScope.cs b/tests/PSRule.Tests/MockLanguageScope.cs index b1cf9fcfce..97dd5cbb4d 100644 --- a/tests/PSRule.Tests/MockLanguageScope.cs +++ b/tests/PSRule.Tests/MockLanguageScope.cs @@ -63,6 +63,11 @@ public IEnumerable GetEmitters() throw new NotImplementedException(); } + public IEnumerable GetConventions() + { + throw new NotImplementedException(); + } + public IResourceFilter GetFilter(ResourceKind kind) { throw new NotImplementedException(); diff --git a/tests/PSRule.Tests/Pipeline/Output/JobSummaryWriterTests.cs b/tests/PSRule.Tests/Pipeline/Output/JobSummaryWriterTests.cs index 46e54df696..c78fdc375a 100644 --- a/tests/PSRule.Tests/Pipeline/Output/JobSummaryWriterTests.cs +++ b/tests/PSRule.Tests/Pipeline/Output/JobSummaryWriterTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.IO; +using PSRule.Definitions; using PSRule.Definitions.Rules; namespace PSRule.Pipeline.Output; @@ -22,7 +23,7 @@ public void JobSummary() 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); + var writer = new JobSummaryWriter(output, option, null, outputPath: "reports/summary.md", stream: stream, source: null, contributors: null); writer.Begin(); writer.WriteObject(result, false); context.RunTime.Stop(); @@ -33,4 +34,71 @@ public void JobSummary() 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); } + + [Fact] + public void JobSummaryWithContributors() + { + 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()); + + // Create mock contributors + var contributors = new IJobSummaryContributor[] + { + new TestJobSummaryContributor("Custom Section", "This is custom content from a convention.\n\n- Item 1\n- Item 2"), + new TestJobSummaryContributor("Another Section", "Additional information:\n\n| Key | Value |\n|-----|-------|\n| Status | Success |\n| Count | 42 |") + }; + + var writer = new JobSummaryWriter(output, option, null, outputPath: "reports/summary.md", stream: stream, source: null, contributors: contributors); + 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"); + + // Verify that the custom sections are included + Assert.Contains("## Custom Section", s); + Assert.Contains("This is custom content from a convention.", s); + Assert.Contains("## Another Section", s); + Assert.Contains("Additional information:", s); + Assert.Contains("| Status | Success |", s); + } + + [Fact] + public void JobSummaryWithEmptyContributors() + { + 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()); + + // Create contributor that returns no content + var contributors = new IJobSummaryContributor[] + { + new TestJobSummaryContributor("", "") // Should be ignored due to empty strings + }; + + var writer = new JobSummaryWriter(output, option, null, outputPath: "reports/summary.md", stream: stream, source: null, contributors: contributors); + 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"); + + // Should only contain standard content, no additional sections + Assert.Contains("# PSRule result summary", s); + Assert.DoesNotContain("## Custom Section", s); + } } diff --git a/tests/PSRule.Tests/Pipeline/Output/TestJobSummaryContributor.cs b/tests/PSRule.Tests/Pipeline/Output/TestJobSummaryContributor.cs new file mode 100644 index 0000000000..c27e189caf --- /dev/null +++ b/tests/PSRule.Tests/Pipeline/Output/TestJobSummaryContributor.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using PSRule.Definitions; + +namespace PSRule.Pipeline.Output; + +#nullable enable + +/// +/// Test implementation of IJobSummaryContributor for testing purposes. +/// +internal sealed class TestJobSummaryContributor(string title, string content) : IJobSummaryContributor +{ + private readonly string _Title = title; + private readonly string _Content = content; + + public IEnumerable? GetJobSummaryContent() + { + if (string.IsNullOrWhiteSpace(_Title) || string.IsNullOrWhiteSpace(_Content)) + return null; + + return [new JobSummarySection(_Title, new InfoString(_Content))]; + } +} + +#nullable restore diff --git a/tests/PSRule.Tests/Runtime/ConventionRegistrationTests.cs b/tests/PSRule.Tests/Runtime/ConventionRegistrationTests.cs new file mode 100644 index 0000000000..bd60c68705 --- /dev/null +++ b/tests/PSRule.Tests/Runtime/ConventionRegistrationTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using PSRule.Runtime; + +namespace PSRule.Tests.Runtime; + +/// +/// Tests for convention registration via IRuntimeServiceCollection. +/// +public sealed class ConventionRegistrationTests +{ + /// + /// Test that conventions can be registered via AddConvention method. + /// + [Fact] + public void AddConvention_WhenCalled_ShouldRegisterConvention() + { + // Arrange + var scope = new LanguageScope("test", null); + var serviceCollection = scope as IRuntimeServiceCollection; + + // Act + serviceCollection.AddConvention(); + + // Assert + var conventions = scope.GetConventions(); + Assert.Single(conventions); + Assert.Equal(typeof(TestConvention), conventions.First()); + } + + /// + /// Test that multiple conventions can be registered. + /// + [Fact] + public void AddConvention_WhenMultipleConventions_ShouldRegisterAll() + { + // Arrange + var scope = new LanguageScope("test", null); + var serviceCollection = scope as IRuntimeServiceCollection; + + // Act + serviceCollection.AddConvention(); + serviceCollection.AddConvention(); + + // Assert + var conventions = scope.GetConventions().ToArray(); + Assert.Equal(2, conventions.Length); + Assert.Contains(typeof(TestConvention), conventions); + Assert.Contains(typeof(TestConvention2), conventions); + } + + /// + /// Test that GetConventions returns empty collection when no conventions are registered. + /// + [Fact] + public void GetConventions_WhenNoConventions_ShouldReturnEmpty() + { + // Arrange + var scope = new LanguageScope("test", null); + + // Act + var conventions = scope.GetConventions(); + + // Assert + Assert.Empty(conventions); + } + + /// + /// Test convention class for testing. + /// + private sealed class TestConvention + { + } + + /// + /// Test convention class for testing. + /// + private sealed class TestConvention2 + { + } +} diff --git a/tests/PSRule.Tests/TestData/Conventions/JobSummary.Convention.ps1 b/tests/PSRule.Tests/TestData/Conventions/JobSummary.Convention.ps1 new file mode 100644 index 0000000000..471510c86a --- /dev/null +++ b/tests/PSRule.Tests/TestData/Conventions/JobSummary.Convention.ps1 @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Test convention that demonstrates job summary contribution capabilities +# Note: To actually contribute to job summaries, a convention would need to be implemented in C# +# and inherit from BaseConvention while implementing IJobSummaryContributor + +Convention 'Test.JobSummaryConvention' { + Begin { + Write-Host "Test convention for job summary contribution" + } +}