From d1c84df697388f964dd4177701de43fb2f96619c Mon Sep 17 00:00:00 2001 From: Benjamin Fuchs Date: Sat, 21 Dec 2024 16:12:17 +0100 Subject: [PATCH 1/5] Add support for excluding functions with [ExcludeFromCodeCoverageAttribute()] attribute --- src/functions/Coverage.ps1 | 88 +++++++++++++++++-- tst/functions/Coverage.Tests.ps1 | 144 +++++++++++++++++-------------- 2 files changed, 157 insertions(+), 75 deletions(-) diff --git a/src/functions/Coverage.ps1 b/src/functions/Coverage.ps1 index 781b9c331..ef26e79e2 100644 --- a/src/functions/Coverage.ps1 +++ b/src/functions/Coverage.ps1 @@ -286,28 +286,100 @@ function Get-CommandsInFile { if ($PSVersionTable.PSVersion.Major -ge 5) { # In PowerShell 5.0, dynamic keywords for DSC configurations are represented by the DynamicKeywordStatementAst - # class. They still trigger breakpoints, but are not a child class of CommandBaseAst anymore. + # class. They still trigger breakpoints, but are not a child class of CommandBaseAst anymore. # ReturnStatementAst is excluded as it's not behaving consistent. # "return" is not hit in 5.1 but fixed in a later version. Using "return 123" we get hit on 123 but not return. # See https://github.com/pester/Pester/issues/1465#issuecomment-604323645 $predicate = { - $args[0] -is [System.Management.Automation.Language.DynamicKeywordStatementAst] -or - $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -or - $args[0] -is [System.Management.Automation.Language.BreakStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ContinueStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ExitStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ThrowStatementAst] + if ($args[0] -is [System.Management.Automation.Language.DynamicKeywordStatementAst] -or + $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -or + $args[0] -is [System.Management.Automation.Language.BreakStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ContinueStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ExitStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ThrowStatementAst]) { + if (-not (IsExcludedByAttribute -Ast $args[0])) { + return $true + } + } } } else { - $predicate = { $args[0] -is [System.Management.Automation.Language.CommandBaseAst] } + $predicate = { + if ($args[0] -is [System.Management.Automation.Language.CommandBaseAst]) { + if (-not (IsExcludedByAttribute -Ast $args[0])) { + return $true + } + } + } } $searchNestedScriptBlocks = $true $ast.FindAll($predicate, $searchNestedScriptBlocks) } +function IsExcludedByAttribute { + param ( + [System.Management.Automation.Language.Ast] $Ast + ) + + $functionParents = @() + for ($parent = $Ast.Parent; $null -ne $parent; $parent = $parent.Parent) { + if ($parent -is [System.Management.Automation.Language.FunctionDefinitionAst]) { + $functionParents += $parent + } + } + + $parentsAttributeNames = @() + foreach ($functionParent in $functionParents) { + $paramBlock = $functionParent.Body.ParamBlock + if ($null -ne $paramBlock -and $paramBlock.Attributes) { + $parentsAttributeNames += $paramBlock.Attributes.TypeName.FullName + } + } + + foreach ($parentAttributeName in $parentsAttributeNames) { + if ($parentAttributeName -match 'ExcludeFromCodeCoverageAttribute$') { + if ($parentAttributeName -eq 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') { + return $true + } + + $namespaces = Get-NamespacesFromAstTopParent -Ast $Ast + if ($namespaces) { + foreach ($namespace in $namespaces) { + $fullyQualifiedName = "$namespace.$parentAttributeName" + if ($fullyQualifiedName -eq 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') { + return $true + } + } + } + } + } + + return $false +} + +function Get-NamespacesFromAstTopParent { + param ( + [System.Management.Automation.Language.Ast] $Ast + ) + + $namespaces = @() + $topParent = Get-AstTopParent -Ast $Ast + + if ($null -ne $topParent) { + $usingStatements = $topParent.FindAll({ + param ($node) $node -is [System.Management.Automation.Language.UsingStatementAst] -and $node.UsingStatementKind -eq 'Namespace' + }, $true) + + foreach ($usingStatement in $usingStatements) { + $namespaces += $usingStatement.Name.Value + } + } + + return $namespaces +} + function Test-CoverageOverlapsCommand { param ([object] $CoverageInfo, [System.Management.Automation.Language.Ast] $Command) diff --git a/tst/functions/Coverage.Tests.ps1 b/tst/functions/Coverage.Tests.ps1 index b108745fb..d801f4463 100644 --- a/tst/functions/Coverage.Tests.ps1 +++ b/tst/functions/Coverage.Tests.ps1 @@ -23,6 +23,8 @@ InPesterModuleScope { $null = New-Item -Path $testScriptPath -ItemType File -ErrorAction SilentlyContinue Set-Content -Path $testScriptPath -Value @' + using namespace System.Diagnostics.CodeAnalysis + function FunctionOne { function NestedFunction @@ -47,6 +49,14 @@ InPesterModuleScope { 'I am function two. I never get called.' } + function FunctionThree + { + [ExcludeFromCodeCoverageAttribute(Justification = 'I am not covered')] + param () + + 'I am function three. I never get called.' + } + FunctionOne '@ @@ -288,42 +298,42 @@ InPesterModuleScope { - + - + - + - + - + - + - + - + @@ -367,21 +377,21 @@ InPesterModuleScope { - - - + + - - - - - - - - - - + + + + + + + + + + + @@ -480,42 +490,42 @@ InPesterModuleScope { - + - + - + - + - + - + - + - + @@ -559,21 +569,21 @@ InPesterModuleScope { - - - + + - - - - - - - - - - + + + + + + + + + + + @@ -675,61 +685,61 @@ InPesterModuleScope { - - - - + + + + - + - + - + - + - + - - + + - - - + + - - - - - - - - - - + + + + + + + + + + + Date: Sun, 29 Dec 2024 13:43:27 +0100 Subject: [PATCH 2/5] Refactor coverage exclusion logic --- src/functions/Coverage.ps1 | 100 +++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 44 deletions(-) diff --git a/src/functions/Coverage.ps1 b/src/functions/Coverage.ps1 index ef26e79e2..3a4617fe0 100644 --- a/src/functions/Coverage.ps1 +++ b/src/functions/Coverage.ps1 @@ -292,25 +292,19 @@ function Get-CommandsInFile { # "return" is not hit in 5.1 but fixed in a later version. Using "return 123" we get hit on 123 but not return. # See https://github.com/pester/Pester/issues/1465#issuecomment-604323645 $predicate = { - if ($args[0] -is [System.Management.Automation.Language.DynamicKeywordStatementAst] -or - $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -or - $args[0] -is [System.Management.Automation.Language.BreakStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ContinueStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ExitStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ThrowStatementAst]) { - if (-not (IsExcludedByAttribute -Ast $args[0])) { - return $true - } - } + $args[0] -is [System.Management.Automation.Language.DynamicKeywordStatementAst] -or + $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -or + $args[0] -is [System.Management.Automation.Language.BreakStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ContinueStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ExitStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ThrowStatementAst] -and + -not (IsExcludedByAttribute -Ast $args[0] -TargetAttribute 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') } } else { $predicate = { - if ($args[0] -is [System.Management.Automation.Language.CommandBaseAst]) { - if (-not (IsExcludedByAttribute -Ast $args[0])) { - return $true - } - } + $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -and + -not (IsExcludedByAttribute -Ast $args[0] -TargetAttribute 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') } } @@ -320,37 +314,40 @@ function Get-CommandsInFile { function IsExcludedByAttribute { param ( - [System.Management.Automation.Language.Ast] $Ast + [System.Management.Automation.Language.Ast] $Ast, + [string] $TargetAttribute ) - $functionParents = @() for ($parent = $Ast.Parent; $null -ne $parent; $parent = $parent.Parent) { if ($parent -is [System.Management.Automation.Language.FunctionDefinitionAst]) { - $functionParents += $parent + if (Test-ContainsAttribute -FunctionAst $parent -TargetAttribute $TargetAttribute) { + return $true + } } } - $parentsAttributeNames = @() - foreach ($functionParent in $functionParents) { - $paramBlock = $functionParent.Body.ParamBlock - if ($null -ne $paramBlock -and $paramBlock.Attributes) { - $parentsAttributeNames += $paramBlock.Attributes.TypeName.FullName - } - } + return $false +} - foreach ($parentAttributeName in $parentsAttributeNames) { - if ($parentAttributeName -match 'ExcludeFromCodeCoverageAttribute$') { - if ($parentAttributeName -eq 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') { - return $true - } +function Test-ContainsAttribute { + param ( + [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst, + [string] $TargetAttribute + ) - $namespaces = Get-NamespacesFromAstTopParent -Ast $Ast - if ($namespaces) { - foreach ($namespace in $namespaces) { - $fullyQualifiedName = "$namespace.$parentAttributeName" - if ($fullyQualifiedName -eq 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') { - return $true - } + $AttributeNames = Get-AttributeNames -FunctionAst $FunctionAst + + foreach ($attributeName in $AttributeNames) { + if ($attributeName -eq $TargetAttribute) { + return $true + } + + if ($attributeName.Split('.')[-1] -eq $TargetAttribute.Split('.')[-1]) { + $Namespaces = Get-NamespacesFromAstTopParent -Ast $FunctionAst + foreach ($namespace in $Namespaces) { + $fullyQualifiedName = "$namespace.$attributeName" + if ($fullyQualifiedName -eq $TargetAttribute) { + return $true } } } @@ -359,6 +356,19 @@ function IsExcludedByAttribute { return $false } +function Get-AttributeNames { + param ( + [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst + ) + + $paramBlock = $FunctionAst.Body.ParamBlock + if ($null -ne $paramBlock -and $paramBlock.Attributes) { + return $paramBlock.Attributes.TypeName.FullName + } + + return @() +} + function Get-NamespacesFromAstTopParent { param ( [System.Management.Automation.Language.Ast] $Ast @@ -367,14 +377,16 @@ function Get-NamespacesFromAstTopParent { $namespaces = @() $topParent = Get-AstTopParent -Ast $Ast - if ($null -ne $topParent) { - $usingStatements = $topParent.FindAll({ - param ($node) $node -is [System.Management.Automation.Language.UsingStatementAst] -and $node.UsingStatementKind -eq 'Namespace' - }, $true) + if ($null -eq $topParent) { + return @() + } - foreach ($usingStatement in $usingStatements) { - $namespaces += $usingStatement.Name.Value - } + $usingStatements = $topParent.FindAll({ + param ($node) $node -is [System.Management.Automation.Language.UsingStatementAst] -and $node.UsingStatementKind -eq 'Namespace' + }, $true) + + foreach ($usingStatement in $usingStatements) { + $namespaces += $usingStatement.Name.Value } return $namespaces From e800b90f9ac619c778cf60569c0126d656b65046 Mon Sep 17 00:00:00 2001 From: Benjamin Fuchs Date: Sun, 12 Jan 2025 16:03:13 +0100 Subject: [PATCH 3/5] Improving Test-ContainsAttribute and minor review improvements --- src/functions/Coverage.ps1 | 57 ++++++++------------------------------ 1 file changed, 11 insertions(+), 46 deletions(-) diff --git a/src/functions/Coverage.ps1 b/src/functions/Coverage.ps1 index 3a4617fe0..8e2f669d4 100644 --- a/src/functions/Coverage.ps1 +++ b/src/functions/Coverage.ps1 @@ -319,8 +319,8 @@ function IsExcludedByAttribute { ) for ($parent = $Ast.Parent; $null -ne $parent; $parent = $parent.Parent) { - if ($parent -is [System.Management.Automation.Language.FunctionDefinitionAst]) { - if (Test-ContainsAttribute -FunctionAst $parent -TargetAttribute $TargetAttribute) { + if ($parent -is [System.Management.Automation.Language.ScriptBlockAst]) { + if (Test-ContainsAttribute -ScriptBlockAst $parent -TargetAttribute $TargetAttribute) { return $true } } @@ -331,65 +331,30 @@ function IsExcludedByAttribute { function Test-ContainsAttribute { param ( - [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst, + [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst, [string] $TargetAttribute ) - $AttributeNames = Get-AttributeNames -FunctionAst $FunctionAst - - foreach ($attributeName in $AttributeNames) { - if ($attributeName -eq $TargetAttribute) { + $attributes = Get-Attributes -ScriptBlockAst $ScriptBlockAst + foreach ($attribute in $attributes) { + $type = $attribute.TypeName.GetReflectionType() + if ($null -ne $type -and $type.FullName -eq $TargetAttribute) { return $true } - - if ($attributeName.Split('.')[-1] -eq $TargetAttribute.Split('.')[-1]) { - $Namespaces = Get-NamespacesFromAstTopParent -Ast $FunctionAst - foreach ($namespace in $Namespaces) { - $fullyQualifiedName = "$namespace.$attributeName" - if ($fullyQualifiedName -eq $TargetAttribute) { - return $true - } - } - } } return $false } -function Get-AttributeNames { +function Get-Attributes { param ( - [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst + [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) - $paramBlock = $FunctionAst.Body.ParamBlock + $paramBlock = $ScriptBlockAst.ParamBlock if ($null -ne $paramBlock -and $paramBlock.Attributes) { - return $paramBlock.Attributes.TypeName.FullName - } - - return @() -} - -function Get-NamespacesFromAstTopParent { - param ( - [System.Management.Automation.Language.Ast] $Ast - ) - - $namespaces = @() - $topParent = Get-AstTopParent -Ast $Ast - - if ($null -eq $topParent) { - return @() + return $paramBlock.Attributes } - - $usingStatements = $topParent.FindAll({ - param ($node) $node -is [System.Management.Automation.Language.UsingStatementAst] -and $node.UsingStatementKind -eq 'Namespace' - }, $true) - - foreach ($usingStatement in $usingStatements) { - $namespaces += $usingStatement.Name.Value - } - - return $namespaces } function Test-CoverageOverlapsCommand { From 88e058c7ecbdbcf761dd8745649b17eba0309da6 Mon Sep 17 00:00:00 2001 From: Benjamin Fuchs Date: Sun, 12 Jan 2025 22:06:17 +0100 Subject: [PATCH 4/5] Replacing FindAll with AstVisitor --- src/csharp/Pester/CoverageLocationVisitor.cs | 72 +++++++++++++++++++ src/functions/Coverage.ps1 | 74 +------------------- 2 files changed, 75 insertions(+), 71 deletions(-) create mode 100644 src/csharp/Pester/CoverageLocationVisitor.cs diff --git a/src/csharp/Pester/CoverageLocationVisitor.cs b/src/csharp/Pester/CoverageLocationVisitor.cs new file mode 100644 index 000000000..05aa3194d --- /dev/null +++ b/src/csharp/Pester/CoverageLocationVisitor.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Management.Automation.Language; + +namespace Pester +{ + public class CoverageLocationVisitor : AstVisitor2 + { + public readonly List CoverageLocations = new(); + + public override AstVisitAction VisitScriptBlock(ScriptBlockAst scriptBlockAst) + { + if (scriptBlockAst.ParamBlock?.Attributes != null) + { + foreach (var attribute in scriptBlockAst.ParamBlock.Attributes) + { + if (attribute.TypeName.GetReflectionType() == typeof(ExcludeFromCodeCoverageAttribute)) + { + return AstVisitAction.SkipChildren; + } + } + } + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitCommand(CommandAst commandAst) + { + CoverageLocations.Add(commandAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitCommandExpression(CommandExpressionAst commandExpressionAst) + { + CoverageLocations.Add(commandExpressionAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitDynamicKeywordStatement(DynamicKeywordStatementAst dynamicKeywordStatementAst) + { + CoverageLocations.Add(dynamicKeywordStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitBreakStatement(BreakStatementAst breakStatementAst) + { + CoverageLocations.Add(breakStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitContinueStatement(ContinueStatementAst continueStatementAst) + { + CoverageLocations.Add(continueStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitExitStatement(ExitStatementAst exitStatementAst) + { + CoverageLocations.Add(exitStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitThrowStatement(ThrowStatementAst throwStatementAst) + { + CoverageLocations.Add(throwStatementAst); + return AstVisitAction.Continue; + } + + // ReturnStatementAst is excluded as it's not behaving consistent. + // "return" is not hit in 5.1 but fixed in a later version. Using "return 123" we get hit on 123 but not return. + // See https://github.com/pester/Pester/issues/1465#issuecomment-604323645 + } +} diff --git a/src/functions/Coverage.ps1 b/src/functions/Coverage.ps1 index 8e2f669d4..8d836114b 100644 --- a/src/functions/Coverage.ps1 +++ b/src/functions/Coverage.ps1 @@ -284,77 +284,9 @@ function Get-CommandsInFile { $tokens = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref] $tokens, [ref] $errors) - if ($PSVersionTable.PSVersion.Major -ge 5) { - # In PowerShell 5.0, dynamic keywords for DSC configurations are represented by the DynamicKeywordStatementAst - # class. They still trigger breakpoints, but are not a child class of CommandBaseAst anymore. - - # ReturnStatementAst is excluded as it's not behaving consistent. - # "return" is not hit in 5.1 but fixed in a later version. Using "return 123" we get hit on 123 but not return. - # See https://github.com/pester/Pester/issues/1465#issuecomment-604323645 - $predicate = { - $args[0] -is [System.Management.Automation.Language.DynamicKeywordStatementAst] -or - $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -or - $args[0] -is [System.Management.Automation.Language.BreakStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ContinueStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ExitStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ThrowStatementAst] -and - -not (IsExcludedByAttribute -Ast $args[0] -TargetAttribute 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') - } - } - else { - $predicate = { - $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -and - -not (IsExcludedByAttribute -Ast $args[0] -TargetAttribute 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') - } - } - - $searchNestedScriptBlocks = $true - $ast.FindAll($predicate, $searchNestedScriptBlocks) -} - -function IsExcludedByAttribute { - param ( - [System.Management.Automation.Language.Ast] $Ast, - [string] $TargetAttribute - ) - - for ($parent = $Ast.Parent; $null -ne $parent; $parent = $parent.Parent) { - if ($parent -is [System.Management.Automation.Language.ScriptBlockAst]) { - if (Test-ContainsAttribute -ScriptBlockAst $parent -TargetAttribute $TargetAttribute) { - return $true - } - } - } - - return $false -} - -function Test-ContainsAttribute { - param ( - [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst, - [string] $TargetAttribute - ) - - $attributes = Get-Attributes -ScriptBlockAst $ScriptBlockAst - foreach ($attribute in $attributes) { - $type = $attribute.TypeName.GetReflectionType() - if ($null -ne $type -and $type.FullName -eq $TargetAttribute) { - return $true - } - } - - return $false -} - -function Get-Attributes { - param ( - [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst - ) - - $paramBlock = $ScriptBlockAst.ParamBlock - if ($null -ne $paramBlock -and $paramBlock.Attributes) { - return $paramBlock.Attributes - } + $visitor = [Pester.CoverageLocationVisitor]::new() + $ast.Visit($visitor) + return $visitor.CoverageLocations } function Test-CoverageOverlapsCommand { From 82ef8687aa70c406ccf5ed9fdee1f400a87649e1 Mon Sep 17 00:00:00 2001 From: Benjamin Fuchs Date: Mon, 13 Jan 2025 00:23:53 +0100 Subject: [PATCH 5/5] Updating tests --- tst/functions/Coverage.Tests.ps1 | 219 ++++++++++++++++++++----------- 1 file changed, 142 insertions(+), 77 deletions(-) diff --git a/tst/functions/Coverage.Tests.ps1 b/tst/functions/Coverage.Tests.ps1 index d801f4463..20c58bab4 100644 --- a/tst/functions/Coverage.Tests.ps1 +++ b/tst/functions/Coverage.Tests.ps1 @@ -23,8 +23,6 @@ InPesterModuleScope { $null = New-Item -Path $testScriptPath -ItemType File -ErrorAction SilentlyContinue Set-Content -Path $testScriptPath -Value @' - using namespace System.Diagnostics.CodeAnalysis - function FunctionOne { function NestedFunction @@ -49,14 +47,6 @@ InPesterModuleScope { 'I am function two. I never get called.' } - function FunctionThree - { - [ExcludeFromCodeCoverageAttribute(Justification = 'I am not covered')] - param () - - 'I am function three. I never get called.' - } - FunctionOne '@ @@ -298,42 +288,42 @@ InPesterModuleScope { - + - + - + - + - + - + - + - + @@ -377,21 +367,21 @@ InPesterModuleScope { - - + + + - - - - - - - - + + + + + + + + + + - - - @@ -490,42 +480,42 @@ InPesterModuleScope { - + - + - + - + - + - + - + - + @@ -569,21 +559,21 @@ InPesterModuleScope { - - + + + - - - - - - - - + + + + + + + + + + - - - @@ -685,61 +675,61 @@ InPesterModuleScope { + - - - - + + + - + - + - + - + - + - - + + - - + + + - - - - - - - - + + + + + + + + + + - - -