From 3e20cbc8628dbf58965a915c26897b858f718173 Mon Sep 17 00:00:00 2001 From: NWarila <33955773+NWarila@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:08:13 +0000 Subject: [PATCH] Add SG-5 canonical attribute order rule --- PSScriptAnalyzerSettings.psd1 | 4 + analyzers/HouseRules.psm1 | 368 ++++++++++++++++++++++++++++++++++ docs/STYLE-GUIDE.md | 48 +++++ tests/HouseRules.Tests.ps1 | 253 ++++++++++++++++++++++- 4 files changed, 672 insertions(+), 1 deletion(-) diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index c440a19..532cb8f 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -76,6 +76,10 @@ Enable = $true } + 'Measure-CanonicalAttributeOrder' = @{ + Enable = $true + } + 'Measure-NoRemoveVariableCleanup' = @{ Enable = $true } diff --git a/analyzers/HouseRules.psm1 b/analyzers/HouseRules.psm1 index 0d4dd0f..2d9c91d 100644 --- a/analyzers/HouseRules.psm1 +++ b/analyzers/HouseRules.psm1 @@ -779,6 +779,262 @@ function Get-HouseRuleFunctionAttribute { } +function Get-HouseRuleAttributeName { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.String])] + param ( + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.AttributeAst] + $AttributeAst + ) + + [System.String]( + ( + ([System.String]$AttributeAst.TypeName.FullName) -replace + '^(System\.Management\.Automation\.)?', + '' + ) -replace 'Attribute$', + '' + ) + +} + +function Get-HouseRuleParameterAttributeOrderKey { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.String])] + param ( + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.AttributeBaseAst] + $AttributeAst + ) + + if ($AttributeAst -is [System.Management.Automation.Language.TypeConstraintAst]) { + return [System.String]'3|' + } + + if ($AttributeAst -isnot [System.Management.Automation.Language.AttributeAst]) { + return [System.String]'2|' + } + + $Private:AttributeName = Get-HouseRuleAttributeName -AttributeAst $AttributeAst + + if ($AttributeName -ieq 'Parameter') { + return [System.String]'0|Parameter' + } + + if ($AttributeName -ieq 'Alias') { + return [System.String]'1|Alias' + } + + [System.String]('2|{0}' -f $AttributeName) + +} + +function Test-HouseRuleAlphabeticalOrder { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Boolean])] + param ( + [Parameter(Mandatory = $True)] + [AllowEmptyCollection()] + [System.String[]] + $Value + ) + + for ($Index = 1; $Index -lt $Value.Count; $Index++) { + if ([System.StringComparer]::OrdinalIgnoreCase.Compare($Value[$Index - 1], $Value[$Index]) -gt 0) { + return [System.Boolean]$False + } + } + + [System.Boolean]$True + +} + +function Test-HouseRuleNamedArgumentValueIsTrue { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Boolean])] + param ( + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.NamedAttributeArgumentAst] + $NamedArgument + ) + + if ($Null -eq $NamedArgument.Argument) { + return [System.Boolean]$True + } + + if ($NamedArgument.Argument.Extent.Text -ieq $NamedArgument.ArgumentName) { + return [System.Boolean]$True + } + + try { + return [System.Boolean]$NamedArgument.Argument.SafeGetValue() + } catch { + return [System.Boolean]$False + } + +} + +function Test-HouseRuleParameterAttributeOrder { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Boolean])] + param ( + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.ParameterAst] + $ParameterAst + ) + + # Initalize Variable(s) + [System.String]$Private:CurrentKey = [System.String]::Empty + [System.Boolean]$Private:HasPreviousKey = $False + [System.String]$Private:PreviousKey = [System.String]::Empty + + foreach ($AttributeAst in $ParameterAst.Attributes) { + $CurrentKey = Get-HouseRuleParameterAttributeOrderKey -AttributeAst $AttributeAst + + if ( + $HasPreviousKey -eq $True -and + [System.StringComparer]::OrdinalIgnoreCase.Compare($PreviousKey, $CurrentKey) -gt 0 + ) { + return [System.Boolean]$False + } + + $HasPreviousKey = $True + $PreviousKey = $CurrentKey + } + + [System.Boolean]$True + +} + +function Test-HouseRuleParameterTypeLast { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Boolean])] + param ( + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.ParameterAst] + $ParameterAst + ) + + $Private:HasSeenType = $False + + foreach ($AttributeAst in $ParameterAst.Attributes) { + if ($AttributeAst -is [System.Management.Automation.Language.TypeConstraintAst]) { + $HasSeenType = $True + continue + } + + if ( + $HasSeenType -eq $True -and + $AttributeAst -is [System.Management.Automation.Language.AttributeAst] + ) { + return [System.Boolean]$False + } + } + + [System.Boolean]$True + +} + +function Test-HouseRuleParameterOrderGuard { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Boolean])] + param ( + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.FunctionDefinitionAst] + $FunctionAst + ) + + foreach ($CmdletBindingAttribute in Get-HouseRuleFunctionAttribute -FunctionAst $FunctionAst -AttributeName 'CmdletBinding') { + foreach ($NamedArgument in $CmdletBindingAttribute.NamedArguments) { + if ($NamedArgument.ArgumentName -ine 'PositionalBinding') { + continue + } + + if ((Test-HouseRuleNamedArgumentValueIsTrue -NamedArgument $NamedArgument) -eq $True) { + return [System.Boolean]$True + } + } + } + + if ($Null -eq $FunctionAst.Body.ParamBlock) { + return [System.Boolean]$False + } + + foreach ($ParameterAst in $FunctionAst.Body.ParamBlock.Parameters) { + foreach ($AttributeAst in $ParameterAst.Attributes) { + if ($AttributeAst -isnot [System.Management.Automation.Language.AttributeAst]) { + continue + } + + if ((Get-HouseRuleAttributeName -AttributeAst $AttributeAst) -ine 'Parameter') { + continue + } + + foreach ($NamedArgument in $AttributeAst.NamedArguments) { + if ( + $NamedArgument.ArgumentName -ieq 'Position' -or + $NamedArgument.ArgumentName -ieq 'ParameterSetName' + ) { + return [System.Boolean]$True + } + } + } + } + + [System.Boolean]$False + +} + function Get-HouseRuleProcessResetVariableName { [CmdletBinding( ConfirmImpact = 'None', @@ -833,6 +1089,118 @@ function Get-HouseRuleProcessResetVariableName { } +function Measure-CanonicalAttributeOrder { + <# + .SYNOPSIS + Flags non-canonical ordering in function declaration attributes and parameters. + #> + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] + param ( + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.ScriptBlockAst] + $ScriptBlockAst + ) + + foreach ($FunctionAst in Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { + foreach ($CmdletBindingAttribute in Get-HouseRuleFunctionAttribute -FunctionAst $FunctionAst -AttributeName 'CmdletBinding') { + $Private:CmdletBindingOptionNames = [System.String[]]@( + $CmdletBindingAttribute.NamedArguments | + ForEach-Object -Process { $PSItem.ArgumentName } + ) + + if ((Test-HouseRuleAlphabeticalOrder -Value $CmdletBindingOptionNames) -eq $False) { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-CanonicalAttributeOrder' ` + -Extent $CmdletBindingAttribute.Extent ` + -Message ( + "Function '{0}' CmdletBinding options are not in alphabetical order (SG-5a)." -f + $FunctionAst.Name + ) + } + } + + if ($Null -eq $FunctionAst.Body.ParamBlock) { + continue + } + + foreach ($ParameterAst in $FunctionAst.Body.ParamBlock.Parameters) { + foreach ($AttributeAst in $ParameterAst.Attributes) { + if ($AttributeAst -isnot [System.Management.Automation.Language.AttributeAst]) { + continue + } + + if ((Get-HouseRuleAttributeName -AttributeAst $AttributeAst) -ine 'Parameter') { + continue + } + + $Private:ParameterArgumentNames = [System.String[]]@( + $AttributeAst.NamedArguments | + ForEach-Object -Process { $PSItem.ArgumentName } + ) + + if ((Test-HouseRuleAlphabeticalOrder -Value $ParameterArgumentNames) -eq $False) { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-CanonicalAttributeOrder' ` + -Extent $AttributeAst.Extent ` + -Message ( + "Function '{0}' parameter '{1}' Parameter attribute arguments are not in alphabetical order (SG-5b)." -f + $FunctionAst.Name, + (Get-HouseRuleVariableName -VariableAst $ParameterAst.Name) + ) + } + } + + if ((Test-HouseRuleParameterTypeLast -ParameterAst $ParameterAst) -eq $False) { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-CanonicalAttributeOrder' ` + -Extent $ParameterAst.Extent ` + -Message ( + "Function '{0}' parameter '{1}' has an attribute after the type literal; the type must be last before the variable (SG-5c)." -f + $FunctionAst.Name, + (Get-HouseRuleVariableName -VariableAst $ParameterAst.Name) + ) + } + + if ((Test-HouseRuleParameterAttributeOrder -ParameterAst $ParameterAst) -eq $False) { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-CanonicalAttributeOrder' ` + -Extent $ParameterAst.Extent ` + -Message ( + "Function '{0}' parameter '{1}' attributes are not in canonical Parameter/Alias/attribute/type order (SG-5c)." -f + $FunctionAst.Name, + (Get-HouseRuleVariableName -VariableAst $ParameterAst.Name) + ) + } + } + + if ((Test-HouseRuleParameterOrderGuard -FunctionAst $FunctionAst) -eq $True) { + continue + } + + $Private:ParameterNames = [System.String[]]@(Get-HouseRuleParameterName -FunctionAst $FunctionAst) + if ((Test-HouseRuleAlphabeticalOrder -Value $ParameterNames) -eq $True) { + continue + } + + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-CanonicalAttributeOrder' ` + -Extent $FunctionAst.Body.ParamBlock.Extent ` + -Message ( + "Function '{0}' parameters are not in alphabetical order by name (SG-5d)." -f + $FunctionAst.Name + ) + } + +} + function Measure-ExplicitCmdletBinding { <# .SYNOPSIS diff --git a/docs/STYLE-GUIDE.md b/docs/STYLE-GUIDE.md index fd56467..a490fce 100644 --- a/docs/STYLE-GUIDE.md +++ b/docs/STYLE-GUIDE.md @@ -303,3 +303,51 @@ present). It enforces **presence**, not values — values are the advisor's/revi except `SupportsShouldProcess` which the built-in `PSUseShouldProcessForStateChangingFunctions` also checks against the verb. (No built-in rule requires option presence; `PSUseOutputTypeCorrectly` is Information-only and merely *validates* a declared type.) + +--- + +## SG-5 — Canonical (alphabetical) ordering of the attribute/parameter surface **[mechanical — analyzer-enforced]** + +**Rule.** The declaration surface of every advanced function is ordered deterministically +so diffs and reviews are stable. Four clauses: + +- **(a) `CmdletBinding` options** appear in **alphabetical** order: + `ConfirmImpact, DefaultParameterSetName, HelpUri, PositionalBinding, SupportsPaging, SupportsShouldProcess`. +- **(b) `[Parameter(...)]` named arguments** appear in **alphabetical** order + (`Mandatory, Position?, ValueFromPipeline, ValueFromPipelineByPropertyName, …`). +- **(c) Attributes on a parameter** appear in this canonical order: + `[Parameter(...)]` → `[Alias(...)]` → validation/transformation attributes + (`[Validate*]`, etc., **alphabetical** among themselves) → **the type literal** → + the variable. **The type literal MUST be last** (immediately before `$Var`). +- **(d) The parameters themselves** appear in **alphabetical order by name**. + +**Behavioral safety (why this is allowed).** (a) and (b) are .NET *named attribute +arguments* — order-independent by language definition, purely cosmetic. (c) is cosmetic +among attributes, with **one hard behavioral constraint**: a validation attribute placed +*after* the type validates the **pre-conversion** value and can throw a spurious +`MetadataError`; Microsoft prescribes **attribute-before-type**, so "type last" is a +*correctness* rule, not just style. (d) reordering parameters is **binding-safe only +because the house mandates `PositionalBinding = $false` (SG-4) with no explicit +`Position` and single parameter sets** — under those conditions declaration order does +not drive binding. Accepted tradeoff: alphabetical parameters also reorder the +`Get-Help` parameter listing and `$PSBoundParameters` enumeration (cosmetic, not +behavioral). + +**Guard (the rule must respect this).** If a function ever declares an explicit +`Position` on any parameter, or multiple parameter sets (`ParameterSetName` on +parameters), or `PositionalBinding = $true`, then **parameter order is load-bearing** — +clause (d) does NOT apply to that function and the rule must exempt it. (a)/(b)/(c) still +apply. House code currently has none of these. + +**Why.** Alphabetical is a deterministic canonical order, so two authors produce +byte-identical declarations and reviews see only real changes — the same auditability +goal as the maximal-explicit surface. (Neither Microsoft nor PoshCode prescribes +alphabetical ordering; it's a deliberate house convention, safe per the classification +above.) + +**Enforced by** a custom rule **`Measure-CanonicalAttributeOrder`** (Warning). Via the +AST: `AttributeAst.NamedArguments` must be alphabetical (a, b); within each +`ParameterAst.Attributes`, no `[Validate*]`/transform `AttributeAst` may follow the +`TypeConstraintAst`, and `[Parameter]`→`[Alias]`→`[Validate* alphabetical]`→type order +holds (c); `ParamBlockAst.Parameters` names must be alphabetical **unless** the +function trips the Guard above (d). No built-in rule covers any of this. diff --git a/tests/HouseRules.Tests.ps1 b/tests/HouseRules.Tests.ps1 index 0a7e12a..5e998c2 100644 --- a/tests/HouseRules.Tests.ps1 +++ b/tests/HouseRules.Tests.ps1 @@ -2,7 +2,8 @@ Describe 'SG-1 house analyzer rules' { BeforeAll { - $script:AnalyzerRulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\analyzers\HouseRules.psm1' + $AnalyzerRulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\analyzers\HouseRules.psm1' + $script:AnalyzerRulePath = $AnalyzerRulePath if (-not (Get-Module -Name PSScriptAnalyzer)) { Import-Module -Name PSScriptAnalyzer -ErrorAction Stop } @@ -389,3 +390,253 @@ function Get-Thing { $Results.Message | Should -Match 'CmdletBinding' } } + +Describe 'SG-5 house analyzer rules' { + BeforeAll { + $script:AnalyzerRulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\analyzers\HouseRules.psm1' + if (-not (Get-Module -Name PSScriptAnalyzer)) { + Import-Module -Name PSScriptAnalyzer -ErrorAction Stop + } + } + + It 'flags misordered CmdletBinding options' { + $ScriptDefinition = @' +function Get-Thing { + [CmdletBinding( + SupportsShouldProcess = $False, + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/example/repo/blob/main/docs/reference/functions.md#get-thing', + PositionalBinding = $False, + SupportsPaging = $False + )] + [OutputType([System.String])] + param () + [System.String]'thing' +} +'@ + + $Results = Invoke-ScriptAnalyzer ` + -ScriptDefinition $ScriptDefinition ` + -CustomRulePath $script:AnalyzerRulePath ` + -IncludeRule 'Measure-CanonicalAttributeOrder' + + $Results = @($Results | Where-Object -FilterScript { $PSItem.RuleName -eq 'Measure-CanonicalAttributeOrder' }) + + $Results.RuleName | Should -Contain 'Measure-CanonicalAttributeOrder' + $Results.Message | Should -Match 'SG-5a' + } + + It 'flags misordered Parameter attribute arguments' { + $ScriptDefinition = @' +function ConvertTo-Thing { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/example/repo/blob/main/docs/reference/functions.md#convertto-thing', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.String])] + param ( + [Parameter(ValueFromPipeline = $True, Mandatory = $True)] + [System.String] + $InputObject + ) + process { + $InputObject + } +} +'@ + + $Results = Invoke-ScriptAnalyzer ` + -ScriptDefinition $ScriptDefinition ` + -CustomRulePath $script:AnalyzerRulePath ` + -IncludeRule 'Measure-CanonicalAttributeOrder' + + $Results = @($Results | Where-Object -FilterScript { $PSItem.RuleName -eq 'Measure-CanonicalAttributeOrder' }) + + $Results.RuleName | Should -Contain 'Measure-CanonicalAttributeOrder' + $Results.Message | Should -Match 'SG-5b' + } + + It 'flags validation attributes after the type literal' { + $ScriptDefinition = @' +function Get-Thing { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/example/repo/blob/main/docs/reference/functions.md#get-thing', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.String])] + param ( + [Parameter(Mandatory = $True)] + [System.String] + [ValidateNotNullOrEmpty()] + $Name + ) + $Name +} +'@ + + $Results = Invoke-ScriptAnalyzer ` + -ScriptDefinition $ScriptDefinition ` + -CustomRulePath $script:AnalyzerRulePath ` + -IncludeRule 'Measure-CanonicalAttributeOrder' + + $Results = @($Results | Where-Object -FilterScript { $PSItem.RuleName -eq 'Measure-CanonicalAttributeOrder' }) + + $Results.RuleName | Should -Contain 'Measure-CanonicalAttributeOrder' + $Results.Message -join [System.Environment]::NewLine | Should -Match 'type must be last' + } + + It 'flags wrong parameter attribute order' { + $ScriptDefinition = @' +function Get-Thing { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/example/repo/blob/main/docs/reference/functions.md#get-thing', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.String])] + param ( + [ValidateNotNullOrEmpty()] + [Parameter(Mandatory = $True)] + [System.String] + $Name + ) + $Name +} +'@ + + $Results = Invoke-ScriptAnalyzer ` + -ScriptDefinition $ScriptDefinition ` + -CustomRulePath $script:AnalyzerRulePath ` + -IncludeRule 'Measure-CanonicalAttributeOrder' + + $Results = @($Results | Where-Object -FilterScript { $PSItem.RuleName -eq 'Measure-CanonicalAttributeOrder' }) + + $Results.RuleName | Should -Contain 'Measure-CanonicalAttributeOrder' + $Results.Message | Should -Match 'SG-5c' + } + + It 'flags unsorted parameter names' { + $ScriptDefinition = @' +function Get-Thing { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/example/repo/blob/main/docs/reference/functions.md#get-thing', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.String])] + param ( + [Parameter()] + [System.String] + $Zoo, + + [Parameter()] + [System.String] + $Alpha + ) + $Alpha + $Zoo +} +'@ + + $Results = Invoke-ScriptAnalyzer ` + -ScriptDefinition $ScriptDefinition ` + -CustomRulePath $script:AnalyzerRulePath ` + -IncludeRule 'Measure-CanonicalAttributeOrder' + + $Results = @($Results | Where-Object -FilterScript { $PSItem.RuleName -eq 'Measure-CanonicalAttributeOrder' }) + + $Results.RuleName | Should -Contain 'Measure-CanonicalAttributeOrder' + $Results.Message | Should -Match 'SG-5d' + } + + It 'does not flag unsorted parameter names when explicit Position trips the guard' { + $ScriptDefinition = @' +function Get-Thing { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/example/repo/blob/main/docs/reference/functions.md#get-thing', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.String])] + param ( + [Parameter(Position = 1)] + [System.String] + $Zoo, + + [Parameter()] + [System.String] + $Alpha + ) + $Alpha + $Zoo +} +'@ + + $Results = Invoke-ScriptAnalyzer ` + -ScriptDefinition $ScriptDefinition ` + -CustomRulePath $script:AnalyzerRulePath ` + -IncludeRule 'Measure-CanonicalAttributeOrder' + + $Results = @($Results | Where-Object -FilterScript { $PSItem.RuleName -eq 'Measure-CanonicalAttributeOrder' }) + + $Results | Should -HaveCount 0 + } + + It 'accepts the canonical declaration idiom' { + $ScriptDefinition = @' +function ConvertTo-Thing { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/example/repo/blob/main/docs/reference/functions.md#convertto-thing', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.String])] + param ( + [Parameter(Mandatory = $True)] + [ValidateNotNullOrEmpty()] + [System.String] + $Alpha, + + [Parameter(Mandatory = $True, ValueFromPipeline = $True)] + [Alias('n')] + [ValidateNotNullOrEmpty()] + [System.String] + $Name + ) + process { + $Name + } +} +'@ + + $Results = Invoke-ScriptAnalyzer ` + -ScriptDefinition $ScriptDefinition ` + -CustomRulePath $script:AnalyzerRulePath ` + -IncludeRule 'Measure-CanonicalAttributeOrder' + + $Results = @($Results | Where-Object -FilterScript { $PSItem.RuleName -eq 'Measure-CanonicalAttributeOrder' }) + + $Results | Should -HaveCount 0 + } +}