From f3f0789c5cf6709b86a59d6eb5e7ad4167ff2dc0 Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Mon, 1 Jun 2026 13:54:48 -0700 Subject: [PATCH] Add shell lint script --- scripts/lint-shell.sh | 211 ++++++++++++++++++++++++++++ tests/shell-lint/test-lint-shell.sh | 179 +++++++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100755 scripts/lint-shell.sh create mode 100644 tests/shell-lint/test-lint-shell.sh diff --git a/scripts/lint-shell.sh b/scripts/lint-shell.sh new file mode 100755 index 00000000..190db014 --- /dev/null +++ b/scripts/lint-shell.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# +# Lint shell scripts in this repository. +# +# Usage: +# scripts/lint-shell.sh [--all] [--format] [--strict] [file ...] +# +# By default, runs ShellCheck and shell syntax checks on changed shell scripts. +# Use --format to format with shfmt before linting. Use --all for the full tracked +# baseline, or pass files explicitly to lint a smaller set. +set -euo pipefail + +usage() { + sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//' +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +require_tool() { + command -v "$1" >/dev/null 2>&1 || die "required tool '$1' is not on PATH" +} + +is_shell_file() { + local path="$1" + local first_line="" + + [[ -f "$path" ]] || return 1 + + case "$path" in + *.sh) + return 0 + ;; + esac + + IFS= read -r first_line <"$path" || true + [[ "$first_line" =~ ^#!.*[/[:space:]](bash|dash|ksh|sh)([[:space:]]|$) ]] +} + +ensure_git_work_tree() { + git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ + || die "run this from inside a git work tree, or pass files explicitly" +} + +add_shell_file() { + local path + local existing + + path="$1" + if ! is_shell_file "$path"; then + return 0 + fi + + if [[ "${#files[@]}" -gt 0 ]]; then + for existing in "${files[@]}"; do + if [[ "$existing" == "$path" ]]; then + return 0 + fi + done + fi + + files+=("$path") +} + +collect_all_shell_files() { + local path + + ensure_git_work_tree + + while IFS= read -r -d '' path; do + add_shell_file "$path" + done < <(git ls-files -z) +} + +collect_changed_shell_files() { + local path + + ensure_git_work_tree + + if git rev-parse --verify HEAD >/dev/null 2>&1; then + while IFS= read -r -d '' path; do + add_shell_file "$path" + done < <(git diff --name-only -z --diff-filter=ACMR HEAD) + + while IFS= read -r -d '' path; do + add_shell_file "$path" + done < <(git diff --cached --name-only -z --diff-filter=ACMR) + else + collect_all_shell_files + fi + + while IFS= read -r -d '' path; do + add_shell_file "$path" + done < <(git ls-files --others --exclude-standard -z) +} + +collect_requested_shell_files() { + local path + + for path in "$@"; do + add_shell_file "$path" + done +} + +syntax_shell_for() { + local path="$1" + local first_line="" + + IFS= read -r first_line <"$path" || true + + case "$first_line" in + *"/sh"* | *" env sh"* | *"/dash"* | *" env dash"*) + printf 'sh' + ;; + *) + printf 'bash' + ;; + esac +} + +run_syntax_checks() { + local file + local shell_name + + for file in "$@"; do + shell_name="$(syntax_shell_for "$file")" + case "$shell_name" in + sh) + sh -n "$file" + ;; + bash) + bash -n "$file" + ;; + *) + die "unsupported shell for syntax check: $shell_name" + ;; + esac + done +} + +format=false +strict=false +all=false +requested_files=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --all) + all=true + ;; + --format) + format=true + ;; + --strict) + strict=true + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + requested_files+=("$@") + break + ;; + -*) + die "unknown option: $1" + ;; + *) + requested_files+=("$1") + ;; + esac + shift +done + +require_tool shellcheck +if [[ "$format" == true ]]; then + require_tool shfmt +fi + +files=() +if [[ "${#requested_files[@]}" -gt 0 ]]; then + collect_requested_shell_files "${requested_files[@]}" +elif [[ "$all" == true ]]; then + collect_all_shell_files +else + collect_changed_shell_files +fi + +if [[ "${#files[@]}" -eq 0 ]]; then + echo "No shell files found." + exit 0 +fi + +if [[ "$format" == true ]]; then + echo "Formatting ${#files[@]} shell files" + shfmt_args=(-i 2 -ci -bn) + shfmt "${shfmt_args[@]}" -w "${files[@]}" +fi + +echo "Linting ${#files[@]} shell files" + +shellcheck_args=(--severity=warning --external-sources --source-path=SCRIPTDIR) +if [[ "$strict" == true ]]; then + shellcheck_args+=("--enable=check-extra-masked-returns,check-set-e-suppressed,quote-safe-variables,deprecate-which,avoid-nullary-conditions") +fi + +shellcheck "${shellcheck_args[@]}" "${files[@]}" +run_syntax_checks "${files[@]}" diff --git a/tests/shell-lint/test-lint-shell.sh b/tests/shell-lint/test-lint-shell.sh new file mode 100644 index 00000000..141bcd9e --- /dev/null +++ b/tests/shell-lint/test-lint-shell.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SCRIPT_UNDER_TEST="$REPO_ROOT/scripts/lint-shell.sh" + +FAILURES=0 +TEST_ROOT="$(mktemp -d)" + +cleanup() { + rm -rf "$TEST_ROOT" +} +trap cleanup EXIT + +pass() { + echo " [PASS] $1" +} + +fail() { + echo " [FAIL] $1" + FAILURES=$((FAILURES + 1)) +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local description="$3" + + if printf '%s' "$haystack" | grep -Fq -- "$needle"; then + pass "$description" + else + fail "$description" + echo " expected to find: $needle" + echo " in:" + printf '%s\n' "$haystack" | sed 's/^/ /' + fi +} + +assert_not_contains() { + local haystack="$1" + local needle="$2" + local description="$3" + + if printf '%s' "$haystack" | grep -Fq -- "$needle"; then + fail "$description" + echo " did not expect to find: $needle" + echo " in:" + printf '%s\n' "$haystack" | sed 's/^/ /' + else + pass "$description" + fi +} + +configure_git_identity() { + local repo="$1" + + git -C "$repo" config user.name "Test Bot" + git -C "$repo" config user.email "test@example.com" +} + +write_stub_tool() { + local path="$1" + local name="$2" + + cat >"$path" <' "\$arg" + done + printf '\n' +} >> "\$SUPERPOWERS_SHELL_LINT_TEST_LOG" +exit 0 +EOF + chmod +x "$path" +} + +make_fixture_repo() { + local repo="$1" + + git init -q -b main "$repo" + configure_git_identity "$repo" + + mkdir -p "$repo/hooks" + cat >"$repo/tracked.sh" <<'EOF' +#!/usr/bin/env bash +echo "tracked" +EOF + cat >"$repo/hooks/session-start" <<'EOF' +#!/bin/sh +echo "extensionless" +EOF + cat >"$repo/README.md" <<'EOF' +# Fixture + +```bash +echo "not a shell script" +``` +EOF + cat >"$repo/untracked.sh" <<'EOF' +#!/usr/bin/env bash +echo "untracked" +EOF + + git -C "$repo" add tracked.sh hooks/session-start README.md + git -C "$repo" commit -q -m "fixture" + + printf '\necho "changed"\n' >>"$repo/tracked.sh" + printf '\necho "changed extensionless"\n' >>"$repo/hooks/session-start" +} + +run_lint_shell() { + local repo="$1" + local fakebin="$2" + local log="$3" + shift 3 + + ( + cd "$repo" + PATH="$fakebin:$PATH" \ + SUPERPOWERS_SHELL_LINT_TEST_LOG="$log" \ + bash "$SCRIPT_UNDER_TEST" "$@" + ) +} + +echo "Shell lint script tests" + +fixture="$TEST_ROOT/repo" +fakebin="$TEST_ROOT/bin" +log="$TEST_ROOT/tool.log" +mkdir -p "$fixture" "$fakebin" +: >"$log" +write_stub_tool "$fakebin/shellcheck" "shellcheck" +write_stub_tool "$fakebin/shfmt" "shfmt" +make_fixture_repo "$fixture" + +if output="$(run_lint_shell "$fixture" "$fakebin" "$log" 2>&1)"; then + pass "lint-shell check mode exits successfully with stub tools" +else + fail "lint-shell check mode exits successfully with stub tools" + printf '%s\n' "$output" | sed 's/^/ /' +fi + +tool_log="$(cat "$log")" +assert_contains "$output" "Linting 3 shell files" "reports changed shell file count" +assert_not_contains "$tool_log" "shfmt:" "does not run shfmt in lint mode" +assert_contains "$tool_log" "shellcheck:" "runs ShellCheck" +assert_contains "$tool_log" "<--severity=warning>" "uses warning severity as the baseline" +assert_contains "$tool_log" "<--external-sources>" "allows ShellCheck to follow sourced files" +assert_contains "$tool_log" "<--source-path=SCRIPTDIR>" "resolves ShellCheck sources relative to each script" +assert_contains "$tool_log" "" "includes changed extensionless shell shebang file" +assert_contains "$tool_log" "" "includes changed tracked .sh file" +assert_contains "$tool_log" "" "includes untracked shell files by default" +assert_not_contains "$tool_log" "README.md" "ignores Markdown with shell snippets" + +: >"$log" +if output="$(run_lint_shell "$fixture" "$fakebin" "$log" --all --format 2>&1)"; then + pass "lint-shell --format exits successfully with stub tools" +else + fail "lint-shell --format exits successfully with stub tools" + printf '%s\n' "$output" | sed 's/^/ /' +fi + +tool_log="$(cat "$log")" +assert_contains "$tool_log" "<-w>" "uses shfmt write mode with --format" +assert_contains "$tool_log" "shellcheck:" "runs ShellCheck after --format" +assert_contains "$tool_log" "<--severity=warning>" "keeps warning severity after --format" +assert_contains "$tool_log" "" "--all includes tracked extensionless shell shebang file" +assert_contains "$tool_log" "" "--all includes tracked .sh file" +assert_not_contains "$tool_log" "untracked.sh" "--all ignores untracked shell files" + +if [[ "$FAILURES" -eq 0 ]]; then + echo "All shell lint script tests passed" +else + echo "$FAILURES shell lint script test(s) failed" + exit 1 +fi