Răsfoiți Sursa

Add shell lint script

Drew Ritter 3 luni în urmă
părinte
comite
21b44e44d3
2 a modificat fișierele cu 390 adăugiri și 0 ștergeri
  1. 211 0
      scripts/lint-shell.sh
  2. 179 0
      tests/shell-lint/test-lint-shell.sh

+ 211 - 0
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[@]}"

+ 179 - 0
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" <<EOF
+#!/usr/bin/env bash
+{
+  printf '${name}:'
+  for arg in "\$@"; do
+    printf ' <%s>' "\$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" "<hooks/session-start>" "includes changed extensionless shell shebang file"
+assert_contains "$tool_log" "<tracked.sh>" "includes changed tracked .sh file"
+assert_contains "$tool_log" "<untracked.sh>" "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" "<hooks/session-start>" "--all includes tracked extensionless shell shebang file"
+assert_contains "$tool_log" "<tracked.sh>" "--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