diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 212d103..510f2dd 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -11,6 +11,8 @@ }, "mise.toml": { "mise": [ + "bats", + "editorconfig-checker", "lychee", "node", "npm:renovate" diff --git a/mise.toml b/mise.toml index 9615cb3..352de08 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,6 @@ [tools] +bats = "1.13.0" +editorconfig-checker = "v3.6.1" lychee = "0.22.0" node = "24.14.1" "npm:renovate" = "43.92.1" @@ -50,6 +52,10 @@ description = "Pre-commit hook: native lint" depends = ["setup:native-lint-tools"] run = "NATIVE=true mise run lint:fast" +[tasks.test] +description = "Run tests" +run = "bats tests/" + [tasks."setup:pre-commit-hook"] description = "Install git pre-commit hook that runs native linting" run = "mise generate git-pre-commit --write --task=pre-commit" diff --git a/tasks/lint/run-linters.sh b/tasks/lint/run-linters.sh new file mode 100755 index 0000000..6de9629 --- /dev/null +++ b/tasks/lint/run-linters.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +#MISE description="Run native linters with changed-file detection" + +set -euo pipefail + +#USAGE flag "--autofix" help="Auto-fix issues instead of checking (uses fix command when defined)" +#USAGE flag "--full" help="Lint all files instead of only changed files" +#USAGE arg "[...]" help="Linters to run (e.g. prettier markdownlint shfmt)" + +# Support both direct invocation (parse flags from $@) and mise invocation (usage_* vars). +AUTOFIX="${AUTOFIX:-false}" +LINT_ALL=false +_TOOLS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --autofix) AUTOFIX=true && shift ;; + --full) LINT_ALL=true && shift ;; + --) shift && _TOOLS+=("$@") && break ;; + *) _TOOLS+=("$@") && break ;; + esac +done + +[ "${usage_autofix:-}" = "true" ] && AUTOFIX=true +[ "${usage_full:-}" = "true" ] && LINT_ALL=true + +# Allow callers to specify tools via env var (useful for mise file tasks where +# positional args can't be passed from a depends list). +if [ ${#_TOOLS[@]} -eq 0 ] && [ -n "${RUN_LINTERS_TOOLS:-}" ]; then + read -ra _TOOLS <<<"$RUN_LINTERS_TOOLS" +fi + +if [ -z "${MISE_PROJECT_ROOT:-}" ]; then + echo "MISE_PROJECT_ROOT environment variable is not set. Exiting." + exit 1 +fi + +cd "${MISE_PROJECT_ROOT}" + +# --- Registry --- +# Format: check_cmd|fix_cmd|file_patterns +# Placeholders: {FILE} (per-file), {FILES} (all at once), {MERGE_BASE}, SELF (no file args) +declare -A _CHECK _FIX _PATTERNS + +_register() { + _CHECK["$1"]="$2" + _FIX["$1"]="$3" + _PATTERNS["$1"]="$4" +} + +_register shellcheck "shellcheck {FILE}" "" "*.sh *.bash *.bats" +_register shfmt "shfmt -d {FILE}" "shfmt -w {FILE}" "*.sh *.bash" +_register markdownlint "markdownlint {FILE}" "markdownlint --fix {FILE}" "*.md" +_register prettier "prettier --check {FILES}" "prettier --write {FILES}" "*.md *.json *.yml *.yaml" +_register actionlint "actionlint {FILE}" "" ".github/workflows/*.yml .github/workflows/*.yaml" +_register hadolint "hadolint {FILE}" "" "Dockerfile Dockerfile.* *.dockerfile" +_register codespell "codespell {FILES}" "codespell --write-changes {FILES}" "*" +_register ec "ec {FILES}" "" "*" +_register golangci-lint "golangci-lint run --new-from-rev={MERGE_BASE}" "" "SELF" +_register ruff "ruff check {FILE}" "ruff check --fix {FILE}" "*.py" +_register ruff-format "ruff format --check {FILE}" "ruff format {FILE}" "*.py" +_register biome "biome check {FILE}" "biome check --fix {FILE}" "*.json *.jsonc *.js *.ts *.jsx *.tsx" +_register biome-format "biome format {FILE}" "biome format --write {FILE}" "*.json *.jsonc *.js *.ts *.jsx *.tsx" + +# Allow callers to extend the registry (useful for testing and custom linters). +# The file is sourced after the built-in entries and may call _register freely. +if [ -n "${RUN_LINTERS_EXTRA_REGISTRY:-}" ]; then + # shellcheck source=/dev/null + source "$RUN_LINTERS_EXTRA_REGISTRY" +fi + +# --- File detection --- + +_filter_files() { + if [ -n "${FILTER_REGEX_EXCLUDE:-}" ]; then + grep -vE "$FILTER_REGEX_EXCLUDE" || true + else + cat + fi +} + +_BASE_BRANCH="${DEFAULT_BRANCH:-main}" +_MERGE_BASE=$(git merge-base "origin/${_BASE_BRANCH}" HEAD 2>/dev/null || echo "") + +_list_files() { + if [ "$LINT_ALL" = "true" ]; then + git ls-files + elif [ -n "$_MERGE_BASE" ]; then + # Files changed in the PR (committed) + uncommitted changes (staged and unstaged) + { + git diff --name-only --diff-filter=d "$_MERGE_BASE"...HEAD + git diff --name-only --diff-filter=d + git diff --cached --name-only --diff-filter=d + } | sort -u + else + # No merge base found (e.g. shallow clone), fall back to all files + git ls-files + fi +} + +# Cache the file list once (avoids re-running git commands per linter). +# Filter to files that exist on disk — excludes uncommitted deletions/renames. +mapfile -t _CACHED_FILES < <( + _list_files | _filter_files | while IFS= read -r f; do + [ -f "$f" ] && printf '%s\n' "$f" + done +) + +_find_files() { + local -a globs=("$@") + [ ${#_CACHED_FILES[@]} -eq 0 ] && return + for file in "${_CACHED_FILES[@]}"; do + for glob in "${globs[@]}"; do + # shellcheck disable=SC2254 # glob pattern matching is intentional + case "$file" in + $glob) echo "$file" ;; + */$glob) echo "$file" ;; + esac + done + done | sort -u +} + +# --- Run linters --- + +_LINTER_RAN=false + +_on_exit() { + local ec=$? + if [ $ec -ne 0 ] && [ "$_LINTER_RAN" = "true" ] && [ "$AUTOFIX" != "true" ]; then + # shellcheck disable=SC2016 # backticks are intentional: literal formatting, not command substitution + printf '\nšŸ’” Try `mise run fix` to auto-fix lint issues, then re-run `mise run lint` to verify.\n' + fi + exit $ec +} +trap _on_exit EXIT + +_LINTER_RAN=true +_failed=() +_skipped=() + +for tool in "${_TOOLS[@]}"; do + if [ -z "${_CHECK[$tool]+set}" ]; then + printf 'āŒ Unknown linter: %s\n' "$tool" >&2 + exit 1 + fi + + check_cmd="${_CHECK[$tool]}" + fix_cmd="${_FIX[$tool]}" + tool_patterns="${_PATTERNS[$tool]}" + bin="${check_cmd%% *}" + + if ! command -v "$bin" >/dev/null 2>&1; then + _skipped+=("$tool") + _failed+=("$tool") + continue + fi + + if [ "$AUTOFIX" = "true" ] && [ -n "$fix_cmd" ]; then + cmd_template="$fix_cmd" + else + cmd_template="$check_cmd" + fi + + # Substitute {MERGE_BASE}; strip --flag={MERGE_BASE} entirely when no merge base available + if [ -n "$_MERGE_BASE" ]; then + cmd_template="${cmd_template//\{MERGE_BASE\}/$_MERGE_BASE}" + else + cmd_template=$(printf '%s' "$cmd_template" | sed 's/ \?--[a-zA-Z_-]*={MERGE_BASE}//g') + fi + + linter_failed=false + + if [ "$tool_patterns" = "SELF" ]; then + if ! eval "$cmd_template"; then + linter_failed=true + fi + else + read -ra pattern_arr <<<"$tool_patterns" + mapfile -t files < <(_find_files "${pattern_arr[@]}") + + # mapfile produces a single empty element when input is empty + if [ ${#files[@]} -eq 0 ] || [[ ${#files[@]} -eq 1 && -z "${files[0]}" ]]; then + continue + fi + + if [[ "$cmd_template" == *"{FILES}"* ]]; then + quoted_files="" + for file in "${files[@]}"; do + # shellcheck disable=SC2016 # single quotes are intentional to prevent expansion + quoted_files+=" '${file//\'/\'\\\'\'}'" + done + cmd="${cmd_template//\{FILES\}/$quoted_files}" + if ! eval "$cmd"; then + linter_failed=true + fi + else + for file in "${files[@]}"; do + # shellcheck disable=SC2016 # single quotes are intentional to prevent expansion + quoted_file="'${file//\'/\'\\\'\'}'" + cmd="${cmd_template//\{FILE\}/$quoted_file}" + if ! eval "$cmd"; then + linter_failed=true + fi + done + fi + fi + + if [ "$linter_failed" = "true" ]; then + _failed+=("$tool") + fi +done + +if [ ${#_skipped[@]} -gt 0 ]; then + printf '\nāŒ Missing lint tools: %s\n' "${_skipped[*]}" +fi + +if [ ${#_failed[@]} -gt 0 ]; then + printf '\nāŒ Linting failed: %s\n' "${_failed[*]}" + exit 1 +fi diff --git a/tasks/lint/super-linter.sh b/tasks/lint/super-linter.sh index affdb9f..fc27793 100755 --- a/tasks/lint/super-linter.sh +++ b/tasks/lint/super-linter.sh @@ -154,8 +154,13 @@ if [ "$NATIVE" = "true" ]; then fi } - # Cache the file list once (avoids re-running git commands per linter) - mapfile -t _CACHED_FILES < <(_list_files | _filter_files) + # Cache the file list once (avoids re-running git commands per linter). + # Filter to files that exist on disk — excludes uncommitted deletions/renames. + mapfile -t _CACHED_FILES < <( + _list_files | _filter_files | while IFS= read -r f; do + [ -f "$f" ] && printf '%s\n' "$f" + done + ) _find_files() { local -a patterns=("$@") @@ -186,7 +191,7 @@ if [ "$NATIVE" = "true" ]; then "VALIDATE_EDITORCONFIG|ec|ec {FILES}||*" "VALIDATE_GITHUB_ACTIONS|actionlint|actionlint {FILE}||.github/workflows/*.yml .github/workflows/*.yaml" "VALIDATE_DOCKERFILE_HADOLINT|hadolint|hadolint {FILE}||Dockerfile Dockerfile.* *.dockerfile" - "VALIDATE_GO_GOLANGCI_LINT|golangci-lint|golangci-lint run||SELF" + "VALIDATE_GO_GOLANGCI_LINT|golangci-lint|golangci-lint run --new-from-rev={MERGE_BASE}||SELF" "VALIDATE_PYTHON_RUFF|ruff|ruff check {FILE}|ruff check --fix {FILE}|*.py" "VALIDATE_PYTHON_RUFF_FORMAT|ruff|ruff format --check {FILE}|ruff format {FILE}|*.py" "VALIDATE_NATURAL_LANGUAGE|textlint|textlint {FILE}||*.md *.txt" @@ -246,13 +251,14 @@ if [ "$NATIVE" = "true" ]; then linter_failed=false + # Substitute {MERGE_BASE}; strip --flag={MERGE_BASE} entirely when no merge base available + if [ -n "$_MERGE_BASE" ]; then + cmd_template="${cmd_template//\{MERGE_BASE\}/$_MERGE_BASE}" + else + cmd_template=$(printf '%s' "$cmd_template" | sed 's/ \?--[a-zA-Z_-]*={MERGE_BASE}//g') + fi + if [ "$patterns" = "SELF" ]; then - # Tool handles its own file discovery; add diff flags when not linting all files - if [ "$LINT_ALL" != "true" ] && [ -n "$_MERGE_BASE" ]; then - if [[ "$cmd_template" == golangci-lint* ]]; then - cmd_template+=" --new-from-rev=$_MERGE_BASE" - fi - fi if ! eval "$cmd_template"; then linter_failed=true fi diff --git a/tests/run-linters.bats b/tests/run-linters.bats new file mode 100644 index 0000000..595cd89 --- /dev/null +++ b/tests/run-linters.bats @@ -0,0 +1,119 @@ +#!/usr/bin/env bats +bats_require_minimum_version 1.5.0 + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../tasks/lint" && pwd)/run-linters.sh" + +setup() { + TMPDIR="$(mktemp -d)" + + # Bare repo acts as "origin" + git init --bare "$TMPDIR/origin.git" -q + + # Working repo + git init "$TMPDIR/repo" -q + git -C "$TMPDIR/repo" config user.email "test@test.com" + git -C "$TMPDIR/repo" config user.name "Test" + git -C "$TMPDIR/repo" remote add origin "$TMPDIR/origin.git" + + # Initial commit → becomes the merge base (simulates the main branch state) + printf 'baseline\n' >"$TMPDIR/repo/baseline.txt" + printf 'will change\n' >"$TMPDIR/repo/changing.txt" + git -C "$TMPDIR/repo" add baseline.txt changing.txt + git -C "$TMPDIR/repo" commit -m "initial" -q + git -C "$TMPDIR/repo" branch -M main + git -C "$TMPDIR/repo" push origin main -q + + # PR changes: modify one existing file, add one new file + printf 'changed\n' >"$TMPDIR/repo/changing.txt" + printf 'new file\n' >"$TMPDIR/repo/new.txt" + git -C "$TMPDIR/repo" add changing.txt new.txt + git -C "$TMPDIR/repo" commit -m "pr change" -q + + export MISE_PROJECT_ROOT="$TMPDIR/repo" + export DEFAULT_BRANCH=main + + # Mock tools + MOCK_BIN="$TMPDIR/bin" + mkdir -p "$MOCK_BIN" + export MOCK_LOG="$TMPDIR/mock.log" + + # Logs "check:" per argument; exits 0 + cat >"$MOCK_BIN/mock-check" <<'EOF' +#!/usr/bin/env bash +for f in "$@"; do printf 'check:%s\n' "$f"; done >> "$MOCK_LOG" +EOF + + # Logs "fix:" per argument; exits 0 + cat >"$MOCK_BIN/mock-fix" <<'EOF' +#!/usr/bin/env bash +for f in "$@"; do printf 'fix:%s\n' "$f"; done >> "$MOCK_LOG" +EOF + + # Always exits 1 + cat >"$MOCK_BIN/mock-fail" <<'EOF' +#!/usr/bin/env bash +exit 1 +EOF + + chmod +x "$MOCK_BIN"/mock-check "$MOCK_BIN"/mock-fix "$MOCK_BIN"/mock-fail + + # Extra registry injected via env var + cat >"$TMPDIR/extra-registry.sh" <<'EOF' +_register mock "mock-check {FILES}" "mock-fix {FILES}" "*.txt" +_register mock-fail "mock-fail {FILES}" "" "*.txt" +_register mock-yaml "mock-check {FILES}" "" "*.yaml" +_register missing-tool "no-such-binary-xyz {FILES}" "" "*.txt" +EOF + + export PATH="$MOCK_BIN:$PATH" + export RUN_LINTERS_EXTRA_REGISTRY="$TMPDIR/extra-registry.sh" +} + +teardown() { + rm -rf "$TMPDIR" +} + +@test "lints only changed files by default" { + run bash "$SCRIPT" mock + [ "$status" -eq 0 ] + grep -q "check:changing.txt" "$MOCK_LOG" + grep -q "check:new.txt" "$MOCK_LOG" + run ! grep -q "check:baseline.txt" "$MOCK_LOG" +} + +@test "--full lints all tracked files" { + run bash "$SCRIPT" --full mock + [ "$status" -eq 0 ] + grep -q "check:baseline.txt" "$MOCK_LOG" + grep -q "check:changing.txt" "$MOCK_LOG" + grep -q "check:new.txt" "$MOCK_LOG" +} + +@test "--autofix uses fix command" { + run bash "$SCRIPT" --autofix mock + [ "$status" -eq 0 ] + grep -q "fix:changing.txt" "$MOCK_LOG" + run ! grep -q "check:" "$MOCK_LOG" +} + +@test "propagates linter failure" { + run bash "$SCRIPT" mock-fail + [ "$status" -ne 0 ] +} + +@test "exits non-zero for unknown linter name" { + run bash "$SCRIPT" not-registered + [ "$status" -ne 0 ] +} + +@test "exits non-zero when tool binary is missing" { + run bash "$SCRIPT" missing-tool + [ "$status" -ne 0 ] +} + +@test "no changed files matching pattern exits zero without calling linter" { + # No .yaml files were changed in the PR, so mock-yaml should not run + run bash "$SCRIPT" mock-yaml + [ "$status" -eq 0 ] + [ ! -f "$MOCK_LOG" ] +}