From 239b03982202df87bd8d0e3e8d941c9313d22f0e Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Fri, 17 Apr 2026 23:56:00 +0100 Subject: [PATCH 1/3] Add RuleOptionInfo class and update RuleInfo to include options for configurable rules --- .../Commands/GetScriptAnalyzerRuleCommand.cs | 6 +- Engine/Generic/RuleInfo.cs | 35 +++++ Engine/Generic/RuleOptionInfo.cs | 131 ++++++++++++++++++ Tests/Engine/GetScriptAnalyzerRule.tests.ps1 | 30 ++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 Engine/Generic/RuleOptionInfo.cs diff --git a/Engine/Commands/GetScriptAnalyzerRuleCommand.cs b/Engine/Commands/GetScriptAnalyzerRuleCommand.cs index 3219affa7..9a2782a45 100644 --- a/Engine/Commands/GetScriptAnalyzerRuleCommand.cs +++ b/Engine/Commands/GetScriptAnalyzerRuleCommand.cs @@ -114,8 +114,12 @@ protected override void ProcessRecord() foreach (IRule rule in rules) { + var ruleOptions = rule is ConfigurableRule + ? RuleOptionInfo.GetRuleOptions(rule) + : null; + WriteObject(new RuleInfo(rule.GetName(), rule.GetCommonName(), rule.GetDescription(), - rule.GetSourceType(), rule.GetSourceName(), rule.GetSeverity(), rule.GetType())); + rule.GetSourceType(), rule.GetSourceName(), rule.GetSeverity(), rule.GetType(), ruleOptions)); } } } diff --git a/Engine/Generic/RuleInfo.cs b/Engine/Generic/RuleInfo.cs index 755d16d15..8d8977d12 100644 --- a/Engine/Generic/RuleInfo.cs +++ b/Engine/Generic/RuleInfo.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic @@ -18,6 +19,7 @@ public class RuleInfo private string sourceName; private RuleSeverity ruleSeverity; private Type implementingType; + private IReadOnlyList options; /// /// Name: The name of the rule. @@ -90,6 +92,16 @@ public Type ImplementingType private set { implementingType = value; } } + /// + /// Options : The configurable properties for this rule, if any. + /// + [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + public IReadOnlyList Options + { + get { return options; } + private set { options = value; } + } + /// /// Constructor for a RuleInfo. /// @@ -128,6 +140,29 @@ public RuleInfo(string name, string commonName, string description, SourceType s ImplementingType = implementingType; } + /// + /// Constructor for a RuleInfo. + /// + /// Name of the rule. + /// Common Name of the rule. + /// Description of the rule. + /// Source type of the rule. + /// Source name of the rule. + /// Severity of the rule. + /// The dotnet type of the rule. + /// The configurable properties for this rule. + public RuleInfo(string name, string commonName, string description, SourceType sourceType, string sourceName, RuleSeverity severity, Type implementingType, IReadOnlyList options) + { + RuleName = name; + CommonName = commonName; + Description = description; + SourceType = sourceType; + SourceName = sourceName; + Severity = severity; + ImplementingType = implementingType; + Options = options; + } + public override string ToString() { return RuleName; diff --git a/Engine/Generic/RuleOptionInfo.cs b/Engine/Generic/RuleOptionInfo.cs new file mode 100644 index 000000000..71e704612 --- /dev/null +++ b/Engine/Generic/RuleOptionInfo.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic +{ + /// + /// Holds metadata for a single configurable rule property. + /// + public class RuleOptionInfo + { + /// + /// The name of the configurable property. + /// + public string Name { get; internal set; } + + /// + /// The CLR type of the property value. + /// + public Type OptionType { get; internal set; } + + /// + /// The default value declared via the ConfigurableRuleProperty attribute. + /// + public object DefaultValue { get; internal set; } + + /// + /// The set of valid values for this property, if constrained. + /// Null when any value of the declared type is acceptable. + /// + public object[] PossibleValues { get; internal set; } + + /// + /// Extracts RuleOptionInfo entries for every ConfigurableRuleProperty on + /// the given rule. For string properties backed by a private enum, the + /// possible values are populated from the enum members. + /// + /// The rule instance to inspect. + /// + /// A list of option metadata, ordered with Enable first then the + /// remainder sorted alphabetically. + /// + public static List GetRuleOptions(IRule rule) + { + var options = new List(); + Type ruleType = rule.GetType(); + + PropertyInfo[] properties = ruleType.GetProperties(BindingFlags.Instance | BindingFlags.Public); + + // Collect all private nested enums declared on the rule type so we + // can match them against string properties whose default value is an + // enum member name. + Type[] nestedEnums = ruleType + .GetNestedTypes(BindingFlags.NonPublic | BindingFlags.Public) + .Where(t => t.IsEnum) + .ToArray(); + + foreach (PropertyInfo prop in properties) + { + var attr = prop.GetCustomAttribute(inherit: true); + if (attr == null) + { + continue; + } + + var info = new RuleOptionInfo + { + Name = prop.Name, + OptionType = prop.PropertyType, + DefaultValue = attr.DefaultValue, + PossibleValues = null + }; + + // For string properties, attempt to find a matching private enum + // whose member names include the default value. This mirrors the + // pattern used by rules such as UseConsistentIndentation and + // ProvideCommentHelp where a string property is parsed into a + // private enum via Enum.TryParse. + // + // When multiple enums contain the default value (e.g. both have + // a "None" member), prefer the enum whose name contains the + // property name or vice-versa (e.g. property "Kind" matches enum + // "IndentationKind"). This helps avoid incorrect matches when a rule + // declares several enums with possible overlapping member names. + if (prop.PropertyType == typeof(string) && attr.DefaultValue is string defaultStr) + { + Type bestMatch = null; + bool bestHasNameRelation = false; + + foreach (Type enumType in nestedEnums) + { + if (!Enum.GetNames(enumType).Any(n => + string.Equals(n, defaultStr, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + bool hasNameRelation = + enumType.Name.IndexOf(prop.Name, StringComparison.OrdinalIgnoreCase) >= 0 || + prop.Name.IndexOf(enumType.Name, StringComparison.OrdinalIgnoreCase) >= 0; + + // Take this enum if we have no match yet, or if it has a + // name-based relationship and the previous match did not. + if (bestMatch == null || (hasNameRelation && !bestHasNameRelation)) + { + bestMatch = enumType; + bestHasNameRelation = hasNameRelation; + } + } + + if (bestMatch != null) + { + info.PossibleValues = Enum.GetNames(bestMatch); + } + } + + options.Add(info); + } + + // Sort with "Enable" first, then alphabetically by name for consistent ordering. + return options + .OrderBy(o => string.Equals(o.Name, "Enable", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(o => o.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 index 422b585bf..d1e2cd98d 100644 --- a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 +++ b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 @@ -180,3 +180,33 @@ Describe "TestImplementingType" { $type.BaseType.Name | Should -Be "ConfigurableRule" } } + +Describe "TestOptions" { + BeforeAll { + $configurableRule = Get-ScriptAnalyzerRule PSUseConsistentIndentation + $nonConfigurableRule = Get-ScriptAnalyzerRule PSAvoidUsingInvokeExpression + } + + It "returns Options for a configurable rule" { + $configurableRule.Options | Should -Not -BeNullOrEmpty + } + + It "includes the Enable option" { + $configurableRule.Options.Name | Should -Contain 'Enable' + } + + It "places Enable as the first option" { + $configurableRule.Options[0].Name | Should -Be 'Enable' + } + + It "populates PossibleValues for enum-backed string properties" { + $kindOption = $configurableRule.Options | Where-Object Name -eq 'Kind' + $kindOption.PossibleValues | Should -Not -BeNullOrEmpty + $kindOption.PossibleValues | Should -Contain 'Space' + $kindOption.PossibleValues | Should -Contain 'Tab' + } + + It "returns null Options for a non-configurable rule" { + $nonConfigurableRule.Options | Should -BeNullOrEmpty + } +} From a09c6de00822ec27b03578244b5aacbaf46517d3 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Mon, 20 Apr 2026 20:55:09 +0100 Subject: [PATCH 2/3] Add Test and New Cmdlets for PSScriptAnalyzer Settings Management - Implemented `New-ScriptAnalyzerSettingsFile` cmdlet to create a new PSScriptAnalyzer settings file, with options for presets and overwriting existing files. - Added `Test-ScriptAnalyzerSettingsFile` cmdlet to validate settings files, checking for parseability, rule existence, and valid options. - Created comprehensive tests for both cmdlets to ensure functionality and error handling. - Updated module manifest to export the new cmdlets. - Added documentation for both cmdlets, including usage examples and parameter descriptions. - Enhanced error messages in the strings resource file for better clarity during validation failures. --- .../NewScriptAnalyzerSettingsFileCommand.cs | 499 ++++++++++++++++++ .../TestScriptAnalyzerSettingsFileCommand.cs | 326 ++++++++++++ Engine/PSScriptAnalyzer.psd1 | 2 +- Engine/PSScriptAnalyzer.psm1 | 4 + Engine/Strings.resx | 33 ++ .../NewScriptAnalyzerSettingsFile.tests.ps1 | 265 ++++++++++ .../TestScriptAnalyzerSettingsFile.tests.ps1 | 209 ++++++++ .../Cmdlets/New-ScriptAnalyzerSettingsFile.md | 183 +++++++ .../Test-ScriptAnalyzerSettingsFile.md | 168 ++++++ 9 files changed, 1688 insertions(+), 1 deletion(-) create mode 100644 Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs create mode 100644 Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs create mode 100644 Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 create mode 100644 Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 create mode 100644 docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md create mode 100644 docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md diff --git a/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs new file mode 100644 index 000000000..32f12fde3 --- /dev/null +++ b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs @@ -0,0 +1,499 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Text; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands +{ + /// + /// Creates a new PSScriptAnalyzer settings file. + /// The emitted file is always named PSScriptAnalyzerSettings.psd1 so that automatic + /// settings discovery works when the file is placed in a project directory. + /// + [Cmdlet(VerbsCommon.New, "ScriptAnalyzerSettingsFile", SupportsShouldProcess = true, + HelpUri = "https://github.com/PowerShell/PSScriptAnalyzer")] + public class NewScriptAnalyzerSettingsFileCommand : PSCmdlet, IOutputWriter + { + private const string SettingsFileName = "PSScriptAnalyzerSettings.psd1"; + + #region Parameters + + /// + /// The directory where the settings file will be created. + /// Defaults to the current working directory. + /// + [Parameter(Mandatory = false, Position = 0)] + [ValidateNotNullOrEmpty] + public string Path { get; set; } + + /// + /// The name of a built-in preset to use as the basis for the + /// generated settings file. When omitted, all rules and their default + /// configurable options are included. Valid values are resolved dynamically + /// from the shipped preset files and tab-completed via an argument completer + /// registered in PSScriptAnalyzer.psm1. + /// + [Parameter(Mandatory = false)] + [ValidateNotNullOrEmpty] + public string BaseOnPreset { get; set; } + + /// + /// Overwrite an existing settings file at the target path. + /// + [Parameter(Mandatory = false)] + public SwitchParameter Force { get; set; } + + #endregion Parameters + + #region Overrides + + /// + /// Initialise the analyser engine so that rule metadata is available. + /// + protected override void BeginProcessing() + { + Helper.Instance = new Helper(SessionState.InvokeCommand); + Helper.Instance.Initialize(); + + ScriptAnalyzer.Instance.Initialize(this, null, null, null, null, true); + } + + /// + /// Generate and write the settings file. + /// + protected override void ProcessRecord() + { + // Validate -BaseOnPreset against the dynamically discovered presets. + if (!string.IsNullOrEmpty(BaseOnPreset)) + { + var validPresets = Settings.GetSettingPresets().ToList(); + if (!validPresets.Contains(BaseOnPreset, StringComparer.OrdinalIgnoreCase)) + { + ThrowTerminatingError( + new ErrorRecord( + new ArgumentException( + string.Format( + CultureInfo.CurrentCulture, + Strings.InvalidPresetName, + BaseOnPreset, + string.Join(", ", validPresets) + ) + ), + "InvalidPresetName", + ErrorCategory.InvalidArgument, + BaseOnPreset + ) + ); + } + } + + string directory = string.IsNullOrEmpty(Path) + ? SessionState.Path.CurrentFileSystemLocation.Path + : GetUnresolvedProviderPathFromPSPath(Path); + + string targetPath = System.IO.Path.Combine(directory, SettingsFileName); + + // Guard against overwriting an existing settings file unless -Force is specified. + if (File.Exists(targetPath) && !Force) + { + ThrowTerminatingError( + new ErrorRecord( + new IOException( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsFileAlreadyExists, + targetPath + ) + ), + "SettingsFileAlreadyExists", + ErrorCategory.ResourceExists, + targetPath + ) + ); + } + + string content; + if (!string.IsNullOrEmpty(BaseOnPreset)) + { + content = GenerateFromPreset(BaseOnPreset); + } + else + { + content = GenerateFromAllRules(); + } + + if (ShouldProcess(targetPath, "Create settings file")) + { + // Ensure the target directory exists. + Directory.CreateDirectory(directory); + File.WriteAllText(targetPath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + WriteObject(new FileInfo(targetPath)); + } + } + + #endregion Overrides + + #region Settings generation + + /// + /// Generates settings content from a built-in preset. The preset is parsed and + /// the output is normalised to include all top-level fields. + /// + private string GenerateFromPreset(string presetName) + { + string presetPath = Settings.GetSettingPresetFilePath(presetName); + if (presetPath == null || !File.Exists(presetPath)) + { + ThrowTerminatingError( + new ErrorRecord( + new ArgumentException( + string.Format( + CultureInfo.CurrentCulture, + Strings.PresetNotFound, + presetName + ) + ), + "PresetNotFound", + ErrorCategory.ObjectNotFound, + presetName + ) + ); + } + + var parsed = new Settings(presetPath); + var ruleOptionMap = BuildRuleOptionMap(); + + var sb = new StringBuilder(); + WriteHeader(sb, presetName); + sb.AppendLine("@{"); + + sb.AppendLine(" # Rules to run. When populated, only these rules are used."); + sb.AppendLine(" # Leave empty to run all rules."); + WriteStringArray(sb, "IncludeRules", parsed.IncludeRules); + sb.AppendLine(); + + sb.AppendLine(" # Rules to skip. Takes precedence over IncludeRules."); + WriteStringArray(sb, "ExcludeRules", parsed.ExcludeRules); + sb.AppendLine(); + + sb.AppendLine(" # Only report diagnostics at these severity levels."); + sb.AppendLine(" # Leave empty to report all severities."); + WriteSeverityArray(sb, parsed.Severities); + sb.AppendLine(); + + sb.AppendLine(" # Per-rule configuration. Only configurable rules appear here."); + sb.AppendLine(" # Values from the preset are shown; other properties use defaults."); + + if (parsed.RuleArguments != null && parsed.RuleArguments.Count > 0) + { + sb.AppendLine(" Rules = @{"); + + bool firstRule = true; + foreach (var ruleEntry in parsed.RuleArguments.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)) + { + if (!firstRule) + { + sb.AppendLine(); + } + firstRule = false; + + string ruleName = ruleEntry.Key; + var presetArgs = ruleEntry.Value; + + if (ruleOptionMap.TryGetValue(ruleName, out var optionInfos)) + { + WriteRuleSettings(sb, ruleName, optionInfos, presetArgs); + } + else + { + WriteRuleSettingsRaw(sb, ruleName, presetArgs); + } + } + + sb.AppendLine(" }"); + } + else + { + sb.AppendLine(" Rules = @{}"); + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + + /// + /// Generates settings content that includes every available rule with all + /// configurable properties set to their defaults. + /// + private string GenerateFromAllRules() + { + var ruleNames = new List(); + var ruleOptionMap = BuildRuleOptionMap(ruleNames); + + var sb = new StringBuilder(); + WriteHeader(sb, presetName: null); + sb.AppendLine("@{"); + + sb.AppendLine(" # Rules to run. When populated, only these rules are used."); + sb.AppendLine(" # Leave empty to run all rules."); + WriteStringArray(sb, "IncludeRules", ruleNames); + sb.AppendLine(); + + sb.AppendLine(" # Rules to skip. Takes precedence over IncludeRules."); + WriteStringArray(sb, "ExcludeRules", Enumerable.Empty()); + sb.AppendLine(); + + sb.AppendLine(" # Only report diagnostics at these severity levels."); + sb.AppendLine(" # Leave empty to report all severities."); + WriteSeverityArray(sb, Enumerable.Empty()); + sb.AppendLine(); + + sb.AppendLine(" # Per-rule configuration. Only configurable rules appear here."); + sb.AppendLine(" Rules = @{"); + + bool firstRule = true; + foreach (var kvp in ruleOptionMap.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)) + { + if (!firstRule) + { + sb.AppendLine(); + } + firstRule = false; + + WriteRuleSettings(sb, kvp.Key, kvp.Value, presetArgs: null); + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + /// + /// Builds a map of rule name to its configurable property metadata. + /// Optionally populates a list of all rule names encountered. + /// + private Dictionary> BuildRuleOptionMap(List allRuleNames = null) + { + var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + string[] modNames = ScriptAnalyzer.Instance.GetValidModulePaths(); + IEnumerable rules = ScriptAnalyzer.Instance.GetRule(modNames, null) + ?? Enumerable.Empty(); + + foreach (IRule rule in rules) + { + string name = rule.GetName(); + allRuleNames?.Add(name); + + if (rule is ConfigurableRule) + { + var options = RuleOptionInfo.GetRuleOptions(rule); + if (options.Count > 0) + { + map[name] = options; + } + } + } + + return map; + } + + #endregion Settings generation + + #region Formatting helpers + + /// + /// Writes a comment header identifying the tool and version that generated + /// the file, along with the preset if one was specified. + /// + private static void WriteHeader(StringBuilder sb, string presetName) + { + Version version = typeof(ScriptAnalyzer).Assembly.GetName().Version; + string versionStr = string.Format(CultureInfo.InvariantCulture, "{0}.{1}.{2}", version.Major, version.Minor, version.Build); + + sb.AppendLine("#"); + sb.AppendLine(string.Format( + CultureInfo.InvariantCulture, + "# PSScriptAnalyzer settings file ({0})", + versionStr)); + + if (!string.IsNullOrEmpty(presetName)) + { + sb.AppendLine(string.Format( + CultureInfo.InvariantCulture, + "# Based on the '{0}' preset.", + presetName)); + } + + sb.AppendLine("#"); + sb.AppendLine("# Generated by New-ScriptAnalyzerSettingsFile."); + sb.AppendLine("#"); + sb.AppendLine(); + } + + /// + /// Writes a PowerShell string-array assignment such as IncludeRules = @( ... ). + /// + private static void WriteStringArray(StringBuilder sb, string key, IEnumerable values) + { + var items = values?.ToList() ?? new List(); + + if (items.Count == 0) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0} = @()", key)); + return; + } + + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0} = @(", key)); + foreach (string item in items) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " '{0}'", item)); + } + sb.AppendLine(" )"); + } + + /// + /// Writes the Severity array with an inline comment listing valid values. + /// + private static void WriteSeverityArray(StringBuilder sb, IEnumerable values) + { + string validValues = string.Join(", ", Enum.GetNames(typeof(RuleSeverity))); + var items = values?.ToList() ?? new List(); + + if (items.Count == 0) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " Severity = @() # {0}", validValues)); + return; + } + + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " Severity = @( # {0}", validValues)); + foreach (string item in items) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " '{0}'", item)); + } + sb.AppendLine(" )"); + } + + /// + /// Writes a rule settings block using option metadata, optionally merging + /// with values from a preset. Enable always appears first, followed by + /// the remaining properties sorted alphabetically. + /// + private static void WriteRuleSettings( + StringBuilder sb, + string ruleName, + List optionInfos, + Dictionary presetArgs) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0} = @{{", ruleName)); + + foreach (RuleOptionInfo option in optionInfos) + { + object value = option.DefaultValue; + if (presetArgs != null + && presetArgs.TryGetValue(option.Name, out object presetVal)) + { + value = presetVal; + } + + string formatted = FormatValue(value); + string comment = FormatPossibleValuesComment(option); + + sb.AppendLine(string.Format( + CultureInfo.InvariantCulture, + " {0} = {1}{2}", + option.Name, + formatted, + comment)); + } + + sb.AppendLine(" }"); + } + + /// + /// Writes preset rule arguments verbatim when no option metadata is available. + /// + private static void WriteRuleSettingsRaw( + StringBuilder sb, + string ruleName, + Dictionary args) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0} = @{{", ruleName)); + + foreach (var kvp in args.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) + { + sb.AppendLine(string.Format( + CultureInfo.InvariantCulture, + " {0} = {1}", + kvp.Key, + FormatValue(kvp.Value))); + } + + sb.AppendLine(" }"); + } + + /// + /// Formats a value as a PowerShell literal suitable for inclusion in a .psd1 file. + /// + private static string FormatValue(object value) + { + if (value is bool boolVal) + { + return boolVal ? "$true" : "$false"; + } + + if (value is int || value is long || value is double || value is float) + { + return Convert.ToString(value, CultureInfo.InvariantCulture); + } + + if (value is string strVal) + { + return string.Format(CultureInfo.InvariantCulture, "'{0}'", strVal); + } + + if (value is Array arr) + { + if (arr.Length == 0) + { + return "@()"; + } + + var elements = new List(); + foreach (object item in arr) + { + elements.Add(FormatValue(item)); + } + return string.Format(CultureInfo.InvariantCulture, "@({0})", string.Join(", ", elements)); + } + + // Fallback - treat as string. + return string.Format(CultureInfo.InvariantCulture, "'{0}'", value); + } + + /// + /// Returns an inline comment listing the valid values, or an empty string + /// when the option is unconstrained. + /// + private static string FormatPossibleValuesComment(RuleOptionInfo option) + { + if (option.PossibleValues == null || option.PossibleValues.Length == 0) + { + return string.Empty; + } + + return " # " + string.Join(", ", option.PossibleValues.Select(v => v.ToString())); + } + + #endregion Formatting helpers + } +} diff --git a/Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs b/Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs new file mode 100644 index 000000000..0c8371bc1 --- /dev/null +++ b/Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Management.Automation; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands +{ + /// + /// TestScriptAnalyzerSettingsFileCommand: Validates a PSScriptAnalyzer settings file. + /// Checks that the file is parseable, that referenced rules exist, and that all + /// rule options and their values are valid. + /// + /// By default, returns $true when a file is valid, and writes non-terminating + /// errors describing each problem found (no output on failure beyond the errors). + /// When -Quiet is specified, returns $true or $false silently. + /// + [Cmdlet(VerbsDiagnostic.Test, "ScriptAnalyzerSettingsFile", + HelpUri = "https://github.com/PowerShell/PSScriptAnalyzer")] + [OutputType(typeof(bool))] + public class TestScriptAnalyzerSettingsFileCommand : PSCmdlet, IOutputWriter + { + #region Parameters + + /// + /// The path to the settings file to validate. + /// + [Parameter(Mandatory = true, Position = 0)] + [ValidateNotNullOrEmpty] + public string Path { get; set; } + + /// + /// When specified, returns only $true or $false without writing + /// errors or warnings. Without this switch the cmdlet writes + /// non-terminating errors for every problem found. + /// + [Parameter(Mandatory = false)] + public SwitchParameter Quiet { get; set; } + + /// + /// Paths to custom rule modules. + /// When specified, custom rule names are also treated as valid. + /// + [Parameter(Mandatory = false)] + [ValidateNotNullOrEmpty] + public string[] CustomRulePath { get; set; } + + /// + /// Search sub-folders under the custom rule path. + /// + [Parameter(Mandatory = false)] + public SwitchParameter RecurseCustomRulePath { get; set; } + + #endregion Parameters + + #region Overrides + + /// + /// BeginProcessing: Initialise the analyser engine. + /// + protected override void BeginProcessing() + { + Helper.Instance = new Helper(SessionState.InvokeCommand); + Helper.Instance.Initialize(); + + string[] rulePaths = Helper.ProcessCustomRulePaths( + CustomRulePath, SessionState, RecurseCustomRulePath); + ScriptAnalyzer.Instance.Initialize(this, rulePaths, null, null, null, rulePaths == null); + } + + /// + /// ProcessRecord: Parse and validate the settings file. + /// + protected override void ProcessRecord() + { + string resolvedPath = GetUnresolvedProviderPathFromPSPath(Path); + + if (!File.Exists(resolvedPath)) + { + var error = new ErrorRecord( + new FileNotFoundException(string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsFileNotFound, + resolvedPath)), + "SettingsFileNotFound", + ErrorCategory.ObjectNotFound, + resolvedPath); + + if (Quiet) + { + WriteObject(false); + } + else + { + WriteError(error); + } + + return; + } + + // Attempt to parse the settings file. + Settings parsed; + try + { + parsed = new Settings(resolvedPath); + } + catch (Exception ex) + { + ReportProblem(string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsFileParseError, + ex.Message), + "SettingsFileParseError", + ErrorCategory.ParserError, + resolvedPath); + return; + } + + bool isValid = true; + + // Build a set of known rule names from the engine. + string[] modNames = ScriptAnalyzer.Instance.GetValidModulePaths(); + IEnumerable knownRules = ScriptAnalyzer.Instance.GetRule(modNames, null) + ?? Enumerable.Empty(); + + var ruleMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (IRule rule in knownRules) + { + ruleMap[rule.GetName()] = rule; + } + + // Validate IncludeRules. + isValid &= ValidateRuleNames(parsed.IncludeRules, ruleMap, "IncludeRules"); + + // Validate ExcludeRules. + isValid &= ValidateRuleNames(parsed.ExcludeRules, ruleMap, "ExcludeRules"); + + // Validate Severity values. + isValid &= ValidateSeverities(parsed.Severities); + + // Validate rule arguments. + if (parsed.RuleArguments != null) + { + foreach (var ruleEntry in parsed.RuleArguments) + { + string ruleName = ruleEntry.Key; + + if (!ruleMap.TryGetValue(ruleName, out IRule rule)) + { + ReportProblem( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileRuleArgRuleNotFound, ruleName), + "RuleNotFound", + ErrorCategory.ObjectNotFound, + ruleName); + isValid = false; + continue; + } + + if (!(rule is ConfigurableRule)) + { + ReportProblem( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileRuleNotConfigurable, ruleName), + "RuleNotConfigurable", + ErrorCategory.InvalidArgument, + ruleName); + isValid = false; + continue; + } + + var optionInfos = RuleOptionInfo.GetRuleOptions(rule); + var optionMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var opt in optionInfos) + { + optionMap[opt.Name] = opt; + } + + foreach (var arg in ruleEntry.Value) + { + string argName = arg.Key; + + if (!optionMap.TryGetValue(argName, out RuleOptionInfo optionInfo)) + { + ReportProblem( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileUnrecognisedOption, ruleName, argName), + "UnrecognisedRuleOption", + ErrorCategory.InvalidArgument, + argName); + isValid = false; + continue; + } + + // Validate possible values for constrained options. + if (optionInfo.PossibleValues != null + && optionInfo.PossibleValues.Length > 0 + && arg.Value is string strValue) + { + bool valueValid = optionInfo.PossibleValues.Any(pv => + string.Equals(pv.ToString(), strValue, StringComparison.OrdinalIgnoreCase)); + + if (!valueValid) + { + ReportProblem( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileInvalidOptionValue, + ruleName, argName, strValue, + string.Join(", ", optionInfo.PossibleValues.Select(v => v.ToString()))), + "InvalidRuleOptionValue", + ErrorCategory.InvalidArgument, + strValue); + isValid = false; + } + } + } + } + } + + if (Quiet) + { + WriteObject(isValid); + } + else if (isValid) + { + WriteObject(true); + } + } + + #endregion Overrides + + #region Helpers + + /// + /// Reports a validation problem. In quiet mode the problem is silently + /// recorded; otherwise a non-terminating error is written. + /// + private void ReportProblem(string message, string errorId, ErrorCategory category, object target) + { + if (!Quiet) + { + WriteError(new ErrorRecord( + new InvalidOperationException(message), + errorId, + category, + target)); + } + } + + /// + /// Validates that rule names from a settings field exist in the known rule set. + /// Entries containing wildcard characters are skipped as they are pattern-matched + /// at runtime. + /// + private bool ValidateRuleNames( + IEnumerable ruleNames, + Dictionary ruleMap, + string fieldName) + { + bool valid = true; + if (ruleNames == null) + { + return valid; + } + + foreach (string name in ruleNames) + { + // Skip wildcard patterns such as PSDSC* - these are resolved at runtime. + if (WildcardPattern.ContainsWildcardCharacters(name)) + { + continue; + } + + if (!ruleMap.ContainsKey(name)) + { + ReportProblem( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileRuleNotFound, fieldName, name), + "RuleNotFound", + ErrorCategory.ObjectNotFound, + name); + valid = false; + } + } + + return valid; + } + + /// + /// Validates severity values against the RuleSeverity enum. + /// + private bool ValidateSeverities(IEnumerable severities) + { + bool valid = true; + if (severities == null) + { + return valid; + } + + foreach (string sev in severities) + { + if (!Enum.TryParse(sev, ignoreCase: true, out _)) + { + ReportProblem( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileInvalidSeverity, + sev, + string.Join(", ", Enum.GetNames(typeof(RuleSeverity)))), + "InvalidSeverity", + ErrorCategory.InvalidArgument, + sev); + valid = false; + } + } + + return valid; + } + + #endregion Helpers + } +} diff --git a/Engine/PSScriptAnalyzer.psd1 b/Engine/PSScriptAnalyzer.psd1 index 993677254..80a5822da 100644 --- a/Engine/PSScriptAnalyzer.psd1 +++ b/Engine/PSScriptAnalyzer.psd1 @@ -65,7 +65,7 @@ FormatsToProcess = @('ScriptAnalyzer.format.ps1xml') FunctionsToExport = @() # Cmdlets to export from this module -CmdletsToExport = @('Get-ScriptAnalyzerRule', 'Invoke-ScriptAnalyzer', 'Invoke-Formatter') +CmdletsToExport = @('Get-ScriptAnalyzerRule', 'Invoke-ScriptAnalyzer', 'Invoke-Formatter', 'New-ScriptAnalyzerSettingsFile', 'Test-ScriptAnalyzerSettingsFile') # Variables to export from this module VariablesToExport = @() diff --git a/Engine/PSScriptAnalyzer.psm1 b/Engine/PSScriptAnalyzer.psm1 index 7e2ca8f31..50348fa75 100644 --- a/Engine/PSScriptAnalyzer.psm1 +++ b/Engine/PSScriptAnalyzer.psm1 @@ -49,6 +49,10 @@ if (Get-Command Register-ArgumentCompleter -ErrorAction Ignore) { } + Register-ArgumentCompleter -CommandName 'New-ScriptAnalyzerSettingsFile' ` + -ParameterName 'BaseOnPreset' ` + -ScriptBlock $settingPresetCompleter + Function RuleNameCompleter { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParmeter) diff --git a/Engine/Strings.resx b/Engine/Strings.resx index 346a25aa6..c10aa5338 100644 --- a/Engine/Strings.resx +++ b/Engine/Strings.resx @@ -324,4 +324,37 @@ Ignoring 'TypeNotFound' parse error on type '{0}'. Check if the specified type is correct. This can also be due the type not being known at parse time due to types imported by 'using' statements. + + '{0}' is not a recognised preset. Valid presets are: {1} + + + Could not locate the preset '{0}'. + + + A settings file already exists at '{0}'. Use -Force to overwrite. + + + The settings file '{0}' does not exist. + + + Failed to parse settings file: {0} + + + {0}: rule '{1}' not found. + + + Rules.{0}: rule not found. + + + Rules.{0}: this rule is not configurable. + + + Rules.{0}.{1}: unrecognised option. + + + Rules.{0}.{1}: '{2}' is not a valid value. Expected one of: {3} + + + Severity: '{0}' is not a valid severity. Expected one of: {1} + \ No newline at end of file diff --git a/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 b/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 new file mode 100644 index 000000000..2acc2024f --- /dev/null +++ b/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 @@ -0,0 +1,265 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeAll { + $settingsFileName = 'PSScriptAnalyzerSettings.psd1' +} + +Describe "New-ScriptAnalyzerSettingsFile" { + Context "When creating a default settings file (no preset)" { + BeforeAll { + $testDir = Join-Path $TestDrive 'default' + New-Item -ItemType Directory -Path $testDir | Out-Null + $result = New-ScriptAnalyzerSettingsFile -Path $testDir + $settingsPath = Join-Path $testDir $settingsFileName + } + + It "Should return a FileInfo object" { + $result | Should -BeOfType ([System.IO.FileInfo]) + } + + It "Should create the settings file" { + $settingsPath | Should -Exist + } + + It "Should produce a valid PSD1 that can be parsed" { + { Import-PowerShellDataFile -Path $settingsPath } | Should -Not -Throw + } + + It "Should contain the IncludeRules key with at least one rule" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('IncludeRules') | Should -BeTrue + $data['IncludeRules'].Count | Should -BeGreaterThan 0 + } + + It "Should contain the ExcludeRules key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('ExcludeRules') | Should -BeTrue + } + + It "Should contain the Severity key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('Severity') | Should -BeTrue + } + + It "Should contain the Rules key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('Rules') | Should -BeTrue + } + + It "Should include all available rules in IncludeRules" { + $data = Import-PowerShellDataFile -Path $settingsPath + $allRules = Get-ScriptAnalyzerRule | ForEach-Object RuleName + foreach ($rule in $allRules) { + $data['IncludeRules'] | Should -Contain $rule + } + } + + It "Should place Enable first in rule settings" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '(?s)PSUseConsistentIndentation = @\{\s+Enable' + } + + It "Should include inline comments listing valid values for constrained properties" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match "# Space, Tab" + } + + It "Should include a comment with valid severity values" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Information, Warning, Error, ParseError' + } + + It "Should be usable with Invoke-ScriptAnalyzer" { + { Invoke-ScriptAnalyzer -ScriptDefinition '"hello"' -Settings $settingsPath } | Should -Not -Throw + } + + It "Should contain a header with the PSScriptAnalyzer version" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# PSScriptAnalyzer settings file \(\d+\.\d+\.\d+\)' + } + + It "Should contain a header with the generation tool name" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Generated by New-ScriptAnalyzerSettingsFile\.' + } + + It "Should not mention a preset in the header" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Not -Match '# Based on the' + } + + It "Should contain a section comment before IncludeRules" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Rules to run\. When populated, only these rules are used\.' + } + + It "Should contain a section comment before ExcludeRules" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Rules to skip\. Takes precedence over IncludeRules\.' + } + + It "Should contain a section comment before Severity" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Only report diagnostics at these severity levels\.' + } + + It "Should contain a section comment before Rules" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Per-rule configuration\. Only configurable rules appear here\.' + } + } + + Context "When creating a settings file based on a preset" { + BeforeAll { + $testDir = Join-Path $TestDrive 'preset' + New-Item -ItemType Directory -Path $testDir | Out-Null + $result = New-ScriptAnalyzerSettingsFile -Path $testDir -BaseOnPreset CodeFormatting + $settingsPath = Join-Path $testDir $settingsFileName + } + + It "Should create the settings file" { + $settingsPath | Should -Exist + } + + It "Should produce a valid PSD1" { + { Import-PowerShellDataFile -Path $settingsPath } | Should -Not -Throw + } + + It "Should contain all top-level fields" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('IncludeRules') | Should -BeTrue + $data.ContainsKey('ExcludeRules') | Should -BeTrue + $data.ContainsKey('Severity') | Should -BeTrue + $data.ContainsKey('Rules') | Should -BeTrue + } + + It "Should include the preset rules in IncludeRules" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data['IncludeRules'] | Should -Contain 'PSPlaceOpenBrace' + $data['IncludeRules'] | Should -Contain 'PSUseConsistentIndentation' + } + + It "Should include rule configuration from the preset" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data['Rules'].ContainsKey('PSPlaceOpenBrace') | Should -BeTrue + $data['Rules']['PSPlaceOpenBrace']['Enable'] | Should -BeTrue + } + + It "Should be usable with Invoke-ScriptAnalyzer" { + { Invoke-ScriptAnalyzer -ScriptDefinition '"hello"' -Settings $settingsPath } | Should -Not -Throw + } + + It "Should mention the preset name in the header" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match "# Based on the 'CodeFormatting' preset\." + } + + It "Should contain section comments" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Rules to run' + $content | Should -Match '# Rules to skip' + $content | Should -Match '# Only report diagnostics at these severity levels' + $content | Should -Match '# Per-rule configuration' + } + } + + Context "When a settings file already exists at the target path" { + BeforeAll { + $testDir = Join-Path $TestDrive 'exists' + New-Item -ItemType Directory -Path $testDir | Out-Null + Set-Content -Path (Join-Path $testDir $settingsFileName) -Value '@{}' + } + + It "Should throw a terminating error without -Force" { + { New-ScriptAnalyzerSettingsFile -Path $testDir -ErrorAction Stop } | + Should -Throw -ErrorId 'SettingsFileAlreadyExists*' + } + } + + Context "When using -Force to overwrite an existing file" { + BeforeAll { + $testDir = Join-Path $TestDrive 'force' + New-Item -ItemType Directory -Path $testDir | Out-Null + Set-Content -Path (Join-Path $testDir $settingsFileName) -Value '@{}' + $result = New-ScriptAnalyzerSettingsFile -Path $testDir -Force + $settingsPath = Join-Path $testDir $settingsFileName + } + + It "Should overwrite the existing file" { + $settingsPath | Should -Exist + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('IncludeRules') | Should -BeTrue + } + + It "Should return a FileInfo object" { + $result | Should -BeOfType ([System.IO.FileInfo]) + } + } + + Context "When using -WhatIf" { + It "Should not create the settings file" { + $testDir = Join-Path $TestDrive 'whatif' + New-Item -ItemType Directory -Path $testDir | Out-Null + $settingsPath = Join-Path $testDir $settingsFileName + # WhatIf messages are written directly to the host UI by ShouldProcess, + # bypassing all output streams. Run in a new runspace whose default host + # silently discards host output. + $ps = [powershell]::Create() + try { + $null = $ps.AddCommand('Import-Module').AddParameter('Name', (Get-Module PSScriptAnalyzer).Path).Invoke() + $ps.Commands.Clear() + $null = $ps.AddCommand('New-ScriptAnalyzerSettingsFile').AddParameter('Path', $testDir).AddParameter('WhatIf', $true).Invoke() + } + finally { + $ps.Dispose() + } + $settingsPath | Should -Not -Exist + } + } + + Context "When the -Path parameter points to a non-existent directory" { + BeforeAll { + $nestedDir = Join-Path (Join-Path (Join-Path $TestDrive 'nested') 'sub') 'folder' + $result = New-ScriptAnalyzerSettingsFile -Path $nestedDir + $settingsPath = Join-Path $nestedDir $settingsFileName + } + + It "Should create the directory and the settings file" { + $settingsPath | Should -Exist + } + } + + Context "When using the default path (current directory)" { + BeforeAll { + $testDir = Join-Path $TestDrive 'cwd' + New-Item -ItemType Directory -Path $testDir | Out-Null + Push-Location $testDir + $result = New-ScriptAnalyzerSettingsFile + $settingsPath = Join-Path $testDir $settingsFileName + } + + AfterAll { + Pop-Location + } + + It "Should create the file in the current working directory" { + $settingsPath | Should -Exist + } + } + + Context "Generated settings file for each preset" { + BeforeDiscovery { + $presets = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::GetSettingPresets() | + ForEach-Object { @{ Preset = $_ } } + } + + It "Should produce a valid PSD1 for the '' preset" -TestCases $presets { + $testDir = Join-Path $TestDrive "preset-$Preset" + New-Item -ItemType Directory -Path $testDir | Out-Null + $settingsPath = Join-Path $testDir $settingsFileName + New-ScriptAnalyzerSettingsFile -Path $testDir -BaseOnPreset $Preset + { Import-PowerShellDataFile -Path $settingsPath } | Should -Not -Throw + } + } +} diff --git a/Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 b/Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 new file mode 100644 index 000000000..4d22f7c58 --- /dev/null +++ b/Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 @@ -0,0 +1,209 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe "Test-ScriptAnalyzerSettingsFile" { + Context "Given a valid generated settings file" { + BeforeAll { + $testDir = Join-Path $TestDrive 'valid' + New-Item -ItemType Directory -Path $testDir | Out-Null + New-ScriptAnalyzerSettingsFile -Path $testDir + $settingsPath = Join-Path $testDir 'PSScriptAnalyzerSettings.psd1' + } + + It "Should return true" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath | Should -BeTrue + } + + It "Should return true with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeTrue + } + } + + Context "Given a valid preset-based settings file" { + BeforeAll { + $testDir = Join-Path $TestDrive 'preset' + New-Item -ItemType Directory -Path $testDir | Out-Null + New-ScriptAnalyzerSettingsFile -Path $testDir -BaseOnPreset CodeFormatting + $settingsPath = Join-Path $testDir 'PSScriptAnalyzerSettings.psd1' + } + + It "Should return true" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath | Should -BeTrue + } + } + + Context "Given a file that does not exist" { + It "Should write a non-terminating error and produce no output" { + $bogusPath = Join-Path $TestDrive 'nonexistent.psd1' + $result = Test-ScriptAnalyzerSettingsFile -Path $bogusPath -ErrorVariable errs -ErrorAction SilentlyContinue + $result | Should -BeNullOrEmpty + $errs | Should -Not -BeNullOrEmpty + $errs[0].FullyQualifiedErrorId | Should -BeLike 'SettingsFileNotFound*' + } + + It "Should return false with -Quiet" { + $bogusPath = Join-Path $TestDrive 'nonexistent.psd1' + Test-ScriptAnalyzerSettingsFile -Path $bogusPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with an unknown rule name" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'unknown-rule.psd1' + $content = @" +@{ + IncludeRules = @( + 'PSBogusRuleThatDoesNotExist' + ) +} +"@ + Set-Content -Path $settingsPath -Value $content + } + + It "Should write a non-terminating error and produce no output" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue + $result | Should -BeNullOrEmpty + $errs | Should -Not -BeNullOrEmpty + } + + It "Should report the unknown rule name in the error" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue + $errs[0].Exception.Message | Should -BeLike "IncludeRules: rule 'PSBogusRuleThatDoesNotExist' not found.*" + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with an invalid rule option name" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-option.psd1' + $content = @" +@{ + IncludeRules = @('PSUseConsistentIndentation') + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = `$true + CompletelyBogusOption = 42 + } + } +} +"@ + Set-Content -Path $settingsPath -Value $content + } + + It "Should write a non-terminating error and produce no output" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue + $result | Should -BeNullOrEmpty + $errs | Should -Not -BeNullOrEmpty + } + + It "Should report the unrecognised option in the error" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue + $errs[0].Exception.Message | Should -BeLike "Rules.PSUseConsistentIndentation.CompletelyBogusOption: unrecognised option.*" + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with an invalid rule option value" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-value.psd1' + $content = @" +@{ + IncludeRules = @('PSUseConsistentIndentation') + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = `$true + Kind = 'banana' + } + } +} +"@ + Set-Content -Path $settingsPath -Value $content + } + + It "Should write a non-terminating error and produce no output" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue + $result | Should -BeNullOrEmpty + $errs | Should -Not -BeNullOrEmpty + } + + It "Should report the invalid value in the error" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue + $errs[0].Exception.Message | Should -BeLike "Rules.PSUseConsistentIndentation.Kind: 'banana' is not a valid value.*" + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with an invalid severity value" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-severity.psd1' + $content = @" +@{ + Severity = @('Critical') +} +"@ + Set-Content -Path $settingsPath -Value $content + } + + It "Should write a non-terminating error and produce no output" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue + $result | Should -BeNullOrEmpty + $errs | Should -Not -BeNullOrEmpty + } + + It "Should report the invalid severity in the error" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue + $errs[0].Exception.Message | Should -BeLike "Severity: 'Critical' is not a valid severity.*" + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with wildcard rule names in IncludeRules" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'wildcard.psd1' + $content = @" +@{ + IncludeRules = @('PSDSC*') +} +"@ + Set-Content -Path $settingsPath -Value $content + } + + It "Should return true - wildcards are valid" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath | Should -BeTrue + } + } + + Context "Given an unparseable file" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'broken.psd1' + Set-Content -Path $settingsPath -Value 'this is not valid psd1 content {{{' + } + + It "Should write a non-terminating error and produce no output" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue + $result | Should -BeNullOrEmpty + $errs | Should -Not -BeNullOrEmpty + } + + It "Should report the parse failure in the error" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue + $errs[0].Exception.Message | Should -BeLike "Failed to parse settings file:*" + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } +} diff --git a/docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md b/docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md new file mode 100644 index 000000000..03d311376 --- /dev/null +++ b/docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md @@ -0,0 +1,183 @@ +--- +external help file: Microsoft.Windows.PowerShell.ScriptAnalyzer.dll-Help.xml +Module Name: PSScriptAnalyzer +ms.date: 04/17/2026 +schema: 2.0.0 +--- + +# New-ScriptAnalyzerSettingsFile + +## SYNOPSIS +Creates a new PSScriptAnalyzer settings file. + +## SYNTAX + +``` +New-ScriptAnalyzerSettingsFile [[-Path] ] [-BaseOnPreset ] [-Force] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION + +The `New-ScriptAnalyzerSettingsFile` cmdlet creates a `PSScriptAnalyzerSettings.psd1` file in the +specified directory. + +When the **BaseOnPreset** parameter is provided, the generated file contains the rules and +configuration defined by the given preset. + +When **BaseOnPreset** is not provided, the generated file includes all current rules in the +`IncludeRules` list and populates the `Rules` section with all configurable properties, set to their +default values. + +If a settings file already exists at the target path, the cmdlet emits a terminating error unless +the **Force** parameter is specified - in which case it is overwritten. + +## EXAMPLES + +### EXAMPLE 1 - Create a default settings file in the current directory + +```powershell +New-ScriptAnalyzerSettingsFile +``` + +Creates `PSScriptAnalyzerSettings.psd1` in the current working directory incluindg all rules and +all configurable options set to their defaults. + +### EXAMPLE 2 - Create a settings file based on a preset + +```powershell +New-ScriptAnalyzerSettingsFile -BaseOnPreset CodeFormatting +``` + +Creates a settings file pre-populated with the rules and configuration from the `CodeFormatting` +preset. + +### EXAMPLE 3 - Create a settings file in a specific directory + +```powershell +New-ScriptAnalyzerSettingsFile -Path ./src/MyModule +``` + +Creates the settings file in the `./src/MyModule` directory. + +### EXAMPLE 4 - Preview the operation without creating the file + +```powershell +New-ScriptAnalyzerSettingsFile -WhatIf +``` + +Shows what the cmdlet would do without actually writing the file. + +## PARAMETERS + +### -Path + +The directory where the settings file will be created. Defaults to the current working directory when not specified. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: Current directory +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -BaseOnPreset + +The name of a built-in preset to use as the basis for the generated settings file. Valid values are +discovered at runtime from the shipped preset files and can be tab-completed in the shell. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf + +Shows what would happen if the cmdlet runs. The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Force + +Overwrite an existing settings file at the target path. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, +-WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +## OUTPUTS + +### System.IO.FileInfo + +The cmdlet returns a **FileInfo** object representing the created settings file. + +## NOTES + +The output file is always named `PSScriptAnalyzerSettings.psd1` so that the automatic settings +discovery in `Invoke-ScriptAnalyzer` picks it up when analysing scripts in the same directory. + +## RELATED LINKS + +[Invoke-ScriptAnalyzer](Invoke-ScriptAnalyzer.md) + +[Get-ScriptAnalyzerRule](Get-ScriptAnalyzerRule.md) + +[Invoke-Formatter](Invoke-Formatter.md) + +[Test-ScriptAnalyzerSettingsFile](Test-ScriptAnalyzerSettingsFile.md) diff --git a/docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md b/docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md new file mode 100644 index 000000000..982606d3c --- /dev/null +++ b/docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md @@ -0,0 +1,168 @@ +--- +external help file: Microsoft.Windows.PowerShell.ScriptAnalyzer.dll-Help.xml +Module Name: PSScriptAnalyzer +ms.date: 04/17/2026 +schema: 2.0.0 +--- + +# Test-ScriptAnalyzerSettingsFile + +## SYNOPSIS +Validates a PSScriptAnalyzer settings file. + +## SYNTAX + +``` +Test-ScriptAnalyzerSettingsFile [-Path] [-Quiet] [-CustomRulePath ] + [-RecurseCustomRulePath] [] +``` + +## DESCRIPTION + +The `Test-ScriptAnalyzerSettingsFile` cmdlet checks whether a PSScriptAnalyzer settings file is +valid. It verifies that: + +- The file can be parsed as a PowerShell data file. +- All rule names referenced in `IncludeRules`, `ExcludeRules`, and `Rules` correspond to known + rules (wildcard patterns are skipped). +- All `Severity` values are valid. +- Rule option names in the `Rules` section correspond to actual configurable properties. +- Rule option values that are constrained to a set of choices contain a valid value. + +By default the cmdlet returns `$true` when the file is valid and writes non-terminating errors +describing each problem found when the file is invalid (no output is returned on failure, only +errors). This allows `$ErrorActionPreference = 'Stop'` to turn validation failures into +terminating errors. + +When `-Quiet` is specified the cmdlet returns only `$true` or `$false` and suppresses all +error output. + +## EXAMPLES + +### EXAMPLE 1 - Validate a settings file + +```powershell +Test-ScriptAnalyzerSettingsFile -Path ./PSScriptAnalyzerSettings.psd1 +``` + +Returns `$true` if the file is valid. Writes non-terminating errors describing any problems found. + +### EXAMPLE 2 - Validate quietly in a conditional + +```powershell +if (Test-ScriptAnalyzerSettingsFile -Path ./PSScriptAnalyzerSettings.psd1 -Quiet) { + Invoke-ScriptAnalyzer -Path ./src -Settings ./PSScriptAnalyzerSettings.psd1 +} +``` + +Returns `$true` or `$false` without writing any errors. + +### EXAMPLE 3 - Validate with custom rules + +```powershell +Test-ScriptAnalyzerSettingsFile -Path ./Settings.psd1 -CustomRulePath ./MyRules +``` + +Validates the settings file whilst also considering rules from the `./MyRules` path. + +## PARAMETERS + +### -Path + +The path to the settings file to validate. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Quiet + +Suppresses error output and returns only `$true` or `$false`. Without this switch the cmdlet +writes non-terminating errors for each problem found and returns `$true` only when the file is +valid. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -CustomRulePath + +Paths to modules or directories containing custom rules. When specified, custom rule names are +treated as valid during validation. + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: CustomizedRulePath + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -RecurseCustomRulePath + +Search sub-folders under the custom rule path for additional rules. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, +-WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +## OUTPUTS + +### System.Boolean + +Returns `$true` when the settings file is valid. Without `-Quiet`, no output is returned when the +file is invalid - problems are reported as non-terminating errors. With `-Quiet`, always returns +`$true` or `$false`. + +## NOTES + +Without `-Quiet`, validation problems are reported as non-terminating errors. This means they +respect `$ErrorActionPreference` and can be promoted to terminating errors by setting +`-ErrorAction Stop`. + +## RELATED LINKS + +[New-ScriptAnalyzerSettingsFile](New-ScriptAnalyzerSettingsFile.md) + +[Invoke-ScriptAnalyzer](Invoke-ScriptAnalyzer.md) + +[Get-ScriptAnalyzerRule](Get-ScriptAnalyzerRule.md) From ab9baf53ca84a5c272b07b5cbc5e5d5cc5b407b3 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Tue, 21 Apr 2026 15:49:14 +0100 Subject: [PATCH 3/3] Enhance ScriptAnalyzer settings file validation and documentation - Update Helper.cs to return null for empty output paths instead of an empty array. - Add new error message for invalid option types in Strings.resx. - Extend tests for New-ScriptAnalyzerSettingsFile to check for new keys: CustomRulePath, IncludeDefaultRules, and RecurseCustomRulePath. - Modify Test-ScriptAnalyzerSettingsFile tests to validate output and error handling for various scenarios, including type mismatches and invalid values. - Improve documentation for New-ScriptAnalyzerSettingsFile and Test-ScriptAnalyzerSettingsFile to clarify behavior and parameters, including handling of custom rules and output format. --- .../NewScriptAnalyzerSettingsFileCommand.cs | 38 ++ .../TestScriptAnalyzerSettingsFileCommand.cs | 527 ++++++++++++++---- Engine/Helper.cs | 2 +- Engine/Strings.resx | 3 + .../NewScriptAnalyzerSettingsFile.tests.ps1 | 18 + .../TestScriptAnalyzerSettingsFile.tests.ps1 | 375 ++++++++++--- .../Cmdlets/New-ScriptAnalyzerSettingsFile.md | 7 +- .../Test-ScriptAnalyzerSettingsFile.md | 97 ++-- 8 files changed, 818 insertions(+), 249 deletions(-) diff --git a/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs index 32f12fde3..3f2b36844 100644 --- a/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs +++ b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs @@ -188,6 +188,26 @@ private string GenerateFromPreset(string presetName) WriteSeverityArray(sb, parsed.Severities); sb.AppendLine(); + sb.AppendLine(" # Paths to modules or directories containing custom rules."); + sb.AppendLine(" # When specified, these rules are loaded in addition to (or instead"); + sb.AppendLine(" # of) the built-in rules, depending on IncludeDefaultRules."); + sb.AppendLine(" # Note: Relative paths are resolved from the caller's working directory,"); + sb.AppendLine(" # not the location of this settings file."); + WriteStringArray(sb, "CustomRulePath", parsed.CustomRulePath); + sb.AppendLine(); + + sb.AppendLine(" # When set to $true and CustomRulePath is specified, built-in rules"); + sb.AppendLine(" # are loaded alongside custom rules. Has no effect without CustomRulePath."); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + " IncludeDefaultRules = {0}", parsed.IncludeDefaultRules ? "$true" : "$false")); + sb.AppendLine(); + + sb.AppendLine(" # When set to $true, searches sub-folders under CustomRulePath for"); + sb.AppendLine(" # additional rule modules. Has no effect without CustomRulePath."); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + " RecurseCustomRulePath = {0}", parsed.RecurseCustomRulePath ? "$true" : "$false")); + sb.AppendLine(); + sb.AppendLine(" # Per-rule configuration. Only configurable rules appear here."); sb.AppendLine(" # Values from the preset are shown; other properties use defaults."); @@ -256,6 +276,24 @@ private string GenerateFromAllRules() WriteSeverityArray(sb, Enumerable.Empty()); sb.AppendLine(); + sb.AppendLine(" # Paths to modules or directories containing custom rules."); + sb.AppendLine(" # When specified, these rules are loaded in addition to (or instead"); + sb.AppendLine(" # of) the built-in rules, depending on IncludeDefaultRules."); + sb.AppendLine(" # Note: Relative paths are resolved from the caller's working directory,"); + sb.AppendLine(" # not the location of this settings file."); + WriteStringArray(sb, "CustomRulePath", Enumerable.Empty()); + sb.AppendLine(); + + sb.AppendLine(" # When set to $true and CustomRulePath is specified, built-in rules"); + sb.AppendLine(" # are loaded alongside custom rules. Has no effect without CustomRulePath."); + sb.AppendLine(" IncludeDefaultRules = $false"); + sb.AppendLine(); + + sb.AppendLine(" # When set to $true, searches sub-folders under CustomRulePath for"); + sb.AppendLine(" # additional rule modules. Has no effect without CustomRulePath."); + sb.AppendLine(" RecurseCustomRulePath = $false"); + sb.AppendLine(); + sb.AppendLine(" # Per-rule configuration. Only configurable rules appear here."); sb.AppendLine(" Rules = @{"); diff --git a/Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs b/Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs index 0c8371bc1..326dd5c57 100644 --- a/Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs +++ b/Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs @@ -8,23 +8,31 @@ using System.IO; using System.Linq; using System.Management.Automation; +using System.Management.Automation.Language; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands { /// - /// TestScriptAnalyzerSettingsFileCommand: Validates a PSScriptAnalyzer settings file. + /// Validates a PSScriptAnalyzer settings file as a self-contained unit. /// Checks that the file is parseable, that referenced rules exist, and that all /// rule options and their values are valid. /// - /// By default, returns $true when a file is valid, and writes non-terminating - /// errors describing each problem found (no output on failure beyond the errors). - /// When -Quiet is specified, returns $true or $false silently. + /// Custom rule paths, RecurseCustomRulePath and IncludeDefaultRules are read + /// from the settings file itself so that validation reflects what + /// Invoke-ScriptAnalyzer would see when given the same file. + /// + /// In the default mode each problem is emitted as a DiagnosticRecord with the + /// source extent of the offending text. When -Quiet is specified, returns only + /// $true or $false - indicating whether the settings file is valid. /// [Cmdlet(VerbsDiagnostic.Test, "ScriptAnalyzerSettingsFile", HelpUri = "https://github.com/PowerShell/PSScriptAnalyzer")] + [OutputType(typeof(DiagnosticRecord))] [OutputType(typeof(bool))] public class TestScriptAnalyzerSettingsFileCommand : PSCmdlet, IOutputWriter { + private const string RuleName = "Test-ScriptAnalyzerSettingsFile"; + #region Parameters /// @@ -35,42 +43,34 @@ public class TestScriptAnalyzerSettingsFileCommand : PSCmdlet, IOutputWriter public string Path { get; set; } /// - /// When specified, returns only $true or $false without writing - /// errors or warnings. Without this switch the cmdlet writes - /// non-terminating errors for every problem found. + /// When specified, returns only $true or $false without emitting + /// diagnostic records. Without this switch the cmdlet writes a + /// DiagnosticRecord for every problem found and produces no output + /// when the file is valid. /// [Parameter(Mandatory = false)] public SwitchParameter Quiet { get; set; } - /// - /// Paths to custom rule modules. - /// When specified, custom rule names are also treated as valid. - /// - [Parameter(Mandatory = false)] - [ValidateNotNullOrEmpty] - public string[] CustomRulePath { get; set; } + #endregion Parameters - /// - /// Search sub-folders under the custom rule path. - /// - [Parameter(Mandatory = false)] - public SwitchParameter RecurseCustomRulePath { get; set; } + #region Private state - #endregion Parameters + private string _resolvedPath; + private List _diagnostics; + + #endregion Private state #region Overrides /// - /// BeginProcessing: Initialise the analyser engine. + /// Initialise the helper. Full engine initialisation is + /// deferred to ProcessRecord because we need to read CustomRulePath and + /// IncludeDefaultRules from the settings file first. /// protected override void BeginProcessing() { Helper.Instance = new Helper(SessionState.InvokeCommand); Helper.Instance.Initialize(); - - string[] rulePaths = Helper.ProcessCustomRulePaths( - CustomRulePath, SessionState, RecurseCustomRulePath); - ScriptAnalyzer.Instance.Initialize(this, rulePaths, null, null, null, rulePaths == null); } /// @@ -78,52 +78,127 @@ protected override void BeginProcessing() /// protected override void ProcessRecord() { - string resolvedPath = GetUnresolvedProviderPathFromPSPath(Path); + _resolvedPath = GetUnresolvedProviderPathFromPSPath(Path); + _diagnostics = new List(); - if (!File.Exists(resolvedPath)) + if (!File.Exists(_resolvedPath)) { - var error = new ErrorRecord( - new FileNotFoundException(string.Format( - CultureInfo.CurrentCulture, - Strings.SettingsFileNotFound, - resolvedPath)), - "SettingsFileNotFound", - ErrorCategory.ObjectNotFound, - resolvedPath); + if (Quiet) + { + WriteObject(false); + } + else + { + WriteError(new ErrorRecord( + new FileNotFoundException(string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsFileNotFound, + _resolvedPath)), + "SettingsFileNotFound", + ErrorCategory.ObjectNotFound, + _resolvedPath)); + } + return; + } + + // Parse with the PowerShell AST to get source extents. + ScriptBlockAst scriptAst = Parser.ParseFile( + _resolvedPath, + out Token[] tokens, + out ParseError[] parseErrors + ); + + if (parseErrors != null && parseErrors.Length > 0) + { + if (Quiet) + { + WriteObject(false); + } + else + { + foreach (ParseError pe in parseErrors) + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileParseError, pe.Message), + pe.Extent, + DiagnosticSeverity.ParseError); + } + + EmitDiagnostics(); + } + + return; + } + + // Locate the root hashtable. + HashtableAst rootHashtable = scriptAst.Find(ast => ast is HashtableAst, searchNestedScriptBlocks: false) as HashtableAst; + if (rootHashtable == null) + { if (Quiet) { WriteObject(false); } else { - WriteError(error); + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileParseError, "File does not contain a hashtable."), + scriptAst.Extent, + DiagnosticSeverity.Error); + EmitDiagnostics(); } return; } - // Attempt to parse the settings file. + // Also parse via Settings to get the evaluated data. Settings parsed; try { - parsed = new Settings(resolvedPath); + parsed = new Settings(_resolvedPath); } catch (Exception ex) { - ReportProblem(string.Format( - CultureInfo.CurrentCulture, - Strings.SettingsFileParseError, - ex.Message), - "SettingsFileParseError", - ErrorCategory.ParserError, - resolvedPath); + if (Quiet) + { + WriteObject(false); + } + else + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileParseError, ex.Message), + rootHashtable.Extent, + DiagnosticSeverity.Error); + EmitDiagnostics(); + } + return; } - bool isValid = true; + // Initialise the analyser engine using custom rule paths and + // IncludeDefaultRules from the settings file so that validation + // reflects the same rule set Invoke-ScriptAnalyzer would use (given + // this settings file). + string[] rulePaths = Helper.ProcessCustomRulePaths( + parsed.CustomRulePath?.ToArray(), + SessionState, + parsed.RecurseCustomRulePath); + + // Treat an empty array the same as null — no custom paths were specified. + if (rulePaths != null && rulePaths.Length == 0) + { + rulePaths = null; + } + + bool includeDefaultRules = rulePaths == null || parsed.IncludeDefaultRules; + ScriptAnalyzer.Instance.Initialize(this, rulePaths, null, null, null, includeDefaultRules); + + // Build lookup structures. + var topLevelMap = BuildAstKeyMap(rootHashtable); - // Build a set of known rule names from the engine. string[] modNames = ScriptAnalyzer.Instance.GetValidModulePaths(); IEnumerable knownRules = ScriptAnalyzer.Instance.GetRule(modNames, null) ?? Enumerable.Empty(); @@ -135,42 +210,47 @@ protected override void ProcessRecord() } // Validate IncludeRules. - isValid &= ValidateRuleNames(parsed.IncludeRules, ruleMap, "IncludeRules"); + ValidateRuleNameArray(parsed.IncludeRules, ruleMap, "IncludeRules", topLevelMap); // Validate ExcludeRules. - isValid &= ValidateRuleNames(parsed.ExcludeRules, ruleMap, "ExcludeRules"); + ValidateRuleNameArray(parsed.ExcludeRules, ruleMap, "ExcludeRules", topLevelMap); // Validate Severity values. - isValid &= ValidateSeverities(parsed.Severities); + ValidateSeverityArray(parsed.Severities, topLevelMap); // Validate rule arguments. if (parsed.RuleArguments != null) { + HashtableAst rulesHashtable = GetNestedHashtable(topLevelMap, "Rules"); + + var rulesAstMap = rulesHashtable != null + ? BuildAstKeyMap(rulesHashtable) + : new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var ruleEntry in parsed.RuleArguments) { string ruleName = ruleEntry.Key; + IScriptExtent ruleKeyExtent = GetKeyExtent(rulesAstMap, ruleName) + ?? rulesHashtable?.Extent + ?? rootHashtable.Extent; if (!ruleMap.TryGetValue(ruleName, out IRule rule)) { - ReportProblem( + AddDiagnostic( string.Format(CultureInfo.CurrentCulture, Strings.SettingsFileRuleArgRuleNotFound, ruleName), - "RuleNotFound", - ErrorCategory.ObjectNotFound, - ruleName); - isValid = false; + ruleKeyExtent, + DiagnosticSeverity.Error); continue; } if (!(rule is ConfigurableRule)) { - ReportProblem( + AddDiagnostic( string.Format(CultureInfo.CurrentCulture, Strings.SettingsFileRuleNotConfigurable, ruleName), - "RuleNotConfigurable", - ErrorCategory.InvalidArgument, - ruleName); - isValid = false; + ruleKeyExtent, + DiagnosticSeverity.Error); continue; } @@ -181,24 +261,43 @@ protected override void ProcessRecord() optionMap[opt.Name] = opt; } + // Get the AST for this rule's nested hashtable. + HashtableAst ruleHashtable = GetNestedHashtable(rulesAstMap, ruleName); + var ruleArgAstMap = ruleHashtable != null + ? BuildAstKeyMap(ruleHashtable) + : new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var arg in ruleEntry.Value) { string argName = arg.Key; + IScriptExtent argKeyExtent = GetKeyExtent(ruleArgAstMap, argName) + ?? ruleKeyExtent; if (!optionMap.TryGetValue(argName, out RuleOptionInfo optionInfo)) { - ReportProblem( + AddDiagnostic( string.Format(CultureInfo.CurrentCulture, Strings.SettingsFileUnrecognisedOption, ruleName, argName), - "UnrecognisedRuleOption", - ErrorCategory.InvalidArgument, - argName); - isValid = false; + argKeyExtent, + DiagnosticSeverity.Error); continue; } - // Validate possible values for constrained options. - if (optionInfo.PossibleValues != null + // Validate that the value is compatible with the expected type. + if (arg.Value != null && !IsValueCompatible(arg.Value, optionInfo.OptionType)) + { + IScriptExtent valueExtent = GetValueExtent(ruleArgAstMap, argName) + ?? argKeyExtent; + + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileInvalidOptionType, + ruleName, argName, GetFriendlyTypeName(optionInfo.OptionType)), + valueExtent, + DiagnosticSeverity.Error); + } + // Validate constrained string values against the set of possible values. + else if (optionInfo.PossibleValues != null && optionInfo.PossibleValues.Length > 0 && arg.Value is string strValue) { @@ -207,15 +306,16 @@ protected override void ProcessRecord() if (!valueValid) { - ReportProblem( + IScriptExtent valueExtent = GetValueExtent(ruleArgAstMap, argName) + ?? argKeyExtent; + + AddDiagnostic( string.Format(CultureInfo.CurrentCulture, Strings.SettingsFileInvalidOptionValue, ruleName, argName, strValue, string.Join(", ", optionInfo.PossibleValues.Select(v => v.ToString()))), - "InvalidRuleOptionValue", - ErrorCategory.InvalidArgument, - strValue); - isValid = false; + valueExtent, + DiagnosticSeverity.Error); } } } @@ -224,53 +324,175 @@ protected override void ProcessRecord() if (Quiet) { - WriteObject(isValid); + WriteObject(_diagnostics.Count == 0); } - else if (isValid) + else { - WriteObject(true); + EmitDiagnostics(); } } #endregion Overrides - #region Helpers + #region Diagnostics /// - /// Reports a validation problem. In quiet mode the problem is silently - /// recorded; otherwise a non-terminating error is written. + /// Records a DiagnosticRecord for later emission. /// - private void ReportProblem(string message, string errorId, ErrorCategory category, object target) + private void AddDiagnostic(string message, IScriptExtent extent, DiagnosticSeverity severity) { - if (!Quiet) + _diagnostics.Add(new DiagnosticRecord( + message, + extent, + RuleName, + severity, + _resolvedPath)); + } + + /// + /// Writes all collected DiagnosticRecord objects to the output pipeline. + /// + private void EmitDiagnostics() + { + foreach (var diag in _diagnostics) + { + WriteObject(diag); + } + } + + #endregion Diagnostics + + #region AST helpers + + /// + /// Builds a case-insensitive dictionary mapping key names to their + /// (key-expression, value-statement) tuples in a HashtableAst. + /// + private static Dictionary> BuildAstKeyMap(HashtableAst hashtableAst) + { + var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (hashtableAst?.KeyValuePairs == null) { - WriteError(new ErrorRecord( - new InvalidOperationException(message), - errorId, - category, - target)); + return map; } + + foreach (var pair in hashtableAst.KeyValuePairs) + { + if (pair.Item1 is StringConstantExpressionAst keyAst) + { + map[keyAst.Value] = pair; + } + } + + return map; } /// - /// Validates that rule names from a settings field exist in the known rule set. - /// Entries containing wildcard characters are skipped as they are pattern-matched - /// at runtime. + /// Returns the IScriptExtent of a key expression in an AST key map, + /// or null if the key is not found. /// - private bool ValidateRuleNames( + private static IScriptExtent GetKeyExtent( + Dictionary> astMap, + string keyName) + { + if (astMap.TryGetValue(keyName, out var pair)) + { + return pair.Item1.Extent; + } + + return null; + } + + /// + /// Returns the IScriptExtent of a value expression in an AST key map, + /// or null if the key is not found. + /// + private static IScriptExtent GetValueExtent( + Dictionary> astMap, + string keyName) + { + if (astMap.TryGetValue(keyName, out var pair)) + { + ExpressionAst valueExpr = (pair.Item2 as PipelineAst)?.GetPureExpression(); + if (valueExpr != null) + { + return valueExpr.Extent; + } + + return pair.Item2.Extent; + } + + return null; + } + + /// + /// Returns the HashtableAst for a nested hashtable value, or null. + /// + private static HashtableAst GetNestedHashtable( + Dictionary> astMap, + string keyName) + { + if (astMap.TryGetValue(keyName, out var pair)) + { + ExpressionAst valueExpr = (pair.Item2 as PipelineAst)?.GetPureExpression(); + return valueExpr as HashtableAst; + } + + return null; + } + + /// + /// Returns the IScriptExtent of a specific string element within an + /// array value in the AST, matching by string value. Falls back to + /// the array extent or key extent if not found. + /// + private static IScriptExtent FindArrayElementExtent( + Dictionary> astMap, + string keyName, + string elementValue) + { + if (!astMap.TryGetValue(keyName, out var pair)) + { + return null; + } + + ExpressionAst valueExpr = (pair.Item2 as PipelineAst)?.GetPureExpression(); + if (valueExpr == null) + { + return pair.Item2.Extent; + } + + // Look for the string element in array expressions. + IEnumerable stringNodes = valueExpr.FindAll( + ast => ast is StringConstantExpressionAst strAst + && string.Equals(strAst.Value, elementValue, StringComparison.OrdinalIgnoreCase), + searchNestedScriptBlocks: false); + + Ast match = stringNodes.FirstOrDefault(); + return match?.Extent ?? valueExpr.Extent; + } + + #endregion AST helpers + + #region Validation helpers + + /// + /// Validates that rule names in an array field exist in the known rule set. + /// Wildcard entries are skipped. + /// + private void ValidateRuleNameArray( IEnumerable ruleNames, Dictionary ruleMap, - string fieldName) + string fieldName, + Dictionary> topLevelMap) { - bool valid = true; if (ruleNames == null) { - return valid; + return; } foreach (string name in ruleNames) { - // Skip wildcard patterns such as PSDSC* - these are resolved at runtime. if (WildcardPattern.ContainsWildcardCharacters(name)) { continue; @@ -278,49 +500,134 @@ private bool ValidateRuleNames( if (!ruleMap.ContainsKey(name)) { - ReportProblem( + IScriptExtent extent = FindArrayElementExtent(topLevelMap, fieldName, name) + ?? GetKeyExtent(topLevelMap, fieldName); + + AddDiagnostic( string.Format(CultureInfo.CurrentCulture, Strings.SettingsFileRuleNotFound, fieldName, name), - "RuleNotFound", - ErrorCategory.ObjectNotFound, - name); - valid = false; + extent, + DiagnosticSeverity.Error); } } - - return valid; } /// /// Validates severity values against the RuleSeverity enum. /// - private bool ValidateSeverities(IEnumerable severities) + private void ValidateSeverityArray( + IEnumerable severities, + Dictionary> topLevelMap) { - bool valid = true; if (severities == null) { - return valid; + return; } foreach (string sev in severities) { if (!Enum.TryParse(sev, ignoreCase: true, out _)) { - ReportProblem( + IScriptExtent extent = FindArrayElementExtent(topLevelMap, "Severity", sev) + ?? GetKeyExtent(topLevelMap, "Severity"); + + AddDiagnostic( string.Format(CultureInfo.CurrentCulture, Strings.SettingsFileInvalidSeverity, sev, string.Join(", ", Enum.GetNames(typeof(RuleSeverity)))), - "InvalidSeverity", - ErrorCategory.InvalidArgument, - sev); - valid = false; + extent, + DiagnosticSeverity.Error); + } + } + } + + /// + /// Checks whether a value from the settings file is compatible with the + /// target CLR property type. + /// + private static bool IsValueCompatible(object value, Type targetType) + { + if (value == null) + { + return !targetType.IsValueType; + } + + Type valueType = value.GetType(); + + // Direct assignment. + if (targetType.IsAssignableFrom(valueType)) + { + return true; + } + + // Bool property — only accept bool. + if (targetType == typeof(bool)) + { + return value is bool; + } + + // Int property — accept int, long within range, or a string that parses as int. + if (targetType == typeof(int)) + { + if (value is int) + { + return true; + } + + if (value is long l) + { + return l >= int.MinValue && l <= int.MaxValue; + } + + return value is string s && int.TryParse(s, out _); + } + + // String property — almost anything is acceptable since ToString works. + if (targetType == typeof(string)) + { + return true; + } + + // Array property — accept arrays or a single element of the right kind. + if (targetType.IsArray) + { + Type elementType = targetType.GetElementType(); + + if (valueType.IsArray) + { + // Check that each element is compatible. + foreach (object item in (Array)value) + { + if (!IsValueCompatible(item, elementType)) + { + return false; + } + } + + return true; } + + // A single value can be wrapped into a one-element array. + return IsValueCompatible(value, elementType); } - return valid; + return false; + } + + /// + /// Returns a user-friendly name for a CLR type for use in error messages. + /// + private static string GetFriendlyTypeName(Type type) + { + if (type == typeof(bool)) return "bool"; + if (type == typeof(int)) return "int"; + if (type == typeof(string)) return "string"; + if (type == typeof(string[])) return "string[]"; + if (type == typeof(int[])) return "int[]"; + return type.Name; } - #endregion Helpers + #endregion Validation helpers } } diff --git a/Engine/Helper.cs b/Engine/Helper.cs index a162bfbcf..f36d17433 100644 --- a/Engine/Helper.cs +++ b/Engine/Helper.cs @@ -1468,7 +1468,7 @@ public static string[] ProcessCustomRulePaths(string[] rulePaths, SessionState s outPaths.Add(path); } - return outPaths.ToArray(); + return outPaths.Count == 0 ? null : outPaths.ToArray(); } diff --git a/Engine/Strings.resx b/Engine/Strings.resx index c10aa5338..86ad0cf2c 100644 --- a/Engine/Strings.resx +++ b/Engine/Strings.resx @@ -357,4 +357,7 @@ Severity: '{0}' is not a valid severity. Expected one of: {1} + + Rules.{0}.{1}: expected a value of type {2}. + \ No newline at end of file diff --git a/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 b/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 index 2acc2024f..f23cdf5f6 100644 --- a/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 +++ b/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 @@ -42,6 +42,21 @@ Describe "New-ScriptAnalyzerSettingsFile" { $data.ContainsKey('Severity') | Should -BeTrue } + It "Should contain the CustomRulePath key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('CustomRulePath') | Should -BeTrue + } + + It "Should contain the IncludeDefaultRules key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('IncludeDefaultRules') | Should -BeTrue + } + + It "Should contain the RecurseCustomRulePath key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('RecurseCustomRulePath') | Should -BeTrue + } + It "Should contain the Rules key" { $data = Import-PowerShellDataFile -Path $settingsPath $data.ContainsKey('Rules') | Should -BeTrue @@ -131,6 +146,9 @@ Describe "New-ScriptAnalyzerSettingsFile" { $data.ContainsKey('IncludeRules') | Should -BeTrue $data.ContainsKey('ExcludeRules') | Should -BeTrue $data.ContainsKey('Severity') | Should -BeTrue + $data.ContainsKey('CustomRulePath') | Should -BeTrue + $data.ContainsKey('IncludeDefaultRules') | Should -BeTrue + $data.ContainsKey('RecurseCustomRulePath') | Should -BeTrue $data.ContainsKey('Rules') | Should -BeTrue } diff --git a/Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 b/Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 index 4d22f7c58..01d2664d1 100644 --- a/Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 +++ b/Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 @@ -10,8 +10,9 @@ Describe "Test-ScriptAnalyzerSettingsFile" { $settingsPath = Join-Path $testDir 'PSScriptAnalyzerSettings.psd1' } - It "Should return true" { - Test-ScriptAnalyzerSettingsFile -Path $settingsPath | Should -BeTrue + It "Should produce no output when the file is valid" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty } It "Should return true with -Quiet" { @@ -27,8 +28,13 @@ Describe "Test-ScriptAnalyzerSettingsFile" { $settingsPath = Join-Path $testDir 'PSScriptAnalyzerSettings.psd1' } - It "Should return true" { - Test-ScriptAnalyzerSettingsFile -Path $settingsPath | Should -BeTrue + It "Should produce no output when the file is valid" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + + It "Should return true with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeTrue } } @@ -50,25 +56,31 @@ Describe "Test-ScriptAnalyzerSettingsFile" { Context "Given a file with an unknown rule name" { BeforeAll { $settingsPath = Join-Path $TestDrive 'unknown-rule.psd1' - $content = @" -@{ - IncludeRules = @( - 'PSBogusRuleThatDoesNotExist' - ) -} -"@ + $content = " + @{ + IncludeRules = @( + 'PSBogusRuleThatDoesNotExist' + ) + } + " Set-Content -Path $settingsPath -Value $content } - It "Should write a non-terminating error and produce no output" { - $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue - $result | Should -BeNullOrEmpty - $errs | Should -Not -BeNullOrEmpty + It "Should output a DiagnosticRecord" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0] | Should -BeOfType ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]) + } + + It "Should report the unknown rule name in the message" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Message | Should -BeLike "*PSBogusRuleThatDoesNotExist*" } - It "Should report the unknown rule name in the error" { - Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue - $errs[0].Exception.Message | Should -BeLike "IncludeRules: rule 'PSBogusRuleThatDoesNotExist' not found.*" + It "Should include an extent pointing to the offending text" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Extent | Should -Not -BeNullOrEmpty + $result[0].Extent.Text | Should -Be "'PSBogusRuleThatDoesNotExist'" } It "Should return false with -Quiet" { @@ -79,29 +91,34 @@ Describe "Test-ScriptAnalyzerSettingsFile" { Context "Given a file with an invalid rule option name" { BeforeAll { $settingsPath = Join-Path $TestDrive 'bad-option.psd1' - $content = @" -@{ - IncludeRules = @('PSUseConsistentIndentation') - Rules = @{ - PSUseConsistentIndentation = @{ - Enable = `$true - CompletelyBogusOption = 42 - } - } -} -"@ + $content = " + @{ + IncludeRules = @('PSUseConsistentIndentation') + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = `$true + CompletelyBogusOption = 42 + } + } + } + " Set-Content -Path $settingsPath -Value $content } - It "Should write a non-terminating error and produce no output" { - $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue - $result | Should -BeNullOrEmpty - $errs | Should -Not -BeNullOrEmpty + It "Should output a DiagnosticRecord" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0] | Should -BeOfType ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]) + } + + It "Should report the unrecognised option in the message" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Message | Should -BeLike "*CompletelyBogusOption*unrecognised option*" } - It "Should report the unrecognised option in the error" { - Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue - $errs[0].Exception.Message | Should -BeLike "Rules.PSUseConsistentIndentation.CompletelyBogusOption: unrecognised option.*" + It "Should include an extent pointing to the option name" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Extent.Text | Should -Be 'CompletelyBogusOption' } It "Should return false with -Quiet" { @@ -112,29 +129,34 @@ Describe "Test-ScriptAnalyzerSettingsFile" { Context "Given a file with an invalid rule option value" { BeforeAll { $settingsPath = Join-Path $TestDrive 'bad-value.psd1' - $content = @" -@{ - IncludeRules = @('PSUseConsistentIndentation') - Rules = @{ - PSUseConsistentIndentation = @{ - Enable = `$true - Kind = 'banana' - } - } -} -"@ + $content = " + @{ + IncludeRules = @('PSUseConsistentIndentation') + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = `$true + Kind = 'banana' + } + } + } + " Set-Content -Path $settingsPath -Value $content } - It "Should write a non-terminating error and produce no output" { - $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue - $result | Should -BeNullOrEmpty - $errs | Should -Not -BeNullOrEmpty + It "Should output a DiagnosticRecord" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0] | Should -BeOfType ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]) } - It "Should report the invalid value in the error" { - Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue - $errs[0].Exception.Message | Should -BeLike "Rules.PSUseConsistentIndentation.Kind: 'banana' is not a valid value.*" + It "Should report the invalid value in the message" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Message | Should -BeLike "*banana*not a valid value*" + } + + It "Should include an extent pointing to the bad value" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Extent.Text | Should -Be "'banana'" } It "Should return false with -Quiet" { @@ -145,23 +167,28 @@ Describe "Test-ScriptAnalyzerSettingsFile" { Context "Given a file with an invalid severity value" { BeforeAll { $settingsPath = Join-Path $TestDrive 'bad-severity.psd1' - $content = @" -@{ - Severity = @('Critical') -} -"@ + $content = " + @{ + Severity = @('Critical') + } + " Set-Content -Path $settingsPath -Value $content } - It "Should write a non-terminating error and produce no output" { - $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue - $result | Should -BeNullOrEmpty - $errs | Should -Not -BeNullOrEmpty + It "Should output a DiagnosticRecord" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0] | Should -BeOfType ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]) + } + + It "Should report the invalid severity in the message" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Message | Should -BeLike "*Critical*not a valid severity*" } - It "Should report the invalid severity in the error" { - Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue - $errs[0].Exception.Message | Should -BeLike "Severity: 'Critical' is not a valid severity.*" + It "Should include an extent pointing to the bad value" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Extent.Text | Should -Be "'Critical'" } It "Should return false with -Quiet" { @@ -172,16 +199,17 @@ Describe "Test-ScriptAnalyzerSettingsFile" { Context "Given a file with wildcard rule names in IncludeRules" { BeforeAll { $settingsPath = Join-Path $TestDrive 'wildcard.psd1' - $content = @" -@{ - IncludeRules = @('PSDSC*') -} -"@ + $content = " + @{ + IncludeRules = @('PSDSC*') + } + " Set-Content -Path $settingsPath -Value $content } - It "Should return true - wildcards are valid" { - Test-ScriptAnalyzerSettingsFile -Path $settingsPath | Should -BeTrue + It "Should produce no output - wildcards are valid" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty } } @@ -191,19 +219,206 @@ Describe "Test-ScriptAnalyzerSettingsFile" { Set-Content -Path $settingsPath -Value 'this is not valid psd1 content {{{' } - It "Should write a non-terminating error and produce no output" { - $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue - $result | Should -BeNullOrEmpty - $errs | Should -Not -BeNullOrEmpty + It "Should output DiagnosticRecord objects with parse errors" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0] | Should -BeOfType ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]) + $result[0].Severity | Should -Be 'ParseError' + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "DiagnosticRecord properties" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'diag-props.psd1' + $content = " + @{ + Severity = @('Critical') + } + " + Set-Content -Path $settingsPath -Value $content + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + } + + It "Should set RuleName to Test-ScriptAnalyzerSettingsFile" { + $result[0].RuleName | Should -Be 'Test-ScriptAnalyzerSettingsFile' + } + + It "Should set ScriptPath to the settings file path" { + $result[0].ScriptPath | Should -Be $settingsPath } - It "Should report the parse failure in the error" { - Test-ScriptAnalyzerSettingsFile -Path $settingsPath -ErrorVariable errs -ErrorAction SilentlyContinue - $errs[0].Exception.Message | Should -BeLike "Failed to parse settings file:*" + It "Should set Severity to Error for validation problems" { + $result[0].Severity | Should -Be 'Error' + } + + It "Should include line number information in the extent" { + $result[0].Extent.StartLineNumber | Should -BeGreaterThan 0 + } + } + + Context "Given a file with a wrong type for a bool option" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-bool.psd1' + $content = " + @{ + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = 123 + } + } + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should output a DiagnosticRecord for the type mismatch" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0].Message | Should -BeLike "*Enable*expected a value of type bool*" } It "Should return false with -Quiet" { Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse } } + + Context "Given a file with a string where an int is expected" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-int.psd1' + $content = " + @{ + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = `$true + IndentationSize = 'abc' + } + } + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should output a DiagnosticRecord for the type mismatch" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0].Message | Should -BeLike "*IndentationSize*expected a value of type int*" + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with a string where a string array is expected" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-array.psd1' + $content = " + @{ + Rules = @{ + PSUseSingularNouns = @{ + NounAllowList = 'Data' + } + } + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should accept a single string for a string array property" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + } + + Context "Given a file with valid types for all options" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'valid-types.psd1' + $content = " + @{ + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = `$true + IndentationSize = 4 + } + } + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should produce no output" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + } + + Context "Given a file with IncludeDefaultRules" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'include-defaults.psd1' + $content = " + @{ + IncludeDefaultRules = `$true + IncludeRules = @('PSUseConsistentIndentation') + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should validate built-in rules when IncludeDefaultRules is true" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + + It "Should return true with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeTrue + } + } + + Context "Given a file with CustomRulePath pointing to community rules" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'custom-rules.psd1' + $communityRulesPath = Join-Path $PSScriptRoot 'CommunityAnalyzerRules' + $content = " + @{ + CustomRulePath = @('$communityRulesPath') + IncludeDefaultRules = `$true + IncludeRules = @('PSUseConsistentIndentation', 'Measure-RequiresModules') + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should validate both built-in and custom rule names" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + + It "Should return true with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeTrue + } + } + + Context "Given a file with CustomRulePath but without IncludeDefaultRules" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'custom-no-defaults.psd1' + $communityRulesPath = Join-Path $PSScriptRoot 'CommunityAnalyzerRules' + $content = " + @{ + CustomRulePath = @('$communityRulesPath') + IncludeRules = @('PSUseConsistentIndentation') + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should report built-in rules as unknown when IncludeDefaultRules is not set" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0].Message | Should -BeLike "*PSUseConsistentIndentation*" + } + } } diff --git a/docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md b/docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md index 03d311376..7cd339b8b 100644 --- a/docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md +++ b/docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md @@ -26,7 +26,9 @@ configuration defined by the given preset. When **BaseOnPreset** is not provided, the generated file includes all current rules in the `IncludeRules` list and populates the `Rules` section with all configurable properties, set to their -default values. +default values. Both modes also include `CustomRulePath`, `RecurseCustomRulePath`, and +`IncludeDefaultRules` keys with descriptive comments so the file is immediately ready for +customisation. If a settings file already exists at the target path, the cmdlet emits a terminating error unless the **Force** parameter is specified - in which case it is overwritten. @@ -172,6 +174,9 @@ The cmdlet returns a **FileInfo** object representing the created settings file. The output file is always named `PSScriptAnalyzerSettings.psd1` so that the automatic settings discovery in `Invoke-ScriptAnalyzer` picks it up when analysing scripts in the same directory. +Note: Relative paths in `CustomRulePath` are resolved from the caller's current working directory, +not from the location of the settings file. This matches `Invoke-ScriptAnalyzer` behaviour. + ## RELATED LINKS [Invoke-ScriptAnalyzer](Invoke-ScriptAnalyzer.md) diff --git a/docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md b/docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md index 982606d3c..b313f93dd 100644 --- a/docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md +++ b/docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md @@ -8,19 +8,22 @@ schema: 2.0.0 # Test-ScriptAnalyzerSettingsFile ## SYNOPSIS -Validates a PSScriptAnalyzer settings file. +Validates a PSScriptAnalyzer settings file as a self-contained unit. ## SYNTAX ``` -Test-ScriptAnalyzerSettingsFile [-Path] [-Quiet] [-CustomRulePath ] - [-RecurseCustomRulePath] [] +Test-ScriptAnalyzerSettingsFile [-Path] [-Quiet] [] ``` ## DESCRIPTION -The `Test-ScriptAnalyzerSettingsFile` cmdlet checks whether a PSScriptAnalyzer settings file is -valid. It verifies that: +The `Test-ScriptAnalyzerSettingsFile` cmdlet validates a PSScriptAnalyzer settings file as a +self-contained unit. It reads `CustomRulePath`, `RecurseCustomRulePath`, and `IncludeDefaultRules` +directly from the file so that validation reflects the same rule set `Invoke-ScriptAnalyzer` would +see when given the same file. + +The cmdlet verifies that: - The file can be parsed as a PowerShell data file. - All rule names referenced in `IncludeRules`, `ExcludeRules`, and `Rules` correspond to known @@ -29,13 +32,13 @@ valid. It verifies that: - Rule option names in the `Rules` section correspond to actual configurable properties. - Rule option values that are constrained to a set of choices contain a valid value. -By default the cmdlet returns `$true` when the file is valid and writes non-terminating errors -describing each problem found when the file is invalid (no output is returned on failure, only -errors). This allows `$ErrorActionPreference = 'Stop'` to turn validation failures into -terminating errors. +By default, when problems are found the cmdlet outputs a `DiagnosticRecord` for each one, with the +source extent pointing to the offending text in the file. This is the same object type returned by +`Invoke-ScriptAnalyzer`, so existing formatting and tooling works out of the box. When the file is +valid, no output is produced. When `-Quiet` is specified the cmdlet returns only `$true` or `$false` and suppresses all -error output. +diagnostic output. ## EXAMPLES @@ -45,7 +48,8 @@ error output. Test-ScriptAnalyzerSettingsFile -Path ./PSScriptAnalyzerSettings.psd1 ``` -Returns `$true` if the file is valid. Writes non-terminating errors describing any problems found. +Outputs a `DiagnosticRecord` for each problem found, with line and column information. Produces no +output when the file is valid. ### EXAMPLE 2 - Validate quietly in a conditional @@ -55,15 +59,17 @@ if (Test-ScriptAnalyzerSettingsFile -Path ./PSScriptAnalyzerSettings.psd1 -Quiet } ``` -Returns `$true` or `$false` without writing any errors. +Returns `$true` or `$false` without producing diagnostic output. -### EXAMPLE 3 - Validate with custom rules +### EXAMPLE 3 - Validate a file that uses custom rules ```powershell -Test-ScriptAnalyzerSettingsFile -Path ./Settings.psd1 -CustomRulePath ./MyRules +# Settings.psd1 contains CustomRulePath and IncludeDefaultRules keys. +# The cmdlet reads those from the file directly — no extra parameters needed. +Test-ScriptAnalyzerSettingsFile -Path ./Settings.psd1 ``` -Validates the settings file whilst also considering rules from the `./MyRules` path. +Validates rule names against both built-in and custom rules as specified in the settings file. ## PARAMETERS @@ -85,42 +91,8 @@ Accept wildcard characters: False ### -Quiet -Suppresses error output and returns only `$true` or `$false`. Without this switch the cmdlet -writes non-terminating errors for each problem found and returns `$true` only when the file is -valid. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: False -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -CustomRulePath - -Paths to modules or directories containing custom rules. When specified, custom rule names are -treated as valid during validation. - -```yaml -Type: String[] -Parameter Sets: (All) -Aliases: CustomizedRulePath - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -RecurseCustomRulePath - -Search sub-folders under the custom rule path for additional rules. +Suppresses diagnostic output and returns only `$true` or `$false`. Without this switch the cmdlet +outputs a `DiagnosticRecord` for each problem found and produces no output when the file is valid. ```yaml Type: SwitchParameter @@ -147,17 +119,28 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS +### Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord + +Without `-Quiet`, a `DiagnosticRecord` is output for each problem found. Each record includes the +error message, the source extent (file, line and column), a severity, and the rule name +`Test-ScriptAnalyzerSettingsFile`. No output is produced when the file is valid. + ### System.Boolean -Returns `$true` when the settings file is valid. Without `-Quiet`, no output is returned when the -file is invalid - problems are reported as non-terminating errors. With `-Quiet`, always returns -`$true` or `$false`. +With `-Quiet`, returns `$true` when the file is valid and `$false` otherwise. ## NOTES -Without `-Quiet`, validation problems are reported as non-terminating errors. This means they -respect `$ErrorActionPreference` and can be promoted to terminating errors by setting -`-ErrorAction Stop`. +The cmdlet reads `CustomRulePath`, `RecurseCustomRulePath`, and `IncludeDefaultRules` from the +settings file so it validates rule names against the same set of rules that `Invoke-ScriptAnalyzer` +would load. This means the settings file is validated as a self-contained unit without requiring +extra command-line parameters. + +Note: Relative paths in `CustomRulePath` are resolved from the caller's current working directory, +not from the location of the settings file. This matches `Invoke-ScriptAnalyzer` behaviour. + +The `DiagnosticRecord` objects use the same type as `Invoke-ScriptAnalyzer`, so they benefit from +the same default formatting and can be piped to the same downstream tooling. ## RELATED LINKS