Skip to content

Commit a09c6de

Browse files
committed
Add Test and New Cmdlets for PSScriptAnalyzer Settings Management
- Implemented `New-ScriptAnalyzerSettingsFile` cmdlet to create a new PSScriptAnalyzer settings file, with options for presets and overwriting existing files. - Added `Test-ScriptAnalyzerSettingsFile` cmdlet to validate settings files, checking for parseability, rule existence, and valid options. - Created comprehensive tests for both cmdlets to ensure functionality and error handling. - Updated module manifest to export the new cmdlets. - Added documentation for both cmdlets, including usage examples and parameter descriptions. - Enhanced error messages in the strings resource file for better clarity during validation failures.
1 parent 239b039 commit a09c6de

9 files changed

Lines changed: 1688 additions & 1 deletion

Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs

Lines changed: 499 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic;
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Globalization;
8+
using System.IO;
9+
using System.Linq;
10+
using System.Management.Automation;
11+
12+
namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands
13+
{
14+
/// <summary>
15+
/// TestScriptAnalyzerSettingsFileCommand: Validates a PSScriptAnalyzer settings file.
16+
/// Checks that the file is parseable, that referenced rules exist, and that all
17+
/// rule options and their values are valid.
18+
///
19+
/// By default, returns $true when a file is valid, and writes non-terminating
20+
/// errors describing each problem found (no output on failure beyond the errors).
21+
/// When -Quiet is specified, returns $true or $false silently.
22+
/// </summary>
23+
[Cmdlet(VerbsDiagnostic.Test, "ScriptAnalyzerSettingsFile",
24+
HelpUri = "https://github.com/PowerShell/PSScriptAnalyzer")]
25+
[OutputType(typeof(bool))]
26+
public class TestScriptAnalyzerSettingsFileCommand : PSCmdlet, IOutputWriter
27+
{
28+
#region Parameters
29+
30+
/// <summary>
31+
/// The path to the settings file to validate.
32+
/// </summary>
33+
[Parameter(Mandatory = true, Position = 0)]
34+
[ValidateNotNullOrEmpty]
35+
public string Path { get; set; }
36+
37+
/// <summary>
38+
/// When specified, returns only $true or $false without writing
39+
/// errors or warnings. Without this switch the cmdlet writes
40+
/// non-terminating errors for every problem found.
41+
/// </summary>
42+
[Parameter(Mandatory = false)]
43+
public SwitchParameter Quiet { get; set; }
44+
45+
/// <summary>
46+
/// Paths to custom rule modules.
47+
/// When specified, custom rule names are also treated as valid.
48+
/// </summary>
49+
[Parameter(Mandatory = false)]
50+
[ValidateNotNullOrEmpty]
51+
public string[] CustomRulePath { get; set; }
52+
53+
/// <summary>
54+
/// Search sub-folders under the custom rule path.
55+
/// </summary>
56+
[Parameter(Mandatory = false)]
57+
public SwitchParameter RecurseCustomRulePath { get; set; }
58+
59+
#endregion Parameters
60+
61+
#region Overrides
62+
63+
/// <summary>
64+
/// BeginProcessing: Initialise the analyser engine.
65+
/// </summary>
66+
protected override void BeginProcessing()
67+
{
68+
Helper.Instance = new Helper(SessionState.InvokeCommand);
69+
Helper.Instance.Initialize();
70+
71+
string[] rulePaths = Helper.ProcessCustomRulePaths(
72+
CustomRulePath, SessionState, RecurseCustomRulePath);
73+
ScriptAnalyzer.Instance.Initialize(this, rulePaths, null, null, null, rulePaths == null);
74+
}
75+
76+
/// <summary>
77+
/// ProcessRecord: Parse and validate the settings file.
78+
/// </summary>
79+
protected override void ProcessRecord()
80+
{
81+
string resolvedPath = GetUnresolvedProviderPathFromPSPath(Path);
82+
83+
if (!File.Exists(resolvedPath))
84+
{
85+
var error = new ErrorRecord(
86+
new FileNotFoundException(string.Format(
87+
CultureInfo.CurrentCulture,
88+
Strings.SettingsFileNotFound,
89+
resolvedPath)),
90+
"SettingsFileNotFound",
91+
ErrorCategory.ObjectNotFound,
92+
resolvedPath);
93+
94+
if (Quiet)
95+
{
96+
WriteObject(false);
97+
}
98+
else
99+
{
100+
WriteError(error);
101+
}
102+
103+
return;
104+
}
105+
106+
// Attempt to parse the settings file.
107+
Settings parsed;
108+
try
109+
{
110+
parsed = new Settings(resolvedPath);
111+
}
112+
catch (Exception ex)
113+
{
114+
ReportProblem(string.Format(
115+
CultureInfo.CurrentCulture,
116+
Strings.SettingsFileParseError,
117+
ex.Message),
118+
"SettingsFileParseError",
119+
ErrorCategory.ParserError,
120+
resolvedPath);
121+
return;
122+
}
123+
124+
bool isValid = true;
125+
126+
// Build a set of known rule names from the engine.
127+
string[] modNames = ScriptAnalyzer.Instance.GetValidModulePaths();
128+
IEnumerable<IRule> knownRules = ScriptAnalyzer.Instance.GetRule(modNames, null)
129+
?? Enumerable.Empty<IRule>();
130+
131+
var ruleMap = new Dictionary<string, IRule>(StringComparer.OrdinalIgnoreCase);
132+
foreach (IRule rule in knownRules)
133+
{
134+
ruleMap[rule.GetName()] = rule;
135+
}
136+
137+
// Validate IncludeRules.
138+
isValid &= ValidateRuleNames(parsed.IncludeRules, ruleMap, "IncludeRules");
139+
140+
// Validate ExcludeRules.
141+
isValid &= ValidateRuleNames(parsed.ExcludeRules, ruleMap, "ExcludeRules");
142+
143+
// Validate Severity values.
144+
isValid &= ValidateSeverities(parsed.Severities);
145+
146+
// Validate rule arguments.
147+
if (parsed.RuleArguments != null)
148+
{
149+
foreach (var ruleEntry in parsed.RuleArguments)
150+
{
151+
string ruleName = ruleEntry.Key;
152+
153+
if (!ruleMap.TryGetValue(ruleName, out IRule rule))
154+
{
155+
ReportProblem(
156+
string.Format(CultureInfo.CurrentCulture,
157+
Strings.SettingsFileRuleArgRuleNotFound, ruleName),
158+
"RuleNotFound",
159+
ErrorCategory.ObjectNotFound,
160+
ruleName);
161+
isValid = false;
162+
continue;
163+
}
164+
165+
if (!(rule is ConfigurableRule))
166+
{
167+
ReportProblem(
168+
string.Format(CultureInfo.CurrentCulture,
169+
Strings.SettingsFileRuleNotConfigurable, ruleName),
170+
"RuleNotConfigurable",
171+
ErrorCategory.InvalidArgument,
172+
ruleName);
173+
isValid = false;
174+
continue;
175+
}
176+
177+
var optionInfos = RuleOptionInfo.GetRuleOptions(rule);
178+
var optionMap = new Dictionary<string, RuleOptionInfo>(StringComparer.OrdinalIgnoreCase);
179+
foreach (var opt in optionInfos)
180+
{
181+
optionMap[opt.Name] = opt;
182+
}
183+
184+
foreach (var arg in ruleEntry.Value)
185+
{
186+
string argName = arg.Key;
187+
188+
if (!optionMap.TryGetValue(argName, out RuleOptionInfo optionInfo))
189+
{
190+
ReportProblem(
191+
string.Format(CultureInfo.CurrentCulture,
192+
Strings.SettingsFileUnrecognisedOption, ruleName, argName),
193+
"UnrecognisedRuleOption",
194+
ErrorCategory.InvalidArgument,
195+
argName);
196+
isValid = false;
197+
continue;
198+
}
199+
200+
// Validate possible values for constrained options.
201+
if (optionInfo.PossibleValues != null
202+
&& optionInfo.PossibleValues.Length > 0
203+
&& arg.Value is string strValue)
204+
{
205+
bool valueValid = optionInfo.PossibleValues.Any(pv =>
206+
string.Equals(pv.ToString(), strValue, StringComparison.OrdinalIgnoreCase));
207+
208+
if (!valueValid)
209+
{
210+
ReportProblem(
211+
string.Format(CultureInfo.CurrentCulture,
212+
Strings.SettingsFileInvalidOptionValue,
213+
ruleName, argName, strValue,
214+
string.Join(", ", optionInfo.PossibleValues.Select(v => v.ToString()))),
215+
"InvalidRuleOptionValue",
216+
ErrorCategory.InvalidArgument,
217+
strValue);
218+
isValid = false;
219+
}
220+
}
221+
}
222+
}
223+
}
224+
225+
if (Quiet)
226+
{
227+
WriteObject(isValid);
228+
}
229+
else if (isValid)
230+
{
231+
WriteObject(true);
232+
}
233+
}
234+
235+
#endregion Overrides
236+
237+
#region Helpers
238+
239+
/// <summary>
240+
/// Reports a validation problem. In quiet mode the problem is silently
241+
/// recorded; otherwise a non-terminating error is written.
242+
/// </summary>
243+
private void ReportProblem(string message, string errorId, ErrorCategory category, object target)
244+
{
245+
if (!Quiet)
246+
{
247+
WriteError(new ErrorRecord(
248+
new InvalidOperationException(message),
249+
errorId,
250+
category,
251+
target));
252+
}
253+
}
254+
255+
/// <summary>
256+
/// Validates that rule names from a settings field exist in the known rule set.
257+
/// Entries containing wildcard characters are skipped as they are pattern-matched
258+
/// at runtime.
259+
/// </summary>
260+
private bool ValidateRuleNames(
261+
IEnumerable<string> ruleNames,
262+
Dictionary<string, IRule> ruleMap,
263+
string fieldName)
264+
{
265+
bool valid = true;
266+
if (ruleNames == null)
267+
{
268+
return valid;
269+
}
270+
271+
foreach (string name in ruleNames)
272+
{
273+
// Skip wildcard patterns such as PSDSC* - these are resolved at runtime.
274+
if (WildcardPattern.ContainsWildcardCharacters(name))
275+
{
276+
continue;
277+
}
278+
279+
if (!ruleMap.ContainsKey(name))
280+
{
281+
ReportProblem(
282+
string.Format(CultureInfo.CurrentCulture,
283+
Strings.SettingsFileRuleNotFound, fieldName, name),
284+
"RuleNotFound",
285+
ErrorCategory.ObjectNotFound,
286+
name);
287+
valid = false;
288+
}
289+
}
290+
291+
return valid;
292+
}
293+
294+
/// <summary>
295+
/// Validates severity values against the RuleSeverity enum.
296+
/// </summary>
297+
private bool ValidateSeverities(IEnumerable<string> severities)
298+
{
299+
bool valid = true;
300+
if (severities == null)
301+
{
302+
return valid;
303+
}
304+
305+
foreach (string sev in severities)
306+
{
307+
if (!Enum.TryParse<RuleSeverity>(sev, ignoreCase: true, out _))
308+
{
309+
ReportProblem(
310+
string.Format(CultureInfo.CurrentCulture,
311+
Strings.SettingsFileInvalidSeverity,
312+
sev,
313+
string.Join(", ", Enum.GetNames(typeof(RuleSeverity)))),
314+
"InvalidSeverity",
315+
ErrorCategory.InvalidArgument,
316+
sev);
317+
valid = false;
318+
}
319+
}
320+
321+
return valid;
322+
}
323+
324+
#endregion Helpers
325+
}
326+
}

Engine/PSScriptAnalyzer.psd1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ FormatsToProcess = @('ScriptAnalyzer.format.ps1xml')
6565
FunctionsToExport = @()
6666

6767
# Cmdlets to export from this module
68-
CmdletsToExport = @('Get-ScriptAnalyzerRule', 'Invoke-ScriptAnalyzer', 'Invoke-Formatter')
68+
CmdletsToExport = @('Get-ScriptAnalyzerRule', 'Invoke-ScriptAnalyzer', 'Invoke-Formatter', 'New-ScriptAnalyzerSettingsFile', 'Test-ScriptAnalyzerSettingsFile')
6969

7070
# Variables to export from this module
7171
VariablesToExport = @()

Engine/PSScriptAnalyzer.psm1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ if (Get-Command Register-ArgumentCompleter -ErrorAction Ignore) {
4949

5050
}
5151

52+
Register-ArgumentCompleter -CommandName 'New-ScriptAnalyzerSettingsFile' `
53+
-ParameterName 'BaseOnPreset' `
54+
-ScriptBlock $settingPresetCompleter
55+
5256
Function RuleNameCompleter {
5357
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParmeter)
5458

Engine/Strings.resx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,37 @@
324324
<data name="TypeNotFoundParseErrorFound" xml:space="preserve">
325325
<value>Ignoring 'TypeNotFound' parse error on type '{0}'. Check if the specified type is correct. This can also be due the type not being known at parse time due to types imported by 'using' statements.</value>
326326
</data>
327+
<data name="InvalidPresetName" xml:space="preserve">
328+
<value>'{0}' is not a recognised preset. Valid presets are: {1}</value>
329+
</data>
330+
<data name="PresetNotFound" xml:space="preserve">
331+
<value>Could not locate the preset '{0}'.</value>
332+
</data>
333+
<data name="SettingsFileAlreadyExists" xml:space="preserve">
334+
<value>A settings file already exists at '{0}'. Use -Force to overwrite.</value>
335+
</data>
336+
<data name="SettingsFileNotFound" xml:space="preserve">
337+
<value>The settings file '{0}' does not exist.</value>
338+
</data>
339+
<data name="SettingsFileParseError" xml:space="preserve">
340+
<value>Failed to parse settings file: {0}</value>
341+
</data>
342+
<data name="SettingsFileRuleNotFound" xml:space="preserve">
343+
<value>{0}: rule '{1}' not found.</value>
344+
</data>
345+
<data name="SettingsFileRuleArgRuleNotFound" xml:space="preserve">
346+
<value>Rules.{0}: rule not found.</value>
347+
</data>
348+
<data name="SettingsFileRuleNotConfigurable" xml:space="preserve">
349+
<value>Rules.{0}: this rule is not configurable.</value>
350+
</data>
351+
<data name="SettingsFileUnrecognisedOption" xml:space="preserve">
352+
<value>Rules.{0}.{1}: unrecognised option.</value>
353+
</data>
354+
<data name="SettingsFileInvalidOptionValue" xml:space="preserve">
355+
<value>Rules.{0}.{1}: '{2}' is not a valid value. Expected one of: {3}</value>
356+
</data>
357+
<data name="SettingsFileInvalidSeverity" xml:space="preserve">
358+
<value>Severity: '{0}' is not a valid severity. Expected one of: {1}</value>
359+
</data>
327360
</root>

0 commit comments

Comments
 (0)