mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 11:59:29 +00:00
The linter was running in CI with --warn-only, meaning it never blocked anything — effectively vaporware. This removes --warn-only to make it a real gate. Also adds rules for bash 4.0+ features that were documented in CLAUDE.md but not enforced: - MC014: readarray/mapfile (bash 4.0+) - MC015: coproc (bash 4.0+) - MC016: &>> redirect (bash 4.0+) - MC017: relative source paths (breaks curl|bash) - MC018: wait -n (bash 4.3+) - MC019: declare -g (bash 4.2+) Excludes .claude/worktrees/ from scanning (temp copies, not committed code). Co-authored-by: spawn-bot <spawn-bot@openrouter.ai> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
204 lines
7.2 KiB
Bash
Executable file
204 lines
7.2 KiB
Bash
Executable file
#!/bin/bash
|
|
set -eo pipefail
|
|
|
|
# macOS Compatibility Linter
|
|
# Catches bash 3.2 incompatibilities in shell scripts.
|
|
# This script itself is bash 3.2 compatible.
|
|
#
|
|
# Usage:
|
|
# bash sh/test/macos-compat.sh # Scan all .sh files
|
|
# bash sh/test/macos-compat.sh --warn-only # Always exit 0
|
|
# bash sh/test/macos-compat.sh path/to/file.sh # Scan specific file(s)
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
WARN_ONLY=false
|
|
FILES_CHECKED=0
|
|
|
|
# Parse arguments
|
|
TARGETS=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--warn-only)
|
|
WARN_ONLY=true
|
|
shift
|
|
;;
|
|
*)
|
|
TARGETS="$TARGETS $1"
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
TARGETS="$(printf '%s' "$TARGETS" | sed 's/^ //')"
|
|
|
|
# Check if a path should be excluded
|
|
is_excluded() {
|
|
case "$1" in
|
|
*/.claude/skills/*) return 0 ;;
|
|
*/.claude/worktrees/*) return 0 ;;
|
|
*/.git/*) return 0 ;;
|
|
*/node_modules/*) return 0 ;;
|
|
*/packages/cli/*) return 0 ;;
|
|
*/test/macos-compat.sh) return 0 ;; # Don't lint ourselves
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# Collect .sh files to check
|
|
collect_files() {
|
|
if [ -n "$TARGETS" ]; then
|
|
for _cf_target in $TARGETS; do
|
|
if [ -d "$_cf_target" ]; then
|
|
find "$_cf_target" -name '*.sh' -type f
|
|
elif [ -f "$_cf_target" ]; then
|
|
printf '%s\n' "$_cf_target"
|
|
fi
|
|
done
|
|
else
|
|
find "$REPO_ROOT" -name '*.sh' -type f
|
|
fi
|
|
}
|
|
|
|
# Temp file for all findings
|
|
_findings="$(mktemp)"
|
|
trap 'rm -f "$_findings"' EXIT
|
|
|
|
# grep_rule: fast grep-based rule check, appends findings to $_findings
|
|
# Filters out comment lines (leading whitespace + #)
|
|
# Args: severity rule_id message file relpath pattern
|
|
grep_rule() {
|
|
local sev="$1" rule="$2" msg="$3" file="$4" rel="$5" pattern="$6"
|
|
grep -nE "$pattern" "$file" 2>/dev/null | while IFS=: read -r lnum content; do
|
|
# Skip comment lines
|
|
case "$(printf '%s' "$content" | sed 's/^[[:space:]]*//')" in
|
|
'#'*) continue ;;
|
|
esac
|
|
printf '%s %s:%s %s %s\n' "$sev" "$rel" "$lnum" "$rule" "$msg"
|
|
done >> "$_findings" || true
|
|
}
|
|
|
|
# Collect all files
|
|
_all_files="$(collect_files | sort)"
|
|
|
|
# Process each file
|
|
while IFS= read -r _f; do
|
|
[ -z "$_f" ] && continue
|
|
is_excluded "$_f" && continue
|
|
|
|
FILES_CHECKED=$((FILES_CHECKED + 1))
|
|
_r="$(printf '%s' "$_f" | sed "s|^${REPO_ROOT}/||")"
|
|
|
|
# MC001: base64 -w0 with file arg (not stdin redirect) — two-pass
|
|
grep -nE 'base64.*-w0[[:space:]]+["$]' "$_f" 2>/dev/null | while IFS=: read -r lnum content; do
|
|
case "$(printf '%s' "$content" | sed 's/^[[:space:]]*//')" in '#'*) continue ;; esac
|
|
printf '%s' "$content" | grep -q '<[[:space:]]' && continue
|
|
printf 'error %s:%s MC001 %s\n' "$_r" "$lnum" \
|
|
"'base64 -w0 \$file' (GNU-only) — use 'base64 -w0 < \$file' instead"
|
|
done >> "$_findings" || true
|
|
|
|
# MC002: non-portable echo flags
|
|
_mc002_msg="'echo"
|
|
_mc002_msg="${_mc002_msg} -e' is not portable — use printf instead"
|
|
grep_rule "error" "MC002" "$_mc002_msg" \
|
|
"$_f" "$_r" 'echo[[:space:]]+-[en]*e[en]*[[:space:]]'
|
|
|
|
# MC003: source <(...) or . <(...)
|
|
grep_rule "error" "MC003" "'source <(...)' fails in bash <(curl...) — use eval instead" \
|
|
"$_f" "$_r" '(source|\.)[[:space:]]+<\('
|
|
|
|
# MC004: ((var++)) or ((var--)) — post-increment
|
|
grep_rule "error" "MC004" "'((var++))' can fail with set -e — use var=\$((var + 1))" \
|
|
"$_f" "$_r" '\(\([[:space:]]*[a-zA-Z_]+[[:space:]]*(\+\+|--)[[:space:]]*\)\)'
|
|
|
|
# MC004: ((++var)) or ((--var)) — pre-increment
|
|
grep_rule "error" "MC004" "'((++var))' can fail with set -e — use var=\$((var + 1))" \
|
|
"$_f" "$_r" '\(\([[:space:]]*(\+\+|--)[[:space:]]*[a-zA-Z_]+[[:space:]]*\)\)'
|
|
|
|
# MC005: read -d
|
|
grep_rule "error" "MC005" "'read -d' requires bash 4+ — use alternative approach" \
|
|
"$_f" "$_r" 'read[[:space:]].*-d'
|
|
|
|
# MC006: nounset flag (set -u and variants)
|
|
grep_rule "error" "MC006" "'set -u' (nounset) — use \${VAR:-} instead" \
|
|
"$_f" "$_r" 'set[[:space:]]+-[a-zA-Z]*u'
|
|
|
|
# MC007: sed -i without '' (warn only) — two-pass
|
|
grep -nE "sed[[:space:]]+-i[[:space:]]" "$_f" 2>/dev/null | while IFS=: read -r lnum content; do
|
|
case "$(printf '%s' "$content" | sed 's/^[[:space:]]*//')" in '#'*) continue ;; esac
|
|
printf '%s' "$content" | grep -qE "sed[[:space:]]+-i[[:space:]]+''" && continue
|
|
printf "warn %s:%s MC007 'sed -i' without '' may fail on macOS\n" "$_r" "$lnum"
|
|
done >> "$_findings" || true
|
|
|
|
# MC008: date %N (nanoseconds)
|
|
grep_rule "error" "MC008" "'date %N' (nanoseconds) not available on macOS" \
|
|
"$_f" "$_r" 'date[[:space:]][^|;]*%N'
|
|
|
|
# MC009: local -n / declare -n (namerefs)
|
|
grep_rule "error" "MC009" "'local -n' (namerefs) requires bash 4.3+" \
|
|
"$_f" "$_r" '(local|declare)[[:space:]]+-n[[:space:]]'
|
|
|
|
# MC010: declare -A (associative arrays)
|
|
grep_rule "error" "MC010" "'declare -A' (associative arrays) requires bash 4.0+" \
|
|
"$_f" "$_r" 'declare[[:space:]]+-A[[:space:]]'
|
|
|
|
# MC011: ${var,,} or ${var^^} (case modification)
|
|
grep_rule "error" "MC011" "'\${var,,}'/'\${var^^}' (case modification) requires bash 4.0+" \
|
|
"$_f" "$_r" '\$\{[a-zA-Z_][a-zA-Z0-9_]*(,,|\^\^)'
|
|
|
|
# MC012: |& (pipe stderr)
|
|
grep_rule "error" "MC012" "'|&' (pipe stderr) requires bash 4.0+ — use 2>&1 | instead" \
|
|
"$_f" "$_r" '\|&[^&]'
|
|
|
|
# MC013: printf -v (variable assignment via printf)
|
|
grep_rule "error" "MC013" "'printf -v' requires bash 4.0+ — use eval or stdout capture" \
|
|
"$_f" "$_r" 'printf[[:space:]]+-v[[:space:]]'
|
|
|
|
# MC014: readarray / mapfile (bash 4.0+)
|
|
grep_rule "error" "MC014" "'readarray'/'mapfile' requires bash 4.0+ — use while read loop" \
|
|
"$_f" "$_r" '\b(readarray|mapfile)\b'
|
|
|
|
# MC015: coproc (bash 4.0+)
|
|
grep_rule "error" "MC015" "'coproc' requires bash 4.0+" \
|
|
"$_f" "$_r" '\bcoproc\b'
|
|
|
|
# MC016: &>> (append redirect stderr+stdout, bash 4.0+)
|
|
grep_rule "error" "MC016" "'&>>' requires bash 4.0+ — use >> file 2>&1 instead" \
|
|
"$_f" "$_r" '&>>'
|
|
|
|
# MC017: relative source paths (breaks bash <(curl ...) execution)
|
|
grep_rule "error" "MC017" "relative source path breaks bash <(curl...) — use absolute path or eval" \
|
|
"$_f" "$_r" '(source|\.)[[:space:]]+\.\.?/'
|
|
|
|
# MC018: wait -n (bash 4.3+)
|
|
grep_rule "error" "MC018" "'wait -n' requires bash 4.3+ — use wait with specific PID" \
|
|
"$_f" "$_r" 'wait[[:space:]]+-n\b'
|
|
|
|
# MC019: declare -g (bash 4.2+)
|
|
grep_rule "error" "MC019" "'declare -g' requires bash 4.2+ — use global assignment instead" \
|
|
"$_f" "$_r" 'declare[[:space:]]+-[a-zA-Z]*g'
|
|
|
|
done <<FILELIST
|
|
$_all_files
|
|
FILELIST
|
|
|
|
# Output findings
|
|
if [ -s "$_findings" ]; then
|
|
cat "$_findings"
|
|
fi
|
|
|
|
# Count errors and warnings from the findings file
|
|
ERRORS=$(grep -c '^error ' "$_findings" 2>/dev/null || true)
|
|
WARNINGS=$(grep -c '^warn ' "$_findings" 2>/dev/null || true)
|
|
|
|
# Summary
|
|
printf '\nmacOS compat: %d error(s), %d warning(s) in %d file(s)\n' "$ERRORS" "$WARNINGS" "$FILES_CHECKED"
|
|
|
|
# Exit code
|
|
if [ "$WARN_ONLY" = true ]; then
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$ERRORS" -gt 0 ]; then
|
|
exit 1
|
|
fi
|
|
|
|
exit 0
|