Skip to content

Commit 940024a

Browse files
Prevent duplicate rule ID suppression errors in -Fix and DSC scenarios (#2181)
* Enhance suppression error handling in ScriptAnalyzer and add tests for unapplied suppression errors * Skip new -Fix suppression test under Library Usage harness The `Library Usage` describe block in `LibraryUsage.tests.ps1` (only active on Windows PowerShell 5.1, since it's gated `-Skip:$IsCoreCLR`) re-runs `RuleSuppression.tests.ps1` against a hand-rolled `Invoke-ScriptAnalyzer` wrapper that drives the analyzer as a .NET library. That wrapper plugs in `PesterTestOutputWriter`, whose `WriteError` is intentionally a no-op: public void WriteError(ErrorRecord error) { // We don't write errors to avoid misleading // error messages in test output } So the unapplied-suppression `ErrorRecord` we now emit during the final `-Fix` pass never reaches `-ErrorVariable`, and `$fixErr | Should -HaveCount 1` fails with "Expected a collection with size 1, but got an empty collection". The behaviour itself is correct - the assertion is just unobservable through this test harness. Mark the new `It` block `-Skip:$testingLibraryUsage`, matching the existing pattern already used by the `Bad Rule Suppression` and `External Rule Suppression` contexts in the same file for the same reason. The regular pwsh and WinPS runs of `RuleSuppression.tests.ps1` (which `[+]` in the failing CI log) continue to exercise the assertion. The new `UseDSCResourceFunctions.tests.ps1` test isn't dot-sourced by `LibraryUsage.tests.ps1`, so it doesn't need the same guard. Drafted by Copilot (Claude Opus 4.7). * Add copilot review suggestions --------- Co-authored-by: Andy Jordan <2226434+andyleejordan@users.noreply.github.com>
1 parent d97ddd4 commit 940024a

3 files changed

Lines changed: 113 additions & 15 deletions

File tree

Engine/ScriptAnalyzer.cs

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1447,10 +1447,24 @@ public IEnumerable<DiagnosticRecord> AnalyzeAndFixPath(string path, Func<string,
14471447
/// </summary>
14481448
/// <param name="scriptDefinition">The script to be analyzed.</param>
14491449
/// <param name="scriptAst">Parsed AST of <paramref name="scriptDefinition"/>.</param>
1450-
/// <param name="scriptTokens">Parsed tokens of <paramref name="scriptDefinition"/.></param>
1450+
/// <param name="scriptTokens">Parsed tokens of <paramref name="scriptDefinition"/>.</param>
14511451
/// <param name="skipVariableAnalysis">Whether variable analysis can be skipped (applicable if rules do not use variable analysis APIs).</param>
14521452
/// <returns></returns>
14531453
public List<DiagnosticRecord> AnalyzeScriptDefinition(string scriptDefinition, out ScriptBlockAst scriptAst, out Token[] scriptTokens, bool skipVariableAnalysis = false)
1454+
{
1455+
return AnalyzeScriptDefinition(scriptDefinition, out scriptAst, out scriptTokens, skipVariableAnalysis, emitSuppressionErrors: true);
1456+
}
1457+
1458+
/// <summary>
1459+
/// Analyzes a script definition in the form of a string input.
1460+
/// </summary>
1461+
/// <param name="scriptDefinition">The script to be analysed.</param>
1462+
/// <param name="scriptAst">Parsed AST of <paramref name="scriptDefinition"/>.</param>
1463+
/// <param name="scriptTokens">Parsed tokens of <paramref name="scriptDefinition"/>.</param>
1464+
/// <param name="skipVariableAnalysis">Whether variable analysis can be skipped (applicable if rules do not use variable analysis APIs).</param>
1465+
/// <param name="emitSuppressionErrors">Whether to emit errors for unapplied rule suppression IDs.</param>
1466+
/// <returns>A list of diagnostics found by rules.</returns>
1467+
public List<DiagnosticRecord> AnalyzeScriptDefinition(string scriptDefinition, out ScriptBlockAst scriptAst, out Token[] scriptTokens, bool skipVariableAnalysis, bool emitSuppressionErrors)
14541468
{
14551469
scriptAst = null;
14561470
scriptTokens = null;
@@ -1490,7 +1504,7 @@ public List<DiagnosticRecord> AnalyzeScriptDefinition(string scriptDefinition, o
14901504
}
14911505

14921506
// now, analyze the script definition
1493-
diagnosticRecords.AddRange(this.AnalyzeSyntaxTree(scriptAst, scriptTokens, null, skipVariableAnalysis));
1507+
diagnosticRecords.AddRange(this.AnalyzeSyntaxTree(scriptAst, scriptTokens, null, skipVariableAnalysis, emitSuppressionErrors));
14941508
return diagnosticRecords;
14951509
}
14961510

@@ -1549,11 +1563,11 @@ public EditableText Fix(EditableText text, Range range, bool skipParsing, out Ra
15491563
IEnumerable<DiagnosticRecord> records;
15501564
if (skipParsing && previousUnusedCorrections == 0)
15511565
{
1552-
records = AnalyzeSyntaxTree(scriptAst, scriptTokens, String.Empty, skipVariableAnalysis);
1566+
records = AnalyzeSyntaxTree(scriptAst, scriptTokens, String.Empty, skipVariableAnalysis, emitSuppressionErrors: false);
15531567
}
15541568
else
15551569
{
1556-
records = AnalyzeScriptDefinition(text.ToString(), out scriptAst, out scriptTokens, skipVariableAnalysis);
1570+
records = AnalyzeScriptDefinition(text.ToString(), out scriptAst, out scriptTokens, skipVariableAnalysis, emitSuppressionErrors: false);
15571571
}
15581572
var corrections = records
15591573
.Select(r => r.SuggestedCorrections)
@@ -1986,17 +2000,21 @@ bool IsRuleAllowed(IRule rule)
19862000
private Tuple<List<SuppressedRecord>, List<DiagnosticRecord>> SuppressRule(
19872001
string ruleName,
19882002
Dictionary<string, List<RuleSuppression>> ruleSuppressions,
1989-
List<DiagnosticRecord> ruleDiagnosticRecords)
2003+
List<DiagnosticRecord> ruleDiagnosticRecords,
2004+
bool emitSuppressionErrors = true)
19902005
{
19912006
List<ErrorRecord> suppressRuleErrors;
19922007
var records = Helper.Instance.SuppressRule(
19932008
ruleName,
19942009
ruleSuppressions,
19952010
ruleDiagnosticRecords,
19962011
out suppressRuleErrors);
1997-
foreach (var error in suppressRuleErrors)
2012+
if (emitSuppressionErrors)
19982013
{
1999-
this.outputWriter.WriteError(error);
2014+
foreach (var error in suppressRuleErrors)
2015+
{
2016+
this.outputWriter.WriteError(error);
2017+
}
20002018
}
20012019
return records;
20022020
}
@@ -2014,13 +2032,15 @@ private Tuple<List<SuppressedRecord>, List<DiagnosticRecord>> SuppressRule(
20142032
/// <returns>Returns a tuple of suppressed and diagnostic records</returns>
20152033
private Tuple<List<SuppressedRecord>, List<DiagnosticRecord>> SuppressRule(
20162034
Dictionary<string, List<RuleSuppression>> ruleSuppressions,
2017-
DiagnosticRecord ruleDiagnosticRecord
2035+
DiagnosticRecord ruleDiagnosticRecord,
2036+
bool emitSuppressionErrors = true
20182037
)
20192038
{
20202039
return SuppressRule(
20212040
ruleDiagnosticRecord.RuleName,
20222041
ruleSuppressions,
2023-
new List<DiagnosticRecord> { ruleDiagnosticRecord });
2042+
new List<DiagnosticRecord> { ruleDiagnosticRecord },
2043+
emitSuppressionErrors);
20242044
}
20252045

20262046
/// <summary>
@@ -2038,6 +2058,27 @@ public IEnumerable<DiagnosticRecord> AnalyzeSyntaxTree(
20382058
Token[] scriptTokens,
20392059
string filePath,
20402060
bool skipVariableAnalysis = false)
2061+
{
2062+
return AnalyzeSyntaxTree(scriptAst, scriptTokens, filePath, skipVariableAnalysis, emitSuppressionErrors: true);
2063+
}
2064+
2065+
/// <summary>
2066+
/// Analyzes the syntax tree of a script file that has already been parsed.
2067+
/// </summary>
2068+
/// <param name="scriptAst">The ScriptBlockAst from the parsed script.</param>
2069+
/// <param name="scriptTokens">The tokens found in the script.</param>
2070+
/// <param name="filePath">The path to the file that was parsed.
2071+
/// If AnalyzeSyntaxTree is called from an AST obtained via ParseInput, this field will be String.Empty.
2072+
/// </param>
2073+
/// <param name="skipVariableAnalysis">Whether to skip variable analysis.</param>
2074+
/// <param name="emitSuppressionErrors">Whether to emit errors for unapplied rule suppression IDs.</param>
2075+
/// <returns>An enumeration of DiagnosticRecords found by rules.</returns>
2076+
public IEnumerable<DiagnosticRecord> AnalyzeSyntaxTree(
2077+
ScriptBlockAst scriptAst,
2078+
Token[] scriptTokens,
2079+
string filePath,
2080+
bool skipVariableAnalysis,
2081+
bool emitSuppressionErrors)
20412082
{
20422083
Dictionary<string, List<RuleSuppression>> ruleSuppressions = new Dictionary<string,List<RuleSuppression>>();
20432084
ConcurrentBag<DiagnosticRecord> diagnostics = new ConcurrentBag<DiagnosticRecord>();
@@ -2117,7 +2158,10 @@ public IEnumerable<DiagnosticRecord> AnalyzeSyntaxTree(
21172158
ruleSuppressions,
21182159
ruleRecords,
21192160
out suppressRuleErrors);
2120-
result.AddRange(suppressRuleErrors);
2161+
if (emitSuppressionErrors)
2162+
{
2163+
result.AddRange(suppressRuleErrors);
2164+
}
21212165
foreach (var record in records.Item2)
21222166
{
21232167
diagnostics.Add(record);
@@ -2177,7 +2221,7 @@ public IEnumerable<DiagnosticRecord> AnalyzeSyntaxTree(
21772221
try
21782222
{
21792223
var ruleRecords = tokenRule.AnalyzeTokens(scriptTokens, filePath).ToList();
2180-
var records = SuppressRule(tokenRule.GetName(), ruleSuppressions, ruleRecords);
2224+
var records = SuppressRule(tokenRule.GetName(), ruleSuppressions, ruleRecords, emitSuppressionErrors);
21812225
foreach (var record in records.Item2)
21822226
{
21832227
diagnostics.Add(record);
@@ -2215,7 +2259,7 @@ public IEnumerable<DiagnosticRecord> AnalyzeSyntaxTree(
22152259
try
22162260
{
22172261
var ruleRecords = dscResourceRule.AnalyzeDSCClass(scriptAst, filePath).ToList();
2218-
var records = SuppressRule(dscResourceRule.GetName(), ruleSuppressions, ruleRecords);
2262+
var records = SuppressRule(dscResourceRule.GetName(), ruleSuppressions, ruleRecords, emitSuppressionErrors);
22192263
foreach (var record in records.Item2)
22202264
{
22212265
diagnostics.Add(record);
@@ -2234,7 +2278,7 @@ public IEnumerable<DiagnosticRecord> AnalyzeSyntaxTree(
22342278
}
22352279

22362280
// Check if the supplied artifact is indeed part of the DSC resource
2237-
if (!filePathIsNullOrWhiteSpace && Helper.Instance.IsDscResourceModule(filePath))
2281+
else if (!filePathIsNullOrWhiteSpace && Helper.Instance.IsDscResourceModule(filePath))
22382282
{
22392283
// Run all DSC Rules
22402284
foreach (IDSCResourceRule dscResourceRule in this.DSCResourceRules)
@@ -2248,7 +2292,7 @@ public IEnumerable<DiagnosticRecord> AnalyzeSyntaxTree(
22482292
try
22492293
{
22502294
var ruleRecords = dscResourceRule.AnalyzeDSCResource(scriptAst, filePath).ToList();
2251-
var records = SuppressRule(dscResourceRule.GetName(), ruleSuppressions, ruleRecords);
2295+
var records = SuppressRule(dscResourceRule.GetName(), ruleSuppressions, ruleRecords, emitSuppressionErrors);
22522296
foreach (var record in records.Item2)
22532297
{
22542298
diagnostics.Add(record);
@@ -2297,7 +2341,7 @@ public IEnumerable<DiagnosticRecord> AnalyzeSyntaxTree(
22972341

22982342
foreach (var ruleRecord in this.GetExternalRecord(scriptAst, scriptTokens, exRules.ToArray(), filePath))
22992343
{
2300-
var records = SuppressRule(ruleSuppressions, ruleRecord);
2344+
var records = SuppressRule(ruleSuppressions, ruleRecord, emitSuppressionErrors);
23012345
foreach (var record in records.Item2)
23022346
{
23032347
diagnostics.Add(record);

Tests/Engine/RuleSuppression.tests.ps1

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,30 @@ function MyFunc
244244
$suppErr.TargetObject.RuleSuppressionID | Should -BeExactly "banana"
245245
}
246246
}
247+
248+
It "Issues one unapplied suppression error when -Fix reanalyzes a file" -Skip:$testingLibraryUsage {
249+
$scriptPath = Join-Path $TestDrive 'SuppressionFix.ps1'
250+
$script = @(
251+
'function Test-Function1 {'
252+
" [System.Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidUsingWriteHost','NonExistentID123')]"
253+
' param() ; Write-Host ''x'''
254+
'}'
255+
) -join "`n"
256+
257+
[System.IO.File]::WriteAllText($scriptPath, $script + "`n")
258+
259+
$diagnostics = Invoke-ScriptAnalyzer `
260+
-Path $scriptPath `
261+
-Fix `
262+
-ErrorVariable fixErr `
263+
-ErrorAction SilentlyContinue
264+
265+
$diagnostics | Should -HaveCount 1
266+
$diagnostics[0].RuleName | Should -BeExactly 'PSAvoidUsingWriteHost'
267+
$fixErr | Should -HaveCount 1
268+
$fixErr[0].TargetObject.RuleName | Should -BeExactly 'PSAvoidUsingWriteHost'
269+
$fixErr[0].TargetObject.RuleSuppressionID | Should -BeExactly 'NonExistentID123'
270+
}
247271
}
248272

249273
Context "RuleSuppressionID with named arguments" {

Tests/Rules/UseDSCResourceFunctions.tests.ps1

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,34 @@ Describe "StandardDSCFunctionsInClass" {
4646
$noClassViolations.Count | Should -Be 0
4747
}
4848
}
49+
50+
Context "When a class-based DSC resource is also in DSC resource module layout" {
51+
It "does not duplicate the unapplied suppression error" {
52+
$resourceRoot = Join-Path $TestDrive 'DSCResources'
53+
$resourceDir = Join-Path $resourceRoot 'MyRes'
54+
$resourcePath = Join-Path $resourceDir 'MyRes.psm1'
55+
$schemaPath = Join-Path $resourceDir 'MyRes.schema.mof'
56+
57+
New-Item -ItemType Directory -Path $resourceDir -Force | Out-Null
58+
[System.IO.File]::WriteAllText($resourcePath, @'
59+
[System.Diagnostics.CodeAnalysis.SuppressMessage('PSDSCStandardDSCFunctionsInResource', 'BadDscId', Scope='Class', Target='MyRes')]
60+
[DscResource()]
61+
class MyRes {
62+
[DscProperty(Key)] [string] $Name
63+
[MyRes] Get() { return $this }
64+
}
65+
'@.TrimStart() + "`n")
66+
Set-Content -Path $schemaPath -Value ''
67+
68+
Invoke-ScriptAnalyzer `
69+
-Path $resourcePath `
70+
-ErrorVariable dscErr `
71+
-ErrorAction SilentlyContinue |
72+
Out-Null
73+
74+
$dscErr | Should -HaveCount 1
75+
$dscErr[0].TargetObject.RuleName | Should -BeExactly $violationName
76+
$dscErr[0].TargetObject.RuleSuppressionID | Should -BeExactly 'BadDscId'
77+
}
78+
}
4979
}

0 commit comments

Comments
 (0)