Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
AGENTS.md
186 changes: 186 additions & 0 deletions .mise/tasks/lint/bats-raw-mise-dispatch
Original file line number Diff line number Diff line change
@@ -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 "<targets>…" 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 <task>`, `mise -C <dir> run <task>`.
# 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 <before_match>
#
# 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 <subcommand>\` 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"
103 changes: 103 additions & 0 deletions test/lint/bats-raw-mise-dispatch/bats-raw-mise-dispatch.bats
Original file line number Diff line number Diff line change
@@ -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 <task>' 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"* ]]
}
1 change: 1 addition & 0 deletions test/lint/bats-raw-mise-dispatch/fixtures/clean/mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[tools]
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bats
load test_helper

@test "does a thing" {
run mytool list --json
[ "$status" -eq 0 ]
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[tools]
Original file line number Diff line number Diff line change
@@ -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 ]
}
1 change: 1 addition & 0 deletions test/lint/bats-raw-mise-dispatch/fixtures/dirty/mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[tools]
6 changes: 6 additions & 0 deletions test/lint/bats-raw-mise-dispatch/fixtures/dirty/test/bad.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bats

@test "locks the session" {
run mise run -q lock
[ "$status" -eq 0 ]
}
1 change: 1 addition & 0 deletions test/lint/bats-raw-mise-dispatch/fixtures/empty/mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[tools]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[tools]
Original file line number Diff line number Diff line change
@@ -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 ]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
# codebase:ignore bats-raw-mise-dispatch
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bats

@test "locks the session" do
run mise run -q lock
[ "$status" -eq 0 ]
}
4 changes: 2 additions & 2 deletions test/lint/caller-pwd-contract/caller-pwd-contract.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Expand All @@ -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 ]
Expand Down