diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c317064 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +AGENTS.md diff --git a/.mise/tasks/lint/bats-raw-mise-dispatch b/.mise/tasks/lint/bats-raw-mise-dispatch new file mode 100755 index 0000000..530ba9a --- /dev/null +++ b/.mise/tasks/lint/bats-raw-mise-dispatch @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +#MISE description="Flag raw 'mise run' in BATS tests (call the tool, not the script)" +#USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:bats-raw-mise-dispatch ." header="Flag raw mise dispatch in BATS tests" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" + +# Rationale: BATS tests should call the tool wrapper (e.g. `notes lock`), +# not raw `mise run lock`. The wrapper is the same path a user/shiv shim +# takes — it handles CALLER_PWD, stderr routing, and the caller-cwd +# contract. Tests that bypass the wrapper can pass but exercise a +# different code path than real usage. See fold/notes/bats-tool-testing.md +# ("Call the Tool, Not the Script") for the full write-up. +# +# This rule is the inverse sibling of lint:bats-test-helper: +# - bats-test-helper: flags DIRECT script invocation (bash .mise/tasks/foo) +# - bats-raw-mise-dispatch: flags mise run IN test bodies +# Both converge on the same desired pattern: wrapper function in helper. +# +# Legitimate exceptions: +# - Wrapper definition itself (test_helper.bash / helpers.bash) — +# that file IS the wrapper, so mise run there is correct. +# - Tests intentionally exercising mise dispatch — annotate with +# `# codebase:ignore bats-raw-mise-dispatch`. +# - Repo-wide opt-out via `codebase:ignore bats-raw-mise-dispatch` +# in mise.toml. + +IFS=' ' read -ra TARGETS <<< "${usage_targets}" + +if [[ ${#TARGETS[@]} -eq 0 ]]; then + echo "ERROR: at least one target is required" >&2 + exit 1 +fi + +# Resolve relative paths against CALLER_PWD (see lib/shell-files.sh). +for i in "${!TARGETS[@]}"; do + TARGETS[$i]=$(resolve_target "${TARGETS[$i]}") +done + +# Allowlisted files: these define the tool wrapper, so `mise run` inside +# them is the canonical pattern, not a violation. +ALLOWLIST_FILES=( + "test/test_helper.bash" + "test/helpers.bash" + "test/test_helper.sh" + "test/helpers.sh" +) + +is_allowlisted() { + local relpath="$1" + for allowed in "${ALLOWLIST_FILES[@]}"; do + [[ "$relpath" == "$allowed" ]] && return 0 + done + return 1 +} + +# Patterns: `mise run `, `mise -C run `. +# We only match at statement boundaries — start of line, after `|`, +# `||`, `&&`, `;`, `(`, `{`, or after `run ` (bats run command) or +# `exec `. This rules out test descriptions (`@test "... mise run ..."`) +# and search patterns (`grep -q 'mise run'`, `-p 'mise run ...'`). +# +# Same approach as bats-test-helper's STMT_BOUNDARY. +STMT_BOUNDARY='(^|[[:space:]]|[;&|({])' +MISE_RUN_RE="${STMT_BOUNDARY}(exec[[:space:]]+)?(run[[:space:]]+)?mise[[:space:]]+(-C[[:space:]]+[^[:space:]]+[[:space:]]+)?run[[:space:]]" + +# is_in_quoted_string +# +# Check if the given prefix (characters before a match on the same line) +# indicates we are inside a quoted string. Returns 0 if inside a string. +# Uses simple odd-count heuristic: if there's an odd number of single +# quotes OR double quotes in the prefix, the match is inside a string. +# +# False positive: a string that ends before the match (e.g. 'foo' mise run). +# But this is rare enough that the heuristic is worth it — the common case +# is `grep -q 'mise run'` or `printf '... mise run ...'`. +is_in_quoted_string() { + local prefix="$1" + local single_count=0 + local double_count=0 + local c + + for (( i=0; i<${#prefix}; i++ )); do + c="${prefix:$i:1}" + [[ "$c" == "'" ]] && single_count=$((single_count + 1)) + [[ "$c" == '"' ]] && double_count=$((double_count + 1)) + done + + # Odd count of either quote type = inside a string + [[ $((single_count % 2)) -eq 1 ]] && return 0 + [[ $((double_count % 2)) -eq 1 ]] && return 0 + return 1 +} + +scan_file() { + local file="$1" + local lineno=0 + local line + + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + + # Skip full-line comments. + [[ "$line" =~ ^[[:space:]]*# ]] && continue + + # Inline opt-out. + [[ "$line" == *"codebase:ignore"* ]] && continue + + # Skip test description lines (heuristic: @test followed by a string). + [[ "$line" =~ ^[[:space:]]*@test ]] && continue + + if [[ "$line" =~ $MISE_RUN_RE ]]; then + # Get the text before the matched mise run + local prefix="${line%%mise*}" + if is_in_quoted_string "$prefix"; then + continue + fi + local trimmed="${line#"${line%%[![:space:]]*}"}" + echo "$lineno: $trimmed" + fi + done < "$file" +} + +failures=0 + +for target in "${TARGETS[@]}"; do + if [[ ! -e "$target" ]]; then + echo "ERROR: target does not exist: $target" >&2 + exit 1 + fi + + name=$(basename "$target") + + # File-level ignore via mise.toml + toml="$target/mise.toml" + if [[ -f "$toml" ]] && rg -q 'codebase:ignore bats-raw-mise-dispatch' "$toml"; then + echo "SKIP $name (codebase:ignore)" + continue + fi + + # Collect test files — same discovery as bats-test-helper: + # *.bats and *.bash under test/, excluding fixtures/. + files=() + if [[ -d "$target/test" ]]; then + while IFS= read -r f; do + [[ -n "$f" ]] && files+=("$f") + done < <(fd -t f -e bats -e bash --exclude fixtures . "$target/test" 2>/dev/null || true) + fi + + if [[ ${#files[@]} -eq 0 ]]; then + echo "OK $name (no test/ files found)" + continue + fi + + hit_count=0 + target_output="" + + for file in "${files[@]}"; do + rel="${file#"$target"/}" + + # Skip allowlisted wrapper definition files + is_allowlisted "$rel" && continue + + while IFS= read -r hit; do + [[ -z "$hit" ]] && continue + target_output+=" $rel:$hit"$'\n' + hit_count=$((hit_count + 1)) + done < <(scan_file "$file") + done + + if [[ "$hit_count" -gt 0 ]]; then + echo "FAIL $name: $hit_count raw mise dispatch(s) in test/" + printf '%s' "$target_output" + echo " hint: define a wrapper (e.g. mytool() { cd \"\$REPO_DIR\" && mise run -q \"\$@\"; })" + echo " in test_helper.bash and call \`run mytool \` instead." + echo " See fold/notes/bats-tool-testing.md ('Call the Tool, Not the Script')" + failures=$((failures + 1)) + else + echo "OK $name (${#files[@]} file(s) clean)" + fi +done + +exit "$failures" \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/bats-raw-mise-dispatch.bats b/test/lint/bats-raw-mise-dispatch/bats-raw-mise-dispatch.bats new file mode 100644 index 0000000..4d9e53c --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/bats-raw-mise-dispatch.bats @@ -0,0 +1,103 @@ +#!/usr/bin/env bats +# Tests for lint:bats-raw-mise-dispatch rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +# === Pass paths === + +@test "bats-raw-mise-dispatch: passes on clean wrapper-based tests" { + run codebase lint:bats-raw-mise-dispatch "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"clean"* ]] +} + +@test "bats-raw-mise-dispatch: passes when target has no test/ dir" { + run codebase lint:bats-raw-mise-dispatch "$FIXTURES/empty" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"empty"* ]] + [[ "$output" == *"no test/ files found"* ]] +} + +# === Invocation signatures — each form fails === + +@test "bats-raw-mise-dispatch: flags 'mise run -q ' in BATS test" { + run codebase lint:bats-raw-mise-dispatch "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty"* ]] + [[ "$output" == *"mise run"* ]] +} + +@test "bats-raw-mise-dispatch: 'bash -c ... mise run ...' is a known limitation (inside quoted string)" { + # KNOWN LIMITATION: 'bash -c "... mise run ..."' wraps the dispatch inside + # a single-quoted string, making it indistinguishable from grep/search + # patterns at the line-scanning level. The rule does NOT flag this form. + # If this becomes important, a smarter parser (statement-context-aware) + # would be needed. + run codebase lint:bats-raw-mise-dispatch "$FIXTURES/dirty-bash-c" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +# === Allowlisted files === + +@test "bats-raw-mise-dispatch: does NOT flag 'mise run' in test_helper.bash" { + # The clean fixture has mise run in test_helper.bash (the wrapper + # definition) — this should be allowed. + run codebase lint:bats-raw-mise-dispatch "$FIXTURES/clean" + [ "$status" -eq 0 ] +} + +# === Ignore directives === + +@test "bats-raw-mise-dispatch: inline '# codebase:ignore' suppresses a single line" { + run codebase lint:bats-raw-mise-dispatch "$FIXTURES/ignored-inline" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "bats-raw-mise-dispatch: 'codebase:ignore' in mise.toml skips the target" { + run codebase lint:bats-raw-mise-dispatch "$FIXTURES/ignored-repo" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"*"ignored-repo"* ]] +} + +# === Output details === + +@test "bats-raw-mise-dispatch: fail output includes file:line citations" { + run codebase lint:bats-raw-mise-dispatch "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"test/bad.bats:"*":"* ]] +} + +@test "bats-raw-mise-dispatch: fail output includes the remediation hint" { + run codebase lint:bats-raw-mise-dispatch "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"bats-tool-testing.md"* ]] + [[ "$output" == *"Call the Tool"* ]] +} + +# === Error handling === + +@test "bats-raw-mise-dispatch: fails when no targets given" { + run codebase lint:bats-raw-mise-dispatch + [ "$status" -ne 0 ] +} + +@test "bats-raw-mise-dispatch: fails when target does not exist" { + run codebase lint:bats-raw-mise-dispatch "/nonexistent/path/xyz" + [ "$status" -ne 0 ] + [[ "$output" == *"does not exist"* ]] +} + +# === Multi-target === + +@test "bats-raw-mise-dispatch: accepts multiple targets and reports each" { + run codebase lint:bats-raw-mise-dispatch "$FIXTURES/clean" "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"OK"*"clean"* ]] + [[ "$output" == *"FAIL"*"dirty"* ]] +} \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/clean/mise.toml b/test/lint/bats-raw-mise-dispatch/fixtures/clean/mise.toml new file mode 100644 index 0000000..2b815ce --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/clean/mise.toml @@ -0,0 +1 @@ +[tools] \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/clean/test/example.bats b/test/lint/bats-raw-mise-dispatch/fixtures/clean/test/example.bats new file mode 100644 index 0000000..b5dab66 --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/clean/test/example.bats @@ -0,0 +1,7 @@ +#!/usr/bin/env bats +load test_helper + +@test "does a thing" { + run mytool list --json + [ "$status" -eq 0 ] +} \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/clean/test/test_helper.bash b/test/lint/bats-raw-mise-dispatch/fixtures/clean/test/test_helper.bash new file mode 100644 index 0000000..baff87f --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/clean/test/test_helper.bash @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +mytool() { + ( cd "$REPO_DIR" && mise run -q "$@" ) +} +export -f mytool \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/dirty-bash-c/mise.toml b/test/lint/bats-raw-mise-dispatch/fixtures/dirty-bash-c/mise.toml new file mode 100644 index 0000000..2b815ce --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/dirty-bash-c/mise.toml @@ -0,0 +1 @@ +[tools] \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/dirty-bash-c/test/bad.bats b/test/lint/bats-raw-mise-dispatch/fixtures/dirty-bash-c/test/bad.bats new file mode 100644 index 0000000..3a8516e --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/dirty-bash-c/test/bad.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "locks via bash -c" { + run env REPO_DIR="$PWD" bash -c 'cd "$REPO_DIR" && mise run -q lock' + [ "$status" -eq 0 ] +} \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/dirty/mise.toml b/test/lint/bats-raw-mise-dispatch/fixtures/dirty/mise.toml new file mode 100644 index 0000000..2b815ce --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/dirty/mise.toml @@ -0,0 +1 @@ +[tools] \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/dirty/test/bad.bats b/test/lint/bats-raw-mise-dispatch/fixtures/dirty/test/bad.bats new file mode 100644 index 0000000..78d16b8 --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/dirty/test/bad.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "locks the session" { + run mise run -q lock + [ "$status" -eq 0 ] +} \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/empty/mise.toml b/test/lint/bats-raw-mise-dispatch/fixtures/empty/mise.toml new file mode 100644 index 0000000..2b815ce --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/empty/mise.toml @@ -0,0 +1 @@ +[tools] \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/ignored-inline/mise.toml b/test/lint/bats-raw-mise-dispatch/fixtures/ignored-inline/mise.toml new file mode 100644 index 0000000..2b815ce --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/ignored-inline/mise.toml @@ -0,0 +1 @@ +[tools] \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/ignored-inline/test/intentional.bats b/test/lint/bats-raw-mise-dispatch/fixtures/ignored-inline/test/intentional.bats new file mode 100644 index 0000000..77e0e25 --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/ignored-inline/test/intentional.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "intentionally tests mise dispatch" { + run mise run --help # codebase:ignore bats-raw-mise-dispatch — testing mise help output + [ "$status" -eq 0 ] +} \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/ignored-repo/mise.toml b/test/lint/bats-raw-mise-dispatch/fixtures/ignored-repo/mise.toml new file mode 100644 index 0000000..a01bfc2 --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/ignored-repo/mise.toml @@ -0,0 +1,2 @@ +[tools] +# codebase:ignore bats-raw-mise-dispatch \ No newline at end of file diff --git a/test/lint/bats-raw-mise-dispatch/fixtures/ignored-repo/test/bad.bats b/test/lint/bats-raw-mise-dispatch/fixtures/ignored-repo/test/bad.bats new file mode 100644 index 0000000..2683263 --- /dev/null +++ b/test/lint/bats-raw-mise-dispatch/fixtures/ignored-repo/test/bad.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "locks the session" do + run mise run -q lock + [ "$status" -eq 0 ] +} \ No newline at end of file diff --git a/test/lint/caller-pwd-contract/caller-pwd-contract.bats b/test/lint/caller-pwd-contract/caller-pwd-contract.bats index f79fa73..5260007 100644 --- a/test/lint/caller-pwd-contract/caller-pwd-contract.bats +++ b/test/lint/caller-pwd-contract/caller-pwd-contract.bats @@ -62,7 +62,7 @@ printf "%s\n" "$TARGET"') @test "fails when runtime exports legacy CALLER_PWD" { target=$(make_pkg bad-export '#!/usr/bin/env bash export CALLER_PWD="$PWD" -exec mise run "$@"') +exec mise run "$@"') # codebase:ignore bats-raw-mise-dispatch — wrapper fixture, not test dispatch run codebase lint:caller-pwd-contract "$target" [ "$status" -eq 1 ] @@ -71,7 +71,7 @@ exec mise run "$@"') @test "fails when runtime assigns legacy CALLER_PWD" { target=$(make_pkg bad-assign '#!/usr/bin/env bash -CALLER_PWD="$PWD" exec mise run "$@"') +CALLER_PWD="$PWD" exec mise run "$@"') # codebase:ignore bats-raw-mise-dispatch — wrapper fixture, not test dispatch run codebase lint:caller-pwd-contract "$target" [ "$status" -eq 1 ]