Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 147 additions & 32 deletions Tests/Documentation/RuleDocumentation.tests.ps1
Original file line number Diff line number Diff line change
@@ -1,52 +1,167 @@
Describe "Validate rule documentation files" {
BeforeAll {
$ruleDocDirectory = Join-Path $PSScriptRoot '../../docs/Rules'
$docs = Get-ChildItem $ruleDocDirectory/*.md -Exclude README.md |
ForEach-Object { "PS" + $_.BaseName} | Sort-Object
$docInfoList = @{}
Get-ChildItem $ruleDocDirectory/*.md -Exclude README.md |
ForEach-Object {
$sev = Select-String -Path $_ -Pattern '\*\*Severity Level: (?<sev>\w+)\*\*'
$def = Select-String -Path $_ -Pattern '\*\*Default state: (?<def>\w+\s?\w+)\*\*'
$docInfoList.Add(('PS' + $_.BaseName), [pscustomobject]@{
FileName = $_.Name
Severity = $sev.Matches.Groups.Where({$_.Name -eq 'sev'}).Value
DefState = $def.Matches.Groups.Where({$_.Name -eq 'def'}).Value
})
}
#$docInfoList

$rules = Get-ScriptAnalyzerRule | ForEach-Object RuleName | Sort-Object
$ruleList = Get-ScriptAnalyzerRule |
Sort-Object RuleName |
Select-Object -Property RuleName, Severity
#$ruleList

$readmeLinks = @{}
$readmeRules = Get-Content -LiteralPath $ruleDocDirectory/README.md |
Foreach-Object { if ($_ -match '^\s*\|\s*\[([^]]+)\]\(([^)]+)\)\s*\|') {
$ruleName = $matches[1] -replace '<sup>.</sup>$', ''
$readmeLinks["$ruleName"] = $matches[2]
"PS${ruleName}"
}} |
Sort-Object
$ruleTable = @()
$linkDefs = @{}
$linkDefLine = @{}
$usedRefs = @{}
# Regex patterns.
# Table rule cell: | [RuleName][ref] | Severity | Default state |... | -> capture name, ref, severity, defstate.
$ruleRowRegex = '^\|\s*\[(?<name>[^\]]+)\]\[(?<ref>[^\]]+)\]\s*\|\s*(?<severity>[^|]+?)\s*\|(?<defstate>[^|]+?)\s*\|'
# Link definition: [ref]: target (target may include an #anchor).
$linkDefRegex = '^\[(?<ref>[^\]]+)\]:\s*(?<target>\S+)'
# Any reference-style usage anywhere: ...][ref]...
$refUsageRegex = '\]\[(?<ref>[^\]]+)\]'
$lines = Get-Content (Join-Path $ruleDocDirectory 'README.md')
$lineNumber = 0
foreach ($line in $lines) {
$lineNumber++
if ($line -match $ruleRowRegex) {
$ruleTable += [pscustomobject]@{
RowName = $Matches['name'].Trim()
RuleName = 'PS' + $Matches['name'].Trim()
Ref = $Matches['ref'].Trim()
Severity = $Matches['severity'].Trim()
DefState = $Matches['defstate'].Trim()
Line = $lineNumber
}
}

$rulesDocsDiff = Compare-Object -ReferenceObject $rules -DifferenceObject $docs -SyncWindow 25
$rulesReadmeDiff = Compare-Object -ReferenceObject $rules -DifferenceObject $readmeRules -SyncWindow 25
if ($line -match $linkDefRegex) {
$ref = $Matches['ref'].Trim()
$linkDefs[$ref] = $Matches['target'].Trim()
$linkDefLine[$ref] = $lineNumber
}

# Collect every reference usage (table rows and prose) for orphan detection.
foreach ($match in [regex]::matches($line, $refUsageRegex)) {
$usedRefs[$match.Groups['ref'].Value] = 1
}
}
}

It "Every rule must have a rule documentation file" {
$rulesDocsDiff | Where-Object SideIndicator -eq "<=" | Foreach-Object InputObject | Should -BeNullOrEmpty
#########################################################################################

It 'Every rule documentation file must be a defined rule' {
$result = $true
foreach ($rule in $docInfoList.Keys) {
if ($rule -notin $ruleList.RuleName) {
Write-Host "Rule not defined for file: $($docInfoList[$rule].FileName)"
$result = $false
}
}
$result | Should -Be $true
}

It 'Every defined rule must have a documentation file' {
$result = $true
foreach ($rule in $ruleList.RuleName) {
if ($rule -notin $docInfoList.Keys) {
Write-Host "Missing documentation file for rule: $($rule)"
$result = $false
}
}
$result | Should -Be $true
}
It "Every rule documentation file must have a corresponding rule" {
$rulesDocsDiff | Where-Object SideIndicator -eq "=>" | Foreach-Object InputObject | Should -BeNullOrEmpty

It 'Every rule doc must have the correct defined severity' {
$result = $true
foreach ($rule in $ruleList) {
if ($rule.Severity -ne $docInfoList[$rule.RuleName].Severity) {
Write-Host "Severity mismatch for rule: $($rule.RuleName). Defined: $($rule.Severity), Doc: $($docInfoList[$rule.RuleName].Severity)"
$result = $false
}
}
$result | Should -Be $true
}

It "Every rule must have an entry in the rule documentation README.md file" {
$rulesReadmeDiff | Where-Object SideIndicator -eq "<=" | Foreach-Object InputObject | Should -BeNullOrEmpty
It 'Every rule in the table must have the correct severity and default state' {
$result = $true
foreach ($ruleRow in $ruleTable) {
$definedRule = $ruleList | Where-Object { $_.RuleName -eq $ruleRow.RuleName }
if ($null -eq $definedRule) {
Write-Host "Rule in table not found in defined rules: $($ruleRow.RowName)"
$result = $false
continue
}
if ($ruleRow.RuleName -notin $docInfoList.Keys) {
Write-Host "Rule in table not found in documentation: $($ruleRow.RowName)"
$result = $false
continue
}
$docInfo = $docInfoList[$ruleRow.RuleName]
if ($ruleRow.Severity -ne $docInfo.Severity) {
Write-Host "Severity mismatch for rule: $($ruleRow.RowName). Table: $($ruleRow.Severity), Doc: $($docInfo.Severity)"
$result = $false
}
if ($ruleRow.DefState -ne $docInfo.DefState) {
Write-Host "Default state mismatch for rule: $($ruleRow.RowName). Table: $($ruleRow.DefState), Doc: $($docInfo.DefState)"
$result = $false
}
}
$result | Should -Be $true
}
It "Every entry in the rule documentation README.md file must correspond to a rule" {
$rulesReadmeDiff | Where-Object SideIndicator -eq "=>" | Foreach-Object InputObject | Should -BeNullOrEmpty

It 'Every link definition must be used at least once' {
$result = $true
foreach ($ref in $linkDefs.Keys) {
if ($ref -notin $usedRefs.Keys) {
Write-Host "Unused link definition: $ref (defined at line $($linkDefLine[$ref]))"
$result = $false
}
}
$result | Should -Be $true
}

It "Every entry in the rule documentation README.md file must have a valid link to the documentation file" {
foreach ($key in $readmeLinks.Keys) {
$link = $readmeLinks[$key]
$filePath = Join-Path $ruleDocDirectory $link
$filePath | Should -Exist
It 'Every link definition that points to a rule must have a matching rule in the table' {
$result = $true
foreach ($ref in $linkDefs.Keys) {
$target = $linkDefs[$ref]
$isRuleFile = $targetFile -match '\.md$' -and $targetFile -notmatch '/'

if ($isRuleFile) {
# A rule-page link definition with no matching table row.
if ($ref -notin $ruleTable.Ref) {
Write-Host "Orphan rule target: Link definition [$ref] -> '$target' has no matching rule in the table (defined at line $($linkDefLine[$ref]))."
$result = $false
}
}
}
$result | Should -Be $true
}

It "Every rule name in the rule documentation README.md file must match the documentation file's basename" {
foreach ($key in $readmeLinks.Keys) {
$link = $readmeLinks[$key]
$filePath = Join-Path $ruleDocDirectory $link
$fileName = Split-Path $filePath -Leaf
$fileName | Should -BeExactly "${key}.md"
It 'Every link definition target must have a valid file path' {
$result = $true
foreach ($ref in $linkDefs.Keys) {
$target = $linkDefs[$ref]
$isRuleFile = $targetFile -match '\.md$' -and $targetFile -notmatch '/'

if ($isRuleFile) {
# A rule-page link definition with no matching table row.
if ($target -notin $ruleTable.FileName) {
Write-Host "Orphan rule target: Link definition [$ref] -> '$target' has no matching rule file."
$result = $false
}
}
}
$result | Should -Be $true
}
}
3 changes: 2 additions & 1 deletion docs/Cmdlets/Invoke-Formatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ schema: 2.0.0
# Invoke-Formatter

## SYNOPSIS

Formats a script text based on the input settings or default settings.

## SYNTAX
Expand Down Expand Up @@ -76,7 +77,7 @@ function foo
}
```

### EXAMPLE 3 - Format the input script text using the settings defined a `.psd1` file
### EXAMPLE 3 - Format the input script text using the settings defined in a `.psd1` file

```powershell
Invoke-Formatter -ScriptDefinition $scriptDefinition -Settings /path/to/settings.psd1
Expand Down
77 changes: 43 additions & 34 deletions docs/Cmdlets/Invoke-ScriptAnalyzer.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
external help file: Microsoft.Windows.PowerShell.ScriptAnalyzer.dll-Help.xml
Module Name: PSScriptAnalyzer
ms.date: 10/07/2021
ms.date: 07/23/2026
online version: https://learn.microsoft.com/powershell/module/psscriptanalyzer/invoke-scriptanalyzer?view=ps-modules&wt.mc_id=ps-gethelp
schema: 2.0.0
---
Expand Down Expand Up @@ -107,7 +107,12 @@ This example runs all rules except for **PSAvoidUsingCmdletAliases** and
subdirectories.

```powershell
Invoke-ScriptAnalyzer -Path C:\ps-test\MyModule -Recurse -ExcludeRule PSAvoidUsingCmdletAliases, PSAvoidUsingInternalURLs
$invokeScriptAnalyzerSplat = @{
Path = 'C:\ps-test\MyModule'
Recurse = $true
ExcludeRule = 'PSAvoidUsingCmdletAliases', 'PSAvoidUsingInternalURLs'
}
Invoke-ScriptAnalyzer @invokeScriptAnalyzerSplat
```

### EXAMPLE 5 - Run Script Analyzer with custom rules
Expand All @@ -116,13 +121,19 @@ This example runs Script Analyzer on `Test-Script.ps1` with the standard rules a
`C:\CommunityAnalyzerRules` path.

```powershell
Invoke-ScriptAnalyzer -Path D:\test_scripts\Test-Script.ps1 -CustomRulePath C:\CommunityAnalyzerRules -IncludeDefaultRules
$invokeScriptAnalyzerSplat = @{
Path = 'D:\test_scripts\Test-Script.ps1'
CustomRulePath = 'C:\CommunityAnalyzerRules'
IncludeDefaultRules = $true
}
Invoke-ScriptAnalyzer @invokeScriptAnalyzerSplat
```

### EXAMPLE 6 - Run only the rules that are Error severity and have the PSDSC source name

```powershell
$DSCError = Get-ScriptAnalyzerRule -Severity Error | Where SourceName -eq PSDSC
$DSCError = Get-ScriptAnalyzerRule -Severity Error |
Where-Object SourceName -eq PSDSC
$Path = "$home\Documents\WindowsPowerShell\Modules\MyDSCModule"
Invoke-ScriptAnalyzerRule -Path $Path -IncludeRule $DSCError -Recurse
```
Expand All @@ -145,34 +156,32 @@ function Get-Widgets
{
[CmdletBinding()]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingCmdletAliases", "", Justification="Resolution in progress.")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingCmdletAliases", "",
Justification="Resolution in progress.")]
Param()

dir $pshome
dir $PSHOME
...
}

Invoke-ScriptAnalyzer -Path .\Get-Widgets.ps1
```

```Output
RuleName Severity FileName Line Message
-------- -------- -------- ---- -------
PSProvideCommentHelp Information ManageProf 14 The cmdlet 'Get-Widget' does not have a help comment.
iles.psm1
RuleName Severity FileName Line Message
-------- -------- -------- ---- -------
PSProvideCommentHelp Information ManageProfiles.psm1 14 The cmdlet 'Get-Widget' does not have a help comment.
```

```powershell
Invoke-ScriptAnalyzer -Path .\Get-Widgets.ps1 -SuppressedOnly
```

```Output
Rule Name Severity File Name Line Justification
--------- -------- --------- ---- -------------
PSAvoidUsingCmdletAliases Warning ManageProf 21 Resolution in progress.
iles.psm1
PSUseSingularNouns Warning ManageProf 14
iles.psm1
Rule Name Severity File Name Line Justification
--------- -------- --------- ---- -------------
PSAvoidUsingCmdletAliases Warning ManageProfiles.psm1 21 Resolution in progress.
PSUseSingularNouns Warning ManageProfiles.psm1 14
```

The second command uses the **SuppressedOnly** parameter to report violations of the rules that are
Expand All @@ -192,7 +201,7 @@ value of the **Profile** parameter is the path to the Script Analyzer profile.
ExcludeRules = '*WriteHost'
}

Invoke-ScriptAnalyzer -Path $pshome\Modules\BitLocker -Settings .\ScriptAnalyzerProfile.txt
Invoke-ScriptAnalyzer -Path $PSHOME\Modules\BitLocker -Settings .\ScriptAnalyzerProfile.txt
```

If you include a conflicting parameter in the `Invoke-ScriptAnalyzer` command, such as
Expand All @@ -208,15 +217,16 @@ Invoke-ScriptAnalyzer -ScriptDefinition "function Get-Widgets {Write-Host 'Hello
```

```Output
RuleName Severity FileName Line Message
-------- -------- -------- ---- -------
PSAvoidUsingWriteHost Warning 1 Script
because
there i
suppres
Write-O
PSUseSingularNouns Warning 1 The cmd
noun sh
RuleName Severity FileName Line Message
-------- -------- -------- ---- -------
PSAvoidUsingWriteHost Warning 1 Script definition uses Write-Host. Avoid using
Write-Host because it might not work in all hosts,
does not work when there is no host, and (prior
to PS 5.0) cannot be suppressed, captured, or
redirected. Instead, use Write-Output, Write-Verbose,
or Write-Information.
PSUseSingularNouns Warning 1 The cmdlet 'Get-Widgets' uses a plural noun. A
singular noun should be used instead.
```

When you use the **ScriptDefinition** parameter, the **FileName** property of the
Expand Down Expand Up @@ -513,7 +523,7 @@ following keys:

The keys and values in the profile are interpreted as if they were standard parameters and values of
`Invoke-ScriptAnalyzer`, similar to splatting. For more information, see
[about_Splatting](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_splatting).
[about_Splatting](/powershell/module/microsoft.powershell.core/about/about_splatting).

```yaml
Type: Object
Expand All @@ -536,15 +546,14 @@ Valid values are:

- Error
- Warning
- Information.

You can specify one ore more severity values.
- Information
- ParseError

The parameter filters the rules violations only after running all rules. To filter rules
efficiently, use `Get-ScriptAnalyzerRule` to select the rules you want to run.
You can specify one or more severity values.

The **Severity** parameter takes precedence over **IncludeRule**. For example, if **Severity** is
`Error`, you cannot use **IncludeRule** to include a `Warning` rule.
The parameter filters the rule violation output only after running all rules. It doesn't filter
which rules are run. To filter rules efficiently, use `Get-ScriptAnalyzerRule` to select the rules
you want to run.

```yaml
Type: String[]
Expand Down
Loading
Loading