Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/bash-format/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "bash-format",
"version": "0.6.10",
"version": "0.6.11",
"description": "Auto-format and lint shell scripts on edit via shfmt + ShellCheck, using the consuming repo's own .editorconfig and .shellcheckrc.",
"author": {
"name": "Melodic Software",
Expand Down
24 changes: 24 additions & 0 deletions plugins/bash-format/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,30 @@
All notable changes to the `bash-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.6.11]
Comment thread
kyle-sexton marked this conversation as resolved.

### Fixed

- **Bare `[*]` is no longer a shell formatting opt-in (#1817).** `section_applies_to_shell`
treated a catch-all `[*]` section as governing shell files, so any repo with a generic
`.editorconfig` (typically only `end_of_line` / `insert_final_newline` under `[*]`) had its
shell scripts rewritten to shfmt's built-in defaults. Opt-in now requires an explicit shell
glob — `[*.sh]`, `[*.bash]`, `[*.{sh,bash}]`, or a path-prefixed form like `[**/*.sh]`.
Path-only sections such as `[scripts/**]` remain excluded.

- **ShellCheck/shfmt path handling no longer surfaces `openBinaryFile` on Windows (#1817).**
On Windows/MSYS the hook now prefers `cygpath -lm` (mixed long form) for tool invocations when
that path exists, re-checks that the file is still present immediately before shfmt/ShellCheck
run (closing a race with deleted scratch/worktree files), retries the original path spelling if
ShellCheck still reports `openBinaryFile`, and strips any remaining `openBinaryFile` noise so it
never becomes a findings line. `openBinaryFile: does not exist` is ShellCheck's (GHC's) missing-
file error — valid path forms already lint cleanly; this hardens the intermittent miss case.

### Changed

- README and `/bash-format:setup` now document that a bare `[*]` is not a formatting opt-in;
consumers who want shfmt must add an explicit shell glob section.

## [0.6.10]

### Fixed
Expand Down
21 changes: 11 additions & 10 deletions plugins/bash-format/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,17 @@ and `.editorconfig` for formatting. It ships no rules of its own.
- **Lint on edit (always).** ShellCheck (`warning` severity and above) runs on
every edit. It is non-mutating; it only reports.
- **Format on edit (opt-in).** `shfmt` runs **only when an `.editorconfig`
section governs the edited shell file** — a `[*]` catch-all or a shell glob
such as `[*.sh]`, `[*.bash]`, or `[*.{sh,bash}]`, found by walking up from the
file to the repository root. A repo whose `.editorconfig` only configures other
languages (or has none) leaves shell files untouched rather than rewriting them
to shfmt's built-in defaults, so the plugin never imposes a style you did not
choose. (Path-only sections like `[scripts/**]` are not treated as a shell
opt-in; use a shell glob or `[*]`.) It runs with no parser/printer flags, so
your `.editorconfig` is authoritative, and with `--apply-ignore` so an
`ignore = true` section (e.g. for generated or vendored scripts) is honored
even on a single edited file.
section names shell files** — a shell glob such as `[*.sh]`, `[*.bash]`, or
`[*.{sh,bash}]` (including path-prefixed forms like `[**/*.sh]`), found by
walking up from the file to the repository root. A bare `[*]` catch-all is
**not** an opt-in: most repos only set line-ending / charset properties there,
and treating that as a format gate would rewrite shell files to shfmt's
built-in defaults. A repo whose `.editorconfig` only configures other
languages (or has none) likewise leaves shell files untouched. Path-only
sections like `[scripts/**]` are also excluded; use an explicit shell glob.
It runs with no parser/printer flags, so your `.editorconfig` is authoritative,
and with `--apply-ignore` so an `ignore = true` section (e.g. for generated or
vendored scripts) is honored even on a single edited file.
- **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.
Expand Down
107 changes: 73 additions & 34 deletions plugins/bash-format/hooks/bash-format.sh
Original file line number Diff line number Diff line change
Expand Up @@ -96,26 +96,28 @@ build_data_json() {
printf '{"tool":"","file":"","findings":[]}'
}

# A section header governs shell files when it is the `[*]` catch-all or names a
# shell extension — `[*.sh]` / `[*.bash]` (incl. path prefixes like `[**/*.sh]`)
# or a brace list naming sh or bash (`[*.{sh,bash}]`). Path-only sections such as
# `[scripts/**]` are intentionally NOT treated as shell opt-in: matching those
# correctly means reimplementing EditorConfig globbing, and the safe bias is to
# leave files untouched when unsure. $1 is the text inside the brackets.
# A section header governs shell files when it names a shell extension —
# `[*.sh]` / `[*.bash]` (incl. path prefixes like `[**/*.sh]`) or a brace list
# naming sh or bash (`[*.{sh,bash}]`). A bare `[*]` catch-all is intentionally
# NOT treated as shell opt-in (#1817): most repos set only line-ending / charset
# properties under `[*]`, and treating that as an shfmt opt-in rewrote shell
# files to shfmt's built-in defaults. Path-only sections such as `[scripts/**]`
# are also excluded — matching those correctly means reimplementing EditorConfig
# globbing, and the safe bias is to leave files untouched when unsure. $1 is the
# text inside the brackets.
section_applies_to_shell() {
local h="$1"
[[ "$h" == '*' ]] && return 0
[[ "$h" =~ \*\.(sh|bash)([^[:alnum:]]|$) ]] && return 0
[[ "$h" =~ [{,](sh|bash)[,}] ]] && return 0
return 1
}

# Consumer opt-in for shfmt: an EditorConfig SECTION that governs the edited
# shell file (not merely the presence of any .editorconfig — a repo whose
# .editorconfig only configures other languages must not have its shell files
# rewritten to shfmt's built-in defaults). Walks up from the file to the repo
# root, stopping at a `root = true` config per EditorConfig search semantics.
# This gate is the whole formatting opt-in.
# Consumer opt-in for shfmt: an EditorConfig SECTION that names shell files
# (not merely the presence of any .editorconfig — a repo whose .editorconfig
# only configures other languages, or only a bare `[*]` for line endings, must
# not have its shell files rewritten to shfmt's built-in defaults). Walks up
# from the file to the repo root, stopping at a `root = true` config per
# EditorConfig search semantics. This gate is the whole formatting opt-in.
shell_editorconfig_opt_in() {
local dir root cfg line is_root parent
dir="$(cd "$(dirname "$FILE")" 2>/dev/null && pwd)" || return 1
Expand Down Expand Up @@ -152,6 +154,20 @@ append_notice() {
NOTICE+="$1"
}

# Tool path for shfmt/ShellCheck. On Windows/MSYS, Claude Code may hand the
# hook a POSIX mount path (`/c/...`), a mixed drive path (`C:/...`), or a
# backslash Win32 path. GHC-based ShellCheck opens paths via openBinaryFile and
# can fail that open on some spellings even when bash's `[[ -f ]]` succeeded on
# the original (#1817). Prefer cygpath's mixed long form when available so both
# tools see one stable existing path; fall back to FILE unchanged elsewhere.
TOOL_FILE="$FILE"
if command -v cygpath >/dev/null 2>&1; then
_tool_lm=$(cygpath -lm -- "$FILE" 2>/dev/null)
if [[ -n "$_tool_lm" && -f "$_tool_lm" ]]; then
TOOL_FILE="$_tool_lm"
fi
fi

# Format pass (opt-in, mutating). No parser/printer flags — that keeps
# .editorconfig formatting in effect. --apply-ignore is a utility flag (not a
# parser/printer flag, so it does not disable editorconfig formatting): it makes
Expand Down Expand Up @@ -184,16 +200,24 @@ if shell_editorconfig_opt_in; then
# flaking on its first invocation, a transient exec error — says nothing
# about the flag, so falling back would mutate through the same discarded
# opt-out; the file is left untouched and the skip is said out loud.
probe_err=""
if probe_err=$(shfmt --apply-ignore --version 2>&1 >/dev/null); then
shfmt --apply-ignore -w "$FILE" 2>/dev/null
elif [[ "$probe_err" == *apply-ignore* ]] &&
[[ "$probe_err" == *"flag provided but not defined"* || "$probe_err" == *"unknown flag"* ]]; then
shfmt -w "$FILE" 2>/dev/null
else
append_notice "bash-format: shfmt capability probe failed unexpectedly (${probe_err%%$'\n'*}) — formatting skipped for this file, opt-outs preserved."
#
# Re-check existence immediately before mutating: the earlier
# hook::read_file_path guard can race a deleted scratch/worktree file, and
# shfmt/ShellCheck then surface GHC's openBinaryFile error (#1817).
if [[ -f "$TOOL_FILE" || -f "$FILE" ]]; then
probe_err=""
_fmt_target="$TOOL_FILE"
[[ -f "$_fmt_target" ]] || _fmt_target="$FILE"
if probe_err=$(shfmt --apply-ignore --version 2>&1 >/dev/null); then
shfmt --apply-ignore -w "$_fmt_target" 2>/dev/null
elif [[ "$probe_err" == *apply-ignore* ]] &&
[[ "$probe_err" == *"flag provided but not defined"* || "$probe_err" == *"unknown flag"* ]]; then
shfmt -w "$_fmt_target" 2>/dev/null
else
append_notice "bash-format: shfmt capability probe failed unexpectedly (${probe_err%%$'\n'*}) — formatting skipped for this file, opt-outs preserved."
fi
ran_any=1
fi
ran_any=1
elif hook::notice_once "bash-format-shfmt" "$INPUT"; then
append_notice "bash-format: .editorconfig opts this repo into shell formatting but 'shfmt' is not on PATH — formatting skipped for this session. Install: https://github.com/mvdan/sh#shfmt"
fi
Expand All @@ -206,18 +230,33 @@ fi
CTX=""
FINDINGS_JSON='[]'
if command -v shellcheck >/dev/null 2>&1; then
ran_any=1
SC_OUTPUT=$(shellcheck -x -f gcc -S warning "$FILE" 2>&1) || true
if [[ -n "$SC_OUTPUT" ]]; then
CTX="bash-format: $(basename "$FILE") has ShellCheck findings:"$'\n'
findings_raw=""
while IFS= read -r line; do
[[ -n "$line" ]] || continue
CTX+=" $line"$'\n'
findings_raw+="$line"$'\n'
done <<<"$SC_OUTPUT"
if [[ -n "$findings_raw" ]]; then
FINDINGS_JSON=$(printf '%s' "$findings_raw" | jq -R . | jq -s . 2>/dev/null) || FINDINGS_JSON='[]'
# Same race window as the format pass: skip quietly if the file is already
# gone rather than reporting GHC's openBinaryFile as a ShellCheck finding.
if [[ -f "$TOOL_FILE" || -f "$FILE" ]]; then
ran_any=1
_lint_target="$TOOL_FILE"
[[ -f "$_lint_target" ]] || _lint_target="$FILE"
SC_OUTPUT=$(shellcheck -x -f gcc -S warning "$_lint_target" 2>&1) || true
# If ShellCheck still failed to open the path, retry the original spelling
# once — some hosts accept only one of the two forms — then drop any
# remaining openBinaryFile noise so a path/race miss is never a finding.
if [[ "$SC_OUTPUT" == *openBinaryFile* && "$_lint_target" != "$FILE" && -f "$FILE" ]]; then
SC_OUTPUT=$(shellcheck -x -f gcc -S warning "$FILE" 2>&1) || true
fi
if [[ "$SC_OUTPUT" == *openBinaryFile* ]]; then
SC_OUTPUT=$(printf '%s\n' "$SC_OUTPUT" | grep -v 'openBinaryFile' || true)
fi
if [[ -n "$SC_OUTPUT" ]]; then
CTX="bash-format: $(basename "$FILE") has ShellCheck findings:"$'\n'
findings_raw=""
while IFS= read -r line; do
[[ -n "$line" ]] || continue
CTX+=" $line"$'\n'
findings_raw+="$line"$'\n'
done <<<"$SC_OUTPUT"
if [[ -n "$findings_raw" ]]; then
FINDINGS_JSON=$(printf '%s' "$findings_raw" | jq -R . | jq -s . 2>/dev/null) || FINDINGS_JSON='[]'
fi
fi
fi
elif hook::notice_once "bash-format-shellcheck" "$INPUT"; then
Expand Down
93 changes: 82 additions & 11 deletions plugins/bash-format/hooks/bash-format.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ EOF
fi

# Opt-in precision: an .editorconfig with NO shell-applicable section (only
# [*.md], no [*]) must NOT trigger formatting — otherwise shfmt would impose
# its built-in defaults on shell files the repo never opted in for.
# [*.md], or a bare [*]) must NOT trigger formatting — otherwise shfmt would
# impose its built-in defaults on shell files the repo never opted in for.
REPO_NONSHELL="$WORK/nonshell-config"
new_repo "$REPO_NONSHELL"
printf '[*.md]\nindent_style = space\n' >"$REPO_NONSHELL/.editorconfig"
Expand All @@ -234,16 +234,32 @@ EOF
fail "non-shell .editorconfig -> shell file was reformatted: $(cat "$REPO_NONSHELL/x.sh")"
fi

# A [*] catch-all governs shell files, so formatting opts in.
# A bare [*] catch-all is NOT a shell opt-in (#1817): most repos set only
# line-ending / charset properties there, and treating [*] as opt-in rewrote
# shell files to shfmt defaults. Even when [*] carries indent_* properties,
# opt-in requires an explicit shell glob (`[*.sh]`, etc.).
REPO_STAR="$WORK/star-config"
new_repo "$REPO_STAR"
printf 'root = true\n[*]\nindent_style = space\nindent_size = 2\n' >"$REPO_STAR/.editorconfig"
printf '#!/usr/bin/env bash\nif true; then\necho hi\nfi\n' >"$REPO_STAR/x.sh"
run_hook "$REPO_STAR/x.sh" >/dev/null
if grep -q '^ echo hi$' "$REPO_STAR/x.sh"; then
ok "[*] catch-all .editorconfig -> shell file formatted"
if grep -q '^echo hi$' "$REPO_STAR/x.sh"; then
ok "bare [*] .editorconfig -> shell file left untouched"
else
fail "[*] catch-all -> shell file not formatted: $(cat "$REPO_STAR/x.sh")"
fail "bare [*] treated as shell opt-in: $(cat "$REPO_STAR/x.sh")"
fi

# Control: a generic [*] with only line-ending properties (the common case
# that #1817 observed) must also leave the file untouched.
REPO_STAR_EOL="$WORK/star-eol-config"
new_repo "$REPO_STAR_EOL"
printf 'root = true\n[*]\nend_of_line = lf\ninsert_final_newline = true\n' >"$REPO_STAR_EOL/.editorconfig"
printf '#!/usr/bin/env bash\nif true; then\necho hi\nfi\n' >"$REPO_STAR_EOL/x.sh"
run_hook "$REPO_STAR_EOL/x.sh" >/dev/null
if grep -q '^echo hi$' "$REPO_STAR_EOL/x.sh"; then
ok "bare [*] with only eol props -> shell file left untouched"
else
fail "bare [*] eol-only treated as shell opt-in: $(cat "$REPO_STAR_EOL/x.sh")"
fi

# A brace-list section naming sh (`[*.{sh,bash}]`) governs shell files:
Expand Down Expand Up @@ -279,11 +295,11 @@ else
echo " (shfmt absent -- gate cases skipped)"
fi

# --- shfmt < 3.8 --apply-ignore fallback (`|| shfmt -w`) --------------------
# The format pass calls `shfmt --apply-ignore -w FILE || shfmt -w FILE`: shfmt
# 3.8+ honors --apply-ignore, older shfmt does not know the flag and fails, so
# the plain-`-w` fallback must still format. A stub shfmt that REJECTS
# --apply-ignore (simulating < 3.8) but formats on plain -w proves the fallback
# --- shfmt < 3.8 --apply-ignore fallback (capability probe) -----------------
# The format pass probes `shfmt --apply-ignore --version`, then formats with
# the flag on success. Older shfmt rejects the flag on the probe, so the
# plain-`-w` compatibility path must still format. A stub shfmt that REJECTS
# --apply-ignore (simulating < 3.8) but formats on plain -w proves that path
# runs. Independent of the host's real shfmt: the stub is prepended to PATH.
STUBDIR="$(mktemp -d "$WORK/shfmtstub.XXXXXX")"
cat >"$STUBDIR/shfmt" <<'STUB'
Expand Down Expand Up @@ -405,6 +421,61 @@ else
fail "unclassified probe failure skipped silently: $FLAKE_OUT"
fi

# --- openBinaryFile / missing-file race (#1817) ------------------------------
# ShellCheck's GHC runtime reports `openBinaryFile: does not exist` when the
# path is gone by the time it opens the file. That must never become a
# findings line. Drive the hook with a stub shellcheck that always emits the
# error text; the hook must exit 0 with empty findings/context for it.
STUBSC="$(mktemp -d "$WORK/scstub.XXXXXX")"
cat >"$STUBSC/shellcheck" <<'STUBSC'
#!/usr/bin/env bash
f="${*: -1}"
echo "$f: $f: openBinaryFile: does not exist (No such file or directory)" >&2
exit 1
STUBSC
chmod +x "$STUBSC/shellcheck"
# Keep real shfmt off the path so only the lint pass runs.
REPO_OBF="$WORK/openbinary"
new_repo "$REPO_OBF"
printf '#!/usr/bin/env bash\necho hi\n' >"$REPO_OBF/x.sh"
OBF_OUT="$(
cd "$UNRELATED" || exit 1
printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO_OBF/x.sh" |
env -u CLAUDE_PROJECT_DIR PATH="$STUBSC:/usr/bin:/bin" \
CLAUDE_PLUGIN_OPTION_BASH_FORMAT_ENABLED=true bash "$HOOK"
)"
RC_OBF=$?
if [[ $RC_OBF -eq 0 && "$OBF_OUT" != *openBinaryFile* ]]; then
ok "openBinaryFile from shellcheck is not surfaced as a finding"
else
fail "openBinaryFile leaked (rc=$RC_OBF out=$OBF_OUT)"
fi

# Windows/MSYS path form: when cygpath can produce a mixed long path for an
# existing file, the hook must still lint cleanly (no openBinaryFile) — the
# TOOL_FILE normalization path under test.
if command -v cygpath >/dev/null 2>&1 && command -v shellcheck >/dev/null 2>&1; then
REPO_WIN="$WORK/winpath"
new_repo "$REPO_WIN"
printf '#!/usr/bin/env bash\necho hi\n' >"$REPO_WIN/x.sh"
WIN_PATH="$(cygpath -w "$REPO_WIN/x.sh")"
# JSON needs escaped backslashes.
WIN_JSON="${WIN_PATH//\\/\\\\}"
WIN_OUT="$(
cd "$UNRELATED" || exit 1
printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$WIN_JSON" |
env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_OPTION_BASH_FORMAT_ENABLED=true bash "$HOOK"
)"
RC_WIN=$?
if [[ $RC_WIN -eq 0 && "$WIN_OUT" != *openBinaryFile* ]]; then
ok "Windows Win32 path form -> hook runs without openBinaryFile"
else
fail "Windows path form failed (rc=$RC_WIN out=$WIN_OUT)"
fi
else
echo " (cygpath/shellcheck absent -- Windows path form case skipped)"
fi

# ============================================================================
# Telemetry
# ============================================================================
Expand Down
Loading
Loading