From 3a4681d77c3d3dd1ae5cb451aac38260abc5a70e Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Mon, 27 Oct 2025 15:19:23 +0000 Subject: [PATCH 01/12] Move Settings Preset files from Engine\Settings -> Engine\SettingsPresets. They are still copied to the built module in the Settings folder. The settings refactor will more naturally sit in the Engine\Settings directory. Note that we only move the Settings Presets - the Command Data Files will move elsewhere --- Engine/{Settings => SettingsPresets}/CmdletDesign.psd1 | 0 Engine/{Settings => SettingsPresets}/CodeFormatting.psd1 | 0 Engine/{Settings => SettingsPresets}/CodeFormattingAllman.psd1 | 0 Engine/{Settings => SettingsPresets}/CodeFormattingOTBS.psd1 | 0 .../{Settings => SettingsPresets}/CodeFormattingStroustrup.psd1 | 0 Engine/{Settings => SettingsPresets}/DSC.psd1 | 0 Engine/{Settings => SettingsPresets}/PSGallery.psd1 | 0 Engine/{Settings => SettingsPresets}/ScriptFunctions.psd1 | 0 Engine/{Settings => SettingsPresets}/ScriptSecurity.psd1 | 0 Engine/{Settings => SettingsPresets}/ScriptingStyle.psd1 | 0 build.psm1 | 2 +- 11 files changed, 1 insertion(+), 1 deletion(-) rename Engine/{Settings => SettingsPresets}/CmdletDesign.psd1 (100%) rename Engine/{Settings => SettingsPresets}/CodeFormatting.psd1 (100%) rename Engine/{Settings => SettingsPresets}/CodeFormattingAllman.psd1 (100%) rename Engine/{Settings => SettingsPresets}/CodeFormattingOTBS.psd1 (100%) rename Engine/{Settings => SettingsPresets}/CodeFormattingStroustrup.psd1 (100%) rename Engine/{Settings => SettingsPresets}/DSC.psd1 (100%) rename Engine/{Settings => SettingsPresets}/PSGallery.psd1 (100%) rename Engine/{Settings => SettingsPresets}/ScriptFunctions.psd1 (100%) rename Engine/{Settings => SettingsPresets}/ScriptSecurity.psd1 (100%) rename Engine/{Settings => SettingsPresets}/ScriptingStyle.psd1 (100%) diff --git a/Engine/Settings/CmdletDesign.psd1 b/Engine/SettingsPresets/CmdletDesign.psd1 similarity index 100% rename from Engine/Settings/CmdletDesign.psd1 rename to Engine/SettingsPresets/CmdletDesign.psd1 diff --git a/Engine/Settings/CodeFormatting.psd1 b/Engine/SettingsPresets/CodeFormatting.psd1 similarity index 100% rename from Engine/Settings/CodeFormatting.psd1 rename to Engine/SettingsPresets/CodeFormatting.psd1 diff --git a/Engine/Settings/CodeFormattingAllman.psd1 b/Engine/SettingsPresets/CodeFormattingAllman.psd1 similarity index 100% rename from Engine/Settings/CodeFormattingAllman.psd1 rename to Engine/SettingsPresets/CodeFormattingAllman.psd1 diff --git a/Engine/Settings/CodeFormattingOTBS.psd1 b/Engine/SettingsPresets/CodeFormattingOTBS.psd1 similarity index 100% rename from Engine/Settings/CodeFormattingOTBS.psd1 rename to Engine/SettingsPresets/CodeFormattingOTBS.psd1 diff --git a/Engine/Settings/CodeFormattingStroustrup.psd1 b/Engine/SettingsPresets/CodeFormattingStroustrup.psd1 similarity index 100% rename from Engine/Settings/CodeFormattingStroustrup.psd1 rename to Engine/SettingsPresets/CodeFormattingStroustrup.psd1 diff --git a/Engine/Settings/DSC.psd1 b/Engine/SettingsPresets/DSC.psd1 similarity index 100% rename from Engine/Settings/DSC.psd1 rename to Engine/SettingsPresets/DSC.psd1 diff --git a/Engine/Settings/PSGallery.psd1 b/Engine/SettingsPresets/PSGallery.psd1 similarity index 100% rename from Engine/Settings/PSGallery.psd1 rename to Engine/SettingsPresets/PSGallery.psd1 diff --git a/Engine/Settings/ScriptFunctions.psd1 b/Engine/SettingsPresets/ScriptFunctions.psd1 similarity index 100% rename from Engine/Settings/ScriptFunctions.psd1 rename to Engine/SettingsPresets/ScriptFunctions.psd1 diff --git a/Engine/Settings/ScriptSecurity.psd1 b/Engine/SettingsPresets/ScriptSecurity.psd1 similarity index 100% rename from Engine/Settings/ScriptSecurity.psd1 rename to Engine/SettingsPresets/ScriptSecurity.psd1 diff --git a/Engine/Settings/ScriptingStyle.psd1 b/Engine/SettingsPresets/ScriptingStyle.psd1 similarity index 100% rename from Engine/Settings/ScriptingStyle.psd1 rename to Engine/SettingsPresets/ScriptingStyle.psd1 diff --git a/build.psm1 b/build.psm1 index 5daba36ba..d82983dd6 100644 --- a/build.psm1 +++ b/build.psm1 @@ -251,7 +251,7 @@ function Start-ScriptAnalyzerBuild } Publish-File $itemsToCopyBinaries $destinationDirBinaries - $settingsFiles = Get-Childitem "$projectRoot\Engine\Settings" | ForEach-Object -MemberName FullName + $settingsFiles = Get-Childitem "$projectRoot\Engine\SettingsPresets" | ForEach-Object -MemberName FullName Publish-File $settingsFiles (Join-Path -Path $script:destinationDir -ChildPath Settings) $rulesProjectOutputDir = if ($env:TF_BUILD) { From ca1e015abacd97dc4ef3eeae8d0d0d91cafb303f Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Mon, 27 Oct 2025 15:31:59 +0000 Subject: [PATCH 02/12] Move Command Data Files from Engine\Settings -> Engine\CommandDataFiles. Previously they were mixed in with the Settings Preset files. This wasn't an issue as all settings file were always PSD1 and these command data files are JSON. If we are to support other settings file formats, then we need to separate these. They are now copied to a CommandDataFiles directory in the built module. Two rules load these and will need to be fixed up to load them from the new directory: - Rules/AvoidOverwritingBuiltInCmdlets.cs - Rules/UseCompatibleCmdlets.cs --- .../{Settings => CommandDataFiles}/core-6.1.0-linux-arm.json | 0 Engine/{Settings => CommandDataFiles}/core-6.1.0-linux.json | 0 Engine/{Settings => CommandDataFiles}/core-6.1.0-macos.json | 0 Engine/{Settings => CommandDataFiles}/core-6.1.0-windows.json | 0 Engine/{Settings => CommandDataFiles}/desktop-2.0-windows.json | 0 Engine/{Settings => CommandDataFiles}/desktop-3.0-windows.json | 0 Engine/{Settings => CommandDataFiles}/desktop-4.0-windows.json | 0 .../desktop-5.1.14393.206-windows.json | 0 build.psm1 | 3 +++ 9 files changed, 3 insertions(+) rename Engine/{Settings => CommandDataFiles}/core-6.1.0-linux-arm.json (100%) rename Engine/{Settings => CommandDataFiles}/core-6.1.0-linux.json (100%) rename Engine/{Settings => CommandDataFiles}/core-6.1.0-macos.json (100%) rename Engine/{Settings => CommandDataFiles}/core-6.1.0-windows.json (100%) rename Engine/{Settings => CommandDataFiles}/desktop-2.0-windows.json (100%) rename Engine/{Settings => CommandDataFiles}/desktop-3.0-windows.json (100%) rename Engine/{Settings => CommandDataFiles}/desktop-4.0-windows.json (100%) rename Engine/{Settings => CommandDataFiles}/desktop-5.1.14393.206-windows.json (100%) diff --git a/Engine/Settings/core-6.1.0-linux-arm.json b/Engine/CommandDataFiles/core-6.1.0-linux-arm.json similarity index 100% rename from Engine/Settings/core-6.1.0-linux-arm.json rename to Engine/CommandDataFiles/core-6.1.0-linux-arm.json diff --git a/Engine/Settings/core-6.1.0-linux.json b/Engine/CommandDataFiles/core-6.1.0-linux.json similarity index 100% rename from Engine/Settings/core-6.1.0-linux.json rename to Engine/CommandDataFiles/core-6.1.0-linux.json diff --git a/Engine/Settings/core-6.1.0-macos.json b/Engine/CommandDataFiles/core-6.1.0-macos.json similarity index 100% rename from Engine/Settings/core-6.1.0-macos.json rename to Engine/CommandDataFiles/core-6.1.0-macos.json diff --git a/Engine/Settings/core-6.1.0-windows.json b/Engine/CommandDataFiles/core-6.1.0-windows.json similarity index 100% rename from Engine/Settings/core-6.1.0-windows.json rename to Engine/CommandDataFiles/core-6.1.0-windows.json diff --git a/Engine/Settings/desktop-2.0-windows.json b/Engine/CommandDataFiles/desktop-2.0-windows.json similarity index 100% rename from Engine/Settings/desktop-2.0-windows.json rename to Engine/CommandDataFiles/desktop-2.0-windows.json diff --git a/Engine/Settings/desktop-3.0-windows.json b/Engine/CommandDataFiles/desktop-3.0-windows.json similarity index 100% rename from Engine/Settings/desktop-3.0-windows.json rename to Engine/CommandDataFiles/desktop-3.0-windows.json diff --git a/Engine/Settings/desktop-4.0-windows.json b/Engine/CommandDataFiles/desktop-4.0-windows.json similarity index 100% rename from Engine/Settings/desktop-4.0-windows.json rename to Engine/CommandDataFiles/desktop-4.0-windows.json diff --git a/Engine/Settings/desktop-5.1.14393.206-windows.json b/Engine/CommandDataFiles/desktop-5.1.14393.206-windows.json similarity index 100% rename from Engine/Settings/desktop-5.1.14393.206-windows.json rename to Engine/CommandDataFiles/desktop-5.1.14393.206-windows.json diff --git a/build.psm1 b/build.psm1 index d82983dd6..e9abea6df 100644 --- a/build.psm1 +++ b/build.psm1 @@ -254,6 +254,9 @@ function Start-ScriptAnalyzerBuild $settingsFiles = Get-Childitem "$projectRoot\Engine\SettingsPresets" | ForEach-Object -MemberName FullName Publish-File $settingsFiles (Join-Path -Path $script:destinationDir -ChildPath Settings) + $settingsFiles = Get-Childitem "$projectRoot\Engine\CommandDataFiles" | ForEach-Object -MemberName FullName + Publish-File $settingsFiles (Join-Path -Path $script:destinationDir -ChildPath CommandDataFiles) + $rulesProjectOutputDir = if ($env:TF_BUILD) { "$projectRoot\bin\${buildConfiguration}\${framework}" } else { From 2aeef95f0c91a69f4c3a94a6ef44bd281fd1cfe2 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Mon, 27 Oct 2025 17:22:31 +0000 Subject: [PATCH 03/12] Refactor settings handling in ScriptAnalyzer - Added a new `HashtableSettingsConverter` to convert inline PowerShell hashtables into a strongly typed `SettingsData`. - Introduced `ISettingsParser` interface for different settings file formats. - Implemented `Psd1SettingsParser` for parsing PowerShell data files (.psd1) into `SettingsData`. - Created a new `Settings` class to centralize the logic for obtaining analyzer settings, supporting auto-discovery, presets, and inline hashtables. - Removed the old `Settings` class and its associated logic - Updated `Helper.cs` and `ScriptAnalyzer.cs` to accommodate changes in settings handling. - Added `SettingsData` class to represent fully parsed and normalized settings. --- Engine/Commands/InvokeFormatterCommand.cs | 2 +- Engine/Formatter.cs | 6 +- Engine/Helper.cs | 5 + Engine/ScriptAnalyzer.cs | 2 +- Engine/Settings.cs | 569 ------------------ Engine/Settings/HashtableSettingsConverter.cs | 189 ++++++ Engine/Settings/ISettingsParser.cs | 35 ++ Engine/Settings/Psd1SettingsParser.cs | 83 +++ Engine/Settings/Settings.cs | 388 ++++++++++++ Engine/Settings/SettingsData.cs | 56 ++ 10 files changed, 761 insertions(+), 574 deletions(-) delete mode 100644 Engine/Settings.cs create mode 100644 Engine/Settings/HashtableSettingsConverter.cs create mode 100644 Engine/Settings/ISettingsParser.cs create mode 100644 Engine/Settings/Psd1SettingsParser.cs create mode 100644 Engine/Settings/Settings.cs create mode 100644 Engine/Settings/SettingsData.cs diff --git a/Engine/Commands/InvokeFormatterCommand.cs b/Engine/Commands/InvokeFormatterCommand.cs index 25a2d364e..bd23a5dcb 100644 --- a/Engine/Commands/InvokeFormatterCommand.cs +++ b/Engine/Commands/InvokeFormatterCommand.cs @@ -17,7 +17,7 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands public class InvokeFormatterCommand : PSCmdlet, IOutputWriter { private const string defaultSettingsPreset = "CodeFormatting"; - private Settings inputSettings; + private SettingsData inputSettings; private Range range; /// diff --git a/Engine/Formatter.cs b/Engine/Formatter.cs index a6a25f0fb..e4d75a1eb 100644 --- a/Engine/Formatter.cs +++ b/Engine/Formatter.cs @@ -23,7 +23,7 @@ public class Formatter /// public static string Format( string scriptDefinition, - Settings settings, + SettingsData settings, Range range, TCmdlet cmdlet) where TCmdlet : PSCmdlet, IOutputWriter { @@ -81,9 +81,9 @@ private static void ValidateNotNull(T obj, string name) } } - private static Settings GetCurrentSettings(Settings settings, string rule) + private static SettingsData GetCurrentSettings(SettingsData settings, string rule) { - return new Settings(new Hashtable() + return HashtableSettingsConverter.Convert(new Hashtable() { {"IncludeRules", new string[] {rule}}, {"Rules", new Hashtable() { { rule, new Hashtable(settings.RuleArguments[rule]) } } } diff --git a/Engine/Helper.cs b/Engine/Helper.cs index 098d8a276..125a3a726 100644 --- a/Engine/Helper.cs +++ b/Engine/Helper.cs @@ -1501,6 +1501,11 @@ public static string[] ProcessCustomRulePaths(string[] rulePaths, SessionState s return null; } + if (rulePaths.Length == 0) + { + return null; + } + Collection pathInfo = new Collection(); foreach (string rulePath in rulePaths) { diff --git a/Engine/ScriptAnalyzer.cs b/Engine/ScriptAnalyzer.cs index f250336b5..5bada52d2 100644 --- a/Engine/ScriptAnalyzer.cs +++ b/Engine/ScriptAnalyzer.cs @@ -207,7 +207,7 @@ public void CleanUp() /// Update includerules, excluderules, severity and rule arguments. /// /// An object of type Settings - public void UpdateSettings(Settings settings) + public void UpdateSettings(SettingsData settings) { if (settings == null) { diff --git a/Engine/Settings.cs b/Engine/Settings.cs deleted file mode 100644 index b0c424c64..000000000 --- a/Engine/Settings.cs +++ /dev/null @@ -1,569 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Management.Automation; -using System.Management.Automation.Language; -using System.Reflection; - -namespace Microsoft.Windows.PowerShell.ScriptAnalyzer -{ - internal enum SettingsMode { None = 0, Auto, File, Hashtable, Preset }; - - /// - /// A class to represent the settings provided to ScriptAnalyzer class. - /// - public class Settings - { - private bool recurseCustomRulePath = false; - private bool includeDefaultRules = false; - private string filePath; - private List includeRules; - private List excludeRules; - private List severities; - private List customRulePath; - private Dictionary> ruleArguments; - - public bool RecurseCustomRulePath => recurseCustomRulePath; - public bool IncludeDefaultRules => includeDefaultRules; - public string FilePath => filePath; - public IEnumerable IncludeRules => includeRules; - public IEnumerable ExcludeRules => excludeRules; - public IEnumerable Severities => severities; - public IEnumerable CustomRulePath => customRulePath; - public Dictionary> RuleArguments => ruleArguments; - - /// - /// Create a settings object from the input object. - /// - /// An input object of type Hashtable or string. - /// A function that takes in a preset and resolves it to a path. - public Settings(object settings, Func presetResolver) - { - if (settings == null) - { - throw new ArgumentNullException(nameof(settings)); - } - - includeRules = new List(); - excludeRules = new List(); - severities = new List(); - ruleArguments = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var settingsFilePath = settings as string; - - //it can either be a preset or path to a file or a hashtable - if (settingsFilePath != null) - { - if (presetResolver != null) - { - var resolvedFilePath = presetResolver(settingsFilePath); - if (resolvedFilePath != null) - { - settingsFilePath = resolvedFilePath; - } - } - - if (File.Exists(settingsFilePath)) - { - filePath = settingsFilePath; - parseSettingsFile(settingsFilePath); - } - else - { - throw new ArgumentException( - String.Format( - CultureInfo.CurrentCulture, - Strings.InvalidPath, - settingsFilePath)); - } - } - else - { - var settingsHashtable = settings as Hashtable; - if (settingsHashtable != null) - { - parseSettingsHashtable(settingsHashtable); - } - else - { - throw new ArgumentException(Strings.SettingsInvalidType); - } - } - } - - /// - /// Create a Settings object from the input object. - /// - /// An input object of type Hashtable or string. - public Settings(object settings) : this(settings, null) - { - } - - /// - /// Retrieves the Settings directory from the Module directory structure - /// - public static string GetShippedSettingsDirectory() - { - // Find the compatibility files in Settings folder - var path = typeof(Helper).GetTypeInfo().Assembly.Location; - if (String.IsNullOrWhiteSpace(path)) - { - return null; - } - - var settingsPath = Path.Combine(Path.GetDirectoryName(path), "Settings"); - if (!Directory.Exists(settingsPath)) - { - // try one level down as the PSScriptAnalyzer module structure is not consistent - // CORECLR binaries are in PSScriptAnalyzer/coreclr/, PowerShell v3 binaries are in PSScriptAnalyzer/PSv3/ - // and PowerShell v5 binaries are in PSScriptAnalyzer/ - settingsPath = Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(path)), "Settings"); - if (!Directory.Exists(settingsPath)) - { - return null; - } - } - - return settingsPath; - } - - /// - /// Returns the builtin setting presets - /// - /// Looks for powershell data files (*.psd1) in the PSScriptAnalyzer module settings directory - /// and returns the names of the files without extension - /// - public static IEnumerable GetSettingPresets() - { - var settingsPath = GetShippedSettingsDirectory(); - if (settingsPath != null) - { - foreach (var filepath in System.IO.Directory.EnumerateFiles(settingsPath, "*.psd1")) - { - yield return System.IO.Path.GetFileNameWithoutExtension(filepath); - } - } - } - - /// - /// Gets the path to the settings file corresponding to the given preset. - /// - /// If the corresponding preset file is not found, the method returns null. - /// - public static string GetSettingPresetFilePath(string settingPreset) - { - var settingsPath = GetShippedSettingsDirectory(); - if (settingsPath != null) - { - if (GetSettingPresets().Contains(settingPreset, StringComparer.OrdinalIgnoreCase)) - { - return System.IO.Path.Combine(settingsPath, settingPreset + ".psd1"); - } - } - - return null; - } - - /// - /// Create a settings object from an input object. - /// - /// An input object of type Hashtable or string. - /// The path in which to search for a settings file. - /// An output writer. - /// The GetResolvedProviderPathFromPSPath method from PSCmdlet to resolve relative path including wildcard support. - /// An object of Settings type. - internal static Settings Create(object settingsObj, string cwd, IOutputWriter outputWriter, - PathResolver.GetResolvedProviderPathFromPSPath> getResolvedProviderPathFromPSPathDelegate) - { - object settingsFound; - var settingsMode = FindSettingsMode(settingsObj, cwd, out settingsFound); - - switch (settingsMode) - { - case SettingsMode.Auto: - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsNotProvided, - "")); - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsAutoDiscovered, - (string)settingsFound)); - break; - - case SettingsMode.Preset: - case SettingsMode.File: - var userProvidedSettingsString = settingsFound.ToString(); - try - { - var resolvedPath = getResolvedProviderPathFromPSPathDelegate(userProvidedSettingsString, out ProviderInfo providerInfo).Single(); - settingsFound = resolvedPath; - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsUsingFile, - resolvedPath)); - } - catch - { - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsCannotFindFile, - userProvidedSettingsString)); - } - break; - - case SettingsMode.Hashtable: - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsUsingHashtable)); - break; - - default: - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsObjectCouldNotBResolved)); - return null; - } - - return new Settings(settingsFound); - } - - /// - /// Recursively convert hashtable to dictionary - /// - /// - /// Dictionary that maps string to object - private Dictionary GetDictionaryFromHashtable(Hashtable hashtable) - { - var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var obj in hashtable.Keys) - { - string key = obj as string; - if (key == null) - { - throw new InvalidDataException( - string.Format( - CultureInfo.CurrentCulture, - Strings.KeyNotString, - key)); - } - - var valueHashtableObj = hashtable[obj]; - if (valueHashtableObj == null) - { - throw new InvalidDataException( - string.Format( - CultureInfo.CurrentCulture, - Strings.WrongValueHashTable, - "", - key)); - } - - var valueHashtable = valueHashtableObj as Hashtable; - if (valueHashtable == null) - { - dictionary.Add(key, valueHashtableObj); - } - else - { - dictionary.Add(key, GetDictionaryFromHashtable(valueHashtable)); - } - } - return dictionary; - } - - private List GetData(object val, string key) - { - // value must be either string or or an array of strings - if (val == null) - { - throw new InvalidDataException( - string.Format( - CultureInfo.CurrentCulture, - Strings.WrongValueHashTable, - "", - key)); - } - - List values = new List(); - var valueStr = val as string; - if (valueStr != null) - { - values.Add(valueStr); - } - else - { - var valueArr = val as object[]; - if (valueArr == null) - { - // check if it is an array of strings - valueArr = val as string[]; - } - - if (valueArr != null) - { - foreach (var item in valueArr) - { - var itemStr = item as string; - if (itemStr != null) - { - values.Add(itemStr); - } - else - { - throw new InvalidDataException( - string.Format( - CultureInfo.CurrentCulture, - Strings.WrongValueHashTable, - val, - key)); - } - } - } - else - { - throw new InvalidDataException( - string.Format( - CultureInfo.CurrentCulture, - Strings.WrongValueHashTable, - val, - key)); - } - } - - return values; - } - - /// - /// Sets the arguments for consumption by rules - /// - /// A hashtable with rule names as keys - private Dictionary> ConvertToRuleArgumentType(object ruleArguments) - { - var ruleArgs = ruleArguments as Dictionary; - if (ruleArgs == null) - { - throw new ArgumentException(Strings.SettingsInputShouldBeDictionary, nameof(ruleArguments)); - } - - if (ruleArgs.Comparer != StringComparer.OrdinalIgnoreCase) - { - throw new ArgumentException(Strings.SettingsDictionaryShouldBeCaseInsesitive, nameof(ruleArguments)); - } - - var ruleArgsDict = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var rule in ruleArgs.Keys) - { - var argsDict = ruleArgs[rule] as Dictionary; - if (argsDict == null) - { - throw new InvalidDataException(Strings.SettingsInputShouldBeDictionary); - } - ruleArgsDict[rule] = argsDict; - } - - return ruleArgsDict; - } - - private void parseSettingsHashtable(Hashtable settingsHashtable) - { - HashSet validKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - var settings = GetDictionaryFromHashtable(settingsHashtable); - foreach (var settingKey in settings.Keys) - { - var key = settingKey.ToLowerInvariant(); // ToLowerInvariant is important to also work with turkish culture, see https://github.com/PowerShell/PSScriptAnalyzer/issues/1095 - object val = settings[key]; - switch (key) - { - case "severity": - severities = GetData(val, key); - break; - - case "includerules": - includeRules = GetData(val, key); - break; - - case "excluderules": - excludeRules = GetData(val, key); - break; - - case "customrulepath": - customRulePath = GetData(val, key); - break; - - case "includedefaultrules": - case "recursecustomrulepath": - if (!(val is bool)) - { - throw new InvalidDataException(string.Format( - CultureInfo.CurrentCulture, - Strings.SettingsValueTypeMustBeBool, - settingKey)); - } - - var booleanVal = (bool)val; - var field = this.GetType().GetField( - key, - BindingFlags.Instance | BindingFlags.IgnoreCase | BindingFlags.NonPublic); - field.SetValue(this, booleanVal); - break; - - case "rules": - try - { - ruleArguments = ConvertToRuleArgumentType(val); - } - catch (ArgumentException argumentException) - { - throw new InvalidDataException( - string.Format(CultureInfo.CurrentCulture, Strings.WrongValueHashTable, "", key), - argumentException); - } - - break; - - default: - throw new InvalidDataException( - string.Format( - CultureInfo.CurrentCulture, - Strings.WrongKeyHashTable, - key)); - } - } - } - - private void parseSettingsFile(string settingsFilePath) - { - Token[] parserTokens = null; - ParseError[] parserErrors = null; - Ast profileAst = Parser.ParseFile(settingsFilePath, out parserTokens, out parserErrors); - IEnumerable hashTableAsts = profileAst.FindAll(item => item is HashtableAst, false); - - // no hashtable, raise warning - if (hashTableAsts.Count() == 0) - { - throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Strings.InvalidProfile, settingsFilePath)); - } - - HashtableAst hashTableAst = hashTableAsts.First() as HashtableAst; - Hashtable hashtable; - try - { - // ideally we should use HashtableAst.SafeGetValue() but since - // it is not available on PSv3, we resort to our own narrow implementation. - hashtable = Helper.GetSafeValueFromHashtableAst(hashTableAst); - } - catch (InvalidOperationException e) - { - throw new ArgumentException(Strings.InvalidProfile, e); - } - - if (hashtable == null) - { - throw new ArgumentException( - String.Format( - CultureInfo.CurrentCulture, - Strings.InvalidProfile, - settingsFilePath)); - } - - parseSettingsHashtable(hashtable); - } - - private static bool IsBuiltinSettingPreset(object settingPreset) - { - var preset = settingPreset as string; - if (preset != null) - { - return GetSettingPresets().Contains(preset, StringComparer.OrdinalIgnoreCase); - } - - return false; - } - - internal static SettingsMode FindSettingsMode(object settings, string path, out object settingsFound) - { - var settingsMode = SettingsMode.None; - - // if the provided settings argument is wrapped in an expressions then PowerShell resolves it but it will be of type PSObject and we have to operate then on the BaseObject - if (settings is PSObject settingsFoundPSObject) - { - settings = settingsFoundPSObject.BaseObject; - } - - settingsFound = settings; - if (settingsFound == null) - { - if (path != null) - { - // add a directory separator character because if there is no trailing separator character, it will return the parent - var directory = path.TrimEnd(System.IO.Path.DirectorySeparatorChar); - if (File.Exists(directory)) - { - // if given path is a file, get its directory - directory = Path.GetDirectoryName(directory); - } - - if (Directory.Exists(directory)) - { - // if settings are not provided explicitly, look for it in the given path - // check if pssasettings.psd1 exists - var settingsFilename = "PSScriptAnalyzerSettings.psd1"; - var settingsFilePath = Path.Combine(directory, settingsFilename); - settingsFound = settingsFilePath; - if (File.Exists(settingsFilePath)) - { - settingsMode = SettingsMode.Auto; - } - } - } - } - else - { - if (!TryResolveSettingForStringType(settingsFound, ref settingsMode, ref settingsFound)) - { - if (settingsFound is Hashtable) - { - settingsMode = SettingsMode.Hashtable; - } - } - } - - return settingsMode; - } - - // If the settings object is a string determine wheter it is one of the settings preset or a file path and resolve the setting in the former case. - private static bool TryResolveSettingForStringType(object settingsObject, ref SettingsMode settingsMode, ref object resolvedSettingValue) - { - if (settingsObject is string settingsString) - { - if (IsBuiltinSettingPreset(settingsString)) - { - settingsMode = SettingsMode.Preset; - resolvedSettingValue = GetSettingPresetFilePath(settingsString); - } - else - { - settingsMode = SettingsMode.File; - resolvedSettingValue = settingsString; - } - return true; - } - - return false; - } - } -} diff --git a/Engine/Settings/HashtableSettingsConverter.cs b/Engine/Settings/HashtableSettingsConverter.cs new file mode 100644 index 000000000..55c95d515 --- /dev/null +++ b/Engine/Settings/HashtableSettingsConverter.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + /// + /// Converts an inline PowerShell hashtable into a strongly typed . + /// Overview of parsing logic: + /// 1. Recursively flattens nested Hashtables into case-insensitive Dictionary + /// while preserving inner dictionaries for rule arguments. + /// 2. Iterates top-level keys, normalising to lowercase to match known setting names. + /// 3. For list-valued keys (Severity, IncludeRules, etc.) coerces single string or enumerable + /// of strings into a List. + /// 4. For boolean flags (IncludeDefaultRules, RecurseCustomRulePath) enforces strict bool + /// types. + /// 5. For Rules, validates a two-level case-insensitive dictionary-of-dictionaries (rule -> + /// argument name/value). + /// 6. Throws on unknown keys or invalid value shapes to fail + /// fast and surface user errors clearly. + /// + internal static class HashtableSettingsConverter + { + + /// + /// Entry point: converts a user-supplied settings hashtable into a + /// instance. + /// + /// Inline settings hashtable. + /// Populated . + /// + /// Thrown when a key is unknown or a value does not meet required type/shape constraints. + /// + public static SettingsData Convert(Hashtable table) + { + var includeRules = new List(); + var excludeRules = new List(); + var severities = new List(); + var customRulePath = new List(); + bool includeDefaultRules = false; + bool recurseCustomRulePath = false; + var ruleArgsOuter = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + var dict = ToDictionary(table); + + foreach (var kvp in dict) + { + var keyLower = kvp.Key.ToLowerInvariant(); + var val = kvp.Value; + switch (keyLower) + { + case "severity": + severities = CoerceStringList(val, kvp.Key); + break; + case "includerules": + includeRules = CoerceStringList(val, kvp.Key); + break; + case "excluderules": + excludeRules = CoerceStringList(val, kvp.Key); + break; + case "customrulepath": + customRulePath = CoerceStringList(val, kvp.Key); + break; + case "includedefaultrules": + includeDefaultRules = CoerceBool(val, kvp.Key); + break; + case "recursecustomrulepath": + recurseCustomRulePath = CoerceBool(val, kvp.Key); + break; + case "rules": + ruleArgsOuter = ConvertRuleArguments(val, kvp.Key); + break; + default: + throw new InvalidDataException($"Unknown settings key '{kvp.Key}'."); + } + } + + return new SettingsData + { + IncludeRules = includeRules, + ExcludeRules = excludeRules, + Severities = severities, + CustomRulePath = customRulePath, + IncludeDefaultRules = includeDefaultRules, + RecurseCustomRulePath = recurseCustomRulePath, + RuleArguments = ruleArgsOuter + }; + } + + /// + /// Recursively converts a Hashtable (and any nested Hashtables) to a case-insensitive + /// Dictionary. + /// Nested Hashtables become nested Dictionary instances. + /// + /// Source hashtable. + /// Case-insensitive dictionary representation. + /// If any key is not a string. + private static Dictionary ToDictionary(Hashtable table) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var keyObj in table.Keys) + { + if (keyObj is not string key) + throw new InvalidDataException("Settings keys must be strings."); + var value = table[keyObj]; + if (value is Hashtable ht) + { + dict[key] = ToDictionary(ht); + } + else + { + dict[key] = value; + } + } + return dict; + } + + /// + /// Coerces a value into a list of strings. Accepts a single string or an enumerable of + /// strings. + /// + /// Value to coerce. + /// Original key name for error context. + /// List of strings. + /// + /// If value is neither string nor enumerable of strings. + /// + private static List CoerceStringList(object val, string key) + { + if (val is string s) return new List { s }; + if (val is IEnumerable enumerable) + { + var list = new List(); + foreach (var item in enumerable) + { + if (item is string si) list.Add(si); + else throw new InvalidDataException($"Non-string element in array for key '{key}'."); + } + return list; + } + throw new InvalidDataException($"Value for key '{key}' must be string or string array."); + } + + /// + /// Validates and returns a boolean settings value. + /// + /// Value to validate. + /// Key name for error messages. + /// Boolean value. + /// If value is not a boolean. + private static bool CoerceBool(object val, string key) + { + if (val is bool b) return b; + throw new InvalidDataException($"Value for key '{key}' must be boolean."); + } + + /// + /// Converts the value of the Rules key into a two-level case-insensitive dictionary + /// structure. + /// Expects outer and each inner dictionary to be case-insensitive + /// Dictionary<string, object>. + /// + /// Rules value object. + /// Original key name ("Rules") for error context. + /// Dictionary of rule name to its argument dictionary. + /// + /// Thrown if the outer/inner dictionaries are missing, not case-insensitive, or wrongly + /// typed. + /// + private static Dictionary> ConvertRuleArguments(object val, string key) + { + if (val is not Dictionary outer || outer.Comparer != StringComparer.OrdinalIgnoreCase) + throw new InvalidDataException($"Rules value must be a case-insensitive dictionary for key '{key}'."); + + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var ruleName in outer.Keys) + { + if (outer[ruleName] is not Dictionary inner || inner.Comparer != StringComparer.OrdinalIgnoreCase) + throw new InvalidDataException($"Rule arguments for '{ruleName}' must be a case-insensitive dictionary."); + result[ruleName] = new Dictionary(inner, StringComparer.OrdinalIgnoreCase); + } + return result; + } + } +} \ No newline at end of file diff --git a/Engine/Settings/ISettingsParser.cs b/Engine/Settings/ISettingsParser.cs new file mode 100644 index 000000000..4422f54aa --- /dev/null +++ b/Engine/Settings/ISettingsParser.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.IO; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + /// + /// Provides an interface for a settings parser. + /// + public interface ISettingsParser + { + /// + /// Gets the name of the format this parser supports. e.g. "psd1", "json". + /// + string FormatName { get; } + + /// + /// Determines whether this parser can parse the given file path or extension. + /// + /// The file path or extension to check. + /// + /// True if the parser can parse the given path or extension; otherwise, false. + /// + bool CanParse(string pathOrExtension); + + /// + /// Parses the content stream into SettingsData. + /// + /// The stream containing the settings content. + /// The source path of the settings file. + /// The parsed SettingsData. + SettingsData Parse(Stream content, string sourcePath); + } +} \ No newline at end of file diff --git a/Engine/Settings/Psd1SettingsParser.cs b/Engine/Settings/Psd1SettingsParser.cs new file mode 100644 index 000000000..5dee7f0fa --- /dev/null +++ b/Engine/Settings/Psd1SettingsParser.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.IO; +using System.Linq; +using System.Management.Automation.Language; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + /// + /// Parses PowerShell data files (.psd1) containing a top-level hashtable into + /// . + /// Parsing steps: + /// 1. Verify the source file exists (the PowerShell parser requires a path). + /// 2. Parse the file into an AST using . + /// 3. Locate the first (expected to represent the settings). + /// 4. Safely convert the hashtable AST into a via + /// . + /// 5. Delegate normalization and validation to + /// . + /// Throws for structural issues (missing hashtable, invalid + /// values). + /// + internal sealed class Psd1SettingsParser : ISettingsParser + { + public string FormatName => "psd1"; + + /// + /// Determines whether the supplied path (or extension) is a .psd1 settings file. + /// + /// Full path or just an extension string. + /// True if the extension is .psd1 (case-insensitive). + public bool CanParse(string pathOrExtension) => + string.Equals(Path.GetExtension(pathOrExtension), ".psd1", StringComparison.OrdinalIgnoreCase); + + /// + /// Parses a .psd1 settings file into . + /// + /// + /// Stream for API symmetry; not directly consumed (PowerShell parser reads from file path). + /// + /// Absolute or relative path to the .psd1 file. + /// Normalized instance. + /// If the file does not exist. + /// + /// If no top-level hashtable is found or conversion yields invalid data. + /// + public SettingsData Parse(Stream content, string sourcePath) + { + // Need file path for PowerShell Parser.ParseFile + if (!File.Exists(sourcePath)) + { + throw new FileNotFoundException("Settings file not found.", sourcePath); + } + + Ast ast = Parser.ParseFile(sourcePath, out Token[] tokens, out ParseError[] errors); + + if (ast.FindAll(a => a is HashtableAst, false).FirstOrDefault() is not HashtableAst hashTableAst) + { + throw new InvalidDataException($"Settings file '{sourcePath}' does not contain a hashtable."); + } + + Hashtable raw; + try + { + raw = Helper.GetSafeValueFromHashtableAst(hashTableAst); + } + catch (InvalidOperationException e) + { + throw new InvalidDataException($"Invalid settings file '{sourcePath}'.", e); + } + if (raw == null) + { + throw new InvalidDataException($"Invalid settings file '{sourcePath}'."); + } + + return HashtableSettingsConverter.Convert(raw); + } + } + +} \ No newline at end of file diff --git a/Engine/Settings/Settings.cs b/Engine/Settings/Settings.cs new file mode 100644 index 000000000..cbf8d2008 --- /dev/null +++ b/Engine/Settings/Settings.cs @@ -0,0 +1,388 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Management.Automation; +using System.Reflection; +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + + /// + /// Central entry point for obtaining analyzer settings. Resolves the -Settings parameter + /// (null, preset name, file path, or inline hashtable) into a SettingsData instance by: + /// 1. Auto-discovering a settings file (PSScriptAnalyzerSettings.*) in the working directory. + /// 2. Mapping preset names to shipped settings files (supporting multiple formats). + /// 3. Loading and parsing settings files via registered format parsers (psd1, json). + /// 4. Converting inline hashtables directly to SettingsData. + /// Also exposes helpers to enumerate shipped presets and locate module resource folders. + /// + public static class Settings + { + + private readonly static string DefaultSettingsFileName = "PSScriptAnalyzerSettings"; + + /// + /// Registered settings parsers in precedence order. + /// The first matching parser "wins" for auto discovery and presets when multiple + /// files of the same base name, but different supported extensions, exist. + /// + private static readonly List s_parsers = new() + { + new Psd1SettingsParser() + }; + + /// + /// Creates a from a user-supplied Settings + /// argument. Primarily used for testing. + /// + public static SettingsData Create(object settingsObj) + { + return Create(settingsObj, null, null); + } + + /// + /// Creates a from a user-supplied -Settings argument. + /// Accepted inputs: + /// null -> attempt auto discovery in + /// string preset name -> resolve shipped preset + /// string file path -> load that file (psd1/json) + /// hashtable -> inline settings + /// Uses the PowerShell provider resolver if supplied to expand relative/wildcard paths. + /// Returns null when no settings can be found (mode None). + /// + /// Hashtable, preset name, file path, or null. + /// Working directory for auto discovery (script or folder path). + /// An output writer. + /// Delegate from PSCmdlet to resolve provider paths (optional). + /// Populated or null if none. + internal static SettingsData Create( + object settingsObj, + string cwd, + IOutputWriter outputWriter, + PathResolver.GetResolvedProviderPathFromPSPath> getResolvedProviderPathFromPSPathDelegate = null + ) + { + // Determine how we're being passed settings + var result = ResolveSettingsSource(settingsObj, cwd, getResolvedProviderPathFromPSPathDelegate); + + return result.Kind switch + { + SettingsSourceKind.None => null, + SettingsSourceKind.InlineHashtable => HashtableSettingsConverter.Convert(result.InlineHashtable), + SettingsSourceKind.AutoFile or SettingsSourceKind.ExplicitFile or SettingsSourceKind.PresetFile => ParseFile(result.FilePath), + _ => null, + }; + + } + + /// + /// Intermediate resolution data describing where settings came from. + /// For Kind InlineHashtable only InlineHashtable is populated; for file-based kinds + /// FilePath is set. + /// + private struct ResolutionResult + { + public SettingsSourceKind Kind; + public string FilePath; + public Hashtable InlineHashtable; + } + + /// + /// Enumerates the distinct ways settings can be supplied or discovered. + /// + private enum SettingsSourceKind + { + /// No settings were provided and auto-discovery found nothing. + None, + /// Settings were discovered automatically from a file. + AutoFile, + /// Settings were explicitly provided via a file path. + ExplicitFile, + /// Settings were loaded from a preset file. + PresetFile, + /// Settings were provided inline as a hashtable. + InlineHashtable + } + + /// + /// Resolves the source kind for the supplied settings object. + /// Unwraps PSObject values to inspect the underlying CLR type. + /// + /// User -Settings argument value. + /// Working directory for auto discovery. + /// ResolutionResult indicating mode and relevant data. + /// Thrown when a string path does not exist. + /// Thrown for unsupported input types. + private static ResolutionResult ResolveSettingsSource( + object settingsObj, + string cwd, + PathResolver.GetResolvedProviderPathFromPSPath> getResolvedProviderPathFromPSPathDelegate = null + ) + { + // If we have no settings object, attempt auto-discovery of settings + // file in the current working directory + // If auto-discovery finds nothing, we return 'None' + if (settingsObj == null) + { + var auto = TryAutoDiscover(cwd); + return new ResolutionResult + { + Kind = auto != null ? SettingsSourceKind.AutoFile : SettingsSourceKind.None, + FilePath = auto + }; + } + + // Unwrap PSObject if necessary. Ensures we see the real underlying + // type (string, Hashtable). Without this, everything passed as an + // expression would remain a PSObject and fail the subsequent type + // checks. + if (settingsObj is PSObject pso) + { + settingsObj = pso.BaseObject; + } + + if (settingsObj is Hashtable ht) + { + return new ResolutionResult + { + Kind = SettingsSourceKind.InlineHashtable, + InlineHashtable = ht + }; + } + + if (settingsObj is string s) + { + // Does the string correspond to a preset name? + var presetPath = TryResolvePreset(s); + if (presetPath != null) + { + return new ResolutionResult + { + Kind = SettingsSourceKind.PresetFile, + FilePath = presetPath + }; + } + + // If it doesn't match a prefix, is it a valid file path? + // Attempt provider path resolution if possible + s = ResolveProviderPathIfPossible(s, getResolvedProviderPathFromPSPathDelegate); + if (File.Exists(s)) + { + return new ResolutionResult + { + Kind = SettingsSourceKind.ExplicitFile, + FilePath = s + }; + } + + throw new FileNotFoundException($"Settings file '{s}' not found."); + } + + throw new ArgumentException("Settings must be a hashtable, a preset name, or a file path."); + } + + /// + /// Attempts to locate a settings file in the supplied path's directory + /// using the registered parser formats in precedence order. + /// + /// File or directory path. + /// Full path to discovered settings file or null. + public static string TryAutoDiscover(string path) + { + // If no path provided, cannot auto-discover + if (string.IsNullOrWhiteSpace(path)) return null; + + // If path is a file, get its directory + string dir = path; + if (File.Exists(dir)) + { + dir = Path.GetDirectoryName(dir); + } + + // If directory doesn't exist, cannot auto-discover + if (!Directory.Exists(dir)) return null; + + // Test for the presence of a settings file for each of the formats + // supported. The parsers format list determines precedence. + foreach (var parser in s_parsers) + { + var filePath = Path.Combine(dir, $"{DefaultSettingsFileName}.{parser.FormatName}"); + if (File.Exists(filePath)) return filePath; + } + + return null; + } + + /// + /// Resolves a shipped preset name to its settings file path. + /// Searches supported formats in precedence order, returning the first match. + /// + /// Preset base name without extension. + /// Full file path or null if not found. + public static string TryResolvePreset(string name) + { + // Get the path to the folder of preset settings files shipped with the module + var settingsDir = GetShippedSettingsDirectory(); + + // If we can't locate it, return null + if (settingsDir == null) return null; + + // Loop through supported formats and check for existence + // return the first match + foreach (var parser in s_parsers) + { + var filePath = Path.Combine(settingsDir, name + "." + parser.FormatName); + if (File.Exists(filePath)) return filePath; + } + + return null; + } + + /// + /// Attempts provider path resolution (wildcards, PSDrive) using PSCmdlet delegate. + /// Falls back to original path if resolution fails. + /// + /// Original path. + /// PSCmdlet resolution delegate. + /// Resolved single provider path or original path. + private static string ResolveProviderPathIfPossible( + string path, + PathResolver.GetResolvedProviderPathFromPSPath> getResolvedProviderPathFromPSPathDelegate) + { + if (getResolvedProviderPathFromPSPathDelegate == null) return path; + try + { + var resolved = getResolvedProviderPathFromPSPathDelegate(path, out ProviderInfo _); + if (resolved != null && resolved.Count == 1 && !string.IsNullOrEmpty(resolved[0])) + { + return resolved[0]; + } + } + catch + { + // Ignore resolution errors; use original path. + } + return path; + } + + /// + /// Opens and parses the specified settings file using an appropriate registered parser. + /// Clones result to stamp correct SourceKind if immutability prevents direct assignment. + /// + /// Existing settings file path. + /// Parsed . + /// If no parser can handle the file. + private static SettingsData ParseFile(string path) + { + var parser = s_parsers.Find(p => p.CanParse(path)) ?? + throw new NotSupportedException($"No parser registered for settings file '{path}'."); + using var fs = File.OpenRead(path); + var data = parser.Parse(fs, path); + return data; + } + + /// + /// Retrieves the Settings directory from the Module directory structure + /// + /// The Settings directory path + public static string GetShippedSettingsDirectory() + { + // Find the compatibility files in Settings folder + var path = typeof(Helper).GetTypeInfo().Assembly.Location; + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + // Find the compatibility files in Settings folder adjacent to the assembly. + // Some builds place binaries in subfolders (coreclr/, PSv3/); in those cases, + // the Settings folder lives in the parent (module root), so we also probe one level up. + var settingsPath = Path.Combine(Path.GetDirectoryName(path), "Settings"); + if (!Directory.Exists(settingsPath)) + { + // Probe parent directory (module root) for Settings folder. + var parentDir = Path.GetDirectoryName(Path.GetDirectoryName(path)); + settingsPath = Path.Combine(parentDir ?? string.Empty, "Settings"); + if (!Directory.Exists(settingsPath)) + { + return null; + } + } + + return settingsPath; + } + + /// + /// Retrieves the Settings directory from the Module directory structure + /// + /// The Settings directory path + public static string GetShippedCommandDataFileDirectory() + { + // Find the compatibility files in Settings folder + var path = typeof(Helper).GetTypeInfo().Assembly.Location; + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + // Find the compatibility files in Settings folder adjacent to the assembly. + // Some builds place binaries in subfolders (coreclr/, PSv3/); in those cases, + // the Settings folder lives in the parent (module root), so we also probe one level up. + var commandDataFilesPath = Path.Combine(Path.GetDirectoryName(path), "CommandDataFiles"); + if (!Directory.Exists(commandDataFilesPath)) + { + // Probe parent directory (module root) for CommandDataFiles folder. + var parentDir = Path.GetDirectoryName(Path.GetDirectoryName(path)); + commandDataFilesPath = Path.Combine(parentDir ?? string.Empty, "CommandDataFiles"); + if (!Directory.Exists(commandDataFilesPath)) + { + return null; + } + } + + return commandDataFilesPath; + } + + /// + /// Returns the builtin setting presets + /// + /// Looks for supported formats in the PSScriptAnalyzer module settings directory + /// and returns the names of the files without extension + /// + public static IEnumerable GetSettingPresets() + { + var settingsPath = GetShippedSettingsDirectory(); + + if (settingsPath == null) + { + yield break; + } + + // Collect unique preset base names across all supported formats. + // e.g. if both Psd1 and Json versions of the same preset exist, + // only yield the name once. + var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var parser in s_parsers) + { + var pattern = "*." + parser.FormatName; + foreach (var filepath in Directory.EnumerateFiles(settingsPath, pattern)) + { + var name = Path.GetFileNameWithoutExtension(filepath); + if (yielded.Add(name)) + { + yield return name; + } + } + } + } + + } + +} \ No newline at end of file diff --git a/Engine/Settings/SettingsData.cs b/Engine/Settings/SettingsData.cs new file mode 100644 index 000000000..53710cd17 --- /dev/null +++ b/Engine/Settings/SettingsData.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + /// + /// Data container representing fully parsed and normalized PSScriptAnalyzer settings. + /// Produced by format parsers (JSON, PSD1) or the hashtable converter; consumed by rule + /// selection and formatter logic. Lists and dictionaries are mutable here for simplicity, but + /// callers should treat the instance as a snapshot and avoid modifying post-creation. + /// + public sealed class SettingsData + { + /// + /// Explicit rule names to include. + /// + public List IncludeRules { get; set; } = new List(); + + /// + /// Rule names to exclude from analysis even if they are part of defaults or includes. + /// + public List ExcludeRules { get; set; } = new List(); + + /// + /// Ordered severity list used for filtering or overriding rule output (e.g. Error, Warning, + /// Information). + /// + public List Severities { get; set; } = new List(); + + /// + /// Paths (files or directories) where custom rule assemblies/modules are located. + /// + public List CustomRulePath { get; set; } = new List(); + + /// + /// Indicates whether built-in default rules should be included when resolving effective + /// rule set. + /// + public bool IncludeDefaultRules { get; set; } + + /// + /// If true, recursively searches each CustomRulePath directory for rules. + /// + public bool RecurseCustomRulePath { get; set; } + + /// + /// Per-rule argument maps: rule name -> (argument name -> value). Case-insensitive outer + /// and inner dictionaries. + /// + public Dictionary> RuleArguments { get; set; } = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + } +} \ No newline at end of file From f572b2a5721a42fd387f61ceb4dc0d070224677b Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Mon, 27 Oct 2025 17:24:03 +0000 Subject: [PATCH 04/12] Add JSON settings parser --- Engine/Engine.csproj | 3 + Engine/Settings/JsonSettingsParser.cs | 107 ++++++++++++++++++++++++++ Engine/Settings/Settings.cs | 1 + 3 files changed, 111 insertions(+) create mode 100644 Engine/Settings/JsonSettingsParser.cs diff --git a/Engine/Engine.csproj b/Engine/Engine.csproj index 63b9a1b9c..b448710a2 100644 --- a/Engine/Engine.csproj +++ b/Engine/Engine.csproj @@ -71,12 +71,15 @@ + + $(DefineConstants);PSV7;CORECLR + diff --git a/Engine/Settings/JsonSettingsParser.cs b/Engine/Settings/JsonSettingsParser.cs new file mode 100644 index 000000000..96c51e77a --- /dev/null +++ b/Engine/Settings/JsonSettingsParser.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + + /// + /// Parses JSON settings files (extension .json) into . + /// Expected top-level properties: + /// Severity : string or string array + /// IncludeRules : string or string array + /// ExcludeRules : string or string array + /// CustomRulePath : string or string array + /// IncludeDefaultRules : bool + /// RecurseCustomRulePath : bool + /// Rules : object with ruleName -> { argumentName : value } mapping + /// Parsing logic: + /// 1. Read entire stream into a string. + /// 2. Deserialize to DTO with Newtonsoft.Json (case-insensitive by default). + /// 3. Validate null result -> invalid data. + /// 4. Normalize each collection to empty lists when absent. + /// 5. Rebuild rule arguments as case-insensitive dictionaries. + /// Throws on malformed JSON or missing structure. + /// + internal sealed class JsonSettingsParser : ISettingsParser + { + + /// + /// DTO for deserializing JSON settings. + /// + private sealed class JsonSettingsDto + { + public List Severity { get; set; } + public List IncludeRules { get; set; } + public List ExcludeRules { get; set; } + public List CustomRulePath { get; set; } + public bool? IncludeDefaultRules { get; set; } + public bool? RecurseCustomRulePath { get; set; } + public Dictionary> Rules { get; set; } + } + + public string FormatName => "json"; + + /// + /// Determines if this parser can handle the supplied path by checking for .json extension. + /// + /// File path or extension string. + /// True if extension is .json. + public bool CanParse(string pathOrExtension) => + string.Equals(Path.GetExtension(pathOrExtension), ".json", StringComparison.OrdinalIgnoreCase); + + /// + /// Parses a JSON settings file stream into . + /// + /// Readable stream positioned at start of JSON content. + /// Original file path (for error context). + /// Populated . + /// + /// Thrown on JSON deserialization error or invalid/empty root object. + /// + public SettingsData Parse(Stream content, string sourcePath) + { + using var reader = new StreamReader(content); + string json = reader.ReadToEnd(); + JsonSettingsDto dto; + try + { + dto = JsonConvert.DeserializeObject(json); + } + catch (JsonException je) + { + throw new InvalidDataException($"Failed to parse settings JSON '{sourcePath}': {je.Message}", je); + } + if (dto == null) + throw new InvalidDataException($"Settings JSON '{sourcePath}' is empty or invalid."); + + // Normalize rule arguments into case-insensitive dictionaries + var ruleArgs = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (dto.Rules != null) + { + foreach (var kv in dto.Rules) + { + ruleArgs[kv.Key] = kv.Value != null + ? new Dictionary(kv.Value, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } + + return new SettingsData + { + IncludeRules = dto.IncludeRules ?? new List(), + ExcludeRules = dto.ExcludeRules ?? new List(), + Severities = dto.Severity ?? new List(), + CustomRulePath = dto.CustomRulePath ?? new List(), + IncludeDefaultRules = dto.IncludeDefaultRules.GetValueOrDefault(), + RecurseCustomRulePath = dto.RecurseCustomRulePath.GetValueOrDefault(), + RuleArguments = ruleArgs + }; + } + } + +} \ No newline at end of file diff --git a/Engine/Settings/Settings.cs b/Engine/Settings/Settings.cs index cbf8d2008..404468f56 100644 --- a/Engine/Settings/Settings.cs +++ b/Engine/Settings/Settings.cs @@ -34,6 +34,7 @@ public static class Settings /// private static readonly List s_parsers = new() { + new JsonSettingsParser(), new Psd1SettingsParser() }; From 2c8e261950d3f836348871670a69372215f066a6 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Mon, 27 Oct 2025 17:24:53 +0000 Subject: [PATCH 05/12] Refactor to use command data file directory instead of settings directory in AvoidOverwritingBuiltInCmdlets and UseCompatibleCmdlets rules --- Rules/AvoidOverwritingBuiltInCmdlets.cs | 6 +++--- Rules/UseCompatibleCmdlets.cs | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Rules/AvoidOverwritingBuiltInCmdlets.cs b/Rules/AvoidOverwritingBuiltInCmdlets.cs index a390178d5..308ba8537 100644 --- a/Rules/AvoidOverwritingBuiltInCmdlets.cs +++ b/Rules/AvoidOverwritingBuiltInCmdlets.cs @@ -90,17 +90,17 @@ public override IEnumerable AnalyzeScript(Ast ast, string file } var psVerList = PowerShellVersion; - string settingsPath = Settings.GetShippedSettingsDirectory(); + string commandDataFilesPath = Settings.GetShippedCommandDataFileDirectory(); foreach (string reference in psVerList) { - if (settingsPath == null || !ContainsReferenceFile(settingsPath, reference)) + if (commandDataFilesPath == null || !ContainsReferenceFile(commandDataFilesPath, reference)) { throw new ArgumentException(nameof(PowerShellVersion)); } } - ProcessDirectory(settingsPath, psVerList); + ProcessDirectory(commandDataFilesPath, psVerList); if (_cmdletMap.Keys.Count != psVerList.Count()) { diff --git a/Rules/UseCompatibleCmdlets.cs b/Rules/UseCompatibleCmdlets.cs index c74665e95..8c8357286 100644 --- a/Rules/UseCompatibleCmdlets.cs +++ b/Rules/UseCompatibleCmdlets.cs @@ -306,7 +306,7 @@ private void SetupCmdletsDictionary() return; } - string settingsPath = Settings.GetShippedSettingsDirectory(); + string commandDataFilesPath = Settings.GetShippedCommandDataFileDirectory(); #if DEBUG object modeObject; if (ruleArgs.TryGetValue("mode", out modeObject)) @@ -317,7 +317,7 @@ private void SetupCmdletsDictionary() switch (mode) { case "offline": - settingsPath = GetStringArgFromListStringArg(ruleArgs["uri"]); + commandDataFilesPath = GetStringArgFromListStringArg(ruleArgs["uri"]); break; case "online": // not implemented yet. @@ -328,8 +328,8 @@ private void SetupCmdletsDictionary() } #endif - if (settingsPath == null - || !ContainsReferenceFile(settingsPath)) + if (commandDataFilesPath == null + || !ContainsReferenceFile(commandDataFilesPath)) { return; } @@ -348,7 +348,7 @@ private void SetupCmdletsDictionary() } ProcessDirectory( - settingsPath, + commandDataFilesPath, extentedCompatibilityList); if (psCmdletMap.Keys.Count != extentedCompatibilityList.Count()) { From 0c11616b6cd1526c6d583bcaae0b6494d7ac3152 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Mon, 27 Oct 2025 17:26:43 +0000 Subject: [PATCH 06/12] Refactor Settings tests to use static Create method instead of New-Object for instantiation --- Tests/Engine/Settings.tests.ps1 | 92 +++++++++++++++++---------------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/Tests/Engine/Settings.tests.ps1 b/Tests/Engine/Settings.tests.ps1 index 917b4ed8e..89a69c7cb 100644 --- a/Tests/Engine/Settings.tests.ps1 +++ b/Tests/Engine/Settings.tests.ps1 @@ -5,7 +5,6 @@ BeforeAll { $settingsTestDirectory = [System.IO.Path]::Combine($PSScriptRoot, "SettingsTest") $project1Root = [System.IO.Path]::Combine($settingsTestDirectory, "Project1") $project2Root = [System.IO.Path]::Combine($settingsTestDirectory, "Project2") - $settingsTypeName = 'Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings' } Describe "Settings Precedence" { @@ -53,7 +52,7 @@ Describe "Settings Class" { ) { Param($Name) - $settings = New-Object -TypeName $settingsTypeName -ArgumentList @{} + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create(@{}) ${settings}.${Name}.Count | Should -Be 0 } @@ -67,7 +66,7 @@ Describe "Settings Class" { Context "When a string is provided for IncludeRules in a hashtable" { BeforeAll { $ruleName = "PSAvoidCmdletAliases" - $settings = New-Object -TypeName $settingsTypeName -ArgumentList @{ IncludeRules = $ruleName } + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create(@{ IncludeRules = $ruleName }) } It "Should return an IncludeRules array with 1 element" { @@ -88,7 +87,7 @@ Describe "Settings Class" { } } } - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) } It 'Should return the rule arguments' { @@ -113,7 +112,7 @@ Describe "Settings Class" { } } } - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) } It "Should return the rule arguments" { @@ -131,8 +130,9 @@ Describe "Settings Class" { Context "When a settings file path is provided" { BeforeAll { - $settings = New-Object -TypeName $settingsTypeName ` - -ArgumentList ([System.IO.Path]::Combine($project1Root, "ExplicitSettings.psd1")) + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create( + ([System.IO.Path]::Combine($project1Root, "ExplicitSettings.psd1")) + ) $expectedNumberOfIncludeRules = 3 } @@ -168,7 +168,7 @@ Describe "Settings Class" { CustomRulePath = $rulePath } - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) $settings.CustomRulePath.Count | Should -Be 1 $settings.CustomRulePath[0] | Should -Be $rulePath } @@ -179,15 +179,16 @@ Describe "Settings Class" { CustomRulePath = $rulePaths } - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) $settings.CustomRulePath.Count | Should -Be $rulePaths.Count 0..($rulePaths.Count - 1) | ForEach-Object { $settings.CustomRulePath[$_] | Should -Be $rulePaths[$_] } } It "Should detect the parameter in a settings file" { - $settings = New-Object -TypeName $settingsTypeName ` - -ArgumentList ([System.IO.Path]::Combine($project1Root, "CustomRulePathSettings.psd1")) + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create( + ([System.IO.Path]::Combine($project1Root, "CustomRulePathSettings.psd1")) + ) $settings.CustomRulePath.Count | Should -Be 2 } } @@ -197,7 +198,7 @@ Describe "Settings Class" { $settingsHashtable = @{} $settingsHashtable.Add($ParamName, $true) - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) $settings."$ParamName" | Should -BeTrue } @@ -205,7 +206,7 @@ Describe "Settings Class" { $settingsHashtable = @{} $settingsHashtable.Add($ParamName, $false) - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) $settings."$ParamName" | Should -BeFalse } @@ -213,12 +214,13 @@ Describe "Settings Class" { $settingsHashtable = @{} $settingsHashtable.Add($ParamName, "some random string") - { New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable } | Should -Throw + { [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) } | Should -Throw } It ": Should detect the parameter in a settings file" -TestCases $customRuleParameterTestCases { - $settings = New-Object -TypeName $settingsTypeName ` - -ArgumentList ([System.IO.Path]::Combine($project1Root, "CustomRulePathSettings.psd1")) + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create( + ([System.IO.Path]::Combine($project1Root, "CustomRulePathSettings.psd1")) + ) $settings."$ParamName" | Should -BeTrue } } @@ -378,33 +380,33 @@ Describe "Settings Class" { ) } - Context "FindSettingsMode" { - BeforeAll { - $findSettingsMode = ($settingsTypeName -as [type]).GetMethod( - 'FindSettingsMode', - [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Static) - - $outputObject = [System.Object]::new() - } - - It "Should detect hashtable" { - $settings = @{} - $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "Hashtable" - } - - It "Should detect hashtable wrapped by a PSObject" { - $settings = [PSObject]@{} # Force the settings hashtable to be wrapped - $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "Hashtable" - } - - It "Should detect string" { - $settings = "" - $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "File" - } - - It "Should detect string wrapped by a PSObject" { - $settings = [PSObject]"" # Force the settings string to be wrapped - $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "File" - } - } + # Context "FindSettingsMode" { + # BeforeAll { + # $findSettingsMode = ('Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings' -as [type]).GetMethod( + # 'FindSettingsMode', + # [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Static) + + # $outputObject = [System.Object]::new() + # } + + # It "Should detect hashtable" { + # $settings = @{} + # $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "Hashtable" + # } + + # It "Should detect hashtable wrapped by a PSObject" { + # $settings = [PSObject]@{} # Force the settings hashtable to be wrapped + # $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "Hashtable" + # } + + # It "Should detect string" { + # $settings = "" + # $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "File" + # } + + # It "Should detect string wrapped by a PSObject" { + # $settings = [PSObject]"" # Force the settings string to be wrapped + # $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "File" + # } + # } } From 16e9e5520db7974d343b736a304dd21ed42539bc Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Tue, 28 Oct 2025 08:06:42 +0000 Subject: [PATCH 07/12] Use Settings.Create rather than HashtableSettingsConverter.Convert directly --- Engine/Formatter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Engine/Formatter.cs b/Engine/Formatter.cs index e4d75a1eb..ea00dfbb4 100644 --- a/Engine/Formatter.cs +++ b/Engine/Formatter.cs @@ -83,7 +83,7 @@ private static void ValidateNotNull(T obj, string name) private static SettingsData GetCurrentSettings(SettingsData settings, string rule) { - return HashtableSettingsConverter.Convert(new Hashtable() + return Settings.Create(new Hashtable() { {"IncludeRules", new string[] {rule}}, {"Rules", new Hashtable() { { rule, new Hashtable(settings.RuleArguments[rule]) } } } From 1d55acecec3ff7b8c0786411b8d1ad1b46f81b0e Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Wed, 29 Oct 2025 14:02:30 +0000 Subject: [PATCH 08/12] Make Settings parsers a readonly array --- Engine/Settings/Settings.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Engine/Settings/Settings.cs b/Engine/Settings/Settings.cs index 404468f56..9a7ed4d9c 100644 --- a/Engine/Settings/Settings.cs +++ b/Engine/Settings/Settings.cs @@ -32,7 +32,7 @@ public static class Settings /// The first matching parser "wins" for auto discovery and presets when multiple /// files of the same base name, but different supported extensions, exist. /// - private static readonly List s_parsers = new() + private static readonly ISettingsParser[] s_parsers = { new JsonSettingsParser(), new Psd1SettingsParser() @@ -281,7 +281,7 @@ private static string ResolveProviderPathIfPossible( /// If no parser can handle the file. private static SettingsData ParseFile(string path) { - var parser = s_parsers.Find(p => p.CanParse(path)) ?? + var parser = Array.Find(s_parsers, p => p.CanParse(path)) ?? throw new NotSupportedException($"No parser registered for settings file '{path}'."); using var fs = File.OpenRead(path); var data = parser.Parse(fs, path); From 7427a41636656309d2efd0fc9d46508be3cd4afc Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Wed, 29 Oct 2025 14:04:16 +0000 Subject: [PATCH 09/12] Add serialisation methods for SettingsData in various parsers --- Engine/Settings/ISettingsParser.cs | 8 +++ Engine/Settings/JsonSettingsParser.cs | 61 ++++++++++++++++++ Engine/Settings/Psd1SettingsParser.cs | 89 ++++++++++++++++++++++++++- Engine/Settings/Settings.cs | 9 +++ 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/Engine/Settings/ISettingsParser.cs b/Engine/Settings/ISettingsParser.cs index 4422f54aa..832c7735b 100644 --- a/Engine/Settings/ISettingsParser.cs +++ b/Engine/Settings/ISettingsParser.cs @@ -31,5 +31,13 @@ public interface ISettingsParser /// The source path of the settings file. /// The parsed SettingsData. SettingsData Parse(Stream content, string sourcePath); + + /// + /// Serialises the SettingsData into a string representation. + /// + /// The SettingsData to serialise. + /// The string representation of the settings. + string Serialise(SettingsData settingsData); + } } \ No newline at end of file diff --git a/Engine/Settings/JsonSettingsParser.cs b/Engine/Settings/JsonSettingsParser.cs index 96c51e77a..cbc91ce6e 100644 --- a/Engine/Settings/JsonSettingsParser.cs +++ b/Engine/Settings/JsonSettingsParser.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer { @@ -102,6 +103,66 @@ public SettingsData Parse(Stream content, string sourcePath) RuleArguments = ruleArgs }; } + + /// + /// Serializes a instance into a PSScriptAnalyzerSettings.json + /// formatted string. Omits empty collections and false boolean flags for brevity. + /// + /// Settings snapshot to serialize. + /// True for indented JSON, false for minified. + /// JSON string suitable for saving as PSScriptAnalyzerSettings.json. + /// If is null. + public string Serialise(SettingsData data) + { + if (data == null) throw new ArgumentNullException(nameof(data)); + + var root = new JObject(); + var serializer = JsonSerializer.CreateDefault(); + + void AddArray(string name, IList list) + { + if (list != null && list.Count > 0) + { + root[name] = new JArray(list); + } + } + + AddArray("IncludeRules", data.IncludeRules); + AddArray("ExcludeRules", data.ExcludeRules); + AddArray("Severity", data.Severities); + AddArray("CustomRulePath", data.CustomRulePath); + + if (data.IncludeDefaultRules) + { + root["IncludeDefaultRules"] = true; + } + if (data.RecurseCustomRulePath) + { + root["RecurseCustomRulePath"] = true; + } + + if (data.RuleArguments != null && data.RuleArguments.Count > 0) + { + var rulesObj = new JObject(); + foreach (var rule in data.RuleArguments) + { + var argsObj = new JObject(); + if (rule.Value != null) + { + foreach (var arg in rule.Value) + { + // Serialize scalar or complex value + argsObj[arg.Key] = arg.Value != null + ? JToken.FromObject(arg.Value, serializer) + : JValue.CreateNull(); + } + } + rulesObj[rule.Key] = argsObj; + } + root["Rules"] = rulesObj; + } + return root.ToString(Formatting.Indented); + } } } \ No newline at end of file diff --git a/Engine/Settings/Psd1SettingsParser.cs b/Engine/Settings/Psd1SettingsParser.cs index 5dee7f0fa..05017c660 100644 --- a/Engine/Settings/Psd1SettingsParser.cs +++ b/Engine/Settings/Psd1SettingsParser.cs @@ -3,6 +3,7 @@ using System; using System.Collections; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Management.Automation.Language; @@ -78,6 +79,92 @@ public SettingsData Parse(Stream content, string sourcePath) return HashtableSettingsConverter.Convert(raw); } - } + /// + /// Serializes a instance into a formatted .psd1 settings file + /// (PowerShell hashtable) similar to shipped presets. + /// Omits empty collections and flags (if false) to keep output concise. + /// + /// Settings to serialize. + /// Formatted .psd1 content as a string. + public string Serialise(SettingsData settingsData) + { + if (settingsData == null) throw new ArgumentNullException(nameof(settingsData)); + + var sb = new System.Text.StringBuilder(); + var indent = " "; + + string Quote(string s) => "'" + s.Replace("'", "''") + "'"; + + void AppendStringList(string key, List list) + { + if (list == null || list.Count == 0) return; + sb.Append(indent).Append(key).Append(" = @(").AppendLine(); + for (int i = 0; i < list.Count; i++) + { + sb.Append(indent).Append(indent).Append(Quote(list[i])); + sb.AppendLine(i == list.Count - 1 ? string.Empty : ","); + } + sb.AppendLine(indent + ")").AppendLine(); + } + + string FormatScalar(object value) + { + if (value == null) return "$null"; + return value switch + { + string s => Quote(s), + bool b => b ? "$true" : "$false", + Enum e => Quote(e.ToString()), + int or long or short or byte or sbyte or uint or ulong or ushort => Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture), + float f => f.ToString(System.Globalization.CultureInfo.InvariantCulture), + double d => d.ToString(System.Globalization.CultureInfo.InvariantCulture), + decimal m => m.ToString(System.Globalization.CultureInfo.InvariantCulture), + _ => Quote(value.ToString()) + }; + } + + sb.AppendLine("@{"); + + // Ordered sections + AppendStringList("IncludeRules", settingsData.IncludeRules); + AppendStringList("ExcludeRules", settingsData.ExcludeRules); + AppendStringList("Severity", settingsData.Severities); + AppendStringList("CustomRulePath", settingsData.CustomRulePath); + + if (settingsData.IncludeDefaultRules) + { + sb.Append(indent).Append("IncludeDefaultRules = ").AppendLine("$true").AppendLine(); + } + if (settingsData.RecurseCustomRulePath) + { + sb.Append(indent).Append("RecurseCustomRulePath = ").AppendLine("$true").AppendLine(); + } + + // Rules block + if (settingsData.RuleArguments != null && settingsData.RuleArguments.Count > 0) + { + sb.Append(indent).AppendLine("Rules = @{"); + foreach (var ruleKvp in settingsData.RuleArguments) + { + sb.Append(indent).Append(indent).Append(ruleKvp.Key).Append(" = @{").AppendLine(); + if (ruleKvp.Value != null && ruleKvp.Value.Count > 0) + { + foreach (var argKvp in ruleKvp.Value) + { + sb.Append(indent).Append(indent).Append(indent) + .Append(argKvp.Key).Append(" = ") + .AppendLine(FormatScalar(argKvp.Value)); + } + } + sb.Append(indent).Append(indent).AppendLine("}").AppendLine(); + } + sb.Append(indent).AppendLine("}"); + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + } } \ No newline at end of file diff --git a/Engine/Settings/Settings.cs b/Engine/Settings/Settings.cs index 9a7ed4d9c..70c1b3b8c 100644 --- a/Engine/Settings/Settings.cs +++ b/Engine/Settings/Settings.cs @@ -288,6 +288,15 @@ private static SettingsData ParseFile(string path) return data; } + public static string Serialise(SettingsData data, string format) + { + // Check each parser to see if the format matches + // and use it to serialize the data + var parser = Array.Find(s_parsers, p => string.Equals(p.FormatName, format, StringComparison.OrdinalIgnoreCase)) ?? + throw new NotSupportedException($"No parser registered for format '{format}'."); + return parser.Serialise(data); + } + /// /// Retrieves the Settings directory from the Module directory structure /// From 03acbd62096a1f0f451440bb82098a321034d117 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Wed, 29 Oct 2025 14:05:33 +0000 Subject: [PATCH 10/12] Refactor to use GetShippedModuleSubDirectory for settings and command data file paths --- Engine/Settings/Settings.cs | 51 +++++-------------------- Rules/AvoidOverwritingBuiltInCmdlets.cs | 2 +- Rules/UseCompatibleCmdlets.cs | 2 +- 3 files changed, 12 insertions(+), 43 deletions(-) diff --git a/Engine/Settings/Settings.cs b/Engine/Settings/Settings.cs index 70c1b3b8c..ce2e4f089 100644 --- a/Engine/Settings/Settings.cs +++ b/Engine/Settings/Settings.cs @@ -229,7 +229,7 @@ public static string TryAutoDiscover(string path) public static string TryResolvePreset(string name) { // Get the path to the folder of preset settings files shipped with the module - var settingsDir = GetShippedSettingsDirectory(); + var settingsDir = GetShippedModuleSubDirectory("Settings"); // If we can't locate it, return null if (settingsDir == null) return null; @@ -298,10 +298,10 @@ public static string Serialise(SettingsData data, string format) } /// - /// Retrieves the Settings directory from the Module directory structure + /// Retrieves a subdirectory from the Module directory structure /// - /// The Settings directory path - public static string GetShippedSettingsDirectory() + /// The subdirectory path + public static string GetShippedModuleSubDirectory(string subDirectoryName) { // Find the compatibility files in Settings folder var path = typeof(Helper).GetTypeInfo().Assembly.Location; @@ -313,50 +313,19 @@ public static string GetShippedSettingsDirectory() // Find the compatibility files in Settings folder adjacent to the assembly. // Some builds place binaries in subfolders (coreclr/, PSv3/); in those cases, // the Settings folder lives in the parent (module root), so we also probe one level up. - var settingsPath = Path.Combine(Path.GetDirectoryName(path), "Settings"); - if (!Directory.Exists(settingsPath)) + var subDirectoryPath = Path.Combine(Path.GetDirectoryName(path), subDirectoryName); + if (!Directory.Exists(subDirectoryPath)) { // Probe parent directory (module root) for Settings folder. var parentDir = Path.GetDirectoryName(Path.GetDirectoryName(path)); - settingsPath = Path.Combine(parentDir ?? string.Empty, "Settings"); - if (!Directory.Exists(settingsPath)) + subDirectoryPath = Path.Combine(parentDir ?? string.Empty, subDirectoryName); + if (!Directory.Exists(subDirectoryPath)) { return null; } } - return settingsPath; - } - - /// - /// Retrieves the Settings directory from the Module directory structure - /// - /// The Settings directory path - public static string GetShippedCommandDataFileDirectory() - { - // Find the compatibility files in Settings folder - var path = typeof(Helper).GetTypeInfo().Assembly.Location; - if (string.IsNullOrWhiteSpace(path)) - { - return null; - } - - // Find the compatibility files in Settings folder adjacent to the assembly. - // Some builds place binaries in subfolders (coreclr/, PSv3/); in those cases, - // the Settings folder lives in the parent (module root), so we also probe one level up. - var commandDataFilesPath = Path.Combine(Path.GetDirectoryName(path), "CommandDataFiles"); - if (!Directory.Exists(commandDataFilesPath)) - { - // Probe parent directory (module root) for CommandDataFiles folder. - var parentDir = Path.GetDirectoryName(Path.GetDirectoryName(path)); - commandDataFilesPath = Path.Combine(parentDir ?? string.Empty, "CommandDataFiles"); - if (!Directory.Exists(commandDataFilesPath)) - { - return null; - } - } - - return commandDataFilesPath; + return subDirectoryPath; } /// @@ -367,7 +336,7 @@ public static string GetShippedCommandDataFileDirectory() /// public static IEnumerable GetSettingPresets() { - var settingsPath = GetShippedSettingsDirectory(); + var settingsPath = GetShippedModuleSubDirectory("Settings"); if (settingsPath == null) { diff --git a/Rules/AvoidOverwritingBuiltInCmdlets.cs b/Rules/AvoidOverwritingBuiltInCmdlets.cs index 308ba8537..814f96c0b 100644 --- a/Rules/AvoidOverwritingBuiltInCmdlets.cs +++ b/Rules/AvoidOverwritingBuiltInCmdlets.cs @@ -90,7 +90,7 @@ public override IEnumerable AnalyzeScript(Ast ast, string file } var psVerList = PowerShellVersion; - string commandDataFilesPath = Settings.GetShippedCommandDataFileDirectory(); + string commandDataFilesPath = Settings.GetShippedModuleSubDirectory("CommandDataFiles"); foreach (string reference in psVerList) { diff --git a/Rules/UseCompatibleCmdlets.cs b/Rules/UseCompatibleCmdlets.cs index 8c8357286..b63e3cd91 100644 --- a/Rules/UseCompatibleCmdlets.cs +++ b/Rules/UseCompatibleCmdlets.cs @@ -306,7 +306,7 @@ private void SetupCmdletsDictionary() return; } - string commandDataFilesPath = Settings.GetShippedCommandDataFileDirectory(); + string commandDataFilesPath = Settings.GetShippedModuleSubDirectory("CommandDataFiles"); #if DEBUG object modeObject; if (ruleArgs.TryGetValue("mode", out modeObject)) From c92738a38239fa60b54214117a6ac6d7063c6041 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Wed, 29 Oct 2025 16:22:11 +0000 Subject: [PATCH 11/12] Change from 'Parser' to 'Format' 'Parser' has one-way connotations. Settle on 'Serialize' over 'Serialise' (as the rest of the project is in American English and mine and copilots mixed usage was getting confusing). Stream -> string in the Deserialize interface method as it wasn't helpful like I thought it might be --- Engine/Settings/ISettingsFormat.cs | 38 +++++++++++++++ Engine/Settings/ISettingsParser.cs | 43 ----------------- ...ettingsParser.cs => JsonSettingsFormat.cs} | 27 ++++------- ...ettingsParser.cs => Psd1SettingsFormat.cs} | 47 +++++++++++-------- 4 files changed, 75 insertions(+), 80 deletions(-) create mode 100644 Engine/Settings/ISettingsFormat.cs delete mode 100644 Engine/Settings/ISettingsParser.cs rename Engine/Settings/{JsonSettingsParser.cs => JsonSettingsFormat.cs} (84%) rename Engine/Settings/{Psd1SettingsParser.cs => Psd1SettingsFormat.cs} (82%) diff --git a/Engine/Settings/ISettingsFormat.cs b/Engine/Settings/ISettingsFormat.cs new file mode 100644 index 000000000..8660fcbf2 --- /dev/null +++ b/Engine/Settings/ISettingsFormat.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + /// + /// Interface for settings file formats + /// + public interface ISettingsFormat + { + /// + /// Format identifier (extension without dot, e.g. 'json', 'psd1'). + /// + string FormatName { get; } + + /// + /// Whether this format can handle the specified path + /// + /// Full path or filename. + /// True if this format can handle the path. + bool Supports(string path); + + /// + /// Deserialises the content stream into . + /// + /// The content to parse. + /// The parsed SettingsData. + SettingsData Deserialize(string content, string sourcePath); + + /// + /// Serializes the into a string representation. + /// + /// + /// + string Serialize(SettingsData settingsData); + + } +} \ No newline at end of file diff --git a/Engine/Settings/ISettingsParser.cs b/Engine/Settings/ISettingsParser.cs deleted file mode 100644 index 832c7735b..000000000 --- a/Engine/Settings/ISettingsParser.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.IO; - -namespace Microsoft.Windows.PowerShell.ScriptAnalyzer -{ - /// - /// Provides an interface for a settings parser. - /// - public interface ISettingsParser - { - /// - /// Gets the name of the format this parser supports. e.g. "psd1", "json". - /// - string FormatName { get; } - - /// - /// Determines whether this parser can parse the given file path or extension. - /// - /// The file path or extension to check. - /// - /// True if the parser can parse the given path or extension; otherwise, false. - /// - bool CanParse(string pathOrExtension); - - /// - /// Parses the content stream into SettingsData. - /// - /// The stream containing the settings content. - /// The source path of the settings file. - /// The parsed SettingsData. - SettingsData Parse(Stream content, string sourcePath); - - /// - /// Serialises the SettingsData into a string representation. - /// - /// The SettingsData to serialise. - /// The string representation of the settings. - string Serialise(SettingsData settingsData); - - } -} \ No newline at end of file diff --git a/Engine/Settings/JsonSettingsParser.cs b/Engine/Settings/JsonSettingsFormat.cs similarity index 84% rename from Engine/Settings/JsonSettingsParser.cs rename to Engine/Settings/JsonSettingsFormat.cs index cbc91ce6e..89b99b524 100644 --- a/Engine/Settings/JsonSettingsParser.cs +++ b/Engine/Settings/JsonSettingsFormat.cs @@ -11,7 +11,7 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer { /// - /// Parses JSON settings files (extension .json) into . + /// Handles JSON settings files (extension .json). /// Expected top-level properties: /// Severity : string or string array /// IncludeRules : string or string array @@ -20,15 +20,8 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer /// IncludeDefaultRules : bool /// RecurseCustomRulePath : bool /// Rules : object with ruleName -> { argumentName : value } mapping - /// Parsing logic: - /// 1. Read entire stream into a string. - /// 2. Deserialize to DTO with Newtonsoft.Json (case-insensitive by default). - /// 3. Validate null result -> invalid data. - /// 4. Normalize each collection to empty lists when absent. - /// 5. Rebuild rule arguments as case-insensitive dictionaries. - /// Throws on malformed JSON or missing structure. /// - internal sealed class JsonSettingsParser : ISettingsParser + internal sealed class JsonSettingsFormat : ISettingsFormat { /// @@ -48,12 +41,12 @@ private sealed class JsonSettingsDto public string FormatName => "json"; /// - /// Determines if this parser can handle the supplied path by checking for .json extension. + /// Determines if this format can handle the supplied path by checking for .json extension. /// - /// File path or extension string. + /// File path /// True if extension is .json. - public bool CanParse(string pathOrExtension) => - string.Equals(Path.GetExtension(pathOrExtension), ".json", StringComparison.OrdinalIgnoreCase); + public bool Supports(string path) => + string.Equals(Path.GetExtension(path), ".json", StringComparison.OrdinalIgnoreCase); /// /// Parses a JSON settings file stream into . @@ -64,14 +57,12 @@ public bool CanParse(string pathOrExtension) => /// /// Thrown on JSON deserialization error or invalid/empty root object. /// - public SettingsData Parse(Stream content, string sourcePath) + public SettingsData Deserialize(string content, string sourcePath) { - using var reader = new StreamReader(content); - string json = reader.ReadToEnd(); JsonSettingsDto dto; try { - dto = JsonConvert.DeserializeObject(json); + dto = JsonConvert.DeserializeObject(content); } catch (JsonException je) { @@ -112,7 +103,7 @@ public SettingsData Parse(Stream content, string sourcePath) /// True for indented JSON, false for minified. /// JSON string suitable for saving as PSScriptAnalyzerSettings.json. /// If is null. - public string Serialise(SettingsData data) + public string Serialize(SettingsData data) { if (data == null) throw new ArgumentNullException(nameof(data)); diff --git a/Engine/Settings/Psd1SettingsParser.cs b/Engine/Settings/Psd1SettingsFormat.cs similarity index 82% rename from Engine/Settings/Psd1SettingsParser.cs rename to Engine/Settings/Psd1SettingsFormat.cs index 05017c660..7705e540d 100644 --- a/Engine/Settings/Psd1SettingsParser.cs +++ b/Engine/Settings/Psd1SettingsFormat.cs @@ -24,7 +24,7 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer /// Throws for structural issues (missing hashtable, invalid /// values). /// - internal sealed class Psd1SettingsParser : ISettingsParser + internal sealed class Psd1SettingsFormat : ISettingsFormat { public string FormatName => "psd1"; @@ -33,30 +33,23 @@ internal sealed class Psd1SettingsParser : ISettingsParser /// /// Full path or just an extension string. /// True if the extension is .psd1 (case-insensitive). - public bool CanParse(string pathOrExtension) => + public bool Supports(string pathOrExtension) => string.Equals(Path.GetExtension(pathOrExtension), ".psd1", StringComparison.OrdinalIgnoreCase); /// - /// Parses a .psd1 settings file into . + /// Deserializes a .psd1 settings file into . /// /// - /// Stream for API symmetry; not directly consumed (PowerShell parser reads from file path). + /// The content of the .psd1 file as a string. /// /// Absolute or relative path to the .psd1 file. /// Normalized instance. - /// If the file does not exist. /// /// If no top-level hashtable is found or conversion yields invalid data. /// - public SettingsData Parse(Stream content, string sourcePath) + public SettingsData Deserialize(string content, string sourcePath) { - // Need file path for PowerShell Parser.ParseFile - if (!File.Exists(sourcePath)) - { - throw new FileNotFoundException("Settings file not found.", sourcePath); - } - - Ast ast = Parser.ParseFile(sourcePath, out Token[] tokens, out ParseError[] errors); + Ast ast = Parser.ParseInput(content, out Token[] tokens, out ParseError[] errors); if (ast.FindAll(a => a is HashtableAst, false).FirstOrDefault() is not HashtableAst hashTableAst) { @@ -87,7 +80,7 @@ public SettingsData Parse(Stream content, string sourcePath) /// /// Settings to serialize. /// Formatted .psd1 content as a string. - public string Serialise(SettingsData settingsData) + public string Serialize(SettingsData settingsData) { if (settingsData == null) throw new ArgumentNullException(nameof(settingsData)); @@ -124,9 +117,26 @@ string FormatScalar(object value) }; } + string FormatValue(object value) + { + // Non-string enumerable -> PowerShell array literal + if (value is System.Collections.IEnumerable en && value is not string) + { + var items = new List(); + foreach (var item in en) + { + // Treat nested enumerable of strings similarly; else fall back to scalar + items.Add(FormatScalar(item)); + } + return items.Count == 0 + ? "@()" + : "@(" + string.Join(", ", items) + ")"; + } + return FormatScalar(value); + } + sb.AppendLine("@{"); - // Ordered sections AppendStringList("IncludeRules", settingsData.IncludeRules); AppendStringList("ExcludeRules", settingsData.ExcludeRules); AppendStringList("Severity", settingsData.Severities); @@ -134,14 +144,13 @@ string FormatScalar(object value) if (settingsData.IncludeDefaultRules) { - sb.Append(indent).Append("IncludeDefaultRules = ").AppendLine("$true").AppendLine(); + sb.Append(indent).Append("IncludeDefaultRules = $true").AppendLine().AppendLine(); } if (settingsData.RecurseCustomRulePath) { - sb.Append(indent).Append("RecurseCustomRulePath = ").AppendLine("$true").AppendLine(); + sb.Append(indent).Append("RecurseCustomRulePath = $true").AppendLine().AppendLine(); } - // Rules block if (settingsData.RuleArguments != null && settingsData.RuleArguments.Count > 0) { sb.Append(indent).AppendLine("Rules = @{"); @@ -154,7 +163,7 @@ string FormatScalar(object value) { sb.Append(indent).Append(indent).Append(indent) .Append(argKvp.Key).Append(" = ") - .AppendLine(FormatScalar(argKvp.Value)); + .AppendLine(FormatValue(argKvp.Value)); } } sb.Append(indent).Append(indent).AppendLine("}").AppendLine(); From 091a1eacbbc955e08cc7e692c4f9842b5f307b05 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Thu, 30 Oct 2025 16:10:56 +0000 Subject: [PATCH 12/12] Added New-ScriptAnalyzerSettingsFile and updated Get-ScriptAnalyzerRule to include rule options (called options to not cause confusion with settings) --- .../Commands/GetScriptAnalyzerRuleCommand.cs | 30 +- .../NewScriptAnalyzerSettingsFileCommand.cs | 289 ++++++++++++++++++ Engine/Generic/RuleInfo.cs | 44 ++- Engine/Generic/RuleOptionInfo.cs | 19 ++ Engine/PSScriptAnalyzer.psd1 | 2 +- Engine/Settings/Settings.cs | 154 +++++++--- Engine/Settings/SettingsData.cs | 3 - Engine/Strings.resx | 3 + 8 files changed, 497 insertions(+), 47 deletions(-) create mode 100644 Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs create mode 100644 Engine/Generic/RuleOptionInfo.cs diff --git a/Engine/Commands/GetScriptAnalyzerRuleCommand.cs b/Engine/Commands/GetScriptAnalyzerRuleCommand.cs index 3219affa7..de9b2fa7e 100644 --- a/Engine/Commands/GetScriptAnalyzerRuleCommand.cs +++ b/Engine/Commands/GetScriptAnalyzerRuleCommand.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.Linq; using System.Management.Automation; +using System.Reflection; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands { @@ -114,8 +115,35 @@ protected override void ProcessRecord() foreach (IRule rule in rules) { + IEnumerable optionInfos = null; + + if (rule is ConfigurableRule configurable) + { + var props = rule.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public); + var optList = new List(); + + foreach (var p in props) + { + if (p.GetCustomAttribute(inherit: true) == null) { + continue; + } + + optList.Add(new RuleOptionInfo + { + Name = p.Name, + OptionType = p.PropertyType, + DefaultValue = p.GetValue(rule) + }); + } + + if (optList.Count > 0) + { + optionInfos = optList; + } + } + 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(), optionInfos)); } } } diff --git a/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs new file mode 100644 index 000000000..3dfab7d49 --- /dev/null +++ b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs @@ -0,0 +1,289 @@ +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Reflection; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands +{ + /// + /// Creates a new PSScriptAnalyzer settings file in the specified directory + /// optionally based on a preset, a blank template, or all rules with default arguments. + /// + [Cmdlet(VerbsCommon.New, "ScriptAnalyzerSettingsFile", SupportsShouldProcess = true)] + [OutputType(typeof(string))] + public sealed class NewScriptAnalyzerSettingsFileCommand : PSCmdlet, IOutputWriter + { + private const string BaseOption_All = "All"; + private const string BaseOption_Blank = "Blank"; + + /// + /// Target directory (or file path) where the settings file will be created. Defaults to + /// current location. + /// + [Parameter(Position = 0)] + [ValidateNotNullOrEmpty] + public string Path { get; set; } + + /// + /// Settings file format/extension (e.g. json, psd1). Defaults to first supported format. + /// + [Parameter] + [ArgumentCompleter(typeof(FileFormatCompleter))] + [ValidateNotNullOrEmpty] + public string FileFormat { get; set; } + + /// + /// Base content: 'Blank', 'All', or a preset name returned by Get-SettingPresets. + /// 'Blank' -> minimal empty settings. + /// 'All' -> include all rules and their configurable arguments with current defaults. + /// preset -> copy preset contents. + /// + [Parameter] + [ArgumentCompleter(typeof(SettingsBaseCompleter))] + [ValidateNotNullOrEmpty] + public string Base { get; set; } = BaseOption_Blank; + + /// + /// Overwrite existing file if present. + /// + [Parameter] + public SwitchParameter Force { get; set; } + + protected override void BeginProcessing() + { + Helper.Instance = new Helper(SessionState.InvokeCommand); + Helper.Instance.Initialize(); + + string[] rulePaths = Helper.ProcessCustomRulePaths(null, SessionState, false); + ScriptAnalyzer.Instance.Initialize(this, rulePaths, null, null, null, null == rulePaths); + } + + protected override void ProcessRecord() + { + // Default Path + if (string.IsNullOrWhiteSpace(Path)) + { + Path = SessionState.Path.CurrentFileSystemLocation.ProviderPath; + } + + // If user passed an existing file path, switch to its directory. + if (File.Exists(Path)) + { + Path = System.IO.Path.GetDirectoryName(Path); + } + + // Require the directory to already exist (do not create it). + if (!Directory.Exists(Path)) + { + ThrowTerminatingError(new ErrorRecord( + new DirectoryNotFoundException($"Directory '{Path}' does not exist."), + "DIRECTORY_NOT_FOUND", + ErrorCategory.ObjectNotFound, + Path)); + return; + } + + // Ensure FileSystem provider for target Path. + ProviderInfo providerInfo; + try + { + SessionState.Path.GetResolvedProviderPathFromPSPath(Path, out providerInfo); + } + catch (Exception ex) + { + ThrowTerminatingError(new ErrorRecord( + new InvalidOperationException($"Cannot resolve path '{Path}': {ex.Message}", ex), + "PATH_RESOLVE_FAILED", + ErrorCategory.InvalidArgument, + Path)); + return; + } + + if (!string.Equals(providerInfo.Name, "FileSystem", StringComparison.OrdinalIgnoreCase)) + { + ThrowTerminatingError(new ErrorRecord( + new InvalidOperationException("Target path must be in the FileSystem provider."), + "INVALID_PROVIDER", + ErrorCategory.InvalidArgument, + Path)); + } + + // Default format to first supported. + if (string.IsNullOrWhiteSpace(FileFormat)) + { + FileFormat = Settings.GetSettingsFormats().First(); + } + + // Validate requested format. + if (!Settings.GetSettingsFormats().Any(f => string.Equals(f, FileFormat, StringComparison.OrdinalIgnoreCase))) + { + ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"Unsupported settings format '{FileFormat}'."), + "UNSUPPORTED_FORMAT", + ErrorCategory.InvalidArgument, + FileFormat)); + } + + var targetFile = System.IO.Path.Combine(Path, $"{Settings.DefaultSettingsFileName}.{FileFormat}"); + + if (File.Exists(targetFile) && !Force) + { + WriteWarning($"Settings file already exists: {targetFile}. Use -Force to overwrite."); + return; + } + + SettingsData data; + try + { + data = BuildSettingsData(); + } + catch (Exception ex) + { + ThrowTerminatingError(new ErrorRecord( + ex, + "BUILD_SETTINGS_FAILED", + ErrorCategory.InvalidData, + Base)); + return; + } + + string content; + try + { + content = Settings.Serialize(data, FileFormat); + } + catch (Exception ex) + { + ThrowTerminatingError(new ErrorRecord( + ex, + "SERIALIZE_FAILED", + ErrorCategory.InvalidData, + FileFormat)); + return; + } + + if (ShouldProcess(targetFile, "Create settings file")) + { + try + { + File.WriteAllText(targetFile, content); + WriteVerbose($"Created settings file: {targetFile}"); + } + catch (Exception ex) + { + ThrowTerminatingError(new ErrorRecord( + ex, + "CREATE_FILE_FAILED", + ErrorCategory.InvalidData, + targetFile)); + return; + } + WriteObject(targetFile); + } + } + + private SettingsData BuildSettingsData() + { + if (string.Equals(Base, BaseOption_Blank, StringComparison.OrdinalIgnoreCase)) + { + return new SettingsData(); // empty snapshot + } + + if (string.Equals(Base, BaseOption_All, StringComparison.OrdinalIgnoreCase)) + { + return BuildAllSettingsData(); + } + + // Preset + var presetPath = Settings.TryResolvePreset(Base); + if (presetPath == null) + { + throw new FileNotFoundException($"Preset '{Base}' not found."); + } + return Settings.Create(presetPath); + } + + private SettingsData BuildAllSettingsData() + { + var ruleNames = new List(); + var ruleArgs = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + var modNames = ScriptAnalyzer.Instance.GetValidModulePaths(); + var rules = ScriptAnalyzer.Instance.GetRule(modNames, null) ?? Enumerable.Empty(); + + foreach (var rule in rules) + { + var name = rule.GetName(); + ruleNames.Add(name); + + if (rule is ConfigurableRule configurable) + { + var props = rule.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public); + var argDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in props) + { + if (p.GetCustomAttribute(inherit: true) == null) + { + continue; + } + argDict[p.Name] = p.GetValue(rule); + } + if (argDict.Count > 0) + { + ruleArgs[name] = argDict; + } + } + } + + return new SettingsData + { + IncludeRules = ruleNames, + RuleArguments = ruleArgs, + }; + } + + #region Completers + + private sealed class FileFormatCompleter : IArgumentCompleter + { + public IEnumerable CompleteArgument(string commandName, + string parameterName, string wordToComplete, CommandAst commandAst, + IDictionary fakeBoundParameters) + { + foreach (var fmt in Settings.GetSettingsFormats()) + { + if (fmt.StartsWith(wordToComplete ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + yield return new CompletionResult(fmt, fmt, CompletionResultType.ParameterValue, $"Settings format '{fmt}'"); + } + } + } + } + + private sealed class SettingsBaseCompleter : IArgumentCompleter + { + public IEnumerable CompleteArgument(string commandName, + string parameterName, string wordToComplete, CommandAst commandAst, + IDictionary fakeBoundParameters) + { + var bases = new List { BaseOption_Blank, BaseOption_All }; + bases.AddRange(Settings.GetSettingPresets()); + + foreach (var b in bases) + { + if (b.StartsWith(wordToComplete ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + yield return new CompletionResult(b, b, CompletionResultType.ParameterValue, $"Base template '{b}'"); + } + } + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Engine/Generic/RuleInfo.cs b/Engine/Generic/RuleInfo.cs index 755d16d15..befe8ba26 100644 --- a/Engine/Generic/RuleInfo.cs +++ b/Engine/Generic/RuleInfo.cs @@ -3,6 +3,8 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Collections.Generic; +using System.Linq; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic { @@ -18,6 +20,7 @@ public class RuleInfo private string sourceName; private RuleSeverity ruleSeverity; private Type implementingType; + private IEnumerable options; /// /// Name: The name of the rule. @@ -90,6 +93,15 @@ public Type ImplementingType private set { implementingType = value; } } + /// + /// Options : The configurable options of the rule if it is a ConfigurableRule. + /// + public IEnumerable Options + { + get { return options; } + private set { options = value; } + } + /// /// Constructor for a RuleInfo. /// @@ -106,6 +118,7 @@ public RuleInfo(string name, string commonName, string description, SourceType s SourceType = sourceType; SourceName = sourceName; Severity = severity; + Options = Enumerable.Empty(); } /// @@ -119,13 +132,36 @@ public RuleInfo(string name, string commonName, string description, SourceType s /// The dotnet type of the rule. public RuleInfo(string name, string commonName, string description, SourceType sourceType, string sourceName, RuleSeverity severity, Type implementingType) { - RuleName = name; - CommonName = commonName; + RuleName = name; + CommonName = commonName; Description = description; - SourceType = sourceType; - SourceName = sourceName; + SourceType = sourceType; + SourceName = sourceName; + Severity = severity; + ImplementingType = implementingType; + Options = Enumerable.Empty(); + } + + /// + /// 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. + /// The dotnet type of the rule. + /// The configurable options of the rule. + public RuleInfo(string name, string commonName, string description, SourceType sourceType, string sourceName, RuleSeverity severity, Type implementingType, IEnumerable options) + { + RuleName = name; + CommonName = commonName; + Description = description; + SourceType = sourceType; + SourceName = sourceName; Severity = severity; ImplementingType = implementingType; + Options = options ?? Enumerable.Empty(); } public override string ToString() diff --git a/Engine/Generic/RuleOptionInfo.cs b/Engine/Generic/RuleOptionInfo.cs new file mode 100644 index 000000000..da030fcdf --- /dev/null +++ b/Engine/Generic/RuleOptionInfo.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic +{ + + /// + /// Holds metadata for a single configurable rule property. + /// + public class RuleOptionInfo + { + public string Name { get; internal set; } + public Type OptionType { get; internal set; } + public object DefaultValue { get; internal set; } + } + +} diff --git a/Engine/PSScriptAnalyzer.psd1 b/Engine/PSScriptAnalyzer.psd1 index 49fb93227..b50494bad 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') # Variables to export from this module VariablesToExport = @() diff --git a/Engine/Settings/Settings.cs b/Engine/Settings/Settings.cs index ce2e4f089..53a408eb1 100644 --- a/Engine/Settings/Settings.cs +++ b/Engine/Settings/Settings.cs @@ -5,6 +5,7 @@ using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; using System.IO; using System.Management.Automation; using System.Reflection; @@ -18,24 +19,24 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer /// (null, preset name, file path, or inline hashtable) into a SettingsData instance by: /// 1. Auto-discovering a settings file (PSScriptAnalyzerSettings.*) in the working directory. /// 2. Mapping preset names to shipped settings files (supporting multiple formats). - /// 3. Loading and parsing settings files via registered format parsers (psd1, json). + /// 3. Loading and parsing settings files via registered formats (e.g. psd1, json). /// 4. Converting inline hashtables directly to SettingsData. /// Also exposes helpers to enumerate shipped presets and locate module resource folders. /// public static class Settings { - private readonly static string DefaultSettingsFileName = "PSScriptAnalyzerSettings"; + public readonly static string DefaultSettingsFileName = "PSScriptAnalyzerSettings"; /// - /// Registered settings parsers in precedence order. - /// The first matching parser "wins" for auto discovery and presets when multiple + /// Registered settings formats in precedence order. + /// The first matching format "wins" for auto discovery and presets when multiple /// files of the same base name, but different supported extensions, exist. /// - private static readonly ISettingsParser[] s_parsers = + private static readonly ISettingsFormat[] s_formats = { - new JsonSettingsParser(), - new Psd1SettingsParser() + new JsonSettingsFormat(), + new Psd1SettingsFormat() }; /// @@ -72,14 +73,61 @@ internal static SettingsData Create( // Determine how we're being passed settings var result = ResolveSettingsSource(settingsObj, cwd, getResolvedProviderPathFromPSPathDelegate); - return result.Kind switch + switch (result.Kind) { - SettingsSourceKind.None => null, - SettingsSourceKind.InlineHashtable => HashtableSettingsConverter.Convert(result.InlineHashtable), - SettingsSourceKind.AutoFile or SettingsSourceKind.ExplicitFile or SettingsSourceKind.PresetFile => ParseFile(result.FilePath), - _ => null, - }; - + case SettingsSourceKind.InlineHashtable: + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsUsingHashtable + ) + ); + return HashtableSettingsConverter.Convert(result.InlineHashtable); + case SettingsSourceKind.AutoFile: + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsNotProvided, + cwd ?? "" + ) + ); + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsAutoDiscovered, + result.FilePath + ) + ); + return Deserialize(result.FilePath); + case SettingsSourceKind.ExplicitFile: + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsUsingFile, + result.FilePath + ) + ); + return Deserialize(result.FilePath); + case SettingsSourceKind.PresetFile: + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsUsingPresetFile, + settingsObj, + result.FilePath + ) + ); + return Deserialize(result.FilePath); + case SettingsSourceKind.None: + default: + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsObjectCouldNotBResolved + ) + ); + return null; + } } /// @@ -170,7 +218,7 @@ private static ResolutionResult ResolveSettingsSource( }; } - // If it doesn't match a prefix, is it a valid file path? + // If it doesn't match a preset, is it a valid file path? // Attempt provider path resolution if possible s = ResolveProviderPathIfPossible(s, getResolvedProviderPathFromPSPathDelegate); if (File.Exists(s)) @@ -182,15 +230,24 @@ private static ResolutionResult ResolveSettingsSource( }; } - throw new FileNotFoundException($"Settings file '{s}' not found."); + throw new FileNotFoundException(string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsCannotFindFile, + s + ) + ); } - throw new ArgumentException("Settings must be a hashtable, a preset name, or a file path."); + throw new ArgumentException(string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsInvalidType + ) + ); } /// /// Attempts to locate a settings file in the supplied path's directory - /// using the registered parser formats in precedence order. + /// using the supported formats in precedence order. /// /// File or directory path. /// Full path to discovered settings file or null. @@ -210,10 +267,10 @@ public static string TryAutoDiscover(string path) if (!Directory.Exists(dir)) return null; // Test for the presence of a settings file for each of the formats - // supported. The parsers format list determines precedence. - foreach (var parser in s_parsers) + // supported. The format list order determines precedence. + foreach (var format in s_formats) { - var filePath = Path.Combine(dir, $"{DefaultSettingsFileName}.{parser.FormatName}"); + var filePath = Path.Combine(dir, $"{DefaultSettingsFileName}.{format.FormatName}"); if (File.Exists(filePath)) return filePath; } @@ -236,9 +293,9 @@ public static string TryResolvePreset(string name) // Loop through supported formats and check for existence // return the first match - foreach (var parser in s_parsers) + foreach (var format in s_formats) { - var filePath = Path.Combine(settingsDir, name + "." + parser.FormatName); + var filePath = Path.Combine(settingsDir, name + "." + format.FormatName); if (File.Exists(filePath)) return filePath; } @@ -273,28 +330,39 @@ private static string ResolveProviderPathIfPossible( } /// - /// Opens and parses the specified settings file using an appropriate registered parser. - /// Clones result to stamp correct SourceKind if immutability prevents direct assignment. + /// Opens and parses the specified settings file using an appropriate supported format. /// /// Existing settings file path. /// Parsed . - /// If no parser can handle the file. - private static SettingsData ParseFile(string path) + /// If no format can handle the file. + public static SettingsData Deserialize(string path) { - var parser = Array.Find(s_parsers, p => p.CanParse(path)) ?? - throw new NotSupportedException($"No parser registered for settings file '{path}'."); + var format = Array.Find(s_formats, f => f.Supports(path)) ?? + throw new NotSupportedException($"No format registered for settings file '{path}'."); using var fs = File.OpenRead(path); - var data = parser.Parse(fs, path); + var content = new StreamReader(fs).ReadToEnd(); + var data = format.Deserialize(content, path); return data; } - public static string Serialise(SettingsData data, string format) + /// + /// Serializes the supplied into the specified format. + /// + /// Settings data to serialize. + /// Format identifier (e.g. 'psd1', 'json'). + /// Serialized settings content. + /// If no format is registered for the specified name. + public static string Serialize(SettingsData data, string format) { - // Check each parser to see if the format matches - // and use it to serialize the data - var parser = Array.Find(s_parsers, p => string.Equals(p.FormatName, format, StringComparison.OrdinalIgnoreCase)) ?? - throw new NotSupportedException($"No parser registered for format '{format}'."); - return parser.Serialise(data); + // Find the appropriate format handler and use it to serialize the data + foreach (var f in s_formats) + { + if (string.Equals(f.FormatName, format, StringComparison.OrdinalIgnoreCase)) + { + return f.Serialize(data); + } + } + throw new NotSupportedException($"No supported format for '{format}'."); } /// @@ -348,9 +416,9 @@ public static IEnumerable GetSettingPresets() // only yield the name once. var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var parser in s_parsers) + foreach (var format in s_formats) { - var pattern = "*." + parser.FormatName; + var pattern = "*." + format.FormatName; foreach (var filepath in Directory.EnumerateFiles(settingsPath, pattern)) { var name = Path.GetFileNameWithoutExtension(filepath); @@ -362,6 +430,16 @@ public static IEnumerable GetSettingPresets() } } + /// + /// Returns supported settings file format identifiers + /// + public static IEnumerable GetSettingsFormats() + { + foreach (var f in s_formats) + { + yield return f.FormatName; + } + } } } \ No newline at end of file diff --git a/Engine/Settings/SettingsData.cs b/Engine/Settings/SettingsData.cs index 53710cd17..902b99dc0 100644 --- a/Engine/Settings/SettingsData.cs +++ b/Engine/Settings/SettingsData.cs @@ -8,9 +8,6 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer { /// /// Data container representing fully parsed and normalized PSScriptAnalyzer settings. - /// Produced by format parsers (JSON, PSD1) or the hashtable converter; consumed by rule - /// selection and formatter logic. Lists and dictionaries are mutable here for simplicity, but - /// callers should treat the instance as a snapshot and avoid modifying post-creation. /// public sealed class SettingsData { diff --git a/Engine/Strings.resx b/Engine/Strings.resx index 346a25aa6..258ece76c 100644 --- a/Engine/Strings.resx +++ b/Engine/Strings.resx @@ -261,6 +261,9 @@ Using settings file at {0}. + + Using settings preset {0}. File found at {1}. + Using settings hashtable.