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)