Skip to content

Commit 2056fe0

Browse files
author
Kapil Borle
authored
Merge pull request #817 from bergmeister/AutoFixWarnings
Add Fix switch parameter for 'File' parameter set to auto-correct warnings (#802)
2 parents e7e01b9 + e12c4a6 commit 2056fe0

9 files changed

+183
-31
lines changed

Engine/Commands/InvokeScriptAnalyzerCommand.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands
3838
[Cmdlet(VerbsLifecycle.Invoke,
3939
"ScriptAnalyzer",
4040
DefaultParameterSetName = "File",
41+
SupportsShouldProcess = true,
4142
HelpUri = "http://go.microsoft.com/fwlink/?LinkId=525914")]
4243
public class InvokeScriptAnalyzerCommand : PSCmdlet, IOutputWriter
4344
{
@@ -180,6 +181,17 @@ public SwitchParameter SuppressedOnly
180181
}
181182
private bool suppressedOnly;
182183

184+
/// <summary>
185+
/// Resolves rule violations automatically where possible.
186+
/// </summary>
187+
[Parameter(Mandatory = false, ParameterSetName = "File")]
188+
public SwitchParameter Fix
189+
{
190+
get { return fix; }
191+
set { fix = value; }
192+
}
193+
private bool fix;
194+
183195
/// <summary>
184196
/// Returns path to the file that contains user profile or hash table for ScriptAnalyzer
185197
/// </summary>
@@ -377,7 +389,16 @@ private void ProcessInput()
377389
{
378390
foreach (var p in processedPaths)
379391
{
380-
diagnosticsList = ScriptAnalyzer.Instance.AnalyzePath(p, this.recurse);
392+
if (fix)
393+
{
394+
ShouldProcess(p, $"Analyzing and fixing path with Recurse={this.recurse}");
395+
diagnosticsList = ScriptAnalyzer.Instance.AnalyzeAndFixPath(p, this.ShouldProcess, this.recurse);
396+
}
397+
else
398+
{
399+
ShouldProcess(p, $"Analyzing path with Recurse={this.recurse}");
400+
diagnosticsList = ScriptAnalyzer.Instance.AnalyzePath(p, this.ShouldProcess, this.recurse);
401+
}
381402
WriteToOutput(diagnosticsList);
382403
}
383404
}

Engine/Formatter.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ public static string Format<TCmdlet>(
5353
ScriptAnalyzer.Instance.Initialize(cmdlet, null, null, null, null, true, false);
5454

5555
Range updatedRange;
56-
text = ScriptAnalyzer.Instance.Fix(text, range, out updatedRange);
56+
bool fixesWereApplied;
57+
text = ScriptAnalyzer.Instance.Fix(text, range, out updatedRange, out fixesWereApplied);
5758
range = updatedRange;
5859
}
5960

Engine/ScriptAnalyzer.cs

Lines changed: 85 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
using System.Collections.ObjectModel;
3232
using System.Collections;
3333
using System.Diagnostics;
34+
using System.Text;
3435

3536
namespace Microsoft.Windows.PowerShell.ScriptAnalyzer
3637
{
@@ -1443,43 +1444,67 @@ public Dictionary<string, List<string>> CheckRuleExtension(string[] path, PathIn
14431444
return results;
14441445
}
14451446

1446-
#endregion
1447+
#endregion
14471448

14481449

14491450
/// <summary>
14501451
/// Analyzes a script file or a directory containing script files.
14511452
/// </summary>
14521453
/// <param name="path">The path of the file or directory to analyze.</param>
1454+
/// <param name="shouldProcess">Whether the action should be executed.</param>
14531455
/// <param name="searchRecursively">
14541456
/// If true, recursively searches the given file path and analyzes any
14551457
/// script files that are found.
14561458
/// </param>
14571459
/// <returns>An enumeration of DiagnosticRecords that were found by rules.</returns>
1458-
public IEnumerable<DiagnosticRecord> AnalyzePath(string path, bool searchRecursively = false)
1460+
public IEnumerable<DiagnosticRecord> AnalyzePath(string path, Func<string, string, bool> shouldProcess, bool searchRecursively = false)
14591461
{
1460-
List<string> scriptFilePaths = new List<string>();
1462+
List<string> scriptFilePaths = ScriptPathList(path, searchRecursively);
14611463

1462-
if (path == null)
1464+
foreach (string scriptFilePath in scriptFilePaths)
14631465
{
1464-
this.outputWriter.ThrowTerminatingError(
1465-
new ErrorRecord(
1466-
new FileNotFoundException(),
1467-
string.Format(CultureInfo.CurrentCulture, Strings.FileNotFound, path),
1468-
ErrorCategory.InvalidArgument,
1469-
this));
1466+
if (shouldProcess(scriptFilePath, $"Analyzing file {scriptFilePath}"))
1467+
{
1468+
// Yield each record in the result so that the caller can pull them one at a time
1469+
foreach (var diagnosticRecord in this.AnalyzeFile(scriptFilePath))
1470+
{
1471+
yield return diagnosticRecord;
1472+
}
1473+
}
14701474
}
1475+
}
1476+
1477+
/// <summary>
1478+
/// Analyzes a script file or a directory containing script files and fixes warning where possible.
1479+
/// </summary>
1480+
/// <param name="path">The path of the file or directory to analyze.</param>
1481+
/// <param name="shouldProcess">Whether the action should be executed.</param>
1482+
/// <param name="searchRecursively">
1483+
/// If true, recursively searches the given file path and analyzes any
1484+
/// script files that are found.
1485+
/// </param>
1486+
/// <returns>An enumeration of DiagnosticRecords that were found by rules and could not be fixed automatically.</returns>
1487+
public IEnumerable<DiagnosticRecord> AnalyzeAndFixPath(string path, Func<string, string, bool> shouldProcess, bool searchRecursively = false)
1488+
{
1489+
List<string> scriptFilePaths = ScriptPathList(path, searchRecursively);
14711490

1472-
// Create in advance the list of script file paths to analyze. This
1473-
// is an optimization over doing the whole operation at once
1474-
// and calling .Concat on IEnumerables to join results.
1475-
this.BuildScriptPathList(path, searchRecursively, scriptFilePaths);
14761491
foreach (string scriptFilePath in scriptFilePaths)
14771492
{
1478-
// Yield each record in the result so that the
1479-
// caller can pull them one at a time
1480-
foreach (var diagnosticRecord in this.AnalyzeFile(scriptFilePath))
1493+
if (shouldProcess(scriptFilePath, $"Analyzing and fixing file {scriptFilePath}"))
14811494
{
1482-
yield return diagnosticRecord;
1495+
var fileEncoding = GetFileEncoding(scriptFilePath);
1496+
bool fixesWereApplied;
1497+
var scriptFileContentWithFixes = Fix(File.ReadAllText(scriptFilePath, fileEncoding), out fixesWereApplied);
1498+
if (fixesWereApplied)
1499+
{
1500+
File.WriteAllText(scriptFilePath, scriptFileContentWithFixes, fileEncoding);
1501+
}
1502+
1503+
// Yield each record in the result so that the caller can pull them one at a time
1504+
foreach (var diagnosticRecord in this.AnalyzeFile(scriptFilePath))
1505+
{
1506+
yield return diagnosticRecord;
1507+
}
14831508
}
14841509
}
14851510
}
@@ -1531,16 +1556,17 @@ public IEnumerable<DiagnosticRecord> AnalyzeScriptDefinition(string scriptDefini
15311556
/// Fix the violations in the given script text.
15321557
/// </summary>
15331558
/// <param name="scriptDefinition">The script text to be fixed.</param>
1559+
/// <param name="updatedRange">Whether any warnings were fixed.</param>
15341560
/// <returns>The fixed script text.</returns>
1535-
public string Fix(string scriptDefinition)
1561+
public string Fix(string scriptDefinition, out bool fixesWereApplied)
15361562
{
15371563
if (scriptDefinition == null)
15381564
{
15391565
throw new ArgumentNullException(nameof(scriptDefinition));
15401566
}
15411567

15421568
Range updatedRange;
1543-
return Fix(new EditableText(scriptDefinition), null, out updatedRange).ToString();
1569+
return Fix(new EditableText(scriptDefinition), null, out updatedRange, out fixesWereApplied).ToString();
15441570
}
15451571

15461572
/// <summary>
@@ -1549,8 +1575,9 @@ public string Fix(string scriptDefinition)
15491575
/// <param name="text">An object of type `EditableText` that encapsulates the script text to be fixed.</param>
15501576
/// <param name="range">The range in which the fixes are allowed.</param>
15511577
/// <param name="updatedRange">The updated range after the fixes have been applied.</param>
1578+
/// <param name="updatedRange">Whether any warnings were fixed.</param>
15521579
/// <returns>The same instance of `EditableText` that was passed to the method, but the instance encapsulates the fixed script text. This helps in chaining the Fix method.</returns>
1553-
public EditableText Fix(EditableText text, Range range, out Range updatedRange)
1580+
public EditableText Fix(EditableText text, Range range, out Range updatedRange, out bool fixesWereApplied)
15541581
{
15551582
if (text == null)
15561583
{
@@ -1583,7 +1610,9 @@ public EditableText Fix(EditableText text, Range range, out Range updatedRange)
15831610
this.outputWriter.WriteVerbose($"Found {corrections.Count} violations.");
15841611
int unusedCorrections;
15851612
Fix(text, corrections, out unusedCorrections);
1586-
this.outputWriter.WriteVerbose($"Fixed {corrections.Count - unusedCorrections} violations.");
1613+
var numberOfFixedViolatons = corrections.Count - unusedCorrections;
1614+
fixesWereApplied = numberOfFixedViolatons > 0;
1615+
this.outputWriter.WriteVerbose($"Fixed {numberOfFixedViolatons} violations.");
15871616

15881617
// This is an indication of an infinite loop. There is a small chance of this.
15891618
// It is better to abort the fixing operation at this point.
@@ -1613,6 +1642,18 @@ public EditableText Fix(EditableText text, Range range, out Range updatedRange)
16131642
return text;
16141643
}
16151644

1645+
private static Encoding GetFileEncoding(string path)
1646+
{
1647+
using (var stream = new FileStream(path, FileMode.Open))
1648+
{
1649+
using (var reader = new StreamReader(stream))
1650+
{
1651+
reader.Peek(); // needed in order to populate the CurrentEncoding property
1652+
return reader.CurrentEncoding;
1653+
}
1654+
}
1655+
}
1656+
16161657
private static Range SnapToEdges(EditableText text, Range range)
16171658
{
16181659
// todo add TextLines.Validate(range) and TextLines.Validate(position)
@@ -1655,6 +1696,28 @@ private static EditableText Fix(
16551696
return text;
16561697
}
16571698

1699+
private List<string> ScriptPathList(string path, bool searchRecursively)
1700+
{
1701+
List<string> scriptFilePaths = new List<string>();
1702+
1703+
if (path == null)
1704+
{
1705+
this.outputWriter.ThrowTerminatingError(
1706+
new ErrorRecord(
1707+
new FileNotFoundException(),
1708+
string.Format(CultureInfo.CurrentCulture, Strings.FileNotFound, path),
1709+
ErrorCategory.InvalidArgument,
1710+
this));
1711+
}
1712+
1713+
// Create in advance the list of script file paths to analyze. This
1714+
// is an optimization over doing the whole operation at once
1715+
// and calling .Concat on IEnumerables to join results.
1716+
this.BuildScriptPathList(path, searchRecursively, scriptFilePaths);
1717+
1718+
return scriptFilePaths;
1719+
}
1720+
16581721
private void BuildScriptPathList(
16591722
string path,
16601723
bool searchRecursively,

Tests/Engine/InvokeScriptAnalyzer.tests.ps1

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,35 @@ Describe "Test CustomizedRulePath" {
465465
}
466466
}
467467
}
468+
469+
Describe "Test -Fix Switch" {
470+
471+
BeforeEach {
472+
$initialTestScript = Get-Content $directory\TestScriptWithFixableWarnings.ps1 -Raw
473+
}
474+
475+
AfterEach {
476+
if ($null -ne $initialTestScript)
477+
{
478+
[System.IO.File]::WriteAllText("$($directory)\TestScriptWithFixableWarnings.ps1", $initialTestScript) # Set-Content -NoNewline was only introduced in PS v5 and would therefore not work in CI
479+
}
480+
}
481+
482+
It "Fixes warnings" {
483+
# we expect the script to contain warnings
484+
$warningsBeforeFix = Invoke-ScriptAnalyzer $directory\TestScriptWithFixableWarnings.ps1
485+
$warningsBeforeFix.Count | Should Be 5
486+
487+
# fix the warnings and expect that it should not return the fixed warnings
488+
$warningsWithFixSwitch = Invoke-ScriptAnalyzer $directory\TestScriptWithFixableWarnings.ps1 -Fix
489+
$warningsWithFixSwitch.Count | Should Be 0
490+
491+
# double check that the warnings are really fixed
492+
$warningsAfterFix = Invoke-ScriptAnalyzer $directory\TestScriptWithFixableWarnings.ps1
493+
$warningsAfterFix.Count | Should Be 0
494+
495+
$expectedScriptContentAfterFix = Get-Content $directory\TestScriptWithFixableWarnings_AfterFix.ps1 -Raw
496+
$actualScriptContentAfterFix = Get-Content $directory\TestScriptWithFixableWarnings.ps1 -Raw
497+
$actualScriptContentAfterFix | Should Be $expectedScriptContentAfterFix
498+
}
499+
}

Tests/Engine/LibraryUsage.tests.ps1

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ $directory = Split-Path -Parent $MyInvocation.MyCommand.Path
1515
# wraps the usage of ScriptAnalyzer as a .NET library
1616
function Invoke-ScriptAnalyzer {
1717
param (
18-
[CmdletBinding(DefaultParameterSetName="File")]
18+
[CmdletBinding(DefaultParameterSetName="File", SupportsShouldProcess = $true)]
1919

2020
[parameter(Mandatory = $true, Position = 0, ParameterSetName="File")]
2121
[Alias("PSPath")]
@@ -48,7 +48,10 @@ function Invoke-ScriptAnalyzer {
4848
[switch] $IncludeDefaultRules,
4949

5050
[Parameter(Mandatory = $false)]
51-
[switch] $SuppressedOnly
51+
[switch] $SuppressedOnly,
52+
53+
[Parameter(Mandatory = $false)]
54+
[switch] $Fix
5255
)
5356

5457
if ($null -eq $CustomRulePath)
@@ -75,7 +78,12 @@ function Invoke-ScriptAnalyzer {
7578
);
7679

7780
if ($PSCmdlet.ParameterSetName -eq "File") {
78-
return $scriptAnalyzer.AnalyzePath($Path, $Recurse.IsPresent);
81+
$supportsShouldProcessFunc = [Func[string, string, bool]]{ return $PSCmdlet.Shouldprocess }
82+
if ($Fix.IsPresent)
83+
{
84+
return $scriptAnalyzer.AnalyzeAndFixPath($Path, $supportsShouldProcessFunc, $Recurse.IsPresent);
85+
}
86+
return $scriptAnalyzer.AnalyzePath($Path, $supportsShouldProcessFunc, $Recurse.IsPresent);
7987
}
8088
else {
8189
return $scriptAnalyzer.AnalyzeScriptDefinition($ScriptDefinition);

Tests/Engine/ModuleHelp.Tests.ps1

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ function Get-ParametersDefaultFirst {
121121
)
122122

123123
BEGIN {
124-
$Common = 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable'
124+
$Common = 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable', 'WhatIf', 'Confirm'
125125
$parameters = @()
126126
}
127127
PROCESS {
@@ -266,7 +266,7 @@ foreach ($command in $commands) {
266266
Context "Test parameter help for $commandName" {
267267

268268
$Common = 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'OutBuffer', 'OutVariable',
269-
'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable'
269+
'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable', 'WhatIf', 'Confirm'
270270

271271
# Get parameters. When >1 parameter with same name,
272272
# get parameter from the default parameter set, if any.
@@ -315,4 +315,4 @@ foreach ($command in $commands) {
315315
}
316316
}
317317
}
318-
}
318+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Produce PSAvoidUsingCmdletAliases and PSAvoidTrailingWhitespace warnings that should get fixed by replacing it with the actual command
2+
gci . | % { } | ? { }
3+
4+
# Produces PSAvoidUsingPlainTextForPassword warning that should get fixed by making it a [SecureString]
5+
function Test-bar([string]$PasswordInPlainText){}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Produce PSAvoidUsingCmdletAliases and PSAvoidTrailingWhitespace warnings that should get fixed by replacing it with the actual command
2+
Get-ChildItem . | ForEach-Object { } | Where-Object { }
3+
4+
# Produces PSAvoidUsingPlainTextForPassword warning that should get fixed by making it a [SecureString]
5+
function Test-bar([SecureString]$PasswordInPlainText){}

docs/markdown/Invoke-ScriptAnalyzer.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Evaluates a script or module based on selected best practice rules
1212
### UNNAMED_PARAMETER_SET_1
1313
```
1414
Invoke-ScriptAnalyzer [-Path] <String> [-CustomRulePath <String>] [-RecurseCustomRulePath]
15-
[-ExcludeRule <String[]>] [-IncludeRule <String[]>] [-Severity <String[]>] [-Recurse] [-SuppressedOnly]
15+
[-ExcludeRule <String[]>] [-IncludeRule <String[]>] [-Severity <String[]>] [-Recurse] [-SuppressedOnly] [-Fix]
1616
[-Settings <String>]
1717
```
1818

@@ -399,6 +399,23 @@ Accept pipeline input: False
399399
Accept wildcard characters: False
400400
```
401401
402+
### -Fix
403+
Fixes certain warnings which contain a fix in their DiagnosticRecord.
404+
405+
When you used Fix, Invoke-ScriptAnalyzer runs as usual but will apply the fixes before running the analysis. Please make sure that you have a backup of your files when using this switch. It tries to preserve the file encoding but there are still some cases where the encoding can change.
406+
407+
```yaml
408+
Type: SwitchParameter
409+
Parameter Sets: UNNAMED_PARAMETER_SET_1
410+
Aliases:
411+
412+
Required: False
413+
Position: Named
414+
Default value: False
415+
Accept pipeline input: False
416+
Accept wildcard characters: False
417+
```
418+
402419
### -Settings
403420
File path that contains user profile or hash table for ScriptAnalyzer
404421

0 commit comments

Comments
 (0)