From 1da47c4204d73f1c22904d5a5426b3b3e351d6cd Mon Sep 17 00:00:00 2001 From: iRon7 Date: Wed, 22 Apr 2026 18:38:41 +0200 Subject: [PATCH] #2098 Add new MissingTryBlock rule --- Rules/MissingTryBlock.cs | 83 ++++++++++++++++ Rules/Strings.resx | 12 +++ Tests/Rules/MissingTryBlock.tests.ps1 | 133 ++++++++++++++++++++++++++ docs/Rules/MissingTryBlock.md | 34 +++++++ docs/Rules/README.md | 1 + 5 files changed, 263 insertions(+) create mode 100644 Rules/MissingTryBlock.cs create mode 100644 Tests/Rules/MissingTryBlock.tests.ps1 create mode 100644 docs/Rules/MissingTryBlock.md diff --git a/Rules/MissingTryBlock.cs b/Rules/MissingTryBlock.cs new file mode 100644 index 000000000..0ef2a0d23 --- /dev/null +++ b/Rules/MissingTryBlock.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Management.Automation.Language; + +#if !CORECLR +using System.ComponentModel.Composition; +#endif + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + + /// + /// Rule that warns when reserved words are used as function names + /// + public class MissingTryBlock : IScriptRule + { + /// + /// Analyzes the PowerShell AST for uses of reserved words as function names. + /// + /// The PowerShell Abstract Syntax Tree to analyze. + /// The name of the file being analyzed (for diagnostic reporting). + /// A collection of diagnostic records for each violation. + public IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) throw new ArgumentNullException(Strings.NullAstErrorMessage); + + // Find all FunctionDefinitionAst in the Ast + var missingTryAsts = ast.FindAll(testAst => + // Normally should be part of a TryStatementAst + testAst is StringConstantExpressionAst stringAst && + // Catch of finally are reserved keywords and should be bare words + stringAst.StringConstantType == StringConstantType.BareWord && + ( + String.Equals(stringAst.Value, "catch", StringComparison.OrdinalIgnoreCase) || + String.Equals(stringAst.Value, "finally", StringComparison.OrdinalIgnoreCase) + ) && + stringAst.Parent is CommandAst commandAst && + // Only violate if the catch or finally is the first command element + commandAst.CommandElements[0] == stringAst, + true + ); + + foreach (StringConstantExpressionAst missingTryAst in missingTryAsts) + { + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.MissingTryBlockError, + CultureInfo.CurrentCulture.TextInfo.ToTitleCase(missingTryAst.Value)), + missingTryAst.Extent, + GetName(), + DiagnosticSeverity.Error, + fileName, + missingTryAst.Value + ); + } + } + + public string GetCommonName() => Strings.MissingTryBlockCommonName; + + public string GetDescription() => Strings.MissingTryBlockDescription; + + public string GetName() => string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.MissingTryBlockName); + + public RuleSeverity GetSeverity() => RuleSeverity.Warning; + + public string GetSourceName() => Strings.SourceName; + + public SourceType GetSourceType() => SourceType.Builtin; + } +} \ No newline at end of file diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 2a04fd759..1b868ea90 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -276,6 +276,18 @@ Module Manifest Fields + + MissingTryBlock + + + Missing try block + + + The catch and finally blocks should be preceded by a try block. + + + {0} is missing a try block + If a script file is in a PowerShell module folder, then that folder must be loadable. diff --git a/Tests/Rules/MissingTryBlock.tests.ps1 b/Tests/Rules/MissingTryBlock.tests.ps1 new file mode 100644 index 000000000..bb28ced9b --- /dev/null +++ b/Tests/Rules/MissingTryBlock.tests.ps1 @@ -0,0 +1,133 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] +param() + +BeforeAll { + $ruleName = "PSMissingTryBlock" +} + +Describe "MissingTryBlock" { + Context "Violates" { + It "Catch is missing a try block" { + $scriptDefinition = { catch { "An error occurred." } }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Error + $violations.Extent.Text | Should -Be catch + $violations.Message | Should -Be 'Catch is missing a try block' + $violations.RuleSuppressionID | Should -Be catch + } + + It "Finally is missing a try block" { + $scriptDefinition = { finally { "Finalizing..." } }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Error + $violations.Extent.Text | Should -Be finally + $violations.Message | Should -Be 'Finally is missing a try block' + $violations.RuleSuppressionID | Should -Be finally + } + + It "Single line catch and finally is missing a try block" { + $scriptDefinition = { + catch { "An error occurred." } finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Error + $violations.Extent.Text | Should -Be catch + $violations.Message | Should -Be 'Catch is missing a try block' + $violations.RuleSuppressionID | Should -Be catch + } + + It "Multi line catch and finally is missing a try block" { + $scriptDefinition = { + catch { "An error occurred." } + finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations.Count | Should -Be 2 + $violations[0].Severity | Should -Be Error + $violations[0].Extent.Text | Should -Be catch + $violations[0].Message | Should -Be 'Catch is missing a try block' + $violations[0].RuleSuppressionID | Should -Be catch + $violations[1].Severity | Should -Be Error + $violations[1].Extent.Text | Should -Be finally + $violations[1].Message | Should -Be 'Finally is missing a try block' + $violations[1].RuleSuppressionID | Should -Be finally + } + } + + Context "Compliant" { + It "try-catch block" { + $scriptDefinition = { + try { NonsenseString } + catch { "An error occurred." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations | Should -BeNullOrEmpty + } + + It "try-catch-final statement" { + $scriptDefinition = { + try { NonsenseString } + catch { "An error occurred." } + finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations | Should -BeNullOrEmpty + } + + It "Single line try statement" { + $scriptDefinition = { + try { NonsenseString } catch { "An error occurred." } finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations | Should -BeNullOrEmpty + } + + It "Catch as parameter" { + $scriptDefinition = { Write-Host Catch }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations | Should -BeNullOrEmpty + } + + It "Catch as double quoted string" { + $scriptDefinition = { "Catch" }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations | Should -BeNullOrEmpty + } + + It "Catch as single quoted string" { + $scriptDefinition = { 'Catch' }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations | Should -BeNullOrEmpty + } + } + + Context "Suppressed" { + It "Multi line catch and finally is missing a try block" { + $scriptDefinition = { + [Diagnostics.CodeAnalysis.SuppressMessage('PSMissingTryBlock', '', Justification = 'Test')] + param() + catch { "An error occurred." } + finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations | Should -BeNullOrEmpty + } + + It "Multi line catch and finally is missing a try block for catch only" { + $scriptDefinition = { + [Diagnostics.CodeAnalysis.SuppressMessage('PSMissingTryBlock', 'finally', Justification = 'Test')] + param() + catch { "An error occurred." } + finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations.Count | Should -Be 1 + } + } +} \ No newline at end of file diff --git a/docs/Rules/MissingTryBlock.md b/docs/Rules/MissingTryBlock.md new file mode 100644 index 000000000..eb18a5f65 --- /dev/null +++ b/docs/Rules/MissingTryBlock.md @@ -0,0 +1,34 @@ +--- +description: Missing Try Block +ms.date: 04/22/2026 +ms.topic: reference +title: MissingTryBlock +--- +# MissingTryBlock + +**Severity Level: Error** + +## Description + +The `catch` and `finally` blocks should be preceded by a `try` block. +Otherwise, the `catch` and `finally` blocks will be interpreted as commands, which is likely a mistake and result +in a "*The term 'catch' is not recognized as a name of a cmdlet*" error at runtime. + +## How + +Add a `try` block before the `catch` and `finally` blocks. + +## Example + +### Wrong + +```powershell +catch { "An error occurred." } +``` + +### Correct + +```powershell +try { $a = 1 / $b } +catch { "Attempted to divide by zero." } +``` diff --git a/docs/Rules/README.md b/docs/Rules/README.md index fca031e33..846eb0799 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -50,6 +50,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [DSCUseVerboseMessageInDSCResource](./DSCUseVerboseMessageInDSCResource.md) | Error | Yes | | | [MisleadingBacktick](./MisleadingBacktick.md) | Warning | Yes | | | [MissingModuleManifestField](./MissingModuleManifestField.md) | Warning | Yes | | +| [MissingTryBlock](./MissingTryBlock.md) | Error | Yes | | | [PlaceCloseBrace](./PlaceCloseBrace.md) | Warning | No | Yes | | [PlaceOpenBrace](./PlaceOpenBrace.md) | Warning | No | Yes | | [PossibleIncorrectComparisonWithNull](./PossibleIncorrectComparisonWithNull.md) | Warning | Yes | |