Version: 2.21.20260623.0
- Status: Active
- Owner: Repository Maintainers
- Last Updated: 2026-06-23
- Scope: PowerShell coding standards for all
.ps1files in this repository — style, formatting, naming, error handling, documentation, and compatibility patterns for both legacy (v1.0) and modern (v2.0+) codebases.
Per RFC 2119: MUST / SHALL / REQUIRED = absolute requirement; MUST NOT / SHALL NOT = absolute prohibition; SHOULD / RECOMMENDED = strong recommendation (deviations require justification); SHOULD NOT / NOT RECOMMENDED = strong discouragement; MAY / OPTIONAL = truly optional.
Scope tags: [All] = all PowerShell versions, [Modern] = PowerShell v2.0+ (requires features not available in v1.0), [v1.0] = backward compatible with Windows PowerShell v1.0. Each item links to its detailed section.
- [All] Code MUST use 4 spaces for indentation, never tabs → Indentation Rules
- [All] Opening braces MUST be placed on same line (OTBS) → Brace Placement (OTBS)
- [All]
catch,finally,elseMUST be on same line as closing brace → Exception: catch, finally, and else Keywords - [All] Code MUST use single space around operators, no extra alignment → Operator Spacing and Alignment
- [All] Operators MUST NOT be vertically aligned across multiple lines → Operator Spacing and Alignment
- [All] Multi-line method parameters MUST have extra indentation → Multi-line Method Indentation
- [All] Blank lines SHOULD be used sparingly: two around functions, one within → Blank Line Usage
- [All] Blank lines MUST NOT contain any whitespace (spaces or tabs) → Blank Line Usage
- [All] Lines MUST NOT end with trailing whitespace → Trailing Whitespace
- [All] Variables in strings SHOULD be delimited with
${}or-foperator → Variable Delimiting in Strings - [All] Source
.ps1files MUST be UTF-8 without BOM by default; see File Encoding for the Windows PowerShell/non-ASCII exception - [All] When writing text files programmatically, encoding MUST be specified explicitly; prefer
.NETfor cross-version UTF-8 without BOM → Programmatic File Writing Encoding - [All] When producing byte-exact text artifacts, serializer output MUST be normalized to LF in memory before writing or comparing → Line Endings for Byte-Exact Text Artifacts
- [All] Text-level file comparison MUST read files with
Get-Content -Rawor[System.IO.File]::ReadAllText()under a fixed encoding/BOM convention;Get-Contentwithout-RawMUST NOT be used → Line Endings for Byte-Exact Text Artifacts - [All] True byte-for-byte comparison MUST read files with
[System.IO.File]::ReadAllBytes(); this is required for hash/signature inputs and any other byte-exact identity check → Line Endings for Byte-Exact Text Artifacts
- [All] Public identifiers (functions, parameters, properties) MUST use PascalCase → Capitalization and Naming Conventions
- [All] PowerShell keywords (function, param, if, else, return, trap) MUST be lowercase → Capitalization and Naming Conventions
- [All] Local variables MUST use camelCase with type-hinting prefixes, fully descriptive (e.g., $strMessage, $intCount, no abbreviations) → Local Variable Naming: Type-Prefixed camelCase
- [All] Functions MUST follow Verb-Noun pattern with approved verbs → Script and Function Naming: Full Explicit Form
- [All] Functions MUST use singular nouns in function names → Script and Function Naming: Nouns
- [All] Modules MUST use PascalCase nouns (containers, not actions) → Module Naming: Noun-Based Containers
- [Modern] Module manifest
Tagskey MUST be populated aggressively with relevant keywords for discoverability → Module Naming: Noun-Based Containers - [All] Aliases MUST NOT be used in code → Do Not Use Aliases
- [Modern] Modules MUST NOT export compatibility aliases (exception: genuine interactive shortcuts) → Do Not Use Aliases
- [All] Parameters MUST use PascalCase, fully descriptive names → Parameter Naming
- [v1.0] Reference parameters MUST use "ReferenceTo" prefix → Parameter Naming
- [All] Code SHOULD avoid relative paths and tilde (~) shortcut → Path and Scope Handling
- [All] Code SHOULD use explicit scoping ($global:, $script:) → Path and Scope Handling
- [All]
-LiteralPathSHOULD be used instead of-Pathwhen operating on concrete (non-wildcard) paths derived from variables orJoin-Path→ Prefer-LiteralPathOver-Pathfor Concrete Paths - [All] For destructive cmdlets (
Remove-Item,Move-Item),-LiteralPathMUST be used for variable-derived paths → Prefer-LiteralPathOver-Pathfor Concrete Paths - [All]
New-Itemdoes not support-LiteralPath; use-PathwithNew-Item→ Prefer-LiteralPathOver-Pathfor Concrete Paths - [All] For directory creation from variable-derived paths that may contain wildcard characters, prefer
[System.IO.Directory]::CreateDirectory()→ Prefer-LiteralPathOver-Pathfor Concrete Paths - [All] Paths passed to .NET file APIs (
System.IO.*) MUST be resolved to absolute viaGetUnresolvedProviderPathFromPSPath()first; non-FileSystem provider paths MUST NOT be used → Resolving Paths for .NET Static Methods
- [All] All functions MUST have full comment-based help → Comment-Based Help: Structure and Format
- [All] Comment-based help MUST be placed inside function body, above param block → Comment-Based Help: Structure and Format
- [All] Comment-based help MUST use single-line comments (#) with dotted keywords (.SYNOPSIS, .DESCRIPTION, etc.) → Comment-Based Help: Structure and Format
- [v1.0] Block comments (
<# ... #>) MUST NOT be used — they cause parser errors in PowerShell v1.0; use single-line comments (#) instead → Help Format Options: Comparison - [All] Comment-based help MUST include sections: .SYNOPSIS, .DESCRIPTION, .PARAMETER (one per parameter, if any), .EXAMPLE, .INPUTS, .OUTPUTS, .NOTES → Comment-Based Help: Structure and Format
- [All] Comment-based help MUST use exactly one comment blank line between top-level help sections → Comment-Based Help Spacing
- [All] Explanatory or output-description lines within
.EXAMPLEblocks MUST use double#(# # <text>) so thatGet-Helprenders them as valid PowerShell comments (# <text>) → Inline Comments Within.EXAMPLEBlocks - [All] Functions SHOULD provide multiple examples with input, output, and explanation → Help Content Quality: High Standards
- [All] Every possible output/return value MUST be documented in .OUTPUTS with exact type and meaning; integer status codes MUST include full code-to-meaning mapping; output examples MUST be placed in .EXAMPLE blocks → Help Content Quality: High Standards
- [All] Positional parameter support MUST be documented in .NOTES → Help Content Quality: High Standards
- [All] Private/internal helper functions'
.NOTESMUST begin with a private-helper banner → Private/Internal Helper Function Documentation - [Modern] Functions omitted from module manifest
FunctionsToExportare treated as private/internal helpers → Private/Internal Helper Function Documentation - [All] Positional parameter documentation for private/internal helpers SHOULD state it is an internal-caller contract only → Positional Parameter Support
- [All] Version number MUST be included in .NOTES (format: Major.Minor.YYYYMMDD.Revision) → Function and Script Versioning
- [All] Version build component MUST be current date in YYYYMMDD format → Function and Script Versioning
- [All]
.NOTESRevisionMUST reset to0whenMajor,Minor, orBuildchanges; otherwise increment (N + 1) for same-day updates → Function and Script Versioning - [All] Inline comments SHOULD focus on "why" not "what" → Inline Comments: Purpose and Placement
- [All] Code SHOULD use #region / #endregion for logical code folding → Structural Documentation: Regions and Licensing
- [All] The param() block MUST be placed before license region (if applicable) → Structural Documentation: Regions and Licensing
- [All] Distributable helpers SHOULD use per-function licensing (#region License after param block) → Structural Documentation: Regions and Licensing
- [All] Parameter documentation SHOULD be centralized in help block, not above individual parameters → Parameter Documentation Placement: Strategic Choice
- [v1.0] v1.0-targeted functions MUST NOT use [CmdletBinding()] attribute → Function Declaration and Structure
- [v1.0] v1.0-targeted functions MUST NOT use [OutputType()] attribute → Function Declaration and Structure
- [v1.0] v1.0-targeted functions MUST NOT use begin/process/end blocks → Function Declaration and Structure
- [v1.0] v1.0-targeted functions MUST NOT support pipeline input → Pipeline Behavior: Deliberately Disabled
- [v1.0] v1.0-targeted functions MUST use simple function keyword with param() block → Function Declaration and Structure
- [v1.0] Parameters MUST use strong typing → Parameter Block Design: Detailed Analysis
- [v1.0] v1.0-targeted functions MUST use explicit return statements → Return Semantics: Explicit Status Codes
- [v1.0] Reference parameters ([ref]) MUST be used for outputs requiring caller modification → Input/Output Contract: Reference Parameters
- [v1.0] Functions MUST return single integer status code (0=success, 1-5=partial, -1=failure) → Return Semantics: Explicit Status Codes
- [v1.0] Exception: Test-* functions MAY return Boolean when no practical error handling needed → Return Semantics: Explicit Status Codes
- [v1.0] Positional parameters SHOULD be supported for v1.0 usability → Positional Parameter Support
- [v1.0] v1.0-targeted functions MUST use trap-based error handling (not try/catch) → Core Error Suppression Mechanism
- [Modern] Modern functions and scripts MUST use [CmdletBinding()] attribute → Rule: "Modern Advanced" Function/Script Requirements (v2.0+)
- [Modern] Modern functions and scripts MUST use [OutputType()] declaring singular primary type → Rule: "Modern Advanced" Function/Script Requirements (v2.0+)
- [Modern] Modern functions and scripts MUST use streaming output (write objects directly to pipeline in loop) → Rule: "Modern Advanced" Function/Script Requirements (v2.0+)
- [Modern] Modern functions and scripts MUST use try/catch for error handling → Rule: "Modern Advanced" Function/Script Requirements (v2.0+)
- [Modern] Modern functions and scripts MUST use Write-Verbose and Write-Debug (not manual preference toggling) → Rule: "Modern Advanced" Function/Script Requirements (v2.0+)
- [Modern] Exception: Modern functions and scripts MAY temporarily suppress $VerbosePreference for noisy nested commands using try/finally → "Modern Advanced" Functions/Scripts: Exception for Suppressing Nested Verbose Streams
- [Modern] [Parameter(Mandatory=$true)] SHOULD be used only when function cannot work without value → "Modern Advanced" Functions/Scripts: Parameter Validation and Attributes (
[Parameter()]) - [Modern] [ValidateNotNullOrEmpty()] SHOULD be used for optional-but-not-empty parameters and for mandatory [string] parameters whose logic depends on a non-empty value → "Modern Advanced" Functions/Scripts: Parameter Validation and Attributes (
[Parameter()]) - [Modern] [ValidateRange(min, max)] SHOULD be used on numeric parameters with a constrained valid domain (counts, delays/timeouts, thresholds, percentages) so invalid input fails at parameter binding rather than producing a confusing downstream error → "Modern Advanced" Functions/Scripts: Parameter Validation and Attributes (
[Parameter()]) - [Modern] Multiple [OutputType()] SHOULD only be used for intentionally polymorphic returns → "Modern Advanced" Functions/Scripts: Handling Multiple or Dynamic Output Types
- [Modern] Subset-only positional contracts MUST use
PositionalBinding = $falsewith explicit[Parameter(Position = N)]→ Positional Parameter Support - [All] Functions MUST be atomic, reusable tools with single purpose → Function Declaration and Structure
- [All] Polymorphic parameters (multiple incompatible types) SHOULD be left un-typed or [object] → Parameter Block Design: Detailed Analysis
- [All] [ref] MUST be used exclusively for output requiring write-back to caller scope → Input/Output Contract: Reference Parameters
- [All] [ref] MUST NOT be used for complex objects that don't need modification → Input/Output Contract: Reference Parameters
- [v1.0] v1.0-targeted functions MUST use trap {} for error suppression → Core Error Suppression Mechanism
- [Modern] catch blocks MUST NOT be empty; default pattern is
Write-Debug+throw→ Modern catch Block Requirements - [Modern] Non-throwing catch (no
throw) MUST have a documented non-throwing contract → Modern catch Block Requirements - [Modern]
throw "message"andthrow ("fmt" -f $args)MUST NOT be used in catch blocks intended to rethrow → Rethrow Anti-Pattern - [Modern] Exception wrapping SHOULD use
$PSCmdlet.ThrowTerminatingError()with the original asInnerException→ Wrapping Exceptions with$PSCmdlet.ThrowTerminatingError() - [Modern] Variables referenced in
finallythat are assigned intryMUST be initialized before thetryblock → Set-StrictMode Considerations for finally Blocks - [Modern] In files bundled into a module or other aggregate script artifact,
Set-StrictMode -Version LatestMUST be placed at script scope as the first executable statement in the file, after any#requirescomments,usingstatements, and any script-level[CmdletBinding()]/paramblock → Set-StrictMode Placement for Dot-Sourced Files - [Modern] In files intended to be dot-sourced directly into the caller's scope (test fixtures, ad-hoc scripts, build tooling),
Set-StrictMode -Version LatestMUST NOT be placed at script scope; it MUST be placed inside the function body (as the first statement inbegin {}when using abegin/process/endlayout, or otherwise as the first statement in the function body) → Set-StrictMode Placement for Dot-Sourced Files
- [All] Scripts MUST verify file writeability before significant processing when writing output to files → File Writeability Testing
- [v1.0] v1.0-targeted scripts MUST use
.NETapproach (Test-FileWriteabilityfunction) → Approaches - [Modern] Scripts MAY use
.NETortry/catchapproach based on requirements → Approaches
- [All] Scripts/functions supporting only specific operating systems MUST include OS compatibility checks → Operating System Compatibility Checks
- [Modern] PowerShell Core 6.0+ only scripts SHOULD use built-in
$IsWindows,$IsMacOS,$IsLinuxvariables → PowerShell Core 6.0+ OS Detection - [v1.0] Scripts supporting older versions MUST use
Test-Windows,Test-macOS,Test-Linuxfunctions from PowerShell_Resources → Cross-Version OS Detection - [All] Wrong OS errors MUST be reported consistently with existing error handling patterns → Error Handling for Wrong OS
- [Modern] Modern functions MUST NOT collect results in
List<T>and return; MUST stream objects to pipeline → Processing Collections in Modern Functions (Streaming Output) - [Modern] Streaming function calls SHOULD be wrapped in @(...) to handle 0-1-Many problem → Consuming Streaming Functions (The
0-1-ManyProblem) - [All] Code MUST use Write-Warning for user-facing anomalies; Write-Debug for internal details → Choosing Between Warning and Debug Streams
- [All] .NET method output MUST be suppressed with void, not | Out-Null → Suppression of Method Output
- [All]
Write-Verbose/Write-DebugMUST NOT emit raw PII, credentials, tokens, or other sensitive identifiers → Sensitive Data in Verbose and Debug Streams - [Modern] Hot-path
Write-Verbose/Write-Debugwith string formatting SHOULD be guarded behind a preference check → Performance-SensitiveWrite-Verbose/Write-Debugin Hot Paths - [All]
-fformat operator MUST be applied inside the argument-expression parentheses of cmdlet calls → String Formatting in Cmdlet Arguments (-fScoping)
- [All]
System.Collections.ArrayListis deprecated and MUST NOT be used in new code; useSystem.Collections.Generic.List[T]instead → .NET Interop Patterns: Safe and Documented - [All] Generic collections MUST provide specific type T (List[PSCustomObject], not List[object]) → .NET Interop Patterns: Safe and Documented
- [All] Code MUST NOT grow PowerShell arrays with
+=inside accumulation loops; useSystem.Collections.Generic.List[T]when an in-memory collection is required → .NET Interop Patterns: Safe and Documented
- [All] New functions SHOULD have corresponding Pester tests when testability is a project requirement → Testing with Pester
- [All] Test files MUST use
*.Tests.ps1naming convention → Test File Naming and Location - [All] Tests MUST use Pester 5.x syntax (BeforeAll, Describe, Context, It) → Pester 5.x Syntax Requirements
- [All] Tests SHOULD use Arrange-Act-Assert pattern in test cases → Test Structure: Arrange-Act-Assert
- [All] Tests MUST verify all documented return codes for functions → Testing Return Code Conventions
- [All] Test-* functions MUST have tests for both
$trueand$falsecases → Testing Return Code Conventions - [All] Tests asserting property names on
[pscustomobject]MUST use order-insensitive comparisons → Testing Property Names on PSCustomObject - [All] Tests asserting strongly-typed array properties MUST check non-emptiness first, then assert the exact array type with
-is; MUST NOT permit[object[]]fallback → Testing Strongly-Typed Array Properties - [All] Test
BeforeAlldot-sourcing MUST use theSplit-Path+Join-Pathtwo-step pattern; multi-segmentJoin-Pathforms MUST NOT be used → Test File Dot-Sourcing Pattern - [All] Tests iterating a returned collection with
foreachMUST assert non-emptiness before the loop → Defensive Assertions Before Iteration and Indexing - [All] Tests accessing specific indices of a returned collection MUST assert count before any indexed access → Defensive Assertions Before Iteration and Indexing
- [All] Tests asserting that a call does not throw MUST use
{ ... } | Should -Not -Throwand MUST NOT rely ontry/catchplus negated assertions on exception text → Asserting Successful Execution With Should -Not -Throw - [All] PSScriptAnalyzer CI integrations that emit host-native diagnostics MUST use the active CI host's command format → PSScriptAnalyzer CI Diagnostic Output
- [All] Host-neutral, local, and interactive PSScriptAnalyzer runs SHOULD use plain output; ambiguous, missing, or contradictory host detection MUST fall back to plain output → PSScriptAnalyzer CI Diagnostic Output
- [All] CI Pester discovery and execution MUST be scoped to the project-owned
tests/tree or documented test root, not the repository root → Running Pester Tests - [All] CI Pester discovery and the Pester configuration
Run.PathMUST share one test-root source of truth and SHOULD guard missing test roots cleanly → Running Pester Tests
Indentation MUST use four spaces for all logical blocks, including param declarations, conditional statements (if/else), loops, and function bodies—tabs MUST NOT be used.
Bracing MUST strictly adhere to the "One True Brace Style" (OTBS): opening braces MUST be placed at the end of the statement line, and closing braces MUST start on a new line, aligned with the opening statement. This applies universally to functions, conditionals, and most script blocks.
Exception for
catch,finally, andelse: These keywords are the major exception to this rule. To be syntactically valid, thecatch,finally, andelse(orelseif) keywords MUST follow the closing brace (}) of the preceding block on the same line.Compliant
if/else:if ($condition) { # ... } else { # ... }Compliant
try/catch:try { # ... } catch { # ... } finally { # ... }
Whitespace MUST be used precisely to enhance clarity: a single space MUST surround operators (e.g., -gt, =, -and, -eq) and MUST follow commas in parameter lists or arrays, with no unnecessary spaces inside parentheses, brackets, or subexpressions. Line terminators SHOULD avoid semicolons entirely, as they are unnecessary and can complicate edits. Line continuation SHOULD eschew backticks, preferring natural breaks at operators, pipes, or commas where possible—though in v1.0-focused code, long lines (e.g., in comments or regex patterns) MAY be tolerated for completeness. Line lengths SHOULD aim for under 115 characters where practical, but verbose comments MAY exceed this; this is acceptable per flexible guidelines, as it prioritizes detailed explanations without sacrificing core code readability.
Code MUST use exactly one space on either side of an operator (e.g., =, -eq). Code MUST NOT add extra whitespace to vertically align operators across multiple lines. This ensures compliance with standard PSScriptAnalyzer rules.
When a method call (like .Add()) is wrapped (e.g., in a [void] cast) and its parameter is a multi-line script block (like a hashtable or [pscustomobject]), an additional level of indentation MUST be used for the contents of that script block.
[void]($list.Add(
[pscustomobject]@{
# This line is indented three times:
# 1. For the opening parenthesis
# 2. For the .Add() method
# 3. For the [pscustomobject]@{...} block
Key = $Value
}
))Blank lines SHOULD be used sparingly but effectively: two SHOULD surround function definitions for visual separation, and single blanks SHOULD group related logic within functions (e.g., before a block comment or between setup and main logic). Files MUST end with a single blank line. Regions (#region ... #endregion) SHOULD logically group elements like licenses or helper sections, improving navigability in larger scripts.
Important: Blank lines MUST be completely empty—they MUST NOT contain any whitespace characters (spaces or tabs). This ensures consistency and prevents issues with some editors and linters.
Compliant (blank line is truly empty):
{
Invoke-SomeCmdlet
Invoke-AnotherCmdlet
}Non-Compliant (blank line contains spaces):
{
Invoke-SomeCmdlet
Invoke-AnotherCmdlet
}In the non-compliant example, the blank line (line 3) contains spaces, which is not allowed.
Lines MUST NOT end with trailing whitespace (spaces or tabs). Most modern editors can be configured to automatically remove trailing whitespace on save, which is RECOMMENDED.
When a variable in an expandable string ("...") is immediately followed by punctuation (especially a colon :) or other text that is not part of the variable name, it can cause parsing errors.
-
Non-Compliant (Ambiguous):
$strMessage = "$SSORegion: Error occurred"
-
Compliant (Preferred): Use curly braces to explicitly delimit the variable name:
$strMessage = "${SSORegion}: Error occurred"
-
Compliant (Also Preferred): Use the
-fformat operator, which avoids all parsing ambiguity.$strMessage = ("{0}: Error occurred" -f $SSORegion)
When a composed string expression is passed to a cmdlet or language construct using parentheses as the argument expression (for example Write-Warning, Write-Host, Write-Error, Write-Verbose, Write-Debug, Write-Output, or throw (...)), any -f format operator MUST be applied inside the same parentheses that form the argument expression. Once the argument-expression parentheses close, PowerShell may parse -f as a parameter token rather than as the format operator.
See also Variable Delimiting in Strings for broader guidance on composing strings safely.
-
Non-Compliant:
Write-Warning ("foo {0}" + "bar") -f $x
-
Compliant (Preferred): Place the
-foperator inside the argument-expression parentheses:Write-Warning (("foo {0}" + "bar") -f $x)
-
Compliant (Alternative): Assign the formatted string to a variable first:
$strMessage = ("foo {0}" + "bar") -f $x Write-Warning $strMessage
PowerShell .ps1 source files MUST be saved as UTF-8 without a Byte Order Mark (BOM, U+FEFF), unless the script contains non-ASCII characters (e.g., accented characters, CJK text, or special symbols in string literals or comments) and must run on Windows PowerShell v5.1 or earlier. In that case, the file MUST either (a) be saved as UTF-8 with BOM so that Windows PowerShell can detect the encoding, or (b) remain ASCII-only so that BOM-less UTF-8 and the system ANSI code page produce identical byte sequences. This exception does not apply to PowerShell 7+, which defaults to UTF-8. Editors used for PowerShell development SHOULD be configured to save .ps1 files as UTF-8 without BOM by default. If tool-specific examples are included, they SHOULD be presented as examples rather than assumptions that all environments behave identically.
When writing or generating text files programmatically (for example, build scripts producing .psm1 files or code generators producing repository artifacts), the output encoding MUST be specified explicitly so results are deterministic across PowerShell versions.
The preferred cross-version pattern for UTF-8 without BOM SHOULD use a .NET encoding object:
$objUtf8NoBomEncoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($strOutputFilePath, $strFileContent, $objUtf8NoBomEncoding)Set-Content and similar cmdlets MUST include an explicit -Encoding parameter when writing generated artifacts, because default encoding behavior varies across PowerShell versions and can make output non-deterministic.
-Encoding utf8NoBOM MUST NOT be the required cross-version pattern, because it is unavailable in Windows PowerShell 5.1. For code that explicitly targets only PowerShell 7+, it MAY be used.
When a PowerShell script or test produces text output whose identity is its exact byte sequence (for example, golden baselines, snapshot fixtures, hash inputs, or signed payloads), the producer MUST normalize line endings to LF at serialization time. Consumers reading the entire file as text MUST NOT use Get-Content without -Raw; they MUST use Get-Content -Raw or the equivalent .NET [System.IO.File]::ReadAllText() API. Those text-returning APIs are acceptable only for text-level comparison when the encoding convention, including BOM presence or absence, is already fixed. For true byte-for-byte identity (for example, hash inputs and signed payloads), consumers MUST use [System.IO.File]::ReadAllBytes(), because Get-Content -Raw and [System.IO.File]::ReadAllText() decode bytes into a System.String and can mask byte-level differences such as a UTF-8 BOM or other encoding distinctions.
Cross-version differences in ConvertTo-Json and other serializers can emit CRLF on some hosts and LF on others, causing byte-exact comparisons to fail unless line endings are normalized in memory before writing or comparing. The recommended pattern is to normalize CRLF to LF immediately after serialization:
$strJson = $objInput | ConvertTo-Json -Depth 5
$strJson = $strJson -replace "`r`n", "`n"
# If the artifact convention requires a trailing LF, also append one:
# $strJson = $strJson + "`n"Get-Content without -Raw strips line terminators and returns an array of lines rather than the original on-disk text, so it MUST NOT be used for byte-exact comparison. Use Get-Content -Raw or [System.IO.File]::ReadAllText() to read the decoded text as a single string when the comparison is text-level, or use [System.IO.File]::ReadAllBytes() when true byte-for-byte identity is required. When using the .NET APIs, paths MUST first be resolved to an absolute filesystem path per Resolving Paths for .NET Static Methods.
Public identifiers (functions, parameters, properties) MUST use PascalCase. Keywords (function, param, if, else, return, trap) MUST be lowercase. Operators (-gt, -eq) MUST be lowercase with surrounding spaces. Local variables MUST use camelCase with a type-hinting prefix (e.g., $strMessage, $intReturnValue, $boolResult, $arrElements).
Function names MUST strictly adhere to the Verb-Noun pattern using approved verbs and singular nouns, rendered in PascalCase. Examples include:
Convert-StringToObjectGet-ReferenceToLastErrorTest-ErrorOccurredSplit-StringOnLiteralString
Functions MUST use approved PowerShell verbs. Run Get-Verb for the complete list. If a verb (like Review or Check) is not approved, choose the closest alternative (e.g., Get- or Test-). For the full reference, see Microsoft's Approved Verbs.
Note: The term verb in PowerShell describes any word implying an action, even if it isn't a standard English verb (e.g.,
New).
Reserved verbs (do not use): ForEach (foreach), Ping (pi), Sort (sr), Tee (te), Where (wh).
The following similar verbs represent different actions.
Use the New verb to create a new resource. Use the Add to add something to an existing container or resource. For example, Add-Content adds output to an existing file.
Use the New verb to create a new resource. Use the Set verb to modify an existing resource, optionally creating it if it doesn't exist, such as the Set-Variable cmdlet.
Use the Find verb to look for an object. Use the Search verb to create a reference to a resource in a container.
Use the Get verb to obtain information about a resource (such as a file) or to obtain an object with which you can access the resource in future. Use the Read verb to open a resource and extract information contained within.
Use the Invoke verb to perform synchronous operations, such as running a command and waiting for it to end. Use the Start verb to begin asynchronous operations, such as starting an autonomous process.
Use the Test verb.
| Verb (alias) | Synonyms to avoid |
|---|---|
Add (a) |
Append, Attach, Concatenate, Insert |
Clear (cl) |
Flush, Erase, Release, Unmark, Unset, Nullify |
Close (cs) |
|
Copy (cp) |
Duplicate, Clone, Replicate, Sync |
Enter (et) |
Push, Into |
Exit (ex) |
Pop, Out |
Find (fd) |
Search |
Format (f) |
|
Get (g) |
Read, Open, Cat, Type, Dir, Obtain, Dump, Acquire, Examine, Find, Search |
Hide (h) |
Block |
Join (j) |
Combine, Unite, Connect, Associate |
Lock (lk) |
Restrict, Secure |
Move (m) |
Transfer, Name, Migrate |
New (n) |
Create, Generate, Build, Make, Allocate |
Open (op) |
|
Optimize (om) |
|
Pop (pop) |
|
Push (pu) |
|
Redo (re) |
|
Remove (r) |
Clear, Cut, Dispose, Discard, Erase |
Rename (rn) |
Change |
Reset (rs) |
|
Resize (rz) |
|
Search (sr) |
Find, Locate |
Select (sc) |
Find, Locate |
Set (s) |
Write, Reset, Assign, Configure, Update |
Show (sh) |
Display, Produce |
Skip (sk) |
Bypass, Jump |
Split (sl) |
Separate |
Step (st) |
|
Switch (sw) |
|
Undo (un) |
|
Unlock (uk) |
Release, Unrestrict, Unsecure |
Watch (wc) |
| Verb (alias) | Synonyms to avoid |
|---|---|
Connect (cc) |
Join, Telnet, Login |
Disconnect (dc) |
Break, Logoff |
Read (rd) |
Acquire, Prompt, Get |
Receive (rc) |
Read, Accept, Peek |
Send (sd) |
Put, Broadcast, Mail, Fax |
Write (wr) |
Put, Print |
| Verb (alias) | Synonyms to avoid |
|---|---|
Backup (ba) |
Save, Burn, Replicate, Sync |
Checkpoint (ch) |
Diff |
Compare (cr) |
Diff |
Compress (cm) |
Compact |
Convert (cv) |
Change, Resize, Resample |
ConvertFrom (cf) |
Export, Output, Out |
ConvertTo (ct) |
Import, Input, In |
Dismount (dm) |
Unmount, Unlink |
Edit (ed) |
Change, Update, Modify |
Expand (en) |
Explode, Uncompress |
Export (ep) |
Extract, Backup |
Group (gp) |
|
Import (ip) |
BulkLoad, Load |
Initialize (in) |
Erase, Init, Renew, Rebuild, Reinitialize, Setup |
Limit (l) |
Quota |
Merge (mg) |
Combine, Join |
Mount (mt) |
Connect |
Out (o) |
|
Publish (pb) |
Deploy, Release, Install |
Restore (rr) |
Repair, Return, Undo, Fix |
Save (sv) |
|
Sync (sy) |
Replicate, Coerce, Match |
Unpublish (ub) |
Uninstall, Revert, Hide |
Update (ud) |
Refresh, Renew, Recalculate, Re-index |
| Verb (alias) | Synonyms to avoid |
|---|---|
Debug (db) |
Diagnose |
Measure (ms) |
Calculate, Determine, Analyze |
Ping (pi) — deprecated; use Test |
|
Repair (rp) |
Fix, Restore |
Resolve (rv) |
Expand, Determine |
Test (t) |
Diagnose, Analyze, Salvage, Verify |
Trace (tr) |
Track, Follow, Inspect, Dig |
| Verb (alias) | Synonyms to avoid |
|---|---|
Approve (ap) |
|
Assert (as) |
Certify |
Build (bd) — PS 6+ |
|
Complete (cp) |
|
Confirm (cn) |
Acknowledge, Agree, Certify, Validate, Verify |
Deny (dn) |
Block, Object, Refuse, Reject |
Deploy (dp) — PS 6+ |
|
Disable (d) |
Halt, Hide |
Enable (e) |
Start, Begin |
Install (is) |
Setup |
Invoke (i) |
Run, Start |
Register (rg) |
|
Request (rq) |
|
Restart (rt) |
Recycle |
Resume (ru) |
|
Start (sa) |
Launch, Initiate, Boot |
Stop (sp) |
End, Kill, Terminate, Cancel |
Submit (sb) |
Post |
Suspend (ss) |
Pause |
Uninstall (us) |
|
Unregister (ur) |
Remove |
Wait (w) |
Sleep, Pause |
| Verb (alias) | Synonyms to avoid |
|---|---|
Block (bl) |
Prevent, Limit, Deny |
Grant (gr) |
Allow, Enable |
Protect (pt) |
Encrypt, Safeguard, Seal |
Revoke (rk) |
Remove, Disable |
Unblock (ul) |
Clear, Allow |
Unprotect (up) |
Decrypt, Unseal |
| Verb (alias) | Synonyms to avoid |
|---|---|
Use (u) |
Noun Singularity: The noun MUST be singular, even if the function returns multiple objects. This is a core PowerShell convention (e.g., Get-Process, Get-ChildItem) and corresponds to the PSScriptAnalyzer PSUseSingularNouns rule. The noun describes the type of object the function works with, not the quantity of its output.
- Correct:
Get-Process(returns many process objects) - Incorrect:
Get-Processes - Correct:
Expand-TrustPrincipal(operates on one principal node, even if it results in many values) - Incorrect:
Expand-TrustPrincipals
Modules are treated as .NET Namespaces or Class Libraries (Containers), not Actions. Therefore, Module names MUST be PascalCase Nouns or Noun Phrases.
- Correct:
ObjectFlattener,NetworkManager,DataParser - Incorrect:
FlattenObject,ManageNetwork,ParseData
Module names MUST NOT be compromised for the sake of keyword searching.
[Modern] In module-based code, the Module Manifest (.psd1) handles discoverability. The Tags key in the manifest MUST be populated aggressively with relevant keywords (including verbs) to ensure the module is found during searches, while keeping the architectural name pure.
Aliases (e.g., gci, gps) or abbreviated forms MUST NOT appear in the code. Even common operations MUST use full command names.
Furthermore, Modules MUST NOT export "Compatibility Aliases" solely to bridge a gap between a module name and a command name (e.g., do not export Flatten-Object when the correct command is ConvertTo-FlatObject).
Exceptions:
Aliases MAY only be exported in a Module Manifest if they provide genuine short-hand convenience for interactive users (e.g., cfo for ConvertTo-FlatObject) and are strictly documented as optional. They MUST NOT be used to mask non-approved verbs.
Parameter names MUST use PascalCase and be highly descriptive (e.g., $ReferenceToResultObject, $StringToProcess, $PSVersion). The ReferenceTo prefix for [ref] parameters signals pass-by-reference semantics. [ref] MUST be used only when data needs to be written back to the caller’s scope; for passing complex objects that do not need modification, pass by value.
Local variables follow a Hungarian-style notation combining a type-hinting prefix with descriptive camelCase. The descriptive portion of each name—everything after the type prefix—MUST be fully spelled out; abbreviations and shorthand are not permitted.
- Prefixes:
$str(string),$int(integer),$dbl(double),$bool(boolean),$arr(array),$obj(object/default),$hashtable(hashtable),$list(generic list), etc. - Default prefix —
obj: Use$objfor any .NET type that does not have a dedicated approved prefix above. This includes enum values (e.g.,$objActionPreference), complex .NET reference types (e.g.,$objMemoryStream), and[pscustomobject]instances (e.g.,$objResult). - Open-ended list: The "etc." above means additional descriptive prefixes such as
$refand$versionare permitted when they provide immediate type clarity (e.g.,$refLastKnownError,$versionPowerShell). However, authors SHOULD NOT invent ad hoc abbreviated type-name prefixes when a canonical documented prefix already exists. Specifically:- Do not use
$enumActionPreference; use$objActionPreferenceinstead (enum values fall under the default$objprefix). - Do not use
$hashSeen,$hashResult, etc.; use$hashtableSeen,$hashtableResult, etc. instead (the canonical prefix for hashtables is$hashtable, not the abbreviated$hash).
- Do not use
- Descriptive Name: The name MUST be fully spelled out.
Examples:
$strPolicyString(not$strSor$strPolicy)$objMemoryStream(not$objMsor$stream)$arrStatements(not$arrStmtor$stmts)$strMessage$intReturnValue$boolResult$arrElements$hashtableSettings$objActionPreference$objResult$refLastKnownError$versionPowerShell
Code SHOULD avoid relative paths (., ..) and the home directory shortcut ~ entirely. This is due to:
~behavior varies by provider (FileSystem vs. Registry vs. others).- Relative paths depend on
[Environment]::CurrentDirectory, which MAY diverge from$PWDwhen calling .NET methods or external tools.
Instead, explicit scoping SHOULD be used:
$global:ErrorActionPreferenceFor shared state, use:
$Script:varNamefor module/script-level variables$Global:varNamefor session-wide state
This eliminates environment-dependent behavior and ensures deterministic execution.
Note: The guidance to avoid relative paths targets bare
./..paths that depend on[Environment]::CurrentDirectoryor$PWD. Paths anchored to$PSScriptRoot— such as"$PSScriptRoot/../config.json"orJoin-Path -Path $PSScriptRoot -ChildPath '../src/Helper.ps1'— are deterministic because they resolve relative to the executing script's directory, not the process working directory.
Non-compliant (CWD-dependent):
# Bad — result changes depending on where the caller invoked the script:
Get-Content -Path '../config.json'Compliant ($PSScriptRoot-anchored):
# Good — always resolves relative to the script's own directory:
Get-Content -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '../config.json')[All] When a script or function passes a user-provided or otherwise unresolved PowerShell path to a .NET file API (for example, [System.IO.File]::WriteAllText(), [System.IO.File]::WriteAllLines(), or other System.IO.* methods that expect a file-system path), the path MUST first be converted to an absolute file-system path via $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath(). This method assumes the path resolves through the FileSystem provider; non-FileSystem provider paths (such as HKLM:\… or Cert:\…) MUST NOT be passed to System.IO.* methods.
Compliant:
# Resolve the PowerShell path before passing it to .NET
$strOutputPath = '.\output.txt'
$strOutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($strOutputPath)
[System.IO.File]::WriteAllText($strOutputPath, $strContent, $objEncoding)Non-Compliant:
# Non-Compliant: passing an unresolved PowerShell path directly to a .NET method
$strOutputPath = '.\output.txt'
[System.IO.File]::WriteAllText($strOutputPath, $strContent, $objEncoding)All functions MUST include full comment-based help using single-line comments (#) with dotted keywords placed inside the function, immediately above the param block.
Required sections: .SYNOPSIS, .DESCRIPTION, .PARAMETER (one per declared parameter, if any), .EXAMPLE (multiple with input, output, and explanation), .INPUTS, .OUTPUTS (document all outputs; when integer status codes are used, include full mapping of codes to meanings), .NOTES (positional parameters, versioning).
Note: If a function declares no parameters in its
param()block (excluding implicit common parameters), the.PARAMETERsection is omitted entirely.
Example of complete help block (from a generic parsing function):
# .SYNOPSIS
# Processes a string input with flexible handling of non-standard formats.
#
# .DESCRIPTION
# Attempts direct processing. On failure, iteratively handles problematic segments...
#
# .PARAMETER ReferenceToResultObject
# Reference to store the resulting object.
#
# .EXAMPLE
# $result = $null; $extras = @('','','','',''); $status = Process-String ([ref]$result) ([ref]$extras) 'input-string'
#
# # $status = 4, $result = processed value, $extras[3] = 'leftover'
#
# .INPUTS
# None. You can't pipe objects to this function.
#
# .OUTPUTS
# [int] Status code: 0=success, 1-5=partial success with extras, -1=failure
#
# .NOTES
# This function/script supports positional parameters:
#
# Position 0: ReferenceToResultObject
# Position 1: ReferenceArrayOfExtraStrings
# Position 2: InputString
#
# Version: 1.0.20250218.0Note: The terse form is acceptable where brevity is preferred, for example:
# Supports positional parameters.followed by# Version: 1.0.20250218.0. The multi-line format shown above is RECOMMENDED because it explicitly identifies which parameters are positional and at which positions. See Positional Parameter Support for full guidance.
A comment blank line is a line inside comment-based help that contains only
the comment marker (#), with the same indentation as the surrounding help
block. For function-level help, the comment blank line is indented with the help
block:
function Get-Example {
# .SYNOPSIS
# Does something.
#
# .DESCRIPTION
# Does something in more detail.
param ()
}Comment-based help MUST use exactly one comment blank line between
top-level help sections. Top-level help sections include .SYNOPSIS,
.DESCRIPTION, .PARAMETER, .EXAMPLE, .INPUTS, .OUTPUTS, and .NOTES.
A comment blank line MUST NOT appear before the first help keyword.
Consecutive .PARAMETER sections MUST be separated by exactly one comment
blank line. Consecutive .EXAMPLE sections MUST be separated by exactly one
comment blank line.
A comment blank line SHOULD NOT appear immediately after a dotted help keyword when the section content begins on the next line. Wrapped lines belonging to the same paragraph MUST NOT be separated by comment blank lines.
Inside .EXAMPLE sections, executable sample code SHOULD be separated from
explanatory rendered-comment lines by exactly one comment blank line. The
existing requirement that explanatory or output-description lines use exactly
# # <text> remains unchanged:
# .EXAMPLE
# $arrResult = @(Get-Example)
#
# # Returns zero or more example objects.Within .NOTES, distinct note groups SHOULD be separated by exactly one
comment blank line, especially positional parameter documentation, additional
note text, private/internal helper banners, and version metadata:
# .NOTES
# This script supports positional parameters:
#
# Position 0: RootDirectoryPath
#
# Parameters without a listed position must be named.
#
# Version: 1.0.20260517.0When writing explanatory or output-description lines within a .EXAMPLE section of comment-based help, use double # — that is, # # <text>. The first # is the standard comment-based help line prefix (required for all help content). The second # creates a PowerShell comment within the rendered example output so that:
Get-Help -Examplesrenders the line as# <text>, which is valid PowerShell syntax and can be safely copy-pasted.- The explanatory text is visually distinct from executable code lines in the example.
Compliant — explanatory lines use # #:
# .EXAMPLE
# $arrRows = @(ConvertTo-VectorRow -Counts $arrCounts -FeatureIndexObject $objIndex)
#
# # $arrRows[0].PrincipalKey = 'user-abc'
# # $arrRows[0].Vector = [double[]] (fixed-length array)Rendered by Get-Help:
$arrRows = @(ConvertTo-VectorRow -Counts $arrCounts -FeatureIndexObject $objIndex)
# $arrRows[0].PrincipalKey = 'user-abc'
# $arrRows[0].Vector = [double[]] (fixed-length array)
Non-compliant — single # for explanation text:
# .EXAMPLE
# $arrRows = @(ConvertTo-VectorRow -Counts $arrCounts -FeatureIndexObject $objIndex)
#
# Returns vector row objects with PrincipalKey, Vector, and TotalActions.Rendered by Get-Help:
$arrRows = @(ConvertTo-VectorRow -Counts $arrCounts -FeatureIndexObject $objIndex)
Returns vector row objects with PrincipalKey, Vector, and TotalActions.
The non-compliant form renders bare prose that (a) is not valid PowerShell, (b) can be confused with actual command output, and (c) is not safely copy-pasteable.
Non-compliant — triple # for explanation text:
# .EXAMPLE
# $arrRows = @(ConvertTo-VectorRow -Counts $arrCounts -FeatureIndexObject $objIndex)
#
# # # Returns vector row objects with PrincipalKey and Vector.Rendered by Get-Help:
$arrRows = @(ConvertTo-VectorRow -Counts $arrCounts -FeatureIndexObject $objIndex)
## Returns vector row objects with PrincipalKey and Vector.
Lines within .EXAMPLE blocks that are intended to render as PowerShell comments in Get-Help output MUST use exactly two # characters (# # <text>). Using three or more # characters (for example, # # # <text>) is non-compliant because it does not preserve the intended rendered form of # <text> in Get-Help output.
- Behavioral Contracts: Every possible output/return value documented with exact type and meaning (in .OUTPUTS) and example (in .EXAMPLE blocks); when integer status codes are used, include full mapping of codes to meanings.
- Edge Case Coverage: Examples include valid input, invalid segments, overflow conditions, excess parts.
- Positional Parameter Support:
.NOTESexplicitly documents positional ordering. - Versioning: Includes internal version in
.NOTESfor change tracking.
[All] If a function is intended only for internal use and is not part of the script, module, or tool's public API surface, its .NOTES section MUST begin with a clear private-helper banner. That banner MUST state that the function is not part of the public API surface, and MUST warn that parameters, return shape, and positional contract may change without notice.
[Modern] In module-based code, a function intentionally omitted from the module manifest's FunctionsToExport is treated as a private/internal helper for purposes of the documentation requirements above.
All other comment-based help requirements (.SYNOPSIS, .DESCRIPTION, .PARAMETER, .EXAMPLE, .INPUTS, .OUTPUTS, .NOTES) still apply to private/internal helpers — the banner is an addition, not a replacement.
Compliant — private/internal helper with required .NOTES banner:
function Convert-RawRecord {
# .SYNOPSIS
# Transforms a raw input record into a normalized internal format.
#
# .DESCRIPTION
# Parses the raw record hashtable, validates required keys, and
# returns a normalized [pscustomobject]. This function is used
# only by Import-DataSet and is not part of the public API.
#
# .PARAMETER ReferenceToResultObject
# Reference to store the resulting normalized object.
#
# .PARAMETER RawRecord
# The raw hashtable to normalize.
#
# .EXAMPLE
# $objResult = $null
# $intReturnCode = Convert-RawRecord ([ref]$objResult) $hashtableInput
#
# # $intReturnCode = 0, $objResult contains the normalized record
#
# .INPUTS
# None. You can't pipe objects to this function.
#
# .OUTPUTS
# [int] Status code: 0 = success, -1 = failure (missing keys)
#
# .NOTES
# PRIVATE/INTERNAL HELPER — This function is not part of the
# public API surface. Parameters, return shape, and positional
# contract may change without notice.
#
# This function supports positional parameters
# (internal-caller contract only; subject to change):
#
# Position 0: ReferenceToResultObject
# Position 1: RawRecord
#
# Version: 1.0.20260415.0
param (
[ref]$ReferenceToResultObject,
[hashtable]$RawRecord
)
# Implementation omitted for brevity
}Inline comments SHOULD focus on "why" rather than "what", be used only when behavior is non-obvious, and be aligned with at least two spaces from code.
The script uses #region / #endregion blocks to create logical code folding:
#region License ########################################################
# Full MIT-style license
#endregion License ########################################################
#region FunctionsToSupportErrorHandling ############################
# Get-ReferenceToLastError
# Test-ErrorOccurred
#endregion FunctionsToSupportErrorHandling ############################For distributable helper functions, the structure MUST be: function declaration, comment-based help, param() block, then #region License block.
Example:
function Get-Example {
# .SYNOPSIS
# Example function with license
#
# .DESCRIPTION
# This demonstrates the correct placement of param() before license.
#
# .NOTES
# Version: 1.0.20260109.0
param(
[string]$Parameter
)
#region License ########################################################
# MIT License or other license text
#endregion License ########################################################
# Function implementation
return 0
}All distributable functions and scripts MUST include a version number in the .NOTES section of their comment-based help. This version number provides critical change tracking and MUST follow a strict, [System.Version]-compatible format: Major.Minor.Build.Revision.
For this section, the previously published version means the most recent .NOTES version published for the same distributable function or script before this change. In repository work, this is the version already on the branch the change lands on, not an in-progress value produced earlier in the same change. For a brand-new function or script, there is no previously published version.
Major: Increment the Major version (e.g.,1.0.20251103.0to2.0.20251230.0) any time a breaking change is introduced. Breaking changes include:- Removing or renaming a function, parameter, or public interface
- Changing parameter types in incompatible ways
- Altering return types or output formats that break existing consumers
- Any modification that requires users to update their code
Minor: Increment the Minor version (e.g.,1.0.20251103.0to1.1.20251230.0) any time a feature or function change is introduced that is non-breaking. This includes:- Adding new functions or capabilities
- Adding new optional parameters
- Enhancing existing functionality without changing interfaces
- Performance improvements that don't affect behavior
Build: This component MUST be an integer in the formatYYYYMMDD, representing the date the code was last modified. This date MUST be updated to the current date for any modification, however minor.Revision: This component is typically0for the first published version of a given distributable function or script at a givenMajor.Minor.Build. It counts same-day published updates of the same function or script relative to the previously published version, not work-in-progress iterations or commits. When a.NOTESversion is assigned or updated, after applying the requiredMajor,Minor, andBuildvalues above,RevisionMUST be0if the resultingMajor.Minor.Builddiffers from the previously published version'sMajor.Minor.Buildor if there is no previously published version. If the previously published version already records the sameMajor.Minor.Buildat revisionN,RevisionMUST beN + 1. Same-day updates at the sameMajor.Minor.Buildthat incrementRevisioncan include, but are not limited to:- Trivial edits, such as typos, formatting, or comment fixes
- Bug fixes that do not require a
MajororMinorversion bump - Documentation-only updates
| Case | Previously published version | This change | Reason |
|---|---|---|---|
| Same-day update | 1.2.20260619.0 |
1.2.20260619.1 |
Same Major.Minor.Build, so N + 1. |
| Same-day breaking change | 1.2.20260619.1 |
2.0.20260619.0 |
Major changed, so reset to 0. |
| Next-day update | 1.2.20260619.1 |
1.2.20260620.0 |
Build date changed, so reset to 0. |
Compliant Example:
# .NOTES
# Version: 1.2.20251230.0This example assumes that the current date is December 30, 2025. In any code you write, use the current date in place of December 30, 2025.
Parameter help SHOULD be centralized in the comment-based help block, not duplicated above individual parameters in the param block.
Comment-based help MUST use single-line comments (#) for v1.0-compatible code. Block comments (<# ... #>) MUST NOT be used when v1.0 compatibility is required.
⚠ PowerShell v1.0 Compatibility Warning: Block comments (
<# ... #>) were introduced in PowerShell v2.0. In PowerShell v1.0, attempting to use block comments results in a parser error that prevents the script from running. Scripts targeting v1.0 compatibility MUST use only single-line comments (#). This applies to both comment-based help and general-purpose comments.
Example — what fails in PowerShell v1.0:
# This will cause a parser error in PowerShell v1.0:
function Get-Example {
<#
.SYNOPSIS
Example function.
#>
param ()
}Correct approach for v1.0 compatibility:
# This works in all PowerShell versions, including v1.0:
function Get-Example {
# .SYNOPSIS
# Example function.
param ()
}All functions MUST follow a strict, uniform template:
function Verb-Noun {
# Full comment-based help block
param (
[type]$ParameterName1,
[type]$ParameterName2 = default
)
# Implementation
return $statusCode
}Key characteristics:
- No
[CmdletBinding()]→ intentional omission for v1.0 compatibility; v1.0-targeted functions MUST NOT use this - No pipeline blocks →
processblock would imply pipeline input, which MUST NOT be supported in v1.0-targeted functions - Explicit
return→ MUST be used to guarantee single-value output and prevent accidental pipeline leakage - Strongly typed
paramblock → MUST validate input at parse time [CmdletBinding()]and[OutputType()]Present → MUST be included for modern, non-v1.0 functions where either the function or script it sits in relies on external dependencies (e.g., a module that only supports Windows PowerShell v5.1 or PowerShell 7.x), making the function explicitly non-v1.0-compatible.
The param block is the primary contract between caller and function. Every parameter MUST be:
- Strongly typed using .NET types
- Fully documented in comment-based help
- Defaulted where appropriate to ensure predictable behavior
Exception for Polymorphic Parameters:
In rare cases, a function MAY be intentionally designed to accept a parameter that can be one of several different, incompatible types (e.g., a string or an object). This is common in "safe wrapper" functions that process dynamic input, such as the Principal element from an IAM policy.
In this scenario, the parameter SHOULD be left un-typed (or explicitly typed as [object]). The function's internal logic is then responsible for inspecting the received object's type (e.g., using if ($MyParameter -is [string])) and handling it appropriately. This pattern SHOULD be used sparingly and only when required by the function's core purpose.
Parameter typing examples:
| Parameter | Type | Purpose |
|---|---|---|
$ReferenceToResultObject |
[ref] |
Output: stores processed result (used only when modification in caller scope is needed) |
$ReferenceArrayOfExtraStrings |
[ref] |
Output: array for additional data (used only for write-back) |
$StringToProcess |
[string] |
Input: string to handle |
$PSVersion |
[version] |
Optional: runtime version for optimization |
Reference parameters ([ref]) are used exclusively for output where data MUST be written back to the caller's scope—a deliberate pattern to:
- Return multiple complex values
- Avoid pipeline interference
- Maintain v1.0 compatibility
- Ensure caller controls variable lifetime
[ref] SHOULD NOT be used for passing complex objects that do not require modification, as PowerShell passes object references by default without performance gains from [ref] in such cases.
Default values are used judiciously:
[string]$StringToProcess = '',
[version]$PSVersion = ([version]'0.0')This ensures the function can be called with minimal arguments while maintaining type safety.
Every v1.0-targeted function MUST return a single integer status code via explicit return statement:
| Code | Meaning |
|---|---|
0 |
Full success |
1–5 |
Partial success with additional data |
-1 |
Complete failure |
Exception for Test-* Functions:
For PowerShell v1.0 scripts/functions that use the Test- verb (in a Verb-Noun naming convention) and are not reasonably anticipated to encounter errors that the caller needs to detect and react to programmatically, a Boolean return MAY be used instead of an integer status code.
This exception applies when:
- The function's verb is
Test- - The function is designed to return a simple true/false result (e.g., testing for the existence of a condition)
- There is no practical need for the caller to distinguish between different error conditions or partial success states
Example of Compliant Test Function with Boolean Return:
function Test-PathExists {
# .SYNOPSIS
# Tests whether a path exists.
#
# .DESCRIPTION
# Returns $true if the path exists, $false otherwise.
#
# .PARAMETER Path
# The path to test.
#
# .EXAMPLE
# $boolPathExists = Test-PathExists -Path 'C:\Temp'
#
# # $boolPathExists is $true when the path exists.
#
# .INPUTS
# None. You can't pipe objects to this function.
#
# .OUTPUTS
# [bool] $true if the path exists, $false otherwise.
#
# .NOTES
# This function supports positional parameters:
#
# Position 0: Path
#
# Version: 1.0.20260517.0
param (
[string]$Path
)
return (Test-Path -LiteralPath $Path)
}For Test-* functions that might encounter meaningful errors (e.g., access denied, network issues) that the caller SHOULD be able to detect, the standard integer status code pattern SHOULD still be used.
Functions MUST use [ref] parameters to return complex data only when write-back is required:
[ref]$ReferenceToResultObject → [object]
[ref]$ReferenceArrayOfExtraStrings → [string[]][ref] SHOULD NOT be used for passing complex objects that do not require modification, as PowerShell passes object references by default.
In v1.0-targeted functions, pipeline input MUST be explicitly rejected:
- No
ValueFromPipelineattributes .INPUTSsection states "None"- No
processblock
In scripts requiring modern PowerShell, pipeline support is added as needed.
Functions SHOULD support positional parameter binding for v1.0 usability:
# Named parameters
Process-String ([ref]$r) ([ref]$e) $str
# Positional parameters (documented in .NOTES)
Process-String ([ref]$r) ([ref]$e) $str $psverImportant distinction: While functions SHOULD support positional parameters in their declarations (for flexibility and v1.0 usability), function calls throughout the codebase SHOULD use named parameters for clarity and maintainability. The PSScriptAnalyzer configuration enforces this via the PSAvoidUsingPositionalParameters rule.
This enables:
- Interactive use without naming
- Script compatibility with older calling patterns
- Flexibility without sacrificing type safety
When a function or script supports positional parameters, the .NOTES section SHOULD use the following multi-line format to document which parameters are positional and at which positions:
# .NOTES
# This function/script supports positional parameters:
#
# Position 0: VectorRows
# Position 1: KMeansResult
#
# Version: 1.0.20250218.0Guidance for this format:
- The header line SHOULD be
# This function/script supports positional parameters:followed by each position listed on its own indented line as# Position N: ParameterName. - Only list parameters that are expected to be used positionally. For functions or scripts with many optional parameters, listing only the mandatory or commonly-used positional parameters is acceptable.
- The parameter name SHOULD match the declared parameter name without the
-prefix (e.g.,VectorRows, not-VectorRows), since the.NOTESsection documents the parameter's identity, not its call syntax. - [All] If positional parameter behavior is documented for a private/internal helper, that documentation SHOULD clearly state that it is an internal-caller contract only and is subject to change. For example, the header line SHOULD read
# This function supports positional parameters/# (internal-caller contract only; subject to change):instead of the standard header.
[Modern] When a [CmdletBinding()] function or script documents only a subset of its parameters as positional, it MUST use [CmdletBinding(PositionalBinding = $false)] and MUST apply explicit [Parameter(Position = N)] attributes only to the parameters intended to be positional. This rule does not apply to v1.0-targeted functions, which cannot use [CmdletBinding()].
Compliant — subset-only positional contract enforced:
function Import-DataSet {
[CmdletBinding(PositionalBinding = $false)]
[OutputType([pscustomobject])]
param (
[Parameter(Mandatory = $true, Position = 0)]
[string]$InputMode,
[Parameter(Mandatory = $true, Position = 1)]
[string]$OutputPath,
[Parameter()]
[switch]$Force,
[Parameter()]
[int]$RetryCount
)
# Only InputMode and OutputPath are positional;
# Force and RetryCount must always be named.
# ...
}Non-compliant — default PositionalBinding contradicts documented subset contract:
# BAD: Documentation claims only InputMode and OutputPath are positional,
# but CmdletBinding() defaults to PositionalBinding = $true, which silently
# makes Force and RetryCount positional as well.
function Import-DataSet {
[CmdletBinding()]
[OutputType([pscustomobject])]
param (
[Parameter(Mandatory = $true)]
[string]$InputMode,
[Parameter(Mandatory = $true)]
[string]$OutputPath,
[Parameter()]
[switch]$Force,
[Parameter()]
[int]$RetryCount
)
# .NOTES claims "Position 0: InputMode, Position 1: OutputPath"
# but all parameters accept positional input — mismatch.
# ...
}The "v1.0 Classicist" style is the default for standalone, portable utilities that MUST maintain backward compatibility.
However, if a script or function cannot target v1.0, it MUST be written in the "Modern Advanced" style. This condition is met if the code:
- Has external module dependencies that require a modern PowerShell version (e.g.,
AWS.Tools,Az,Microsoft.Graph). - Intentionally uses features from PowerShell v2.0 or later (e.g.,
try/catch,[pscustomobject]literals,Add-Type -AssemblyName), and there are no reasonable alternative approaches that can be used to ensure support for PowerShell v1.0.
Functions and scripts written in this "Modern Advanced" style MUST adhere to the following rules:
- Must Use
[CmdletBinding()]: All modern functions and scripts MUST use the[CmdletBinding()]attribute. This is the non-negotiable identifier of an advanced function or script and enables support for common parameters (-Verbose,-Debug,-ErrorAction, etc.). For modern scripts (.ps1files that are not functions),[CmdletBinding()]and theparamblock MUST appear as the first statement in the script, other than permittedusingstatements, comments, and blank lines. Placing other statements before[CmdletBinding()]/paramcauses aParseExceptionwhen the script is invoked via the call operator (& $path). - Must Use
[OutputType()]: All modern functions and scripts MUST declare their primary output object type using[OutputType()]. This is critical for discoverability, integration, and validating the function's or script's contract. - Must Use Streaming Output: Functions and scripts that return collections MUST write objects directly to the pipeline (stream) from within a loop. They MUST NOT collect results in a
List<T>or array to be returned at the end. (See Processing Collections in Modern Functions). - Must Use
try/catch: Error handling MUST usetry/catchblocks. The v1.0trap/ preference-toggling pattern is prohibited in this style. - Must Use Proper Streams: Verbose and debug messages MUST be written to their respective streams (
Write-Verbose,Write-Debug). Manual toggling of$VerbosePreferenceis prohibited.
Example of a Compliant Modern Function:
function Get-ModernData {
[CmdletBinding()]
[OutputType([pscustomobject])]
param (
[Parameter(Mandatory = $true)]
[string]$InputPath
)
process {
Write-Verbose "Processing file: $InputPath"
try {
$data = Get-Content -LiteralPath $InputPath -ErrorAction Stop
foreach ($line in $data) {
# This is streaming output.
[pscustomobject]@{
Length = $line.Length
Line = $line
}
}
} catch {
Write-Error -Message "Failed to process $InputPath: $($_.Exception.Message)"
}
}
}Benefits: Pipeline-friendly, discoverable
Trade-off: Breaks v1.0 compatibility
Rule 5 states that manual toggling of $VerbosePreference is prohibited. This rule's primary intent is to ensure your function or script respects the user's desire for verbose output from your script (via Write-Verbose).
An exception to this rule MAY be made when you MUST surgically suppress the verbose stream from a "chatty" or "noisy" nested command (a command you call within your function or script). This pattern allows your function or script to remain verbose while silencing the underlying tool.
In this specific scenario, you MUST use the following pattern to temporarily set $VerbosePreference and guarantee it is restored, even if the command fails:
# Save the user's current preference
$VerbosePreferenceAtStartOfBlock = $VerbosePreference
try {
# Temporarily silence the verbose stream for the nested command
$VerbosePreference = [System.Management.Automation.ActionPreference]::SilentlyContinue
# Call the noisy command.
$result = Get-NoisyCommand -Parameter $foo -ErrorAction Stop
# Restore the preference *immediately* after the call
$VerbosePreference = $VerbosePreferenceAtStartOfBlock
# All logic that depends on $result's success MUST
# also be inside the 'try' block.
Write-Verbose "Successfully processed result from noisy command."
# ... (other code that uses $result) ...
} catch {
# If Get-NoisyCommand fails, this block will run and
# the dependent logic above will be skipped.
# Re-throw the error so the caller knows what happened.
throw
} finally {
# This 'finally' block ensures the preference is restored,
# even if the 'catch' block runs and throws an error.
$VerbosePreference = $VerbosePreferenceAtStartOfBlock
}This try/finally pattern is robust, safe, and compliantly achieves your goal of controlling output from third-party cmdlets.
When a function returns one or more objects via the pipeline (streaming), the [OutputType()] attribute MUST declare the singular type of object in the stream (e.g., [OutputType([pscustomobject])]). Code MUST NOT use the plural array type (e.g., [OutputType([pscustomobject[]])]). The pipeline always creates an array for the caller automatically if multiple objects are returned.
When a function is intentionally designed to return different, non-related object types (e.g., a wrapper for ConvertFrom-Json which can return a single object, an array, or a scalar type), it is preferred to list all primary output types using multiple [OutputType()] attributes. This is far more descriptive and helpful to the caller than using a single, generic [OutputType([System.Object])].
If a function's output type is truly dynamic and unpredictable, [OutputType([System.Object])] SHOULD be used only as a last resort, as it provides minimal value for discoverability.
When using multiple [OutputType()] attributes, the goal is to list the primary, high-level object types a user can expect. It is not necessary to create an exhaustive list of every possible scalar or primitive type (e.g., [int], [bool], [double]) if they are not the main, intended output of the function.
This practice avoids cluttering the function's definition and keeps the developer's focus on the most common and important return values. For example, a JSON parsing function SHOULD list [pscustomobject] and [object[]], but does not need to list [int].
Using [CmdletBinding()] unlocks powerful parameter validation attributes like [Parameter(Mandatory = $true)], [ValidateNotNullOrEmpty()], and [ValidateSet()].
These are not stylistic requirements; they are design tools that MUST be used deliberately to enforce a function's operational contract.
-
Use
[Parameter(Mandatory = $true)]when:- The function cannot possibly perform its core purpose without this value (e.g.,
$Identityfor aGet-Userfunction). - You want the PowerShell engine to fail fast and prompt the user if it's missing.
- The function cannot possibly perform its core purpose without this value (e.g.,
-
DO NOT Use
[Parameter(Mandatory = $true)]when:- The function is a "safe" wrapper or helper designed to handle any input, including
$null. - The function has a clear, graceful default behavior when the parameter is omitted (e.g., returning
$null, an empty array, or$false). - Example: The
Convert-JsonFromPossiblyUrlEncodedStringfunction is a "safe" wrapper. Its contract is to try to convert a string. A$nullstring is a valid input that SHOULD gracefully return$null, not throw a script-terminating error. Making$InputStringmandatory would violate this "safe" contract.
- The function is a "safe" wrapper or helper designed to handle any input, including
-
Prefer
[ValidateNotNullOrEmpty()]over[Parameter(Mandatory = $true)]when:- The parameter is technically optional, but if it is provided, it MUST NOT be an empty string.
- This is common for optional parameters like
$LogPathor$Description.
-
Also use
[ValidateNotNullOrEmpty()]on mandatory[string]parameters when:- The function's logic depends on the parameter having a non-empty value (e.g., computing a hash, constructing a path, or performing a lookup).
- PowerShell coerces
$nullto[string]::Emptyfor[string]-typed parameters. Because[string]::Emptyis not$null, a mandatory[string]parameter satisfied by this coercion will pass the mandatory check but silently bind an empty string. This can cause incorrect behavior—for example, hashing an empty string instead of rejecting invalid input. - Adding
[ValidateNotNullOrEmpty()]alongside[Parameter(Mandatory = $true)]catches this edge case at parameter-binding time and produces a clear error message. - This guidance applies to functions and scripts targeting Windows PowerShell 2.0 or newer, because
[ValidateNotNullOrEmpty()]is not available in Windows PowerShell v1.0.
-
Use
[ValidateRange(min, max)]on numeric parameters when:- The parameter has a constrained valid domain, such as counts, delays or timeouts, thresholds, or percentages.
- Fail-fast parameter binding provides a clearer error than allowing an invalid value to surface later in downstream logic.
- When the domain is naturally bounded on both sides, prefer explicit bounds (for example
0, 100for percentages). When only a lower bound is principled, a type maximum such as[int]::MaxValueor[double]::MaxValueSHOULD be used as the upper bound to preserve the fail-fast benefit for the lower bound without imposing an artificial ceiling. - This attribute is available in Windows PowerShell 2.0 and newer.
When a function or script streams its output (whether it's a "modern advanced" function/script as mandated by the "Processing Collections" rule, or whether it's a standard, v1.0-compatible function and just happens to be streaming its output), the caller's variable will be $null if zero objects are returned, a scalar object if one object is returned, or an [object[]] array if multiple objects are returned.
This can cause errors in subsequent code that always expects an array (e.g., foreach ($item in $result) or $result.Count).
To ensure the result is always an array (even if empty or with a single item), the caller SHOULD wrap the function call in the array subexpression operator @(...). This is the standard, robust way to consume a streaming function and SHOULD be the default way to demonstrate usage in .EXAMPLE blocks.
Compliant .EXAMPLE:
# .EXAMPLE
# $arrPrincipals = @(Expand-TrustPrincipal -PrincipalNode $statement.Principal)
#
# # This example shows how to safely call the function and guarantee the
# # result is an array, even if only one principal is returned.v1.0-targeted functions MUST use two complementary v1.0-native suppression techniques:
| Technique | Implementation | Purpose |
|---|---|---|
trap { } |
Empty trap block at function scope | Catches terminating errors (e.g., type cast failures) and prevents script termination |
$global:ErrorActionPreference = 'SilentlyContinue' |
Temporarily set before risky operation, restored immediately after | Suppresses non-terminating error output to host |
trap { } # Intentional error suppression
$originalPref = $global:ErrorActionPreference
$global:ErrorActionPreference = [System.Management.Automation.ActionPreference]::SilentlyContinue
# Risky operation here
$global:ErrorActionPreference = $originalPref # State restorationKey characteristics:
- No error leakage to host → script continues
- State preservation → original preference restored
- v1.0 compatibility → works in all PowerShell versions
The author rejects unreliable heuristics ($?, $Error[0].Exception, null checks) and implements a reference-equality detection system using two custom helper functions:
Get-ReferenceToLastError: Returns[ref]$Error[0]if errors exist,[ref]$nullotherwiseTest-ErrorOccurred: Compares two[ref]objects to determine if a new error appeared
Detection workflow:
$refBefore = Get-ReferenceToLastError # Snapshot pre-operation
# ... perform risky operation ...
$refAfter = Get-ReferenceToLastError # Snapshot post-operation
$errorOccurred = Test-ErrorOccurred $refBefore $refAfterReference comparison logic:
| Before \ After | $null |
Not $null (same) |
Not $null (different) |
|---|---|---|---|
$null |
No | YES | N/A |
Not $null |
No | No | YES |
This eliminates false positives from $error array clearing and ensures 100% accurate error detection.
Every type conversion or risky operation MUST follow this exact atomic pattern:
function Convert-Safely {
param([ref]$refOutput, [string]$input)
trap { } # Suppress termination
$refBefore = Get-ReferenceToLastError
$originalPref = $global:ErrorActionPreference
$global:ErrorActionPreference = 'SilentlyContinue'
$refOutput.Value = [type]$input # Risky cast
$global:ErrorActionPreference = $originalPref
$refAfter = Get-ReferenceToLastError
return (-not (Test-ErrorOccurred $refBefore $refAfter))
}Key guarantees:
- Operation is isolated — error cannot affect caller
- State is restored — preference reset
- Result is boolean —
$true= success,$false= failure - No side effects — even on failure
Write-Warning MUST be used sparingly to flag logically impossible states (contract violations). Code MUST NOT suppress these warnings.
Write-Warning -Message 'Conversion failed even though individual parts succeeded. This should not be possible!'Despite suppression, full error context is preserved in the global $Error array (original ErrorRecord objects remain intact with stack trace and exception details).
In modern functions using try/catch (i.e., those not targeting v1.0), catch blocks MUST NOT be empty. An empty catch block is flagged by PSScriptAnalyzer and provides no diagnostic value.
The return-code and error-swallowing patterns described in the preceding v1.0 sections are primarily associated with library/helper functions — highly reusable building blocks that handle operational errors internally and communicate failure through an explicitly documented contract (e.g., integer return codes, reference outputs). These functions are designed to never throw, and their non-throwing behavior MUST be documented in .DESCRIPTION and .OUTPUTS. While v1.0 compatibility is a common consequence of this design goal, the non-throwing contract itself is the primary architectural motivation; a modern function can adopt the same pattern when the design requires it.
For modern higher-level functions and scripts — code that orchestrates these building blocks or performs tasks for end users — the default expectation is that unexpected failures propagate to the caller.
The standard catch pattern for modern advanced functions and scripts SHOULD log the error to the Debug stream and then re-throw it so that unexpected failures propagate to the caller. This SHOULD be the default unless the function is explicitly designed as a non-throwing wrapper with a documented contract.
# Default: log and re-throw
try {
...
} catch {
Write-Debug ("Failed to do X: {0}" -f $_)
throw
}When a catch block is intended to rethrow, throw "message" and throw ("format string" -f $args) MUST NOT be used. These forms throw a string that PowerShell wraps into a new RuntimeException/ErrorRecord, discarding the original exception type, stack trace, and ErrorRecord. This makes root-cause analysis significantly harder and breaks any caller logic that catches specific exception types. This prohibition applies only to catch blocks whose intent is to preserve and propagate the original failure; catch blocks that intentionally translate an error into a new, independently documented message (such as the file writeability tests) are not subject to this rule.
# WRONG — destroys the original exception:
try {
Get-Item -LiteralPath $strPath -ErrorAction Stop
} catch {
throw "Failed to get item: $($_.Exception.Message)"
}
# WRONG — same problem with -f operator:
try {
Get-Item -LiteralPath $strPath -ErrorAction Stop
} catch {
throw ("Failed to get item: {0}" -f $_.Exception.Message)
}If contextual information is needed before rethrowing, it SHOULD be logged via Write-Debug before the bare throw. This preserves the original exception while still providing diagnostic context on the Debug stream, reinforcing the Default Pattern.
# Correct — context logged, original exception preserved:
try {
Get-Item -LiteralPath $strPath -ErrorAction Stop
} catch {
Write-Debug ("Failed to get item at path '{0}': {1}" -f $strPath, $_)
throw
}If an exception must be wrapped with additional context while still propagating, the preferred pattern for advanced functions SHOULD use $PSCmdlet.ThrowTerminatingError() and preserve the original exception as the InnerException. This approach maintains the full exception chain for callers while adding meaningful context.
# Correct — wraps with context, preserves original as InnerException:
function Get-ResolvedItem {
[CmdletBinding()]
param (
[string]$Path
)
try {
Get-Item -LiteralPath $Path -ErrorAction Stop
} catch {
$objException = [System.InvalidOperationException]::new(
("Failed to resolve item at path '{0}'" -f $Path),
$_.Exception
)
$objErrorRecord = [System.Management.Automation.ErrorRecord]::new(
$objException,
'ResolvedItemFailure',
[System.Management.Automation.ErrorCategory]::ObjectNotFound,
$Path
)
$PSCmdlet.ThrowTerminatingError($objErrorRecord)
}
}Note: The above wrapping pattern is appropriate only when additional context is genuinely needed beyond what
Write-Debug+ barethrowprovides. In most cases, the Default Pattern is sufficient.
A modern function MAY intentionally handle an exception without re-throwing only when its contract explicitly specifies non-throwing behavior. In that case, the function's comment-based help (.DESCRIPTION and .OUTPUTS) MUST clearly document that failures are communicated through return values, output state, warnings, or another defined mechanism rather than by throwing.
# Non-throwing wrapper with documented contract
function Convert-SafelyFromJson {
# .SYNOPSIS
# Converts a JSON string to an object without throwing on invalid input.
#
# .DESCRIPTION
# Attempts to convert a JSON string to an object. This function does
# NOT throw on invalid input; instead it returns $null and logs the
# error to the Debug stream. Callers MUST check the return value.
#
# .PARAMETER JsonString
# JSON string to convert.
#
# .EXAMPLE
# $objResult = Convert-SafelyFromJson -JsonString '{"name":"example"}'
#
# # $objResult is a converted object on success, or $null on failure.
#
# .INPUTS
# None. You can't pipe objects to this function.
#
# .OUTPUTS
# [object] on success; $null on failure.
#
# .NOTES
# This function supports positional parameters:
#
# Position 0: JsonString
#
# Version: 1.0.20260517.0
[CmdletBinding()]
[OutputType([object])]
param (
[string]$JsonString
)
if ([string]::IsNullOrEmpty($JsonString)) {
return $null
}
try {
$JsonString | ConvertFrom-Json -ErrorAction Stop
} catch {
Write-Debug ("JSON conversion failed: {0}" -f $_)
$null
}
}When Set-StrictMode -Version Latest is in effect, referencing a variable that has never been assigned raises a terminating error. This creates a subtle but important pitfall when a finally block references a variable that is assigned inside the corresponding try block (for example, a disposable resource). If an exception occurs before the assignment executes, Set-StrictMode will raise a terminating error for the uninitialized variable inside finally, which can mask the original exception and interfere with proper cleanup.
Rule: When a finally block references a variable that is assigned inside the corresponding try block, that variable MUST be initialized before the try block, typically to $null.
Compliant Example:
$objResource = $null
try {
$objResource = [SomeDisposable]::Create()
# ... use $objResource ...
} finally {
if ($null -ne $objResource) {
$objResource.Dispose()
}
}In this example, $objResource is initialized to $null before the try block. If [SomeDisposable]::Create() throws before the assignment completes, the finally block can safely check $null -ne $objResource without triggering a Set-StrictMode violation.
Where Set-StrictMode -Version Latest belongs depends on how the .ps1 file is consumed at runtime. A .ps1 file that is dot-sourced executes its script-scope statements in the caller's scope, which means a script-scope Set-StrictMode call leaks into the caller and silently changes the caller's strict-mode setting. By contrast, when code is consumed through an imported module or by executing a script or aggregate artifact normally (for example, .\Helpers.ps1, & .\Helpers.ps1, or Import-Module), script-scope statements run in that artifact's own script scope, so a script-scope Set-StrictMode call is contained to that scope.
Rule (bundled files): For files bundled into a module or other aggregate script artifact, Set-StrictMode -Version Latest MUST be placed at script scope as the first executable statement in the file, after any required file-header constructs such as #requires comments, using statements, and any script-level [CmdletBinding()]/param block. The bundled artifact may also establish strict mode, making this redundant at runtime, but it preserves file-level correctness if the source file is ever executed directly. This rule does not make dot-sourcing the source file safe: dot-sourcing any .ps1 file — including an individual bundled source file or a monolithic bundled artifact — still runs its script-scope statements in the caller's scope and will leak strict mode.
Rule (dot-sourced files): For files that are not bundled and are instead intended to be dot-sourced directly into the caller's scope (for example, test fixtures, ad-hoc scripts, or build tooling), Set-StrictMode -Version Latest MUST NOT be placed at script scope. Instead, it MUST be placed inside the function body — as the first statement in begin {} when the function uses a begin/process/end layout (so strict mode covers begin, process, and end, and is not re-invoked for every pipeline input), or otherwise as the first statement in the function body.
#requires -Version 5.1
using namespace System.Text
Set-StrictMode -Version Latest
function Get-Thing {
[CmdletBinding()]
[OutputType([string])]
param (
[string]$Name
)
process {
# ... implementation ...
}
}# Set-StrictMode is missing at file scope. If the bundled artifact fails to
# establish strict mode, or if this file is executed independently,
# strict-mode guarantees are lost.
function Get-Thing {
[CmdletBinding()]
[OutputType([string])]
param (
[string]$Name
)
process {
# ... implementation ...
}
}function Invoke-TestFixture {
[CmdletBinding()]
[OutputType([void])]
param (
[string]$Path
)
begin {
Set-StrictMode -Version Latest
}
process {
# ... implementation ...
}
}# WRONG — when this file is dot-sourced, Set-StrictMode executes in the
# caller's scope and silently changes the caller's strict-mode setting.
Set-StrictMode -Version Latest
function Invoke-TestFixture {
[CmdletBinding()]
[OutputType([void])]
param (
[string]$Path
)
process {
# ... implementation ...
}
}Scripts that write output to files MUST verify the destination path is writable before performing any significant processing (preflight check for invalid paths, missing directories, insufficient permissions, read-only locations, locked files).
.NETapproach (Test-FileWriteability): Comprehensive, uses .NET file operations. MUST be used for v1.0-targeted scripts (sincetry/catchcauses parser errors in v1.0).try/catchapproach: Shorter (~10 lines), requires PowerShell v2.0+.
Both use a create-then-delete pattern. The delete step catches additional failure modes (e.g., file locks on Windows) that file creation alone may miss.
[v1.0] scripts MUST use the .NET approach. [Modern] scripts MAY use either approach.
Prefer .NET for mission-critical/unattended scripts, or where v1.0 parseability is needed. Prefer try/catch for simple utilities or when minimizing size.
Bundle Test-FileWriteability from the reference implementation:
$errRecord = $null
$boolIsWritable = Test-FileWriteability -Path 'Z:\InvalidPath\file.log' -ReferenceToErrorRecord ([ref]$errRecord)
if (-not $boolIsWritable) {
Write-Warning ('Failed to write to path. Error: ' + $errRecord.Exception.Message)
return # replace with an appropriate exiting action
}try {
$strOutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
$strWriteTestPath = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($strOutputPath), ('.write_test_{0}.tmp' -f [Guid]::NewGuid().ToString('N')))
[System.IO.File]::Open($strWriteTestPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write).Dispose()
[System.IO.File]::Delete($strWriteTestPath)
} catch {
throw ("Cannot write to '{0}': {1}" -f $OutputPath, $_.Exception.Message)
}Warning: File APIs with create-or-overwrite semantics (e.g.,
[System.IO.File]::Create(),New-Item -Force) SHOULD NOT be used for writeability probes unless the probe filename is guaranteed unique. Using the actual output path as the probe can destroy pre-existing data or cause false failures when the file already exists.
Full Test-FileWriteability implementation: https://github.com/franklesniak/PowerShell_Resources/blob/master/Test-FileWriteability.ps1
Platform-specific scripts/functions MUST include OS checks before platform-specific operations. Fail early — perform checks at the beginning of the function or script.
Use built-in variables: $IsWindows, $IsMacOS, $IsLinux.
if (-not $IsWindows) {
Write-Error -Message "This function only runs on Windows."
return -1
}For scripts supporting PowerShell older than 6.0 (including Windows PowerShell 1.0–5.1), $IsWindows/$IsMacOS/$IsLinux do not exist; referencing them yields $null or throws under strict mode, causing incorrect behavior. Use safe detection functions from PowerShell_Resources:
| OS | Function | Link |
|---|---|---|
| Windows | Test-Windows |
Test-Windows.ps1 |
| macOS | Test-macOS |
Test-macOS.ps1 |
| Linux | Test-Linux |
Test-Linux.ps1 |
$boolIsWindows = Test-Windows
if (-not $boolIsWindows) {
Write-Warning -Message "This function only runs on Windows."
return -1
}Report errors consistently with the script's existing error handling pattern (status codes, exceptions, or Write-Error). Error messages SHOULD clearly state which OS(es) are required.
A dedicated version probe returns a [System.Version] object:
function Get-PSVersion {
if (Test-Path variable:\PSVersionTable) {
return $PSVersionTable.PSVersion
} else {
return [version]'1.0'
}
}Returns actual version on v2.0+; falls back to [version]'1.0' when $PSVersionTable is absent.
Use PowerShell version as a feature flag for .NET types:
if ($versionPowerShell.Major -ge 3) {
$boolResult = Convert-StringToBigIntegerSafely ...
} else {
$boolResult = Convert-StringToDoubleSafely ...
}| PowerShell Version | .NET Type Used | Numeric Range |
|---|---|---|
| v3.0+ | [System.Numerics.BigInteger] |
Unlimited (subject to memory) |
| v1.0–v2.0 | [double] |
±1.7 × 10³⁰⁸ (IEEE 754) |
| All versions | [int], [int64] |
Built-in safe conversions |
| .NET Usage | Technical Justification |
|---|---|
[regex]::Escape() + [regex]::Split() |
Literal string splitting in v1.0 (alternative to v2.0+ -split) |
[System.Numerics.BigInteger] |
Overflow handling — only when PS v3+ detected |
Deprecation of System.Collections.ArrayList: ArrayList is deprecated and MUST NOT be used in new code. Use System.Collections.Generic.List[T] instead (available since .NET 2.0 / PS v1.0). ArrayList is only permitted as a caught-exception fallback, reported via debug stream.
# Compliant
$list = New-Object System.Collections.Generic.List[PSCustomObject]
# Non-Compliant (Deprecated)
$list = New-Object System.Collections.ArrayListTyped Generic Collections: The specific type T MUST be provided if known (e.g., [PSCustomObject], [string]), not [object].
PowerShell Array Accumulation: Code MUST NOT grow a PowerShell array with += inside an accumulation loop. PowerShell arrays are fixed-size, so each += creates a new array and copies the existing elements. When a collection must be accumulated in memory, code MUST use System.Collections.Generic.List[T] with .Add() or .AddRange(), and convert to an array with .ToArray() only at a boundary where an array is actually required. This rule complements, and does not weaken, the requirement that modern functions stream output when streaming is the correct contract.
# Compliant
$listOutput = New-Object System.Collections.Generic.List[PSCustomObject]
foreach ($objItem in $InputObject) {
[void]($listOutput.Add($objItem))
}
$arrOutput = $listOutput.ToArray()
# Non-Compliant
$arrOutput = @()
foreach ($objItem in $InputObject) {
$arrOutput += $objItem
}| .NET Type | First Available | Technical Purpose |
|---|---|---|
[regex] |
.NET 2.0 (PS v1.0) | Literal string parsing |
[System.Numerics.BigInteger] |
.NET 4.0 (PS v3.0+) | Unlimited integer precision |
[version] |
.NET 2.0 | Standard version semantics |
[ref] |
.NET 2.0 | Multiple return values (only for write-back) |
All types are v1.0-safe except BigInteger, which is guarded by version check.
v1.0-targeted functions return a single [int] status code via explicit return:
return 0 # Full success
return 4 # Partial success
return -1 # Complete failureDocument in .OUTPUTS: # [int] Status code: 0=success, 1-5=partial with additional data, -1=failure
A modern (non-v1.0) function SHOULD NOT build a large collection (like a List<T>) and return it at the end. This is memory-inefficient, as it requires holding all results in memory, and often creates an unnecessary O(n) performance hit when the list is copied.
The preferred, idiomatic PowerShell pattern is to "stream" the output: write each result object directly to the pipeline from within the processing loop. This is highly memory-efficient and aligns with the pipeline's "one object at a time" philosophy.
- Compliant (Streaming): Write objects to the pipeline inside your loop.
- Non-Compliant (Collecting): Adding all objects to a
$listand returning$listat the end.
Compliant (Streaming) Example:
[CmdletBinding()]
[OutputType([pscustomobject])]
param(...)
foreach ($objItem in $SourceData) {
# ... logic to create $objResult ...
$objResult # This writes the object to the pipeline
}
# Note: There is no 'return' statement for the collectionNon-Compliant (Collecting) Example:
[CmdletBinding()]
[OutputType([pscustomobject[]])] # Unnecessary plural
param(...)
$listOutput = New-Object System.Collections.Generic.List[PSCustomObject]
foreach ($objItem in $SourceData) {
# ... logic ...
[void]($listOutput.Add($objResult))
}
return $listOutput.ToArray() # Unnecessary copy and non-idiomaticThis rule is distinct from the v1.0-native pattern, which uses explicit integer return codes and passes data via [ref] parameters. The v1.0-native pattern MAY be desireable in situations where the function SHOULD return no output in the event of any error occurring during processing, or where error/warning status needs to be passed back to the caller.
All structured data is returned via [ref] parameters only when write-back to the caller is required.
| Stream | Command | Purpose |
|---|---|---|
| Success | return |
Primary result (status code) |
| Warning | Write-Warning |
Logical anomalies (contract violations) |
| Host | Never used | Prohibited — Write-Host MUST NOT be used |
v1.0-targeted functions MUST NOT emit mixed object types on the success stream.
The choice of output stream is critical for communicating intent:
- Warning Stream (
Write-Warning): Reserved for logical anomalies or conditions that the end-user SHOULD be aware of, but which do not halt execution (e.g., "Could not determine root user email for account X"). - Debug Stream (
Write-Debug): Used for logging internal function details that are not relevant to the end-user but are critical for diagnostics. This includes handledcatchblock errors, or fallback logic (e.g., "Failed to create generic lists; falling back to ArrayLists.").
When calling .NET methods that return a value (like System.Collections.ArrayList.Add()), that output MUST be suppressed to avoid polluting the pipeline. The preferred method is to cast the entire statement to [void] for performance, as it is measurably faster than piping to | Out-Null.
# Compliant (Preferred for performance)
[void]($list.Add($item))
# Non-Compliant (Typically slower than casting to void)
$list.Add($item) | Out-NullFunctions MUST NOT emit raw PII, credentials, secrets, tokens, or sensitive identifiers via Write-Verbose or Write-Debug. Use safe alternatives: boolean presence flags, non-sensitive metadata (length, count, type name), or redacted values.
# Non-Compliant
Write-Verbose -Message ('PrincipalKey: ' + $PrincipalKey)
# Compliant - logs whether the value is present (boolean)
Write-Verbose -Message ('PrincipalKey present: {0}' -f ($null -ne $PrincipalKey))[Modern] functions that are called per-record or inside tight loops (that is, hot paths) SHOULD guard Write-Verbose and Write-Debug calls that perform string formatting — such as with the -f operator or string concatenation — behind an appropriate preference check to avoid unconditional string allocation overhead when the stream is not enabled.
Recommended pattern for Write-Verbose:
if ($VerbosePreference -ne 'SilentlyContinue') {
Write-Verbose ("Processing item: {0}" -f $strCurrentItem)
}Recommended pattern for Write-Debug:
if ($DebugPreference -ne 'SilentlyContinue') {
Write-Debug ("Processing item: {0}" -f $strCurrentItem)
}Note: This guard is recommended only for performance-sensitive code paths and is NOT required for functions that run once, or only a small number of times, per pipeline or script execution.
Pester 5.x is required. Legacy 3.x/4.x patterns MUST NOT be used. Pester requires PowerShell 3.0+ to run, but scripts under test can target any version. See pester.dev.
- Test files MUST use
*.Tests.ps1suffix - Test files SHOULD be in a
tests/directory at the repository root - One test file per function/script SHOULD be created
Tests MUST use Pester 5.x syntax. Legacy Pester 3.x/4.x patterns MUST NOT be used.
| Block | Purpose |
|---|---|
BeforeAll |
One-time setup at the beginning of a Describe or Context block (e.g., dot-sourcing the function under test) |
BeforeEach |
Setup before each It block (use sparingly) |
AfterAll / AfterEach |
Teardown (cleanup resources, restore state) |
Describe |
Groups tests for a single function or script |
Context |
Groups tests for a specific scenario or condition |
It |
Defines an individual test case |
Should |
Assertion cmdlet for validating expected outcomes |
Key Pester 5.x Changes:
- Use
BeforeAllfor dot-sourcing scripts (not at the file level outside blocks) - Discovery and Run phases are separate—code at the top level runs during discovery
- Code MUST use
Should -Be,Should -BeExactly,Should -BeNullOrEmpty, etc. (not legacyAssert-*patterns)
Pester test files that dot-source scripts under test in BeforeAll MUST use the Split-Path + Join-Path two-step pattern. This pattern resolves the parent directory of the test file's directory and then builds the path to the source file:
BeforeAll {
$strSrcPath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'src'
. (Join-Path -Path $strSrcPath -ChildPath 'FunctionName.ps1')
}Why this pattern is required:
- Multi-segment
Join-Pathforms such asJoin-Path $PSScriptRoot '..' 'src' 'FunctionName.ps1'rely on the-AdditionalChildPathparameter, which was introduced in PowerShell 6.0 and is not available in Windows PowerShell 5.1. Test files MUST NOT use this form. - For consistency and canonical style in test files,
$PSScriptRoot-anchored..path forms such as$PSScriptRoot/../src/...orJoin-Path -Path $PSScriptRoot -ChildPath '../src/...'MUST NOT be used; use the explicit parent-resolution pattern instead.
Tests SHOULD follow the Arrange-Act-Assert (AAA) pattern for clarity and maintainability:
- Arrange: Set up test data, preconditions, and inputs
- Act: Execute the function or script under test
- Assert: Verify the output matches expectations
Each It block SHOULD test one specific behavior. Use comments to delineate the AAA sections for readability.
Example:
It "Returns success code 0 when given valid input" {
# Arrange
$refResult = $null
$strInput = "valid-input"
# Act
$intReturnCode = Get-ProcessedData -ReferenceToResult ([ref]$refResult) -InputString $strInput
# Assert
$intReturnCode | Should -Be 0
}When a test iterates or indexes into a collection returned by the function or script under test, the test MUST include defensive pre-assertions so that an empty or $null result produces a clear, immediate Pester failure instead of silently passing or generating a confusing runtime error.
-
Pre-iteration non-emptiness. Tests that iterate a collection with
foreach ($x in $collection) { ... }MUST assert$collection | Should -Not -BeNullOrEmptybefore theforeach. Aforeachover$nullor an empty collection executes zero iterations, causing all inner assertions to be silently skipped. -
Pre-index count assertion. Tests that access specific indices of a returned collection (e.g.,
$arrResult[0]) MUST assert$arrResult.Count | Should -Be <N>before any indexed access when the exact count is part of the contract being tested. If exact count is not part of the contract, the test MUST assert a minimum-count condition that covers the highest index accessed—for example,Should -BeGreaterThan <highest-index-accessed>(since collections are zero-based,$arr.Count | Should -BeGreaterThan 2guarantees that$arr[0],$arr[1], and$arr[2]are safe to access). -
Pre-index non-empty on nested properties. When a test indexes into a property of a returned element (e.g.,
$arrResult[0].Principals[0]), the test MUST also assert$arrResult[0].Principals | Should -Not -BeNullOrEmptybefore the inner index. -
Ordering. For a test that accesses
$arr[i].Prop[j], assertions SHOULD follow this order:$arr | Should -Not -BeNullOrEmptyor$arr.Count | Should -Be <N>$arr[i].Prop | Should -Not -BeNullOrEmpty- Strong-type check on
$arr[i].Prop, if applicable - Assertions that verify the actual behavior under test
Compliant (foreach — assert non-emptiness before iterating):
It "Each ClusterActions entry includes a Principals array" {
# Assert
$script:objResult.ClusterActions | Should -Not -BeNullOrEmpty
foreach ($objCluster in $script:objResult.ClusterActions) {
$objCluster.PSObject.Properties.Name | Should -Contain 'Principals'
$objCluster.Principals | Should -Not -BeNullOrEmpty
($objCluster.Principals -is [string[]]) | Should -BeTrue
}
}Non-Compliant (foreach — missing non-emptiness assertion):
# Non-Compliant: foreach over $null or an empty collection can execute zero
# iterations and leave the test without any evaluated inner assertions.
It "Each ClusterActions entry includes a Principals array" {
# Assert
foreach ($objCluster in $script:objResult.ClusterActions) {
$objCluster.PSObject.Properties.Name | Should -Contain 'Principals'
}
}Compliant (indexed access — count and nested non-emptiness before indexing):
# Assert
$arrResult.Count | Should -Be 1
$arrResult[0].Principals | Should -Not -BeNullOrEmpty
$arrResult[0].Principals.Count | Should -Be 2
$arrResult[0].Principals[0] | Should -Be 'userA'
$arrResult[0].Principals[1] | Should -Be 'userB'Non-Compliant (indexed access — no count assertion before [0]):
# Non-Compliant: no count assertion before [0].
# Assert
$arrResult[0].Principals.Count | Should -Be 1
$arrResult[0].Principals[0] | Should -Be 'userA'When a Pester test's purpose is to assert that a call succeeds — that is, completes without throwing — the test MUST wrap the invocation in a script block and assert it with Should -Not -Throw. Such tests MUST NOT use try { ... } catch { $e = $_ } followed only by negated assertions against exception text (for example, Should -Not -Match) as the mechanism for proving success. Negated assertions on exception text silently pass when an unrelated exception is thrown whose message does not happen to match the negated pattern, producing a green result for fundamentally broken code.
Tests whose purpose is to assert a specific expected failure MAY inspect exception details, but follow-up assertions on the captured exception MUST fail when the expected exception is absent or different — for example, Should -Throw -ExpectedMessage '<pattern>', or Should -Throw -PassThru (which returns the thrown ErrorRecord) or an explicit try { ... } catch { $e = $_ } to capture the exception, followed by assertions such as Should -Match or Should -Be against the captured object. A presence guard that fails when no exception was captured — specifically $e | Should -Not -BeNullOrEmpty immediately after a try/catch capture — IS permitted (and SHOULD be used before dereferencing $e.Exception) because it fails when $e is $null, which is the "expected exception absent" case. Should -Throw without -PassThru does not implicitly expose the thrown exception; a capture mechanism is required before any follow-up assertion. Negated assertions against exception text (for example, $e.Exception.Message | Should -Not -Match '<pattern>') MUST NOT be used as the sole mechanism for validating either success or expected failure, because any exception whose message does not match the negated pattern — including an unrelated exception — will silently satisfy the assertion.
Compliant (success assertion — Should -Not -Throw fails on any exception):
It "Completes without throwing for valid input" {
# Arrange
$strInput = 'valid-data'
# Act / Assert
{ Convert-StringToObject -StringToConvert $strInput } | Should -Not -Throw
}Non-Compliant (success assertion — try/catch plus negated message assertion can pass on an unrelated failure):
# Non-Compliant: if the function throws for an unrelated reason whose message
# does not contain 'invalid', the negated -Not -Match assertion still passes
# and the test reports success even though the call failed.
It "Completes without throwing for valid input" {
# Arrange
$strInput = 'valid-data'
$e = $null
# Act
try {
Convert-StringToObject -StringToConvert $strInput
} catch {
$e = $_
}
# Assert
$e.Exception.Message | Should -Not -Match 'invalid'
}Compliant (expected-failure assertion — capture with -PassThru and assert positively):
It "Throws a specific error for invalid input" {
# Arrange
$strInput = 'bad-data'
# Act
$errorRecord = { Convert-StringToObject -StringToConvert $strInput } |
Should -Throw -PassThru
# Assert
$errorRecord.Exception.Message | Should -Match 'invalid'
}Compliant (expected-failure assertion — capture with try/catch and assert positively):
It "Throws a specific error for invalid input" {
# Arrange
$strInput = 'bad-data'
$e = $null
# Act
try {
Convert-StringToObject -StringToConvert $strInput
} catch {
$e = $_
}
# Assert — positive assertion fails when $e is $null (no exception thrown)
$e | Should -Not -BeNullOrEmpty
$e.Exception.Message | Should -Match 'invalid'
}For functions and scripts that use explicit integer status codes, tests MUST verify the return code conventions documented in Return Semantics: Explicit Status Codes.
Note for functions and scripts that return objects: If a function or script returns
[pscustomobject]or other structured data instead of integer status codes, this section does not apply. For such cases, output contract verification — including edge cases such as$nullreturns — SHOULD be covered by Pester tests in accordance with Testing with Pester, where applicable.
For functions that return integer status codes (0 = success, 1-5 = partial success, -1 = failure), tests MUST cover:
| Return Code | Test Requirement |
|---|---|
0 |
At least one test verifying success case |
1-5 |
At least one test for partial success cases (if applicable to the function) |
-1 |
At least one test verifying failure case |
Additionally, if the function uses [ref] parameters for output:
- Tests MUST verify the reference parameter is populated correctly on success
- Tests MUST verify the reference parameter state on failure (typically
$nullor unchanged)
Example for Integer Status Code Function:
Describe "Convert-StringToObject" {
BeforeAll {
$strSrcPath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'src'
. (Join-Path -Path $strSrcPath -ChildPath 'Convert-StringToObject.ps1')
}
Context "When given valid input" {
It "Returns 0 for success" {
# Arrange
$refResult = $null
$strInput = "valid-data"
# Act
$intReturnCode = Convert-StringToObject -ReferenceToResult ([ref]$refResult) -StringToConvert $strInput
# Assert
$intReturnCode | Should -Be 0
}
It "Populates the reference parameter with the converted object" {
# Arrange
$refResult = $null
$strInput = "valid-data"
# Act
[void](Convert-StringToObject -ReferenceToResult ([ref]$refResult) -StringToConvert $strInput)
# Assert
$refResult | Should -Not -BeNullOrEmpty
}
}
Context "When given invalid input" {
It "Returns -1 for failure" {
# Arrange
$refResult = $null
$strInput = ""
# Act
$intReturnCode = Convert-StringToObject -ReferenceToResult ([ref]$refResult) -StringToConvert $strInput
# Assert
$intReturnCode | Should -Be -1
}
}
}For Test-* functions that return Boolean values (as documented in the exception for Test-* functions), tests MUST verify:
- A case that returns
$true - A case that returns
$false
Example for Boolean Test Function:
Describe "Test-PathExists" {
BeforeAll {
$strSrcPath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'src'
. (Join-Path -Path $strSrcPath -ChildPath 'Test-PathExists.ps1')
}
Context "When the path exists" {
It "Returns true" {
# Arrange
$strPath = $env:TEMP # Known to exist
# Act
$boolResult = Test-PathExists -Path $strPath
# Assert
$boolResult | Should -BeTrue
}
}
Context "When the path does not exist" {
It "Returns false" {
# Arrange
$strPath = "C:\NonExistent\Path\That\Does\Not\Exist"
# Act
$boolResult = Test-PathExists -Path $strPath
# Assert
$boolResult | Should -BeFalse
}
}
}When testing that a [pscustomobject] contains the expected property names, assertions MUST use an order-insensitive comparison. Although PSObject.Properties.Name preserves declaration order in practice for objects created via the [pscustomobject] type accelerator with a hashtable literal, this ordering is not a documented guarantee. Tests SHOULD NOT rely on property ordering because:
- Future refactors might change the property declaration order.
- Objects constructed via
Add-Memberor other mechanisms may not preserve insertion order. - Order-insensitive tests are more resilient and communicate intent more clearly.
Per-property containment (preferred when property count is small):
$objResult.PSObject.Properties.Name | Should -Contain 'Key'
$objResult.PSObject.Properties.Name | Should -Contain 'Type'
$objResult.PSObject.Properties.Name | Should -HaveCount 2Sorted array comparison (acceptable alternative — the expected array must be in sorted order to match the Sort-Object output):
($objResult.PSObject.Properties.Name | Sort-Object) |
Should -Be @('Key', 'Type')Non-Compliant (order-sensitive — fragile):
# Non-Compliant
$objResult.PSObject.Properties.Name | Should -Be @('Key', 'Type')When asserting that a property on a returned object is a non-empty, strongly-typed array, tests MUST follow these rules:
-
Non-emptiness first. Tests MUST use
Should -Not -BeNullOrEmptybefore any.Count-based assertion. This produces a clear failure message when the property is$nullor empty, rather than a confusing "expected greater than 0" when the property is$null. -
Strongly-typed assertion. When production code explicitly casts an output property to a strongly-typed array (e.g.,
[string[]]), tests MUST assert that exact array type using the-isoperator wrapped inShould -BeTrue:($obj.Prop -is [string[]]) | Should -BeTrue
Tests MUST NOT use a disjunction that also permits
[object[]], because that masks regressions when the intended production cast is accidentally removed. -
Avoid
Should -BeOfType [string[]]for array types. Tests SHOULD NOT use$x | Should -BeOfType [string[]]to assert array type, because the pipeline unrolls the array before Pester evaluates the type. Prefer the-is [string[]]pattern. -
Ordering. When combined with a property-name check, the recommended assertion order SHOULD be: property name first, then non-empty assertion, then strongly-typed assertion.
Compliant (preferred pattern):
$script:objResult.ClusterActions | Should -Not -BeNullOrEmpty
foreach ($objCluster in $script:objResult.ClusterActions) {
$objCluster.PSObject.Properties.Name | Should -Contain 'Principals'
$objCluster.Principals | Should -Not -BeNullOrEmpty
($objCluster.Principals -is [string[]]) | Should -BeTrue
}Non-Compliant (too permissive):
# Non-Compliant: [object[]] disjunction masks regressions in the
# production strongly-typed cast.
(($objCluster.Principals -is [string[]]) -or ($objCluster.Principals -is [object[]])) |
Should -BeTrue
# Non-Compliant: .Count on a potentially-null value is asserted before
# proving the property is non-empty.
$objCluster.Principals.Count | Should -BeGreaterThan 0Non-Compliant (pipeline unrolling):
# Non-Compliant: the array is unrolled before Pester evaluates the type assertion.
$objCluster.Principals | Should -BeOfType [string[]]Use Pester's Mock command to isolate the function under test from external dependencies:
Context "When external service is unavailable" {
BeforeAll {
Mock Get-ExternalData { throw "Connection failed" }
}
It "Returns failure code -1 and does not throw" {
# Arrange
$refResult = $null
# Act
$intReturnCode = Process-ExternalData -ReferenceToResult ([ref]$refResult)
# Assert
$intReturnCode | Should -Be -1
}
}Mocking Guidelines:
- Mock cmdlets and external commands that introduce dependencies (network, file system, cloud services)
- Mock at the narrowest scope possible (prefer
Context-level mocks overDescribe-level) - Use
Assert-MockCalledto verify expected interactions when appropriate
When a PSScriptAnalyzer CI integration emits host-native diagnostics in addition to or instead of plain analyzer output, it MUST use the command format for the active CI host. GitHub Actions annotations MUST use GitHub Actions workflow commands such as ::warning file=...,line=...::... or ::error file=...,line=...::.... Azure Pipelines issues MUST use Azure Pipelines logging commands such as ##vso[task.logissue type=warning;sourcepath=...;linenumber=...]... or ##vso[task.logissue type=error;sourcepath=...;linenumber=...].... These command syntaxes MUST NOT be interchanged.
When a command string includes dynamic values such as file paths, line numbers, rule names, messages, or titles, those values MUST be formatted, escaped, or encoded according to the active host's command syntax before they are written to the log. File paths SHOULD be emitted in the form the active host expects so that diagnostics resolve to the correct file.
Host-neutral, local, and interactive runs SHOULD use plain PSScriptAnalyzer output unless the active CI host is explicitly known. If host detection is ambiguous, missing, or contradictory, the integration MUST use plain PSScriptAnalyzer output and MUST NOT emit host-native diagnostic commands.
For local developer runs, use the documented project test root directly:
Invoke-Pester -Path tests/ -Output DetailedCI workflow runs often need a PowerShell run: step that performs both test discovery and Pester execution. In those steps, CI Pester discovery (Get-ChildItem ... -Filter '*.Tests.ps1' -Recurse) and execution (the Pester configuration Run.Path) MUST be scoped to the project-owned tests/ tree or to the project's documented test root. They MUST NOT scan from the repository root, because root-level scanning can sweep up unrelated tests, including starter, sample, template, vendored, or dependency *.Tests.ps1 files, and produce a misleading green test signal.
The Pester configuration Run.Path value and the discovery step's path MUST be derived from a single source of truth, such as a workflow-level env: value like PESTER_TEST_ROOT, so the discovery scope and execution scope cannot drift apart.
The discovery step SHOULD guard against a missing test root using Test-Path -LiteralPath ... -PathType Container so projects that have not yet created or have intentionally removed the tests/ directory still see a clean "no test files" skip rather than a workflow error. Using -PathType Container keeps the skip scoped to "the test root is a missing directory"; a bare Test-Path would also succeed when the path resolves to a file (for example a mis-set PESTER_TEST_ROOT), masking a real misconfiguration as a clean skip. Get-ChildItem ... -ErrorAction SilentlyContinue MAY be used as an alternative, but it is NOT equivalent: SilentlyContinue also suppresses non-existence-related errors such as permission or IO failures, which can mask genuine CI problems as a clean skip. A Test-Path guard followed by Get-ChildItem without -ErrorAction SilentlyContinue is preferred.
Compliant (single env-var-driven test root for discovery and execution):
- name: Run Pester tests
shell: pwsh
env:
PESTER_TEST_ROOT: tests
run: |
$strPesterTestRoot = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath $env:PESTER_TEST_ROOT
$arrPesterTestFiles = @()
if (Test-Path -LiteralPath $strPesterTestRoot -PathType Container) {
$arrPesterTestFiles = @(
Get-ChildItem -LiteralPath $strPesterTestRoot -Filter '*.Tests.ps1' -Recurse -File
)
}
if ($arrPesterTestFiles.Count -eq 0) {
Write-Output ("No Pester test files found under '{0}'." -f $strPesterTestRoot)
exit 0
}
$objPesterConfiguration = New-PesterConfiguration
$objPesterConfiguration.Run.Path = $strPesterTestRoot
$objPesterConfiguration.Output.Verbosity = 'Detailed'
Invoke-Pester -Configuration $objPesterConfigurationNon-compliant (repository-root discovery and execution):
- name: Run Pester tests
shell: pwsh
run: |
$arrPesterTestFiles = @(
Get-ChildItem -Path . -Filter '*.Tests.ps1' -Recurse -File
)
if ($arrPesterTestFiles.Count -eq 0) {
Write-Output "No Pester test files found."
exit 0
}
$objPesterConfiguration = New-PesterConfiguration
$objPesterConfiguration.Run.Path = '.'
$objPesterConfiguration.Output.Verbosity = 'Detailed'
Invoke-Pester -Configuration $objPesterConfigurationWhen a cmdlet supports both -Path and -LiteralPath, and the code is operating on a single concrete path value—not an intentionally wildcarded pattern—-LiteralPath SHOULD be used instead of -Path. This especially applies when the path is derived from variables, Join-Path, or string construction, because -Path interprets wildcard characters ([, ], *, ?) and can silently match the wrong files or match nothing at all.
For destructive operations—Remove-Item, Move-Item—-LiteralPath MUST be used when the path value comes from a variable or expression. Wildcard interpretation of a variable-derived path in a destructive cmdlet can silently delete or move unintended files.
Reserve -Path for cases where wildcard expansion is explicitly intended.
Exception — New-Item: New-Item does not have a -LiteralPath parameter (across Windows PowerShell 5.1 and PowerShell 7.x). Use New-Item -Path for item creation. Because -Path still interprets wildcard characters, code SHOULD validate or reject untrusted input containing [, ], *, or ? as literal characters, or use a .NET file API (e.g., [System.IO.File]::Create()) when literal path semantics are required.
Directory creation: When creating a directory from a variable-derived path that may contain wildcard characters ([, ], *, or ?), code SHOULD prefer [System.IO.Directory]::CreateDirectory() over New-Item -Path ... -ItemType Directory. The path MUST first be resolved to an absolute filesystem path per Resolving Paths for .NET Static Methods.
Compliant (wildcard-safe directory creation):
$strOutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
if (-not (Test-Path -LiteralPath $strOutputPath)) {
[void]([System.IO.Directory]::CreateDirectory($strOutputPath))
}Common cmdlets where this rule applies: Copy-Item, Get-ChildItem, Get-Content, Get-Item, Move-Item, Remove-Item, Set-Content, Test-Path.
Compliant:
Test-Path -LiteralPath $strFilePath
Get-Content -LiteralPath $strConfigFile -ErrorAction Stop
Remove-Item -LiteralPath $strTempFile -ForceNon-compliant (variable-derived path with -Path):
# Risk: $strFilePath may contain [] or wildcard characters
Test-Path -Path $strFilePath
Get-Content -Path $strConfigFile -ErrorAction Stop
Remove-Item -Path $strTempFile -Force # Dangerous: destructive + wildcardAcceptable (intentional wildcard):
# Intentional wildcard — -Path is correct here:
Get-ChildItem -Path 'C:\Logs\*.log'
Remove-Item -Path 'C:\Temp\*.tmp' -Force