On this page

Writing PowerShell for Hyperdot

The rules for anyone, person or agent, who writes PowerShell in this repository: the kit (ci/godot), the hooks, the benchmark and the self-tests. Hyperdot needs PowerShell 7.6+ and runs under Set-StrictMode -Version 3.0 and $ErrorActionPreference = 'Stop', locally on Windows and on GitHub's Linux runners. AGENTS.md ("Working here") has the short form; this page has every lesson, why it matters and where it comes from.

The 78 lessons merge two reports made on 2026-10-26: 61 from the official PowerShell documentation and 35 from the PowerShell Community blog, with the 18 that overlap folded together. Verified means the behaviour was run in pwsh -NoProfile 7.6.6 on Linux and gave the result described: by those reports, and for the lessons marked re-run, again for this page. Claims about Windows or about versions other than 7.6 come from the sources and were not run. Each source is a link with a short verbatim quote.

Read this first

The ten mistakes that cost the most here: each fails silently, or only on the Linux runner.

  1. continue or break inside ForEach-Object, Where-Object, .ForEach() or .Where() ends the whole script with exit code 0. Use return (E1).
  2. A native program's non-zero exit code never throws, even under Stop. Check $LASTEXITCODE on the next line (E2).
  3. A throw is skipped when a caller passes -ErrorAction SilentlyContinue or lowers $ErrorActionPreference; the next line runs (E3).
  4. Get-Content X | Set-Content X on Linux truncates the file without an error (20,000 lines became 1,041) (S12).
  5. ConvertTo-Json without -Depth turns level 3 into the text System.Collections.Hashtable, and its warning lands on stdout of a child pwsh, ahead of the JSON (J1, E7).
  6. Under strict mode, .Count on a scalar or $null, and dot access to a missing key or property, throw (S1, S2).
  7. Preferences and strict mode do not reach ForEach-Object -Parallel, thread jobs, modules or a child pwsh (X1).
  8. On Linux, a variable passed to a native program is wildcard-expanded when it matches files: quote it (N3).
  9. -f, .ToString(), [double]::Parse, -join and Sort-Object follow the current culture; under de-DE, [double]::Parse('1.5') is 15 (C1).
  10. -Path reads [ ] as a wildcard and returns nothing, without an error, when nothing matches: use -LiteralPath (P1).

Errors and exit codes

E1. continue or break in a pipeline block ends the script

E2. A native program's exit code never throws: check $LASTEXITCODE

E3. A lowered preference swallows throw; put return after a guard throw

E4. Stop set inside an advanced function stops only the statement

E5. Catch typed, rethrow bare, wrap with the inner exception

E6. Start a child PowerShell as pwsh -NoProfile -NonInteractive -File

E7. In a child pwsh, every stream except Error goes to stdout

E8. $LASTEXITCODE is unset until a native program runs, and stale after

E9. Dispose streams and handles in finally

Strict mode, arrays and the pipeline

S1. Count with @(...): .Count on a scalar or $null throws under strict mode

S2. Dot access to a missing key or property throws: index or test first

S3. One result is the object itself; indexing one string gives a character

S4. Functions unroll what they return

S5. Everything not captured is output: $null = the side-effect values

S6. $null on the left; -contains or -in for membership

S7. A one-item collection is as true as its item; any non-empty string is true

S8. $Matches is overwritten by any later match, also inside Where-Object

S9. Copy the keys before changing a dictionary in a loop

S10. switch loops over arrays, runs every matching clause and skips empty input

S11. Pin Set-StrictMode -Version 3.0, never Latest

S12. Never read and write one file in one pipeline

Strings, culture and encoding

C1. Format and parse machine-read values with the invariant culture

C2. In strings: $( ) for members, ${name} before a colon, no $ from values in a -replace replacement

C3. Operators are case-insensitive regex; .NET string methods are case-sensitive and literal

C4. Case-convert identifiers with ToUpperInvariant()/ToLowerInvariant()

C5. UTF-8 without BOM; decode native output explicitly; never -Encoding ASCII for data

C6. Split lines on '\r?\n'; write byte-stable files with "`n" and -NoNewline

C7. Do not write files or logs through the formatting system

C8. Set-Content and Add-Content write ToString()

Native commands

N1. Pass arguments as an array; never build a command line, never Invoke-Expression

N2. Start-Process -ArgumentList joins with spaces and adds no quotes

N3. On Linux, variables passed to native programs are globbed: quote them

N4. 2>&1 from a native program gives ErrorRecords, in no fixed order, and is never an error

N5. Bytes through > and | between native programs are kept

N6. & runs in $PWD; ProcessStartInfo in the process folder

N7. Some programs report success with a non-zero exit code

N8. pwsh -File passes every argument as one string: no arrays

N9. Parse native output by its content, in functions tested on captured output

N10. Do not name a function like a program the kit calls

Paths and portability

P1. -LiteralPath for every path from data; -PathType when it matters

P2. Files and environment variables are case-sensitive on Linux

P3. Join-Path, never [IO.Path]::Combine or "$root\sub"

P4. Give .NET absolute paths only

P5. No aliases in scripts

P6. Copy-Item -Recurse into an existing folder nests the source

P7. Sort ordinally when order reaches output

P8. $env:X = '' sets an empty value; $null removes it

P9. Temporary files and folders from the API, never from $env:TEMP

P10. Guard Windows-only cmdlets and APIs with $IsWindows

Functions and parameters

F1. A [string] parameter turns $null into '': test presence with ContainsKey

F2. Switches: no default, decide on the value, forward with -X:$X

F3. Pipeline blocks run in the caller's scope; functions in a child scope

F4. Splat: a hashtable for cmdlets, an array for native programs

F5. Round with [Math], not an [int] cast

F6. No $global:; $PSDefaultParameterValues is shared

F7. Validation attributes in helpers, not on the -Json entry point

JSON and data

J1. Every ConvertTo-Json gets a -Depth

J2. Arrays as -InputObject @(...) or -AsArray, never a bare pipe

J3. [ordered] for every dictionary that reaches output

J4. ConvertFrom-Json makes dates, unrolls arrays and returns $null for empty input

J5. -AsHashtable gives an ordered, case-sensitive dictionary

J6. CSV and tables take their columns from the first object

J7. Load XML with XmlDocument.Load from an absolute path

Concurrency

X1. New runspaces, modules and child processes start without your settings

X2. Share only thread-safe objects; expect any order

X3. Jobs: check Wait-Job -Timeout's result, wrap Receive-Job, clean up in finally

Performance

R1. Collect a loop's output or use List[T]; no array += in loops

R2. Look up by key; stop at the first match

R3. $null = rather than | Out-Null; [pscustomobject] rather than New-Object and Add-Member in loops

R4. One pipeline per stream, not one per item

R5. Strings with -join or StringBuilder; hot loops in C#

R6. Get-Content -Tail for the end of a file, -Raw for all of it

R7. Time with a Stopwatch, not Measure-Command

Secrets

K1. CI secrets live in the runner's environment, go in headers, never in URLs

K2. RandomNumberGenerator for anything secret

Testing and tooling

T1. Test on the floor version, under other cultures and with hostile paths

T2. Lint with PSScriptAnalyzer, pinned and curated

T3. The floor is 7.6; use what it allows

Advice that does not hold here

From the sources, outdated or wrong for 7.6 on Linux under strict mode:

Where Advice Instead
Everything about PSCustomObject "check for that property to have a value. if( $null -ne $myObject.ID )" Throws under strict mode: .PSObject.Properties['ID'] (S2)
Everything about null "Use the automatic variable $PSBoundParameters.Value instead." Throws when not passed: .ContainsKey('Value') (S2, F1)
about_Arrays Every scalar has Count Not under strict mode: @($x).Count (S1)
Mastering the (steppable) pipeline Same-file Get-Content | Set-Content "causes the following error" Silent truncation on Linux (S12)
Reading a text file bottom up No way to read a file from the end Get-Content -Tail (R6)
Crescendo output handler -split "`r`n`r`n" '\r?\n' (C6)
Converting string output to objects Skip a fixed number of header lines per OS Anchor on the header text (N9)
How to Use $PSDefaultParameterValues Changing it in a script "does not affect the session" True only for a fresh assignment (F6)
Borrowing a built-in command (temp folder) "$($ENV:Temp)\tmpXXXX.tmp" GetTempPath() with a GUID (P9)
Getting Yesterday's Date ToString() -replace '/','-' for file names Invariant yyyy-MM-dd (C1)
Convert … excel sheet to JSON Out-File -Encoding ASCII, $results +=, no -Depth, GetFullPath(relative) C5, R1, J1, J2, P4
Update XML files [xml](Get-Content $path) XmlDocument.Load (J7)
Encrypting secrets locally DPAPI "tested under version 5 & 7" Windows only; throws on Linux (P10, K1)
Measuring average download time HttpWebRequest; $Host.Version for the edition Obsolete since .NET 6: Invoke-WebRequest or HttpClient; $PSVersionTable.PSVersion
Porting … GeneratePassword RNGCryptoServiceProvider, $global: state, LoadWithPartialName K2, F6; LoadWithPartialName is obsolete
On Preferences and Scopes Web cmdlets' -Verbose omits the URI, hence a proxy 7.6 prints the URI without its query
Lightning-fast provisioning of Git iwr … | iex Never run downloaded code through Invoke-Expression (N1)
Script authoring considerations, about_Parsing, about_Redirection Workarounds for += cost, legacy argument passing and text-only native pipes Needed before 7.5, 7.3 and 7.4; the 7.6 floor settles them (R1, N1, N5)

How this is checked

Check Lessons Run by
PSScriptAnalyzer 1.25.0, settings, every tracked .ps1 S6 (PSPossibleIncorrectComparisonWithNull), N1 (PSAvoidUsingInvokeExpression), P5 on Windows (PSAvoidUsingCmdletAliases), F2 (PSAvoidDefaultValueSwitchParameter), F6 (PSAvoidGlobalVars), N10 for cmdlets (PSAvoidOverwritingBuiltInCmdlets), S5 in part (PSUseDeclaredVarsMoreThanAssignments), empty catch, if ($a = 1), > for -gt, automatic variables, misleading backticks, trailing whitespace the lint job of .github/workflows/self-test.yml, failing on any finding
Test-PowerShellPatterns.ps1, every tracked .ps1, read from the syntax tree E1 (pipeline-continue), S6 (null-on-right), N1 (invoke-expression), P3 (path-combine), S2 and F1 (boundparams-dot), E3 for assignments (lowered-erroraction), P4 for literal paths (relative-dotnet-path), S11 (strictmode-latest), P5 on Linux (windows-alias), S12 (same-file-pipeline) the same lint job; each check proven on tests/powershell-patterns-fixture/
tests/Test-LibNames.ps1 N10 for aliases and cmdlets in ci/godot the lint job
Self-tests under other cultures and odd paths C1, P1 (in part) Test-Plan.ps1, Test-Audio.ps1, Test-BenchHotspots.ps1, Test-InstallAddon.ps1
Review everything else, notably E2, E7, J1, J2, C1, N2, N3, P1 and S1, which have uses in the repository today and so cannot be hard checks the person or agent changing the code

The pattern test holds only what has no use left in the repository; patterns that still have uses (for example ConvertTo-Json without -Depth, Start-Process -ArgumentList, -Path where -LiteralPath belongs) are left to a review of every script, after which they can join it.

Version floor

Decision (2026-10-26): the floor is PowerShell 7.6, the current LTS release, in every #Requires and in AGENTS.md ("Targets").

Sources

Lessons from the Microsoft PowerShell documentation (MicrosoftDocs/PowerShell-Docs at commit a3de8f2, 24 September 2026; reference pages for 7.5), © Microsoft, licensed CC BY 4.0, its code samples MIT; and from the PowerShell Community blog (all 49 posts, 2021 to 2024, read 26 October 2026), © Microsoft and the posts' authors, which states no open licence: only short quotes, linked to their posts, are used. The rules and explanations are this project's wording.