From dc4d30c23ccbe9b88abcd5b021c58a76beee7ddc Mon Sep 17 00:00:00 2001 From: johnson Date: Mon, 18 May 2026 15:25:11 -0400 Subject: [PATCH 1/2] feat: add aggregate codebase lint entrypoint --- .mise/tasks/lint/_default | 68 +++++++ .mise/tasks/lint/github-actions | 49 +---- .mise/tasks/pre-commit | 131 ++---------- lib/codebase-config.sh | 127 ++++++++++++ test/lint/default.bats | 200 +++++++++++++++++++ test/lint/github-actions/github-actions.bats | 8 +- test/lint/relative-paths.bats | 21 ++ test/pre-commit/pre-commit.bats | 51 +++-- 8 files changed, 467 insertions(+), 188 deletions(-) create mode 100755 .mise/tasks/lint/_default create mode 100644 lib/codebase-config.sh create mode 100644 test/lint/default.bats diff --git a/.mise/tasks/lint/_default b/.mise/tasks/lint/_default new file mode 100755 index 0000000..297abc7 --- /dev/null +++ b/.mise/tasks/lint/_default @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +#MISE description="Run configured codebase convention lints" +#USAGE arg "[target]" default="." help="Path to the target repository" + +set -euo pipefail + +# shellcheck source=../../../lib/codebase-config.sh +source "$MISE_CONFIG_ROOT/lib/codebase-config.sh" + +TARGET_ARG="${usage_target:-.}" + +if ! REPO_ROOT=$(codebase_resolve_repo "$TARGET_ARG"); then + exit 1 +fi + +TOML="$REPO_ROOT/mise.toml" +if [[ ! -f "$TOML" ]]; then + echo "ERROR: no mise.toml found in $REPO_ROOT" >&2 + exit 1 +fi + +RULES=() +while IFS= read -r rule; do + [[ -n "$rule" ]] && RULES+=("$rule") +done < <(codebase_configured_lint_rules "$REPO_ROOT") + +if [[ ${#RULES[@]} -eq 0 ]]; then + echo "ERROR: no lint rules configured in $TOML" >&2 + echo "Add to mise.toml:" >&2 + echo ' [_.codebase]' >&2 + echo ' lint = ["mise-settings", "gum-table"]' >&2 + exit 1 +fi + +failures=0 +FAILED=() + +for rule in "${RULES[@]}"; do + rule_target=$(codebase_target_for_rule "$REPO_ROOT" "$rule") + echo "codebase: lint:$rule $rule_target" + + output="" + if output=$(mise run -q "lint:$rule" "$rule_target" 2>&1); then + status=0 + else + status=$? + fi + + if [[ -n "$output" ]]; then + printf '%s\n' "$output" + fi + + if [[ "$status" -ne 0 ]]; then + failures=$((failures + 1)) + FAILED+=("lint:$rule ($rule_target) exited $status") + fi +done + +if [[ "$failures" -gt 0 ]]; then + echo "" >&2 + echo "codebase: $failures lint rule(s) failed" >&2 + for failed in "${FAILED[@]}"; do + echo " - $failed" >&2 + done + exit 1 +fi + +echo "codebase: all ${#RULES[@]} lint rule(s) passed" diff --git a/.mise/tasks/lint/github-actions b/.mise/tasks/lint/github-actions index 4ba88d5..b19e389 100755 --- a/.mise/tasks/lint/github-actions +++ b/.mise/tasks/lint/github-actions @@ -5,8 +5,8 @@ set -euo pipefail -# shellcheck source=../../../lib/shell-files.sh -source "$MISE_CONFIG_ROOT/lib/shell-files.sh" +# shellcheck source=../../../lib/codebase-config.sh +source "$MISE_CONFIG_ROOT/lib/codebase-config.sh" FIX="${usage_fix:-false}" @@ -27,25 +27,6 @@ if ! command -v actionlint >/dev/null 2>&1; then exit 127 fi -configured_codebase_lints() { - local toml="$1" - - [[ -f "$toml" ]] || return 0 - - awk ' - /^\[_\.codebase\]/ { in_section=1; next } - /^\[/ { in_section=0 } - in_section && /^lint[ \t]*=/ { - gsub(/.*=[ \t]*\[/, "") - gsub(/\].*/, "") - gsub(/"/, "") - gsub(/,/, " ") - gsub(/^[ \t]+|[ \t]+$/, "") - print - } - ' "$toml" -} - has_test_task() { local target="$1" [[ -f "$target/.mise/tasks/test" || -f "$target/.mise/tasks/test/_default" ]] @@ -123,7 +104,7 @@ write_default_workflow() { local target="$1" local workflow="$target/.github/workflows/test.yml" local lints - lints=$(configured_codebase_lints "$target/mise.toml") + lints=$(codebase_configured_lint_rules "$target") if ! has_test_task "$target" && [[ -z "$lints" ]]; then echo "FAIL $(basename "$target"): no workflow found and no checks inferred (add .mise/tasks/test or [_.codebase].lint)" @@ -180,29 +161,7 @@ YAML cat >> "$workflow" <<'YAML' - name: Run codebase lints - run: | - for target in \ -YAML - - # shellcheck disable=SC2086 # intentional word splitting: lints are simple rule names from TOML - set -- $lints - local i=1 - local count=$# - local rule - for rule in "$@"; do - if [[ $i -lt $count ]]; then - printf ' lint:%s \\\n' "$rule" >> "$workflow" - else - printf ' lint:%s -' "$rule" >> "$workflow" - fi - i=$((i + 1)) - done - - cat >> "$workflow" <<'YAML' - do - codebase "$target" "$PWD" - done + run: codebase lint "$PWD" YAML fi diff --git a/.mise/tasks/pre-commit b/.mise/tasks/pre-commit index 81f1bbf..c009a4a 100755 --- a/.mise/tasks/pre-commit +++ b/.mise/tasks/pre-commit @@ -5,13 +5,16 @@ set -euo pipefail +# shellcheck source=../../lib/codebase-config.sh +source "$MISE_CONFIG_ROOT/lib/codebase-config.sh" + REVERT="${usage_revert:-false}" CHECK="${usage_check:-false}" # Find repo root — use CODEBASE_CALLER_PWD (set by shiv) if available, # otherwise fall back to legacy CALLER_PWD during migration and then current directory. CALLER="${CODEBASE_CALLER_PWD:-${CALLER_PWD:-$(pwd)}}" -if ! REPO_ROOT=$(git -C "$CALLER" rev-parse --show-toplevel 2>/dev/null); then +if ! REPO_ROOT=$(codebase_git_root "$CALLER"); then REPO_ROOT="" fi if [[ -z "$REPO_ROOT" ]]; then @@ -45,29 +48,20 @@ if [[ "$REVERT" == "true" ]]; then exit 0 fi -# --- Read config --- +# --- Validate config --- if [[ ! -f "$TOML" ]]; then + [[ "$CHECK" == "true" ]] && exit 1 echo "ERROR: no mise.toml found" >&2 exit 1 fi -RULES=$(awk ' - /^\[_\.codebase\]/ { in_section=1; next } - /^\[/ { in_section=0 } - in_section && /^lint[ \t]*=/ { - gsub(/.*=[ \t]*\[/, "") - gsub(/\].*/, "") - gsub(/"/, "") - gsub(/,/, " ") - gsub(/^[ \t]+|[ \t]+$/, "") - print - } -' "$TOML") - -if [[ -z "$RULES" ]]; then - if [[ "$CHECK" == "true" ]]; then - exit 1 - fi +RULES=() +while IFS= read -r rule; do + [[ -n "$rule" ]] && RULES+=("$rule") +done < <(codebase_configured_lint_rules "$REPO_ROOT") + +if [[ ${#RULES[@]} -eq 0 ]]; then + [[ "$CHECK" == "true" ]] && exit 1 echo "ERROR: no lint rules configured in $TOML" >&2 echo "Add to mise.toml:" >&2 echo ' [_.codebase]' >&2 @@ -75,106 +69,17 @@ if [[ -z "$RULES" ]]; then exit 1 fi -# Read per-rule scope overrides from [_.codebase.scope]. -# Stored as 'rulescope' lines (bash 3 compatible — no associative arrays). -# Tab separator chosen because it can't appear in rule names or fs paths -# read from TOML; ':' would truncate scopes with colons (legal in paths). -USER_SCOPES="" -while IFS='=' read -r rule scope; do - [[ -z "$rule" ]] && continue - rule=$(echo "$rule" | tr -d ' "') - scope=$(echo "$scope" | tr -d ' "') - USER_SCOPES+="${rule}"$'\t'"${scope}"$'\n' -done < <(awk ' - /^\[_\.codebase\.scope\]/ { in_section=1; next } - /^\[/ { in_section=0 } - in_section && /=/ { print } -' "$TOML") - -# Default scopes per rule (same 'rulescope' format). -DEFAULT_SCOPES=$'mise-settings\t.\ngum-table\t.mise/tasks\n' - -lookup_scope() { - local map="$1" rule="$2" - printf '%s' "$map" | awk -F'\t' -v r="$rule" '$1==r { print $2; exit }' -} - -resolve_scope() { - local rule="$1" value - value=$(lookup_scope "$USER_SCOPES" "$rule") - [[ -n "$value" ]] && { echo "$value"; return; } - value=$(lookup_scope "$DEFAULT_SCOPES" "$rule") - [[ -n "$value" ]] && { echo "$value"; return; } - echo "." -} - -# --- Build scope case statement (baked at install time, bash 3 compatible) --- -# Guard against scope strings with single quotes, which would break the -# case body's quoting. Scopes are paths read from mise.toml; apostrophes -# are vanishingly rare but easy to detect and reject cleanly. -SCOPE_CASE="" -for rule in $RULES; do - scope=$(resolve_scope "$rule") - if [[ "$scope" == *"'"* ]]; then - echo "ERROR: scope for rule '$rule' contains a single quote: $scope" >&2 - echo " Single quotes in scopes break the generated hook. Remove or escape." >&2 - exit 1 - fi - SCOPE_CASE+=" $rule) scope='$scope' ;;"$'\n' -done - # --- Generate hook content --- -# The template contains a case statement with ';;'. Bash's parser chokes -# on that inside command-substituted heredocs (even quoted), so we stage -# the template to a tempfile instead of '$(cat < "$TMP_TEMPLATE" <<'HOOK' +NEW_HOOK=$(cat <<'HOOK' #!/usr/bin/env bash # Auto-generated by: codebase pre-commit -# Runs codebase lint rules configured in mise.toml [_.codebase].lint -set -eo pipefail +# Delegates to the aggregate lint entrypoint configured in mise.toml. +set -euo pipefail REPO_ROOT="$(git rev-parse --show-toplevel)" - -RULES=(__RULES__) - -failures=0 - -for rule in "${RULES[@]}"; do - scope='.' - case "$rule" in -__SCOPE_CASE__ - *) scope='.' ;; - esac - - target="$REPO_ROOT/$scope" - [[ "$scope" == "." ]] && target="$REPO_ROOT" - - output=$(codebase lint:"$rule" "$target" 2>&1) || status=$? - if [ "${status:-0}" -ne 0 ]; then - echo "codebase: lint:$rule failed" >&2 - if filtered=$(echo "$output" | grep -vE '^(INFO|OK)|\[lint:'); then - echo "$filtered" >&2 - fi - failures=$((failures + 1)) - fi -done - -if [ "$failures" -gt 0 ]; then - echo "" >&2 - echo "codebase: $failures lint rule(s) failed. Fix issues or commit with --no-verify." >&2 - exit 1 -fi +exec codebase lint "$REPO_ROOT" HOOK - -# Splice dynamic pieces via bash parameter expansion (handles multi-line -# SCOPE_CASE cleanly; awk -v can't accept embedded newlines). -# Strip trailing newline from SCOPE_CASE to avoid a blank line before '*)'. -SCOPE_CASE="${SCOPE_CASE%$'\n'}" -NEW_HOOK=$(cat "$TMP_TEMPLATE") -NEW_HOOK="${NEW_HOOK//__RULES__/$RULES}" -NEW_HOOK="${NEW_HOOK//__SCOPE_CASE__/$SCOPE_CASE}" +) # --- Check mode --- if [[ "$CHECK" == "true" ]]; then diff --git a/lib/codebase-config.sh b/lib/codebase-config.sh new file mode 100644 index 0000000..2d43820 --- /dev/null +++ b/lib/codebase-config.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Shared configuration helpers for codebase tasks. +# +# This file is a lib, not a mise task. Self-locate via BASH_SOURCE rather than +# reading MISE_CONFIG_ROOT; agent sessions can inherit a stale MCR from the +# launcher repo. See fold/notes/mise-gotchas.md. + +_CODEBASE_CONFIG_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./shell-files.sh +source "$_CODEBASE_CONFIG_LIB_DIR/shell-files.sh" + +# codebase_git_root +# +# Resolve to the containing git repo root. Returns non-zero when the +# path is not inside a git repository. +codebase_git_root() { + local path="$1" + git -C "$path" rev-parse --show-toplevel 2>/dev/null +} + +# codebase_resolve_repo +# +# Resolve a caller-relative target path to a repository/config root. If the +# target is inside a git repo, emit that repo's top-level directory. For +# non-git fixture workspaces, fall back to the target directory itself. +codebase_resolve_repo() { + local target="$1" + local resolved root git_dir + + resolved=$(resolve_target "$target") + + if [[ ! -e "$resolved" ]]; then + echo "ERROR: target does not exist: $resolved" >&2 + return 1 + fi + + if [[ -d "$resolved" && -f "$resolved/mise.toml" ]]; then + (cd "$resolved" && pwd -P) + return 0 + fi + + git_dir="$resolved" + [[ -d "$git_dir" ]] || git_dir=$(dirname "$git_dir") + if root=$(codebase_git_root "$git_dir"); then + printf '%s\n' "$root" + return 0 + fi + + if [[ -d "$resolved" ]]; then + (cd "$resolved" && pwd -P) + return 0 + fi + + (cd "$(dirname "$resolved")" && pwd -P) +} + +# codebase_configured_lint_rules +# +# Emit configured [_.codebase].lint rules, one per line. Rule names are expected +# to be simple task suffixes such as "mise-settings" or "gum-table". +codebase_configured_lint_rules() { + local repo_root="$1" + local toml="$repo_root/mise.toml" + local raw + + [[ -f "$toml" ]] || return 0 + + if ! raw=$(mise config get -f "$toml" _.codebase.lint 2>/dev/null); then + return 0 + fi + + printf '%s\n' "$raw" | awk '{ + gsub(/[\[\]",]/, " ") + for (i = 1; i <= NF; i++) print $i + }' +} + +# codebase_default_scope_for_rule +# +# Emit the built-in default scope for rules whose useful target is narrower +# than the whole repo. Rules not listed here default to the repo root. +codebase_default_scope_for_rule() { + local rule="$1" + + case "$rule" in + mise-settings) printf '%s\n' "." ;; + gum-table) printf '%s\n' ".mise/tasks" ;; + *) printf '%s\n' "." ;; + esac +} + +# codebase_scope_for_rule +# +# Emit the effective scope for a rule: user override from +# [_.codebase.scope] when present, otherwise the built-in default. +codebase_scope_for_rule() { + local repo_root="$1" + local rule="$2" + local toml="$repo_root/mise.toml" + local value + + if [[ -f "$toml" ]] && value=$(mise config get -f "$toml" "_.codebase.scope.$rule" 2>/dev/null); then + if [[ -n "$value" ]]; then + printf '%s\n' "$value" + return 0 + fi + fi + + codebase_default_scope_for_rule "$rule" +} + +# codebase_target_for_rule +# +# Emit the concrete target path for a rule after scope resolution. +codebase_target_for_rule() { + local repo_root="$1" + local rule="$2" + local scope + + scope=$(codebase_scope_for_rule "$repo_root" "$rule") + + case "$scope" in + ""|".") printf '%s\n' "$repo_root" ;; + /*) printf '%s\n' "$scope" ;; + *) printf '%s/%s\n' "$repo_root" "$scope" ;; + esac +} diff --git a/test/lint/default.bats b/test/lint/default.bats new file mode 100644 index 0000000..9a6eac1 --- /dev/null +++ b/test/lint/default.bats @@ -0,0 +1,200 @@ +#!/usr/bin/env bats +# Tests for aggregate `codebase lint` + +load ../test_helper + +setup() { + REPO="$BATS_TEST_TMPDIR/repo" + mkdir -p "$REPO" + git -C "$REPO" init -q + REPO_ROOT="$(git -C "$REPO" rev-parse --show-toplevel)" +} + +write_config() { + cat > "$REPO/mise.toml" +} + +write_clean_task() { + mkdir -p "$REPO/.mise/tasks" + cat > "$REPO/.mise/tasks/clean" <<'EOF' +#!/usr/bin/env bash +echo ok +EOF +} + +write_bad_table_script() { + local dir="$1" + mkdir -p "$REPO/$dir" + cat > "$REPO/$dir/table" <<'EOF' +#!/usr/bin/env bash +for item in alpha beta; do + printf "%-10s %s\n" "$item" ok +done +EOF +} + +@test "lint: runs configured rules and passes" { + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["mise-settings"] +EOF + + run codebase lint "$REPO" + [ "$status" -eq 0 ] + [[ "$output" == *"codebase: lint:mise-settings"* ]] + [[ "$output" == *"OK"*"repo"* ]] + [[ "$output" == *"codebase: all 1 lint rule(s) passed"* ]] +} + +@test "lint: default target resolves from CODEBASE_CALLER_PWD" { + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["mise-settings"] +EOF + + CODEBASE_CALLER_PWD="$REPO" run codebase lint + [ "$status" -eq 0 ] + [[ "$output" == *"codebase: lint:mise-settings $REPO_ROOT"* ]] +} + +@test "lint: relative target resolves from CODEBASE_CALLER_PWD" { + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["mise-settings"] +EOF + + CODEBASE_CALLER_PWD="$BATS_TEST_TMPDIR" run codebase lint repo + [ "$status" -eq 0 ] + [[ "$output" == *"codebase: lint:mise-settings $REPO_ROOT"* ]] +} + +@test "lint: target directory config wins over containing git root" { + mkdir -p "$REPO/nested" + cat > "$REPO/nested/mise.toml" <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["mise-settings"] +EOF + nested_root="$(cd "$REPO/nested" && pwd -P)" + + run codebase lint "$REPO/nested" + [ "$status" -eq 0 ] + [[ "$output" == *"codebase: lint:mise-settings $nested_root"* ]] +} + +@test "lint: honors default rule scopes" { + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["gum-table"] +EOF + write_clean_task + write_bad_table_script scripts + + run codebase lint "$REPO" + [ "$status" -eq 0 ] + [[ "$output" == *"codebase: lint:gum-table $REPO_ROOT/.mise/tasks"* ]] + [[ "$output" == *"codebase: all 1 lint rule(s) passed"* ]] +} + +@test "lint: honors user scope overrides" { + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["gum-table"] + +[_.codebase.scope] +gum-table = "scripts" +EOF + write_clean_task + write_bad_table_script scripts + + run codebase lint "$REPO" + [ "$status" -ne 0 ] + [[ "$output" == *"codebase: lint:gum-table $REPO_ROOT/scripts"* ]] + [[ "$output" == *"WARN"*"scripts:table"* ]] + [[ "$output" == *"codebase: 1 lint rule(s) failed"* ]] +} + +@test "lint: summarizes multiple failing rules" { + write_config <<'EOF' +[settings] +quiet = true + +[_.codebase] +lint = ["mise-settings", "gum-table"] + +[_.codebase.scope] +gum-table = "scripts" +EOF + write_bad_table_script scripts + + run codebase lint "$REPO" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"repo: missing task_output"* ]] + [[ "$output" == *"WARN"*"scripts:table"* ]] + [[ "$output" == *"codebase: 2 lint rule(s) failed"* ]] + [[ "$output" == *"lint:mise-settings"* ]] + [[ "$output" == *"lint:gum-table"* ]] +} + +@test "lint: fails when target does not exist" { + run codebase lint "$BATS_TEST_TMPDIR/does-not-exist" + [ "$status" -ne 0 ] + [[ "$output" == *"target does not exist"* ]] +} + +@test "lint: fails when no mise.toml is present" { + run codebase lint "$REPO" + [ "$status" -ne 0 ] + [[ "$output" == *"no mise.toml"* ]] +} + +@test "lint: fails when no lint rules are configured" { + write_config <<'EOF' +[tools] +bats = "1.13.0" +EOF + + run codebase lint "$REPO" + [ "$status" -ne 0 ] + [[ "$output" == *"no lint rules configured"* ]] +} + +@test "lint: parses multiline lint arrays" { + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = [ + "mise-settings", +] +EOF + + run codebase lint "$REPO" + [ "$status" -eq 0 ] + [[ "$output" == *"codebase: lint:mise-settings"* ]] +} diff --git a/test/lint/github-actions/github-actions.bats b/test/lint/github-actions/github-actions.bats index db2ca3f..0bb2ccd 100644 --- a/test/lint/github-actions/github-actions.bats +++ b/test/lint/github-actions/github-actions.bats @@ -55,7 +55,7 @@ copy_fixture() { grep -q 'run: mise run test' "$workflow" } -@test "fix: includes configured codebase lint rules" { +@test "fix: includes aggregate codebase lint step" { target=$(copy_fixture fix-lints) run codebase lint:github-actions --fix "$target" @@ -64,9 +64,9 @@ copy_fixture() { workflow="$target/.github/workflows/test.yml" [ -f "$workflow" ] grep -q 'Run codebase lints' "$workflow" - grep -q 'lint:mise-settings' "$workflow" - grep -q 'lint:shellcheck' "$workflow" - grep -q 'codebase "$target" "$PWD"' "$workflow" + grep -q 'run: codebase lint "$PWD"' "$workflow" + ! grep -q 'lint:mise-settings' "$workflow" + ! grep -q 'lint:shellcheck' "$workflow" } @test "fix: provisions codebase CLI when configured lint rules are generated" { diff --git a/test/lint/relative-paths.bats b/test/lint/relative-paths.bats index ed17d2f..7312305 100644 --- a/test/lint/relative-paths.bats +++ b/test/lint/relative-paths.bats @@ -54,6 +54,27 @@ EOF echo "$tmp" } +# --- lint (aggregate) ------------------------------------------------------ + +@test "lint: relative path resolves against CODEBASE_CALLER_PWD" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/project" + cat > "$tmp/project/mise.toml" <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["mise-settings"] +EOF + + CODEBASE_CALLER_PWD="$tmp" run codebase lint project + [[ "$output" != *"does not exist"* ]] + [[ "$output" == *"OK"* ]] || [[ "$output" == *"FAIL"* ]] + rm -rf "$tmp" +} + # --- lint:shellcheck ------------------------------------------------------- @test "shellcheck: relative path resolves against CODEBASE_CALLER_PWD" { diff --git a/test/pre-commit/pre-commit.bats b/test/pre-commit/pre-commit.bats index ff02963..96bd4f2 100644 --- a/test/pre-commit/pre-commit.bats +++ b/test/pre-commit/pre-commit.bats @@ -8,6 +8,7 @@ setup() { REPO="$BATS_TEST_TMPDIR/repo" mkdir -p "$REPO" git -C "$REPO" init -q + REPO_ROOT="$(git -C "$REPO" rev-parse --show-toplevel)" cat > "$REPO/mise.toml" <<'EOF' [settings] quiet = true @@ -43,15 +44,12 @@ EOF [ -x "$REPO/.git/hooks/pre-commit.d/codebase" ] } -@test "install: hook contains configured rules" { +@test "install: hook delegates to aggregate lint" { codebase pre-commit - grep -q "mise-settings" "$REPO/.git/hooks/pre-commit.d/codebase" - grep -q "gum-table" "$REPO/.git/hooks/pre-commit.d/codebase" + grep -q 'exec codebase lint "$REPO_ROOT"' "$REPO/.git/hooks/pre-commit.d/codebase" } -@test "install: hook honors user scope overrides" { - # Override gum-table's default (.mise/tasks) with a non-default value. - # Verifies the user override is actually applied, not masked by the default. +@test "install: hook does not bake configured rules or scopes" { cat > "$REPO/mise.toml" <<'EOF' [settings] quiet = true @@ -64,7 +62,9 @@ lint = ["mise-settings", "gum-table"] gum-table = "custom/gum-path" EOF codebase pre-commit - grep -q 'custom/gum-path' "$REPO/.git/hooks/pre-commit.d/codebase" + grep -q 'exec codebase lint "$REPO_ROOT"' "$REPO/.git/hooks/pre-commit.d/codebase" + ! grep -q 'lint:mise-settings' "$REPO/.git/hooks/pre-commit.d/codebase" + ! grep -q 'custom/gum-path' "$REPO/.git/hooks/pre-commit.d/codebase" } @test "install: generated hook is syntactically valid bash" { @@ -72,27 +72,24 @@ EOF bash -n "$REPO/.git/hooks/pre-commit.d/codebase" } -@test "install: generated hook runs end-to-end against a clean repo" { - # Full smoke test: install the hook, actually execute it. Catches - # generation bugs the grep-based tests can't. - # - # Uses mise-settings (expects quiet=true + task_output="interleave" - # in the target's mise.toml) as the only lint rule; our fixture - # mise.toml satisfies it, so the hook should exit 0. - cat > "$REPO/mise.toml" <<'EOF' -[settings] -quiet = true -task_output = "interleave" +@test "install: generated hook delegates to codebase lint for repo root" { + codebase pre-commit -[_.codebase] -lint = ["mise-settings"] + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat > "$BATS_TEST_TMPDIR/bin/codebase" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$CODEBASE_HOOK_ARGS" EOF - codebase pre-commit + chmod +x "$BATS_TEST_TMPDIR/bin/codebase" + export CODEBASE_HOOK_ARGS="$BATS_TEST_TMPDIR/hook-args" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" # Git always invokes hooks from the repo root. Simulate that — # the hook reads REPO_ROOT via 'git rev-parse --show-toplevel'. - run bash -c "cd '$REPO' && bash '$REPO/.git/hooks/pre-commit.d/codebase'" + run bash -c 'cd "$1" && bash "$1/.git/hooks/pre-commit.d/codebase"' _ "$REPO" [ "$status" -eq 0 ] + [ "$(sed -n '1p' "$CODEBASE_HOOK_ARGS")" = "lint" ] + [ "$(sed -n '2p' "$CODEBASE_HOOK_ARGS")" = "$REPO_ROOT" ] } @test "install: dispatcher is executable" { @@ -232,7 +229,7 @@ EOF # Scope # ============================================================================ -@test "scope: uses default when no override" { +@test "scope: default scopes are delegated to aggregate lint" { cat > "$REPO/mise.toml" <<'EOF' [settings] quiet = true @@ -241,10 +238,11 @@ quiet = true lint = ["gum-table"] EOF codebase pre-commit - grep -q '.mise/tasks' "$REPO/.git/hooks/pre-commit.d/codebase" + grep -q 'exec codebase lint "$REPO_ROOT"' "$REPO/.git/hooks/pre-commit.d/codebase" + ! grep -q '.mise/tasks' "$REPO/.git/hooks/pre-commit.d/codebase" } -@test "scope: override takes precedence" { +@test "scope: overrides are delegated to aggregate lint" { cat > "$REPO/mise.toml" <<'EOF' [settings] quiet = true @@ -256,7 +254,8 @@ lint = ["gum-table"] gum-table = "src/scripts" EOF codebase pre-commit - grep -q 'src/scripts' "$REPO/.git/hooks/pre-commit.d/codebase" + grep -q 'exec codebase lint "$REPO_ROOT"' "$REPO/.git/hooks/pre-commit.d/codebase" + ! grep -q 'src/scripts' "$REPO/.git/hooks/pre-commit.d/codebase" } # ============================================================================ From 2f313c2e6104c8b41c0b2ab92107d54fcd670e04 Mon Sep 17 00:00:00 2001 From: johnson Date: Mon, 18 May 2026 15:36:14 -0400 Subject: [PATCH 2/2] fix: keep generated lint workflows on current codebase --- .mise/tasks/lint/github-actions | 19 +++++++++++++++++++ test/lint/github-actions/github-actions.bats | 13 +++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.mise/tasks/lint/github-actions b/.mise/tasks/lint/github-actions index b19e389..ae3e426 100755 --- a/.mise/tasks/lint/github-actions +++ b/.mise/tasks/lint/github-actions @@ -73,6 +73,7 @@ ensure_codebase_tooling() { local target="$1" local toml="$target/mise.toml" local added=() + local tmp [[ -f "$toml" ]] || return 0 @@ -84,6 +85,24 @@ ensure_codebase_tooling() { if ! section_has_key "$toml" "tools" '^[[:space:]]*"shiv:codebase"[[:space:]]*='; then ensure_section_key "$toml" "tools" '"shiv:codebase" = "latest"' added+=("shiv:codebase tool") + elif ! awk ' + /^\[tools\][[:space:]]*$/ { in_tools=1; next } + /^\[/ { in_tools=0 } + in_tools && /^[[:space:]]*"shiv:codebase"[[:space:]]*=/ && $0 ~ /"latest"/ { found=1 } + END { exit found ? 0 : 1 } + ' "$toml"; then + tmp=$(mktemp) + awk ' + /^\[tools\][[:space:]]*$/ { in_tools=1; print; next } + /^\[/ { in_tools=0 } + in_tools && /^[[:space:]]*"shiv:codebase"[[:space:]]*=/ { + print "\"shiv:codebase\" = \"latest\"" + next + } + { print } + ' "$toml" > "$tmp" + mv "$tmp" "$toml" + added+=("shiv:codebase tool -> latest") fi if [[ ${#added[@]} -gt 0 ]]; then diff --git a/test/lint/github-actions/github-actions.bats b/test/lint/github-actions/github-actions.bats index 0bb2ccd..3156e2b 100644 --- a/test/lint/github-actions/github-actions.bats +++ b/test/lint/github-actions/github-actions.bats @@ -93,6 +93,19 @@ copy_fixture() { [ "$(grep -c '^"shiv:codebase" = "latest"' "$target/mise.toml")" -eq 1 ] } +@test "fix: updates existing old codebase pin for aggregate workflow" { + target=$(copy_fixture fix-lints-provisioned) + tmp="$target/mise.toml.tmp" + awk '{ gsub(/"latest"/, "\"0.2.0\""); print }' "$target/mise.toml" > "$tmp" + mv "$tmp" "$target/mise.toml" + + run codebase lint:github-actions --fix "$target" + [ "$status" -eq 0 ] + + grep -q '^"shiv:codebase" = "latest"' "$target/mise.toml" + grep -q 'run: codebase lint "$PWD"' "$target/.github/workflows/test.yml" +} + @test "fix: fails when no safe checks can be inferred" { target=$(copy_fixture fix-empty)