# AGENTS.md: PowerShell Engineer Standard

**Version:** 1.1.1 · **Maintainer:** Jim Tyler, Microsoft MVP, PowerShell Engineer (powershellengineer.com)
**Applies to:** all PowerShell (`.ps1`, `.psm1`, `.psd1`) authored in this repository.
**Licence:** MIT. Copy it, fork it, adapt it to your house rules. Attribution appreciated, not required.

> 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 a task cannot be done without breaking one,
name the rule and say why rather than shipping something that looks finished.

A vague request is not that case. Underspecified is the normal state of a request: choose a
sensible default, state it in one line, and write the function. Ask a question only when the task
cannot be attempted at all without an answer. Uncertainty about whether a command or parameter
exists is handled by the `# VERIFY:` marker in 0.1, not by declining to write the code. Refusing to
answer a question you could have answered under a stated assumption is its own defect, and it is
the more common one.

- [ ] Advanced function: `[CmdletBinding()]` + `param()` block
- [ ] Approved verb (`Get-Verb`), singular noun, `Prefix` applied in module context
- [ ] `[OutputType()]` declared
- [ ] Emits objects (`[pscustomobject]` or a class), never formatted strings
- [ ] Validation expressed as parameter attributes, not `if` blocks
- [ ] Comment-based help with a runnable `.EXAMPLE`
- [ ] `SupportsShouldProcess` on 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:

1. Mark it in the code where the reader will see it:
   `# VERIFY: confirm -IncludeDisabled exists in your module version`
2. Give the command that settles it:

```powershell
Get-Command Get-ADUser -Syntax
(Get-Command Get-ADUser).Parameters.Keys
Get-Help Get-ADUser -Parameter IncludeDisabled
$result | Get-Member
```

The same applies to .NET surface, which is invented just as often and checked far less. A
constructor overload that does not exist, a static method that was never there, an enum value
that sounds right: all of these produce code that reads fine and throws on the first run.

```powershell
[System.IO.Path] | Get-Member -Static
[System.Net.Http.HttpClient].GetConstructors() | ForEach-Object { $_.ToString() }
[System.DayOfWeek].GetEnumNames()
[System.Text.Encoding] | Get-Member -Static -Name UTF8
```

Tab completion after `[System.` is the fastest check of all and costs nothing. Two community
modules do the same job more legibly: `Get-TypeConstructor` and `Get-TypeMember` from
PSScriptTools output copy-pasteable syntax, and `Find-PSMDType` from PSModuleDevelopment finds
types by interface or pattern.

Stating 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.

```powershell
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-Verb` only. `Get`/`Set`/`New`/`Remove`/`Test`/`Invoke` cover most needs.
- Nouns singular, PascalCase, prefixed: `PSE` for this project (`Get-PSEModulePlan`).
- Parameters match ecosystem convention: `Path`, `Identity`, `Name`, `Force`, `PassThru`.
- No abbreviations in public surface. `$svc` is fine locally; `-Svc` is not a parameter name.

---

## 3. Parameters and validation

Push constraints into attributes so `Get-Help` and tab-completion expose them for free:

```powershell
[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]` must `throw` a message that names the offending value. The default
  failure text tells the user nothing.
- Use parameter sets with `DefaultParameterSetName` instead 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:

```powershell
[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 `CompletionResult` objects rather than bare strings when you want tooltips or a
  display name that differs from the inserted value.
- Filter on `$wordToComplete` yourself. Nothing does it for you.
- When the same completer serves several parameters, implement `IArgumentCompleter` as a
  class and reference it with `[ArgumentCompleter([PSEProfileCompleter])]`.
- `Register-ArgumentCompleter` is for adding completion to commands you do not own. Do not
  use it as a substitute for declaring the completer on your own parameters.

### Dynamic parameters

Default position: **do not**. A `DynamicParam` block is a large amount of fragile boilerplate
that hides the parameter from `Get-Help` and from tab completion until its condition is met,
which makes the command harder to discover and harder to document. Reach for the static
alternatives first:

| You want | Use instead |
|---|---|
| Values that vary by environment | `[ArgumentCompleter]` on a normal parameter |
| Mutually exclusive parameter groups | Parameter sets |
| A parameter that only applies sometimes | An optional parameter and a validation check |
| A closed list known at authoring time | `[ValidateSet]` |

Dynamic parameters earn their place in two cases only: a provider-aware command whose
parameters genuinely differ by PSDrive, and a parameter whose very existence depends on
runtime state that cannot be known when the function is parsed.

If you write one:

- Comment why a static parameter would not work. Without that note the next maintainer will
  assume it was cargo cult and be right most of the time.
- Document it in `.PARAMETER` anyway, even though help will not generate it automatically.
- Test the condition being both true and false. Dynamic parameters are where untested branches
  hide.
- `New-PSDynamicParameter` in PSScriptTools generates the block correctly, which is worth more
  than writing it by hand, and worth much more than letting a model improvise it.

---

## 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.

```powershell
# 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 Stop
```

Conditional parameters are added to the hashtable, never assembled as string fragments:

```powershell
if ($Credential) { $params['Credential'] = $Credential }
```

**Never use backtick line continuation.** Splat instead.

Converting an existing long command line into a splat is mechanical, so do not do it by hand.
`Convert-CommandToHashtable` in PSScriptTools takes the command text and emits the hashtable
plus the call.

---

## 5. Output and the pipeline

- `Write-Host` is 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 `PSTypeName` so formatting and `Get-Member` behave.
- A `PSTypeName` on 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` (via `TypesToProcess`) for `ScriptProperty`, `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-FormatData` and `Update-TypeData` are 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 `ValueFromPipelineByPropertyName` wherever 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 `$ErrorActionPreference` globally 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 `-TargetObject` so the caller can identify which input failed.
- `finally` for cleanup that must run: connections, temp files, impersonation.

### Strict mode

- `Set-StrictMode -Version Latest` at the top of every script, and in the root `.psm1` of
  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.0` instead 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 `-Force` switch must also set `$ConfirmPreference` appropriately, not skip `ShouldProcess`.

---

## 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.

### External help

Comment-based help is the baseline. A module intended for other people ships external help as
well, because MAML is what supports updatable help, localized help, and help that survives the
function being compiled or obfuscated.

- Author in markdown and generate MAML. `Microsoft.PowerShell.PlatyPS` is the supported tool;
  the original `platyPS` (0.14.2) is retired and should not be used for new work.
- Generated markdown lives in `docs/`, is committed, and is regenerated rather than hand-edited
  after a parameter changes.
- PlatyPS creates templates. It does not write documentation. The descriptions and examples are
  still yours to write, to the standard in section 19.
- Verify with `Get-HelpPreview` or by importing the built module and running `Get-Help -Full`.
  Markdown that renders is not the same as help that renders.
- MAML conversion has known rough edges in the current release, so check the output rather than
  assuming the pipeline is clean.

---

## 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 `It` blocks are set in `BeforeAll`, not at describe scope.
- `Describe` per function; `Context` per scenario or parameter set.
- Test **behavior and contracts**, not implementation details.
- Mock with `-ModuleName` to intercept inside the module scope.
- `InModuleScope` for private functions.
- Required coverage: happy path, each validation failure, error branch, `-WhatIf` makes 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.psm1
```

- `FunctionsToExport` lists 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 `.psm1` for load performance; author in separate files.
- Use a build tool rather than a bespoke script. `ModuleBuilder` handles the source-to-`.psm1`
  merge and manifest updates; `InvokeBuild` or `psake` handle the wider task graph.
- This layout is a convention, not a law. Several mature modules put `functions/`, `formats/`,
  `types/`, and `en-US/` at the root instead of under `src/`. Either is fine. What matters is
  that it is consistent within a repository and that the build knows where things are.
- SemVer in the manifest. Maintain `CHANGELOG.md`.
- Declare `RequiredModules`, `PowerShellVersion`, and `CompatiblePSEditions`.

### Compiled components

Most modules should not have one. Reach for C# when there is a reason PowerShell cannot cover:
a hot inner loop where the parse overhead dominates, a type system the language cannot express,
P/Invoke, or an SDK that ships only as a library.

- Source lives in `library/<ModuleName>/`. Build output is copied to `bin/`, and the source is
  committed. A DLL with no repository behind it is not something an administrator should be
  asked to trust.
- Load through `RequiredAssemblies` in the manifest rather than `Add-Type` inside the `.psm1`.
  `RequiredAssemblies` resolves before the module loads and fails with a clear error instead of
  a confusing one halfway through import.
- `Add-Type -TypeDefinition` compiling C# at import time does not belong in a shipped module.
  It costs seconds on every import and assumes a compiler is present.
- Assembly version conflicts are the real cost. In Windows PowerShell everything shares one
  load context, so a second module wanting a different version of the same assembly loses
  silently and fails somewhere unrelated. PowerShell 7 improves this but does not remove it.
  Depend on as few assemblies as possible and pin the versions you do take.
- Once a binary component exists, state the runtime, platform, and edition requirements in the
  manifest and the README. The module is no longer trivially portable and pretending otherwise
  wastes someone's afternoon.

### Scaffolding

The layout above is available as a PSModuleDevelopment template, so anyone already working
through `Invoke-PSMDTemplate` can adopt this structure without changing tools. Generating the
same structure by hand is fine. Generating a different one and calling it this one is not.

### Dependencies

Every entry in `RequiredModules` is an install step, a version conflict, and a support
question. Target zero. The whole of onboarding should be:

```powershell
Install-Module PSEngineer -Scope CurrentUser        # PowerShellGet
Install-PSResource PSEngineer -Scope CurrentUser    # PSResourceGet
Import-Module PSEngineer
```

Document both. `Microsoft.PowerShell.PSResourceGet` is the current module management stack and
ships with PowerShell 7.4 and later, but `Install-Module` is what most people still have in
their fingers and what every older runbook says.

No 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 `PowerShellVersion` and `CompatiblePSEditions` honest so the Gallery filters correctly
  for people who cannot meet your minimum.
- A platform-specific dependency means a companion module, not a conditional import.

### Ecosystem frameworks

Two mature community projects overlap with parts of this document, and pretending they do not
exist serves nobody.

- **PSFramework** provides logging, configuration, and tab completion infrastructure that is
  more capable than what section 15.2 describes building by hand.
- **PSModuleDevelopment** provides templating, script splitting, encoding repair, and type
  discovery.
- **PSScriptTools** provides scripting helpers that implement several rules in this document
  directly, including splat conversion, type and constructor discovery, dynamic parameter
  generation, repeatable performance testing, and session provenance.

The position this document takes: for a module that strangers will install from the Gallery,
the zero-dependency rule above wins and you implement what you need. For internal tooling where
you control the environment and the install, PSFramework is a sound choice and its logging is
better than a hand-rolled equivalent.

Nothing else changes either way. A module built on PSFramework still owes approved verbs,
`ShouldProcess`, object output, real help, and tests.

---

## 11. Performance

- Never `+=` on arrays in a loop. In 5.1 through 7.4 it reallocates the whole array every
  iteration. PowerShell 7.5 optimized it substantially, but code in this repository cannot
  assume 7.5, so 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 = $expr` or `[void]$expr` instead of `| Out-Null`.
- `foreach` statement beats `ForEach-Object` for large in-memory collections.
- Hashtable lookups instead of nested `Where-Object` scans when joining datasets.
- Don't micro-optimize readable code without a measurement. Cite `Measure-Command` results
  when you do optimize.
- A single `Measure-Command` run is noise. Caching, JIT, and whatever else the machine is doing
  all move the number. Run the comparison repeatedly and report the median. `Test-Expression`
  in PSScriptTools does this, including random intervals between runs and trimmed averages.

### Parallelism

Sequential until measurement says otherwise. Each runspace is its own session that must
import modules and resolve commands itself, and that startup cost swamps the work in most
scripts people reach for parallelism to fix. Since 7.1, `ForEach-Object -Parallel` reuses
runspaces from a pool sized by `-ThrottleLimit` (opt out with `-UseNewRunspace`, added in
7.3), which amortizes the cost across iterations but does not remove it.

- `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 `-ThrottleLimit` deliberately. 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-ThreadJob` suits 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 from `Microsoft.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().Password` at the last possible moment, in the narrowest
  scope, and never assign the result to a variable that outlives the call.
- `SecureString` is not encrypted on non-Windows platforms. Never imply that it is.
- `ConvertTo-SecureString -Key $bytes` with the key stored beside the ciphertext is
  obfuscation, not encryption. Do not present it as protection.
- `-AsPlainText -Force` is 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. |
| SQL | Never interpolate untrusted values into `-Query`. `Invoke-Sqlcmd` has no true query parameters (`-Variable` is textual sqlcmd substitution); use `Microsoft.Data.SqlClient` with `SqlParameter` for untrusted input. |
| 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.** `-SkipCertificateCheck` and `-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-Clixml` is not a safe data format. It can instantiate arbitrary types; never point
  it at untrusted input. Use JSON for interchange.
- `Expand-Archive` on 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`. `ModuleVersion` alone permits drift.
- Never `Set-PSRepository -InstallationPolicy Trusted` on a public repository.
- Check `Get-AuthenticodeSignature` on installed modules where signing is expected.
- **The `Invoke-WebRequest | Invoke-Expression` install 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 -RunAsAdministrator` only 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.0` unless 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-Path` over string concatenation. `[System.IO.Path]::DirectorySeparatorChar` when needed.
- Flag Windows-only surface: the CIM cmdlets (`Get-CimInstance` ships only on Windows), the
  registry provider, WMI, and the ActiveDirectory module.
- `$IsWindows` / `$IsLinux` / `$IsMacOS` for branching. These don't exist in 5.1, so guard.
- Prefer `Get-CimInstance` over the deprecated `Get-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 a `PSTypeName` would do, which covers most cases
- The type must be reloaded during an interactive session (classes don't reload cleanly)
- Consumers need `using module` semantics 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_COLOR` and use `$PSStyle` rather than raw ANSI escapes.
- `Write-Progress` for 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 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. Know the event log surface before choosing:
  `New-WinEvent` works in 5.1 and 7 but requires a registered ETW provider manifest;
  `Write-EventLog` needs no manifest but exists only in 5.1. From 7, without a registered
  provider, `[System.Diagnostics.EventLog]::WriteEntry()` against a registered event source
  is the plain route.
- `Start-Transcript` is 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-Progress` is 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:

```powershell
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 and file conventions

### 17.1 Formatting

- 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.
- `$null` on 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.
- The limit does not apply inside comment-based help. Section 8 requires
  realistic `.EXAMPLE` blocks and exempts them from splatting, and splatting is
  the only sanctioned way to shorten a line, so enforcing the limit inside help
  would leave a realistic example with no legal way to comply.

### 17.2 File encoding

UTF-8 everywhere. The only real decision is the byte order mark, and it is not cosmetic.

- Windows PowerShell 5.1 reads a file with no BOM as ANSI. PowerShell 6 and later assume UTF-8
  without one. So a script containing any non-ASCII character, an accented name in help, a
  currency symbol in a string, a box-drawing character in output, parses correctly in 7 and
  corrupts in 5.1.
- **Code that must run on 5.1: UTF-8 with BOM.** Code that targets 7.x only: UTF-8 without BOM.
- The failure is silent. There is no parse error, just wrong characters appearing in output
  weeks later, which is exactly why it survives code review.
- Set it once for the repository rather than per file:

```ini
# .editorconfig
[*.{ps1,psm1,psd1,ps1xml}]
charset = utf-8-bom          # utf-8 if the code is 7.x only
end_of_line = crlf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
```

- Add `* text=auto` to `.gitattributes` so line endings normalize instead of churning through
  every diff.
- One encoding per repository. Mixed encodings are how this problem starts.
- CI can check this cheaply, and should. Inheriting a repository with mixed encodings is a
  known problem with a known fix: `Set-PSMDEncoding` from PSModuleDevelopment repairs a tree
  in bulk.

---

## 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.

Packaging has two defensible answers, and the community has run the experiment both ways.

**A companion module** (`PSEngineer.Gui`) depending on the core is right when the interface is
substantial, drags dependencies the core does not need, or would otherwise stop a cross-platform
module loading on Linux.

**A single module with per-command platform guards** is right for a toolbox where the graphical
commands are a handful among many. Note that PSScriptTools split itself by edition and then
reverted to exactly this, shipping WPF commands alongside cross-platform ones and handling the
platform per command. That is production experience, not theory.

Either way, the requirement is the same: importing the module on Linux must succeed, and calling
a Windows-only command there must fail with a clear message rather than a type resolution error.
Choose on the size of the interface, not on principle.

### 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 `.xaml` file, 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:Class` and every event handler attribute before loading. `XamlReader` cannot
  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 `AutoScaleMode` and 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:$false` and 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 `AccessibleName` in 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 user` above a call to `Get-ADUser` is noise.
- No banner blocks of asterisks or hashes. The function name is already the divider.
- A `# TODO` carries 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:

```powershell
Get-PSEStaleAccount -InactiveDays 90 | Export-Csv .\stale.csv -NoTypeInformation -Encoding utf8
```

Not `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 `-Encoding` explicitly 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, and know that the name
  itself shifts meaning: `utf8` writes a BOM in 5.1 and no BOM in 7, where `utf8BOM` exists
  if you need one. Section 17.2's reasoning about which consumers need the BOM applies to
  data files too.
- Timestamps in filenames use a sortable form: `yyyy-MM-dd_HHmmss`. State the timezone or use
  UTC.
- Return the resulting file object under `-PassThru` so the caller can chain.
- Do not silently overwrite. Either `ShouldProcess` covers it or a `-Force` switch 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.

- `-NoTypeInformation` on `Export-Csv`. Required in 5.1; the default behavior since 6.0, so
  stating it is harmless in 7 and protects the script when it lands back on 5.1.
- 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.
- `-Append` against 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. `-UseCulture` handles 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.

If the destination really is Excel rather than a generic consumer, consider writing `.xlsx`
directly with the `ImportExcel` module instead. It preserves types, needs no Excel installed,
supports formatting and multiple sheets, and sidesteps both the delimiter problem and formula
injection. Optional dependency, so section 10 applies.

### 20.3 JSON

JSON is the default when a script hands data to another script, another language, or an API.

- **Always set `-Depth` explicitly.** The default of 2 truncates nested objects, and the
  resulting file looks fine until someone reads it. Windows PowerShell 5.1 truncates
  silently; 7.1 and later at least emit a warning, which unattended runs never see. There is
  no good reason to leave it unset.
- `-Compress` for machine consumers, indented for anything a person will open.
- `ConvertFrom-Json -AsHashtable` (6.0+, not in 5.1) when 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 -Fragment` produces 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-Html` HTML-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-Table` output 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

`Get-PSWho` in PSScriptTools returns most of this in one object: user, elevation, computer,
operating system, PowerShell version and edition, host, execution policy, and culture.

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.

### 20.5 Markdown

Markdown is now the default report format for anything that lands in a repository, a pull
request, a wiki, or a chat channel. It renders everywhere, diffs cleanly, and stays readable as
plain text, which HTML does not.

- Reach for markdown before HTML when the audience is technical and the destination is a repo.
  Reach for HTML when the report will be emailed or archived for non-technical readers.
- Emit tables from objects. `ConvertTo-Markdown` in PSScriptTools handles table and list forms
  with pre and post content, which saves hand-building pipe-delimited rows.
- Escape pipe characters in any value that might contain one, or the table silently breaks.
- The provenance block above applies here too, as a footer.
- Write with `-Encoding utf8` and end the file with a newline.
- Fenced code blocks get a language tag, so `powershell` rather than a bare fence. It matters
  for rendering and it matters more for anything that later reads the file as context.

---

## Definition of done

```powershell
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 nothing
```

All 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>` |
| WPF and WinForms interfaces | `pse-gui` |
| CSV, JSON, HTML, and markdown reporting | `pse-reporting` |
| External help, platyPS, updatable help | `pse-help-external` |
