diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4985c438f..46be33b86 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -42,6 +42,12 @@ "source": "./plugins/desktop-notification", "category": "notifications", "tags": ["notification", "desktop", "toast", "bell", "osc9", "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 ad4aedff9..929c31216 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ 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. | 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..cef51e332 --- /dev/null +++ b/plugins/powershell-format/README.md @@ -0,0 +1,83 @@ +# 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. + +## 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 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. + +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..bdea452e2 --- /dev/null +++ b/plugins/powershell-format/hooks/powershell-format.sh @@ -0,0 +1,292 @@ +#!/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 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 +# 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 + CEILING="$(hook::normalize_path "$(hook::physical_path "$CLAUDE_PROJECT_DIR")")" +fi + +# Consumer opt-in: a PSScriptAnalyzerSettings.psd1 that governs the edited file. +# 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. +SETTINGS_FOUND="" +dir="$FILE_DIR_POSIX" +while [[ -n "$dir" ]]; do + if [[ -f "$dir/PSScriptAnalyzerSettings.psd1" ]]; then + SETTINGS_FOUND="$dir/PSScriptAnalyzerSettings.psd1" + break + fi + [[ -n "$CEILING" && "$dir" == "$CEILING" ]] && 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 +# 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" \ + pwsh -NoProfile -NonInteractive -Command ' + if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { + exit 3 + } + try { + $file = $env:PSSA_FILE + $settings = $env:PSSA_SETTINGS + + # 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 + # 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" + } + [System.IO.File]::WriteAllText($file, $formatted, $enc) + } + + # 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( + "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 + ;; +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 + # 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..d7e45465b --- /dev/null +++ b/plugins/powershell-format/hooks/powershell-format.test.sh @@ -0,0 +1,450 @@ +#!/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" + +# --- 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 +# ============================================================================ +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 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" +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 + +# --- 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 "\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 +# ============================================================================ + +# --- 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 ]]