Skip to content
Merged
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [0.4.0] 2026-05-04

### Added

- `Measure-GremlinCharacter`: New rule to detect invisible or visually
deceptive Unicode characters (gremlins) such as zero-width spaces,
bidirectional overrides, and curly quotes. 19 characters flagged with
per-character severity (`Error`, `Warning`, or `Information`).
Inspired by [vscode-gremlins](https://github.com/nhoizey/vscode-gremlins).
- Tests for `Measure-GremlinCharacter` with per-character `-ForEach`
cases, a negative clean-code case, a fixture-based detection test,
and a suppression test using `SuppressMessageAttribute`.
- `CLAUDE.md` with project guidance for Claude Code.

### Changed

- `Measure-TODOComment`: Updated `Token` parameter type to `Token[]`
to match how PSScriptAnalyzer invokes token-based rules; renamed
`$matches` to `$regexMatches` to avoid collision with the automatic
`$Matches` variable; normalized keyword casing to lowercase.
- `tests/PSScriptAnalyzerRules.psm1`: Proxy module now loads explicitly
from the `Output\GoodEnoughRules` build directory.

## [0.3.1] 2025-12-14

### Fixed
Expand Down
68 changes: 68 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

Bootstrap dependencies (first time or after `requirements.psd1` changes):
```powershell
.\build.ps1 -Bootstrap
```

Build (compiles module to `Output/`):
```powershell
.\build.ps1
```

Run all tests:
```powershell
.\build.ps1 -Task Test
```

Run a single test file:
```powershell
.\build.ps1 -Task Build
Invoke-Pester -Path .\tests\Measure-TODOComment.tests.ps1
```

List available tasks:
```powershell
.\build.ps1 -Help
```

Spell check:
```powershell
cspell lint "**/*.ps1" "**/*.md"
```

## Architecture

This is a **PSScriptAnalyzer custom rules module**. The build uses `psake` → `PowerShellBuild`, which compiles all `Public/*.ps1` files into a single monolithic `GoodEnoughRules.psm1` in `Output/<version>/`.

**Tests run against the compiled `Output/` module**, not the source files. Always build before running tests.

### Rule conventions

Each rule lives in `GoodEnoughRules/Public/Measure-<Name>.ps1` and exports one function named `Measure-<Name>`.

PSScriptAnalyzer custom rules come in two shapes:

- **AST rules** — parameter type is an AST node (e.g., `[ScriptBlockAst]`). PSScriptAnalyzer calls the rule once per matching node.
- **Token rules** — parameter type is `[System.Management.Automation.Language.Token[]]`. PSScriptAnalyzer passes **all tokens as a single array** in one call. The rule must iterate over `$Token` internally with `foreach ($tok in $Token)`.

> **Important:** Token rules receive `Token[]` from `Invoke-ScriptAnalyzer`, but tests that call the function directly (e.g., `$tokens | ForEach-Object { Measure-X -Token $_ }`) pass one token per call. The `foreach ($tok in $Token)` pattern handles both.

Rules return `[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]` objects via hashtable constructor with keys: `Message`, `Extent`, `RuleName` (`$PSCmdlet.MyInvocation.InvocationName`), `Severity` (`'Error'`, `'Warning'`, or `'Information'`).

### Tests

Each rule has a corresponding `tests/Measure-<Name>.tests.ps1`. Tests use Pester 5 and follow these patterns:

- **Inline token tests** — parse a fake script string with `[Parser]::ParseInput(...)`, pipe tokens to the rule function directly, assert on the result.
- **Path tests** — use `Invoke-ScriptAnalyzer -Path` with `CustomRulePath` pointing to `$script:outputModVerModule` (the compiled `.psm1`). Fixture files live in `tests/fixtures/`.
- **Suppression test** — a fixture file with `[Diagnostics.CodeAnalysis.SuppressMessageAttribute('Measure-RuleName', '')]` on a function; run via `Invoke-ScriptAnalyzer -Path` and assert empty result.
- **`-ForEach` cases** — use hashtable entries (`@{ Key = Value }`) so test names interpolate as `'Detects <Description>'`.

### Docs

`docs/en-US/Measure-<Name>.md` — PlatyPS-style help. Kept in sync with the function's comment-based help.
2 changes: 1 addition & 1 deletion GoodEnoughRules/GoodEnoughRules.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
RootModule = 'GoodEnoughRules.psm1'

# Version number of this module.
ModuleVersion = '0.3.1'
ModuleVersion = '0.4.0'

# Supported PSEditions
# CompatiblePSEditions = @()
Expand Down
76 changes: 76 additions & 0 deletions GoodEnoughRules/Public/Measure-GremlinCharacter.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
function Measure-GremlinCharacter {
<#
.SYNOPSIS
Rule to detect invisible or visually deceptive Unicode characters (gremlins).
.DESCRIPTION
This rule detects Unicode characters that are invisible or visually similar to
legitimate characters, such as zero-width spaces, bidirectional overrides, and
curly quotes. These characters can introduce subtle bugs or security issues that
are nearly impossible to see in an editor.
.EXAMPLE
Measure-GremlinCharacter -Token $Token

This will check if the given Token contains any gremlin characters.
.PARAMETER Token
The token to check for gremlin characters.
.INPUTS
[System.Management.Automation.Language.Token[]]
.OUTPUTS
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
.NOTES
Inspired by https://github.com/nhoizey/vscode-gremlins
#>
[CmdletBinding()]
[OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord])]
param
(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[System.Management.Automation.Language.Token[]]
$Token
)

begin {
# Maps Unicode char to description + PSScriptAnalyzer severity
$script:gremlins = [ordered]@{
[char]0x0003 = @{ Description = 'end of text'; Severity = 'Warning' }
[char]0x000B = @{ Description = 'line tabulation'; Severity = 'Warning' }
[char]0x00A0 = @{ Description = 'non-breaking space'; Severity = 'Information' }
[char]0x00AD = @{ Description = 'soft hyphen'; Severity = 'Information' }
[char]0x200B = @{ Description = 'zero width space'; Severity = 'Error' }
[char]0x200C = @{ Description = 'zero width non-joiner'; Severity = 'Warning' }
[char]0x200E = @{ Description = 'left-to-right mark'; Severity = 'Error' }
[char]0x2013 = @{ Description = 'en dash'; Severity = 'Warning' }
[char]0x2018 = @{ Description = 'left single quotation mark'; Severity = 'Warning' }
[char]0x2019 = @{ Description = 'right single quotation mark'; Severity = 'Warning' }
[char]0x201C = @{ Description = 'left double quotation mark'; Severity = 'Warning' }
[char]0x201D = @{ Description = 'right double quotation mark'; Severity = 'Warning' }
[char]0x2029 = @{ Description = 'paragraph separator'; Severity = 'Error' }
[char]0x2066 = @{ Description = 'left-to-right isolate'; Severity = 'Error' }
[char]0x2069 = @{ Description = 'pop directional isolate'; Severity = 'Error' }
[char]0x202C = @{ Description = 'pop directional formatting'; Severity = 'Error' }
[char]0x202D = @{ Description = 'left-to-right override'; Severity = 'Error' }
[char]0x202E = @{ Description = 'right-to-left override'; Severity = 'Error' }
[char]0xFFFC = @{ Description = 'object replacement character'; Severity = 'Error' }
}
}

process {
foreach ($tok in $Token) {
$text = $tok.Extent.Text

foreach ($char in $script:gremlins.Keys) {
if (-not $text.Contains($char)) {
continue
}
$info = $script:gremlins[$char]
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
'Message' = "Gremlin character found: U+{0:X4} ({1}). This character may be invisible or visually deceptive." -f [int]$char, $info.Description
'Extent' = $tok.Extent
'RuleName' = $PSCmdlet.MyInvocation.InvocationName
'Severity' = $info.Severity
}
}
}
}
}
16 changes: 8 additions & 8 deletions GoodEnoughRules/Public/Measure-TODOComment.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,23 @@
.PARAMETER Token
The token to check for TODO comments.
.INPUTS
[System.Management.Automation.Language.Token]
[System.Management.Automation.Language.Token[]]
.OUTPUTS
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
.NOTES
None
#>
[CmdletBinding()]
[OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord])]
Param
param
(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[System.Management.Automation.Language.Token]
[System.Management.Automation.Language.Token[]]
$Token
)

Begin {
begin {
$toDoIndicators = @(
'TODO',
'FIXME',
Expand All @@ -37,26 +37,26 @@
) -join '|'
$regExOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase #, IgnorePatternWhitespace, Multiline"
# TODO: Add more comments to make it easier to understand the regular expression.
# so meta hehe

Check warning on line 40 in GoodEnoughRules/Public/Measure-TODOComment.ps1

View workflow job for this annotation

GitHub Actions / ci / Run Linters

Unknown word (hehe) Suggestions: (hebe, heme, here, Hebe, Here)
$regExPattern = "((\/\/|#|<!--|;|\*|^)((\s+(!|\?|\*|\-))?(\s+\[ \])?|(\s+(!|\?|\*)\s+\[.\])?)\s*($toDoIndicators)\s*\:?)"
$regEx = [regex]::new($regExPattern, $regExOptions)
}

Process {
process {
if (-not $Token.Type -ne 'Comment') {
return
}
#region Finds ASTs that match the predicates.
foreach ($i in $Token.Extent.Text) {
try {
$matches = $regEx.Matches($i)
$regexMatches = $regEx.Matches($i)
} catch {
$PSCmdlet.ThrowTerminatingError($PSItem)
}
if ($matches.Count -eq 0) {
if ($regexMatches.Count -eq 0) {
continue
}
$matches | ForEach-Object {
$regexMatches | ForEach-Object {
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
'Message' = "TODO comment found. Please consider removing it or tracking with issue."
'Extent' = $Token.Extent
Expand Down
89 changes: 89 additions & 0 deletions docs/en-US/Measure-GremlinCharacter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
external help file: GoodEnoughRules-help.xml
Module Name: GoodEnoughRules
online version:
schema: 2.0.0
---

# Measure-GremlinCharacter

## SYNOPSIS
Rule to detect invisible or visually deceptive Unicode characters (gremlins).

## SYNTAX

```
Measure-GremlinCharacter [-Token] <Token[]> [-ProgressAction <ActionPreference>] [<CommonParameters>]
```

## DESCRIPTION
This rule detects Unicode characters that are invisible or visually similar to
legitimate characters, such as zero-width spaces, bidirectional overrides, and
curly quotes. These characters can introduce subtle bugs or security issues that
are nearly impossible to see in an editor.

Severity levels reflect how dangerous the character is:

- **Error** - Bidirectional overrides, zero-width spaces, and control characters
that can actively obscure code intent or enable Trojan-source attacks.
- **Warning** - Typographic characters (curly quotes, en dash) that are unlikely
to be intentional in source code and may cause parse errors.
- **Information** - Characters like non-breaking spaces that are rarely intentional
but generally harmless.

Inspired by the [vscode-gremlins](https://github.com/nhoizey/vscode-gremlins) extension.

## EXAMPLES

### EXAMPLE 1
```
Measure-GremlinCharacter -Token $Token
```

This will check if the given Token contains any gremlin characters.

## PARAMETERS

### -Token
The token to check for gremlin characters.

```yaml
Type: Token[]
Parameter Sets: (All)
Aliases:

Required: True
Position: 1
Default value: None
Accept pipeline input: False
Accept wildcard characters: False
```

### -ProgressAction
{{ Fill ProgressAction Description }}

```yaml
Type: ActionPreference
Parameter Sets: (All)
Aliases: proga

Required: False
Position: Named
Default value: None
Accept pipeline input: False
Accept wildcard characters: False
```

### CommonParameters
This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216).

## INPUTS

### [System.Management.Automation.Language.Token]
## OUTPUTS

### [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
## NOTES
Inspired by https://github.com/nhoizey/vscode-gremlins

## RELATED LINKS
4 changes: 2 additions & 2 deletions docs/en-US/Measure-TODOComment.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Rule to detect if TODO style comments are present.
## SYNTAX

```
Measure-TODOComment [-Token] <Token> [-ProgressAction <ActionPreference>] [<CommonParameters>]
Measure-TODOComment [-Token] <Token[]> [-ProgressAction <ActionPreference>] [<CommonParameters>]
```

## DESCRIPTION
Expand All @@ -34,7 +34,7 @@ This would check if the given ScriptBlockAst contains any TODO comments.
The token to check for TODO comments.

```yaml
Type: Token
Type: Token[]
Parameter Sets: (All)
Aliases:

Expand Down
1 change: 0 additions & 1 deletion tests/Measure-BasicWebRequestProperty.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ Describe 'Measure-BasicWebRequestProperty' {
# Remove all versions of the module from the session. Pester can't handle multiple versions.
Get-Module $env:BHProjectName | Remove-Module -Force -ErrorAction Ignore
Import-Module -Name $outputModVerManifest -Verbose:$false -ErrorAction Stop
Import-Module -Name 'PSScriptAnalyzer' -Verbose:$false -ErrorAction Inquire
}
Context 'Method usage with Invoke-WebRequest and UseBasicParsing' {
It 'Detects bad method usage' {
Expand Down
Loading
Loading