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/Commands/NewScriptAnalyzerSettingsFileCommand.cs b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs new file mode 100644 index 000000000..3f2b36844 --- /dev/null +++ b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs @@ -0,0 +1,537 @@ +// 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(" # 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."); + + 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(" # 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 = @{"); + + 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..326dd5c57 --- /dev/null +++ b/Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs @@ -0,0 +1,633 @@ +// 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.Management.Automation.Language; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands +{ + /// + /// 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. + /// + /// 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 + + /// + /// 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 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; } + + #endregion Parameters + + #region Private state + + private string _resolvedPath; + private List _diagnostics; + + #endregion Private state + + #region Overrides + + /// + /// 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(); + } + + /// + /// ProcessRecord: Parse and validate the settings file. + /// + protected override void ProcessRecord() + { + _resolvedPath = GetUnresolvedProviderPathFromPSPath(Path); + _diagnostics = new List(); + + if (!File.Exists(_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 + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileParseError, "File does not contain a hashtable."), + scriptAst.Extent, + DiagnosticSeverity.Error); + EmitDiagnostics(); + } + + return; + } + + // Also parse via Settings to get the evaluated data. + Settings parsed; + try + { + parsed = new Settings(_resolvedPath); + } + catch (Exception ex) + { + if (Quiet) + { + WriteObject(false); + } + else + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileParseError, ex.Message), + rootHashtable.Extent, + DiagnosticSeverity.Error); + EmitDiagnostics(); + } + + return; + } + + // 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); + + 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. + ValidateRuleNameArray(parsed.IncludeRules, ruleMap, "IncludeRules", topLevelMap); + + // Validate ExcludeRules. + ValidateRuleNameArray(parsed.ExcludeRules, ruleMap, "ExcludeRules", topLevelMap); + + // Validate Severity values. + 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)) + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileRuleArgRuleNotFound, ruleName), + ruleKeyExtent, + DiagnosticSeverity.Error); + continue; + } + + if (!(rule is ConfigurableRule)) + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileRuleNotConfigurable, ruleName), + ruleKeyExtent, + DiagnosticSeverity.Error); + continue; + } + + var optionInfos = RuleOptionInfo.GetRuleOptions(rule); + var optionMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var opt in optionInfos) + { + 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)) + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileUnrecognisedOption, ruleName, argName), + argKeyExtent, + DiagnosticSeverity.Error); + continue; + } + + // 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) + { + bool valueValid = optionInfo.PossibleValues.Any(pv => + string.Equals(pv.ToString(), strValue, StringComparison.OrdinalIgnoreCase)); + + if (!valueValid) + { + IScriptExtent valueExtent = GetValueExtent(ruleArgAstMap, argName) + ?? argKeyExtent; + + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileInvalidOptionValue, + ruleName, argName, strValue, + string.Join(", ", optionInfo.PossibleValues.Select(v => v.ToString()))), + valueExtent, + DiagnosticSeverity.Error); + } + } + } + } + } + + if (Quiet) + { + WriteObject(_diagnostics.Count == 0); + } + else + { + EmitDiagnostics(); + } + } + + #endregion Overrides + + #region Diagnostics + + /// + /// Records a DiagnosticRecord for later emission. + /// + private void AddDiagnostic(string message, IScriptExtent extent, DiagnosticSeverity severity) + { + _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) + { + return map; + } + + foreach (var pair in hashtableAst.KeyValuePairs) + { + if (pair.Item1 is StringConstantExpressionAst keyAst) + { + map[keyAst.Value] = pair; + } + } + + return map; + } + + /// + /// Returns the IScriptExtent of a key expression in an AST key map, + /// or null if the key is not found. + /// + 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, + Dictionary> topLevelMap) + { + if (ruleNames == null) + { + return; + } + + foreach (string name in ruleNames) + { + if (WildcardPattern.ContainsWildcardCharacters(name)) + { + continue; + } + + if (!ruleMap.ContainsKey(name)) + { + IScriptExtent extent = FindArrayElementExtent(topLevelMap, fieldName, name) + ?? GetKeyExtent(topLevelMap, fieldName); + + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileRuleNotFound, fieldName, name), + extent, + DiagnosticSeverity.Error); + } + } + } + + /// + /// Validates severity values against the RuleSeverity enum. + /// + private void ValidateSeverityArray( + IEnumerable severities, + Dictionary> topLevelMap) + { + if (severities == null) + { + return; + } + + foreach (string sev in severities) + { + if (!Enum.TryParse(sev, ignoreCase: true, out _)) + { + IScriptExtent extent = FindArrayElementExtent(topLevelMap, "Severity", sev) + ?? GetKeyExtent(topLevelMap, "Severity"); + + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileInvalidSeverity, + sev, + string.Join(", ", Enum.GetNames(typeof(RuleSeverity)))), + 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 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 Validation helpers + } +} 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/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/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..86ad0cf2c 100644 --- a/Engine/Strings.resx +++ b/Engine/Strings.resx @@ -324,4 +324,40 @@ 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} + + + Rules.{0}.{1}: expected a value of type {2}. + \ No newline at end of file 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 + } +} diff --git a/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 b/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 new file mode 100644 index 000000000..f23cdf5f6 --- /dev/null +++ b/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 @@ -0,0 +1,283 @@ +# 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 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 + } + + 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('CustomRulePath') | Should -BeTrue + $data.ContainsKey('IncludeDefaultRules') | Should -BeTrue + $data.ContainsKey('RecurseCustomRulePath') | 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..01d2664d1 --- /dev/null +++ b/Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 @@ -0,0 +1,424 @@ +# 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 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 + } + } + + 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 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 + } + } + + 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 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 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" { + 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 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 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" { + 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 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 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" { + 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 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 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" { + 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 produce no output - wildcards are valid" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + } + + 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 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 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 new file mode 100644 index 000000000..7cd339b8b --- /dev/null +++ b/docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md @@ -0,0 +1,188 @@ +--- +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. 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. + +## 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. + +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) + +[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..b313f93dd --- /dev/null +++ b/docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md @@ -0,0 +1,151 @@ +--- +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 as a self-contained unit. + +## SYNTAX + +``` +Test-ScriptAnalyzerSettingsFile [-Path] [-Quiet] [] +``` + +## DESCRIPTION + +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 + 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, 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 +diagnostic output. + +## EXAMPLES + +### EXAMPLE 1 - Validate a settings file + +```powershell +Test-ScriptAnalyzerSettingsFile -Path ./PSScriptAnalyzerSettings.psd1 +``` + +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 + +```powershell +if (Test-ScriptAnalyzerSettingsFile -Path ./PSScriptAnalyzerSettings.psd1 -Quiet) { + Invoke-ScriptAnalyzer -Path ./src -Settings ./PSScriptAnalyzerSettings.psd1 +} +``` + +Returns `$true` or `$false` without producing diagnostic output. + +### EXAMPLE 3 - Validate a file that uses custom rules + +```powershell +# 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 rule names against both built-in and custom rules as specified in the settings file. + +## 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 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 +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 + +### 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 + +With `-Quiet`, returns `$true` when the file is valid and `$false` otherwise. + +## NOTES + +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 + +[New-ScriptAnalyzerSettingsFile](New-ScriptAnalyzerSettingsFile.md) + +[Invoke-ScriptAnalyzer](Invoke-ScriptAnalyzer.md) + +[Get-ScriptAnalyzerRule](Get-ScriptAnalyzerRule.md)