https://github.com/manderse21/claude-powershell-lsp
Real-time PowerShell diagnostics and PSScriptAnalyzer fixes inside Claude Code, as it edits your .ps1/.psm1/.psd1 -- powered by PowerShell Editor Services.
https://github.com/manderse21/claude-powershell-lsp
claude-code claude-code-plugin developer-tools diagnostics linter lsp powershell powershell-editor-services psscriptanalyzer static-analysis
Last synced: about 11 hours ago
JSON representation
Real-time PowerShell diagnostics and PSScriptAnalyzer fixes inside Claude Code, as it edits your .ps1/.psm1/.psd1 -- powered by PowerShell Editor Services.
- Host: GitHub
- URL: https://github.com/manderse21/claude-powershell-lsp
- Owner: manderse21
- License: gpl-3.0
- Created: 2026-06-06T11:59:20.000Z (26 days ago)
- Default Branch: main
- Last Pushed: 2026-06-28T16:07:28.000Z (4 days ago)
- Last Synced: 2026-06-28T16:23:18.209Z (4 days ago)
- Topics: claude-code, claude-code-plugin, developer-tools, diagnostics, linter, lsp, powershell, powershell-editor-services, psscriptanalyzer, static-analysis
- Language: PowerShell
- Homepage: https://github.com/manderse21/claude-powershell-lsp#quick-start
- Size: 746 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# PowerShell diagnostics for Claude Code
[](https://github.com/manderse21/claude-powershell-lsp/actions/workflows/powershell-lsp-ci.yml)
[](https://github.com/manderse21/claude-powershell-lsp/tags)
[](./LICENSE)
[](./TRUST.md#supply-chain-artifacts-sbom--build-provenance)
[](#diagnostic-correctness-corpus)
[](./TRUST.md#signing-posture)
As Claude edits a `.ps1`, `.psm1`, or `.psd1`, this plugin runs real PowerShell
Editor Services + PSScriptAnalyzer over that file and feeds the result -- syntax
errors and lint findings, with fix suggestions -- straight back into Claude's
context, so a mistake gets caught and corrected in the same turn. It is language
tooling, not project tooling: near-zero always-on token cost, a language server
spawns only when a PowerShell file is open, and one warm process serves the whole
session, so each edit pays a fast pipe round-trip instead of a cold start.

**See it catch something.** Ask Claude to write:
```powershell
function Frobnicate-Thing { Get-Process }
```
and the PostToolUse hook returns, right in Claude's context:
> The cmdlet 'Frobnicate-Thing' uses an unapproved verb. (PSUseApprovedVerbs)
Claude sees its own mistake and corrects it without you switching tools.
**Install in under a minute.** Requires `pwsh` (PowerShell 7+) on your PATH; then,
in Claude Code:
```text
/plugin marketplace add manderse21/claude-powershell-lsp
/plugin install powershell-lsp@claude-powershell-lsp
/plugin enable powershell-lsp
```
Start a new session and you are running. The full prerequisites, the self-bootstrap
sequence, and the preflight doctor are in [Quick start](#quick-start) below.
> **What works today vs. what is coming.** The per-file diagnostic loop above is live
> on every supported host right now. Hover, go-to-definition, find-references, and
> workspace-wide analysis are on the roadmap -- native LSP serve is gated on an upstream
> Claude Code initialization handshake, not on this plugin. Details:
> [Why a hook, not native registration](#why-a-hook-not-native-lspjson-registration).
## Prerequisites
Check these before you start; the [Quick start](#quick-start) below runs them in order.
- [ ] **PowerShell 7+ (`pwsh`) on your PATH.** As of 1.1.1 the plugin's hooks launch
under `pwsh`; Windows PowerShell 5.1 alone cannot bootstrap them. Check with
`pwsh -v`; if it is missing, step 1 of the Quick start installs it.
- [ ] **Internet access on the first enabled session.** PowerShell Editor Services
(PSES) and PSScriptAnalyzer are downloaded on first use, not vendored (see
[Pinned versions](#pinned-versions) for the exact pins). The download is idempotent
and marker-gated -- it runs once and no-ops every session after. Offline or behind a
proxy, the first run surfaces an honest `unavailable` banner instead of failing
silently (see [Diagnostics status](#diagnostics-status)).
- [ ] **On managed / locked-down Windows,** a security control (WDAC / AppLocker /
ExecutionPolicy / Constrained Language Mode) can block a downloaded component; it
then reads as `unavailable` rather than crashing. See [Troubleshooting](#troubleshooting).
Windows PowerShell 5.1 can still serve as the PSES *child host* (set `ps_host` to
`powershell`); it simply cannot launch the hooks themselves. See
[Platform support](#platform-support).
## Quick start
Copy-paste, top to bottom:
```text
# 1. Prerequisite (run in a terminal) -- skip if `pwsh -v` already works:
winget install Microsoft.PowerShell
# 2. In Claude Code -- add the marketplace, install, then enable the plugin:
/plugin marketplace add manderse21/claude-powershell-lsp
/plugin install powershell-lsp@claude-powershell-lsp
/plugin enable powershell-lsp
# 3. Start a new session (or run /reload-plugins) so the hooks load and the first
# SessionStart bootstraps PSES + the warm daemon.
# 4. Confirm it is healthy before you rely on it -- run the preflight DOCTOR from
# inside the enabled session (so it can see the plugin data dir):
pwsh -File "$env:CLAUDE_PLUGIN_ROOT/scripts/doctor.ps1"
# All PASS (benign UNKNOWNs are fine) -> ready. A FAIL names the exact fix.
# 5. See it catch something: ask Claude to edit a .ps1 -- e.g. write
# `function Frobnicate-Thing { Get-Process }` -- and the PostToolUse hook returns
# "The cmdlet 'Frobnicate-Thing' uses an unapproved verb." (PSUseApprovedVerbs).
```
The machinery self-bootstraps, so the sequence above is the whole job -- from install to a
real caught diagnostic in about five minutes. A few of its steps are deliberate, documented
here rather than removed:
- **`/plugin enable` stays an explicit step.** The plugin ships disabled by default
(`defaultEnabled: false`) because it downloads a bundle and spawns a language server,
so enabling it is a conscious opt-in.
- **The new session / reload is required** -- Claude Code loads plugin hooks at session
start, so enabling alone does not load them.
- **The first enabled session does the rest itself.** Its `SessionStart` hook downloads
PSES and vendors PSScriptAnalyzer (both idempotent and marker-gated), then launches
one warm daemon for the session. The first edit may briefly read `incomplete` while
PSES finishes starting, then settles on the next edit (see
[Diagnostics status](#diagnostics-status)).
- **Run the doctor first (step 4).** It turns the worst onboarding failure -- enabled but a
prerequisite is missing, so diagnostics silently do nothing -- into a named, actionable
fix-list, and it confirms the warm daemon is actually answering before you trust a silent
result as "analyzed, clean". It is **report-only** (it never downloads, repairs, or starts
anything); fuller details under [the preflight doctor](#preflight-self-check-the-doctor).
## Configuration
Set these via the `/plugin` config UI for `powershell-lsp`, or leave the defaults.
| Key | Default | Meaning |
|--------------------|----------|--------------------------------------------------------------------------------------|
| `ps_host` | `pwsh` | Host executable: `pwsh` (PowerShell 7+, recommended/tested) or `powershell` (Win 5.1) |
| `severityThreshold`| `Hint` | Least-severe level to report: `Error` > `Warning` > `Information` > `Hint` |
| `ruleInclude` | _(empty)_| Comma-separated PSScriptAnalyzer rule codes to report exclusively; empty = all |
| `ruleExclude` | _(empty)_| Comma-separated rule codes to suppress (e.g. `PSAvoidUsingWriteHost`) |
| `timeoutMs` | `5000` | Total hard cap (ms) before the PostToolUse client degrades to log-only |
| `debounceMs` | `150` | Edits landing within this window (ms) fold into one analysis pass |
| `keepLastN` | `10` | Newest rolling log files kept per family (swept at SessionStart) |
| `idleTtlMin` | `30` | Daemon self-terminates after this many minutes with no diagnostics request |
| `perFileCap` | `20` | Max diagnostics reported per file; the rest collapse into an `... and N more` line; `0` = no cap |
| `enableStats` | `false` | Append one JSONL timing line per analyzed edit to `logs/stats.jsonl` (rotating, ~5 MB); observe-only, never changes output. View with `scripts/show-stats.ps1`. `0`/`off` disable |
| `settingsPath` | _(empty)_| Absolute path to a `PSScriptAnalyzerSettings.psd1` to honor, overriding auto-discovery; a relative value is ignored; empty = auto-discover (nearest file walked up to the project root) |
| `scopeToEdit` | `true` | Scope surfaced diagnostics to the lines the edit touched (plus `editContextLines`); fails open to whole-file when the range is indeterminate. `0`/`off` report whole-file |
| `editContextLines` | `0` | Extra context lines kept above and below the touched range when `scopeToEdit` is on; the edit's patch already includes a few, so the default is `0` |
| `formatOnEdit` | `off` | When `suggest`, after an edit the warm daemon runs `Invoke-Formatter` on the file (honoring the repo's `PSScriptAnalyzerSettings.psd1`) and surfaces the formatted result as a **suggestion** -- a unified diff -- via the same channel as diagnostics; it **never rewrites your file**. `off` (default) does nothing and the diagnostics surface is unchanged. `apply` is reserved for a future release and is treated as `off` |
| `ruleset` | `pses-default` | Live diagnostics ruleset tier. `pses-default` (default) keeps PSES's built-in no-settings rule set (about 15 rules) -- unchanged from prior versions. `base` opts in to the plugin's shipped enumerated base ruleset (PSScriptAnalyzer's default-on set minus the compatibility rules), broadening the live surface so `PSAvoidUsingWriteHost` and the three Error-severity security rules surface. A repo-local `PSScriptAnalyzerSettings.psd1` and an explicit `settingsPath` always win over the base. See [Ruleset tiers](#ruleset-tiers-opt-in-broaden) |
Diagnostics are returned in a stable order (severity, then line, then column),
deduped, threshold- and rule-filtered, then capped per file.
These filters apply on top of whatever **PSES** publishes. By default (`ruleset` =
`pses-default`) PSES runs its own built-in no-settings rule set for live analysis, which is
narrower than the `Invoke-ScriptAnalyzer` CLI default -- for example `PSAvoidUsingWriteHost`
is not surfaced on the fly even though the CLI flags it. The filter knobs
(`severityThreshold`, `ruleInclude`, `ruleExclude`) can *suppress or narrow* what PSES
reports. To *broaden* the live surface instead, set `ruleset` = `base` -- or point
`settingsPath` at your own settings file -- which replaces that built-in set with a resolved
rule set (see [Ruleset tiers](#ruleset-tiers-opt-in-broaden) below).
### Format-on-edit (suggest, never rewrite)
`formatOnEdit` is **off by default**. When set to `suggest`, each time Claude edits a
PowerShell file the warm daemon runs PSScriptAnalyzer's `Invoke-Formatter` over it --
honoring the repo's own `PSScriptAnalyzerSettings.psd1` formatter rules when present (the
same settings auto-discovery the analyzer uses) -- and surfaces the reformatted result as a
**suggestion**: a compact unified diff, clearly labelled and distinct from a diagnostic,
stating that the file was **not** modified. The hook **never writes your file** -- it only
suggests, so editing is never disrupted and you stay in control of what lands. A formatting
failure (no settings, a malformed settings file, a formatter error) degrades quietly: no
suggestion is shown, and the edit is never blocked. Formatting runs on the already-warm
daemon, so it adds no cold-start, and a file that already matches the configured style
produces no suggestion at all. Values are `off` (default) and `suggest`; `apply` is reserved
for a possible future release and currently behaves as `off`.
### Ruleset tiers (opt-in broaden)
`ruleset` is **`pses-default` by default**, which keeps today's live surface exactly: PowerShell
Editor Services applies its own built-in no-settings rule set (about 15 PSScriptAnalyzer rules) on
the fly, and no plugin ruleset is resolved. Set `ruleset` = `base` to opt in to the plugin's shipped
**base ruleset** (`rulesets/base.psd1`): PSScriptAnalyzer's full default-on set **minus** the
compatibility-profile rules, **enumerated explicitly** so the surfaced set is deterministic and does
not drift when the pinned analyzer is bumped (regenerate with `scripts/regen-base-ruleset.ps1`).
Opting in broadens the live surface -- notably `PSAvoidUsingWriteHost` and the three Error-severity
security rules (`PSAvoidUsingComputerNameHardcoded`, `PSAvoidUsingConvertToSecureStringWithPlainText`,
`PSAvoidUsingUsernameAndPasswordParams`) start surfacing where the built-in set omits them.
Precedence is always yours to control: an explicit `settingsPath` and a repo-local
`PSScriptAnalyzerSettings.psd1` **both win over the base** -- the base only fills the gap when neither
is present. The existing noise controls still apply on top: `scopeToEdit` (on by default) limits
findings to the lines you edited, `perFileCap` caps the count per file, and `severityThreshold` drops
low-severity findings -- so `base` broadens *what can surface* without flooding a single edit. The
default is deliberately **not** flipped: the broadened surface never activates unless you opt in.
> **Privacy note -- `enableStats` logs absolute paths.** When `enableStats` is on (it is
> **off by default**), each timing line in `logs/stats.jsonl` records the **absolute path**
> of the analyzed file. All logs stay under your plugin data directory and are never
> transmitted, but if you share a log for a bug report, sanitize the paths first. (Path
> redaction may arrive as a later option; for now the caveat is the contract.)
## Performance
Measured on `pwsh` 7.6.3, Windows 11, at the v1.12.0 build:
- **Warm-path latency** (edit -> diagnostic round-trip; median of 5 successive
real edits against an already-warm daemon): **~2.2 s** (median ~2210 ms; range
~2154-2236 ms).
- **Cold-start latency** (SessionStart hook -> the per-session PSES daemon reaches
ready; median of 3): **~3.9 s** (median ~3892 ms; range ~3789-4561 ms).
Roughly 0.7 s of the warm path is the per-hook `pwsh` process spawn that Claude
Code pays regardless of plugin code.
These latencies are **measured and guarded in CI** by a repeatable benchmark
harness (`tests/PowerShellLsp.Benchmark.Tests.ps1`): it times the real daemon/pipe
path on all four CI legs (Windows `pwsh`, Windows PowerShell 5.1, Ubuntu, macOS),
emits structured results (`benchmark-results.json`), and fails if a median
regresses past a generous threshold. The first-pass bounds are deliberately loose
(cold under 20 s, warm under 9 s) -- enough to catch a gross regression without
flaking on slower hosted runners; they tighten as per-leg CI numbers are
characterized.
The acceptance suite also confirms: cold-session bring-up launches exactly one
daemon; a deliberate diagnostic returns over the warm path; the settled
PSScriptAnalyzer pass (not the early parser publish) is reported; file URIs carry
uppercase drive letters; three rapid edits coalesce into one analysis pass;
SessionEnd leaves no daemon/PSES processes; and killing the daemon mid-session
degrades gracefully (no stdout, under the hard cap) while the next SessionStart
reaps the stale session and its orphaned PSES.
## How it works (warm-start daemon)
Diagnostics are delivered through a **PostToolUse hook backed by a warm,
per-session daemon** -- one PSES stays hot for the whole session, so each edit
pays a pipe round-trip instead of a cold PSES start.
```text
SessionStart -> scripts/session-start.ps1
ensure-pses.ps1 (idempotent PSES bootstrap, pinned tag)
ensure-pssa.ps1 (idempotent PSScriptAnalyzer vendor, pinned)
log sweep (keep-last-10 per family)
reap OUR stale daemons (recorded pids only, verified)
launch scripts/pses-daemon.ps1 (one warm PSES via -Stdio;
named pipe powershell-lsp-; pid/heartbeat in
CLAUDE_PLUGIN_DATA/session/.json)
PostToolUse -> scripts/lsp-client.ps1
read hook JSON (session_id, file_path) from stdin
connect to the pipe, request diagnostics for the edited file
daemon: didOpen/didChange -> wait for the SETTLED PScriptAnalyzer
publish (not the early parser publish) -> debounce
return deduped, severity-sorted diagnostics to Claude via
hookSpecificOutput.additionalContext
SessionEnd -> scripts/session-end.ps1
pipe {shutdown} -> daemon sends LSP shutdown/exit to PSES,
removes its session file, exits
```
- **`scripts/lib/lsp-common.ps1`**: shared helpers (host detection, file-URI with
uppercase drive, LSP framing, diagnostics ordering/dedupe), dot-sourced by the
daemon, client, hooks, and tests.
- **`scripts/ensure-pses.ps1`**: idempotent PSES bootstrap into
`${CLAUDE_PLUGIN_DATA}/PowerShellEditorServices`; no-op once present.
- **`scripts/ensure-pssa.ps1`**: idempotent vendor of pinned PSScriptAnalyzer into
`${CLAUDE_PLUGIN_DATA}/modules`, prepended to the PSES child's `PSModulePath` so
the analyzer pass runs (PSES emits only parser errors without it).
- **`scripts/pses-stdio.ps1`**: the cold-start `-Stdio` launcher -- the destination
for native `.lsp.json` registration (see below).
All scripts run `-NoLogo -NoProfile`, write nothing to stdout on the daemon/LSP
path, and keep all state, logs, and pids under `CLAUDE_PLUGIN_DATA` only.
## CI mode: SARIF + standalone scanning
The same diagnostics engine that runs in-agent is also a standalone gate you can wire into
CI. `scripts/lsp-scan.ps1` runs over a path -- a single file or a whole directory -- and
emits **SARIF 2.1.0** for GitHub code scanning, or a human-readable text report. (The first
run bootstraps PSES + the pinned PSScriptAnalyzer, exactly as a session does.)
```powershell
# Scan a directory, emit SARIF for code scanning (the default format):
pwsh -File scripts/lsp-scan.ps1 ./src -OutputPath results.sarif
# Scan a single file, human-readable text:
pwsh -File scripts/lsp-scan.ps1 ./build.ps1 -Format text
# Fail the build (exit 2) if any warning-or-worse finding is present:
pwsh -File scripts/lsp-scan.ps1 ./src -Format text -FailOn warning
```
**One engine, in-agent and in-CI.** The scan is a *sibling* invocation of the exact same
path the PostToolUse hook uses: it brings up the same warm PSES daemon and runs the same
`scripts/lsp-client.ps1` over each file, so a finding is identical whether it surfaces while
Claude edits or in your CI. This is not a re-implementation -- a test
(`tests/PowerShellLsp.SarifScan.Tests.ps1`) runs the whole diagnostic-correctness corpus
through the scan entry point and asserts its findings match the in-agent snapshots exactly
(the same measured 0% false-positive / 100% true-positive numbers). PSScriptAnalyzer is the
same pinned, SHA-256-verified vendor; there is no second acquisition path.
**What is scanned.** Only the file types the tool handles -- `.ps1`, `.psm1`, `.psd1`. A
directory is **recursed by default** (`-NoRecurse` limits to the top level); every
non-PowerShell file is skipped (and counted in the text summary). The repository's own
`PSScriptAnalyzerSettings.psd1` is honored, exactly as in-agent.
**Severity mapping (honest -- no inflation, no deflation).** The tool's diagnostic
severities map to SARIF result levels as:
| Tool severity | SARIF level |
|---------------|-------------|
| Error | `error` |
| Warning | `warning` |
| Information | `note` |
| Hint | `note` |
SARIF 2.1.0 has exactly four levels (`error`, `warning`, `note`, `none`). The only fold is
Information **and** Hint to `note`, because SARIF has no separate info/hint level below
warning; nothing is mapped to `none` (which would suppress it from code-scanning views), and
an unknown severity maps to `warning`, so a finding is never silently dropped. The emitted
SARIF is validated against the official SARIF 2.1.0 JSON Schema in CI.
**Output format is a CLI parameter, not a config knob.** `-Format sarif|text` is an
entry-point parameter -- the CI invocation is explicit, so the choice is a parameter, not a
`userConfig` knob. (The 1.x contract freezes the knob names and status tokens; this entry
point adds neither, so `CONTRACT.md` is unchanged.)
**Exit codes.** `0` = completed (clean, or under the `-FailOn` threshold); `2` = `-FailOn`
threshold met; `3` = usage error (no PowerShell host, or the path does not exist); `4` =
scan incomplete (the analyzer was not reachable -- an unanalyzed file is never reported as a
clean one).
A minimal GitHub Actions step that uploads the results to code scanning:
```yaml
- shell: pwsh
run: ./scripts/lsp-scan.ps1 . -OutputPath results.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
```
## Why a hook, not native `.lsp.json` registration
Claude Code declares plugin language servers through an inline `lspServers` block in
`plugin.json` (or a standalone per-plugin
[`.lsp.json`](https://code.claude.com/docs/en/plugins-reference#lsp-servers) file). This plugin
carries the inline block. As of v1.18.1 the manifest-side blocker that kept it from registering
is removed -- so native **registration** is no longer the obstacle. The plugin still ships
diagnostics over a **warm PostToolUse hook** for one reason: **registration is restored, but
end-to-end serve is not.**
Once the server is registered, Claude Code launches it and PSES reaches "Starting Language
Server", but Claude Code's LSP client currently times out during initialization (the
`#1359`-class server->client init handshake). So a native `goToDefinition` / `hover` /
`findReferences` on a `.ps1` does not complete yet -- it is gated **upstream**, on the Claude
Code side, not on this plugin's launcher (which is provably stdout-clean: its first stdout line
is a valid `Content-Length:` LSP header). The warm hook, by contrast, works on every supported
host today and does not depend on the native path at all. The hook is the product; native
registration is the bonus, now one upstream fix away from serving.
### What used to block registration, and what fixed it
For a long stretch (Claude Code 2.1.167 through 2.1.183) the native path looked **inert** -- every
probe returned `No LSP server available for file type: .ps1` -- and two upstream issues were the
leading suspects:
1. **A marketplace packaging gap.** A marketplace install copies only the plugin's source
directory, so an `lspServers` block living **solely** in `marketplace.json` is dropped and the
installed plugin registers **0 servers**. Tracked (open) at
[claude-plugins-official#379](https://github.com/anthropics/claude-plugins-official/issues/379);
the proposed fix [PR #378](https://github.com/anthropics/claude-plugins-official/pull/378) was
**closed unmerged** (2026-02-11). This plugin sidesteps it by declaring the server inline in
`plugin.json`, which the installer does copy.
2. **A registration race.** `LspServerManager` could initialize before plugins finished loading.
First reported in
[claude-code#14803](https://github.com/anthropics/claude-code/issues/14803) (**fixed**) and
analyzed in [#29858](https://github.com/anthropics/claude-code/issues/29858);
[#15168](https://github.com/anthropics/claude-code/issues/15168) /
[#15148](https://github.com/anthropics/claude-code/issues/15148) track the residual symptom.
On Claude Code 2.1.195, a controlled single-field probe matrix (dispatch 000069) showed neither of
those was what blocked **this** plugin: the official `typescript` control plugin registers and
serves, and a clean known-good `lspServers` block in a `plugin.json` registers too -- so the
platform path is effective. The real blocker was **two fields in our own manifest**:
> Claude Code's runtime LSP registrar **silently drops** any `lspServers` entry that declares
> **`restartOnCrash`** or **`shutdownTimeout`**. Both are accepted by the plugin-manifest JSON
> schema (so `plugin.json` validates), but the registrar rejects them with no diagnostic -- and our
> block declared both, so `.ps1 -> powershell` was never registered.
Removing those two fields (v1.18.1) clears the obstacle; a CI guard
(`tests/PowerShellLsp.Unit.Tests.ps1`) now fails if any `lspServers` entry re-declares a field
outside the registrar-supported set `{command, args, extensionToLanguage, transport,
startupTimeout, maxRestarts, env}`. Full methodology and the 23-probe matrix are in
[`docs/upstream/claude-code-lsp-registration.md`](docs/upstream/claude-code-lsp-registration.md).
### The standalone `.lsp.json` template
The plugin's `plugin.json` already carries the registrar-clean `lspServers` block, so native
registration needs no extra step once the plugin is enabled -- there is nothing to copy in. A
standalone copy of the declaration also ships at
[`docs/lsp.json.template`](docs/lsp.json.template) for reference and for any setup that wants a
root-level `.lsp.json`; it is deliberately **not** live at the repo root, because a second,
duplicate declaration would risk double registration.
> **Heads-up for when serve lands -- duplicate diagnostics.** If native serving ever completes
> (the upstream init handshake is fixed) while the PostToolUse hook is also enabled, each
> diagnostic could arrive twice. Use one path or the other.
## Pinned versions
| Component | Version | Pinned in | Source |
|-------------------|----------|---------------------------|-----------------------------------------|
| PSES | `v4.6.0` | `scripts/ensure-pses.ps1` (`$PsesTag`) | GitHub release `PowerShellEditorServices.zip` |
| PSScriptAnalyzer | `1.25.0` | `scripts/ensure-pssa.ps1` (`$PssaVersion`) | PowerShell Gallery |
To bump either, change the single pin variable named above and start a fresh
session (the ensure-step re-vendors at the new version, keyed by a per-version
marker). See [CHANGELOG](./CHANGELOG.md#versioning) for how a bump maps to SemVer.
In CI, the pinned PSScriptAnalyzer `.nupkg` is cached (`actions/cache`, keyed by the
pinned version **and** SHA-256) and restored on a cache hit, so the PowerShell
Gallery is contacted only on a miss -- the analyzer-acquisition step does not depend
on live Gallery egress every run. The integrity pin is still load-bearing on every
path: a restored `.nupkg` is run through the exact same SHA-256 verification as a
fresh download before use, and a poisoned or stale cache entry fails closed (it is
refused, never installed). The cache is a transport optimization, never a trust
shortcut. For a normal install (no `POWERSHELL_LSP_PSSA_CACHE` set) acquisition is
unchanged: download, verify against the pin, then vendor.
## Platform support
As of 1.1.1 the **hooks require `pwsh` (PowerShell 7)** -- they launch the bootstrap
under it on every platform. Windows PowerShell 5.1 is supported as the **PSES child
host** (set `ps_host` to `powershell`), not as the hook interpreter.
CI runs the Pester suite on a four-leg matrix: **Windows `pwsh` 7**, **Windows
PowerShell 5.1**, **Ubuntu `pwsh`**, and (as of 1.3.0) **macOS `pwsh`**. The full
warm-daemon **integration suite** (one-daemon bring-up, the settled PSScriptAnalyzer
pass, clean SessionEnd) runs and is **green on all four legs** -- so the **Linux and
macOS daemon paths are CI-verified**, not merely authored. The integration tests drive the daemon under
`pwsh` on every leg, so the Windows-PowerShell-5.1 leg's distinct value is exercising
the **shared-library surface under 5.1** -- file-URI casing, BOM-tolerant stdin, the
`ArgumentList`-vs-quoted-`.Arguments` split, and the config-env fallback -- the code
that must keep working when PSES runs as a 5.1 child.
The scripts are cross-platform: all paths go through `Join-Path`, host detection is
shared, the single Windows-only call (process command-line lookup, used to verify a
pid is ours before any kill) is guarded behind `Test-OnWindows` with Linux `/proc`
and macOS `ps` fallbacks, and the client/daemon transport is `System.IO.Pipes` (Unix
domain socket semantics on *nix). As of 1.3.0 that macOS `ps` fallback is exercised by
the macOS CI integration leg, so **macOS is CI-verified** alongside Linux.
## Diagnostics status
Every analyzed edit resolves to one of four statuses. The clean case is silent; the other
three surface a one-line banner in Claude's context, so a result is never *mistaken* for
"analyzed, clean" when it was not actually analyzed. The wording is owned in one place
(`Get-DiagnosticsStatusBanner` in `scripts/lib/lsp-common.ps1`).
| Status | When | What you see / what to do |
|-------------------|----------------------------------------------------------------------|---------------------------|
| **`ok`** | The PSScriptAnalyzer pass settled and the analyzer was available. | Nothing extra -- diagnostics (if any) are shown, no banner. The warm happy path. |
| **`incomplete`** | The pass did **not** settle for this edit -- PSES timed out, threw, exited, a supervised re-spawn was mid-flight, or PSES is **still starting** (pipe-first opens the request pipe before PSES is ready, dispatch 000028). | `analysis did not complete -- this edit was NOT checked.` Transient: the next edit usually succeeds once PSES is ready. |
| **`degraded`** | PSES is up and settled, but the vendored **PSScriptAnalyzer is absent**, so only the parser ran. | `parser-only mode -- PSScriptAnalyzer unavailable, lint rules were NOT checked (syntax errors are still reported).` Start a fresh session so `ensure-pssa` re-vendors; see `logs/ensure-pssa.log`. |
| **`unavailable`** | PSES **could not start at all**, for the whole session -- either the bundle **never bootstrapped** (a clean box, offline or behind a proxy) or it is present but **failed to initialize** (a startup failure / init timeout, dispatch 000028). | `PowerShell editor services could not start -- not installed (the bootstrap did not complete), or installed but failed to start. Diagnostics will stay OFF for this whole session until it is fixed and the session is restarted.` Fix the install/startup, then start a fresh session; see `logs/ensure-pses.log` and `logs/pses-daemon.log`. |
`incomplete` (transient -- "not ready/settled this time, the next edit will be") and
`unavailable` (permanent for the session -- "could not start; fix and restart") are
deliberately distinct, with distinct remedies. The install-time `unavailable` arrived in 1.5.0
(dispatch 000024); 1.6.0 (dispatch 000028) made the daemon **pipe-first** -- it opens the
request pipe *before* bringing PSES up -- so a first edit that races startup now gets one of
these honest banners instead of silence, and generalized `unavailable` to also cover a
present-but-failed start (not just a missing install). When the daemon is unreachable entirely --
no pipe at all (the brief daemon-launch window, or a session whose daemon has stopped after idle) --
the PostToolUse client surfaces its own honest "analyzer was not reachable -- this edit was NOT
checked" banner (start a new session to restart the daemon), so even the no-pipe case is never
silent. The mid-session `incomplete`/`degraded` split was introduced earlier (dispatch 000022).
## Dogfood diagnostic capture
Every diagnostic the plugin surfaces is also **teed to a local, append-only log** so the real
diagnostics from real day-to-day editing can drive the roadmap's quality work -- rule curation,
false-positive reduction, fix-suggestion quality -- ranked on evidence instead of guesses. The
companion tool that annotates this log -- filling each `verdict` -- is documented in **Dogfood
review** below.
- **Where:** `dogfood/diagnostics.jsonl` in the plugin tree. Override with the
`POWERSHELL_LSP_DOGFOOD_LOG` environment variable (a full path to the `.jsonl` file).
- **What:** one JSON object per line, one line per diagnostic **occurrence** -- two identical
diagnostics make two lines (frequency is the signal; de-duplication is an analysis-time concern,
never a capture-time one). Each entry carries: `ts` (ISO-8601), `file`, `line`, `col`, `ruleId`
(the PSScriptAnalyzer rule, or empty for a parser error), `source` (`PSScriptAnalyzer` or
`parser`), `severity`, `message`, `snippet` (the full offending line), `hash` (a stable key over
the rule id + the normalized offending-line shape, for analysis-time de-duplication), and
`verdict` -- written **empty**, reserved for you to annotate later with `scripts/review-dogfood.ps1`
(see **Dogfood review** below).
- **Invisible side channel:** capture runs *after* the diagnostics are surfaced and is fully
fail-safe. If the write fails for any reason, the diagnostics you see and the hook's exit code are
byte-for-byte unchanged; logging never changes, reorders, delays, or gates what is surfaced.
> **Never commit this log.** It holds **real source snippets** from the files you edit. The whole
> `dogfood/` directory is gitignored (see `.gitignore`) and must never be staged, added, or
> committed -- do not weaken that entry.
## Dogfood review
The offline tool `scripts/review-dogfood.ps1` fills the empty `verdict` field that the capture
reserves. It never changes what the daemon or hooks run and never alters the diagnostics surface or
the capture log. Instead, it turns raw captured diagnostics into ranked input for the roadmap's
quality work (rule curation, false-positive reduction, fix quality).
- Collapses captured occurrences into distinct diagnostic **shapes**, keyed by the record's `hash`
(rule id + normalized offending-line shape). Identical diagnostics share one verdict, so a misfire
seen many times is judged once; re-runs skip shapes that already have a verdict (resumable).
- Fixed verdict vocabulary (lower-case): `useful` (true, actionable), `false-positive` (the rule
misfired), `noisy` (correct but low-value / clutter), `bad-fix` (the finding is fine but its
suggested correction is wrong / harmful), `unsure` (needs a second look). It is a fixed enum, not
free text; an optional one-line rationale may accompany a verdict.
- **Persistence:** verdicts are written to a **separate sibling file**, `dogfood/annotations.jsonl`,
keyed by the shape hash. Append-only, last-write-wins (a corrected verdict appends a new line;
readers honor the latest). The capture log (`diagnostics.jsonl`) is never rewritten -- it stays
immutable evidence.
- **Read-only by default:** with no write action the tool lists the pending shapes and prints a
**summary** (counts by verdict, annotation coverage, the source split, and the top "actionable"
rules -- those verdicted false-positive / noisy / bad-fix -- ranked by occurrence count). Writing a
verdict is the explicit action.
- **Reading the right log (`-Source`):** by default (`-Source auto`) the reviewer reads the
**installed marketplace-cache** log -- the one the live hook writes to under normal installed use --
when it exists and is non-empty, so a review run from the dev checkout is not blind to the real
captures; otherwise it falls back to the running-tree (checkout) log. Force one with `-Source cache`
or `-Source checkout`. The versioned cache path is **discovered** (it follows `CLAUDE_PLUGIN_ROOT`
when set, else picks the current installed version under the plugin cache tree) -- never hardcoded.
This is a read-side locator only; it never changes where the hook writes.
- **Source split:** the summary also buckets captures **by source** -- `canonical-checkout` (edits of
the real checkout), `other-genuine` (linked worktrees, the demo recording, other repos), and
`synthetic` (temp / Pester-fixture paths) -- so the quality wave can tell real canonical source from
the rest. An ambiguous path is classified conservatively (never as `canonical-checkout`).
- **Recording a verdict:** non-interactively with `-Hash -Verdict [-Rationale
"..."]`, or interactively with `-Review` (a guarded prompt loop over pending shapes; on a
non-interactive host it falls back to the read-only listing instead of blocking).
- Use `-Redact` to mask the offending-line snippet in listings when sharing a review. Other flags:
`-Summary` (summary only), `-All` (list every shape, not just pending), `-Source`
(`auto` / `cache` / `checkout`), `-Path` and `-AnnotationsPath` (point at explicit files).
```text
pwsh -File scripts/review-dogfood.ps1
pwsh -File scripts/review-dogfood.ps1 -Summary
pwsh -File scripts/review-dogfood.ps1 -Source cache
pwsh -File scripts/review-dogfood.ps1 -Review
pwsh -File scripts/review-dogfood.ps1 -Hash -Verdict false-positive -Rationale "..."
```
> **Never commit the annotations file either.** It lives under the same already-gitignored
> `dogfood/` directory as the capture log, so the `.gitignore` already covers it -- do not weaken
> that entry. Its free-text rationale could quote source, so it stays local-only like the log.
## Diagnostic-correctness corpus
A curated corpus (`tests/corpus/`) proves the diagnostics the tool *reports* are correct -- not
merely present, and not merely honest when it cannot analyze. Three sample categories:
- **clean** (34 cases) -- expect zero findings (no false positives on clean code); a deliberately
broad span of real-world idioms (advanced functions with `begin`/`process`/`end`, classes with
inheritance and static members, `[Flags]` enums, validation attributes, `SecureString` /
`PSCredential` parameters, splatting, multi-stage pipelines, typed `try`/`catch`/`finally`,
here-strings, regex, `ShouldProcess`, and more).
- **known-bad** (36 cases) -- six cases per surfaced rule, each tripping a specific PSScriptAnalyzer
rule the tool surfaces, asserting the exact rule id, line, and severity; the several cases per
rule exercise varied triggering constructs.
- **parser-error** (3 cases) -- expect parser diagnostics.
**Measured correctness (default config, all four CI legs).** Across those curated cases the tool
posts a **0% false-positive rate** (0 of 34 known-good cases produced any finding) and **100%
true-positive coverage** (36 of 36 known-bad cases surfaced their expected rule), spanning every
rule the default ruleset surfaces. These numbers are not prose -- they are recomputed from the live
tool on every CI run and **guarded** (`tests/PowerShellLsp.Corpus.Tests.ps1`: the report fails CI if
the false-positive rate rises above zero, coverage drops below 100%, the corpus shrinks below 30
known-good or 30 known-bad, or any surfaced default rule loses its known-bad case), and the per-run
report is uploaded as a CI artifact (`logs/corpus-correctness-report.json`). The claim is *measured
and defensible*, not *exhaustive*.
**The invariant that makes it trustworthy:** every expected finding is *derived* by running the
REAL tool over the sample and snapshotting exactly what it emits (through the plugin's own dogfood
capture channel) -- never hand-authored, never model-authored. A generator
(`tests/corpus/Update-CorpusSnapshots.ps1`) writes the committed snapshots; the corpus test
re-derives the same way and asserts the live tool still matches. A future behavior change becomes a
visible, located failure, and a hand-edited snapshot cannot make the test pass -- it would simply
disagree with the real tool.
One fact the corpus surfaced: the tool's effective default ruleset (via PowerShell Editor Services)
is **narrower** than raw PSScriptAnalyzer. Measured against the live daemon, it surfaces **six** rules
on the fly -- `PSAvoidUsingCmdletAliases`, `PSUseApprovedVerbs`,
`PSUseDeclaredVarsMoreThanAssignments`, `PSAvoidUsingPlainTextForPassword`,
`PSPossibleIncorrectComparisonWithNull`, and `PSAvoidDefaultValueSwitchParameter` -- and drops others
the CLI flags (e.g. `PSAvoidUsingEmptyCatchBlock`, `PSReviewUnusedParameter`,
`PSUseShouldProcessForStateChangingFunctions`, `PSAvoidUsingWriteHost`,
`PSAvoidUsingPositionalParameters`, `PSUseSingularNouns`). The corpus records what the tool actually
surfaces; tuning the ruleset is a separate, dogfood-paced quality track. The corpus runs in CI on all
four legs.
## Troubleshooting
### Preflight self-check (the doctor)
Before chasing a specific symptom, run the preflight **doctor** -- it checks the
prerequisites and bootstrap health in one place and prints a named fix-list:
```
pwsh -File scripts/doctor.ps1
```
It verifies, in order: PowerShell 7 (`pwsh`) is present and new enough (see
[Prerequisites](#prerequisites)); the plugin is enabled (see [Quick start](#quick-start)); the PSES
bundle and PSScriptAnalyzer finished bootstrapping (the pinned markers plus
`Start-EditorServices.ps1`, see [Pinned versions](#pinned-versions)); the first-run
download hosts are reachable; and the **warm per-session daemon** is alive and answering on its
named pipe -- the *runtime* check the first five cannot make (they confirm the bundle is
**installed**; this confirms the language server is actually **running**). Each check reports
`PASS`, a specific failure with the fix, or an honest `UNKNOWN` when it genuinely cannot
determine -- for example, run outside a Claude Code session it cannot see the plugin data
directory, so the enable-state, bundle, and daemon checks report `UNKNOWN` (run it from inside
an enabled session for a definitive result).
The daemon check **observes only** -- it never starts, restarts, or kills the daemon -- and it
is honest about the auto-relaunch design (see [Diagnostics status](#diagnostics-status)): **no
daemon running** reports `PASS` (benign -- one auto-relaunches on your next edit), never a scary
failure, while a daemon that is alive but parked `unavailable` / `degraded`, or alive but not
answering its pipe, is a `FAIL` with the restart remedy.
The doctor is **report-only**: it never downloads, repairs, runs the bootstrap, or
starts/restarts the daemon. It also does **not** probe security controls itself -- but when a *bootstrap* failure is
caused by one, the SessionStart banner now names the most likely control and the
legitimate fix (see [Security-control blocks on managed Windows](#security-control-blocks-on-managed-windows)
below). If a doctor check fails for a reason its own fix does not resolve, a security
control on a managed machine (an execution or application-control policy) may be the
cause -- check that banner and the section below.
### Symptoms
- **Hooks fail with `'pwsh' is not recognized` / pwsh not found:** as of 1.1.1 the
hooks launch under PowerShell 7. Install it (`winget install Microsoft.PowerShell`)
-- Windows PowerShell 5.1 alone cannot launch the hooks. (`ps_host` only selects the
PSES *child* host, not the hook interpreter.)
- **A leftover user-level PSES hook fires alongside the plugin (duplicate or
conflicting diagnostics):** if you previously wired a PowerShell diagnostics hook
directly in `~/.claude/settings.json` (a pre-plugin setup), remove it -- the plugin
owns the SessionStart / PostToolUse / SessionEnd hooks now, and a stray user-level
hook will double up or conflict with them.
- **`/plugin` Errors tab shows `Executable not found in $PATH`** for the
`powershell` server: `ps_host` points at an executable that is not on PATH.
Install PowerShell 7 (`pwsh`) or set `ps_host` to `powershell`.
- **No diagnostics / server never starts:** confirm the bootstrap ran by checking
that
`${CLAUDE_PLUGIN_DATA}/PowerShellEditorServices/PowerShellEditorServices/Start-EditorServices.ps1`
exists. If not, start a fresh session so the `SessionStart` hook can run, and
inspect `${CLAUDE_PLUGIN_DATA}/logs/ensure-pses.log`.
- **Server starts but handshake fails:** inspect the PSES log under
`${CLAUDE_PLUGIN_DATA}/logs/pses-lsp.log/StartEditorServices-.log` for the
PSES-side error.
- **`PrepareRenameHandler` `NullReferenceException` on initialize:** a PSES
`v4.6.0` bug -- its rename handler dereferences a null `RenameCapability` when an
LSP client's `textDocument` capabilities **omit** `rename`. This plugin's daemon
**declares a minimal `rename` capability on purpose**, which is what *avoids* the
NRE, so the warm path is unaffected. You would only hit this by driving PSES from
a client that omits rename (e.g. a hand-rolled minimal client against the cold
`-Stdio` launcher); if so, pin PSES `v4.5.0` in `scripts/ensure-pses.ps1`
(`$PsesTag`), which predates the rename handler.
### Security-control blocks on managed Windows
PowerShell developers often work inside locked-down Windows estates, and this plugin does
exactly what those estates gate: it **downloads** executables (PSES, PSScriptAnalyzer),
**runs** PowerShell, and **spawns** a daemon. When a security control blocks one of those
at first start, the bootstrap fails -- and instead of a generic "could not start", the
SessionStart banner now **names the most likely control and the legitimate remediation**.
The status stays `unavailable` (see [Diagnostics status](#diagnostics-status)); only the
message gets specific.
A control is named **only on positive evidence**, with calibrated confidence -- an
uncertain case gets an honest "here is how to check" pointer, never a guessed control:
| Control | How it is detected | Confidence | Banner names / fix |
|---------|--------------------|------------|--------------------|
| **ExecutionPolicy** (Group Policy) | `Get-ExecutionPolicy -List` shows `MachinePolicy`/`UserPolicy` = `AllSigned`/`RemoteSigned` (a command-line `-Bypass` is ignored when the policy is from GPO) | likely | the policy + scope. Fix: an admin allow-lists / signs the scripts, or adjusts the policy. |
| **Constrained Language Mode** | the session `LanguageMode` is `ConstrainedLanguage` | likely | CLM. The plugin's .NET-using bootstrap cannot run under it. Fix: sign + policy-trust the plugin (admin). |
| **App Control / WDAC** | a CodeIntegrity Operational event **3077** (enforced) or **3076** (audit) names a plugin component | confirmed / likely | the control + event id. Fix: an admin adds an allow rule. |
| **Microsoft Defender ASR** | a Defender Operational event **1121** (block) or **1122** (audit) names a plugin component | confirmed / likely | the rule family + event id. Fix: an admin reviews / allows the rule. |
| **Smart App Control** | the SAC registry state (`VerifiedAndReputablePolicyState`) is enforced / evaluation | possible | SAC is reputation-gated, so it is only ever *possible*. Fix: it relaxes as reputation accrues, or an admin turns it off. |
| *(none identified)* | no positive evidence | -- | honest pointer: usually network/proxy; if managed, check `Get-ExecutionPolicy -List`, the language mode, and the CodeIntegrity log. |
To investigate a named (or suspected) block yourself, on the affected machine:
```
Get-ExecutionPolicy -List
$ExecutionContext.SessionState.LanguageMode
Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-CodeIntegrity/Operational'; Id = 3076, 3077 } -MaxEvents 20
```
**The plugin only ever detects and explains a block -- it never bypasses, disables, or
modifies a security control.** Every remediation above is something a user or their
administrator does deliberately (sign, allow-list, adjust policy); the plugin itself takes
no such action. A tool that tried to circumvent enterprise security would deserve to be
banned -- honest degradation, telling you exactly what is blocked and how to allow it, is
the whole value.
## Verify your install
You do not have to take this plugin's integrity on trust -- you can check it. The two pinned
dependencies it downloads on first run are each verified against a SHA-256 computed from the real
known-good artifact *before* they are used, and a mismatch **fails closed** (the unverified bundle is
refused and the session reads `unavailable`). The pins and their hashes live in the repo, so you can
confirm the bytes on your machine match what this repo ships:
```
# The pinned versions + SHA-256 hashes are tabulated in TRUST.md; the pins themselves live in
# scripts/ensure-pses.ps1 ($PsesTag / $PsesSha256) and scripts/ensure-pssa.ps1 ($PssaVersion /
# $PssaSha256). Confirm a downloaded component matches the pin this repo ships:
(Get-FileHash -Algorithm SHA256 -LiteralPath .\PowerShellEditorServices.zip).Hash
```
Every release cut by the **gated release pipeline** also ships a **CycloneDX SBOM**
(`powershell-lsp-.cdx.json`, generated straight from those same pins, so it cannot disagree
with what the tool downloads) and a **SLSA build-provenance attestation** over the source archive,
and the release **tag itself is keyless-signed via Sigstore** (gitsign) -- see
**[Verifying a release](#verifying-a-release)** below for the download-and-verify steps, the tag
signature check, and what a pass proves.
The full pinned-hash table, the SBOM / provenance details, the **signing posture** (release tags
keyless-signed via Sigstore; scripts deliberately not Authenticode-signed; not independently
audited), and paste-ready WDAC / AppLocker allow-list rules are all in **[TRUST.md](./TRUST.md)**.
## Verifying a release
Every tagged release is built by this repository's own gated release pipeline, which publishes a
**SLSA v1.0 build-provenance attestation** over the release archive and a **keyless gitsign (Sigstore)
signature on the release tag** -- both made through GitHub's OIDC identity, with **no maintainer-held
key** in the trust path. Anyone can verify a release. First download the archive for the version you
want:
```
gh release download v1.17.0 --repo manderse21/claude-powershell-lsp --pattern "*.tar.gz"
```
Then verify its provenance (a pass prints `Verification succeeded!` and exits 0; any mismatch fails
non-zero):
```
gh attestation verify powershell-lsp-1.17.0.tar.gz --repo manderse21/claude-powershell-lsp
```
A successful verification proves that exact archive:
- **was built by this repository's release workflow**
(`.github/workflows/powershell-lsp-release.yml`) -- workflow identity, not another repo or a
hand-run command;
- **is byte-identical to what was signed** -- its SHA-256 digest matches the attestation, so a
single tampered byte fails the check;
- **carries SLSA v1.0 build provenance** -- a provenance predicate issued through GitHub's OIDC,
verifiable with no key the maintainer holds or could leak.
You can also verify the **signature on the release tag** itself. This needs
[gitsign](https://github.com/sigstore/gitsign) installed -- a plain `git verify-tag` cannot read the
x509 / Sigstore signature, and gitsign must be given the expected identity (it checks WHO signed, not
merely that a signature exists). Fetch the tags, then verify against this repository's release
workflow identity and the GitHub OIDC issuer:
```
git fetch --tags
gitsign verify \
--certificate-identity="https://github.com/manderse21/claude-powershell-lsp/.github/workflows/powershell-lsp-release.yml@refs/heads/main" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
v1.17.0
```
A successful verify confirms the tag was signed by THIS repository's release workflow under GitHub's
OIDC issuer, anchored in the public Rekor transparency log.
**What this does and does not prove.** This is build provenance and integrity over the downloadable
**source archive** -- it proves the release came untampered from this repository's pipeline. It is
**not** Windows Authenticode and does **not** assert a Windows verified-publisher identity (no
SmartScreen reputation, no signed-script trust) -- Authenticode signing of the scripts is deliberately
not pursued for a git-distributed plugin. That is the correct boundary for a plugin distributed by
`git clone`: the integrity of the normal `/plugin` install path rests on the **git commit and the
keyless-signed tag** themselves, not on the archive -- verify the tag as shown above, then trust the
tree it names. See
**[SECURITY.md](./SECURITY.md#verifying-release-integrity)** for the full step-by-step walkthrough
(with sample output), and
**[docs/RELEASING.md](docs/RELEASING.md#provenance-what-it-covers-and-what-it-does-not)** for exactly
what the provenance covers.
## Security and trust
Evaluating this plugin for a managed or locked-down Windows estate? **[TRUST.md](./TRUST.md)**
is the approve-or-deny reference: what runs locally and what never leaves the machine (no
network service, no telemetry), the **pinned + SHA-256-verified** downloads, the CycloneDX
SBOM and build-provenance attestation, the **signing posture** (release tags keyless-signed via
Sigstore; scripts deliberately not Authenticode-signed; no security audit), paste-ready WDAC /
AppLocker allow-list rules, and the governance / bus-factor posture.
Found a vulnerability? See **[SECURITY.md](./SECURITY.md)** -- report it privately via GitHub
private vulnerability reporting (never a public issue); it covers supported versions, scope,
and what to expect.
## Releasing
Releases are cut by a **maintainer-triggered, gate-validated pipeline** -- never automatically
on push or merge. The pipeline refuses to tag unless the target commit is merged to `main`,
green on every CI leg, and version-matched (`plugin.json` agrees with `marketplace.json`), then
cuts the **keyless gitsign-signed** tag itself on that validated commit and publishes a GitHub
Release with CHANGELOG-sourced notes, a CycloneDX SBOM, and a build-provenance attestation. See
[docs/RELEASING.md](docs/RELEASING.md) for how to trigger a release, what it validates, what it
produces, and the manual fallback.
## Contributing and development
Contributions are welcome. Start with **[CONTRIBUTING.md](./CONTRIBUTING.md)** (prerequisites, how
to run the suite, the test story), **[ARCHITECTURE.md](./ARCHITECTURE.md)** (how a diagnostic flows
from edit to banner), and **[DEV_NOTES.md](./DEV_NOTES.md)** (the quirks that bite -- ASCII
discipline, the 5.1 traps, the pipe-first daemon, the tool-derived corpus). Found a false positive?
The [report-a-false-positive form](./.github/ISSUE_TEMPLATE/false_positive_report.yml) feeds it
straight into the correctness corpus. The single-maintainer bus factor and the GPLv3 continuity path
are stated honestly in **[CONTINUITY.md](./CONTINUITY.md)**.
**Git hooks (contributors).** This repo ships a tracked pre-push guard that refuses a direct push to
`origin/main` -- main lands via a reviewed, merged PR (the PR-and-HOLD discipline), never a local
push. Enable it once per clone with `pwsh -File scripts/install-git-hooks.ps1`; it sets
`core.hooksPath`, so the guard fires from linked worktrees too, not only the primary checkout. A
deliberate one-off is allowed and audited:
`POWERSHELL_LSP_ALLOW_PUSH_TO_MAIN="" git push ...`. See
[CONTRIBUTING.md](./CONTRIBUTING.md#git-hooks) for the override, the audit log, and the rationale.
## License
[GPL-3.0-or-later](https://spdx.org/licenses/GPL-3.0-or-later.html) (GPLv3). See [LICENSE](./LICENSE).
The change to GPLv3 is **forward-only**, effective from **v1.6.1**. Prior releases (v1.0 through
v1.6.0) remain under the MIT license they shipped with -- that grant is irrevocable and is not
affected by this change.
PowerShell Editor Services and PSScriptAnalyzer are **downloaded at install time** (not bundled in
this repository) and remain under their own MIT licenses (Microsoft); MIT is GPL-compatible. See
[THIRD-PARTY-LICENSES.md](./THIRD-PARTY-LICENSES.md).