PS C:\> Get-Standard · v0.7.0
AGENTS.md: PowerShell Engineer Standard
Version: 0.7.0 · Maintainer: Jim Tyler, Microsoft MVP, PowerShell Engineer (powershellengineer.com) Applies to: all PowerShell (.ps1, .psm1, .psd1) authored in this repository.
Not affiliated with or endorsed by Microsoft. PowerShell is a trademark of Microsoft Corporation.
Write for the engineer who inherits this code and has to run it against production at 2am. When a rule below conflicts with an explicit instruction from the user, follow the user and say which rule you set aside and why.
0. Non-negotiables
Every function you emit must satisfy all of these. If you cannot, stop and say so.
- Advanced function:
[CmdletBinding()]+param()block - Approved verb (
Get-Verb), singular noun,Prefixapplied in module context [OutputType()]declared- Emits objects (
[pscustomobject]or a class), never formatted strings - Validation expressed as parameter attributes, not
ifblocks - Comment-based help with a runnable
.EXAMPLE SupportsShouldProcesson anything that changes state- Terminating errors are catchable; non-terminating errors don't masquerade as success
- No secret reaches an output stream, a native-command argument, or source control
- PSScriptAnalyzer clean against
PSScriptAnalyzerSettings.psd1 - Comments and help written in the voice set out in section 19, not in marketing register
- No invented cmdlets, parameters, modules, or properties (section 0.1)
- Full cmdlet names and named parameters. No aliases, no positional args
0.1 Do not invent API surface
The most common defect in generated PowerShell is not style. It is a parameter that does not exist. Get-ADUser -IncludeDisabled reads perfectly, passes review, and fails the moment it runs. Code that is wrong in this way is worse than code that is ugly, because nothing about it looks wrong.
- If you are not certain a cmdlet, parameter, module, property, enum value, exception type,
or .NET method signature exists, do not use it.
- Prefer a construct you are sure of over an elegant one you are not.
Where-Object { -not $_.Enabled } that you know works beats a -Filter string you half remember.
- Never invent a module name. If you do not know which module provides a command, say so
rather than guessing at something plausible.
- Never fabricate example output. Either derive it from the code you just wrote or label it
as illustrative.
- Do not reproduce documentation URLs from memory. Name the command and let the reader run
Get-Help against the version they actually have.
Version-specific surface is where this fails most often. Parameters added in later module releases, renames between AzureRM and Az, Graph SDK v1 against v2, Exchange Online v2 against v3. Being right about one version is being wrong about another.
When you are unsure and the task still needs it, do both of these:
- Mark it in the code where the reader will see it:
# VERIFY: confirm -IncludeDisabled exists in your module version
- Give the command that settles it:
Get-Command Get-ADUser -Syntax
(Get-Command Get-ADUser).Parameters.Keys
Get-Help Get-ADUser -Parameter IncludeDisabled
$result | Get-MemberStating uncertainty costs the reader five seconds. An invented parameter costs them a debugging session, and costs you their trust in everything else in the file.
1. Canonical shape
This is the reference implementation. Match its structure.
function Remove-PSEStaleAccount {
<#
.SYNOPSIS
Disables Active Directory accounts inactive beyond a threshold.
.DESCRIPTION
Finds enabled user accounts whose LastLogonDate precedes the cutoff and
disables them. Requires delegated write access to the target OU. Emits one
result object per account evaluated, including skipped accounts, so the
caller can audit the full run rather than only the changes.
.PARAMETER Identity
The account to evaluate. Accepts pipeline input from Get-ADUser so you can
filter with the AD provider's server-side query before piping in.
.PARAMETER InactiveDays
Days of inactivity before an account is considered stale. Set this to match
your organization's account lifecycle policy; the default is deliberately
conservative.
.INPUTS
Microsoft.ActiveDirectory.Management.ADUser
.OUTPUTS
PSEngineer.AccountResult
.EXAMPLE
Get-ADUser -Filter 'Enabled -eq $true' -SearchBase $ou -Properties LastLogonDate |
Remove-PSEStaleAccount -InactiveDays 90 -WhatIf
Previews the full run without modifying anything.
.EXAMPLE
Remove-PSEStaleAccount -Identity jdoe -InactiveDays 30 -Verbose
Evaluates a single account with progress detail on the verbose stream.
.EXAMPLE
$stale = Get-Content .\accounts.txt | Remove-PSEStaleAccount -InactiveDays 180
$stale | Where-Object Action -eq 'Disabled' | Export-Csv .\disabled.csv -NoTypeInformation
Captures results as objects for downstream reporting.
.NOTES
Requires: ActiveDirectory module, delegated write access to the target OU.
.LINK
Get-ADUser
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
[OutputType('PSEngineer.AccountResult')]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[ValidateNotNullOrEmpty()]
[string[]]$Identity,
[Parameter()]
[ValidateRange(1, 3650)]
[int]$InactiveDays = 90
)
begin {
$cutoff = (Get-Date).AddDays(-$InactiveDays)
Write-Verbose "Cutoff date: $cutoff"
}
process {
foreach ($id in $Identity) {
try {
$getParams = @{
Identity = $id
Properties = 'LastLogonDate', 'Enabled'
ErrorAction = 'Stop'
}
$user = Get-ADUser @getParams
}
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] {
Write-Error -Message "Account not found: $id" -Category ObjectNotFound -TargetObject $id
continue
}
$isStale = $user.LastLogonDate -and $user.LastLogonDate -lt $cutoff
$action = 'Skipped'
if ($isStale -and $PSCmdlet.ShouldProcess($id, 'Disable account')) {
try {
Disable-ADAccount -Identity $user -ErrorAction Stop
$action = 'Disabled'
}
catch {
$PSCmdlet.WriteError($_)
$action = 'Failed'
}
}
[pscustomobject]@{
PSTypeName = 'PSEngineer.AccountResult'
Identity = $id
LastLogonDate = $user.LastLogonDate
Action = $action
}
}
}
end {
Write-Verbose 'Evaluation complete.'
}
}2. Naming
- Verbs from
Get-Verbonly.Get/Set/New/Remove/Test/Invokecover most needs. - Nouns singular, PascalCase, prefixed:
PSEfor this project (Get-PSEModulePlan). - Parameters match ecosystem convention:
Path,Identity,Name,Force,PassThru. - No abbreviations in public surface.
$svcis fine locally;-Svcis not a parameter name.
3. Parameters and validation
Push constraints into attributes so Get-Help and tab-completion expose them for free:
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[ValidateSet('Dev', 'Test', 'Prod')]
[string]$Environment
[ValidateRange(1, 100)]
[int]$BatchSize = 25
[ValidateScript({
if (-not (Test-Path -LiteralPath $_)) { throw "Path not found: $_" }
$true
})]
[string]$ConfigPath- Prefer
[ValidateSet]over string comparison; it drives completion. [ValidateScript]mustthrowa message that names the offending value. The default
failure text tells the user nothing.
- Use parameter sets with
DefaultParameterSetNameinstead of mutually exclusive switches. - Never default a parameter to a mutable shared object.
Completion
[ValidateSet] handles closed sets that are known at authoring time. When the valid values depend on the environment, supply a completer instead of leaving the user to guess:
[Parameter(Mandatory)]
[ArgumentCompleter({
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
(Get-PSEProfile).Name |
Where-Object { $_ -like "$wordToComplete*" } |
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_) }
})]
[string]$ProfileName- Completers run on every Tab press. Keep them under a few hundred milliseconds and cache
anything that crosses the network. A completer that queries a directory on each keystroke makes the whole shell feel broken.
- Return
CompletionResultobjects rather than bare strings when you want tooltips or a
display name that differs from the inserted value.
- Filter on
$wordToCompleteyourself. Nothing does it for you. - When the same completer serves several parameters, implement
IArgumentCompleteras a
class and reference it with [ArgumentCompleter([PSEProfileCompleter])].
Register-ArgumentCompleteris for adding completion to commands you do not own. Do not
use it as a substitute for declaring the completer on your own parameters.
4. Splatting
Splat any call with three or more parameters, or any call whose line would exceed ~100 characters. Build the hashtable immediately above the call.
# Yes
$requestParams = @{
Uri = "$BaseUri/v1.0/users"
Method = 'Get'
Headers = $authHeader
ContentType = 'application/json'
ErrorAction = 'Stop'
}
Invoke-RestMethod @requestParams
# No. Backticks are a maintenance hazard
Invoke-RestMethod -Uri "$BaseUri/v1.0/users" -Method Get `
-Headers $authHeader -ContentType 'application/json' -ErrorAction StopConditional parameters are added to the hashtable, never assembled as string fragments:
if ($Credential) { $params['Credential'] = $Credential }Never use backtick line continuation. Splat instead.
5. Output and the pipeline
Write-Hostis banned for data. Use it only for deliberate, human-only decoration, and
prefer Write-Information even then.
- One object per item, emitted in
process. Don't accumulate and return an array unless
ordering or summary requires it.
- Attach
PSTypeNameso formatting andGet-Memberbehave. - A
PSTypeNameon its own only tags the object. Any type name you invent and emit from more
than one function gets a Format.ps1xml defining its default view, otherwise you have paid the cost of typing the output and taken none of the benefit. Register it through FormatsToProcess in the manifest.
- Use
Types.ps1xml(viaTypesToProcess) forScriptProperty,AliasProperty, and
DefaultDisplayPropertySet. DefaultDisplayPropertySet is what lets an object carry twenty properties while showing the four that matter, which removes most of the temptation to reach for Format-Table in the first place.
Update-FormatDataandUpdate-TypeDataare for the development loop. Shipped modules
declare both files in the manifest.
- Never emit
Format-*output from a function. It cannot be used downstream. - Support pipeline input via
ValueFromPipelineByPropertyNamewherever a natural upstream
cmdlet exists. This is the most commonly skipped requirement in generated code; treat it as mandatory unless the function genuinely takes no item-shaped input.
6. Error handling
| Situation | Do this |
|---|---|
| One item of many fails | Write-Error + continue, keep processing |
| Precondition invalid, cannot proceed | $PSCmdlet.ThrowTerminatingError($errorRecord) |
| Calling a cmdlet you must catch | Pass -ErrorAction Stop on that call |
| Re-surfacing a caught error | $PSCmdlet.WriteError($_), which preserves the record |
- Catch specific exception types before a general
catch. - Never set
$ErrorActionPreferenceglobally in a module; scope it per call. - Never use
trap. - Never swallow:
catch { }is a defect. If you truly intend to ignore, say so in a comment. - Include
-TargetObjectso the caller can identify which input failed. finallyfor cleanup that must run: connections, temp files, impersonation.
Strict mode
Set-StrictMode -Version Latestat the top of every script, and in the root.psm1of
every module. It turns typo'd variable names, missing properties, and out-of-range array indexes into errors instead of silent $null.
- Pin to
-Version 3.0instead when the code must behave identically across engine versions.
Latest changes meaning as the engine changes, which is the point of it and also the risk.
- Strict mode is a safety net for authoring mistakes, not a substitute for parameter
validation. It does nothing about a caller passing a valid-but-wrong value.
- Do not enable it inside a single function to paper over a scope you do not trust. Set it
once at the top and fix what it surfaces.
7. Safety: ShouldProcess
Any function that creates, modifies, or deletes state declares SupportsShouldProcess.
ConfirmImpact = 'High'for destructive or irreversible operations.- Gate the mutation only. Evaluation, reads, and result objects still run under
-WhatIf,
so a -WhatIf run doubles as an audit.
$PSCmdlet.ShouldContinue()only for additional interactive confirmation beyond
ShouldProcess, and always paired with a -Force switch to bypass it.
- A
-Forceswitch must also set$ConfirmPreferenceappropriately, not skipShouldProcess.
8. Help quality bar
Decorative help is worse than no help, because it implies documentation exists.
.SYNOPSIS: one imperative line. Do not restate the function name..DESCRIPTION: side effects, prerequisites, required permissions, and what it does not do..PARAMETER: explain why you'd set it. The type is already visible, so don't repeat it..INPUTS/.OUTPUTS: real type names. Composability depends on these being accurate..EXAMPLE×3 minimum: simple → realistic → edge case. Each must run unmodified..NOTES: permissions, module dependencies, version requirements.- Modules ship
en-US/about_PSEngineer.help.txt. Most modules skip it, which is why most
modules read as a folder of scripts rather than a product.
Test: everything in Get-Help <function> -Examples must be copy-pasteable.
9. Testing with Pester v5
Pester v5 only. v4 syntax fails in v5 because of discovery/run phase scoping. Do not mix them.
- Variables used by
Itblocks are set inBeforeAll, not at describe scope. Describeper function;Contextper scenario or parameter set.- Test behavior and contracts, not implementation details.
- Mock with
-ModuleNameto intercept inside the module scope. InModuleScopefor private functions.- Required coverage: happy path, each validation failure, error branch,
-WhatIfmakes no change. - Tag slow or environment-dependent tests so CI can select.
10. Module structure
PSEngineer/
├── src/
│ ├── Public/ # one exported function per file, named for the function
│ ├── Private/ # helpers, not exported
│ ├── Classes/ # loaded before functions
│ └── en-US/ # about_ help, localized strings
├── tests/
├── build/
├── PSEngineer.psd1
├── PSEngineer.Format.ps1xml # default views for emitted PSTypeNames
├── PSEngineer.Types.ps1xml # DefaultDisplayPropertySet, ScriptProperties
└── PSEngineer.psm1FunctionsToExportlists names explicitly. Never'*', because wildcards force full-module
parse on discovery and measurably slow every Import-Module and tab-completion.
- Same for
CmdletsToExport,VariablesToExport,AliasesToExport: explicit or@(). - Build concatenates
src/into a single.psm1for load performance; author in separate files. - SemVer in the manifest. Maintain
CHANGELOG.md. - Declare
RequiredModules,PowerShellVersion, andCompatiblePSEditions.
Dependencies
Every entry in RequiredModules is an install step, a version conflict, and a support question. Target zero. The whole of onboarding should be:
Install-Module PSEngineer -Scope CurrentUser
Import-Module PSEngineerNo build step, no manual assembly copying, no elevation, no post-install script, no network call at import time.
- Prefer the .NET base class library over a module that wraps it.
- When a dependency serves one or two functions, make it optional rather than required. Test
for it at call time and fail with the exact command that fixes it: throw "Requires the Az.Accounts module. Install with: Install-Module Az.Accounts -Scope CurrentUser"
- Pin exact versions for anything genuinely required (section 12.3).
- Do not vendor another module inside yours. Its licensing, updates, and version conflicts
become yours the moment you do.
- Keep
PowerShellVersionandCompatiblePSEditionshonest so the Gallery filters correctly
for people who cannot meet your minimum.
- A platform-specific dependency means a companion module, not a conditional import.
11. Performance
- Never
+=on arrays in a loop. It reallocates the whole array every iteration. Use
[System.Collections.Generic.List[T]]::new() or emit straight to the pipeline.
- Filter left, format right. Push predicates into
-Filter(server-side) rather than
Where-Object (client-side) for AD, Exchange, and file system queries.
$null = $expror[void]$exprinstead of| Out-Null.foreachstatement beatsForEach-Objectfor large in-memory collections.- Hashtable lookups instead of nested
Where-Objectscans when joining datasets. - Don't micro-optimize readable code without a measurement. Cite
Measure-Commandresults
when you do optimize.
Parallelism
Sequential until measurement says otherwise. Every runspace is a fresh session that re-imports modules and re-resolves commands, and that startup cost swamps the work in most scripts people reach for parallelism to fix.
ForEach-Object -Parallel(7.0+) pays off when the work is I/O bound, such as REST calls or
remote queries, and there are enough items to amortize runspace startup. It rarely helps CPU-bound PowerShell.
- Set
-ThrottleLimitdeliberately. The default of 5 is a starting point, not a tuned value,
and the right number depends on what is being waited on.
- Variables from the caller's scope need
$using:. Collections written from parallel blocks
need a thread-safe type such as [System.Collections.Concurrent.ConcurrentBag[object]]. A plain List[T] will corrupt or throw under contention.
Start-ThreadJobsuits a handful of long-running background tasks better than
ForEach-Object -Parallel suits the same job.
- Manage runspace pools directly only when neither of the above fits, and write a comment
saying which constraint forced it.
- Never parallelize work that prompts through
ShouldProcess, writes to a shared file
without synchronization, or depends on ordering.
- Not available in 5.1. Guard it, or state the version requirement.
12. Security
12.1 Secret handling
- Credentials are
[pscredential]. Secrets come fromMicrosoft.PowerShell.SecretManagement. - Never accept
[string]$Password. Take[pscredential]or[securestring]. - Secrets never touch an output stream. Script block logging (Event ID 4104), module
logging, and transcription capture expanded string values, so Write-Verbose "pass: $plain" writes the secret to the event log on every endpoint that runs it. This applies to verbose, debug, warning, error, and progress alike.
- Never pass a secret as a native-command argument. Arguments are visible in the process
table to any local user. Use stdin, an environment variable scoped to the child process, or a temp file with a restrictive ACL.
- Call
.GetNetworkCredential().Passwordat the last possible moment, in the narrowest
scope, and never assign the result to a variable that outlives the call.
SecureStringis not encrypted on non-Windows platforms. Never imply that it is.ConvertTo-SecureString -Key $byteswith the key stored beside the ciphertext is
obfuscation, not encryption. Do not present it as protection.
-AsPlainText -Forceis a smell. If you emit it, justify it in a comment.- No plaintext secrets in source, comments, examples, default parameter values, or tests.
- Prefer managed identity, workload identity, or JEA over any stored credential.
- CI runs secret scanning; a committed secret is a build failure, not a cleanup task.
12.2 Untrusted input
Assume every parameter value is hostile. Banning Invoke-Expression alone is not enough. These are the equivalent surfaces:
| Surface | Rule |
|---|---|
Invoke-Expression | Banned. Use & with an argument array, or splatting. |
[scriptblock]::Create($input) | Banned on any non-literal input. |
Add-Type -TypeDefinition $input | Banned on any non-literal input. |
LDAP -Filter / -LDAPFilter | Escape input; never concatenate raw values into a filter. |
Invoke-Sqlcmd | Parameterized queries only. Never interpolate into -Query. |
| Native command args | Quote deliberately; know when --% applies and the PS 7.3 argument-passing change. |
| Regex patterns | [regex]::Escape() on any user-supplied fragment. |
| Paths | -LiteralPath when the value may contain wildcard characters. |
Also:
- Certificate validation is never bypassed.
-SkipCertificateCheckand-SkipCACheck
require an inline justification. Setting [ServicePointManager]::ServerCertificateValidationCallback = { $true } is banned outright. It disables TLS validation session-wide, far beyond the call that set it.
Import-Clixmlis not a safe data format. It can instantiate arbitrary types; never point
it at untrusted input. Use JSON for interchange.
Expand-Archiveon untrusted archives: validate that each resolved entry path stays inside
the destination directory (zip-slip).
- Temp files use
[System.IO.Path]::GetRandomFileName(), never a predictable name, and get a
restrictive ACL before anything sensitive is written.
- Avoid
Test-Path-then-act on shared paths; the check and the use can race. Act and catch. - Remoting: HTTPS-only WinRM. CredSSP exposes credentials on the target. Solve the double-hop
problem with JEA or resource-based constrained delegation instead.
- Code must behave correctly under Constrained Language Mode; several constructs (class
definitions, most type accelerators, Add-Type) simply won't run in WDAC/AppLocker environments. Fail with a clear message rather than a type-resolution error.
12.3 Supply chain
- PowerShell Gallery is not curated, and typosquatting is trivial. Verify the publisher.
- Pin exact versions in
RequiredModules.ModuleVersionalone permits drift. - Never
Set-PSRepository -InstallationPolicy Trustedon a public repository. - Check
Get-AuthenticodeSignatureon installed modules where signing is expected. - The
Invoke-WebRequest | Invoke-Expressioninstall pattern is banned, including in
documentation and README examples, no matter how many upstream projects recommend it.
- Vendor or hash-verify any script downloaded as part of a build.
12.4 Execution context
#Requires -RunAsAdministratoronly when the function genuinely requires elevation.- Detect privilege rather than assuming it; fail early with an actionable message.
- Request the narrowest scope and permission set that completes the task.
- Signing and execution policy are deployment concerns. Never instruct a user to weaken them
as a workaround.
13. Cross-platform
#Requires -Version 7.0unless Windows PowerShell 5.1 support is explicitly requested.- When targeting both, state which constructs are 5.1-safe (no ternary, no
??,
no parallel ForEach-Object).
Join-Pathover string concatenation.[System.IO.Path]::DirectorySeparatorCharwhen needed.- Flag Windows-only cmdlets (
Get-CimInstanceon non-Windows, registry, WMI, AD module). $IsWindows/$IsLinux/$IsMacOSfor branching. These don't exist in 5.1, so guard.- Prefer
Get-CimInstanceover the deprecatedGet-WmiObject.
14. When to use classes
PowerShell classes earn their place for:
- Enums (
enum LogLevel { ... }) - Custom exception types
- Typed models with validation in the constructor
- DSC resources
They are the wrong tool when:
- A
[pscustomobject]with aPSTypeNamewould do, which covers most cases - The type must be reloaded during an interactive session (classes don't reload cleanly)
- Consumers need
using modulesemantics you'd rather not explain
Idiomatic PowerShell is functions plus objects. Do not produce Java-flavored PowerShell: no service-locator patterns, no abstract base classes, no interface hierarchies. Reach for a class when it removes duplication or enforces an invariant you would otherwise have to check by hand.
15. Output, logging, and accessibility
15.1 Presentation
- Never encode meaning in color alone. Pair it with text.
- Respect
$env:NO_COLORand use$PSStylerather than raw ANSI escapes. Write-Progressfor long operations, not redrawn ASCII progress bars.- Structured objects beat aligned columns: screen readers navigate data, not whitespace.
- Error messages state what failed, which input caused it, and what to do next.
- Don't rely on Unicode box-drawing or emoji for meaning; terminals and readers vary.
15.2 Logging for unattended and long-running work
The streams are built for someone watching a console. A script that runs for six hours on a schedule with nobody attached needs a durable record, and Write-Verbose is not one.
- The primary interface stays the object stream. A caller who pipes your output to
Export-Csv should not also have to parse a log file to learn what happened.
- Emit one structured record per event, not prose. JSON per line, carrying UTC timestamp,
severity, the run identifier, and the target object's identity.
- Generate a run identifier once (
[guid]::NewGuid()) and attach it to every record, so a
single invocation can be reconstructed from a shared log.
- Write to the platform's native sink where one exists: the Windows Event Log through
New-WinEvent for scheduled tasks, or stdout where a container runtime collects it. A bare .log file next to the script is the fallback, not the default.
Start-Transcriptis a troubleshooting tool, never a logging strategy. It captures
everything typed and returned, including anything sensitive that crossed the session.
- Nothing in section 12.1 becomes loggable because the destination is a file.
Write-Progressis not logging. It vanishes, and unattended runs never see it.- Do not grow unbounded files in the script's own directory. Retention belongs to whoever
operates the script, so take a path parameter and document the expectation.
16. Localization
User-facing strings in modules come from Import-LocalizedData, not string literals:
Import-LocalizedData -BindingVariable Strings -FileName Strings.psd1
Write-Verbose ($Strings.ProcessingAccount -f $Identity)with src/en-US/Strings.psd1 holding the defaults. Verbose, warning, and error text are localizable. Parameter names, property names, and PSTypeName values are not, because they are API surface and stay English.
17. Style
- 4-space indent, no tabs. OTBS brace style (opening brace on the same line).
- One blank line between logical blocks; none at the start of a scope.
$nullon the left in comparisons:if ($null -eq $result).- Full parameter names, full cmdlet names.
Where-Object, not?. - Comments explain why. The code already says what.
- Lines under ~120 characters. Break by splatting, never by backtick.
18. Graphical interfaces
One rule outranks everything else in this section: the GUI is a client of the module, never the home of its logic. Every action a window can trigger must be reachable as a function call with the same parameters and the same output. Behavior that exists only behind a button cannot be tested, scheduled, logged, or composed, and a module in that state has failed at its main job.
Ship the interface as a separate companion module (PSEngineer.Gui) that depends on the core. WPF and WinForms are Windows only, and putting them in the core module makes an otherwise cross-platform module fail to load on Linux.
18.1 Choosing an approach
| Need | Use |
|---|---|
| Let the user pick from a list | Out-GridView -PassThru, or Out-ConsoleGridView where cross-platform matters |
| A form for one function's parameters | Show-Command |
| Small utility, roughly a dozen controls | WinForms |
| Layout, styling, theming, or data binding | WPF |
| Must run on Linux or macOS | A local web UI or a terminal UI. Not WPF, not WinForms. |
Show-Command deserves a look before anything gets built. It generates a form directly from a function's parameter metadata, so a function written to section 3 of this document already has a working interface at no cost. Reach for a custom window only once you know why that one is not enough.
18.2 WPF
- XAML lives in its own
.xamlfile, loaded with[Windows.Markup.XamlReader]::Load().
A several-hundred-line here-string cannot be opened in a designer and will not be maintained.
- Strip
x:Classand every event handler attribute before loading.XamlReadercannot
resolve code-behind and throws when it finds a reference to it.
- Retrieve controls by name with
$window.FindName('ButtonRun')and attach handlers in
PowerShell with $button.Add_Click({ ... }).
- Name every control you intend to touch. Walking the visual tree to find things is a defect
waiting to happen.
18.3 WinForms
- Set
AutoScaleModeand use anchoring or a layout panel. Fixed pixel positions break on the
high-DPI displays that are now the default.
- Dispose the form when it closes.
- WinForms suits a window of roughly fifteen controls or fewer. Past that, the layout code
costs more to maintain than the XAML would have.
18.4 Responsiveness
The most common defect in PowerShell GUIs is a window that locks up the moment work begins, because the work is running on the thread that draws the window.
- Anything touching the network, a remote system, or the filesystem at scale runs in a
separate runspace.
- Marshal every UI update back to the UI thread:
$window.Dispatcher.Invoke({ ... })in WPF,
$control.Invoke([Action]{ ... }) in WinForms. Touching a control from another thread throws or corrupts state.
- Share state through a synchronized hashtable (
[hashtable]::Synchronized(@{})) handed to
the runspace, not through module-scoped variables.
- Every long operation gets a progress indicator and a cancel path. A greyed-out button with
no feedback is indistinguishable from a crash.
- Verify apartment state rather than assuming it. WPF requires STA, and the default has
differed across Windows PowerShell and PowerShell 7 releases, so set it explicitly in whatever launches the window.
18.5 Behavior
- Call the underlying functions with
-Confirm:$falseand do the confirming in the window.
Two confirmation prompts, one of them a console dialog hidden behind the GUI, is worse than either on its own.
- Surface errors in the window. A GUI that writes to the error stream where nobody is looking
has swallowed the error.
- Section 15 applies here in full. Set
AccessibleNamein WinForms or
AutomationProperties.Name in WPF on every control that carries meaning, keep tab order logical, and give every action a keyboard route.
19. Prose: comments, help, and READMEs
This document produces text as well as code. Comment-based help, inline comments, README files, and changelog entries all ship with the module, and they are where generated work gives itself away fastest.
Write like a competent colleague explaining something in a hurry. The reader is stuck, in a rush, and does not want to be reading this at all.
19.1 Voice
- Second person. "You need write access to the OU for this."
- Contractions are fine. Short sentences are better. The occasional fragment is fine too.
- Say the actual thing. "This breaks if the path has spaces" beats "note that certain path
configurations may present challenges."
- Name the real cmdlet, the real error, the real file. Concrete beats abstract every time.
- State limits flatly. "This does not work on Linux" needs no apology and no softening.
- One good example replaces three paragraphs. Prefer the example.
- Delete the last paragraph. It is nearly always a summary of what was just read.
19.2 Words and shapes to avoid
| Instead of | Write |
|---|---|
| leverage, utilize | use |
| robust, powerful, seamless, comprehensive | what it actually does |
| simply, just, easily | nothing, delete the word |
| In today's fast-paced world | the first real sentence |
| Let's dive in, let's explore | nothing |
| In conclusion, by following these steps | nothing, stop writing |
| It is not just X, it is Y | one clear claim |
| Whether you are a beginner or an expert | nothing |
Also out: em dashes, emoji in headings, rhetorical questions as transitions, three adjectives where one works, and any paragraph that restates the heading above it.
19.3 Comments
- Comment the surprising thing. A workaround, a non-obvious ordering requirement, a bug in
someone else's module with a link to the issue.
- Never restate the code.
# Get the userabove a call toGet-ADUseris noise. - No banner blocks of asterisks or hashes. The function name is already the divider.
- A
# TODOcarries a name and a date, or it does not go in.
19.4 Comment-based help
Section 8 covers what the help must contain. This covers how it should sound. .DESCRIPTION is where modules give themselves away, because it is the only field long enough to hold filler.
Before:
.DESCRIPTION
This powerful function leverages the ActiveDirectory module to seamlessly
retrieve and process user account information, providing a comprehensive
solution for identifying accounts that may require attention.After:
.DESCRIPTION
Finds enabled accounts that have not signed in since the cutoff date and
disables them. You need write access to the OU. Returns a result object for
every account it checked, including the ones it skipped, so you can pipe the
output to Export-Csv and keep a record of the whole run.The second is longer and says more. Length was never the problem. Filler was.
19.5 READMEs
- The first sentence says what the module does. No preamble, no "Welcome to".
- Install command inside the first screen, copy-pasteable, no surrounding explanation.
- One example that produces visible output, early. Show the output.
- Requirements stated plainly: PowerShell version, OS, permissions, modules.
- Skip the badge wall, the table of contents on a two-screen document, and any Features list
made of adjectives.
- A short "Known limitations" section buys more trust than any amount of enthusiasm.
20. Files and reports
20.1 Emitting and exporting are different jobs
A function that queries data emits objects. It does not write files. The caller decides where the data goes:
Get-PSEStaleAccount -InactiveDays 90 | Export-Csv .\stale.csv -NoTypeInformation -Encoding utf8Not Get-PSEStaleAccount -OutputCsv .\stale.csv. The moment a query function owns a file path, it can no longer be piped, filtered, tested without touching disk, or reused by the next script that needs the same data in a different shape.
A dedicated export or report function is fine when the formatting itself is the work, for example Export-PSEAccountReport. It takes objects from the pipeline. It does not do its own querying.
Rules for anything that writes a file:
- Writing a file changes state, so the function declares
SupportsShouldProcess(section 7). - Take a path parameter. Never hardcode one, never default to the current directory silently.
- Set
-Encodingexplicitly on every write. The default differs between Windows PowerShell
and PowerShell 7, and the failure shows up later as mangled non-ASCII characters in someone else's spreadsheet. Use utf8 unless a consumer demands otherwise.
- Timestamps in filenames use a sortable form:
yyyy-MM-dd_HHmmss. State the timezone or use
UTC.
- Return the resulting file object under
-PassThruso the caller can chain. - Do not silently overwrite. Either
ShouldProcesscovers it or a-Forceswitch does.
20.2 CSV
CSV is for handing data to a human with Excel. It is a poor interchange format between scripts, because every value comes back as a string and the types are gone.
-NoTypeInformationonExport-Csv. Required in 5.1, harmless in 7.- Flatten before exporting. A property holding an array or a nested object serializes as a
type name, which is worse than useless. Use calculated properties to reduce it to a string first.
-Appendagainst a file whose columns have changed produces silent nonsense. If the schema
can vary, write a new file.
- Non-US locales: Excel expects the local list separator.
-UseCulturehandles it, and it
matters more often than people expect.
- Formula injection is a real vulnerability. A field beginning with
=,+,-, or@
is executed as a formula when the file opens in Excel, which turns an exported display name into code execution on someone else's workstation. Prefix any untrusted string field with a single quote, or reject those leading characters. This is the same class of problem as section 12.2 and gets the same treatment.
20.3 JSON
JSON is the default when a script hands data to another script, another language, or an API.
- Always set
-Depthexplicitly. The default of 2 silently truncates nested objects, and
the resulting file looks fine until someone reads it. There is no good reason to leave it unset.
-Compressfor machine consumers, indented for anything a person will open.ConvertFrom-Json -AsHashtablewhen you need case-sensitive keys or want to avoid building
PSCustomObject graphs for large payloads.
- Never build JSON by concatenating strings. Build an object or a hashtable and convert it.
- Serialize dates deliberately.
.ToString('o')gives a round-trippable ISO 8601 value with
offset, which is what a consumer in another timezone needs.
- Ordered hashtables (
[ordered]@{}) keep the property order stable, which makes diffs of
generated JSON readable.
- JSON is also the right shape for the structured log records in section 15.2.
20.4 HTML reports
A report is an artifact someone forwards, archives, and opens six months later on a machine with no network. Build for that.
ConvertTo-Html -Fragmentproduces the table. Compose the surrounding document yourself
rather than accepting the default output, which is a bare unstyled page.
- Self-contained, always. CSS goes inline in
-Head. No CDN links, no external
stylesheets, no remote fonts, no script tags pulling from the internet. A report that renders as broken text in an offline Outlook preview has failed, and a remote reference in an emailed file is a tracking beacon whether you intended one or not.
ConvertTo-HtmlHTML-encodes the property values it renders. It does not encode
-PreContent, -PostContent, or -Head. Anything user-supplied that reaches those parameters must be encoded first with [System.Net.WebUtility]::HtmlEncode().
<meta charset="utf-8">in the head, and write the file with-Encoding utf8.- Never feed
Format-Tableoutput into HTML. Pass objects.
Every report carries a provenance block, or it cannot be trusted later:
- Generation time in UTC, with local time alongside if humans read it
- The account and host that produced it
- The script or module version
- The parameters and scope it ran against, including anything excluded
Accessibility applies here exactly as in section 15:
- Real
<table>markup with<th scope="col">and a<caption>. Never tables for layout. - Never encode status in color alone. Pair every colored cell with text or a symbol.
- Meet contrast requirements against a white background, since that is what the printer and
the email client will use.
For anything beyond tables, a here-string template with placeholder substitution stays readable far longer than deeply nested ConvertTo-Html calls. A community module such as PSWriteHTML is a reasonable choice for complex output, but it is an optional dependency and section 10 applies: check for it at call time and fail with the install command.
Definition of done
Invoke-ScriptAnalyzer -Path .\src -Recurse -Settings .\PSScriptAnalyzerSettings.psd1
Invoke-Pester -Path .\tests -Output Detailed
Get-Help <Function> -Full # examples run unmodified
<Function> -WhatIf # produces useful output, changes nothingAll four clean. No warnings suppressed without an inline justification comment.
Lineage
This document assembles existing consensus rather than inventing a new one. Where it agrees with the sources below, they came first:
- PSScriptAnalyzer's built-in rules, maintained by Microsoft's PowerShell team together
with community maintainers. Roughly a third of this document is mechanically enforced by rules that already ship in that module.
- Microsoft's cmdlet development guidelines, whose Strongly Encouraged and Advisory tiers
are the origin of most of sections 2 through 7.
- The PoshCode "PowerShell Practice and Style" guide, the closest thing the community has
to an agreed style baseline, though it predates PowerShell 7 and Pester v5.
- The Pester project's own documentation for v5 discovery and run phase semantics.
Where those sources disagree with each other, this document picks one and says why in the relevant section. The material on security, accessibility, localization, and writing for generation rather than for reading goes beyond them, and that is where to aim criticism.
Corrections and disputes belong in the issue tracker at powershellengineer.com.
Deeper guidance
Load the matching skill rather than expanding this file. These are planned and not yet published. If one is unavailable, apply this document and state which guidance you lacked:
| Task | Skill |
|---|---|
| New module scaffold | pse-module-scaffold |
| Pester v5 suites | pse-testing |
| Comment-based + about_ help | pse-help-authoring |
| Refactoring inherited scripts | pse-refactor |
| Threat modeling, JEA, signing, hardening review | pse-security |
| 5.1 → 7.x migration | pse-migration |
| Microsoft Graph, Entra, Exchange, AD | pse-domain-<name> |