From eb6d2dd95289971937fc764388c630221602ff8a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:20:00 -0400 Subject: [PATCH 1/7] feat(powershell-format): add PSScriptAnalyzer formatter plugin Migrate medley's powershell-format hook into a repo-agnostic marketplace plugin. Mirrors the ruff-format/biome-format tool-runner pattern: - Opt-in on a consumer PSScriptAnalyzerSettings.psd1, discovered by walking up from the edited file to the repo root (keeps the medley seam, adds monorepo-safe per-module discovery). - Graceful degrade: pwsh absent OR PSScriptAnalyzer module absent -> clean silent no-op (exit 3 sentinel from the pwsh probe; exit 4 = analyzer throw surfaces as advisory tool-break). - Advisory only (always exit 0); kill switch HOOK_POWERSHELL_FORMAT_ENABLED; opt-in telemetry envelope via HOOK_TELEMETRY_SINK. - Byte-identical hook-utils.sh copy (sync-hook-utils SSOT). Fixes a latent bug inherited from the medley baseline: PowerShell string -ne is case-insensitive, so a casing-only reformat (PSUseCorrectCasing) never persisted. The formatted output is now compared with case-sensitive -cne. Self-contained contract tests (36 cases; skip cleanly without pwsh, with deterministic pwsh-stub cases for the exit-3/exit-4 degrade paths). --- .claude-plugin/marketplace.json | 6 + README.md | 1 + .../.claude-plugin/plugin.json | 11 + plugins/powershell-format/README.md | 69 ++++ plugins/powershell-format/hooks/hook-utils.sh | 247 +++++++++++++ plugins/powershell-format/hooks/hooks.json | 16 + .../hooks/powershell-format.sh | 243 +++++++++++++ .../hooks/powershell-format.test.sh | 343 ++++++++++++++++++ 8 files changed, 936 insertions(+) create mode 100644 plugins/powershell-format/.claude-plugin/plugin.json create mode 100644 plugins/powershell-format/README.md create mode 100644 plugins/powershell-format/hooks/hook-utils.sh create mode 100644 plugins/powershell-format/hooks/hooks.json create mode 100755 plugins/powershell-format/hooks/powershell-format.sh create mode 100755 plugins/powershell-format/hooks/powershell-format.test.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 56f8b27c1..791216fc5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -30,6 +30,12 @@ "source": "./plugins/ruff-format", "category": "formatting", "tags": ["ruff", "python", "formatter", "linter", "hook"] + }, + { + "name": "powershell-format", + "source": "./plugins/powershell-format", + "category": "formatting", + "tags": ["powershell", "psscriptanalyzer", "pwsh", "formatter", "linter", "hook"] } ] } diff --git a/README.md b/README.md index aab514ab2..94bd46346 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Browse and manage with `/plugin`. To refresh after updates: `/plugin marketplace | [`bash-lint`](plugins/bash-lint) | Hook | Lints shell scripts on edit via ShellCheck, and formats via shfmt when the repo opts in with an `.editorconfig`, using the consuming repo's own config. | | [`biome-format`](plugins/biome-format) | Hook | Formats and lints JS/TS/JSX/JSON on edit via Biome, only when the repo opts in with a `biome.json`, using the consuming repo's own Biome config. | | [`ruff-format`](plugins/ruff-format) | Hook | Formats and lints Python on edit via Ruff, only when the repo opts in with a Ruff config (`ruff.toml`, `.ruff.toml`, or `pyproject.toml` `[tool.ruff]`), using the consuming repo's own Ruff config. | +| [`powershell-format`](plugins/powershell-format) | Hook | Formats and lints PowerShell on edit via PSScriptAnalyzer, only when the repo opts in with a `PSScriptAnalyzerSettings.psd1`, using the consuming repo's own analyzer settings. | Install one: `/plugin install @melodic-software`. diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json new file mode 100644 index 000000000..196c42060 --- /dev/null +++ b/plugins/powershell-format/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "powershell-format", + "version": "0.1.0", + "description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo — using the consuming repo's own analyzer settings.", + "author": { + "name": "Melodic Software", + "email": "info@melodicsoftware.com" + }, + "keywords": ["powershell", "psscriptanalyzer", "pwsh", "formatter", "linter", "hook"] +} diff --git a/plugins/powershell-format/README.md b/plugins/powershell-format/README.md new file mode 100644 index 000000000..1b7eb259c --- /dev/null +++ b/plugins/powershell-format/README.md @@ -0,0 +1,69 @@ +# powershell-format + +A Claude Code plugin that formats and lints PowerShell the moment you edit it. On +every `Write` or `Edit` of a `.ps1`, `.psm1`, or `.psd1` file it runs +[PSScriptAnalyzer](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/overview)'s +`Invoke-Formatter` (formatting in place) and `Invoke-ScriptAnalyzer` (linting), +then surfaces any residual findings back to Claude as advisory context. + +It uses **your repository's own analyzer settings**. It ships no rules of its own +and runs only when your repo has opted into a `PSScriptAnalyzerSettings.psd1`. + +## Behavior + +- **Opt-in on a settings file.** PSScriptAnalyzer runs **only when a + `PSScriptAnalyzerSettings.psd1` governs the edited file**, found by walking up + from the file to the repository root and stopping at the closest one. Unlike + some formatters, PSScriptAnalyzer does not auto-discover its settings — + `Invoke-Formatter` and `Invoke-ScriptAnalyzer` take an explicit settings path — + so the hook both gates on that file and passes it through. A repo without a + settings file is left untouched rather than formatted and linted with + PSScriptAnalyzer's built-in defaults, so the plugin never imposes a style you + did not choose. +- **Format on edit.** `Invoke-Formatter` applies your settings' formatting rules + (indentation, alias expansion, brace placement, and so on) in place. +- **Findings are advisory.** Semantic diagnostics your settings enable (for + example `PSAvoidGlobalVars`) are *reported* but never auto-applied. +- **Advisory, never blocking.** The hook always exits `0`. Findings are reported + via `additionalContext`; they never reject the edit. Make a commit hook or CI + your hard gate. +- **Graceful degrade.** If PowerShell (`pwsh`) is not installed, or the + PSScriptAnalyzer module is not available, the hook is a clean silent no-op — + no error spam. `pwsh` is resolved from `PATH` and is never downloaded. + +## Requirements + +- **PowerShell** (`pwsh`) on `PATH` — PowerShell 7+ (cross-platform) or Windows + PowerShell. If absent, the hook is a silent no-op. +- The **PSScriptAnalyzer** module installed + (`Install-Module PSScriptAnalyzer`). If absent, the hook is a silent no-op. +- A **`PSScriptAnalyzerSettings.psd1`** in your repo — the opt-in. + +The hook itself runs on Bash 3.2+. Telemetry timing uses `EPOCHREALTIME` +(Bash 5.0+); on older bash the telemetry envelope is skipped while formatting and +linting still run. + +## Install + +```shell +/plugin marketplace add melodic-software/claude-code-plugins +/plugin install powershell-format@melodic-software +``` + +## Configuration + +This plugin has no `userConfig` — its only "configuration" is the +`PSScriptAnalyzerSettings.psd1` already in your repository, which it reads +automatically. To change the rules, edit that file. + +### Disable without uninstalling + +Set the kill switch in your settings `env` block: + +```json +{ "env": { "HOOK_POWERSHELL_FORMAT_ENABLED": "false" } } +``` + +## License + +[MIT](../../LICENSE). diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh new file mode 100644 index 000000000..1c003435a --- /dev/null +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -0,0 +1,247 @@ +# shellcheck shell=bash +# Shared hook utility library for this marketplace's hook plugins. Sourced +# (not executed): kill switch, file_path parsing + path normalization, +# repo-root resolution, additionalContext accumulator, telemetry envelope. +# +# SINGLE SOURCE OF TRUTH: lib/hook-utils.sh at the marketplace repo root. The +# copies at plugins/*/hooks/hook-utils.sh exist because installed plugins are +# cache-isolated and must be self-contained — never edit a copy. Edit the +# source and run scripts/sync-hook-utils.sh; CI rejects drifted copies. + +# Guard against double-sourcing. +[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0 +readonly _HOOK_UTILS_LOADED=1 + +# Per-hook kill switch via HOOK__ENABLED env var. Exits 0 (allow) if +# disabled. Place after source, before stdin parsing. +# hook::check_enabled "MARKDOWN_FORMAT" # checks HOOK_MARKDOWN_FORMAT_ENABLED +hook::check_enabled() { + local var_name="HOOK_${1}_ENABLED" + if [[ "${!var_name:-true}" != "true" ]]; then + exit 0 + fi +} + +# Normalize a path for the membership comparison below: backslashes → forward +# slashes, and — only on Windows/MSYS, whose filesystem is case-insensitive — +# fold a leading drive (POSIX `/c/...` or `c:/...`) to an upper-case drive +# letter + lower-cased remainder so the byte-exact comparison is effectively +# case-insensitive. The fold is gated on the host (OSTYPE), NOT on the path +# shape: on a case-sensitive POSIX filesystem a real single-letter top-level +# directory such as `/c/Repo` must pass through unchanged, otherwise it would +# collapse with `/c/repo` and the membership guard would admit a sibling +# outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the +# emitted path is always the caller's original. +hook::normalize_path() { + local p="${1//\\//}" + case "${OSTYPE:-}" in + msys* | cygwin* | win32) + if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then + local rest="${p:2}" + printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" + return + fi + ;; + *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below + esac + printf '%s' "$p" +} + +# Canonicalize to a physical path — symlinks resolved — for the membership +# comparison below, so an in-project symlink pointing outside the project root +# cannot defeat the guard (the lexical path would pass the prefix check while +# the write lands elsewhere). GNU realpath ships with Git Bash and Linux +# coreutils; readlink -f covers the BSD/macOS hosts that have no realpath. +# When neither resolver exists the caller falls back to comparing the lexical +# path as before — the guard is defense-in-depth scoping for a file the agent +# already wrote via its own tools, so degrading to the historical comparison +# beats silently disabling the hook on those hosts. +hook::physical_path() { + local resolved + if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then + if [[ -n "$resolved" ]]; then + printf '%s' "$resolved" + return + fi + fi + printf '%s' "$1" +} + +# Parse file_path from PostToolUse JSON on stdin; validate existence and (when +# CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership +# comparison are canonicalized (symlinks resolved) first, so neither an +# escaping symlink nor a project root reached via a symlinked path (e.g. +# macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. +# FILE=$(hook::read_file_path) || exit 0 +hook::read_file_path() { + local file + file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + [[ -n "$file" ]] || return 1 + [[ -f "$file" ]] || return 1 + if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + local norm_file norm_project + norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") + norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + norm_project="${norm_project%/}" + # Anchor on a path-segment boundary: accept the project root itself or a + # child under it, but not a sibling whose name merely shares the prefix + # (e.g. /c/repo must not admit /c/repo-backup/...). + if [[ "$norm_file" != "$norm_project" && "$norm_file" != "$norm_project"/* ]]; then + return 1 + fi + fi + printf '%s' "$file" +} + +# Resolve the repository root (working-tree top) for a path inside the tree. +# markdownlint config auto-discovery is CWD-anchored, so the hook cd's here +# before linting. File-anchored (`git -C "$hint" rev-parse --show-toplevel`) +# so it is correct for clones, linked worktrees, and bare-hub clones; falls +# back to the hint (with a trailing /.claude stripped) when git cannot resolve. +# ROOT=$(hook::repo_root "$some_path") +hook::repo_root() { + local hint="${1:-.}" + local root + root=$(git -C "$hint" rev-parse --show-toplevel 2>/dev/null | tr -d '\r') + if [[ -z "$root" ]]; then + root="$hint" + root="${root%/.claude}" + root="${root%\\.claude}" + fi + printf '%s' "$root" +} + +# Per-hook stdout context accumulator. ctx_reset at entry, ctx_append per line, +# ctx_flush once at exit with the hook event name. +_HOOK_CTX_BUFFER="" + +hook::ctx_reset() { + _HOOK_CTX_BUFFER="" +} + +hook::ctx_append() { + _HOOK_CTX_BUFFER+="$1"$'\n' +} + +# Emit the accumulated context as hookSpecificOutput JSON, then clear the buffer. +hook::ctx_flush() { + local event_name="$1" + local trimmed="${_HOOK_CTX_BUFFER%"${_HOOK_CTX_BUFFER##*[![:space:]]}"}" + trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}" + hook::emit_additional_context "$event_name" "$trimmed" + hook::ctx_reset +} + +# Cheap telemetry opt-in probe — true iff a consumer wired a sink. Producers +# gate telemetry-payload construction on this (repo-relative path +# normalization, data JSON) so the unwired default path spawns zero +# telemetry-only subprocesses. Pure shell test, no subprocess. +# hook::emit_telemetry re-checks the sink itself, so skipping this probe +# costs only wasted payload work, never correctness. +hook::telemetry_enabled() { + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] +} + +# Emit one telemetry envelope per hook run to the consumer-set sink. +# Fire-and-forget: sink is dispatched in the background; the hook never waits +# on it and its failure never affects the hook's own exit code or stdout. +# Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. +# Fail-open: jq absent → return 0 immediately. +# +# Usage: +# hook::emit_telemetry [repo_root] +# +# Value of $EPOCHREALTIME captured by the caller before work began. +# Handles both '.' and ',' as the decimal separator (LC_NUMERIC). +# Pre-built JSON object for the `data` field. +# Optional consuming-repo root, used to resolve a RELATIVE +# HOOK_TELEMETRY_SINK. The caller passes the root it already +# resolved for data.file; ignored when the sink is absolute. +# +# Sink path resolution: HOOK_TELEMETRY_SINK may be absolute OR relative to the +# consuming repo root. Absolute (POSIX /… or Windows X:\ / X:/) is used as-is; a +# relative value is joined onto (or $CLAUDE_PROJECT_DIR when no root +# is passed), and skipped fail-open if neither is available. Relative is the +# portable, team-shared wiring form: CC injects settings.json env values +# literally (no ${VAR} expansion), so a relative path tracked in settings.json is +# the only clone-portable, worktree-safe option. +# +# NEVER writes to fd1 (the hook's stdout / additionalContext channel). +hook::emit_telemetry() { + # Opt-in guard. + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 + # Fail-open when jq is absent. + command -v jq >/dev/null 2>&1 || return 0 + + local hook_id="$1" + local hook_event="$2" + local status="$3" + local start_epoch="$4" + local data_json="$5" + local repo_root="${6:-}" + + # Compute duration_ms from caller's $EPOCHREALTIME snapshot to now. + # Both '.' and ',' separators handled; 10# prefix prevents octal misreading + # of fractional parts with leading zeros (e.g. .045123 → 10#045123 = 45123). + local now=$EPOCHREALTIME + local s_s="${start_epoch%[.,]*}" s_f="${start_epoch#*[.,]}" + local e_s="${now%[.,]*}" e_f="${now#*[.,]}" + local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) + + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). + local timestamp + timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) + + # Build the envelope. Redirect jq stderr to /dev/null; output goes to a local + # variable — never to fd1. + local envelope + envelope=$(jq -n \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --argjson data "$data_json" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:$data}' \ + 2>/dev/null) || return 0 + + # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the + # consuming repo root (portable, tracked wiring); absolute is used as-is. A + # relative value with no anchor is skipped fail-open — never exec a path the + # drifted hook CWD would resolve incorrectly. + local sink="$HOOK_TELEMETRY_SINK" + case "$sink" in + /* | [A-Za-z]:[/\\]*) ;; + *) + local root="${repo_root:-${CLAUDE_PROJECT_DIR:-}}" + [[ -n "$root" ]] || return 0 + sink="${root%/}/$sink" + ;; + esac + + # Fire-and-forget: pipe the envelope to the sink in a background subshell. + # The subshell's stdout AND stderr are redirected to /dev/null so the sink + # cannot write to the hook's fd1 (the additionalContext channel) and the + # backgrounded subshell does not hold a copy of the hook's fd1 open — which + # would block any command substitution wrapping the hook until the sink exits + # (the "C1 fd1-inheritance blocker"). The sink is quoted — it is a single + # executable path (wrap in a script to pass arguments). + printf '%s\n' "$envelope" | ("$sink" >/dev/null 2>&1) & +} + +# Print cross-host hook JSON to stdout (exit 0). No-op when context is empty. +# Shape: { hookSpecificOutput: { hookEventName[, additionalContext] } }. +hook::emit_additional_context() { + local event_name="$1" + local context="$2" + [[ -n "$context" ]] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + jq -n \ + --arg event "$event_name" \ + --arg ctx "$context" \ + '{hookSpecificOutput: ( + {hookEventName: $event} + + (if $ctx != "" then {additionalContext: $ctx} else {} end) + )}' +} diff --git a/plugins/powershell-format/hooks/hooks.json b/plugins/powershell-format/hooks/hooks.json new file mode 100644 index 000000000..0820cce7c --- /dev/null +++ b/plugins/powershell-format/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/powershell-format.sh", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/powershell-format/hooks/powershell-format.sh b/plugins/powershell-format/hooks/powershell-format.sh new file mode 100755 index 000000000..4af760e40 --- /dev/null +++ b/plugins/powershell-format/hooks/powershell-format.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# PostToolUse hook: auto-format and lint PowerShell files via PSScriptAnalyzer. +# Triggered on Write|Edit of *.ps1, *.psm1, and *.psd1 files. +# +# ADVISORY: always exits 0. Invoke-Formatter applies the consumer's formatting +# in place; PSScriptAnalyzer findings surface via additionalContext but never +# block the edit. A commit hook or CI is the hard gate. +# +# Opt-in: PSScriptAnalyzer runs ONLY when a PSScriptAnalyzerSettings.psd1 governs +# the edited file — found by walking up from the file to the repo root, stopping +# at the closest one. PSScriptAnalyzer does not auto-discover a settings file +# (Invoke-Formatter / Invoke-ScriptAnalyzer take an explicit -Settings path), so +# the hook both gates on that file and passes it through. A repo that has not +# adopted a settings file is left untouched rather than formatted and linted with +# PSScriptAnalyzer's built-in defaults, so the plugin never imposes a style it did +# not choose. +# +# Graceful degrade: pwsh absent (pwsh-less contributor box, Linux cloud session +# without PowerShell) OR the PSScriptAnalyzer module not installed -> clean silent +# no-op (exit 0, no error spam). pwsh is resolved from PATH — never downloaded. + +set -uo pipefail + +# Read inherited fd0 directly (bare cat) — NEVER ` silent no-op). stdin is read ONCE here and fed to both +# hook::read_file_path (file_path) and the tool_name parse below; reading fd0 +# twice would drain the pipe on the second call. +# shellcheck source=hook-utils.sh +source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" + +hook::check_enabled "POWERSHELL_FORMAT" + +# Capture $EPOCHREALTIME immediately after kill-switch so duration_ms covers the +# work below (pre-work exits do not emit telemetry). EPOCHREALTIME is Bash 5.0+; +# on older bash it is unset, so default to empty — referencing it bare under +# `set -u` would abort before the advisory exit 0, failing every edit. +start=${EPOCHREALTIME:-} + +# Telemetry needs the high-res start stamp. When EPOCHREALTIME is unavailable +# (Bash < 5.0) the stamp is empty and telemetry is skipped, so the hook still +# formats and lints on older bash rather than aborting. +emit_tel() { + [[ -n "$start" ]] || return 0 + hook::emit_telemetry "$@" +} + +INPUT=$(cat) + +FILE=$(printf '%s' "$INPUT" | hook::read_file_path) || exit 0 +case "$FILE" in +*.ps1 | *.psm1 | *.psd1) ;; +*) exit 0 ;; +esac + +TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) + +# Resolve repo root early — used to bound the settings opt-in walk and to compute +# the schema-required repo-relative path in data.file. +REPO_ROOT="$(hook::repo_root "$(dirname "$FILE")")" +# Repo-relative path: schema requires "relative to the consuming repo root". +# On Windows Git Bash, git rev-parse --show-toplevel returns a drive-letter path +# while FILE may be in POSIX mount form. Normalize both through cygpath -lm +# (long name, forward-slash mixed form) when available so the prefix strip +# compares the same representation. On Linux/macOS, cygpath is absent and both +# paths are already POSIX. Falls back to raw FILE on any normalization error. +FILE_REL="$FILE" +if command -v cygpath >/dev/null 2>&1; then + _file_lm=$(cygpath -lm "$FILE" 2>/dev/null) + _root_lm=$(cygpath -lm "$REPO_ROOT" 2>/dev/null) + if [[ -n "$_file_lm" && -n "$_root_lm" ]]; then + FILE_REL="${_file_lm#"$_root_lm"/}" + fi +else + FILE_REL="${FILE#"$REPO_ROOT"/}" +fi + +# Build the telemetry data object for the current TOOL/FILE_REL. $1 is the +# findings JSON array. jq is authoritative. The fallback is a fixed empty-shape +# object — NOT an interpolation of TOOL/FILE_REL, which could inject quotes or +# backslashes from a path and corrupt the envelope. The fallback is essentially +# unreachable in practice (it fires only if `jq -n` fails, and when jq is absent +# hook::emit_telemetry drops the envelope anyway), so losing the values here is +# harmless and strictly safer than emitting malformed JSON. +build_data_json() { + jq -n \ + --arg tool "$TOOL" \ + --arg file "$FILE_REL" \ + --argjson findings "$1" \ + '{tool:$tool,file:$file,findings:$findings}' 2>/dev/null || + printf '{"tool":"","file":"","findings":[]}' +} + +emit_skipped() { + local data_json + data_json=$(build_data_json '[]') + emit_tel "powershell-format" "PostToolUse" "skipped" "$start" "$data_json" "$REPO_ROOT" + exit 0 +} + +# Resolve the file's directory in `pwd` form once — anchors the settings walk. +FILE_DIR_POSIX="$(cd "$(dirname "$FILE")" 2>/dev/null && pwd)" || FILE_DIR_POSIX="" +root="$(cd "$REPO_ROOT" 2>/dev/null && pwd)" || root="" + +# Consumer opt-in: a PSScriptAnalyzerSettings.psd1 that governs the edited file. +# Walk up from the file's directory to the repo root, stopping at the FIRST +# (closest) settings file — a monorepo may keep per-module settings, and the +# closest one is the one that should govern this file. Absence of any settings +# file is the opt-out: the file is left untouched. +SETTINGS_FOUND="" +dir="$FILE_DIR_POSIX" +while [[ -n "$dir" ]]; do + if [[ -f "$dir/PSScriptAnalyzerSettings.psd1" ]]; then + SETTINGS_FOUND="$dir/PSScriptAnalyzerSettings.psd1" + break + fi + [[ -n "$root" && "$dir" == "$root" ]] && break + parent="$(dirname "$dir")" + [[ "$parent" == "$dir" ]] && break # reached filesystem root + dir="$parent" +done + +[[ -n "$SETTINGS_FOUND" ]] || emit_skipped + +# Resolve pwsh from PATH — never downloaded. Absent -> clean skip (a pwsh-less +# contributor box, or a Linux cloud session without PowerShell). CI's PowerShell +# job is the authoritative PSScriptAnalyzer gate, so nothing is lost locally. +command -v pwsh >/dev/null 2>&1 || emit_skipped + +# PowerShell on Windows does not understand MSYS mount paths (/d/...). Convert +# both the file and the settings path to a mixed drive-letter form (D:/...) that +# pwsh consumes, when cygpath is available (Git Bash on Windows); on Linux/macOS +# cygpath is absent and the POSIX paths pwsh already understands pass through. +to_pwsh_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -m "$1" 2>/dev/null || printf '%s' "$1" + else + printf '%s' "$1" + fi +} +PSSA_FILE_ARG="$(to_pwsh_path "$FILE")" +PSSA_SETTINGS_ARG="$(to_pwsh_path "$SETTINGS_FOUND")" + +# Single pwsh invocation — probe the module, format in place, then lint. File +# and settings pass via env vars to avoid pwsh argument parsing issues. +# exit 3 PSScriptAnalyzer module not installed -> clean skip (no findings) +# exit 0 clean +# exit 1 findings (written to stderr, one line each) +# exit 4 Invoke-Formatter / Invoke-ScriptAnalyzer threw -> tool break +# SC2016: PowerShell uses $env:VAR syntax inside single quotes — not bash expansion. +# shellcheck disable=SC2016 +PSSA_OUTPUT=$(PSSA_FILE="$PSSA_FILE_ARG" PSSA_SETTINGS="$PSSA_SETTINGS_ARG" \ + pwsh -NoProfile -NonInteractive -Command ' + if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { + exit 3 + } + try { + $file = $env:PSSA_FILE + $settings = $env:PSSA_SETTINGS + + $original = Get-Content -Raw -LiteralPath $file + if ($null -eq $original) { $original = "" } + $formatted = Invoke-Formatter -ScriptDefinition $original -Settings $settings + # -cne (case-SENSITIVE) is load-bearing: PowerShell string -ne is + # case-insensitive, so a casing-only reformat (get-childitem -> + # Get-ChildItem via PSUseCorrectCasing) would compare equal and the fix + # would never be written back. + if ($formatted -cne $original) { + if (-not $formatted.EndsWith("`n")) { + $formatted += "`n" + } + Set-Content -LiteralPath $file -Value $formatted -NoNewline + } + + $results = @(Invoke-ScriptAnalyzer -Path $file -Settings $settings) + if ($results.Count -gt 0) { + foreach ($r in $results) { + [Console]::Error.WriteLine( + "PSScriptAnalyzer: L$($r.Line) [$($r.Severity)] $($r.RuleName): $($r.Message)" + ) + } + exit 1 + } + } catch { + [Console]::Error.WriteLine("powershell-format: $($_.Exception.Message)") + exit 4 + } + exit 0 +' 2>&1) +PWSH_EXIT=$? + +case $PWSH_EXIT in +0) + # Clean — the analyzer ran to judgment with no findings. + data_json=$(build_data_json '[]') + emit_tel "powershell-format" "PostToolUse" "ok" "$start" "$data_json" "$REPO_ROOT" + exit 0 + ;; +1) + # Findings — advisory context, exit 0. Status "ok": the analyzer RAN and + # produced a judgment (findings live in data.findings), mirroring the sibling + # formatter plugins where status reflects whether the tool ran, not clean-ness. + hook::ctx_reset + hook::ctx_append "powershell-format: $(basename "$FILE") has PSScriptAnalyzer findings (advisory):" + findings_raw="" + while IFS= read -r line; do + [[ -n "$line" ]] || continue + hook::ctx_append " $line" + findings_raw+="$line"$'\n' + done <<<"$PSSA_OUTPUT" + hook::ctx_flush PostToolUse + + FINDINGS_JSON='[]' + if [[ -n "$findings_raw" ]]; then + FINDINGS_JSON=$(printf '%s' "$findings_raw" | jq -R . | jq -s . 2>/dev/null) || FINDINGS_JSON='[]' + fi + data_json=$(build_data_json "$FINDINGS_JSON") + emit_tel "powershell-format" "PostToolUse" "ok" "$start" "$data_json" "$REPO_ROOT" + exit 0 + ;; +3) + # PSScriptAnalyzer module not installed — the repo opted into a settings file + # but the analyzer is not present; nothing to run. Clean silent skip, the same + # status as the no-settings / no-pwsh paths. + emit_skipped + ;; +*) + # pwsh threw for non-lint reasons (bad settings file, internal error) — no + # judgment was made. Surface via additionalContext (NOT stderr — an advisory + # hook's exit-0 stderr can trip a false "Hook Error" label). Record as + # "skipped" (the analyzer never ran to judgment). + hook::ctx_reset + hook::ctx_append "powershell-format: pwsh failed for $(basename "$FILE") (no diagnostics; tool break, not a finding):" + while IFS= read -r line; do + [[ -n "$line" ]] || continue + hook::ctx_append " $line" + done <<<"$PSSA_OUTPUT" + hook::ctx_flush PostToolUse + data_json=$(build_data_json '[]') + emit_tel "powershell-format" "PostToolUse" "skipped" "$start" "$data_json" "$REPO_ROOT" + exit 0 + ;; +esac diff --git a/plugins/powershell-format/hooks/powershell-format.test.sh b/plugins/powershell-format/hooks/powershell-format.test.sh new file mode 100755 index 000000000..9df603fdf --- /dev/null +++ b/plugins/powershell-format/hooks/powershell-format.test.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +# Black-box contract test for powershell-format.sh (the powershell-format plugin hook). +# +# Proves WIRING: the hook fires on .ps1/.psm1/.psd1, skips otherwise, applies +# PSScriptAnalyzer's formatting (alias expansion), surfaces residual findings via +# additionalContext (advisory, exit 0), honors the kill switch, gates on a +# consumer PSScriptAnalyzerSettings.psd1 (present -> run, absent -> leave bytes +# untouched), degrades cleanly when the PSScriptAnalyzer module is unavailable, +# and emits a schema-valid telemetry envelope. +# +# Self-contained: builds throwaway git repos with runtime-generated fixtures. The +# hook is invoked from an UNRELATED cwd so any reliance on the caller's working +# directory would surface (settings discovery is file-anchored). +# +# Requires a real pwsh with the PSScriptAnalyzer module. Without pwsh the whole +# suite skips; the pwsh-absent and module-absent GRACEFUL-DEGRADE paths are +# exercised separately by simulating their absence, so they run even where a real +# pwsh + module is present. + +set -uo pipefail + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$HOOK_DIR/powershell-format.sh" + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +WORK="$(mktemp -d)" +UNRELATED="$(mktemp -d)" +cleanup() { rm -rf "$WORK" "$UNRELATED"; } +trap cleanup EXIT + +# Settings that enable a formatter-applied rule (PSUseCorrectCasing, which +# Invoke-Formatter rewrites in place) and a semantic lint rule (PSAvoidGlobalVars, +# which the formatter never rewrites, so it survives to surface as a finding). +SETTINGS_BODY="@{ + IncludeRules = @('PSUseCorrectCasing','PSAvoidGlobalVars','PSUseConsistentIndentation') + Rules = @{ + PSUseCorrectCasing = @{ Enable = \$true } + PSUseConsistentIndentation = @{ Enable = \$true; IndentationSize = 4 } + } +}" + +# new_repo [NO_SETTINGS] -> init a git repo, writing PSScriptAnalyzerSettings.psd1 +# at the root unless the second arg is the literal NO_SETTINGS. +new_repo() { + local r="$1" mode="${2:-}" + mkdir -p "$r" + git -C "$r" init -q + git -C "$r" config user.email t@t.t + git -C "$r" config user.name t + if [[ "$mode" != "NO_SETTINGS" ]]; then + printf '%s\n' "$SETTINGS_BODY" >"$r/PSScriptAnalyzerSettings.psd1" + fi +} + +# Invoke the hook from an unrelated cwd. CLAUDE_PROJECT_DIR is left UNSET so +# read_file_path's membership guard is disabled (not part of the fire gate). +run_hook() { + local file_path="$1" + ( + cd "$UNRELATED" || return 1 + printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$file_path" | + env -u CLAUDE_PROJECT_DIR HOOK_POWERSHELL_FORMAT_ENABLED=true bash "$HOOK" + ) +} + +# Same as run_hook but with caller-supplied extra env (NAME=VALUE or -u NAME ...). +run_hook_env() { + local file_path="$1" + shift + ( + cd "$UNRELATED" || return 1 + printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$file_path" | + env -u CLAUDE_PROJECT_DIR "$@" bash "$HOOK" + ) +} + +# make_sink -> path to an executable single-command stub sink running +# (which reads the envelope on stdin). HOOK_TELEMETRY_SINK must be a +# single executable path, not a command-with-args, so tests point it at a stub. +make_sink() { + local s + s="$(mktemp -p "$WORK" sink.XXXXXX)" + { + printf '#!/usr/bin/env bash\n' + printf '%s\n' "$1" + } >"$s" + chmod +x "$s" + printf '%s' "$s" +} + +# wait_for_sink [tries] -> block until is non-empty (the +# fire-and-forget sink flushed) or the bound elapses, polling in 20ms steps. +wait_for_sink() { + local f="$1" tries="${2:-150}" + while ((tries-- > 0)); do + [[ -s "$f" ]] && return 0 + sleep 0.02 + done + return 1 +} + +# ============================================================================ +# GRACEFUL DEGRADE — run regardless of pwsh/module presence +# ============================================================================ + +# --- pwsh absent -> clean silent skip ---------------------------------------- +# Simulate a pwsh-less box by dropping pwsh-containing dirs from PATH while +# retaining jq/git/dirname. Works where pwsh has its own dir (Windows: Program +# Files\PowerShell); on a runner where pwsh shares /usr/bin with coreutils the +# drop would also lose jq, so that case is skipped. +_pwsh_free_usable() { + PATH="$1" jq --version >/dev/null 2>&1 && + PATH="$1" git --version >/dev/null 2>&1 && + PATH="$1" dirname / >/dev/null 2>&1 && + ! PATH="$1" command -v pwsh >/dev/null 2>&1 +} +_filtered="" +IFS=':' read -ra _path_dirs <<<"$PATH" +for _d in "${_path_dirs[@]}"; do + [[ -x "$_d/pwsh" || -x "$_d/pwsh.exe" ]] && continue + _filtered="${_filtered:+$_filtered:}$_d" +done +REPO_ABSENT="$WORK/pwsh-absent" +new_repo "$REPO_ABSENT" +printf "%s\n" "gci -Path '.'" >"$REPO_ABSENT/a.ps1" +BEFORE_ABSENT="$(cat "$REPO_ABSENT/a.ps1")" +if _pwsh_free_usable "$_filtered"; then + OUT=$(run_hook_env "$REPO_ABSENT/a.ps1" HOOK_POWERSHELL_FORMAT_ENABLED=true PATH="$_filtered") + RC=$? + if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "pwsh absent -> exit 0, silent (clean skip)"; else fail "pwsh absent not silent (rc=$RC out=$OUT)"; fi + if [[ "$(cat "$REPO_ABSENT/a.ps1")" == "$BEFORE_ABSENT" ]]; then ok "pwsh absent -> file left untouched"; else fail "pwsh absent -> file was rewritten"; fi +else + echo "SKIP: could not assemble a pwsh-free PATH retaining jq/git/dirname" +fi + +# --- exit-code mappings via a stub pwsh (module-absent + tool-break) --------- +# The pwsh block signals PSScriptAnalyzer-module-absent as exit 3 and an +# analyzer throw (bad settings, internal error) as exit 4. A real installed +# module cannot be hidden on Windows (PSScriptAnalyzer ships in $PSHOME and +# PSModulePath cannot drop it), so a stub pwsh emitting those exits proves the +# hook's bash-side mapping deterministically — and runs even where no real pwsh +# is present. The stub takes precedence via a prepended PATH entry. +STUB_BIN="$WORK/stub-bin" +mkdir -p "$STUB_BIN" +make_stub_pwsh() { + { + printf '#!/usr/bin/env bash\n' + printf '%s\n' "$1" + } >"$STUB_BIN/pwsh" + chmod +x "$STUB_BIN/pwsh" +} +REPO_STUB="$WORK/stub-repo" +new_repo "$REPO_STUB" +printf "%s\n" "Get-ChildItem -Path '.'" >"$REPO_STUB/s.ps1" +BEFORE_STUB="$(cat "$REPO_STUB/s.ps1")" + +# exit 3 -> module absent -> clean silent skip +make_stub_pwsh 'exit 3' +OUT=$(run_hook_env "$REPO_STUB/s.ps1" HOOK_POWERSHELL_FORMAT_ENABLED=true PATH="$STUB_BIN:$PATH") +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "module absent (pwsh exit 3) -> exit 0, silent (clean skip)"; else fail "module absent not silent (rc=$RC out=$OUT)"; fi +if [[ "$(cat "$REPO_STUB/s.ps1")" == "$BEFORE_STUB" ]]; then ok "module absent -> file left untouched"; else fail "module absent -> file was rewritten"; fi + +# exit 4 (+ stderr) -> analyzer threw -> advisory tool-break context, exit 0 +make_stub_pwsh 'echo "boom: bad settings" >&2; exit 4' +OUT=$(run_hook_env "$REPO_STUB/s.ps1" HOOK_POWERSHELL_FORMAT_ENABLED=true PATH="$STUB_BIN:$PATH") +RC=$? +if [[ $RC -eq 0 ]]; then ok "tool break (pwsh exit 4) -> exit 0 (advisory)"; else fail "tool break exit $RC (must be advisory)"; fi +if printf '%s' "$OUT" | jq -e '.hookSpecificOutput.additionalContext' >/dev/null 2>&1; then + CTX=$(printf '%s' "$OUT" | jq -r '.hookSpecificOutput.additionalContext') + if printf '%s' "$CTX" | grep -qi 'tool break' && printf '%s' "$CTX" | grep -q 'boom'; then + ok "tool break -> surfaced as advisory (not a finding)" + else + fail "tool break -> not in tool-break branch: $CTX" + fi +else + fail "tool break -> no additionalContext JSON: $OUT" +fi +rm -f "$STUB_BIN/pwsh" + +# ============================================================================ +# Behavioral cases — require a real pwsh + PSScriptAnalyzer module +# ============================================================================ +if ! command -v pwsh >/dev/null 2>&1; then + echo "SKIP: no pwsh on PATH -- powershell-format behavioral tests skipped" + echo + echo "PASS=$PASS FAIL=$FAIL" + [[ $FAIL -eq 0 ]] + exit $? +fi +if ! pwsh -NoProfile -NonInteractive -Command 'if (Get-Module -ListAvailable -Name PSScriptAnalyzer) { exit 0 } else { exit 1 }' >/dev/null 2>&1; then + echo "SKIP: PSScriptAnalyzer module not installed -- powershell-format behavioral tests skipped" + echo + echo "PASS=$PASS FAIL=$FAIL" + [[ $FAIL -eq 0 ]] + exit $? +fi + +# --- Case 1: opt-in gate OFF (no settings) -> file left untouched ------------- +REPO_NO="$WORK/no-settings" +new_repo "$REPO_NO" NO_SETTINGS +printf "%s\n" "gci -Path '.'" >"$REPO_NO/nofmt.ps1" +BEFORE_NO="$(cat "$REPO_NO/nofmt.ps1")" +OUT=$(run_hook "$REPO_NO/nofmt.ps1") +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "gate OFF (no settings) -> exit 0, silent"; else fail "gate OFF not silent (rc=$RC out=$OUT)"; fi +if [[ "$(cat "$REPO_NO/nofmt.ps1")" == "$BEFORE_NO" ]]; then ok "gate OFF -> file left untouched"; else fail "gate OFF -> file was rewritten"; fi + +# --- Case 2: gate ON + clean file -> exit 0, empty stdout -------------------- +REPO="$WORK/consumer" +new_repo "$REPO" +printf "%s\n" "Get-ChildItem -Path '.'" >"$REPO/clean.ps1" +OUT=$(run_hook "$REPO/clean.ps1") +RC=$? +if [[ $RC -eq 0 ]]; then ok "clean .ps1 -> exit 0"; else fail "clean .ps1 exit $RC"; fi +if [[ -z "$OUT" ]]; then ok "clean .ps1 -> empty stdout"; else fail "clean .ps1 stdout not empty: $OUT"; fi + +# --- Case 3: gate ON + wrong casing -> formatter fixes it in place ------------ +# PSUseCorrectCasing is applied by Invoke-Formatter (unlike alias expansion, +# which is a lint-only rule). The subdir file exercises the settings walk. +mkdir -p "$REPO/src" +printf "%s\n" "get-childitem -Path '.'" >"$REPO/src/fmt.ps1" +OUT=$(run_hook "$REPO/src/fmt.ps1") +RC=$? +if [[ $RC -eq 0 ]]; then ok "format case -> exit 0 (advisory)"; else fail "format case exit $RC"; fi +if grep -q 'Get-ChildItem' "$REPO/src/fmt.ps1"; then + ok "gate ON (subdir file) -> formatter fixed the casing in place" +else + fail "gate ON -> casing not fixed: $(cat "$REPO/src/fmt.ps1")" +fi + +# --- Case 4: gate ON + lint finding (global var) -> advisory context --------- +mkdir -p "$REPO/lib" +# SC2016: literal PowerShell variable syntax — single quotes are intentional. +# shellcheck disable=SC2016 +printf '%s\n' '$global:foo = 1' >"$REPO/lib/lint.ps1" +OUT=$(run_hook "$REPO/lib/lint.ps1") +RC=$? +if [[ $RC -eq 0 ]]; then ok "lint finding -> exit 0 (advisory)"; else fail "lint finding exit $RC (must be advisory)"; fi +if printf '%s' "$OUT" | jq -e '.hookSpecificOutput.additionalContext' >/dev/null 2>&1; then + CTX=$(printf '%s' "$OUT" | jq -r '.hookSpecificOutput.additionalContext') + if printf '%s' "$CTX" | grep -q 'PSAvoidGlobalVars'; then + ok "lint finding -> surfaced in additionalContext" + else + fail "lint ctx missing the finding: $CTX" + fi +else + fail "lint finding -> no additionalContext JSON: $OUT" +fi + +# --- Case 5: .psm1 and .psd1 extensions match -------------------------------- +printf "%s\n%s\n%s\n" "function Get-Foo {" " Write-Output 'x'" "}" >"$REPO/mod.psm1" +OUT=$(run_hook "$REPO/mod.psm1") +RC=$? +if [[ $RC -eq 0 ]]; then ok ".psm1 -> exit 0 (glob matches)"; else fail ".psm1 exit $RC"; fi +printf "%s\n%s\n%s\n" "@{" " Severity = @('Error')" "}" >"$REPO/data.psd1" +OUT=$(run_hook "$REPO/data.psd1") +RC=$? +if [[ $RC -eq 0 ]]; then ok ".psd1 -> exit 0 (glob matches)"; else fail ".psd1 exit $RC"; fi + +# --- Case 6: non-matching extension -> exit 0 silently ----------------------- +echo "console.log('x')" >"$REPO/notes.js" +OUT=$(run_hook "$REPO/notes.js") +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "non-matching ext -> exit 0 silent"; else fail "non-matching ext not skipped (rc=$RC out=$OUT)"; fi + +# --- Case 7: kill switch bypasses hook --------------------------------------- +printf "%s\n" "gci -Path '.'" >"$REPO/kill.ps1" +BEFORE_K="$(cat "$REPO/kill.ps1")" +OUT=$(run_hook_env "$REPO/kill.ps1" HOOK_POWERSHELL_FORMAT_ENABLED=false) +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "kill switch off -> exit 0 silent"; else fail "kill switch failed (rc=$RC out=$OUT)"; fi +if [[ "$(cat "$REPO/kill.ps1")" == "$BEFORE_K" ]]; then ok "kill switch -> file untouched"; else fail "kill switch -> file was modified"; fi + +# ============================================================================ +# Telemetry +# ============================================================================ + +# --- Sink unset -> empty stdout, exit 0 (parity) ----------------------------- +OUT_NS=$(run_hook_env "$REPO/clean.ps1" -u HOOK_TELEMETRY_SINK HOOK_POWERSHELL_FORMAT_ENABLED=true) +RC_NS=$? +if [[ $RC_NS -eq 0 && -z "$OUT_NS" ]]; then + ok "telemetry/sink-unset: exit 0, empty stdout (parity)" +else + fail "telemetry/sink-unset: rc=$RC_NS out=$OUT_NS" +fi + +# --- Stub sink + lint finding -> envelope status ok with findings ------------ +# SC2016: literal PowerShell variable syntax — single quotes are intentional. +# shellcheck disable=SC2016 +printf '%s\n' '$global:tel = 1' >"$REPO/tel.ps1" +TEL="$(mktemp)" +SINK="$(make_sink "cat >\"$TEL\"")" +run_hook_env "$REPO/tel.ps1" HOOK_POWERSHELL_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$SINK" >/dev/null +wait_for_sink "$TEL" +if [[ -s "$TEL" ]]; then + ok "telemetry/stub-sink: envelope received" + for field in schema_version timestamp hook hook_event status duration_ms data; do + if jq -e "has(\"$field\")" "$TEL" >/dev/null 2>&1; then + ok "envelope: $field present" + else + fail "envelope: $field missing ($(cat "$TEL"))" + fi + done + if [[ "$(jq -r '.hook' "$TEL")" == "powershell-format" ]]; then ok "envelope: hook is powershell-format"; else fail "envelope: hook=$(jq -r '.hook' "$TEL")"; fi + if [[ "$(jq -r '.status' "$TEL")" == "ok" ]]; then ok "envelope: status ok"; else fail "envelope: status=$(jq -r '.status' "$TEL")"; fi + if [[ "$(jq -r '.schema_version' "$TEL")" == "1.0" ]]; then ok "envelope: schema_version 1.0"; else fail "envelope: schema_version=$(jq -r '.schema_version' "$TEL")"; fi + if [[ "$(jq '.data.findings | length' "$TEL")" -ge 1 ]]; then ok "envelope: findings populated"; else fail "envelope: findings empty ($(jq '.data.findings' "$TEL"))"; fi + FREL=$(jq -r '.data.file' "$TEL") + if [[ -n "$FREL" && "$FREL" != /* && "$FREL" != ?:* ]]; then ok "envelope: data.file repo-relative ($FREL)"; else fail "envelope: data.file not repo-relative: $FREL"; fi + if jq -e '.duration_ms | type == "number" and . >= 0 and floor == .' "$TEL" >/dev/null 2>&1; then ok "envelope: duration_ms non-negative int"; else fail "envelope: duration_ms invalid ($(jq .duration_ms "$TEL"))"; fi +else + fail "telemetry/stub-sink: no envelope written" +fi +rm -f "$TEL" + +# --- Stub sink + gate OFF (no settings) -> status skipped -------------------- +printf "%s\n" "gci -Path '.'" >"$REPO_NO/tel2.ps1" +TELS="$(mktemp)" +SINKS="$(make_sink "cat >\"$TELS\"")" +run_hook_env "$REPO_NO/tel2.ps1" HOOK_POWERSHELL_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$SINKS" >/dev/null +wait_for_sink "$TELS" +if [[ -s "$TELS" ]]; then + if [[ "$(jq -r '.status' "$TELS")" == "skipped" ]]; then ok "telemetry/gate-off: status skipped"; else fail "telemetry/gate-off: status=$(jq -r '.status' "$TELS")"; fi + if [[ "$(jq '.data.findings | length' "$TELS")" -eq 0 ]]; then ok "telemetry/gate-off: findings empty array"; else fail "telemetry/gate-off: findings not empty"; fi +else + fail "telemetry/gate-off: no envelope written" +fi +rm -f "$TELS" + +echo +echo "PASS=$PASS FAIL=$FAIL" +[[ $FAIL -eq 0 ]] From e56081660d7c89208102ff41388339d8f2dd2b8e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:51:40 -0400 Subject: [PATCH 2/7] fix(powershell-format): bound settings walk to project dir, literal-path analyzer, document settings trust model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security-review follow-up (ACCEPT-WITH-NOTES): - Bound the PSScriptAnalyzerSettings.psd1 walk-up ceiling by CLAUDE_PROJECT_DIR when set, so the settings ceiling matches the file-membership ceiling hook::read_file_path already enforces; the git-root ceiling stays the fallback when unset. A settings file above the project dir can no longer govern an edit. - Analyzer call: Invoke-ScriptAnalyzer has no -LiteralPath (verified against module 1.25.0 — only -Path, which treats [ ] * ? as wildcards). Realize the literal-path intent via [WildcardPattern]::Escape($file) on -Path; a filename containing wildcard metacharacters was otherwise silently skipped (verified: raw -Path on weird[1].ps1 -> 0 findings; escaped -> 1). - README: document the settings-file trust model. A PSScriptAnalyzerSettings.psd1 may declare CustomRulePath modules that PSScriptAnalyzer loads and executes during analysis (Microsoft Learn, "Using PSScriptAnalyzer > Custom rules"), so it is executed-adjacent config — trust it as you trust build/CI config. Contract suite: 39 cases green (adds ceiling-bound + git-root-fallback contrast). --- plugins/powershell-format/README.md | 13 +++++++ .../hooks/powershell-format.sh | 24 ++++++++++-- .../hooks/powershell-format.test.sh | 38 +++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/plugins/powershell-format/README.md b/plugins/powershell-format/README.md index 1b7eb259c..149952ad8 100644 --- a/plugins/powershell-format/README.md +++ b/plugins/powershell-format/README.md @@ -31,6 +31,19 @@ and runs only when your repo has opted into a `PSScriptAnalyzerSettings.psd1`. PSScriptAnalyzer module is not available, the hook is a clean silent no-op — no error spam. `pwsh` is resolved from `PATH` and is never downloaded. +## Trust model + +The opt-in `PSScriptAnalyzerSettings.psd1` is **executed-adjacent configuration**, +not inert data. A settings file may declare a +[`CustomRulePath`](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/using-scriptanalyzer#custom-rules) +pointing at PowerShell rule modules, and PSScriptAnalyzer **loads and runs** those +modules' exported functions during analysis. Treat the settings file — and any +module it references — with the same trust you give your build and CI +configuration: it runs on your machine on every edit. The hook only reads a +settings file at or below your project root (bounded by `CLAUDE_PROJECT_DIR` when +set), so it never picks up one from an ancestor directory outside the project. +Do not enable this plugin against an untrusted working tree. + ## Requirements - **PowerShell** (`pwsh`) on `PATH` — PowerShell 7+ (cross-platform) or Windows diff --git a/plugins/powershell-format/hooks/powershell-format.sh b/plugins/powershell-format/hooks/powershell-format.sh index 4af760e40..bb33e3da1 100755 --- a/plugins/powershell-format/hooks/powershell-format.sh +++ b/plugins/powershell-format/hooks/powershell-format.sh @@ -102,8 +102,20 @@ emit_skipped() { FILE_DIR_POSIX="$(cd "$(dirname "$FILE")" 2>/dev/null && pwd)" || FILE_DIR_POSIX="" root="$(cd "$REPO_ROOT" 2>/dev/null && pwd)" || root="" +# Ceiling for the settings walk-up. When CLAUDE_PROJECT_DIR is set the walk stops +# there, so the settings ceiling matches the file-membership ceiling that +# hook::read_file_path already enforced — a settings file above the project dir +# (which the agent was never allowed to write under) can never govern the edit, +# and a settings file discovered under CustomRulePath is executed during analysis +# (see README "Trust model"). The git-root ceiling is the fallback when unset. +CEILING="$root" +if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + _cpd="$(cd "$CLAUDE_PROJECT_DIR" 2>/dev/null && pwd)" || _cpd="" + [[ -n "$_cpd" ]] && CEILING="$_cpd" +fi + # Consumer opt-in: a PSScriptAnalyzerSettings.psd1 that governs the edited file. -# Walk up from the file's directory to the repo root, stopping at the FIRST +# Walk up from the file's directory to the ceiling, stopping at the FIRST # (closest) settings file — a monorepo may keep per-module settings, and the # closest one is the one that should govern this file. Absence of any settings # file is the opt-out: the file is left untouched. @@ -114,7 +126,7 @@ while [[ -n "$dir" ]]; do SETTINGS_FOUND="$dir/PSScriptAnalyzerSettings.psd1" break fi - [[ -n "$root" && "$dir" == "$root" ]] && break + [[ -n "$CEILING" && "$dir" == "$CEILING" ]] && break parent="$(dirname "$dir")" [[ "$parent" == "$dir" ]] && break # reached filesystem root dir="$parent" @@ -172,7 +184,13 @@ PSSA_OUTPUT=$(PSSA_FILE="$PSSA_FILE_ARG" PSSA_SETTINGS="$PSSA_SETTINGS_ARG" \ Set-Content -LiteralPath $file -Value $formatted -NoNewline } - $results = @(Invoke-ScriptAnalyzer -Path $file -Settings $settings) + # Invoke-ScriptAnalyzer has no -LiteralPath (verified against module + # 1.25.0); -Path treats wildcard metacharacters ([ ] * ?) in a filename + # as a pattern. Escape the path so it is matched literally — the same + # literal-path intent the Get-Content/Set-Content -LiteralPath calls above + # carry, realized via the only mechanism -Path offers. + $litFile = [System.Management.Automation.WildcardPattern]::Escape($file) + $results = @(Invoke-ScriptAnalyzer -Path $litFile -Settings $settings) if ($results.Count -gt 0) { foreach ($r in $results) { [Console]::Error.WriteLine( diff --git a/plugins/powershell-format/hooks/powershell-format.test.sh b/plugins/powershell-format/hooks/powershell-format.test.sh index 9df603fdf..4b95a1a99 100755 --- a/plugins/powershell-format/hooks/powershell-format.test.sh +++ b/plugins/powershell-format/hooks/powershell-format.test.sh @@ -188,6 +188,29 @@ else fi rm -f "$STUB_BIN/pwsh" +# --- settings walk-up bounded by CLAUDE_PROJECT_DIR ceiling ------------------ +# A settings file ABOVE CLAUDE_PROJECT_DIR (but still inside the git repo) must +# NOT govern an edit inside the project — the walk stops at the project-dir +# ceiling, matching the membership guard hook::read_file_path enforces. Runs +# without pwsh: with no settings found within the ceiling, the skip fires before +# the pwsh call. The git-root-fallback contrast (CLAUDE_PROJECT_DIR unset finds +# the same root settings) is Case 1b below, which needs a real pwsh. +REPO_CEIL="$WORK/ceiling" +new_repo "$REPO_CEIL" # settings at git root +mkdir -p "$REPO_CEIL/proj/sub" +# SC2016: literal PowerShell variable syntax — single quotes are intentional. +# shellcheck disable=SC2016 +printf '%s\n' '$global:c = 1' >"$REPO_CEIL/proj/sub/c.ps1" +BEFORE_CEIL="$(cat "$REPO_CEIL/proj/sub/c.ps1")" +OUT=$( + cd "$UNRELATED" || exit 1 + printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO_CEIL/proj/sub/c.ps1" | + CLAUDE_PROJECT_DIR="$REPO_CEIL/proj" HOOK_POWERSHELL_FORMAT_ENABLED=true bash "$HOOK" +) +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "settings above CLAUDE_PROJECT_DIR ceiling -> not found, silent skip"; else fail "ceiling not respected (rc=$RC out=$OUT)"; fi +if [[ "$(cat "$REPO_CEIL/proj/sub/c.ps1")" == "$BEFORE_CEIL" ]]; then ok "ceiling -> file left untouched"; else fail "ceiling -> file was rewritten"; fi + # ============================================================================ # Behavioral cases — require a real pwsh + PSScriptAnalyzer module # ============================================================================ @@ -216,6 +239,21 @@ RC=$? if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "gate OFF (no settings) -> exit 0, silent"; else fail "gate OFF not silent (rc=$RC out=$OUT)"; fi if [[ "$(cat "$REPO_NO/nofmt.ps1")" == "$BEFORE_NO" ]]; then ok "gate OFF -> file left untouched"; else fail "gate OFF -> file was rewritten"; fi +# --- Case 1b: ceiling contrast — CLAUDE_PROJECT_DIR unset finds git-root cfg -- +# Reuses the REPO_CEIL tree from the ceiling case above. With CLAUDE_PROJECT_DIR +# unset, run_hook's walk ceiling falls back to the git root, so the root settings +# file IS found and the global-var finding surfaces — proving the ceiling case's +# skip was the bound working, not a missing settings file. +# SC2016: literal PowerShell variable syntax — single quotes are intentional. +# shellcheck disable=SC2016 +printf '%s\n' '$global:d = 1' >"$REPO_CEIL/proj/sub/d.ps1" +OUT=$(run_hook "$REPO_CEIL/proj/sub/d.ps1") +if printf '%s' "$OUT" | jq -r '.hookSpecificOutput.additionalContext' 2>/dev/null | grep -q 'PSAvoidGlobalVars'; then + ok "CLAUDE_PROJECT_DIR unset -> git-root settings found (walk fallback)" +else + fail "git-root fallback did not find settings: $OUT" +fi + # --- Case 2: gate ON + clean file -> exit 0, empty stdout -------------------- REPO="$WORK/consumer" new_repo "$REPO" From 0006f12b789f86efa7f4c045f5ac6b828a0b5c68 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:03:54 -0400 Subject: [PATCH 3/7] fix(powershell-format): preserve source encoding and anchor analyzer at settings dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Codex review findings on #39: - P2: read with BOM detection (StreamReader) and write back with the detected encoding — pwsh's Set-Content default (utf8NoBOM) stripped a UTF-8 BOM and transcoded UTF-16 on first format. Files that are neither BOM'd nor valid UTF-8 (legacy ANSI) skip cleanly (exit 5) instead of being transcoded. - P2: Set-Location to the settings directory before Invoke-Formatter / Invoke-ScriptAnalyzer so repo-relative CustomRulePath entries resolve from the settings file, not the agent's incidental cwd. --- .../hooks/powershell-format.sh | 34 ++++++++- .../hooks/powershell-format.test.sh | 69 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/plugins/powershell-format/hooks/powershell-format.sh b/plugins/powershell-format/hooks/powershell-format.sh index bb33e3da1..755ae5abc 100755 --- a/plugins/powershell-format/hooks/powershell-format.sh +++ b/plugins/powershell-format/hooks/powershell-format.sh @@ -159,6 +159,8 @@ PSSA_SETTINGS_ARG="$(to_pwsh_path "$SETTINGS_FOUND")" # exit 0 clean # exit 1 findings (written to stderr, one line each) # exit 4 Invoke-Formatter / Invoke-ScriptAnalyzer threw -> tool break +# exit 5 file is neither BOM'd nor valid UTF-8 (legacy ANSI) -> clean skip +# (cannot round-trip the bytes safely, so never rewrite) # SC2016: PowerShell uses $env:VAR syntax inside single quotes — not bash expansion. # shellcheck disable=SC2016 PSSA_OUTPUT=$(PSSA_FILE="$PSSA_FILE_ARG" PSSA_SETTINGS="$PSSA_SETTINGS_ARG" \ @@ -170,7 +172,29 @@ PSSA_OUTPUT=$(PSSA_FILE="$PSSA_FILE_ARG" PSSA_SETTINGS="$PSSA_SETTINGS_ARG" \ $file = $env:PSSA_FILE $settings = $env:PSSA_SETTINGS - $original = Get-Content -Raw -LiteralPath $file + # Relative CustomRulePath entries in the settings file resolve from the + # current PowerShell location, and the hook inherits whatever cwd the + # agent happened to use — anchor at the settings directory so + # repo-relative rule paths resolve the way the repo intended. + Set-Location -LiteralPath (Split-Path -Parent $settings) + + # Read with BOM detection and remember the encoding so the write-back + # preserves the original byte shape (UTF-8 with/without BOM, UTF-16 + # LE/BE). Get-Content/Set-Content would rewrite with the shell default + # (utf8NoBOM on pwsh), stripping a BOM or transcoding UTF-16 on the + # first auto-format. Strict UTF-8 is the BOM-less fallback: a file that + # fails it (legacy ANSI) cannot be round-tripped safely -> exit 5. + $sr = $null + try { + $strictUtf8 = [System.Text.UTF8Encoding]::new($false, $true) + $sr = [System.IO.StreamReader]::new($file, $strictUtf8, $true) + $original = $sr.ReadToEnd() + $enc = $sr.CurrentEncoding + } catch [System.Text.DecoderFallbackException] { + exit 5 + } finally { + if ($sr) { $sr.Dispose() } + } if ($null -eq $original) { $original = "" } $formatted = Invoke-Formatter -ScriptDefinition $original -Settings $settings # -cne (case-SENSITIVE) is load-bearing: PowerShell string -ne is @@ -181,7 +205,7 @@ PSSA_OUTPUT=$(PSSA_FILE="$PSSA_FILE_ARG" PSSA_SETTINGS="$PSSA_SETTINGS_ARG" \ if (-not $formatted.EndsWith("`n")) { $formatted += "`n" } - Set-Content -LiteralPath $file -Value $formatted -NoNewline + [System.IO.File]::WriteAllText($file, $formatted, $enc) } # Invoke-ScriptAnalyzer has no -LiteralPath (verified against module @@ -242,6 +266,12 @@ case $PWSH_EXIT in # status as the no-settings / no-pwsh paths. emit_skipped ;; +5) + # File is neither BOM'd nor valid UTF-8 (legacy ANSI) — rewriting it would + # transcode bytes the hook cannot round-trip. Clean silent skip; the repo's + # commit hook / CI remains the gate for such files. + emit_skipped + ;; *) # pwsh threw for non-lint reasons (bad settings file, internal error) — no # judgment was made. Surface via additionalContext (NOT stderr — an advisory diff --git a/plugins/powershell-format/hooks/powershell-format.test.sh b/plugins/powershell-format/hooks/powershell-format.test.sh index 4b95a1a99..2fec0ff7e 100755 --- a/plugins/powershell-format/hooks/powershell-format.test.sh +++ b/plugins/powershell-format/hooks/powershell-format.test.sh @@ -320,6 +320,75 @@ RC=$? if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "kill switch off -> exit 0 silent"; else fail "kill switch failed (rc=$RC out=$OUT)"; fi if [[ "$(cat "$REPO/kill.ps1")" == "$BEFORE_K" ]]; then ok "kill switch -> file untouched"; else fail "kill switch -> file was modified"; fi +# --- Case 8: UTF-8 BOM survives a reformat (encoding preservation) ----------- +# pwsh's Set-Content default (utf8NoBOM) would strip the BOM on the first +# auto-format; the hook must write back with the detected original encoding. +printf '\xEF\xBB\xBFget-childitem -Path '"'"'.'"'"'\n' >"$REPO/bom.ps1" +OUT=$(run_hook "$REPO/bom.ps1") +RC=$? +if [[ $RC -eq 0 ]]; then ok "BOM file -> exit 0"; else fail "BOM file exit $RC"; fi +if grep -q 'Get-ChildItem' "$REPO/bom.ps1"; then ok "BOM file -> casing fixed (formatter ran)"; else fail "BOM file -> casing not fixed: $(cat "$REPO/bom.ps1")"; fi +BOM_HEX=$(head -c 6 "$REPO/bom.ps1" | od -An -tx1 | tr -d ' \n') +if [[ "$BOM_HEX" == "efbbbf476574" ]]; then + ok "BOM file -> single UTF-8 BOM preserved on write-back" +else + fail "BOM file -> leading bytes changed: $BOM_HEX (expected efbbbf476574)" +fi + +# --- Case 9: legacy ANSI (invalid UTF-8) -> skipped byte-identical ------------ +# A BOM-less file that is not valid UTF-8 cannot be round-tripped safely; the +# hook must skip it (exit 5 arm) rather than transcode. Wrong casing proves the +# skip: had the formatter run, it would have rewritten it. +printf 'write-output "caf\xE9"\n' >"$REPO/ansi.ps1" +ANSI_HEX_BEFORE=$(od -An -tx1 <"$REPO/ansi.ps1" | tr -d ' \n') +OUT=$(run_hook "$REPO/ansi.ps1") +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "ANSI file -> exit 0 silent skip"; else fail "ANSI file (rc=$RC out=$OUT)"; fi +if [[ "$(od -An -tx1 <"$REPO/ansi.ps1" | tr -d ' \n')" == "$ANSI_HEX_BEFORE" ]]; then + ok "ANSI file -> byte-identical (never transcoded)" +else + fail "ANSI file -> bytes changed" +fi + +# --- Case 10: relative CustomRulePath resolves from the settings dir ---------- +# PSScriptAnalyzer resolves a relative CustomRulePath from the current +# PowerShell location; the hook runs from an unrelated cwd, so it must anchor +# at the settings directory first. Without that anchor this repo's analysis +# throws (rule path not found) -> tool break; with it, the format applies. +REPO_CRP="$WORK/customrule" +new_repo "$REPO_CRP" NO_SETTINGS +mkdir -p "$REPO_CRP/rules" +cat >"$REPO_CRP/rules/CleanRules.psm1" <<'EOF' +function Measure-AlwaysClean { + [CmdletBinding()] + [OutputType('Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]')] + param( + [Parameter(Mandatory = $true)] + [System.Management.Automation.Language.ScriptBlockAst]$ScriptBlockAst + ) + return +} +Export-ModuleMember -Function Measure-AlwaysClean +EOF +cat >"$REPO_CRP/PSScriptAnalyzerSettings.psd1" <<'EOF' +@{ + CustomRulePath = './rules/CleanRules.psm1' + IncludeRules = @('PSUseCorrectCasing', 'Measure-AlwaysClean') + Rules = @{ + PSUseCorrectCasing = @{ Enable = $true } + } +} +EOF +printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp.ps1" +OUT=$(run_hook "$REPO_CRP/crp.ps1") +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "CustomRulePath -> exit 0, no tool break"; else fail "CustomRulePath (rc=$RC out=$OUT)"; fi +if grep -q 'Get-ChildItem' "$REPO_CRP/crp.ps1"; then + ok "CustomRulePath -> relative rule path resolved, formatter ran" +else + fail "CustomRulePath -> formatter did not run: $(cat "$REPO_CRP/crp.ps1")" +fi + # ============================================================================ # Telemetry # ============================================================================ From 57014d325265e0ce89569098c7488b8d3faa1031 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:08:37 -0400 Subject: [PATCH 4/7] docs: add missing eol-normalizer catalog row PR #38 added the marketplace.json entry but not the root README catalog row; every other plugin PR carries both. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5fcd3ea7a..929c31216 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Browse and manage with `/plugin`. To refresh after updates: `/plugin marketplace | [`bash-lint`](plugins/bash-lint) | Hook | Lints shell scripts on edit via ShellCheck, and formats via shfmt when the repo opts in with an `.editorconfig`, using the consuming repo's own config. | | [`biome-format`](plugins/biome-format) | Hook | Formats and lints JS/TS/JSX/JSON on edit via Biome, only when the repo opts in with a `biome.json`, using the consuming repo's own Biome config. | | [`ruff-format`](plugins/ruff-format) | Hook | Formats and lints Python on edit via Ruff, only when the repo opts in with a Ruff config (`ruff.toml`, `.ruff.toml`, or `pyproject.toml` `[tool.ruff]`), using the consuming repo's own Ruff config. | +| [`eol-normalizer`](plugins/eol-normalizer) | Hook | Normalizes a file's working-tree line endings on edit to its `.gitattributes` `eol=` value via `git check-attr` — symmetric CRLF↔LF, binary-safe, using the consuming repo's own attributes. | | [`desktop-notification`](plugins/desktop-notification) | Hook | Alerts you when Claude Code needs input via an audible bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts. | | [`powershell-format`](plugins/powershell-format) | Hook | Formats and lints PowerShell on edit via PSScriptAnalyzer, only when the repo opts in with a `PSScriptAnalyzerSettings.psd1`, using the consuming repo's own analyzer settings. | From 0e3f31b90d4f03e14af8814c0a1ca2c6e72bbb26 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:14:21 -0400 Subject: [PATCH 5/7] test(powershell-format): drop dictionary-adjacent fragment from ANSI fixture The escaped 'caf\xE9' left a bare 'caf' token that trips the typos CI gate; the lone high byte is all the fixture needs. --- plugins/powershell-format/hooks/powershell-format.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/powershell-format/hooks/powershell-format.test.sh b/plugins/powershell-format/hooks/powershell-format.test.sh index 2fec0ff7e..d7e45465b 100755 --- a/plugins/powershell-format/hooks/powershell-format.test.sh +++ b/plugins/powershell-format/hooks/powershell-format.test.sh @@ -339,7 +339,7 @@ fi # A BOM-less file that is not valid UTF-8 cannot be round-tripped safely; the # hook must skip it (exit 5 arm) rather than transcode. Wrong casing proves the # skip: had the formatter run, it would have rewritten it. -printf 'write-output "caf\xE9"\n' >"$REPO/ansi.ps1" +printf 'write-output "\xE9"\n' >"$REPO/ansi.ps1" ANSI_HEX_BEFORE=$(od -An -tx1 <"$REPO/ansi.ps1" | tr -d ' \n') OUT=$(run_hook "$REPO/ansi.ps1") RC=$? From 1c90d741ace62e99c683fd0d15dc7180fb21d27f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:22:27 -0400 Subject: [PATCH 6/7] fix(powershell-format): normalize settings walk paths to physical form Use hook::physical_path for FILE_DIR_POSIX and CEILING so symlinked CLAUDE_PROJECT_DIR matches the resolved file directory during the PSScriptAnalyzerSettings.psd1 walk-up. Co-Authored-By: Claude Co-authored-by: Cursor --- plugins/powershell-format/hooks/powershell-format.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/plugins/powershell-format/hooks/powershell-format.sh b/plugins/powershell-format/hooks/powershell-format.sh index 755ae5abc..bdea452e2 100755 --- a/plugins/powershell-format/hooks/powershell-format.sh +++ b/plugins/powershell-format/hooks/powershell-format.sh @@ -98,9 +98,11 @@ emit_skipped() { exit 0 } -# Resolve the file's directory in `pwd` form once — anchors the settings walk. -FILE_DIR_POSIX="$(cd "$(dirname "$FILE")" 2>/dev/null && pwd)" || FILE_DIR_POSIX="" -root="$(cd "$REPO_ROOT" 2>/dev/null && pwd)" || root="" +# Resolve the file's directory and walk anchors as physical paths — same +# representation hook::read_file_path uses for membership — so a symlinked +# CLAUDE_PROJECT_DIR still matches the file's resolved directory at the ceiling. +FILE_DIR_POSIX="$(hook::normalize_path "$(hook::physical_path "$(dirname "$FILE")")")" || FILE_DIR_POSIX="" +root="$(hook::normalize_path "$(hook::physical_path "$REPO_ROOT")")" || root="" # Ceiling for the settings walk-up. When CLAUDE_PROJECT_DIR is set the walk stops # there, so the settings ceiling matches the file-membership ceiling that @@ -110,8 +112,7 @@ root="$(cd "$REPO_ROOT" 2>/dev/null && pwd)" || root="" # (see README "Trust model"). The git-root ceiling is the fallback when unset. CEILING="$root" if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - _cpd="$(cd "$CLAUDE_PROJECT_DIR" 2>/dev/null && pwd)" || _cpd="" - [[ -n "$_cpd" ]] && CEILING="$_cpd" + CEILING="$(hook::normalize_path "$(hook::physical_path "$CLAUDE_PROJECT_DIR")")" fi # Consumer opt-in: a PSScriptAnalyzerSettings.psd1 that governs the edited file. From 3f57de3bf83782545697156cef949d0c74dd4099 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:28:59 -0400 Subject: [PATCH 7/7] docs(powershell-format): scope the requirement to PowerShell 7+ (pwsh) The hook probes pwsh only; claiming Windows PowerShell support was inaccurate. PSScriptAnalyzer behavior also differs on 5.1, so the supported engine is stated exactly. --- plugins/powershell-format/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/powershell-format/README.md b/plugins/powershell-format/README.md index 149952ad8..cef51e332 100644 --- a/plugins/powershell-format/README.md +++ b/plugins/powershell-format/README.md @@ -46,8 +46,9 @@ Do not enable this plugin against an untrusted working tree. ## Requirements -- **PowerShell** (`pwsh`) on `PATH` — PowerShell 7+ (cross-platform) or Windows - PowerShell. If absent, the hook is a silent no-op. +- **PowerShell 7+** (`pwsh`) on `PATH` — the hook probes `pwsh` only; legacy + Windows PowerShell 5.1 (`powershell.exe`) is not used. If absent, the hook is + a silent no-op. - The **PSScriptAnalyzer** module installed (`Install-Module PSScriptAnalyzer`). If absent, the hook is a silent no-op. - A **`PSScriptAnalyzerSettings.psd1`** in your repo — the opt-in.