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/_default b/.mise/tasks/lint/_default index 297abc7..bc9aafa 100755 --- a/.mise/tasks/lint/_default +++ b/.mise/tasks/lint/_default @@ -19,6 +19,13 @@ if [[ ! -f "$TOML" ]]; then exit 1 fi +# Check if the original config uses groups, for preamble display. +ORIG_LINT="$(mise config get -f "$TOML" _.codebase.lint 2>/dev/null || true)" +HAS_GROUPS=false +if [[ "$ORIG_LINT" == *"@"* ]]; then + HAS_GROUPS=true +fi + RULES=() while IFS= read -r rule; do [[ -n "$rule" ]] && RULES+=("$rule") @@ -29,31 +36,45 @@ if [[ ${#RULES[@]} -eq 0 ]]; then echo "Add to mise.toml:" >&2 echo ' [_.codebase]' >&2 echo ' lint = ["mise-settings", "gum-table"]' >&2 + echo " # Or use a preset group:" >&2 + echo ' # lint = ["@maintained-tool"]' >&2 + echo " # See 'mise run lint:groups' for available groups" >&2 exit 1 fi +if $HAS_GROUPS; then + echo "codebase: expanded ${#RULES[@]} rule(s) from lint groups" +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 + # Collect all targets for this rule (supports multi-path scopes) + TARGETS=() + while IFS= read -r t; do + [[ -n "$t" ]] && TARGETS+=("$t") + done < <(codebase_targets_for_rule "$REPO_ROOT" "$rule") + + for rule_target in "${TARGETS[@]}"; do + 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 done if [[ "$failures" -gt 0 ]]; then diff --git a/.mise/tasks/lint/bats-python-one-liner b/.mise/tasks/lint/bats-python-one-liner new file mode 100644 index 0000000..7dd5165 --- /dev/null +++ b/.mise/tasks/lint/bats-python-one-liner @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +#MISE description="Flag unreadable Python assertion one-liners in BATS tests" +#USAGE arg "..." help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:bats-python-one-liner ." header="Flag inline Python assertions in BATS tests" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" + +# Rationale: BATS tests that embed Python assertions as inline +# `python3 -c "...assert..."` one-liners are hard to review, hard to +# diff, and easy to normalize across repos. The preferred alternative +# is a heredoc Python script: +# +# json_file="$BATS_TEST_TMPDIR/parse.json" +# printf '%s\n' "$output" > "$json_file" +# python3 - "$json_file" <<'PY' +# import json, sys +# data = json.load(open(sys.argv[1])) +# assert data["frontmatter"] == {} +# PY +# +# See fold/notes/bats-tool-testing.md for the broader BATS principle. +# +# Detection: flag any line containing `python3 -c` or `python -c` where +# the inline code contains the word `assert`. This catches the primary +# readability concern while avoiding false positives from short harmless +# calls like `python3 -c 'print("hello")'`. + +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 + +# discover_bats_files +# +# Emit paths of .bats files under /test, excluding '*/fixtures/*'. +discover_bats_files() { + local target="$1" + if [[ -d "$target/test" ]]; then + fd -t f -e bats --exclude fixtures . "$target/test" + fi +} + +# is_heredoc_context +# +# Check if the given line is inside a heredoc block by scanning backward +# for a heredoc delimiter. Returns 0 if inside a heredoc, 1 otherwise. +is_heredoc_context() { + local file="$1" lineno="$2" + # Scan lines before the current one for heredoc start markers + sed -n "1,$((lineno - 1))p" "$file" | tac | grep -qE '<<[-]?["'"'"']?(PY|EOF|END|PYTHON|JSON)' +} + +scan_file() { + local file="$1" + local lineno=0 + local line + + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + + # Skip @test description lines (they contain literal strings, not code). + if [[ "$line" =~ ^[[:space:]]*@test[[:space:]]+ ]]; then + continue + fi + + # Skip full-line comments. + if [[ "$line" =~ ^[[:space:]]*# ]]; then + continue + fi + + # Inline opt-out. + if [[ "$line" == *"codebase:ignore"* ]]; then + continue + fi + + # Skip lines inside heredoc context. + if is_heredoc_context "$file" "$lineno"; then + continue + fi + + # Flag python3 -c / python -c lines containing 'assert'. + if [[ "$line" =~ python3?[[:space:]]+-c[[:space:]]+ ]] && [[ "$line" == *"assert"* ]]; then + local trimmed="${line#"${line%%[![:space:]]*}"}" + echo "$lineno: $trimmed" + echo " hint: Use a heredoc Python script instead of inline -c with assert" + 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-python-one-liner' "$toml"; then + echo "SKIP $name (codebase:ignore)" + continue + fi + + files=() + while IFS= read -r f; do + [[ -n "$f" ]] && files+=("$f") + done < <(discover_bats_files "$target") + + if [[ ${#files[@]} -eq 0 ]]; then + echo "OK $name (no test/ .bats files found)" + continue + fi + + hit_count=0 + target_output="" + for file in "${files[@]}"; do + rel="${file#"$target"/}" + while IFS= read -r hit; do + [[ -z "$hit" ]] && continue + target_output+=" $rel:$hit"$'\n' + # Only count lines starting with a line number as findings. + # Hint lines (indented) are informational, not separate findings. + if [[ "$hit" =~ ^[0-9]+: ]]; then + hit_count=$((hit_count + 1)) + fi + done < <(scan_file "$file") + done + + if [[ "$hit_count" -gt 0 ]]; then + echo "FAIL $name: $hit_count unreadable Python one-liner(s)" + printf '%s' "$target_output" + echo " hint: Use a heredoc Python script instead of inline -c with assert —" + echo " see fold/notes/bats-tool-testing.md" + failures=$((failures + 1)) + else + echo "OK $name (${#files[@]} file(s) clean)" + fi +done + +exit "$failures" 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/.mise/tasks/lint/bats-setup-suite-path b/.mise/tasks/lint/bats-setup-suite-path new file mode 100755 index 0000000..771288c --- /dev/null +++ b/.mise/tasks/lint/bats-setup-suite-path @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +#MISE description="Flag setup_suite.bash that calls mise env without preserving BATS_LIBEXEC" +#USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:bats-setup-suite-path ." header="Flag repos with unprotected mise env in setup_suite" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" + +# Rationale: BATS 1.13.0 looks up bats-exec-file, bats-format-*, +# etc. by bare name from files under its libexec dir. If +# test/setup_suite.bash calls `mise env` (which rewrites PATH to +# include tool-manager shims), Bats' libexec directory can fall +# off PATH, and the suite fails before running any tests: +# +# bats-exec-suite: line 323: bats-exec-file: command not found +# # bats warning: Executed 0 instead of expected N tests +# +# The safe shape saves BATS_LIBEXEC before mise env and prepends +# it afterward: +# +# setup_suite() { +# REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" +# export REPO_DIR +# bats_libexec="${BATS_LIBEXEC:-}" +# eval "$(cd "$REPO_DIR" && mise env)" +# if [ -n "$bats_libexec" ]; then +# export PATH="$bats_libexec:$PATH" +# fi +# } +# +# When upstream Bats releases a fix (bats-core/bats-core#1209) +# and KKL repos move past Bats 1.13.0, this rule may become less +# necessary. + +SETUP_SUITE="test/setup_suite.bash" + +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 + +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") + + # Repo-level ignore via mise.toml + toml="$target/mise.toml" + if [[ -f "$toml" ]] && rg -q 'codebase:ignore bats-setup-suite-path' "$toml"; then + echo "SKIP $name (codebase:ignore)" + continue + fi + + suite="$target/$SETUP_SUITE" + if [[ ! -f "$suite" ]]; then + echo "OK $name (no $SETUP_SUITE -- nothing to check)" + continue + fi + + # Inline ignore + if rg -q 'codebase:ignore bats-setup-suite-path' "$suite"; then + echo "SKIP $name (inline codebase:ignore)" + continue + fi + + # Check: does the file call `mise env`? + if ! rg -q 'mise env' "$suite"; then + echo "OK $name ($SETUP_SUITE doesn't call mise env -- nothing to check)" + continue + fi + + # Check: is BATS_LIBEXEC preserved before the mise env call? + # Walk through the file top-to-bottom. If any `mise env` line appears + # without a preceding BATS_LIBEXEC capture, flag it. + bats_preserved=false + found_offense=false + + while IFS= read -r line; do + # Skip comments + [[ "$line" =~ ^[[:space:]]*# ]] && continue + + # Detect BATS_LIBEXEC capture (any assignment reading BATS_LIBEXEC) + if grep -q 'BATS_LIBEXEC' <<< "$line"; then + bats_preserved=true + fi + + # Detect `mise env` invocation + if grep -q 'mise env' <<< "$line"; then + if [[ "$bats_preserved" == false ]]; then + echo "FAIL $name (mise env without BATS_LIBEXEC preservation in $SETUP_SUITE)" + echo " hint: save BATS_LIBEXEC before mise env and prepend it afterward:" + # shellcheck disable=SC2016 + echo ' bats_libexec="${BATS_LIBEXEC:-}"' + # shellcheck disable=SC2016 + echo ' eval "$(cd "$REPO_DIR" && mise env)"' + # shellcheck disable=SC2016 + echo ' if [ -n "$bats_libexec" ]; then' + # shellcheck disable=SC2016 + echo ' export PATH="$bats_libexec:$PATH"' + echo ' fi' + echo " see: https://github.com/bats-core/bats-core/pull/1209" + failures=$((failures + 1)) + found_offense=true + break + fi + # Reset for potential subsequent mise env calls (defensive) + bats_preserved=false + fi + done < "$suite" + + if [[ "$found_offense" == false ]]; then + echo "OK $name ($SETUP_SUITE preserves BATS_LIBEXEC correctly)" + fi +done + +exit "$failures" \ No newline at end of file diff --git a/.mise/tasks/lint/groups b/.mise/tasks/lint/groups new file mode 100755 index 0000000..78f895f --- /dev/null +++ b/.mise/tasks/lint/groups @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +#MISE description="List available lint groups and their rule membership" +#USAGE flag "-a --all" help="Show expanded rule count for each group (default: only group names)" +#USAGE example "mise run lint:groups" header="List available groups" +#USAGE example "mise run lint:groups --all" header="Show groups with expanded rule details" + +set -euo pipefail + +# shellcheck source=../../../lib/lint-groups.sh +source "$MISE_CONFIG_ROOT/lib/lint-groups.sh" + +ALL="${usage_all:-false}" + +groups=() +while IFS= read -r g; do + [[ -n "$g" ]] && groups+=("$g") +done < <(codebase_available_groups) + +if [[ ${#groups[@]} -eq 0 ]]; then + echo "No lint groups defined." + exit 0 +fi + +echo "Available lint groups:" +echo "" + +for group in "${groups[@]}"; do + echo " $group" + if [[ "$ALL" == "true" ]]; then + members=() + while IFS= read -r rule; do + [[ -n "$rule" ]] && members+=("$rule") + done < <(codebase_group_members "$group") + for rule in "${members[@]}"; do + echo " - $rule" + done + echo "" + fi +done + +if [[ "$ALL" != "true" ]]; then + echo " (use --all to see expanded rules for each group)" +fi + +echo "" +echo "Usage in mise.toml:" +echo ' [_.codebase]' +echo ' lint = ["@maintained-tool"]' +echo "" +echo "Exclude a rule:" +echo ' [_.codebase]' +echo ' lint = ["@maintained-tool"]' +echo ' lint_exclude = ["caller-pwd-contract"]' \ No newline at end of file diff --git a/.mise/tasks/lint/mise-run-quiet b/.mise/tasks/lint/mise-run-quiet new file mode 100644 index 0000000..f3018cc --- /dev/null +++ b/.mise/tasks/lint/mise-run-quiet @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +#MISE description="Flag mise run without -q in clean-output contexts" +#USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:mise-run-quiet ." header="Flag mise run calls missing -q" +#USAGE example "codebase lint:mise-run-quiet /path/to/repo" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" + +# Rationale: 'mise run' without -q prints task runner headers such as +# '[shell] $ ...' to stderr. That is useful for debugging, but harmful +# when the command output is being eval'd, piped, embedded in generated +# output, or used in shell startup. +# +# Example failure mode: eval "$(shiv shell)" visibly printed the task +# header in the terminal on every shell startup. +# +# Contexts flagged: +# - command substitution: $(mise run ...) — header goes into captured output +# - eval: eval "$(mise run ...)" — header pollutes the eval'd string +# - pipeline: mise run ... | consumer — header goes into the pipe +# +# Safe contexts (not flagged): +# - mise run -q ... — already quiet +# - mise run ... 2>/dev/null — stderr explicitly discarded +# - bare mise run at line start (interactive/debug) — no capture +# - mise run in comments or string literals + +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 + +# ── Helpers ────────────────────────────────────────────────────────────── + +# has_stderr_redirect +# Returns 0 if the line redirects stderr (2>...). +has_stderr_redirect() { + local line="$1" + echo "$line" | grep -qE '2>[>&]?[^&[:space:]]' 2>/dev/null +} + +# is_pattern_match +# Returns 0 if 'mise run' appears inside a grep/sed/awk pattern or string +# literal rather than as an actual command invocation. +is_pattern_match() { + local line="$1" + # grep/sed/awk with 'mise run' in the pattern (single or double quoted) + echo "$line" | grep -qE "(grep|sed|awk)[[:space:]].*['\"].*mise run" 2>/dev/null && return 0 + # echo with 'mise run' in a string + echo "$line" | grep -qE "echo[[:space:]].*['\"].*mise run" 2>/dev/null && return 0 + # printf with 'mise run' in a format string + echo "$line" | grep -qE "printf[[:space:]].*['\"].*mise run" 2>/dev/null && return 0 + return 1 +} + +# classify_context +# Emit the context classification for a mise run invocation. +classify_context() { + local line="$1" + + # Skip if mise run is inside a grep/sed pattern or string literal + is_pattern_match "$line" && echo "pattern" && return + + # Already quiet — skip + if echo "$line" | grep -qE 'mise run\s+-q'; then + echo "quiet" + return + fi + + # eval context: eval "$(mise run ...)" + if echo "$line" | grep -qE 'eval\s+"\$\([^)]*mise run'; then + echo "eval" + return + fi + + # Command substitution context: $(mise run ...) + if echo "$line" | grep -qE '\$\([^)]*mise run'; then + echo "command-sub" + return + fi + + # Pipeline context: mise run ... | consumer + if echo "$line" | grep -qE 'mise run[^|]*\|'; then + echo "pipeline" + return + fi + + # Has stderr redirect — safe + if has_stderr_redirect "$line"; then + echo "stderr-redirect" + return + fi + + # Bare mise run (interactive/debug) — safe + echo "bare" +} + +# has_line_ignore +# Returns 0 if the line has a rule-specific inline ignore. +has_line_ignore() { + local line="$1" + echo "$line" | grep -qE 'codebase:ignore[[:space:]]+mise-run-quiet' 2>/dev/null +} + +# scan_file +# Emit flagged line numbers and context for mise run calls without -q. +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 + + # Skip lines without mise run. + [[ "$line" != *"mise run"* ]] && continue + + # Rule-specific inline ignore with reason is accepted. + has_line_ignore "$line" && continue + + # Classify the context. + local context + context=$(classify_context "$line") + + case "$context" in + quiet|stderr-redirect|bare|pattern) + # Safe contexts — skip + ;; + command-sub) + local trimmed + trimmed="${line#"${line%%[![:space:]]*}"}" + echo "[command-sub] $lineno: $trimmed" + ;; + eval) + local trimmed + trimmed="${line#"${line%%[![:space:]]*}"}" + echo "[eval] $lineno: $trimmed" + ;; + pipeline) + local trimmed + trimmed="${line#"${line%%[![:space:]]*}"}" + echo "[pipeline] $lineno: $trimmed" + ;; + esac + done < "$file" +} + +# ── Main ────────────────────────────────────────────────────────────────── + +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" ]] && grep -m1 -q 'codebase:ignore mise-run-quiet' "$toml"; then + echo "SKIP $name (codebase:ignore)" + continue + fi + + # Collect shell files + files=() + while IFS= read -r f; do + [[ -n "$f" ]] && files+=("$f") + done < <(discover_shell_files "$target") + + if [[ ${#files[@]} -eq 0 ]]; then + echo "OK $name (no shell files found)" + continue + fi + + # Scan each file; collect hits + hit_count=0 + warn_count=0 + target_output="" + for file in "${files[@]}"; do + rel="${file#"$target"/}" + while IFS= read -r hit; do + [[ -z "$hit" ]] && continue + target_output+=" $rel:$hit"$'\n' + if [[ "$hit" == \[command-sub\]* ]] || [[ "$hit" == \[eval\]* ]]; then + hit_count=$((hit_count + 1)) + elif [[ "$hit" == \[pipeline\]* ]]; then + warn_count=$((warn_count + 1)) + fi + done < <(scan_file "$file") + done + + if [[ "$hit_count" -gt 0 ]] || [[ "$warn_count" -gt 0 ]]; then + detail="" + [[ "$hit_count" -gt 0 ]] && detail+="$hit_count mise run call(s) without -q in capture context(s)" + [[ "$hit_count" -gt 0 && "$warn_count" -gt 0 ]] && detail+=", " + [[ "$warn_count" -gt 0 ]] && detail+="$warn_count pipeline(s) without -q" + echo "FAIL $name: $detail" + printf '%s' "$target_output" + cat <<'HINT' + hint: Add -q to mise run calls in capture contexts: mise run -q + For pipelines: mise run -q | consumer + For eval: eval "$(mise run -q )" + For command substitution: $(mise run -q ) +HINT + failures=$((failures + 1)) + else + echo "OK $name (${#files[@]} file(s) clean)" + fi +done + +exit "$failures" diff --git a/lib/codebase-config.sh b/lib/codebase-config.sh index 2d43820..b1cf7e2 100644 --- a/lib/codebase-config.sh +++ b/lib/codebase-config.sh @@ -8,6 +8,8 @@ _CODEBASE_CONFIG_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./shell-files.sh source "$_CODEBASE_CONFIG_LIB_DIR/shell-files.sh" +# shellcheck source=./lint-groups.sh +source "$_CODEBASE_CONFIG_LIB_DIR/lint-groups.sh" # codebase_git_root # @@ -56,8 +58,9 @@ codebase_resolve_repo() { # 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". +# Emit configured [_.codebase].lint rules, one per line, with @group +# references expanded and any excluded rules removed. Rule names are simple +# task suffixes such as "mise-settings" or "gum-table". codebase_configured_lint_rules() { local repo_root="$1" local toml="$repo_root/mise.toml" @@ -69,10 +72,53 @@ codebase_configured_lint_rules() { return 0 fi - printf '%s\n' "$raw" | awk '{ + # Parse raw TOML array values into an array of rule names. + local -a parsed + while IFS= read -r token; do + [[ -n "$token" ]] && parsed+=("$token") + done < <(printf '%s\n' "$raw" | awk '{ gsub(/[\[\]",]/, " ") for (i = 1; i <= NF; i++) print $i - }' + }') + + # Expand @group references. + local -a expanded + if codebase_has_group_reference "${parsed[@]}"; then + while IFS= read -r rule; do + [[ -n "$rule" ]] && expanded+=("$rule") + done < <(codebase_expand_lint_groups "${parsed[@]}") + else + expanded=("${parsed[@]}") + fi + + # Apply [_.codebase].lint_exclude if present. + local excludes_raw + excludes_raw=$(mise config get -f "$toml" _.codebase.lint_exclude 2>/dev/null || true) + if [[ -n "$excludes_raw" ]]; then + local -a exclude_list + while IFS= read -r ex; do + local clean + clean=$(printf '%s' "$ex" | tr -d '[]," ') + [[ -n "$clean" ]] && exclude_list+=("$clean") + done < <(printf '%s\n' "$excludes_raw" | awk '{ + gsub(/[\[\]",]/, " ") + for (i = 1; i <= NF; i++) print $i + }') + + local -a filtered + local rule skip + for rule in "${expanded[@]}"; do + skip=false + for ex in "${exclude_list[@]}"; do + [[ "$rule" == "$ex" ]] && { skip=true; break; } + done + $skip && continue + filtered+=("$rule") + done + expanded=("${filtered[@]}") + fi + + printf '%s\n' "${expanded[@]}" } # codebase_default_scope_for_rule @@ -109,19 +155,29 @@ codebase_scope_for_rule() { codebase_default_scope_for_rule "$rule" } -# codebase_target_for_rule +# codebase_targets_for_rule # -# Emit the concrete target path for a rule after scope resolution. -codebase_target_for_rule() { +# Emit the concrete target path(s) for a rule after scope resolution. +# When the scope value is a space-separated list of paths (from TOML string +# values like or-true = ".mise/tasks lib"), emits one line per target. +codebase_targets_for_rule() { local repo_root="$1" local rule="$2" - local scope + local scope token scope=$(codebase_scope_for_rule "$repo_root" "$rule") + # Handle empty/dot scope — single target: the repo root itself. case "$scope" in - ""|".") printf '%s\n' "$repo_root" ;; - /*) printf '%s\n' "$scope" ;; - *) printf '%s/%s\n' "$repo_root" "$scope" ;; + ""|".") printf '%s\n' "$repo_root"; return 0 ;; esac + + # Split scope on whitespace into individual path tokens and resolve each. + # This supports multi-path scopes like ".mise/tasks lib" from TOML. + for token in $scope; do + case "$token" in + /*) printf '%s\n' "$token" ;; + *) printf '%s/%s\n' "$repo_root" "$token" ;; + esac + done } diff --git a/lib/lint-groups.sh b/lib/lint-groups.sh new file mode 100644 index 0000000..9fd2274 --- /dev/null +++ b/lib/lint-groups.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Built-in lint group definitions and expansion helpers. +# +# Groups are named bundles of lint rules that repos can reference with +# the @prefix, e.g. lint = ["@maintained-tool"]. This eliminates the +# need to enumerate every convention rule in every repo's mise.toml. +# +# To add a new group: +# 1. Define it in _CODEBASE_LINT_GROUPS below +# 2. List its rules (lowercase-hyphenated, matching .mise/tasks/lint/) +# 3. Update docs in AGENTS.md and/or README +# +# To add a new rule to an existing group: +# 1. Add the rule name to the group's string +# 2. Ensure member repos can opt out via [_.codebase].lint_exclude if needed + +# === Built-in group definitions === + +declare -A _CODEBASE_LINT_GROUPS + +# @maintained-tool — the standard convention bundle for maintained KKL +# tool repos (CLI tools, libraries, SDK wrappers). Includes all shared +# conventions that every maintained-tool repo should follow. +_CODEBASE_LINT_GROUPS["@maintained-tool"]="mise-settings gum-table bats-test-helper bats-test-task mcr-scope or-true shellcheck caller-pwd-contract github-actions" + +# Future groups: +# _CODEBASE_LINT_GROUPS["@minimal"]="mise-settings shellcheck" +# _CODEBASE_LINT_GROUPS["@ci-only"]="github-actions mise-settings" + +# === Public API === + +# codebase_expand_lint_groups ... +# +# Accepts one or more lint entries (rule names or @group references). +# Emits concrete rule names, one per line, expanding @-groups inline. +# Unknown groups produce an ERROR message on stderr and return 1. +# Duplicates are preserved (caller may deduplicate if needed). +codebase_expand_lint_groups() { + local entry expanded rules + + for entry in "$@"; do + if [[ "$entry" == @* ]]; then + expanded="${_CODEBASE_LINT_GROUPS[$entry]:-}" + if [[ -z "$expanded" ]]; then + echo "ERROR: unknown lint group '$entry'" >&2 + printf ' known groups:' >&2 + local g + for g in "${!_CODEBASE_LINT_GROUPS[@]}"; do + printf ' %s' "$g" >&2 + done + echo >&2 + return 1 + fi + # Word-split the expanded string: each token is a rule name. + # shellcheck disable=SC2086 — intentional word-splitting + for _rule in $expanded; do + printf '%s\n' "$_rule" + done + else + printf '%s\n' "$entry" + fi + done +} + +# codebase_available_groups +# +# Emit all known group names, one per line, suitable for help display. +codebase_available_groups() { + local name + for name in "${!_CODEBASE_LINT_GROUPS[@]}"; do + printf '%s\n' "$name" + done +} + +# codebase_group_members +# +# Emit the rule names belonging to a group, one per line. +# Returns 1 if the group is unknown. +codebase_group_members() { + local group="$1" + local members="${_CODEBASE_LINT_GROUPS[$group]:-}" + if [[ -z "$members" ]]; then + return 1 + fi + printf '%s\n' $members +} + +# codebase_has_group_reference ... +# +# Returns 0 if any entry starts with @, 1 otherwise. +codebase_has_group_reference() { + local entry + for entry in "$@"; do + if [[ "$entry" == @* ]]; then + return 0 + fi + done + return 1 +} \ No newline at end of file diff --git a/test/lib/shell-files.bats b/test/lib/shell-files.bats index a462482..5d6ed6e 100644 --- a/test/lib/shell-files.bats +++ b/test/lib/shell-files.bats @@ -7,9 +7,7 @@ setup() { source "$REPO_DIR/lib/shell-files.sh" } -# ============================================================================ # resolve_target -# ============================================================================ @test "resolve_target: absolute path passes through unchanged" { result=$(resolve_target "/some/absolute/path") diff --git a/test/lint/bats-python-one-liner/bats-python-one-liner.bats b/test/lint/bats-python-one-liner/bats-python-one-liner.bats new file mode 100644 index 0000000..1018800 --- /dev/null +++ b/test/lint/bats-python-one-liner/bats-python-one-liner.bats @@ -0,0 +1,113 @@ +#!/usr/bin/env bats +# Tests for lint:bats-python-one-liner rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +# Pass paths + +@test "bats-python-one-liner: passes on clean codebase with heredoc Python" { + run codebase lint:bats-python-one-liner "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"clean"* ]] +} + +@test "bats-python-one-liner: passes when target has no test/ dir" { + run codebase lint:bats-python-one-liner "$FIXTURES/empty" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"empty"* ]] + [[ "$output" == *"no test/ .bats files found"* ]] +} + +@test "bats-python-one-liner: does not flag short harmless python3 -c print" { + run codebase lint:bats-python-one-liner "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +# Detection + +@test "bats-python-one-liner: flags python3 -c with assert" { + run codebase lint:bats-python-one-liner "$FIXTURES/dirty-assert" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-assert"* ]] + [[ "$output" == *"python3 -c"* ]] + [[ "$output" == *"assert"* ]] +} + +@test "bats-python-one-liner: flags python -c with assert" { + run codebase lint:bats-python-one-liner "$FIXTURES/dirty-python" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-python"* ]] + [[ "$output" == *"python -c"* ]] + [[ "$output" == *"assert"* ]] +} + +@test "bats-python-one-liner: flags long python3 -c with assert" { + run codebase lint:bats-python-one-liner "$FIXTURES/dirty-long" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-long"* ]] + [[ "$output" == *"assert"* ]] +} + +# False positives — these should NOT be flagged + +@test "bats-python-one-liner: does NOT flag heredoc Python" { + run codebase lint:bats-python-one-liner "$FIXTURES/heredoc" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +# Ignore directives + +@test "bats-python-one-liner: inline '# codebase:ignore' suppresses a single line" { + run codebase lint:bats-python-one-liner "$FIXTURES/ignored-inline" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "bats-python-one-liner: 'codebase:ignore bats-python-one-liner' in mise.toml skips target" { + run codebase lint:bats-python-one-liner "$FIXTURES/ignored-file" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"*"ignored-file"* ]] +} + +# Output details + +@test "bats-python-one-liner: fail output includes file:line citations" { + run codebase lint:bats-python-one-liner "$FIXTURES/dirty-assert" + [ "$status" -ne 0 ] + [[ "$output" == *"test/bad.bats:"*":"* ]] +} + +@test "bats-python-one-liner: fail output includes remediation hint" { + run codebase lint:bats-python-one-liner "$FIXTURES/dirty-assert" + [ "$status" -ne 0 ] + [[ "$output" == *"bats-tool-testing.md"* ]] + [[ "$output" == *"heredoc"* ]] +} + +# Error handling + +@test "bats-python-one-liner: fails when no targets given" { + run codebase lint:bats-python-one-liner + [ "$status" -ne 0 ] +} + +@test "bats-python-one-liner: fails when target does not exist" { + run codebase lint:bats-python-one-liner "/nonexistent/path/xyz" + [ "$status" -ne 0 ] + [[ "$output" == *"does not exist"* ]] +} + +# Multi-target + +@test "bats-python-one-liner: accepts multiple targets and reports each" { + run codebase lint:bats-python-one-liner "$FIXTURES/clean" "$FIXTURES/dirty-assert" + [ "$status" -ne 0 ] + [[ "$output" == *"OK"*"clean"* ]] + [[ "$output" == *"FAIL"*"dirty-assert"* ]] +} diff --git a/test/lint/bats-python-one-liner/fixtures/clean/mise.toml b/test/lint/bats-python-one-liner/fixtures/clean/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bats-python-one-liner/fixtures/clean/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bats-python-one-liner/fixtures/clean/test/example.bats b/test/lint/bats-python-one-liner/fixtures/clean/test/example.bats new file mode 100644 index 0000000..d01226c --- /dev/null +++ b/test/lint/bats-python-one-liner/fixtures/clean/test/example.bats @@ -0,0 +1,14 @@ +#!/usr/bin/env bats +load test_helper + +@test "parses frontmatter with heredoc" { + run mytool parse + json_file="$BATS_TEST_TMPDIR/parse.json" + printf '%s\n' "$output" > "$json_file" + python3 - "$json_file" <<'PY' +import json, sys +data = json.load(open(sys.argv[1])) +assert data["frontmatter"] == {} +PY + [ "$status" -eq 0 ] +} diff --git a/test/lint/bats-python-one-liner/fixtures/clean/test/short-print.bats b/test/lint/bats-python-one-liner/fixtures/clean/test/short-print.bats new file mode 100644 index 0000000..4f54107 --- /dev/null +++ b/test/lint/bats-python-one-liner/fixtures/clean/test/short-print.bats @@ -0,0 +1,8 @@ +#!/usr/bin/env bats +load test_helper + +@test "short harmless print" { + run mytool version + result="$(python3 -c 'print("hello")')" + [ "$result" = "hello" ] +} diff --git a/test/lint/bats-python-one-liner/fixtures/dirty-assert/test/bad.bats b/test/lint/bats-python-one-liner/fixtures/dirty-assert/test/bad.bats new file mode 100644 index 0000000..995356f --- /dev/null +++ b/test/lint/bats-python-one-liner/fixtures/dirty-assert/test/bad.bats @@ -0,0 +1,7 @@ +#!/usr/bin/env bats + +@test "parses frontmatter" { + run mytool parse + echo "$output" | python3 -c "import sys, json; data = json.load(sys.stdin); assert data['frontmatter'] == {}" + [ "$status" -eq 0 ] +} diff --git a/test/lint/bats-python-one-liner/fixtures/dirty-long/test/bad.bats b/test/lint/bats-python-one-liner/fixtures/dirty-long/test/bad.bats new file mode 100644 index 0000000..12bfa88 --- /dev/null +++ b/test/lint/bats-python-one-liner/fixtures/dirty-long/test/bad.bats @@ -0,0 +1,7 @@ +#!/usr/bin/env bats + +@test "complex transformation" { + run mytool transform + result="$(python3 -c "import sys; data = [l.strip().split(',') for l in sys.stdin]; assert all(len(d) > 2 for d in data)")" + [ -n "$result" ] +} diff --git a/test/lint/bats-python-one-liner/fixtures/dirty-python/test/bad.bats b/test/lint/bats-python-one-liner/fixtures/dirty-python/test/bad.bats new file mode 100644 index 0000000..5077012 --- /dev/null +++ b/test/lint/bats-python-one-liner/fixtures/dirty-python/test/bad.bats @@ -0,0 +1,7 @@ +#!/usr/bin/env bats + +@test "parses frontmatter with python" { + run mytool parse + echo "$output" | python -c "import sys, json; data = json.load(sys.stdin); assert data['frontmatter'] == {}" + [ "$status" -eq 0 ] +} diff --git a/test/lint/bats-python-one-liner/fixtures/heredoc/test/good.bats b/test/lint/bats-python-one-liner/fixtures/heredoc/test/good.bats new file mode 100644 index 0000000..3151fcc --- /dev/null +++ b/test/lint/bats-python-one-liner/fixtures/heredoc/test/good.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +@test "parses frontmatter with heredoc" { + run mytool parse + json_file="$BATS_TEST_TMPDIR/parse.json" + printf '%s\n' "$output" > "$json_file" + python3 - "$json_file" <<'PY' +import json +import sys + +data = json.load(open(sys.argv[1])) +assert data["frontmatter"] == {} +assert data["frontmatter_present"] is False +assert data["diagnostics"] == [] +PY + [ "$status" -eq 0 ] +} diff --git a/test/lint/bats-python-one-liner/fixtures/ignored-file/mise.toml b/test/lint/bats-python-one-liner/fixtures/ignored-file/mise.toml new file mode 100644 index 0000000..054b4a9 --- /dev/null +++ b/test/lint/bats-python-one-liner/fixtures/ignored-file/mise.toml @@ -0,0 +1,3 @@ +[_.codebase] +lint = ["bats-python-one-liner"] +codebase:ignore bats-python-one-liner diff --git a/test/lint/bats-python-one-liner/fixtures/ignored-file/test/bad.bats b/test/lint/bats-python-one-liner/fixtures/ignored-file/test/bad.bats new file mode 100644 index 0000000..995356f --- /dev/null +++ b/test/lint/bats-python-one-liner/fixtures/ignored-file/test/bad.bats @@ -0,0 +1,7 @@ +#!/usr/bin/env bats + +@test "parses frontmatter" { + run mytool parse + echo "$output" | python3 -c "import sys, json; data = json.load(sys.stdin); assert data['frontmatter'] == {}" + [ "$status" -eq 0 ] +} diff --git a/test/lint/bats-python-one-liner/fixtures/ignored-inline/test/bad.bats b/test/lint/bats-python-one-liner/fixtures/ignored-inline/test/bad.bats new file mode 100644 index 0000000..4b7b917 --- /dev/null +++ b/test/lint/bats-python-one-liner/fixtures/ignored-inline/test/bad.bats @@ -0,0 +1,7 @@ +#!/usr/bin/env bats + +@test "parses frontmatter with inline ignore" { + run mytool parse + echo "$output" | python3 -c "import sys, json; data = json.load(sys.stdin); assert data['frontmatter'] == {}" # codebase:ignore bats-python-one-liner — intentional inline assertion for a trivial check + [ "$status" -eq 0 ] +} 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/bats-setup-suite-path/bats-setup-suite-path.bats b/test/lint/bats-setup-suite-path/bats-setup-suite-path.bats new file mode 100644 index 0000000..c32faf4 --- /dev/null +++ b/test/lint/bats-setup-suite-path/bats-setup-suite-path.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# Tests for bats-setup-suite-path lint rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +# ============================================================================ +# Detection +# ============================================================================ + +@test "lint: passes when no test/setup_suite.bash exists" { + run codebase lint:bats-setup-suite-path "$FIXTURES/no-setup-suite" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + [[ "$output" == *"no test/setup_suite.bash"* ]] +} + +@test "lint: passes when BATS_LIBEXEC is preserved correctly" { + run codebase lint:bats-setup-suite-path "$FIXTURES/preserved" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + [[ "$output" == *"preserves BATS_LIBEXEC correctly"* ]] +} + +@test "lint: fails when mise env is called without BATS_LIBEXEC preservation" { + run codebase lint:bats-setup-suite-path "$FIXTURES/missing-preserve" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"* ]] + [[ "$output" == *"mise env without BATS_LIBEXEC preservation"* ]] + # Should mention the fix + [[ "$output" == *"bats_libexec"* ]] + [[ "$output" == *"BATS_LIBEXEC:-"* ]] +} + +@test "lint: passes when setup_suite.bash doesn't call mise env" { + run codebase lint:bats-setup-suite-path "$FIXTURES/no-mise-env" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + [[ "$output" == *"doesn't call mise env"* ]] +} + +@test "lint: skips when inline codebase:ignore is present" { + run codebase lint:bats-setup-suite-path "$FIXTURES/ignored-inline" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"* ]] +} + +@test "lint: skips when repo-level codebase:ignore is present in mise.toml" { + run codebase lint:bats-setup-suite-path "$FIXTURES/ignored-repo" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"* ]] +} + +@test "lint: passes when no test/ directory exists" { + run codebase lint:bats-setup-suite-path "$FIXTURES/no-test-dir" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + [[ "$output" == *"no test/setup_suite.bash"* ]] +} + +@test "lint: handles multiple targets with mixed results" { + run codebase lint:bats-setup-suite-path \ + "$FIXTURES/preserved" \ + "$FIXTURES/missing-preserve" \ + "$FIXTURES/no-mise-env" + [ "$status" -ne 0 ] + [[ "$output" == *"OK"*"preserved"* ]] + [[ "$output" == *"FAIL"*"missing-preserve"* ]] + [[ "$output" == *"OK"*"no-mise-env"* ]] +} + +# ============================================================================ +# Error handling +# ============================================================================ + +@test "lint: fails when target does not exist" { + run codebase lint:bats-setup-suite-path /nonexistent + [ "$status" -ne 0 ] + [[ "$output" == *"ERROR"* ]] +} + +@test "lint: fails when no targets provided" { + run codebase lint:bats-setup-suite-path + [ "$status" -ne 0 ] + [[ "$output" == *"ERROR"* ]] +} \ No newline at end of file diff --git a/test/lint/bats-setup-suite-path/fixtures/ignored-inline/test/setup_suite.bash b/test/lint/bats-setup-suite-path/fixtures/ignored-inline/test/setup_suite.bash new file mode 100644 index 0000000..ef491e7 --- /dev/null +++ b/test/lint/bats-setup-suite-path/fixtures/ignored-inline/test/setup_suite.bash @@ -0,0 +1,6 @@ +setup_suite() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + export REPO_DIR + # codebase:ignore bats-setup-suite-path -- intentionally bypassing for test setup reasons + eval "$(cd "$REPO_DIR" && mise env)" +} \ No newline at end of file diff --git a/test/lint/bats-setup-suite-path/fixtures/ignored-repo/mise.toml b/test/lint/bats-setup-suite-path/fixtures/ignored-repo/mise.toml new file mode 100644 index 0000000..5b3247b --- /dev/null +++ b/test/lint/bats-setup-suite-path/fixtures/ignored-repo/mise.toml @@ -0,0 +1,3 @@ +[_.codebase] +lint = ["mise-settings"] +codebase:ignore bats-setup-suite-path \ No newline at end of file diff --git a/test/lint/bats-setup-suite-path/fixtures/ignored-repo/test/setup_suite.bash b/test/lint/bats-setup-suite-path/fixtures/ignored-repo/test/setup_suite.bash new file mode 100644 index 0000000..e7945ce --- /dev/null +++ b/test/lint/bats-setup-suite-path/fixtures/ignored-repo/test/setup_suite.bash @@ -0,0 +1,5 @@ +setup_suite() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + export REPO_DIR + eval "$(cd "$REPO_DIR" && mise env)" +} \ No newline at end of file diff --git a/test/lint/bats-setup-suite-path/fixtures/missing-preserve/test/setup_suite.bash b/test/lint/bats-setup-suite-path/fixtures/missing-preserve/test/setup_suite.bash new file mode 100644 index 0000000..e7945ce --- /dev/null +++ b/test/lint/bats-setup-suite-path/fixtures/missing-preserve/test/setup_suite.bash @@ -0,0 +1,5 @@ +setup_suite() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + export REPO_DIR + eval "$(cd "$REPO_DIR" && mise env)" +} \ No newline at end of file diff --git a/test/lint/bats-setup-suite-path/fixtures/no-mise-env/test/setup_suite.bash b/test/lint/bats-setup-suite-path/fixtures/no-mise-env/test/setup_suite.bash new file mode 100644 index 0000000..f976535 --- /dev/null +++ b/test/lint/bats-setup-suite-path/fixtures/no-mise-env/test/setup_suite.bash @@ -0,0 +1,5 @@ +setup_suite() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + export REPO_DIR + echo "setup complete" +} \ No newline at end of file diff --git a/test/lint/bats-setup-suite-path/fixtures/no-setup-suite/.gitkeep b/test/lint/bats-setup-suite-path/fixtures/no-setup-suite/.gitkeep new file mode 100644 index 0000000..2979cca --- /dev/null +++ b/test/lint/bats-setup-suite-path/fixtures/no-setup-suite/.gitkeep @@ -0,0 +1 @@ +# Minimal repo fixture — no test/setup_suite.bash to check \ No newline at end of file diff --git a/test/lint/bats-setup-suite-path/fixtures/preserved/test/setup_suite.bash b/test/lint/bats-setup-suite-path/fixtures/preserved/test/setup_suite.bash new file mode 100644 index 0000000..59b69ff --- /dev/null +++ b/test/lint/bats-setup-suite-path/fixtures/preserved/test/setup_suite.bash @@ -0,0 +1,10 @@ +setup_suite() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + export REPO_DIR + + bats_libexec="${BATS_LIBEXEC:-}" + eval "$(cd "$REPO_DIR" && mise env)" + if [ -n "$bats_libexec" ]; then + export PATH="$bats_libexec:$PATH" + fi +} \ No newline at end of file diff --git a/test/lint/bats-test-helper/bats-test-helper.bats b/test/lint/bats-test-helper/bats-test-helper.bats index 27ebba4..f4b0e76 100644 --- a/test/lint/bats-test-helper/bats-test-helper.bats +++ b/test/lint/bats-test-helper/bats-test-helper.bats @@ -7,9 +7,7 @@ setup() { FIXTURES="$BATS_TEST_DIRNAME/fixtures" } -# ============================================================================ # Pass paths -# ============================================================================ @test "bats-test-helper: passes on clean wrapper-based tests" { run codebase lint:bats-test-helper "$FIXTURES/clean" @@ -24,9 +22,7 @@ setup() { [[ "$output" == *"no test/ files found"* ]] } -# ============================================================================ # Invocation signatures — each form fails -# ============================================================================ @test "bats-test-helper: flags 'bash \$TASK' (var whose name contains TASK)" { run codebase lint:bats-test-helper "$FIXTURES/dirty-task-var" @@ -84,9 +80,7 @@ setup() { [[ "$output" == *"bash \$REPO_DIR/.mise/tasks/lint/check"* ]] } -# ============================================================================ # False positives — none of these are actual invocations -# ============================================================================ @test "bats-test-helper: does NOT flag reading a task file as data (grep/cat)" { # Regression guard: 'grep "$MCR/.mise/tasks/foo"' reads the script, doesn't @@ -97,9 +91,7 @@ setup() { [[ "$output" == *"OK"* ]] } -# ============================================================================ # Ignore directives -# ============================================================================ @test "bats-test-helper: inline '# codebase:ignore' suppresses a single line" { run codebase lint:bats-test-helper "$FIXTURES/ignored-inline" @@ -113,9 +105,7 @@ setup() { [[ "$output" == *"SKIP"*"ignored-file"* ]] } -# ============================================================================ # Output details -# ============================================================================ @test "bats-test-helper: fail output includes file:line citations" { run codebase lint:bats-test-helper "$FIXTURES/dirty-task-var" @@ -130,9 +120,7 @@ setup() { [[ "$output" == *"Call the Tool"* ]] } -# ============================================================================ # Error handling -# ============================================================================ @test "bats-test-helper: fails when no targets given" { run codebase lint:bats-test-helper @@ -146,9 +134,7 @@ setup() { [[ "$output" == *"does not exist"* ]] } -# ============================================================================ # Multi-target -# ============================================================================ @test "bats-test-helper: accepts multiple targets and reports each" { run codebase lint:bats-test-helper "$FIXTURES/clean" "$FIXTURES/dirty-task-var" diff --git a/test/lint/bats-test-task/bats-test-task.bats b/test/lint/bats-test-task/bats-test-task.bats index 295d92f..f761f2b 100644 --- a/test/lint/bats-test-task/bats-test-task.bats +++ b/test/lint/bats-test-task/bats-test-task.bats @@ -7,9 +7,7 @@ setup() { FIXTURES="$BATS_TEST_DIRNAME/fixtures" } -# ============================================================================ # Pass paths -# ============================================================================ @test "bats-test-task: passes on the canonical pattern" { run codebase lint:bats-test-task "$FIXTURES/clean" @@ -33,9 +31,7 @@ setup() { [[ "$output" == *"OK"*"shimmer-variant"* ]] } -# ============================================================================ # Failure modes -# ============================================================================ @test "bats-test-task: flags missing USAGE arg spec" { run codebase lint:bats-test-task "$FIXTURES/missing-usage-arg" @@ -80,9 +76,7 @@ setup() { [[ "$output" != *"invocations found"* ]] } -# ============================================================================ # Ignore directive -# ============================================================================ @test "bats-test-task: 'codebase:ignore bats-test-task' in mise.toml skips the target" { run codebase lint:bats-test-task "$FIXTURES/ignored-file" @@ -90,9 +84,7 @@ setup() { [[ "$output" == *"SKIP"*"ignored-file"* ]] } -# ============================================================================ # Output details -# ============================================================================ @test "bats-test-task: fail output includes the remediation hint" { run codebase lint:bats-test-task "$FIXTURES/missing-usage-arg" @@ -109,9 +101,7 @@ setup() { [[ "$output" == *"invocations"* ]] } -# ============================================================================ # Error handling -# ============================================================================ @test "bats-test-task: fails when no targets given" { run codebase lint:bats-test-task @@ -125,9 +115,7 @@ setup() { [[ "$output" == *"does not exist"* ]] } -# ============================================================================ # Multi-target -# ============================================================================ @test "bats-test-task: accepts multiple targets and reports each" { run codebase lint:bats-test-task "$FIXTURES/clean" "$FIXTURES/missing-examples" 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 ] diff --git a/test/lint/default.bats b/test/lint/default.bats index 9a6eac1..1f99003 100644 --- a/test/lint/default.bats +++ b/test/lint/default.bats @@ -182,6 +182,44 @@ EOF [[ "$output" == *"no lint rules configured"* ]] } +@test "lint: honors multi-path scope (space-separated targets)" { + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["gum-table"] + +[_.codebase.scope] +gum-table = ".mise/tasks scripts" +EOF + + # Create a dirty file in .mise/tasks/ (printf inside loop = WARN) + write_clean_task + cat > "$REPO/.mise/tasks/build" <<'SCRIPT' +#!/usr/bin/env bash +while read -r item; do + printf "%-10s %s\n" "$item" ok +done < input +SCRIPT + + # Create a dirty file in scripts/ (printf padding = INFO, not a WARN) + mkdir -p "$REPO/scripts" + cat > "$REPO/scripts/deploy" <<'SCRIPT' +#!/usr/bin/env bash +printf "%-20s %s\n" "$1" "$2" +SCRIPT + + run codebase lint "$REPO" + # gum-table fails on .mise/tasks/build (loop-table WARN) + [ "$status" -ne 0 ] + [[ "$output" == *"codebase: lint:gum-table $REPO_ROOT/.mise/tasks"* ]] + [[ "$output" == *"codebase: lint:gum-table $REPO_ROOT/scripts"* ]] + [[ "$output" == *"WARN"*"tasks:build"* ]] + [[ "$output" == *"scripts:deploy"* ]] +} + @test "lint: parses multiline lint arrays" { write_config <<'EOF' [settings] diff --git a/test/lint/gum-table/gum-table.bats b/test/lint/gum-table/gum-table.bats index 2dfddaa..809b670 100644 --- a/test/lint/gum-table/gum-table.bats +++ b/test/lint/gum-table/gum-table.bats @@ -7,9 +7,7 @@ setup() { FIXTURES="$BATS_TEST_DIRNAME/fixtures" } -# ============================================================================ # High confidence: column -t (always a true positive) -# ============================================================================ @test "column-t: detects piping to column -t" { run codebase lint:gum-table "$FIXTURES/manual-padding/task-c" @@ -18,9 +16,7 @@ setup() { [[ "$output" == *"WARN"* ]] } -# ============================================================================ # High confidence: printf padding inside a loop -# ============================================================================ @test "loop-table: detects printf %-Ns inside while-read" { run codebase lint:gum-table "$FIXTURES/manual-padding/task-b" @@ -45,9 +41,7 @@ setup() { echo "$output" | grep "padding" | grep -q "INFO" } -# ============================================================================ # Low confidence: printf padding outside loops (INFO only, not a failure) -# ============================================================================ @test "padding: printf %-Ns outside loop is INFO, not a failure" { run codebase lint:gum-table "$FIXTURES/manual-padding/task-a" @@ -69,9 +63,7 @@ setup() { [[ "$output" == *"INFO"* ]] } -# ============================================================================ # True negatives — no output at all -# ============================================================================ @test "clean: already using gum table" { run codebase lint:gum-table "$FIXTURES/clean/task-gum" @@ -103,9 +95,7 @@ setup() { [[ "$output" == *"OK"* ]] } -# ============================================================================ # Multi-file scanning -# ============================================================================ @test "directory scan finds high-confidence hits" { run codebase lint:gum-table "$FIXTURES/manual-padding" @@ -121,9 +111,7 @@ setup() { [ "$status" -eq 0 ] } -# ============================================================================ # Output format -# ============================================================================ @test "WARN output includes file path, category, and line number" { run codebase lint:gum-table "$FIXTURES/manual-padding/task-b" @@ -136,9 +124,7 @@ setup() { [[ "$output" =~ INFO.*task-a:\[padding\].*[0-9]+: ]] } -# ============================================================================ # Error handling -# ============================================================================ @test "fails when target does not exist" { run codebase lint:gum-table /nonexistent @@ -146,9 +132,7 @@ setup() { [[ "$output" == *"ERROR"* ]] } -# ============================================================================ # Relative path resolution (regression: codebase#24) -# ============================================================================ @test "relative path resolves against CODEBASE_CALLER_PWD (dirty fixture)" { # Regression: relative targets resolved against codebase's install diff --git a/test/lint/lint-groups/lint-groups.bats b/test/lint/lint-groups/lint-groups.bats new file mode 100644 index 0000000..9aaee09 --- /dev/null +++ b/test/lint/lint-groups/lint-groups.bats @@ -0,0 +1,258 @@ +#!/usr/bin/env bats +# Tests for lint group expansion (lib/lint-groups.sh) and aggregate dispatch + +load ../../test_helper + +# Library-level tests (source lint-groups.sh directly) + +@test "lint-groups: codebase_available_groups lists @maintained-tool" { + source "$REPO_DIR/lib/lint-groups.sh" + run codebase_available_groups + [ "$status" -eq 0 ] + [[ "$output" == *"@maintained-tool"* ]] +} + +@test "lint-groups: codebase_group_members returns 9 rules for @maintained-tool" { + source "$REPO_DIR/lib/lint-groups.sh" + run codebase_group_members "@maintained-tool" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 9 ] + [[ "$output" == *"mise-settings"* ]] + [[ "$output" == *"gum-table"* ]] + [[ "$output" == *"bats-test-helper"* ]] + [[ "$output" == *"bats-test-task"* ]] + [[ "$output" == *"mcr-scope"* ]] + [[ "$output" == *"or-true"* ]] + [[ "$output" == *"shellcheck"* ]] + [[ "$output" == *"caller-pwd-contract"* ]] + [[ "$output" == *"github-actions"* ]] +} + +@test "lint-groups: codebase_group_members fails on unknown group" { + source "$REPO_DIR/lib/lint-groups.sh" + run codebase_group_members "@nonexistent" + [ "$status" -ne 0 ] +} + +@test "lint-groups: codebase_has_group_reference detects @ prefix" { + source "$REPO_DIR/lib/lint-groups.sh" + codebase_has_group_reference "@maintained-tool" "or-true" + [ "$?" -eq 0 ] +} + +@test "lint-groups: codebase_has_group_reference returns 1 when no @ present" { + source "$REPO_DIR/lib/lint-groups.sh" + run codebase_has_group_reference "mise-settings" "or-true" + [ "$status" -ne 0 ] +} + +@test "lint-groups: codebase_expand_lint_groups expands @maintained-tool to 9 rules" { + source "$REPO_DIR/lib/lint-groups.sh" + run codebase_expand_lint_groups "@maintained-tool" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 9 ] +} + +@test "lint-groups: codebase_expand_lint_groups passes through individual rules" { + source "$REPO_DIR/lib/lint-groups.sh" + run codebase_expand_lint_groups "or-true" "shellcheck" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 2 ] + [[ "$output" == *"or-true"* ]] + [[ "$output" == *"shellcheck"* ]] +} + +@test "lint-groups: codebase_expand_lint_groups mixes group + individual rules" { + source "$REPO_DIR/lib/lint-groups.sh" + run codebase_expand_lint_groups "@maintained-tool" "or-true" + [ "$status" -eq 0 ] + # 9 from group + 1 individual = 10 (or-true appears twice, preserved) + [ "${#lines[@]}" -eq 10 ] +} + +@test "lint-groups: codebase_expand_lint_groups errors on unknown group" { + source "$REPO_DIR/lib/lint-groups.sh" + run codebase_expand_lint_groups "@nonexistent" + [ "$status" -ne 0 ] + [[ "$output" == *"ERROR"* ]] + [[ "$output" == *"unknown lint group"* ]] +} + +@test "lint-groups: codebase_expand_lint_groups errors on unknown group mixed with valid" { + source "$REPO_DIR/lib/lint-groups.sh" + run codebase_expand_lint_groups "mise-settings" "@nonexistent" + [ "$status" -ne 0 ] + [[ "$output" == *"ERROR"* ]] + [[ "$output" == *"unknown lint group"* ]] +} + +# Aggregate dispatch — group expansion in codebase lint + +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" +} + +@test "lint: expands @maintained-tool group and shows preamble" { + # Create a repo with all the dirs/files that @maintained-tool rules need to pass + mkdir -p "$REPO/.mise/tasks" + mkdir -p "$REPO/.github/workflows" + cat > "$REPO/.github/workflows/test.yml" <<'EOF' +name: Test +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 +EOF + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["@maintained-tool"] +EOF + + run codebase lint "$REPO" + echo "STATUS=$status" + echo "OUTPUT=$output" + [ "$status" -eq 0 ] + [[ "$output" == *"codebase: expanded 9 rule(s) from lint groups"* ]] + # Verify all 9 rules appear in the output + [[ "$output" == *"codebase: lint:mise-settings"* ]] + [[ "$output" == *"codebase: lint:gum-table"* ]] + [[ "$output" == *"codebase: lint:bats-test-helper"* ]] + [[ "$output" == *"codebase: lint:bats-test-task"* ]] + [[ "$output" == *"codebase: lint:mcr-scope"* ]] + [[ "$output" == *"codebase: lint:or-true"* ]] + [[ "$output" == *"codebase: lint:shellcheck"* ]] + [[ "$output" == *"codebase: lint:caller-pwd-contract"* ]] + [[ "$output" == *"codebase: lint:github-actions"* ]] +} + +@test "lint: expands @maintained-tool group with pass-through for extra rules" { + mkdir -p "$REPO/.mise/tasks" + mkdir -p "$REPO/.github/workflows" + cat > "$REPO/.github/workflows/test.yml" <<'EOF' +name: Test +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 +EOF + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["@maintained-tool"] +EOF + + run codebase lint "$REPO" + [ "$status" -eq 0 ] + [[ "$output" == *"9 rule(s)"* ]] + [[ "$output" == *"codebase: all 9 lint rule(s) passed"* ]] +} + +@test "lint: lint_exclude removes a rule from expanded group" { + mkdir -p "$REPO/.mise/tasks" + mkdir -p "$REPO/.github/workflows" + cat > "$REPO/.github/workflows/test.yml" <<'EOF' +name: Test +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 +EOF + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["@maintained-tool"] +lint_exclude = ["caller-pwd-contract"] +EOF + + run codebase lint "$REPO" + echo "STATUS=$status" + echo "OUTPUT=$output" + [ "$status" -eq 0 ] + # 8 remaining rules + [[ "$output" == *"expanded 8 rule(s)"* ]] + [[ "$output" == *"codebase: all 8 lint rule(s) passed"* ]] + # caller-pwd-contract should NOT appear + [[ "$output" != *"lint:caller-pwd-contract"* ]] +} + +@test "lint: group + individual rules both shown in preamble count" { + mkdir -p "$REPO/.mise/tasks" + mkdir -p "$REPO/.github/workflows" + cat > "$REPO/.github/workflows/test.yml" <<'EOF' +name: Test +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 +EOF + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["@maintained-tool"] +EOF + + run codebase lint "$REPO" + [ "$status" -eq 0 ] + [[ "$output" == *"expanded 9 rule(s)"* ]] +} + +@test "lint: no-group pass-through still works (backward compat)" { + write_config <<'EOF' +[settings] +quiet = true +task_output = "interleave" + +[_.codebase] +lint = ["mise-settings"] +EOF + + run codebase lint "$REPO" + [ "$status" -eq 0 ] + # Should NOT show the groups preamble + [[ "$output" != *"expanded"* ]] + [[ "$output" == *"codebase: lint:mise-settings"* ]] + [[ "$output" == *"codebase: all 1 lint rule(s) passed"* ]] +} + +@test "lint: lint:groups task lists available groups" { + run codebase lint:groups + [ "$status" -eq 0 ] + [[ "$output" == *"@maintained-tool"* ]] + [[ "$output" == *"Usage in mise.toml"* ]] +} + +@test "lint: lint:groups --all shows expanded rules" { + run codebase lint:groups --all + [ "$status" -eq 0 ] + [[ "$output" == *"@maintained-tool"* ]] + [[ "$output" == *"- mise-settings"* ]] + [[ "$output" == *"- github-actions"* ]] +} \ No newline at end of file diff --git a/test/lint/mcr-scope/mcr-scope.bats b/test/lint/mcr-scope/mcr-scope.bats index 3ae6215..49869b4 100644 --- a/test/lint/mcr-scope/mcr-scope.bats +++ b/test/lint/mcr-scope/mcr-scope.bats @@ -7,9 +7,7 @@ setup() { FIXTURES="$BATS_TEST_DIRNAME/fixtures" } -# ============================================================================ # Detection -# ============================================================================ @test "mcr-scope: passes on a clean codebase" { run codebase lint:mcr-scope "$FIXTURES/clean" @@ -102,9 +100,7 @@ setup() { [[ "$output" == *"no test/ or lib/ files found"* ]] } -# ============================================================================ # Ignore directives -# ============================================================================ @test "mcr-scope: inline '# codebase:ignore' suppresses a single line" { run codebase lint:mcr-scope "$FIXTURES/ignored-inline" @@ -118,9 +114,7 @@ setup() { [[ "$output" == *"SKIP"*"ignored-file"* ]] } -# ============================================================================ # Output details -# ============================================================================ @test "mcr-scope: fail output includes file:line citations" { run codebase lint:mcr-scope "$FIXTURES/dirty-lib" @@ -136,9 +130,7 @@ setup() { [[ "$output" == *"BASH_SOURCE"* ]] } -# ============================================================================ # Error handling -# ============================================================================ @test "mcr-scope: fails when no targets given" { run codebase lint:mcr-scope @@ -153,9 +145,7 @@ setup() { [[ "$output" == *"does not exist"* ]] } -# ============================================================================ # Multi-target -# ============================================================================ @test "mcr-scope: accepts multiple targets and reports each" { run codebase lint:mcr-scope "$FIXTURES/clean" "$FIXTURES/dirty-test" diff --git a/test/lint/mise-run-quiet/fixtures/clean-already-quiet/.mise/tasks/t b/test/lint/mise-run-quiet/fixtures/clean-already-quiet/.mise/tasks/t new file mode 100644 index 0000000..cfee627 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/clean-already-quiet/.mise/tasks/t @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Already using -q — should be fine +mise run -q build +mise run -q test -- --filter unit +result=$(mise run -q status) diff --git a/test/lint/mise-run-quiet/fixtures/clean-already-quiet/mise.toml b/test/lint/mise-run-quiet/fixtures/clean-already-quiet/mise.toml new file mode 100644 index 0000000..8a0a5a4 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/clean-already-quiet/mise.toml @@ -0,0 +1,2 @@ +[settings] +quiet = true diff --git a/test/lint/mise-run-quiet/fixtures/clean/.mise/tasks/t b/test/lint/mise-run-quiet/fixtures/clean/.mise/tasks/t new file mode 100644 index 0000000..559debd --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/clean/.mise/tasks/t @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# No mise run calls here — just regular shell commands +echo "hello world" +ls -la +git status diff --git a/test/lint/mise-run-quiet/fixtures/clean/mise.toml b/test/lint/mise-run-quiet/fixtures/clean/mise.toml new file mode 100644 index 0000000..8a0a5a4 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/clean/mise.toml @@ -0,0 +1,2 @@ +[settings] +quiet = true diff --git a/test/lint/mise-run-quiet/fixtures/dirty-command-sub/.mise/tasks/t b/test/lint/mise-run-quiet/fixtures/dirty-command-sub/.mise/tasks/t new file mode 100644 index 0000000..493ccb8 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/dirty-command-sub/.mise/tasks/t @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Command substitution — header goes into captured output +result=$(mise run build) +echo "$result" + +# Also in a more complex expression +count=$(mise run count --all) diff --git a/test/lint/mise-run-quiet/fixtures/dirty-command-sub/mise.toml b/test/lint/mise-run-quiet/fixtures/dirty-command-sub/mise.toml new file mode 100644 index 0000000..8a0a5a4 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/dirty-command-sub/mise.toml @@ -0,0 +1,2 @@ +[settings] +quiet = true diff --git a/test/lint/mise-run-quiet/fixtures/dirty-eval/.mise/tasks/t b/test/lint/mise-run-quiet/fixtures/dirty-eval/.mise/tasks/t new file mode 100644 index 0000000..4deeb26 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/dirty-eval/.mise/tasks/t @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +# eval — header pollutes the eval'd string +eval "$(mise run env)" + +# Also with additional flags +eval "$(mise run shell --init)" diff --git a/test/lint/mise-run-quiet/fixtures/dirty-eval/mise.toml b/test/lint/mise-run-quiet/fixtures/dirty-eval/mise.toml new file mode 100644 index 0000000..8a0a5a4 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/dirty-eval/mise.toml @@ -0,0 +1,2 @@ +[settings] +quiet = true diff --git a/test/lint/mise-run-quiet/fixtures/dirty-mixed/.mise/tasks/t b/test/lint/mise-run-quiet/fixtures/dirty-mixed/.mise/tasks/t new file mode 100644 index 0000000..9a3a05b --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/dirty-mixed/.mise/tasks/t @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Command substitution +result=$(mise run build) + +# eval +eval "$(mise run env)" + +# Pipeline +mise run check | grep error + +# Bare invocation (interactive) — should be fine +mise run status + +# Already quiet — should be fine +mise run -q deploy + +# Stderr redirect — should be fine +mise run lint 2>/dev/null diff --git a/test/lint/mise-run-quiet/fixtures/dirty-mixed/mise.toml b/test/lint/mise-run-quiet/fixtures/dirty-mixed/mise.toml new file mode 100644 index 0000000..8a0a5a4 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/dirty-mixed/mise.toml @@ -0,0 +1,2 @@ +[settings] +quiet = true diff --git a/test/lint/mise-run-quiet/fixtures/dirty-pipe/.mise/tasks/t b/test/lint/mise-run-quiet/fixtures/dirty-pipe/.mise/tasks/t new file mode 100644 index 0000000..3ed5dfe --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/dirty-pipe/.mise/tasks/t @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Pipeline — header goes into the pipe +mise run check | grep error + +# Also with multiple pipes +mise run list | sort | uniq diff --git a/test/lint/mise-run-quiet/fixtures/dirty-pipe/mise.toml b/test/lint/mise-run-quiet/fixtures/dirty-pipe/mise.toml new file mode 100644 index 0000000..8a0a5a4 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/dirty-pipe/mise.toml @@ -0,0 +1,2 @@ +[settings] +quiet = true diff --git a/test/lint/mise-run-quiet/fixtures/ignored-file/.mise/tasks/t b/test/lint/mise-run-quiet/fixtures/ignored-file/.mise/tasks/t new file mode 100644 index 0000000..33859ff --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/ignored-file/.mise/tasks/t @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This file has repo-level ignore, so violations are skipped +result=$(mise run build) +eval "$(mise run env)" +mise run check | grep error diff --git a/test/lint/mise-run-quiet/fixtures/ignored-file/mise.toml b/test/lint/mise-run-quiet/fixtures/ignored-file/mise.toml new file mode 100644 index 0000000..f8a0eb4 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/ignored-file/mise.toml @@ -0,0 +1,3 @@ +codebase:ignore mise-run-quiet +[settings] +quiet = true diff --git a/test/lint/mise-run-quiet/fixtures/ignored-inline/.mise/tasks/t b/test/lint/mise-run-quiet/fixtures/ignored-inline/.mise/tasks/t new file mode 100644 index 0000000..bd59f9b --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/ignored-inline/.mise/tasks/t @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Inline ignore with reason — accepted +result=$(mise run build) # codebase:ignore mise-run-quiet — intentional, header is part of UX + +# No ignore — should be flagged +eval "$(mise run env)" diff --git a/test/lint/mise-run-quiet/fixtures/ignored-inline/mise.toml b/test/lint/mise-run-quiet/fixtures/ignored-inline/mise.toml new file mode 100644 index 0000000..8a0a5a4 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/ignored-inline/mise.toml @@ -0,0 +1,2 @@ +[settings] +quiet = true diff --git a/test/lint/mise-run-quiet/fixtures/no-shell-files/mise.toml b/test/lint/mise-run-quiet/fixtures/no-shell-files/mise.toml new file mode 100644 index 0000000..8a0a5a4 --- /dev/null +++ b/test/lint/mise-run-quiet/fixtures/no-shell-files/mise.toml @@ -0,0 +1,2 @@ +[settings] +quiet = true diff --git a/test/lint/mise-run-quiet/mise-run-quiet.bats b/test/lint/mise-run-quiet/mise-run-quiet.bats new file mode 100644 index 0000000..7919f1f --- /dev/null +++ b/test/lint/mise-run-quiet/mise-run-quiet.bats @@ -0,0 +1,143 @@ +#!/usr/bin/env bats +# Tests for lint:mise-run-quiet rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +# Detection + +@test "mise-run-quiet: passes on a clean codebase (no mise run calls)" { + run codebase lint:mise-run-quiet "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "mise-run-quiet: passes when -q is already used" { + run codebase lint:mise-run-quiet "$FIXTURES/clean-already-quiet" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "mise-run-quiet: flags mise run in command substitution" { + run codebase lint:mise-run-quiet "$FIXTURES/dirty-command-sub" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-command-sub"* ]] + [[ "$output" == *"[command-sub]"* ]] + [[ "$output" == *'$(mise run build)'* ]] +} + +@test "mise-run-quiet: flags mise run in eval" { + run codebase lint:mise-run-quiet "$FIXTURES/dirty-eval" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-eval"* ]] + [[ "$output" == *"[eval]"* ]] + [[ "$output" == *'eval "$(mise run env)"'* ]] +} + +@test "mise-run-quiet: warns on mise run in pipeline" { + run codebase lint:mise-run-quiet "$FIXTURES/dirty-pipe" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-pipe"* ]] + [[ "$output" == *"[pipeline]"* ]] + [[ "$output" == *'mise run check | grep error'* ]] +} + +@test "mise-run-quiet: flags multiple violations in one file" { + run codebase lint:mise-run-quiet "$FIXTURES/dirty-mixed" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-mixed"* ]] + [[ "$output" == *"[command-sub]"* ]] + [[ "$output" == *"[eval]"* ]] + [[ "$output" == *"[pipeline]"* ]] +} + +@test "mise-run-quiet: fail output includes the violating file path" { + run codebase lint:mise-run-quiet "$FIXTURES/dirty-command-sub" + [ "$status" -ne 0 ] + [[ "$output" == *".mise/tasks/t"* ]] +} + +@test "mise-run-quiet: fail output suggests adding -q" { + run codebase lint:mise-run-quiet "$FIXTURES/dirty-command-sub" + [ "$status" -ne 0 ] + [[ "$output" == *"mise run -q"* ]] +} + +# Safe contexts + +@test "mise-run-quiet: does not flag bare mise run (interactive)" { + run codebase lint:mise-run-quiet "$FIXTURES/dirty-mixed" + [ "$status" -ne 0 ] + # Should flag the violations but NOT the bare 'mise run status' line + [[ "$output" == *"FAIL"* ]] + # The bare line should not appear in the output + local bare_count + bare_count=$(echo "$output" | grep -c "mise run status" || true) + # It may appear in the hint text, but should not be flagged as a violation + [[ "$output" != *"[command-sub]".*"mise run status"* ]] + [[ "$output" != *"[eval]".*"mise run status"* ]] + [[ "$output" != *"[pipeline]".*"mise run status"* ]] +} + +@test "mise-run-quiet: does not flag mise run with stderr redirect" { + run codebase lint:mise-run-quiet "$FIXTURES/dirty-mixed" + [ "$status" -ne 0 ] + # The stderr redirect line should not appear as a violation + [[ "$output" != *"mise run lint 2>/dev/null"* ]] +} + +@test "mise-run-quiet: does not flag mise run -q in command substitution" { + run codebase lint:mise-run-quiet "$FIXTURES/clean-already-quiet" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +# Ignore mechanisms + +@test "mise-run-quiet: respects file-level codebase:ignore" { + run codebase lint:mise-run-quiet "$FIXTURES/ignored-file" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"*"ignored-file"* ]] +} + +@test "mise-run-quiet: respects inline codebase:ignore with reason" { + run codebase lint:mise-run-quiet "$FIXTURES/ignored-inline" + [ "$status" -ne 0 ] + # Should flag the eval line (no ignore) but not the command sub line (has ignore) + [[ "$output" == *"[eval]"* ]] + # The ignored line should not appear as a violation + [[ "$output" != *"codebase:ignore mise-run-quiet"* ]] +} + +# Edge cases + +@test "mise-run-quiet: skips directories with no shell files" { + run codebase lint:mise-run-quiet "$FIXTURES/no-shell-files" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"no shell files"* ]] +} + +@test "mise-run-quiet: handles multiple targets" { + run codebase lint:mise-run-quiet "$FIXTURES/clean" "$FIXTURES/dirty-command-sub" + [ "$status" -ne 0 ] + [[ "$output" == *"OK"*"clean"* ]] + [[ "$output" == *"FAIL"*"dirty-command-sub"* ]] +} + +@test "mise-run-quiet: does not flag 'mise run' in comments" { + run codebase lint:mise-run-quiet "$FIXTURES/clean" + [ "$status" -eq 0 ] + # The clean fixture has no mise run calls at all + [[ "$output" == *"OK"* ]] +} + +@test "mise-run-quiet: does not flag mise run with flags (not just bare)" { + run codebase lint:mise-run-quiet "$FIXTURES/dirty-command-sub" + [ "$status" -ne 0 ] + # Should flag both $(mise run build) and $(mise run count --all) + [[ "$output" == *"mise run build"* ]] + [[ "$output" == *"mise run count"* ]] +} diff --git a/test/lint/mise-settings/mise-settings.bats b/test/lint/mise-settings/mise-settings.bats index 565be0a..d925495 100644 --- a/test/lint/mise-settings/mise-settings.bats +++ b/test/lint/mise-settings/mise-settings.bats @@ -7,9 +7,7 @@ setup() { FIXTURES="$BATS_TEST_DIRNAME/fixtures" } -# ============================================================================ # Detection -# ============================================================================ @test "lint: passes when all settings present" { run codebase lint:mise-settings "$FIXTURES/complete" @@ -54,9 +52,7 @@ setup() { [[ "$output" == *"FAIL"*"missing-both"* ]] } -# ============================================================================ # Fix mode -# ============================================================================ @test "fix: adds missing settings" { WORK_DIR="$BATS_TEST_TMPDIR/fix-test" @@ -96,9 +92,7 @@ setup() { [[ "$output" == *"OK"* ]] } -# ============================================================================ # Error handling -# ============================================================================ @test "lint: fails when target does not exist" { run codebase lint:mise-settings /nonexistent diff --git a/test/lint/or-true/or-true.bats b/test/lint/or-true/or-true.bats index 31d7776..68cfaca 100644 --- a/test/lint/or-true/or-true.bats +++ b/test/lint/or-true/or-true.bats @@ -7,9 +7,7 @@ setup() { FIXTURES="$BATS_TEST_DIRNAME/fixtures" } -# ============================================================================ # Detection -# ============================================================================ @test "or-true: passes on a clean codebase" { run codebase lint:or-true "$FIXTURES/clean" @@ -76,9 +74,7 @@ setup() { [[ "$output" == *"if !"* ]] } -# ============================================================================ # Corpus-calibrated diagnostics -# ============================================================================ @test "or-true: arithmetic increments get a safer arithmetic suggestion" { local tmp @@ -173,9 +169,7 @@ EOF [[ "$output" == *"Intentional cases need a rule-specific reason"* ]] } -# ============================================================================ # Ignore directives -# ============================================================================ @test "or-true: inline '# codebase:ignore or-true — reason' skips the line" { run codebase lint:or-true "$FIXTURES/ignored-inline" @@ -189,9 +183,7 @@ EOF [[ "$output" == *"SKIP"*"ignored-file"* ]] } -# ============================================================================ # Scope / discovery -# ============================================================================ @test "or-true: walks the whole target — finds hits outside .mise/tasks and lib/" { run codebase lint:or-true "$FIXTURES/broad-walk" @@ -213,9 +205,7 @@ EOF [[ "$output" == *"no shell files"* ]] } -# ============================================================================ # Discovery correctness -# ============================================================================ @test "or-true: discovery skips non-bash/sh shebangs (fish, zsh, …)" { # Regression: the shebang regex '^#!.*(bash|sh)\b' matched 'sh' as @@ -255,9 +245,7 @@ EOF rm -rf "$tmp" } -# ============================================================================ # Comment handling -# ============================================================================ @test "or-true: does not flag '|| true' inside a single-quoted string" { # Accidental protection: the closing quote ''' is not in the @@ -293,9 +281,7 @@ EOF rm -rf "$tmp" } -# ============================================================================ # Multi-target -# ============================================================================ @test "or-true: checks multiple targets and reports each" { run codebase lint:or-true "$FIXTURES/clean" "$FIXTURES/dirty" @@ -309,9 +295,7 @@ EOF [ "$status" -eq 2 ] } -# ============================================================================ # Error paths -# ============================================================================ @test "or-true: fails when target does not exist" { run codebase lint:or-true "$FIXTURES/does-not-exist" @@ -328,9 +312,7 @@ EOF [[ "$output" == *""* ]] } -# ============================================================================ # Relative path resolution (regression: codebase#24) -# ============================================================================ @test "or-true: relative path resolves against CODEBASE_CALLER_PWD, not codebase install dir" { # Regression: when invoked via the shiv shim, relative paths resolved diff --git a/test/lint/shellcheck/shellcheck.bats b/test/lint/shellcheck/shellcheck.bats index 370c7d6..f0ac032 100644 --- a/test/lint/shellcheck/shellcheck.bats +++ b/test/lint/shellcheck/shellcheck.bats @@ -7,9 +7,7 @@ setup() { FIXTURES="$BATS_TEST_DIRNAME/fixtures" } -# ============================================================================ # Detection -# ============================================================================ @test "lint: passes on a clean codebase" { run codebase lint:shellcheck "$FIXTURES/clean" @@ -37,9 +35,7 @@ setup() { [[ "$output" == *"SC"* ]] } -# ============================================================================ # Ignore directive -# ============================================================================ @test "lint: skips when codebase:ignore shellcheck is set in mise.toml" { run codebase lint:shellcheck "$FIXTURES/ignored" @@ -76,9 +72,7 @@ setup() { [[ "$output" == *"SC2154"* ]] } -# ============================================================================ # Scope -# ============================================================================ @test "lint: works on a codebase with no mise.toml" { run codebase lint:shellcheck "$FIXTURES/no-toml" @@ -100,9 +94,7 @@ setup() { [[ "$output" == *"bad.sh"* ]] } -# ============================================================================ # Multi-target -# ============================================================================ @test "lint: checks multiple targets and reports each" { run codebase lint:shellcheck "$FIXTURES/clean" "$FIXTURES/dirty" @@ -116,9 +108,7 @@ setup() { [ "$status" -eq 2 ] } -# ============================================================================ # Error paths -# ============================================================================ @test "lint: fails when target does not exist" { run codebase lint:shellcheck "$FIXTURES/does-not-exist" diff --git a/test/migrations/task-pattern/task-pattern.bats b/test/migrations/task-pattern/task-pattern.bats index d86c33e..5f8a90d 100644 --- a/test/migrations/task-pattern/task-pattern.bats +++ b/test/migrations/task-pattern/task-pattern.bats @@ -23,9 +23,7 @@ assert_matches_before() { diff -u "$FIXTURES/before/$file" "$WORK_DIR/$file" } -# ============================================================================ # Individual variant tests -# ============================================================================ @test "migrate: simple mise run → _task" { codebase migrate:task-pattern "$WORK_DIR" @@ -57,9 +55,7 @@ assert_matches_before() { assert_matches_after ".mise/tasks/error-strings" } -# ============================================================================ # Full migration test -# ============================================================================ @test "migrate: all files match expected after state" { codebase migrate:task-pattern "$WORK_DIR" @@ -68,9 +64,7 @@ assert_matches_before() { [ "$status" -eq 0 ] } -# ============================================================================ # Reverse migration tests -# ============================================================================ @test "reverse: _task → mise run" { # Start from the after state @@ -101,9 +95,7 @@ assert_matches_before() { ! grep -q '_task' "$WORK_DIR/.mise/tasks/in-subshell" } -# ============================================================================ # Round-trip tests -# ============================================================================ @test "round-trip: forward then reverse restores lossless fixtures" { # Only test fixtures where forward is lossless (no -q flag) @@ -114,9 +106,7 @@ assert_matches_before() { assert_matches_before ".mise/tasks/error-strings" } -# ============================================================================ # Error handling -# ============================================================================ @test "migrate: fails when target does not exist" { run codebase migrate:task-pattern /nonexistent diff --git a/test/pre-commit/pre-commit.bats b/test/pre-commit/pre-commit.bats index 96bd4f2..c0fbddc 100644 --- a/test/pre-commit/pre-commit.bats +++ b/test/pre-commit/pre-commit.bats @@ -24,9 +24,7 @@ EOF export CODEBASE_CALLER_PWD="$REPO" } -# ============================================================================ # Install — fresh repo -# ============================================================================ @test "install: creates dispatcher" { codebase pre-commit @@ -97,9 +95,7 @@ EOF [ -x "$REPO/.git/hooks/pre-commit" ] } -# ============================================================================ # Install — existing dispatcher -# ============================================================================ @test "install: preserves existing dispatcher and other hooks" { mkdir -p "$REPO/.git/hooks/pre-commit.d" @@ -121,9 +117,7 @@ EOF [ -f "$REPO/.git/hooks/pre-commit.d/codebase" ] } -# ============================================================================ # Install — existing plain hook (not a dispatcher) -# ============================================================================ @test "install: errors when existing plain hook is not a dispatcher" { cat > "$REPO/.git/hooks/pre-commit" <<'EOF' @@ -137,9 +131,7 @@ EOF [[ "$output" == *"not a dispatcher"* ]] } -# ============================================================================ # Idempotent -# ============================================================================ @test "install: running twice is safe" { codebase pre-commit @@ -149,9 +141,7 @@ EOF [ -f "$REPO/.git/hooks/pre-commit.d/codebase" ] } -# ============================================================================ # --check -# ============================================================================ @test "check: exits 0 when hook is current" { codebase pre-commit @@ -189,9 +179,7 @@ EOF [ "$status" -ne 0 ] } -# ============================================================================ # --revert -# ============================================================================ @test "revert: removes codebase hook" { codebase pre-commit @@ -225,9 +213,7 @@ EOF [[ "$output" == *"No codebase hook"* ]] } -# ============================================================================ # Scope -# ============================================================================ @test "scope: default scopes are delegated to aggregate lint" { cat > "$REPO/mise.toml" <<'EOF' @@ -258,9 +244,7 @@ EOF ! grep -q 'src/scripts' "$REPO/.git/hooks/pre-commit.d/codebase" } -# ============================================================================ # Error handling -# ============================================================================ @test "error: fails outside git repo" { export CODEBASE_CALLER_PWD="$BATS_TEST_TMPDIR" diff --git a/test/scan/scan.bats b/test/scan/scan.bats index 9552433..e52a500 100644 --- a/test/scan/scan.bats +++ b/test/scan/scan.bats @@ -8,9 +8,7 @@ setup() { FIXTURES_B="$BATS_TEST_DIRNAME/fixtures-b" } -# ============================================================================ # Single target (basic matching) -# ============================================================================ @test "scan: finds mise run calls in extension-less task files" { run codebase scan -p 'mise run $$$ARGS' "$FIXTURES_A" @@ -39,9 +37,7 @@ setup() { [[ -z "$output" ]] } -# ============================================================================ # Different patterns -# ============================================================================ @test "scan: finds _task calls with custom pattern" { run codebase scan -p '_task $$$ARGS' "$FIXTURES_A" @@ -56,9 +52,7 @@ setup() { [ "$count" -eq 4 ] } -# ============================================================================ # Multiple targets -# ============================================================================ @test "multi: finds matches across multiple codebases" { run codebase scan -p 'mise run $$$ARGS' "$FIXTURES_A" "$FIXTURES_B" @@ -84,9 +78,7 @@ setup() { [[ "$output" != *"fixtures-b:"* ]] } -# ============================================================================ # Exclude filter -# ============================================================================ @test "exclude: filters out files matching glob" { run codebase scan -p 'mise run $$$ARGS' -e '.mise/tasks/ci/*' "$FIXTURES_A" @@ -110,9 +102,7 @@ setup() { [[ "$output" != *"ci/deploy"* ]] } -# ============================================================================ # Error handling -# ============================================================================ @test "error: fails when no pattern provided" { run codebase scan "$FIXTURES_A"