From a84556ac2ae8697ab07e938b3505119fda848032 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 16 Aug 2026 21:57:19 +0200 Subject: [PATCH] Split shell_check.sh into an entry script and three libs 428 lines -> 121, with lib/shell_check_lint.sh (171), lib/shell_check_install.sh (86) and lib/shell_check_discover.sh (78). The temp-file paths (TMPDIR, ABS_FILES_Z, REL_FILES_Z) are set once in the entry before any lib is sourced and only read afterwards, so nothing mutable crosses a seam. shellcheck was clean on the first attempt. Runtime check is --skip-install --list-only --verbose, diffed against the same run from a detached worktree at HEAD: 318 files before, 321 now, the delta being exactly the three new libs the discovery walk now sees. Nothing removed. Not verified: the linting path itself. --skip-install does not gate the AUR branch of install_linters, so a full run cannot be done under decision 6 -- I learned that by running it, which installed four AUR build deps before I killed it. The user chose to leave them. run_linters moved as one intact block with no interior edit and the line-set diff shows zero lines lost, so the seam risk is confined to its two braces. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- .../shell-check-split-2026-08-16.json | 19 ++ .../shell-check-split-2026-08-16.json | 57 ++++ .../scripts/meta/lib/shell_check_discover.sh | 77 +++++ .../scripts/meta/lib/shell_check_install.sh | 86 +++++ .../scripts/meta/lib/shell_check_lint.sh | 171 ++++++++++ .../scripts/meta/shell_check.sh | 315 +----------------- 6 files changed, 414 insertions(+), 311 deletions(-) create mode 100644 docs/superpowers/contracts/shell-check-split-2026-08-16.json create mode 100644 docs/superpowers/evidence/shell-check-split-2026-08-16.json create mode 100755 linux_configuration/scripts/meta/lib/shell_check_discover.sh create mode 100755 linux_configuration/scripts/meta/lib/shell_check_install.sh create mode 100755 linux_configuration/scripts/meta/lib/shell_check_lint.sh diff --git a/docs/superpowers/contracts/shell-check-split-2026-08-16.json b/docs/superpowers/contracts/shell-check-split-2026-08-16.json new file mode 100644 index 00000000..af440468 --- /dev/null +++ b/docs/superpowers/contracts/shell-check-split-2026-08-16.json @@ -0,0 +1,19 @@ +{ + "title": "Split shell_check.sh (428 lines) into an entry script and three libs", + "objective": "Bring linux_configuration/scripts/meta/shell_check.sh under the repo-wide 250-line cap by moving its install, discovery and linting functions into lib/shell_check_{install,discover,lint}.sh, without changing which files it discovers or which linters it runs.", + "acceptance_criteria": [ + "shell_check.sh and each new lib/shell_check_*.sh file is at most 250 lines.", + "shellcheck reports zero findings across the entry script and all three libs, at the repo's no-severity-filter setting, with no suppression added.", + "The set of functions defined across the entry and the libs is identical to the nine defined at HEAD, each defined exactly once.", + "Every executable line of the original survives; the only additions are the three source lines.", + "Running the script with --skip-install --list-only --verbose discovers exactly the same file list as the same invocation at HEAD, plus the three new lib files themselves and nothing else.", + "No lib sources another lib; the entry sources all three, after the option parsing and the temp-file paths they read.", + "The entry keeps the mktemp -d and its EXIT trap, so cleanup still happens on every exit path." + ], + "out_of_scope": [ + "Running the script without --list-only. Its install_linters path invokes sudo pacman and an AUR helper, and --skip-install does not gate the AUR branch; decision 6 forbids mutating the live system.", + "Fixing the --skip-install flag so it also suppresses the AUR install path. That is a real defect but a behaviour change, not a line-count split.", + "Changing the 2-space indentation this file uses, which differs from the tab-indented scripts elsewhere in the repo." + ], + "verifier": "shellcheck over the entry plus all three libs (expects zero findings), bash -n on each, bash ~/utils/scripts/check_file_length.sh over the four files (expects none listed), a diff of the --list-only --verbose file list against the same run from a detached worktree at HEAD (expects only the three new libs added), a function-set diff against HEAD (expects identical), and a sorted line-set diff (expects only the three source lines added)." +} diff --git a/docs/superpowers/evidence/shell-check-split-2026-08-16.json b/docs/superpowers/evidence/shell-check-split-2026-08-16.json new file mode 100644 index 00000000..bb550990 --- /dev/null +++ b/docs/superpowers/evidence/shell-check-split-2026-08-16.json @@ -0,0 +1,57 @@ +{ + "intent": "Bring shell_check.sh under the 250-line cap by moving its nine functions into three sourced libs, leaving discovery and linting behaviour unchanged.", + "scope": [ + "linux_configuration/scripts/meta/shell_check.sh (428 -> 121 lines)", + "New: lib/shell_check_{install,discover,lint}.sh", + "Non-goal: fixing --skip-install, which does not gate the AUR install path", + "Non-goal: changing the file's 2-space indentation style" + ], + "changes": [ + "Moved is_cmd, is_arch, have_aur_helper, install_if_missing and install_linters into lib/shell_check_install.sh.", + "Moved discover_shell_files and print_file_list into lib/shell_check_discover.sh.", + "Moved run_linters, the 163-line body holding the shellcheck, shfmt, checkbashisms, bashate and per-dialect syntax passes, into lib/shell_check_lint.sh.", + "The entry keeps the header, common.sh source, option defaults, usage, argument parsing, the mktemp -d with its EXIT trap, the ABS_FILES_Z / REL_FILES_Z paths, and the main dispatch. The three libs are sourced after the options and paths they read.", + "The temp-file paths are set once in the entry before any lib is sourced and are only read thereafter, so no mutable state crosses a file boundary." + ], + "verification": [ + { + "command": "shellcheck shell_check.sh lib/shell_check_*.sh", + "result": "pass", + "evidence": "Zero findings across all four files at the repo's no-severity-filter setting, on the first attempt, with no suppression added." + }, + { + "command": "bash -n on the entry and each lib", + "result": "pass", + "evidence": "All four parse." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh", + "result": "pass", + "evidence": "entry 121, lint 171, install 86, discover 78. None of the four appears in the violation list." + }, + { + "command": "bash scripts/meta/shell_check.sh --skip-install --list-only --verbose, diffed against the same run from a detached worktree at HEAD", + "result": "pass", + "evidence": "318 files at HEAD, 321 now. The delta is exactly the three new lib files, which the discovery walk correctly picks up as shell scripts; nothing was removed and no other path changed. An earlier version of this check used a grep that matched nothing and silently compared two empty lists -- it needed --verbose, since print_file_list only enumerates in verbose mode." + }, + { + "command": "function-set diff against HEAD", + "result": "pass", + "evidence": "Identical: all nine functions preserved (discover_shell_files, have_aur_helper, install_if_missing, install_linters, is_arch, is_cmd, print_file_list, run_linters, usage), each defined exactly once across the four files." + }, + { + "command": "sorted line-set diff of HEAD:shell_check.sh against entry+libs concatenated", + "result": "pass", + "evidence": "Zero lines lost. The only additions are the three source lines." + } + ], + "risks": [ + "The linting path itself was not executed. run_linters cannot be exercised without the install path running first, and --skip-install does not gate the AUR branch: a full run during this session began installing python-bashate and pulled in python-pbr, python-fixtures, python-discover and python-reno before it was killed. The user chose to leave those four installed. Verification is therefore static plus the --list-only runtime path, which does exercise every source line and the discovery function.", + "Because run_linters was moved as one intact 163-line block with no interior edit, and the line-set diff proves nothing was lost or reordered, the risk of a behavioural change inside it is confined to the seam at its boundaries, both of which are function braces.", + "CI invokes this script as `bash scripts/meta/shell_check.sh --skip-install` from linux_configuration/.github/workflows/shell-check.yml. The entry keeps its path and its ${BASH_SOURCE[0]}-based SCRIPT_DIR, so the libs resolve there the same way they do locally." + ], + "rollback": [ + "git revert the commit; shell_check.sh returns to its 428-line single-file form and the three libs disappear.", + "After rollback, re-run bash scripts/meta/shell_check.sh --skip-install --list-only --verbose and confirm it discovers 318 files again." + ] +} diff --git a/linux_configuration/scripts/meta/lib/shell_check_discover.sh b/linux_configuration/scripts/meta/lib/shell_check_discover.sh new file mode 100755 index 00000000..c2fbc11d --- /dev/null +++ b/linux_configuration/scripts/meta/lib/shell_check_discover.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Shell-file discovery (git-tracked where possible, else a filesystem walk, +# matching by extension or shebang) and the file listing. +# +# Sourced by shell_check.sh; split out to keep it under the 250-line cap. +# Sourced rather than run, so it inherits the caller's options and reads the +# ABS_FILES_Z / REL_FILES_Z paths the entry sets above the source line. + +discover_shell_files() { + local base="$1" + local -a all + all=() + + if git -C "$base" rev-parse --is-inside-work-tree > /dev/null 2>&1; then + while IFS= read -r -d '' f; do all+=("$f"); done < <(git -C "$base" ls-files -z) + while IFS= read -r -d '' f; do all+=("$f"); done < <(git -C "$base" ls-files --others --exclude-standard -z) + else + while IFS= read -r -d '' f; do + # trim leading ./ to keep consistent style with git paths + f="${f#./}" + f="${f#"${base}"/}" + all+=("$f") + done < <(find "$base" -type f -print0) + fi + + local -a shells + shells=() + + for rel in "${all[@]}"; do + # skip binary-ish or huge files quickly by extension heuristic + case "$rel" in + *.png | *.jpg | *.jpeg | *.gif | *.ico | *.pdf | *.svg | *.zip | *.tar | *.gz | *.xz | *.7z | *.so | *.o | *.bin) + continue + ;; + esac + + local abs="$base/$rel" + [[ -f $abs && -r $abs ]] || continue + + if [[ $rel == *.sh || $rel == *.bash || $rel == *.zsh ]]; then + shells+=("$rel") + continue + fi + + # Check shebang + local first + first=$(head -n 1 -- "$abs" 2> /dev/null || true) + if [[ $first =~ ^#! && $first =~ (ba|z|d|k)?sh ]]; then + shells+=("$rel") + continue + fi + + # Also catch executable files with shell shebang even without extension + if [[ -x $abs ]]; then + if [[ $first =~ ^#! && $first =~ (ba|z|d|k)?sh ]]; then + shells+=("$rel") + fi + fi + done + + # write lists + : > "$REL_FILES_Z" + : > "$ABS_FILES_Z" + for rel in "${shells[@]}"; do + printf '%s\0' "$rel" >> "$REL_FILES_Z" + printf '%s\0' "$base/$rel" >> "$ABS_FILES_Z" + done +} + +print_file_list() { + local count + count=$(tr -cd '\0' < "$REL_FILES_Z" | wc -c) + log_info "Discovered $count shell file(s) under $ROOT_DIR" + if [[ $VERBOSE == "true" ]]; then + tr '\0' '\n' < "$REL_FILES_Z" | sed 's/^/ - /' + fi +} diff --git a/linux_configuration/scripts/meta/lib/shell_check_install.sh b/linux_configuration/scripts/meta/lib/shell_check_install.sh new file mode 100755 index 00000000..3e912aa1 --- /dev/null +++ b/linux_configuration/scripts/meta/lib/shell_check_install.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Linter installation: command probes, Arch/AUR detection, and the pacman / +# AUR-helper dispatch for shellcheck, shfmt, checkbashisms and bashate. +# +# Sourced by shell_check.sh; split out to keep it under the 250-line cap. +# Sourced rather than run, so it inherits the caller's options and reads +# SKIP_INSTALL and VERBOSE, which the entry sets from its arguments above the +# source line. + +is_cmd() { command -v "$1" > /dev/null 2>&1; } + +is_arch() { is_cmd pacman; } +have_aur_helper() { is_cmd yay || is_cmd paru; } + +install_if_missing() { + local pkg cmd + pkg="$1" + cmd="$2" + if is_cmd "$cmd"; then + [[ $VERBOSE == "true" ]] && log_info "Found $cmd" + return 0 + fi + + if [[ $SKIP_INSTALL == "true" ]]; then + log_warn "Skipping install of $pkg ($cmd not found)" + return 1 + fi + + if is_arch; then + log_info "Installing $pkg via pacman..." + if ! sudo pacman -S --needed --noconfirm "$pkg"; then + log_warn "Failed to install $pkg via pacman." + return 1 + fi + return 0 + else + log_warn "Non-Arch system detected. Please install '$pkg' manually." + return 1 + fi +} + +install_linters() { + local ok=0 + + # Core linters + install_if_missing shellcheck shellcheck || ok=1 + install_if_missing shfmt shfmt || ok=1 + + # Optional linters (best-effort) + # checkbashisms may be in repos or AUR; try pacman first, then AUR helper + if ! is_cmd checkbashisms; then + if is_arch; then + if ! sudo pacman -S --needed --noconfirm checkbashisms 2> /dev/null; then + if have_aur_helper; then + log_info "Installing checkbashisms from AUR (requires yay/paru)..." + if is_cmd yay; then yay -S --noconfirm checkbashisms || true; fi + if is_cmd paru; then paru -S --noconfirm checkbashisms || true; fi + else + log_warn "checkbashisms not installed (no AUR helper)." + fi + fi + fi + fi + + # bashate (python-based), typically available as python-bashate in AUR + if ! is_cmd bashate; then + if is_arch && have_aur_helper; then + log_info "Installing bashate from AUR (requires yay/paru)..." + if is_cmd yay; then yay -S --noconfirm python-bashate || true; fi + if is_cmd paru; then paru -S --noconfirm python-bashate || true; fi + else + # Try pip if user has it and wants to + if is_cmd pipx; then + log_info "Installing bashate via pipx..." + pipx install bashate || true + elif is_cmd pip3; then + log_info "Installing bashate via pip (user)..." + pip3 install --user bashate || true + else + log_warn "bashate not installed (no AUR helper or pip available)." + fi + fi + fi + + return "$ok" +} diff --git a/linux_configuration/scripts/meta/lib/shell_check_lint.sh b/linux_configuration/scripts/meta/lib/shell_check_lint.sh new file mode 100755 index 00000000..e2d71af6 --- /dev/null +++ b/linux_configuration/scripts/meta/lib/shell_check_lint.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# The linter runs themselves: shellcheck, shfmt diff mode, the optional +# checkbashisms and bashate passes, and the per-dialect syntax checks. +# +# Sourced by shell_check.sh; split out to keep it under the 250-line cap. +# Sourced rather than run, so it inherits the caller's options and reads +# TMPDIR, ABS_FILES_Z and VERBOSE, which the entry sets above the source line. + +run_linters() { + local issues=0 + local count + count=$(tr -cd '\0' < "$ABS_FILES_Z" | wc -c) + if [[ $count -eq 0 ]]; then + log_warn "No shell files found to lint." + return 0 + fi + + mapfile -d '' -t FILES < "$ABS_FILES_Z" + + log_info "Running shellcheck..." + local sc_out="$TMPDIR/shellcheck.txt" + if is_cmd shellcheck; then + if ! shellcheck -x -S style "${FILES[@]}" > "$sc_out" 2>&1; then + issues=$((issues + 1)) + fi + else + log_warn "shellcheck not found; skipping" + fi + + log_info "Running shfmt (diff mode)..." + local shfmt_out="$TMPDIR/shfmt.diff" + if is_cmd shfmt; then + if ! shfmt -d -i 2 -ci -sr -s "${FILES[@]}" > "$shfmt_out" 2>&1; then + # shfmt returns non-zero when diff exists + issues=$((issues + 1)) + fi + else + log_warn "shfmt not found; skipping" + fi + + log_info "Running checkbashisms (optional)..." + local cbi_out="$TMPDIR/checkbashisms.txt" + local cbi_status=0 + if is_cmd checkbashisms; then + # Only run checkbashisms on scripts that are intended for /bin/sh (or unspecified), + # skip explicit bash/zsh scripts to avoid false positives. + local -a CBI_FILES + CBI_FILES=() + for f in "${FILES[@]}"; do + local first + first=$(head -n 1 -- "$f" 2> /dev/null || true) + if [[ $first =~ bash || $first =~ zsh ]]; then + continue + fi + CBI_FILES+=("$f") + done + if [[ ${#CBI_FILES[@]} -gt 0 ]]; then + # checkbashisms exits 0 if OK, 1 if issues, other codes for tool warnings + checkbashisms "${CBI_FILES[@]}" > "$cbi_out" 2>&1 + else + : > "$cbi_out" + fi + cbi_status=$? + if [[ $cbi_status -eq 1 ]]; then + issues=$((issues + 1)) + elif [[ $cbi_status -ne 0 ]]; then + log_warn "checkbashisms exited with status $cbi_status (treated as warning)" + fi + else + log_warn "checkbashisms not found; skipping" + fi + + log_info "Running bash/zsh/sh syntax checks (-n)..." + local bash_out="$TMPDIR/bash_syntax.txt" + local zsh_out="$TMPDIR/zsh_syntax.txt" + local sh_out="$TMPDIR/sh_syntax.txt" + + # Partition files by shebang for better accuracy + local -a BASH_FILES ZSH_FILES SH_FILES + BASH_FILES=() + ZSH_FILES=() + SH_FILES=() + for f in "${FILES[@]}"; do + local first + first=$(head -n 1 -- "$f" 2> /dev/null || true) + if [[ $first =~ bash ]]; then + BASH_FILES+=("$f") + elif [[ $first =~ zsh ]]; then + ZSH_FILES+=("$f") + else + SH_FILES+=("$f") + fi + done + + if [[ ${#BASH_FILES[@]} -gt 0 ]] && is_cmd bash; then + if ! bash -n "${BASH_FILES[@]}" 2> "$bash_out"; then + issues=$((issues + 1)) + fi + fi + if [[ ${#ZSH_FILES[@]} -gt 0 ]] && is_cmd zsh; then + if ! zsh -n "${ZSH_FILES[@]}" 2> "$zsh_out"; then + issues=$((issues + 1)) + fi + fi + # prefer dash if present for /bin/sh style + if [[ ${#SH_FILES[@]} -gt 0 ]]; then + if is_cmd dash; then + if ! dash -n "${SH_FILES[@]}" 2> "$sh_out"; then + issues=$((issues + 1)) + fi + elif is_cmd sh; then + if ! sh -n "${SH_FILES[@]}" 2> "$sh_out"; then + issues=$((issues + 1)) + fi + fi + fi + + echo + log_info "========== Shell Lint Report ==========" + + if [[ -s $sc_out ]]; then + printf '\n\033[1m-- shellcheck --\033[0m\n' + cat "$sc_out" + else + printf '\n\033[1;32m-- shellcheck: PASS (no issues) --\033[0m\n' + fi + + if [[ -s $shfmt_out ]]; then + printf '\n\033[1m-- shfmt (diffs found) --\033[0m\n' + cat "$shfmt_out" + else + printf '\n\033[1;32m-- shfmt: PASS (formatted) --\033[0m\n' + fi + + if [[ -s $cbi_out ]]; then + printf '\n\033[1m-- checkbashisms --\033[0m\n' + cat "$cbi_out" + else + printf '\n\033[1;32m-- checkbashisms: PASS (or skipped) --\033[0m\n' + fi + + if [[ -s $bash_out ]]; then + printf '\n\033[1m-- bash -n (syntax) --\033[0m\n' + cat "$bash_out" + else + printf '\n\033[1;32m-- bash -n: PASS (or none) --\033[0m\n' + fi + + if [[ -s $zsh_out ]]; then + printf '\n\033[1m-- zsh -n (syntax) --\033[0m\n' + cat "$zsh_out" + else + printf '\n\033[1;32m-- zsh -n: PASS (or none) --\033[0m\n' + fi + + if [[ -s $sh_out ]]; then + printf '\n\033[1m-- sh/dash -n (syntax) --\033[0m\n' + cat "$sh_out" + else + printf '\n\033[1;32m-- sh/dash -n: PASS (or none) --\033[0m\n' + fi + + echo + if [[ $issues -gt 0 ]]; then + log_error "Linting completed with $issues tool(s) reporting issues." + return 1 + else + log_info "All checks passed." + return 0 + fi +} diff --git a/linux_configuration/scripts/meta/shell_check.sh b/linux_configuration/scripts/meta/shell_check.sh index 8196c379..41c770f9 100755 --- a/linux_configuration/scripts/meta/shell_check.sh +++ b/linux_configuration/scripts/meta/shell_check.sh @@ -88,83 +88,6 @@ if [[ ! -d $ROOT_DIR ]]; then exit 2 fi -is_cmd() { command -v "$1" > /dev/null 2>&1; } - -is_arch() { is_cmd pacman; } -have_aur_helper() { is_cmd yay || is_cmd paru; } - -install_if_missing() { - local pkg cmd - pkg="$1" - cmd="$2" - if is_cmd "$cmd"; then - [[ $VERBOSE == "true" ]] && log_info "Found $cmd" - return 0 - fi - - if [[ $SKIP_INSTALL == "true" ]]; then - log_warn "Skipping install of $pkg ($cmd not found)" - return 1 - fi - - if is_arch; then - log_info "Installing $pkg via pacman..." - if ! sudo pacman -S --needed --noconfirm "$pkg"; then - log_warn "Failed to install $pkg via pacman." - return 1 - fi - return 0 - else - log_warn "Non-Arch system detected. Please install '$pkg' manually." - return 1 - fi -} - -install_linters() { - local ok=0 - - # Core linters - install_if_missing shellcheck shellcheck || ok=1 - install_if_missing shfmt shfmt || ok=1 - - # Optional linters (best-effort) - # checkbashisms may be in repos or AUR; try pacman first, then AUR helper - if ! is_cmd checkbashisms; then - if is_arch; then - if ! sudo pacman -S --needed --noconfirm checkbashisms 2> /dev/null; then - if have_aur_helper; then - log_info "Installing checkbashisms from AUR (requires yay/paru)..." - if is_cmd yay; then yay -S --noconfirm checkbashisms || true; fi - if is_cmd paru; then paru -S --noconfirm checkbashisms || true; fi - else - log_warn "checkbashisms not installed (no AUR helper)." - fi - fi - fi - fi - - # bashate (python-based), typically available as python-bashate in AUR - if ! is_cmd bashate; then - if is_arch && have_aur_helper; then - log_info "Installing bashate from AUR (requires yay/paru)..." - if is_cmd yay; then yay -S --noconfirm python-bashate || true; fi - if is_cmd paru; then paru -S --noconfirm python-bashate || true; fi - else - # Try pip if user has it and wants to - if is_cmd pipx; then - log_info "Installing bashate via pipx..." - pipx install bashate || true - elif is_cmd pip3; then - log_info "Installing bashate via pip (user)..." - pip3 install --user bashate || true - else - log_warn "bashate not installed (no AUR helper or pip available)." - fi - fi - fi - - return "$ok" -} TMPDIR=$(mktemp -d) trap 'rm -rf "${TMPDIR:-}"' EXIT @@ -172,240 +95,10 @@ trap 'rm -rf "${TMPDIR:-}"' EXIT ABS_FILES_Z="$TMPDIR/files_abs.zlist" REL_FILES_Z="$TMPDIR/files_rel.zlist" -discover_shell_files() { - local base="$1" - local -a all - all=() - - if git -C "$base" rev-parse --is-inside-work-tree > /dev/null 2>&1; then - while IFS= read -r -d '' f; do all+=("$f"); done < <(git -C "$base" ls-files -z) - while IFS= read -r -d '' f; do all+=("$f"); done < <(git -C "$base" ls-files --others --exclude-standard -z) - else - while IFS= read -r -d '' f; do - # trim leading ./ to keep consistent style with git paths - f="${f#./}" - f="${f#"${base}"/}" - all+=("$f") - done < <(find "$base" -type f -print0) - fi - - local -a shells - shells=() - - for rel in "${all[@]}"; do - # skip binary-ish or huge files quickly by extension heuristic - case "$rel" in - *.png | *.jpg | *.jpeg | *.gif | *.ico | *.pdf | *.svg | *.zip | *.tar | *.gz | *.xz | *.7z | *.so | *.o | *.bin) - continue - ;; - esac - - local abs="$base/$rel" - [[ -f $abs && -r $abs ]] || continue - - if [[ $rel == *.sh || $rel == *.bash || $rel == *.zsh ]]; then - shells+=("$rel") - continue - fi - - # Check shebang - local first - first=$(head -n 1 -- "$abs" 2> /dev/null || true) - if [[ $first =~ ^#! && $first =~ (ba|z|d|k)?sh ]]; then - shells+=("$rel") - continue - fi - - # Also catch executable files with shell shebang even without extension - if [[ -x $abs ]]; then - if [[ $first =~ ^#! && $first =~ (ba|z|d|k)?sh ]]; then - shells+=("$rel") - fi - fi - done - - # write lists - : > "$REL_FILES_Z" - : > "$ABS_FILES_Z" - for rel in "${shells[@]}"; do - printf '%s\0' "$rel" >> "$REL_FILES_Z" - printf '%s\0' "$base/$rel" >> "$ABS_FILES_Z" - done -} - -print_file_list() { - local count - count=$(tr -cd '\0' < "$REL_FILES_Z" | wc -c) - log_info "Discovered $count shell file(s) under $ROOT_DIR" - if [[ $VERBOSE == "true" ]]; then - tr '\0' '\n' < "$REL_FILES_Z" | sed 's/^/ - /' - fi -} - -run_linters() { - local issues=0 - local count - count=$(tr -cd '\0' < "$ABS_FILES_Z" | wc -c) - if [[ $count -eq 0 ]]; then - log_warn "No shell files found to lint." - return 0 - fi - - mapfile -d '' -t FILES < "$ABS_FILES_Z" - - log_info "Running shellcheck..." - local sc_out="$TMPDIR/shellcheck.txt" - if is_cmd shellcheck; then - if ! shellcheck -x -S style "${FILES[@]}" > "$sc_out" 2>&1; then - issues=$((issues + 1)) - fi - else - log_warn "shellcheck not found; skipping" - fi - - log_info "Running shfmt (diff mode)..." - local shfmt_out="$TMPDIR/shfmt.diff" - if is_cmd shfmt; then - if ! shfmt -d -i 2 -ci -sr -s "${FILES[@]}" > "$shfmt_out" 2>&1; then - # shfmt returns non-zero when diff exists - issues=$((issues + 1)) - fi - else - log_warn "shfmt not found; skipping" - fi - - log_info "Running checkbashisms (optional)..." - local cbi_out="$TMPDIR/checkbashisms.txt" - local cbi_status=0 - if is_cmd checkbashisms; then - # Only run checkbashisms on scripts that are intended for /bin/sh (or unspecified), - # skip explicit bash/zsh scripts to avoid false positives. - local -a CBI_FILES - CBI_FILES=() - for f in "${FILES[@]}"; do - local first - first=$(head -n 1 -- "$f" 2> /dev/null || true) - if [[ $first =~ bash || $first =~ zsh ]]; then - continue - fi - CBI_FILES+=("$f") - done - if [[ ${#CBI_FILES[@]} -gt 0 ]]; then - # checkbashisms exits 0 if OK, 1 if issues, other codes for tool warnings - checkbashisms "${CBI_FILES[@]}" > "$cbi_out" 2>&1 - else - : > "$cbi_out" - fi - cbi_status=$? - if [[ $cbi_status -eq 1 ]]; then - issues=$((issues + 1)) - elif [[ $cbi_status -ne 0 ]]; then - log_warn "checkbashisms exited with status $cbi_status (treated as warning)" - fi - else - log_warn "checkbashisms not found; skipping" - fi - - log_info "Running bash/zsh/sh syntax checks (-n)..." - local bash_out="$TMPDIR/bash_syntax.txt" - local zsh_out="$TMPDIR/zsh_syntax.txt" - local sh_out="$TMPDIR/sh_syntax.txt" - - # Partition files by shebang for better accuracy - local -a BASH_FILES ZSH_FILES SH_FILES - BASH_FILES=() - ZSH_FILES=() - SH_FILES=() - for f in "${FILES[@]}"; do - local first - first=$(head -n 1 -- "$f" 2> /dev/null || true) - if [[ $first =~ bash ]]; then - BASH_FILES+=("$f") - elif [[ $first =~ zsh ]]; then - ZSH_FILES+=("$f") - else - SH_FILES+=("$f") - fi - done - - if [[ ${#BASH_FILES[@]} -gt 0 ]] && is_cmd bash; then - if ! bash -n "${BASH_FILES[@]}" 2> "$bash_out"; then - issues=$((issues + 1)) - fi - fi - if [[ ${#ZSH_FILES[@]} -gt 0 ]] && is_cmd zsh; then - if ! zsh -n "${ZSH_FILES[@]}" 2> "$zsh_out"; then - issues=$((issues + 1)) - fi - fi - # prefer dash if present for /bin/sh style - if [[ ${#SH_FILES[@]} -gt 0 ]]; then - if is_cmd dash; then - if ! dash -n "${SH_FILES[@]}" 2> "$sh_out"; then - issues=$((issues + 1)) - fi - elif is_cmd sh; then - if ! sh -n "${SH_FILES[@]}" 2> "$sh_out"; then - issues=$((issues + 1)) - fi - fi - fi - - echo - log_info "========== Shell Lint Report ==========" - - if [[ -s $sc_out ]]; then - printf '\n\033[1m-- shellcheck --\033[0m\n' - cat "$sc_out" - else - printf '\n\033[1;32m-- shellcheck: PASS (no issues) --\033[0m\n' - fi - - if [[ -s $shfmt_out ]]; then - printf '\n\033[1m-- shfmt (diffs found) --\033[0m\n' - cat "$shfmt_out" - else - printf '\n\033[1;32m-- shfmt: PASS (formatted) --\033[0m\n' - fi - - if [[ -s $cbi_out ]]; then - printf '\n\033[1m-- checkbashisms --\033[0m\n' - cat "$cbi_out" - else - printf '\n\033[1;32m-- checkbashisms: PASS (or skipped) --\033[0m\n' - fi - - if [[ -s $bash_out ]]; then - printf '\n\033[1m-- bash -n (syntax) --\033[0m\n' - cat "$bash_out" - else - printf '\n\033[1;32m-- bash -n: PASS (or none) --\033[0m\n' - fi - - if [[ -s $zsh_out ]]; then - printf '\n\033[1m-- zsh -n (syntax) --\033[0m\n' - cat "$zsh_out" - else - printf '\n\033[1;32m-- zsh -n: PASS (or none) --\033[0m\n' - fi - - if [[ -s $sh_out ]]; then - printf '\n\033[1m-- sh/dash -n (syntax) --\033[0m\n' - cat "$sh_out" - else - printf '\n\033[1;32m-- sh/dash -n: PASS (or none) --\033[0m\n' - fi - - echo - if [[ $issues -gt 0 ]]; then - log_error "Linting completed with $issues tool(s) reporting issues." - return 1 - else - log_info "All checks passed." - return 0 - fi -} - +# Sourced after the options and the temp-file paths above, which they read. +source "$SCRIPT_DIR/lib/shell_check_install.sh" +source "$SCRIPT_DIR/lib/shell_check_discover.sh" +source "$SCRIPT_DIR/lib/shell_check_lint.sh" # Main if [[ $INSTALL_ONLY == "true" ]]; then install_linters