Skip to content

Commit c06e005

Browse files
authoredFeb 13, 2024
PSReviewUnusedParameter: Add CommandsToTraverse option (#1921)
* Added command traversal option Explicitly included Where-Object and ForEach-Object scriptblocks to also be searched for variable use * Command traversal check no longer case sensitive * Extended tests for selective command traversal * Rename setting to CommandsToTraverse * Added docs for new configuration: CommandsToTraverse
1 parent 6cb66c1 commit c06e005

File tree

3 files changed

+130
-5
lines changed

3 files changed

+130
-5
lines changed
 

‎Rules/ReviewUnusedParameter.cs

+87-4
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,60 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
2121
#endif
2222
public class ReviewUnusedParameter : IScriptRule
2323
{
24+
private readonly string TraverseArgName = "CommandsToTraverse";
25+
public List<string> TraverseCommands { get; private set; }
26+
27+
/// <summary>
28+
/// Configure the rule.
29+
///
30+
/// Sets the list of commands to traverse of this rule
31+
/// </summary>
32+
private void SetProperties()
33+
{
34+
TraverseCommands = new List<string>() { "Where-Object", "ForEach-Object" };
35+
36+
Dictionary<string, object> ruleArgs = Helper.Instance.GetRuleArguments(GetName());
37+
if (ruleArgs == null)
38+
{
39+
return;
40+
}
41+
42+
if (!ruleArgs.TryGetValue(TraverseArgName, out object obj))
43+
{
44+
return;
45+
}
46+
IEnumerable<string> commands = obj as IEnumerable<string>;
47+
if (commands == null)
48+
{
49+
// try with enumerable objects
50+
var enumerableObjs = obj as IEnumerable<object>;
51+
if (enumerableObjs == null)
52+
{
53+
return;
54+
}
55+
foreach (var x in enumerableObjs)
56+
{
57+
var y = x as string;
58+
if (y == null)
59+
{
60+
return;
61+
}
62+
else
63+
{
64+
TraverseCommands.Add(y);
65+
}
66+
}
67+
}
68+
else
69+
{
70+
TraverseCommands.AddRange(commands);
71+
}
72+
}
73+
2474
public IEnumerable<DiagnosticRecord> AnalyzeScript(Ast ast, string fileName)
2575
{
76+
SetProperties();
77+
2678
if (ast == null)
2779
{
2880
throw new ArgumentNullException(Strings.NullAstErrorMessage);
@@ -46,10 +98,7 @@ public IEnumerable<DiagnosticRecord> AnalyzeScript(Ast ast, string fileName)
4698
IEnumerable<Ast> parameterAsts = scriptBlockAst.FindAll(oneAst => oneAst is ParameterAst, false);
4799

48100
// list all variables
49-
IDictionary<string, int> variableCount = scriptBlockAst.FindAll(oneAst => oneAst is VariableExpressionAst, false)
50-
.Select(variableExpressionAst => ((VariableExpressionAst)variableExpressionAst).VariablePath.UserPath)
51-
.GroupBy(variableName => variableName, StringComparer.OrdinalIgnoreCase)
52-
.ToDictionary(variableName => variableName.Key, variableName => variableName.Count(), StringComparer.OrdinalIgnoreCase);
101+
IDictionary<string, int> variableCount = GetVariableCount(scriptBlockAst);
53102

54103
foreach (ParameterAst parameterAst in parameterAsts)
55104
{
@@ -164,5 +213,39 @@ public string GetSourceName()
164213
{
165214
return string.Format(CultureInfo.CurrentCulture, Strings.SourceName);
166215
}
216+
217+
/// <summary>
218+
/// Returns a dictionary including all variables in the scriptblock and their count
219+
/// </summary>
220+
/// <param name="ast">The scriptblock ast to scan</param>
221+
/// <param name="data">Previously generated data. New findings are added to any existing dictionary if present</param>
222+
/// <returns>a dictionary including all variables in the scriptblock and their count</returns>
223+
IDictionary<string, int> GetVariableCount(ScriptBlockAst ast, Dictionary<string, int> data = null)
224+
{
225+
Dictionary<string, int> content = data;
226+
if (null == data)
227+
content = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
228+
IDictionary<string, int> result = ast.FindAll(oneAst => oneAst is VariableExpressionAst, false)
229+
.Select(variableExpressionAst => ((VariableExpressionAst)variableExpressionAst).VariablePath.UserPath)
230+
.GroupBy(variableName => variableName, StringComparer.OrdinalIgnoreCase)
231+
.ToDictionary(variableName => variableName.Key, variableName => variableName.Count(), StringComparer.OrdinalIgnoreCase);
232+
233+
foreach (string key in result.Keys)
234+
{
235+
if (content.ContainsKey(key))
236+
content[key] = content[key] + result[key];
237+
else
238+
content[key] = result[key];
239+
}
240+
241+
IEnumerable<Ast> foundScriptBlocks = ast.FindAll(oneAst => oneAst is ScriptBlockExpressionAst, false)
242+
.Where(oneAst => oneAst?.Parent is CommandAst && ((CommandAst)oneAst.Parent).CommandElements[0] is StringConstantExpressionAst && TraverseCommands.Contains(((StringConstantExpressionAst)((CommandAst)oneAst.Parent).CommandElements[0]).Value, StringComparer.OrdinalIgnoreCase))
243+
.Select(oneAst => ((ScriptBlockExpressionAst)oneAst).ScriptBlock);
244+
foreach (Ast astItem in foundScriptBlocks)
245+
if (astItem != ast)
246+
GetVariableCount((ScriptBlockAst)astItem, content);
247+
248+
return content;
249+
}
167250
}
168251
}

‎Tests/Rules/ReviewUnusedParameter.tests.ps1

+25-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ Describe "ReviewUnusedParameter" {
3232
$Violations.Count | Should -Be 1
3333
}
3434

35+
It "doesn't traverse scriptblock scope for a random command" {
36+
$ScriptDefinition = '{ param ($Param1) 1..3 | Invoke-Parallel { $Param1 }}'
37+
$Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
38+
$Violations.Count | Should -Be 1
39+
}
40+
3541
It "violations have correct rule and severity" {
3642
$ScriptDefinition = 'function BadFunc1 { param ($Param1, $Param2) $Param1}'
3743
$Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
@@ -81,6 +87,24 @@ Describe "ReviewUnusedParameter" {
8187
$ScriptDefinition = 'function foo { param ($Param1, $param2) $param1; $Param2}'
8288
$Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
8389
$Violations.Count | Should -Be 0
90+
}
91+
92+
It "does traverse scriptblock scope for Foreach-Object" {
93+
$ScriptDefinition = '{ param ($Param1) 1..3 | ForEach-Object { $Param1 }}'
94+
$Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
95+
$Violations.Count | Should -Be 0
96+
}
97+
98+
It "does traverse scriptblock scope for commands added to the traversal list" {
99+
$ScriptDefinition = '{ param ($Param1) Invoke-PSFProtectedCommand { $Param1 } }'
100+
$Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName -Settings @{
101+
Rules = @{
102+
PSReviewUnusedParameter = @{
103+
CommandsToTraverse = @('Invoke-PSFProtectedCommand')
104+
}
105+
}
106+
}
107+
$Violations.Count | Should -Be 0
84108
}
85109
}
86-
}
110+
}

‎docs/Rules/ReviewUnusedParameter.md

+18
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ title: ReviewUnusedParameter
1414
This rule identifies parameters declared in a script, scriptblock, or function scope that have not
1515
been used in that scope.
1616

17+
## Configuration settings
18+
19+
|Configuration key|Meaning|Accepted values|Mandatory|Example|
20+
|---|---|---|---|---|
21+
|CommandsToTraverse|By default, this command will not consider child scopes other than scriptblocks provided to Where-Object or ForEach-Object. This setting allows you to add additional commands that accept scriptblocks that this rule should traverse into.|string[]: list of commands whose scriptblock to traverse.|`@('Invoke-PSFProtectedCommand')`|
22+
23+
```powershell
24+
@{
25+
Rules = @{
26+
ReviewUnusedParameter = @{
27+
CommandsToTraverse = @(
28+
'Invoke-PSFProtectedCommand'
29+
)
30+
}
31+
}
32+
}
33+
```
34+
1735
## How
1836

1937
Consider removing the unused parameter.

0 commit comments

Comments
 (0)
Please sign in to comment.