diff --git a/.mise/tasks/lint/_default b/.mise/tasks/lint/_default index 297abc7..fbb0e92 100755 --- a/.mise/tasks/lint/_default +++ b/.mise/tasks/lint/_default @@ -1,6 +1,7 @@ #!/usr/bin/env bash #MISE description="Run configured codebase convention lints" #USAGE arg "[target]" default="." help="Path to the target repository" +#USAGE example "codebase lint /path/to/repo" header="Lint a repo" set -euo pipefail @@ -19,6 +20,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 +37,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/bash-empty-argv-forwarding b/.mise/tasks/lint/bash-empty-argv-forwarding new file mode 100755 index 0000000..87e3fb1 --- /dev/null +++ b/.mise/tasks/lint/bash-empty-argv-forwarding @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +#MISE description="Flag direct empty-argv forwarding under nounset (macOS Bash 3.2 compat)" +#USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:bash-empty-argv-forwarding ." +#USAGE example "codebase lint:bash-empty-argv-forwarding /path/to/repo" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" + +# Rationale: 'cmd "$@"' and 'cmd "${@}"' under nounset (set -u) fail on +# macOS Bash 3.2 when the argument list is empty. Bash 5+ and Homebrew +# Bash handle it gracefully, creating a silent cross-platform gotcha. +# ShellCheck does not catch this. +# +# Safe form on all Bash versions: +# cmd ${@+"$@"} +# +# Contexts that are safe and NOT flagged: +# - 'for arg in "$@"' — Bash handles empty $@ in for loops gracefully +# - 'local arr=("$@")' — array assignment handles empty $@ +# - Files without nounset (set -u / set -eu / set -euo pipefail) — no risk +# - Already-safe forms using alternate-value: ${@+"$@"} +# - Lines with inline 'codebase:ignore bash-empty-argv-forwarding' + +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_nounset +# Returns 0 if the file enables nounset via any form: +# set -u, set -eu, set -euo pipefail, set -o nounset, etc. +has_nounset() { + local file="$1" + rg -q '^[^#]*set\s+(-[a-z]*u[a-z]*|-o\s+nounset\b)' "$file" 2>/dev/null +} + +# is_safe_context +# Returns 0 if the line uses "$@" in a context that is safe under nounset. +# Safe contexts: for arg in "$@", local arr=("$@"), readonly arr=("$@") +is_safe_context() { + local line="$1" + # for arg in "$@" + [[ "$line" =~ ^[[:space:]]*for\ [a-zA-Z_][a-zA-Z0-9_]*\ in\ \"\$ ]] && return 0 + # local arr=("$@"), readonly arr=("$@"), declare arr=("$@"), typeset arr=("$@") + [[ "$line" =~ ^[[:space:]]*(local|readonly|declare|typeset)[[:space:]]+[a-zA-Z_][a-zA-Z0-9_]*=\(\"\$ ]] && return 0 + return 1 +} + +# has_safe_form +# Returns 0 if the line already uses the alternate-value safe form. +has_safe_form() { + local line="$1" + # shellcheck disable=SC2016 # intentional: match literal '${@+"$@"}' + [[ "$line" == *'${@+"$@"}'* ]] && return 0 + # shellcheck disable=SC2016 # intentional: match literal '${@+"${@}"}' + [[ "$line" == *'${@+"${@}"}'* ]] && return 0 + return 1 +} + +# Pattern: "$@" or "${@}" in double quotes +ARGV_FORWARD_RE='"\$\{?@\}?"' + +# has_line_ignore +# Returns 0 if the line has a rule-specific inline ignore. +has_line_ignore() { + local line="$1" + [[ "$line" =~ codebase:ignore[[:space:]]+bash-empty-argv-forwarding ]] +} + +# scan_file +# Emit flagged line numbers and trimmed content for lines matching the +# empty-argv forwarding pattern in a nounset file. +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 + + # Rule-specific inline ignore with reason is accepted. + has_line_ignore "$line" && continue + + # Safe contexts (for loops, array assignments) are accepted. + is_safe_context "$line" && continue + + # Already-safe forms are accepted. + has_safe_form "$line" && continue + + # Flag matching lines. + if [[ "$line" =~ $ARGV_FORWARD_RE ]]; then + local trimmed="${line#"${line%%[![:space:]]*}"}" + echo "$lineno: $trimmed" + fi + 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 bash-empty-argv-forwarding' "$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 that has nounset enabled; collect hits + hit_count=0 + target_output="" + for file in "${files[@]}"; do + # Skip files without nounset — no risk of unbound variable + has_nounset "$file" || continue + + rel="${file#"$target"/}" + 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 empty-argv forwarding under nounset" + printf '%s' "$target_output" + cat <<'HINT' + hint: Use ${@+"$@"} instead of "$@" for Bash-3-safe empty-argv forwarding +HINT + failures=$((failures + 1)) + else + echo "OK $name (${#files[@]} file(s) clean)" + fi +done + +exit "$failures" diff --git a/.mise/tasks/lint/bash-empty-array-expansions b/.mise/tasks/lint/bash-empty-array-expansions new file mode 100755 index 0000000..df1c19f --- /dev/null +++ b/.mise/tasks/lint/bash-empty-array-expansions @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +#MISE description="Flag empty array expansions under nounset (macOS Bash 3.2 compat)" +#USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:bash-empty-array-expansions ." +#USAGE example "codebase lint:bash-empty-array-expansions /path/to/repo" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" + +# Rationale: "${array[@]}" and "${array[*]}" under nounset (set -u) fail on +# macOS Bash 3.2 when the array is empty. Bash 5+ and Homebrew Bash handle +# it gracefully, creating a silent cross-platform gotcha. ShellCheck does +# not catch this. +# +# Safe form on all Bash versions: +# cmd ${arr[@]+"${arr[@]}"} +# cmd ${arr[*]+"${arr[*]}"} +# +# Contexts that are safe and NOT flagged: +# - 'for item in "${items[@]}"' — Bash handles empty $@ in for loops +# - 'local arr=("${items[@]}")' — array assignment handles empty +# - Files without nounset (set -u / set -eu / set -euo pipefail) — no risk +# - Already-safe forms using alternate-value: ${arr[@]+\"${arr[@]}\"} +# - Lines with inline 'codebase:ignore bash-empty-array-expansions' + +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_nounset +# Returns 0 if the file enables nounset via any form. +has_nounset() { + local file="$1" + rg -q '^[^#]*set\s+(-[a-z]*u[a-z]*|-o\s+nounset\b)' "$file" 2>/dev/null +} + +# is_safe_context +# Returns 0 if "$line" uses "${arr[@]}" in a safe context. +is_safe_context() { + local line="$1" + # for item in "${items[@]}" — loop header + [[ "$line" =~ ^[[:space:]]*for\ [a-zA-Z_][a-zA-Z0-9_]*\ in\ \"\$\{ ]] && return 0 + # local arr=("${items[@]}") — array assignment + [[ "$line" =~ ^[[:space:]]*(local|readonly|declare|typeset)[[:space:]]+[a-zA-Z_][a-zA-Z0-9_]*=\(\"\$\{ ]] && return 0 + return 1 +} + +# has_safe_form +# Returns 0 if "$line" already uses the alternate-value safe form. +has_safe_form() { + local line="$1" + # Match ${arr[@]+"${arr[@]}"} or ${arr[*]+"${arr[*]}"} + echo "$line" | grep -qE '\$\{[a-zA-Z_][a-zA-Z0-9_]*\[[@*]\]\+\"\$\{[a-zA-Z_][a-zA-Z0-9_]*\[[@*]\]\}\"\}' && return 0 + return 1 +} + +# Pattern: "${identifier[@]}" or "${identifier[*]}" in double quotes +ARRAY_EXPAND_RE='"\$\{[a-zA-Z_][a-zA-Z0-9_]*\[[@*]\]\}"' + +# has_line_ignore +# Returns 0 if the line has a rule-specific inline ignore. +has_line_ignore() { + local line="$1" + [[ "$line" =~ codebase:ignore[[:space:]]+bash-empty-array-expansions ]] +} + +# scan_file +# Emit flagged line numbers and trimmed content for lines matching the +# empty-array expansion pattern in a nounset file. +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 + + # Rule-specific inline ignore with reason is accepted. + has_line_ignore "$line" && continue + + # Safe contexts (for loops, array assignments) are accepted. + is_safe_context "$line" && continue + + # Already-safe forms are accepted. + has_safe_form "$line" && continue + + # Flag matching lines. + if [[ "$line" =~ $ARRAY_EXPAND_RE ]]; then + local trimmed + trimmed="${line#"${line%%[![:space:]]*}"}" + echo "$lineno: $trimmed" + fi + 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 bash-empty-array-expansions' "$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 that has nounset enabled; collect hits + hit_count=0 + target_output="" + for file in "${files[@]}"; do + # Skip files without nounset — no risk of unbound variable + has_nounset "$file" || continue + + rel="${file#"$target"/}" + 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 empty-array expansions under nounset" + printf '%s' "$target_output" + cat <<'HINT' + hint: Use ${arr[@]+"${arr[@]}"} instead of "${arr[@]}" for Bash-3-safe empty-array forwarding +HINT + failures=$((failures + 1)) + else + echo "OK $name (${#files[@]} file(s) clean)" + fi +done + +exit "$failures" diff --git a/.mise/tasks/lint/bats-test-helper b/.mise/tasks/lint/bats-test-helper index 39d9141..cf74fa7 100755 --- a/.mise/tasks/lint/bats-test-helper +++ b/.mise/tasks/lint/bats-test-helper @@ -1,6 +1,7 @@ #!/usr/bin/env bash #MISE description="Flag direct invocation of .mise/tasks/* scripts from BATS tests (call the tool, not the script)" #USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:bats-test-helper ." header="Check for direct script invocation in BATS tests" set -euo pipefail diff --git a/.mise/tasks/lint/bats-test-task b/.mise/tasks/lint/bats-test-task index cdd03e6..5c70070 100755 --- a/.mise/tasks/lint/bats-test-task +++ b/.mise/tasks/lint/bats-test-task @@ -1,6 +1,7 @@ #!/usr/bin/env bash #MISE description="Enforce the canonical BATS test-task shape in .mise/tasks/test" #USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:bats-test-task ." header="Check BATS test task shape" set -euo pipefail diff --git a/.mise/tasks/lint/caller-pwd-contract b/.mise/tasks/lint/caller-pwd-contract index 919a896..cd1844a 100755 --- a/.mise/tasks/lint/caller-pwd-contract +++ b/.mise/tasks/lint/caller-pwd-contract @@ -1,6 +1,7 @@ #!/usr/bin/env bash #MISE description="Check shiv caller-cwd environment variable contract" #USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:caller-pwd-contract ." header="Check caller-pwd contract" set -euo pipefail diff --git a/.mise/tasks/lint/ci-lint-enforcement b/.mise/tasks/lint/ci-lint-enforcement new file mode 100755 index 0000000..d93fe59 --- /dev/null +++ b/.mise/tasks/lint/ci-lint-enforcement @@ -0,0 +1,386 @@ +#!/usr/bin/env bash +#MISE description="Require configured codebase lints to run in CI" +#USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE flag "--fix" help="Add aggregate codebase lint enforcement step to existing workflow" +#USAGE example "codebase lint:ci-lint-enforcement ." header="Check repos enforce their lint portfolio in CI" +#USAGE example "codebase lint:ci-lint-enforcement --fix ." header="Add aggregate lint enforcement to primary workflow" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" +# shellcheck source=../../../lib/codebase-config.sh +source "$MISE_CONFIG_ROOT/lib/codebase-config.sh" + +FIX="${usage_fix:-false}" + +IFS=' ' read -ra TARGETS <<< "${usage_targets}" + +if [[ ${#TARGETS[@]} -eq 0 ]]; then + echo "ERROR: at least one target is required" >&2 + exit 1 +fi + +for i in "${!TARGETS[@]}"; do + TARGETS[i]=$(resolve_target "${TARGETS[i]}") +done + +# === detection patterns === + +# Direct aggregate enforcement: codebase lint invoked directly in a workflow step +DIRECT_PATTERNS=( + 'codebase lint "$PWD"' + 'codebase lint .' + 'codebase lint \$PWD' +) + +# Mise exec indirection: codebase lint invoked via mise exec +EXEC_PATTERNS=( + 'mise exec.*codebase lint' +) + +# Local task delegation: workflows that run a local test/CI task that may +# itself invoke codebase lint. We trace into the task file to confirm. +TEST_TASK_PATTERNS=( + 'mise run test' + 'mise run ci' + 'mise run check' +) + +# Hard-coded per-rule loop pattern (e.g. codebase lint:mise-settings "$PWD") +# These bypass the configured aggregate and are drift-prone. +PER_RULE_PATTERN='codebase lint:[a-zA-Z0-9_-]+' + +# === helpers === + +collect_workflows() { + local target="$1" + local workflows_dir="$target/.github/workflows" + [[ -d "$workflows_dir" ]] || return 0 + find "$workflows_dir" -type f \( -name "*.yml" -o -name "*.yaml" \) 2>/dev/null | LC_ALL=C sort +} + +# Strip leading whitespace from a string. +strip_leading_space() { + printf '%s' "$1" | sed 's/^[[:space:]]*//' +} + +# Check if a trimmed YAML line contains an aggregate enforcement pattern. +line_has_aggregate_lint() { + local trimmed="$1" + local pattern + + for pattern in "${DIRECT_PATTERNS[@]}"; do + if [[ "$trimmed" == *"$pattern"* ]]; then + return 0 + fi + done + + for pattern in "${EXEC_PATTERNS[@]}"; do + if [[ "$trimmed" =~ $pattern ]]; then + return 0 + fi + done + + return 1 +} + +# Discover aggregate enforcement by scanning workflow YAML lines. +workflow_has_aggregate_lint() { + local workflow="$1" + local trimmed + + while IFS= read -r line || [[ -n "$line" ]]; do + trimmed=$(strip_leading_space "$line") + [[ -z "$trimmed" || "$trimmed" == '#'* ]] && continue + if line_has_aggregate_lint "$trimmed"; then + return 0 + fi + done < "$workflow" + + return 1 +} + +# Check if a workflow runs a local task that delegates to codebase lint. +workflow_runs_lint_delegating_task() { + local workflow="$1" + local target="$2" + local trimmed task_name + + while IFS= read -r line || [[ -n "$line" ]]; do + trimmed=$(strip_leading_space "$line") + [[ -z "$trimmed" || "$trimmed" == '#'* ]] && continue + + for pattern in "${TEST_TASK_PATTERNS[@]}"; do + if [[ "$trimmed" == *"$pattern"* ]]; then + # Extract the task name from 'mise run ' + task_name=$(printf '%s' "$trimmed" | sed -n 's/.*mise run \([a-zA-Z0-9_-]\+\).*/\1/p') + [[ -z "$task_name" ]] && continue + + if task_invokes_codebase_lint "$target" "$task_name"; then + return 0 + fi + fi + done + done < "$workflow" + + return 1 +} + + +# Check if a local mise task file invokes codebase lint. +task_invokes_codebase_lint() { + local target="$1" + local task_name="$2" + local candidate + + for candidate in "$target/.mise/tasks/$task_name" "$target/.mise/tasks/$task_name/_default"; do + if [[ -f "$candidate" ]] && grep -qE 'codebase lint|CODEBASE_BIN.*lint' "$candidate" 2>/dev/null; then + return 0 + fi + done + + return 1 +} + +# Check if any workflow uses hard-coded per-rule loops. +workflow_has_per_rule_loop() { + local workflow="$1" + [[ $(grep -cE "$PER_RULE_PATTERN" "$workflow" 2>/dev/null || true) -gt 0 ]] +} + +# Count the number of per-rule invocations across all workflows. +count_per_rule_loops() { + local workflows=("$@") + local total=0 wf + for wf in "${workflows[@]}"; do + total=$((total + $(grep -cE "$PER_RULE_PATTERN" "$wf" 2>/dev/null || true))) + done + printf '%s' "$total" +} + +# === --fix helpers === + +ensure_codebase_tooling() { + local target="$1" + local toml="$target/mise.toml" + local added=() + local tmp + + [[ -f "$toml" ]] || return 0 + + # Ensure [plugins] section has shiv plugin + if ! grep -q '^shiv = "https://github.com/KnickKnackLabs/vfox-shiv"' "$toml" 2>/dev/null; then + if grep -q '^\[plugins\]' "$toml" 2>/dev/null; then + tmp=$(mktemp) + awk '/^\[plugins\]/ { print; if (!printed) { print "shiv = \"https://github.com/KnickKnackLabs/vfox-shiv\""; printed=1; next } } 1' "$toml" > "$tmp" + mv "$tmp" "$toml" + else + printf '\n[plugins]\nshiv = "https://github.com/KnickKnackLabs/vfox-shiv"\n' >> "$toml" + fi + added+=("shiv plugin") + fi + + # Ensure [tools] section has shiv:codebase + if ! grep -q '"shiv:codebase"' "$toml" 2>/dev/null; then + if grep -q '^\[tools\]' "$toml" 2>/dev/null; then + tmp=$(mktemp) + awk '/^\[tools\]/ { print; if (!printed) { print "\"shiv:codebase\" = \"latest\""; printed=1; next } } 1' "$toml" > "$tmp" + mv "$tmp" "$toml" + else + printf '\n[tools]\n"shiv:codebase" = "latest"\n' >> "$toml" + fi + added+=("shiv:codebase tool") + fi + + if [[ ${#added[@]} -gt 0 ]]; then + echo "FIX $(basename "$target"): provisioned ${added[*]}" + fi +} + +# Add a standard aggregate lint step to the primary workflow. +add_lint_step_to_workflow() { + local target="$1" + local workflow="$2" + local tmp existing_has_lint + + # Skip if the workflow already has aggregate enforcement in any job. + existing_has_lint=false + while IFS= read -r line; do + trimmed=$(strip_leading_space "$line") + if line_has_aggregate_lint "$trimmed"; then + existing_has_lint=true + break + fi + done < "$workflow" + + if $existing_has_lint; then + return 0 + fi + + # Find the last steps: block and insert before its last step. + tmp=$(mktemp) + awk ' + BEGIN { in_steps = 0; last_step = ""; inserted = 0 } + + # Detect start of a steps: block + /^[[:space:]]*steps:[[:space:]]*$/ { + in_steps = 1 + last_step = "" + print + next + } + + # Track the last step entry inside a steps block + in_steps && /^[[:space:]]*- name:/ { + if (last_step != "") print last_step + last_step = $0 + next + } + + in_steps && /^[[:space:]]*- run:/ { + if (last_step != "") print last_step + last_step = $0 + next + } + + # Outdent back to a non-step key ends the steps block + in_steps && /^[a-zA-Z]/ && !/^[[:space:]]*- / && last_step != "" { + # Insert our step before the last tracked step + print " - name: Run codebase lints" + print " run: codebase lint \"$PWD\"" + inserted = 1 + print last_step + last_step = "" + in_steps = 0 + print + next + } + + # End of file: flush last step and insert + { + print + } + + END { + if (!inserted && last_step != "") { + print " - name: Run codebase lints" + print " run: codebase lint \"$PWD\"" + print last_step + } + } + ' "$workflow" > "$tmp" + mv "$tmp" "$workflow" + + echo "FIXED $(basename "$target"): added aggregate codebase lint step to $(basename "$workflow")" +} + +# === main === + +failures=0 +FIXED_REPOS=() + +for target in "${TARGETS[@]}"; do + if [[ ! -e "$target" ]]; then + echo "ERROR: target does not exist: $target" >&2 + exit 1 + fi + + name=$(basename "$target") + toml="$target/mise.toml" + + # File-level ignore + if [[ -f "$toml" ]] && grep -m1 -q 'codebase:ignore ci-lint-enforcement' "$toml" 2>/dev/null; then + echo "SKIP $name (codebase:ignore)" + continue + fi + + # Read configured lint rules + RULES=() + while IFS= read -r rule; do + [[ -n "$rule" ]] && RULES+=("$rule") + done < <(codebase_configured_lint_rules "$target") + + # No lint config → nothing to enforce + if [[ ${#RULES[@]} -eq 0 ]]; then + echo "SKIP $name (no [_.codebase].lint configured)" + continue + fi + + # Collect workflows + workflows=() + while IFS= read -r wf; do + [[ -n "$wf" ]] && workflows+=("$wf") + done < <(collect_workflows "$target") + + # No workflows at all → enforcement not possible + if [[ ${#workflows[@]} -eq 0 ]]; then + echo "FAIL $name: lint configured but no CI workflows found" + echo " hint: run 'codebase lint:github-actions --fix $target' to create a default workflow" + failures=$((failures + 1)) + continue + fi + + # Check enforcement across all workflows + found_enforcement=false + found_per_rule_loop=false + + for wf in "${workflows[@]}"; do + if workflow_has_aggregate_lint "$wf"; then + found_enforcement=true + fi + + # Check indirect delegation (mise run test → tasks/test → codebase lint) + if workflow_runs_lint_delegating_task "$wf" "$target"; then + found_enforcement=true + fi + + # Check for hard-coded per-rule loops (warn, not fail) + if workflow_has_per_rule_loop "$wf"; then + found_per_rule_loop=true + fi + done + + if $found_enforcement; then + if $found_per_rule_loop; then + loop_count=$(count_per_rule_loops "${workflows[@]}") + echo "WARN $name: aggregate lint enforced but $loop_count per-rule invocation(s) detected (drift-prone)" + else + echo "OK $name (${#RULES[@]} rule(s) enforced in ${#workflows[@]} workflow(s))" + fi + continue + fi + + # === --fix mode === + if [[ "$FIX" == "true" ]]; then + # Provision codebase tooling if missing + ensure_codebase_tooling "$target" + + # Add enforcement step to the primary workflow + primary="${workflows[0]}" + add_lint_step_to_workflow "$target" "$primary" + + if $found_per_rule_loop; then + loop_count=$(count_per_rule_loops "${workflows[@]}") + echo "WARN $name: aggregate step added but $loop_count per-rule loop(s) remain (review manually)" + fi + + FIXED_REPOS+=("$name") + continue + fi + + # === fail === + echo "FAIL $name: lint configured but no aggregate enforcement in CI" + if $found_per_rule_loop; then + loop_count=$(count_per_rule_loops "${workflows[@]}") + echo " detail: $loop_count hard-coded per-rule invocation(s) found — these bypass the configured aggregate" + fi + echo " hint: add 'run: codebase lint \"\$PWD\"' to your CI workflow, or run with --fix" + failures=$((failures + 1)) +done + +if [[ ${#FIXED_REPOS[@]} -gt 0 ]]; then + echo "FIXED: ${FIXED_REPOS[*]}" +fi + +exit "$failures" \ No newline at end of file diff --git a/.mise/tasks/lint/exec-stderr-silence b/.mise/tasks/lint/exec-stderr-silence new file mode 100755 index 0000000..eec89d9 --- /dev/null +++ b/.mise/tasks/lint/exec-stderr-silence @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +#MISE description="Flag 'exec' with stderr redirection — suppression persists in the current shell" +#USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:exec-stderr-silence ." header="Flag exec stderr suppression" +#USAGE example "exec 3<>\"$path\" 2>/dev/null # codebase:ignore exec-stderr-silence — intentional fd setup" header="Intentional exec-replacements need a rule-specific reason" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" + +# Rationale: 'exec' with stderr redirection (2>/dev/null, 2>&-, 2>!) +# applies the redirect to the current shell, not just the exec'd command. +# Inside an 'if' condition, '||', or function body, this suppresses all +# subsequent stderr output — including BATS diagnostic output, error +# messages from the containing function, etc. +# +# Safe alternative: group the exec probe so the redirection is scoped: +# +# if ! { exec 3<>"$tty_path"; } 2>/dev/null; then +# echo "Error: confirmation required" >&2 +# return 2 +# fi +# +# For intentional process replacement / fd setup, add a rule-specific +# inline ignore with a reason. + +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 + +# Pattern: line contains 'exec' followed (after skipping comments) by '2>' +# with a stderr suppression/discard target. +# We test for dangerous stderr targets directly: +# /dev/null — discard +# &- — close +# &1 — merge to stdout (also suppresses if stdout is null) +# ! — noclobber override (when used with /dev/null or null target) +EXEC_STDERR_PATTERN='\bexec\b[^#]*2>([[:space:]]*/dev/(null|zero)|[[:space:]]*&[-1!?]|[[:space:]]*!)' + +has_rule_specific_ignore() { + local line="$1" + [[ "$line" =~ codebase:ignore[[:space:]]+exec-stderr-silence[[:space:]]+ ]] +} + +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 + + # Check for exec with stderr suppression pattern. + if [[ "$line" =~ $EXEC_STDERR_PATTERN ]]; then + # Rule-specific inline ignore with a reason is accepted. + if has_rule_specific_ignore "$line"; 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" ]] && grep -m1 -q 'codebase:ignore exec-stderr-silence' "$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 + target_output="" + for file in "${files[@]}"; do + rel="${file#"$target"/}" + 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 exec stderr suppression(s)" + printf '%s' "$target_output" + echo " hint: exec with stderr redirection affects the current shell. Use a grouping construct ({ exec ...; } redirect stderr) or save/restore stderr explicitly." + 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/github-actions b/.mise/tasks/lint/github-actions index ae3e426..b2410b1 100755 --- a/.mise/tasks/lint/github-actions +++ b/.mise/tasks/lint/github-actions @@ -2,6 +2,8 @@ #MISE description="Lint GitHub Actions workflows and create a KKL default workflow when missing" #USAGE arg "…" help="Paths to codebases to check (one or more)" #USAGE flag "--fix" help="Create a default GitHub Actions workflow when missing" +#USAGE example "codebase lint:github-actions ." header="Lint GitHub Actions workflows" +#USAGE example "codebase lint:github-actions . --fix" header="Create default workflow" set -euo pipefail @@ -222,7 +224,7 @@ for target in "${TARGETS[@]}"; do fi fi - if ! output=$(actionlint "${workflows[@]}" 2>&1); then + if ! output=$(actionlint "${workflows[@]}" 2>&1); then # codebase:ignore bash-empty-array-expansions -- guard ensures workflows non-empty count=$(printf '%s\n' "$output" | grep -cE '^[^:]+:[0-9]+:[0-9]+:' || true) # codebase:ignore or-true — grep -c exits 1 on zero matches [[ "$count" -eq 0 ]] && count=1 echo "FAIL $name: $count GitHub Actions violation(s)" 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/gum-table b/.mise/tasks/lint/gum-table index b1fa03c..fabf8bf 100755 --- a/.mise/tasks/lint/gum-table +++ b/.mise/tasks/lint/gum-table @@ -1,6 +1,7 @@ #!/usr/bin/env bash #MISE description="Detect manual table formatting that should use gum table" #USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:gum-table ." header="Check for manual table formatting" set -euo pipefail diff --git a/.mise/tasks/lint/mcr-scope b/.mise/tasks/lint/mcr-scope index e1235e4..ddc333a 100755 --- a/.mise/tasks/lint/mcr-scope +++ b/.mise/tasks/lint/mcr-scope @@ -1,6 +1,7 @@ #!/usr/bin/env bash #MISE description="Forbid MISE_CONFIG_ROOT in test/ and lib/ (use BATS_TEST_DIRNAME or BASH_SOURCE instead)" #USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:mcr-scope ." header="Check for MISE_CONFIG_ROOT misuse in test/lib" set -euo pipefail diff --git a/.mise/tasks/lint/mise-settings b/.mise/tasks/lint/mise-settings index aa41c08..2505e23 100755 --- a/.mise/tasks/lint/mise-settings +++ b/.mise/tasks/lint/mise-settings @@ -2,6 +2,8 @@ #MISE description="Check that mise.toml has required settings" #USAGE arg "…" help="Paths to codebases to check (one or more)" #USAGE flag "--fix" help="Add missing settings to mise.toml" +#USAGE example "codebase lint:mise-settings ." header="Check mise.toml settings" +#USAGE example "codebase lint:mise-settings . --fix" header="Fix missing settings" set -euo pipefail @@ -77,7 +79,7 @@ for target in "${TARGETS[@]}"; do while IFS= read -r line || [[ -n "$line" ]]; do printf '%s\n' "$line" >> "$tmp" if ! $inserted && [[ "$line" =~ ^\[settings\] ]]; then - printf '%s\n' "${missing[@]}" >> "$tmp" + printf '%s\n' "${missing[@]}" >> "$tmp" # codebase:ignore bash-empty-array-expansions -- guarded: missing is non-empty here inserted=true fi done < "$toml" diff --git a/.mise/tasks/lint/mise-usage-examples b/.mise/tasks/lint/mise-usage-examples new file mode 100755 index 0000000..110208b --- /dev/null +++ b/.mise/tasks/lint/mise-usage-examples @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +#MISE description="Enforce #USAGE example directives for public argument-bearing mise tasks" +#USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:mise-usage-examples ." header="Check #USAGE example coverage" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" + +# Rationale: `mise run --help` shows #USAGE example lines as usage +# examples. Tasks that declare #USAGE arg or #USAGE flag directives give +# users a parameter reference but no concrete invocation examples — making +# the --help output incomplete. +# +# The bats-test-task rule already enforces this for .mise/tasks/test; +# this rule generalises the check to all public tasks. +# +# Legitimate exceptions (e.g. internal-only tasks that happen to have +# #USAGE declarations for documentation) opt out via: +# mise.toml: codebase:ignore mise-usage-examples +# inline comment: # codebase:ignore mise-usage-examples -- reason +# +# Hidden tasks (#MISE hide=true) are always skipped — they aren't shown +# in --help anyway, so missing examples are harmless. + +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") + + # File-level ignore via mise.toml + toml="$target/mise.toml" + if [[ -f "$toml" ]] && rg -q 'codebase:ignore mise-usage-examples' "$toml"; then + echo "SKIP $name (codebase:ignore)" + continue + fi + + tasks_dir="$target/.mise/tasks" + if [[ ! -d "$tasks_dir" ]]; then + echo "OK $name (no .mise/tasks — nothing to check)" + continue + fi + + target_failures=0 + + while IFS= read -r -d '' task; do + task_rel="${task#"$tasks_dir/"}" + task_rel="${task_rel#/}" + + # Skip hidden tasks — they aren't shown in --help + if rg -q '^#MISE hide=true' "$task"; then + continue + fi + + # Skip tasks with no user-facing args/flags (no USAGE surface) + if ! rg -q '^#USAGE (arg|flag) ' "$task"; then + continue + fi + + # Skip inline-ignored files + if rg -q 'codebase:ignore mise-usage-examples' "$task"; then + continue + fi + + # Check for at least one #USAGE example directive + if ! rg -q '^#USAGE example ' "$task"; then + echo "FAIL $name $task_rel: missing #USAGE example directives (has args/flags but no examples)" + target_failures=$((target_failures + 1)) + fi + done < <(find "$tasks_dir" -maxdepth 1 -type f -print0) + + if [[ "$target_failures" -eq 0 ]]; then + echo "OK $name (.mise/tasks tasks all have #USAGE examples)" + fi + failures=$((failures + target_failures)) +done + +exit "$failures" \ No newline at end of file diff --git a/.mise/tasks/lint/shellcheck b/.mise/tasks/lint/shellcheck index 222f0e4..c790073 100755 --- a/.mise/tasks/lint/shellcheck +++ b/.mise/tasks/lint/shellcheck @@ -1,6 +1,7 @@ #!/usr/bin/env bash #MISE description="Run shellcheck against shell files in a codebase" #USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:shellcheck ." header="Run shellcheck on repo" set -euo pipefail @@ -69,7 +70,7 @@ for target in "${TARGETS[@]}"; do # behavior for .mise/tasks files while letting POSIX-sh / dash / # ksh scripts in bin/, scripts/, etc. be checked with the correct # dialect. - if ! violations=$(shellcheck --exclude="$DEFAULT_EXCLUDES" "${files[@]}" 2>&1); then + if ! violations=$(shellcheck --exclude="$DEFAULT_EXCLUDES" "${files[@]}" 2>&1); then # codebase:ignore bash-empty-array-expansions -- guard ensures files non-empty count=$(printf '%s\n' "$violations" | grep -cE '^In .* line [0-9]+:' || true) # codebase:ignore or-true — grep -c exits 1 on zero matches echo "FAIL $name: $count violation(s)" printf '%s\n' "$violations" | sed 's/^/ /' diff --git a/.mise/tasks/lint/usage-flag-naming b/.mise/tasks/lint/usage-flag-naming new file mode 100644 index 0000000..d8701ea --- /dev/null +++ b/.mise/tasks/lint/usage-flag-naming @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +#MISE description="Flag #USAGE flag directives where flag name and placeholder produce different usage_* env var names" +#USAGE arg "…" help="Paths to codebases to check (one or more)" +#USAGE example "codebase lint:usage-flag-naming ." header="Flag mismatched USAGE flag/placeholder" +#USAGE example "codebase lint:usage-flag-naming /path/to/repo" header="Check a specific repo" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" +# shellcheck source=../../../lib/usage-parser.sh +source "$MISE_CONFIG_ROOT/lib/usage-parser.sh" + +IFS=' ' read -ra TARGETS <<< "${usage_targets}" + +if [[ ${#TARGETS[@]} -eq 0 ]]; then + echo "ERROR: at least one target is required" >&2 + exit 1 +fi + +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") + + # File-level ignore via mise.toml + toml="$target/mise.toml" + if [[ -f "$toml" ]] && grep -q 'codebase:ignore usage-flag-naming' "$toml" 2>/dev/null; then + echo "SKIP $name (codebase:ignore)" + continue + fi + + tasks_dir="$target/.mise/tasks" + if [[ ! -d "$tasks_dir" ]]; then + echo "OK $name (no .mise/tasks directory)" + continue + fi + + # Discover task files + task_files=() + while IFS= read -r f; do + [[ -n "$f" ]] && task_files+=("$f") + done < <(discover_task_files "$target") + + if [[ ${#task_files[@]} -eq 0 ]]; then + echo "OK $name (no task files found)" + continue + fi + + target_failures=0 + target_output="" + + for task_file in "${task_files[@]}"; do + rel="${task_file#"$target/"}" + + while IFS=$'\t' read -r lineno flag_name placeholder; do + [[ -z "$lineno" ]] && continue + + # Boolean flag (no placeholder) — skip + [[ -z "$placeholder" ]] && continue + + flag_env=$(usage_env_name "$flag_name") + placeholder_env=$(usage_env_name "$placeholder") + + if [[ "$flag_env" != "$placeholder_env" ]]; then + target_failures=$((target_failures + 1)) + target_output+=" $rel:$lineno: --${flag_name#--} ($flag_env) vs <$placeholder> ($placeholder_env)"$'\n' + fi + done < <(parse_usage_flags "$task_file") + done + + if [[ "$target_failures" -gt 0 ]]; then + echo "FAIL $name: $target_failures flag/placeholder mismatch(es)" + printf '%s' "$target_output" + echo " hint: use '--flag-name ' so mise's usage_* env var matches what the task reads" + echo " or annotate with '# codebase:ignore=usage-flag-naming -- reason'" + failures=$((failures + 1)) + else + echo "OK $name (${#task_files[@]} file(s) clean)" + fi +done + +exit "$failures" diff --git a/.mise/tasks/lint/variadic-args b/.mise/tasks/lint/variadic-args new file mode 100755 index 0000000..36eeec3 --- /dev/null +++ b/.mise/tasks/lint/variadic-args @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +#MISE description="Flag dangerous variadic usage_* consumption (eval or read -ra on var=#true values)" +#USAGE arg "…" help="Paths to codebases to check (one or more)" + +set -euo pipefail + +# shellcheck source=../../../lib/shell-files.sh +source "$MISE_CONFIG_ROOT/lib/shell-files.sh" + +# Rationale: mise's `#USAGE arg "..." var=#true` and `#USAGE flag "--x " var=#true` +# deliver variadic values as a single shell-escaped string in $usage_*. Two common +# consumption patterns are dangerous: +# +# 1. eval "ARRAY=(${usage_X:-})" — SHELL INJECTION VECTOR. +# Any value containing backticks, $(), or shell metacharacters executes. +# +# 2. read -ra ARRAY <<< "$usage_X" — LOSES QUOTING on multi-word values. +# mise wraps multi-word values in single quotes; read -ra splits on +# whitespace and ignores the quoting, producing broken argument lists. +# +# The correct pattern for bash (per mise-conventions.md): +# +# ARGS=() +# if [ -n "${usage_args:-}" ]; then +# while IFS= read -r arg; do +# ARGS+=("$arg") +# done < <(printf '%s' "$usage_args" | xargs printf '%s\n') +# fi +# +# For Python tasks, use shlex.split() instead. +# +# See: or#146, mise-conventions.md ("variadic args" section) + +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 + +# === Phase 1 helpers: parse #USAGE directives to extract variadic env var names === + +# collect_variadic_vars +# +# Emit one usage_ per line for every #USAGE arg or flag with var=#true. +# +# #USAGE arg "[args]..." var=#true → usage_args +# #USAGE flag "--exclude " var=#true → usage_exclude +# #USAGE flag "-e --exclude " var=#true → usage_exclude +# +# Non-variadic directives (no var=#true), boolean flags, and short-only +# flags are skipped. +collect_variadic_vars() { + local file="$1" + local line + + while IFS= read -r line; do + # Match #USAGE arg with var=#true → usage_args + if [[ "$line" =~ ^[[:space:]]*#USAGE[[:space:]]+arg[[:space:]] ]]; then + if [[ "$line" =~ var=#?true ]]; then + echo "usage_args" + fi + continue + fi + + # Match #USAGE flag with var=#true → extract long flag name + if [[ "$line" =~ ^[[:space:]]*#USAGE[[:space:]]+flag[[:space:]] ]]; then + if [[ "$line" =~ var=#?true ]]; then + # Extract the last long flag (--foo-bar or --foo) + if [[ "$line" =~ --([a-zA-Z0-9_-]+) ]]; then + local flag_name="${BASH_REMATCH[1]}" + # Mise converts hyphens to underscores for the env var name + local var_name="usage_${flag_name//-/_}" + echo "$var_name" + fi + fi + fi + done < "$file" +} + +# === Phase 2 helpers: scan file for dangerous consumption patterns === + +# Patterns for dangerous variadic consumption. +# These match the *variable reference* side — we cross-reference against +# known variadic vars after detection. + +# eval "ARRAY=(${usage_X...})" or eval "ARRAY=($usage_X...)" +EVAL_RE='eval\s+"?\w+=\((\$\{?usage_[A-Za-z_][A-Za-z0-9_]*)' + +# eval ARRAY=(${usage_X...}) (bare — rarely used but still dangerous) +EVAL_BARE_RE='eval\s+\w+=\((\$\{?usage_[A-Za-z_][A-Za-z0-9_]*)' + +# read -ra ARRAY <<< "$usage_X" (quoted herestring) +READRA_QUOTED_RE='read\s+-ra\s+\w+\s+<<<\s+"(\$usage_[A-Za-z_][A-Za-z0-9_]*)"' + +# read -ra ARRAY <<< $usage_X (unquoted — even worse, word-splits before read) +READRA_UNQUOTED_RE='read\s+-ra\s+\w+\s+<<<\s+(\$usage_[A-Za-z_][A-Za-z0-9_]*)([^"]|$)' + +# extract_usage_var +# +# Emit the usage_ variable referenced in a dangerous consumption line. +# Matches all four patterns above and deduplicates. +extract_usage_var() { + local line="$1" + + if [[ "$line" =~ $EVAL_RE ]]; then + echo "${BASH_REMATCH[1]}" | sed 's/[${}]//g' + return + fi + + if [[ "$line" =~ $EVAL_BARE_RE ]]; then + echo "${BASH_REMATCH[1]}" | sed 's/[${}]//g' + return + fi + + if [[ "$line" =~ $READRA_QUOTED_RE ]]; then + echo "${BASH_REMATCH[1]}" | sed 's/[$"]//g' + return + fi + + if [[ "$line" =~ $READRA_UNQUOTED_RE ]]; then + echo "${BASH_REMATCH[1]}" | sed 's/[$"]//g' + return + fi +} + +# classify_dangerous_pattern +# +# Returns "eval" if the line uses eval-array, "read-ra" if read -ra. +classify_dangerous_pattern() { + local line="$1" + if [[ "$line" =~ eval ]]; then + echo "eval" + elif [[ "$line" =~ read[[:space:]]+-ra ]]; then + echo "read-ra" + fi +} + +# discover_task_files +# +# Emit paths of task files under /.mise/tasks/. +# Excludes '*/fixtures/*' paths (lint-rule fixtures contain intentional +# negatives — scanning them would produce self-flagging meta-recursion). +discover_task_files() { + local target="$1" + + if [[ ! -d "$target/.mise/tasks" ]]; then + return + fi + + # Use fd with --exclude fixtures for component-aware exclusion. + # Tasks under .mise/tasks/ are extension-less executable files (no .sh). + fd -t f --exclude fixtures . "$target/.mise/tasks" +} + +# scan_file +# +# Scan a single task file for dangerous consumption of any var in the +# variadic set. Emit "lineno:line" for each finding. +scan_file() { + local file="$1" + local -n _vars="$2" + local lineno=0 + local line + local findings=() + + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + + # Skip full-line comments. + [[ "$line" =~ ^[[:space:]]*# ]] && continue + + # Inline opt-out — respects structured and bare forms. + [[ "$line" == *"codebase:ignore"* ]] && continue + + # Check if line contains any dangerous pattern + if [[ "$line" =~ $EVAL_RE ]] || [[ "$line" =~ $EVAL_BARE_RE ]] \ + || [[ "$line" =~ $READRA_QUOTED_RE ]] || [[ "$line" =~ $READRA_UNQUOTED_RE ]]; then + + local consumed_var + consumed_var=$(extract_usage_var "$line") + + # Cross-reference: only flag if the consumed var is in the variadic set + local is_variadic=false + for v in "${_vars[@]}"; do + if [[ "$v" == "$consumed_var" ]]; then + is_variadic=true + break + fi + done + + if $is_variadic; then + local pattern + pattern=$(classify_dangerous_pattern "$line") + local trimmed="${line#"${line%%[![:space:]]*}"}" + findings+=("$lineno|$trimmed|$pattern|$consumed_var") + fi + fi + done < "$file" + + if [[ ${#findings[@]} -gt 0 ]]; then + for f in "${findings[@]}"; do + echo "$f" + done + fi +} + +# === Main loop === + +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 variadic-args' "$toml"; then + echo "SKIP $name (codebase:ignore)" + continue + fi + + # Discover task files + task_files=() + while IFS= read -r f; do + [[ -n "$f" ]] && task_files+=("$f") + done < <(discover_task_files "$target") + + if [[ ${#task_files[@]} -eq 0 ]]; then + echo "OK $name (no .mise/tasks/ files found)" + continue + fi + + # Phase 1 for each task: collect variadic vars + hit_count=0 + target_output="" + + for task_file in "${task_files[@]}"; do + variadic_vars=() + while IFS= read -r v; do + [[ -n "$v" ]] && variadic_vars+=("$v") + done < <(collect_variadic_vars "$task_file") + + # Skip if no variadic vars declared in this task + if [[ ${#variadic_vars[@]} -eq 0 ]]; then + continue + fi + + # Phase 2: scan for dangerous consumption + rel="${task_file#"$target"/}" + while IFS= read -r finding; do + [[ -z "$finding" ]] && continue + + lineno="${finding%%|*}" + rest="${finding#*|}" + line_text="${rest%%|*}" + rest="${rest#*|}" + pattern="${rest%%|*}" + var_name="${rest#*|}" + + if [[ "$pattern" == "eval" ]]; then + target_output+=" $rel:$lineno: $line_text"$'\n' + target_output+=" ERROR: eval is a shell injection vector — use xargs printf pattern instead"$'\n' + else + target_output+=" $rel:$lineno: $line_text"$'\n' + target_output+=" WARN: read -ra loses quoting on multi-word values from mise — use xargs printf pattern instead"$'\n' + fi + hit_count=$((hit_count + 1)) + done < <(scan_file "$task_file" variadic_vars) + done + + if [[ "$hit_count" -gt 0 ]]; then + echo "FAIL $name: $hit_count dangerous variadic-arg consumption(s)" + printf '%s' "$target_output" + echo " hint: use 'xargs printf' pattern for bash:" + echo " while IFS= read -r arg; do ARGS+=(\"\$arg\"); done < <(printf '%s' \"\$var\" | xargs printf '%s\\n')" + echo " For Python tasks, use shlex.split(). See mise-conventions.md ('variadic args' section)." + echo " Annotate false positives with '# codebase:ignore variadic-args — '" + failures=$((failures + 1)) + else + echo "OK $name (${#task_files[@]} task file(s) clean)" + fi +done + +exit "$failures" diff --git a/.mise/tasks/migrate/task-pattern b/.mise/tasks/migrate/task-pattern index 033ead4..798b079 100755 --- a/.mise/tasks/migrate/task-pattern +++ b/.mise/tasks/migrate/task-pattern @@ -2,6 +2,8 @@ #MISE description="Migrate mise run calls to _task pattern (or reverse)" #USAGE arg "" help="Path to the codebase to migrate" #USAGE flag "--reverse" help="Reverse: _task back to mise run" +#USAGE example "codebase migrate /path/to/repo" header="Migrate to _task pattern" +#USAGE example "codebase migrate /path/to/repo --reverse" header="Reverse migration to mise run" set -euo pipefail diff --git a/.mise/tasks/pre-commit b/.mise/tasks/pre-commit index c009a4a..1454b10 100755 --- a/.mise/tasks/pre-commit +++ b/.mise/tasks/pre-commit @@ -2,6 +2,8 @@ #MISE description="Install or check codebase pre-commit hook" #USAGE flag "--revert" help="Remove the codebase pre-commit hook" #USAGE flag "--check" help="Check if hook is installed and current (no changes)" +#USAGE example "codebase pre-commit" header="Install the pre-commit hook" +#USAGE example "codebase pre-commit --check" header="Check hook installation" set -euo pipefail diff --git a/.mise/tasks/scan b/.mise/tasks/scan index 2c512e7..248e642 100755 --- a/.mise/tasks/scan +++ b/.mise/tasks/scan @@ -3,7 +3,8 @@ #USAGE arg "…" help="Paths to codebases to scan (one or more)" #USAGE flag "-p --pattern " required=#true help="ast-grep pattern to search for" #USAGE flag "-l --lang " help="Language (default: bash)" -#USAGE flag "-e --exclude " help="Glob patterns to exclude (space-separated)" +#USAGE flag "-e --exclude " help="Glob patterns to exclude (space-separated)" +#USAGE example "codebase scan . -p 'some pattern' -l bash" header="Scan for AST patterns" set -euo pipefail diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d37187b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,79 @@ +# AGENTS.md — codebase + +<<<<<<< HEAD +**Structural code analysis, linting & migrations.** A toolbox of convention lints, AST-based scanning, and codemod migrations for KnickKnackLabs repos. Designed to be used as a shiv dependency by other repos. + +## Commands + +| Command | Description | +|---------|-------------| +| `lint [target]` | Run all configured convention lints (from repo's `[_.codebase]` mise config) | +| `lint: ` | Run a specific lint check by name | +| `scan -p -l ` | AST pattern matching via ast-grep | +| `migrate task-pattern ` | Migrate mise run calls to `_task` pattern (or reverse) | +| `pre-commit [--check \| --revert]` | Install, check, or remove the codebase pre-commit hook | +| `test [args...]` | Run the BATS test suite | + +### Available lint checks + +| Lint | What it checks | +|------|---------------| +| `mise-settings` | `mise.toml` has required settings (`experimental`, `quiet`, `task_output`) | +| `gum-table` | Detects manual table formatting that should use `gum table` | +| `bats-test-helper` | Flags direct script invocation from BATS tests (should call the tool) | +| `bats-test-task` | Enforces canonical BATS test-task shape in `.mise/tasks/test` | +| `mcr-scope` | Forbids `MISE_CONFIG_ROOT` in `test/` and `lib/` | +| `or-true` | Classifies risky unannotated `\|\| true` / `\|\| :` failure suppression | +| `shellcheck` | Runs shellcheck against shell files | +| `caller-pwd-contract` | Checks shiv caller-cwd environment variable contract | +| `github-actions` | Lints workflows and creates a default KKL workflow when missing | +| `bash-empty-array-expansions` | Flags empty array expansions under nounset (macOS Bash 3.2 compat) | +| `usage-flag-naming` | Detects #USAGE flag directives where flag name and arg placeholder produce different `usage_*` env var names (flag silently does nothing) | +| `mise-usage-examples` | Enforce #USAGE example directives for public argument-bearing `mise` tasks | + +## Install + +```bash +shiv install codebase +``` + +Projects add it to `mise.toml`: + +```toml +[tools] +"shiv:codebase" = "latest" + +[_.codebase] +lint = [ + "mise-settings", + "bats-test-task", + "or-true", + "shellcheck", + "caller-pwd-contract", + "github-actions", +] +``` + +## Conventions + +### Commentary style + +Use single-line `# Section title` comments for test section headers. Do not use three-line `# ====== / # Title / # ======` dividers — they waste vertical space without adding information. + +```bash +# Good — single line +# Detection + +@test "..." { + +# Bad — three-line divider +# ==================================================================== +# Detection +# ==================================================================== +``` + +## Key patterns + +- Lint checks are per-repo opt-in via `[_.codebase]` in `mise.toml` +- Uses `ast-grep` for structural pattern matching (not regex) +- Sub-commands: `lint/` directory contains individual lint scripts, `migrate/` contains codemod scripts 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/lib/usage-parser.sh b/lib/usage-parser.sh new file mode 100644 index 0000000..5fa2914 --- /dev/null +++ b/lib/usage-parser.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Shared helpers for parsing #USAGE directives in .mise/tasks files. +# +# Source from .mise/tasks/lint/* (task context): +# # shellcheck source=../../lib/usage-parser.sh +# source "$MISE_CONFIG_ROOT/lib/usage-parser.sh" +# +# MISE_CONFIG_ROOT is the right primitive here — mise dispatched the task, so +# it's set correctly. Do NOT source this file from test helpers or other lib +# files using $MISE_CONFIG_ROOT; see fold/notes/mise-gotchas.md. + +# usage_flag_name +# +# Given a #USAGE flag directive like '#USAGE flag "-e --exclude "', +# extract the long flag name with -- prefix. Returns empty if no long flag. +# +# Examples: +# '#USAGE flag "--verbose"' → "--verbose" +# '#USAGE flag "--model "' → "--model" +# '#USAGE flag "-e --exclude "' → "--exclude" +# '#USAGE flag "-e"' → "" +usage_flag_name() { + local directive="$1" + # Match a '--' word. The long flag appears before any placeholder. + if [[ "$directive" =~ --([a-zA-Z0-9_-]+) ]]; then + printf '%s\n' "--${BASH_REMATCH[1]}" + fi +} + +# usage_placeholder +# +# Given a #USAGE flag directive, extract the arg placeholder <...> text +# (without angle brackets). Returns empty if no placeholder. +# +# Examples: +# '#USAGE flag "--model "' → "model" +# '#USAGE flag "--exclude "' → "x" +# '#USAGE flag "--verbose"' → "" +usage_placeholder() { + local directive="$1" + if [[ "$directive" =~ '<'([a-zA-Z0-9_-]+)'>' ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + fi +} + +# usage_env_name +# +# Convert a flag name (with --) or placeholder name to the mise usage_* env var +# name: strip leading --, replace hyphens with underscores. +# +# Examples: +# "--exclude" → "usage_exclude" +# "excludes" → "usage_excludes" +# "file-path" → "usage_file_path" +usage_env_name() { + local name="$1" + # Strip leading -- if present + name="${name#--}" + # Replace hyphens with underscores (mise normalises hyphens to underscores) + name="${name//-/_}" + printf 'usage_%s\n' "$name" +} + +# parse_usage_flags +# +# Read a task file and emit tab-separated lines of the form: +# \t\t +# for each #USAGE flag directive found. +# +# - Empty placeholder means boolean flag (no arg). +# - Empty flag_name means short-only flag (e.g. -v, no --long form). +# - Lines with codebase:ignore are skipped (respect inline opt-out). +# +# Output is tab-separated for reliable parsing with IFS=$'\t' read -r. +parse_usage_flags() { + local file="$1" + local lineno=0 + local line + + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + + # Non-directive lines are skipped + [[ "$line" != *"#USAGE flag"* ]] && continue + + # Inline opt-out + [[ "$line" == *"codebase:ignore"* ]] && continue + + local flag_name + local placeholder + + flag_name=$(usage_flag_name "$line") + placeholder=$(usage_placeholder "$line") + + # Skip short-only flags (no long name) — nothing to compare + [[ -z "$flag_name" ]] && continue + + printf '%s\t%s\t%s\n' "$lineno" "$flag_name" "$placeholder" + done < "$file" +} + +# discover_task_files +# +# Emit paths of task files under /.mise/tasks/, one per line. +# Excludes subdirectories named fixtures/ (lint-rule fixtures intentionally +# contain synthetic patterns that should not trigger self-flagging during +# self-hosting scans). +discover_task_files() { + local target="$1" + local tasks_dir="$target/.mise/tasks" + + if [[ ! -d "$tasks_dir" ]]; then + return + fi + + fd --type f --exclude fixtures . "$tasks_dir" 2>/dev/null || true +} 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/bash-empty-argv-forwarding/bash-empty-argv-forwarding.bats b/test/lint/bash-empty-argv-forwarding/bash-empty-argv-forwarding.bats new file mode 100644 index 0000000..571038e --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/bash-empty-argv-forwarding.bats @@ -0,0 +1,265 @@ +#!/usr/bin/env bats +# Tests for lint:bash-empty-argv-forwarding rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +# Detection + +@test "bash-empty-argv-forwarding: passes on a clean codebase" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"clean"* ]] +} + +@test "bash-empty-argv-forwarding: flags '\"$@\"' under nounset" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty"* ]] + [[ "$output" == *'"$@"'* ]] +} + +@test "bash-empty-argv-forwarding: does not flag '\"$@\"' in files without nounset" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/no-nounset" + [ "$status" -eq 0 ] +} + +@test "bash-empty-argv-forwarding: does not flag 'for arg in \"$@\"' (safe context)" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/safe-context" + [ "$status" -eq 0 ] +} + +@test "bash-empty-argv-forwarding: does not flag already-safe '${@+\"$@\"}'" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/already-safe" + [ "$status" -eq 0 ] +} + +@test "bash-empty-argv-forwarding: flags across exec, mise run, and piped forms" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/exec-wrapper" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +exec other "$@" +EOF + cat > "$tmp/.mise/tasks/mise-wrapper" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +mise run child "$@" +EOF + + run codebase lint:bash-empty-argv-forwarding "$tmp" + [ "$status" -ne 0 ] + [[ "$output" == *"exec other"* ]] + [[ "$output" == *"mise run child"* ]] + rm -rf "$tmp" +} + +@test "bash-empty-argv-forwarding: fail output includes the hint" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *'Use ${@+"$@"}'* ]] +} + +@test "bash-empty-argv-forwarding: fail output names the violating file" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"delegate"* ]] +} + +# Ignore directives + +@test "bash-empty-argv-forwarding: inline '# codebase:ignore — reason' skips the line" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/ignored-inline" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"ignored-inline"* ]] +} + +@test "bash-empty-argv-forwarding: 'codebase:ignore' in mise.toml skips the whole target" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/ignored-file" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"*"ignored-file"* ]] +} + +# Scope / discovery + +@test "bash-empty-argv-forwarding: walks the whole target — finds hits outside .mise/tasks" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/broad-walk" + [ "$status" -ne 0 ] + [[ "$output" == *"scripts/deploy.sh"* ]] + [[ "$output" == *"bin/tool"* ]] +} + +@test "bash-empty-argv-forwarding: works on a codebase with no mise.toml" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/no-toml" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"no-toml"* ]] +} + +@test "bash-empty-argv-forwarding: passes on a codebase with no shell files" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/no-shell-files" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"no-shell-files"* ]] + [[ "$output" == *"no shell files"* ]] +} + +@test "bash-empty-argv-forwarding: discovery skips non-bash shebangs (fish, zsh)" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/installer" <<'EOF' +#!/usr/bin/env fish +set -u +some_command "$@" +EOF + # fish is not a bash/sh shebang, so discover_shell_files should skip it. + run codebase lint:bash-empty-argv-forwarding "$tmp" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + rm -rf "$tmp" +} + +@test "bash-empty-argv-forwarding: discovery prunes .git/ (hooks are ignored)" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.git/hooks" "$tmp/.mise/tasks" + cat > "$tmp/.git/hooks/pre-commit" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +exec hook "$@" +EOF + cat > "$tmp/.mise/tasks/greet" <<'EOF' +#!/usr/bin/env bash +echo hi +EOF + + run codebase lint:bash-empty-argv-forwarding "$tmp" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + # One shell file scanned (the greet task), zero from .git + [[ "$output" == *"1 file(s) clean"* ]] + rm -rf "$tmp" +} + +# Comment handling + +@test "bash-empty-argv-forwarding: does not flag '\"$@\"' inside a full-line comment" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/t" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +# Note: some_command "$@" — not really executed +echo ok +EOF + run codebase lint:bash-empty-argv-forwarding "$tmp" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + rm -rf "$tmp" +} + +# Multi-target + +@test "bash-empty-argv-forwarding: checks multiple targets and reports each" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/clean" "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"OK"*"clean"* ]] + [[ "$output" == *"FAIL"*"dirty"* ]] +} + +@test "bash-empty-argv-forwarding: exit code is the number of failing targets" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/dirty" "$FIXTURES/broad-walk" + [ "$status" -eq 2 ] +} + +# Error paths + +@test "bash-empty-argv-forwarding: fails when target does not exist" { + run codebase lint:bash-empty-argv-forwarding "$FIXTURES/does-not-exist" + [ "$status" -ne 0 ] + [[ "$output" == *"does not exist"* ]] +} + +@test "bash-empty-argv-forwarding: fails when no targets given" { + run codebase lint:bash-empty-argv-forwarding + [ "$status" -ne 0 ] + [[ "$output" == *"Missing required arg"* ]] + [[ "$output" == *""* ]] +} + +# Relative path resolution + +@test "bash-empty-argv-forwarding: relative path resolves against CODEBASE_CALLER_PWD" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/t" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +some_command "$@" +EOF + + CODEBASE_CALLER_PWD="$tmp" run codebase lint:bash-empty-argv-forwarding .mise/tasks + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"* ]] + [[ "$output" == *'"$@"'* ]] + rm -rf "$tmp" +} + +# Explicit braces detection + +@test "bash-empty-argv-forwarding: flags '\"\${@}\"' (explicit braces) under nounset" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/t" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +some_command "${@}" +EOF + + run codebase lint:bash-empty-argv-forwarding "$tmp" + [ "$status" -ne 0 ] + [[ "$output" == *'"${@}"'* ]] + rm -rf "$tmp" +} + +@test "bash-empty-argv-forwarding: flags '\"\${@}\"' in redirect context" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/t" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +some_command "${@}" > /tmp/output +EOF + + run codebase lint:bash-empty-argv-forwarding "$tmp" + [ "$status" -ne 0 ] + [[ "$output" == *'"${@}"'* ]] + rm -rf "$tmp" +} + +@test "bash-empty-argv-forwarding: does not flag local array assignment from '\"$@\"'" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/t" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +local args=("$@") +for arg in "${args[@]}"; do + echo "$arg" +done +EOF + + run codebase lint:bash-empty-argv-forwarding "$tmp" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + rm -rf "$tmp" +} diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/already-safe/.mise/tasks/task b/test/lint/bash-empty-argv-forwarding/fixtures/already-safe/.mise/tasks/task new file mode 100644 index 0000000..275732c --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/already-safe/.mise/tasks/task @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +some_command ${@+"$@"} diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/already-safe/mise.toml b/test/lint/bash-empty-argv-forwarding/fixtures/already-safe/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/already-safe/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/bin/tool b/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/bin/tool new file mode 100644 index 0000000..c2b2759 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/bin/tool @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +command "$@" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/mise.toml b/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/scripts/deploy.sh b/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/scripts/deploy.sh new file mode 100644 index 0000000..076a2b5 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/scripts/deploy.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +rsync "$@" deploy@example.com:/srv/app/ diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/clean/.mise/tasks/greet b/test/lint/bash-empty-argv-forwarding/fixtures/clean/.mise/tasks/greet new file mode 100644 index 0000000..ec8a236 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/clean/.mise/tasks/greet @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "hello $USER" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/clean/mise.toml b/test/lint/bash-empty-argv-forwarding/fixtures/clean/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/clean/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/dirty/.mise/tasks/delegate b/test/lint/bash-empty-argv-forwarding/fixtures/dirty/.mise/tasks/delegate new file mode 100644 index 0000000..2a429fc --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/dirty/.mise/tasks/delegate @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +mise run child "$@" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/dirty/mise.toml b/test/lint/bash-empty-argv-forwarding/fixtures/dirty/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/dirty/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/ignored-file/.mise/tasks/broken b/test/lint/bash-empty-argv-forwarding/fixtures/ignored-file/.mise/tasks/broken new file mode 100644 index 0000000..258bc84 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/ignored-file/.mise/tasks/broken @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +some_command "$@" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/ignored-file/mise.toml b/test/lint/bash-empty-argv-forwarding/fixtures/ignored-file/mise.toml new file mode 100644 index 0000000..c663d19 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/ignored-file/mise.toml @@ -0,0 +1,2 @@ +[_.codebase] +# codebase:ignore bash-empty-argv-forwarding diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/ignored-inline/.mise/tasks/task b/test/lint/bash-empty-argv-forwarding/fixtures/ignored-inline/.mise/tasks/task new file mode 100644 index 0000000..de98463 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/ignored-inline/.mise/tasks/task @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +some_command "$@" # codebase:ignore bash-empty-argv-forwarding — intentional: always called with args diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/ignored-inline/mise.toml b/test/lint/bash-empty-argv-forwarding/fixtures/ignored-inline/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/ignored-inline/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/no-nounset/.mise/tasks/delegate b/test/lint/bash-empty-argv-forwarding/fixtures/no-nounset/.mise/tasks/delegate new file mode 100644 index 0000000..f19e98c --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/no-nounset/.mise/tasks/delegate @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# No set -u here — this is safe on all platforms +mise run child "$@" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/no-nounset/mise.toml b/test/lint/bash-empty-argv-forwarding/fixtures/no-nounset/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/no-nounset/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/no-shell-files/mise.toml b/test/lint/bash-empty-argv-forwarding/fixtures/no-shell-files/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/no-shell-files/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/no-toml/.mise/tasks/greet b/test/lint/bash-empty-argv-forwarding/fixtures/no-toml/.mise/tasks/greet new file mode 100644 index 0000000..f90f3e8 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/no-toml/.mise/tasks/greet @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "greetings" diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/safe-context/.mise/tasks/loop b/test/lint/bash-empty-argv-forwarding/fixtures/safe-context/.mise/tasks/loop new file mode 100644 index 0000000..aafd136 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/safe-context/.mise/tasks/loop @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +for arg in "$@"; do + echo "$arg" +done diff --git a/test/lint/bash-empty-argv-forwarding/fixtures/safe-context/mise.toml b/test/lint/bash-empty-argv-forwarding/fixtures/safe-context/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/safe-context/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-array-expansions/bash-empty-array-expansions.bats b/test/lint/bash-empty-array-expansions/bash-empty-array-expansions.bats new file mode 100644 index 0000000..53178cb --- /dev/null +++ b/test/lint/bash-empty-array-expansions/bash-empty-array-expansions.bats @@ -0,0 +1,145 @@ +#!/usr/bin/env bats +# Tests for lint:bash-empty-array-expansions rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +# Detection + +@test "bash-empty-array-expansions: passes on a clean codebase" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "bash-empty-array-expansions: flags '\"${args[@]}\"' under nounset" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty"* ]] + [[ "$output" == *'"${arch_args[@]}"'* ]] +} + +@test "bash-empty-array-expansions: flags '\"${args[*]}\"' under nounset" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/dirty-star" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-star"* ]] + [[ "$output" == *'"${all_flags[*]}"'* ]] +} + +@test "bash-empty-array-expansions: flags multiple array expansions in one file" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/dirty-multiple" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-multiple"* ]] + [[ "$output" == *'"${names[@]}"'* ]] + [[ "$output" == *'"${paths[@]}"'* ]] + [[ "$output" == *'"${flags[*]}"'* ]] +} + +@test "bash-empty-array-expansions: does not flag '\"${args[@]}\"' in files without nounset" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/no-nounset" + [ "$status" -eq 0 ] +} + +# Safe contexts + +@test "bash-empty-array-expansions: does not flag 'for item in \"\${items[@]}\"'" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/safe-context" + [ "$status" -eq 0 ] +} + +@test "bash-empty-array-expansions: does not flag 'local arr=(\"\${items[@]}\")'" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/safe-context-local" + [ "$status" -eq 0 ] +} + +@test "bash-empty-array-expansions: does not flag already-safe '\${arr[@]+\"\${arr[@]}\"}" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/already-safe" + [ "$status" -eq 0 ] +} + +# Ignore mechanisms + +@test "bash-empty-array-expansions: respects inline ignore" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/ignored-inline" + [ "$status" -eq 0 ] +} + +@test "bash-empty-array-expansions: respects file-level ignore via mise.toml" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/ignored-file" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"* ]] +} + +# Edge cases + +@test "bash-empty-array-expansions: handles missing mise.toml gracefully" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/no-toml" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"no-toml"* ]] +} + +@test "bash-empty-array-expansions: finds hits outside .mise/tasks in broad walk" { + run codebase lint:bash-empty-array-expansions "$FIXTURES/broad-walk" + [ "$status" -ne 0 ] + [[ "$output" == *"lib/helper.sh"* ]] +} + +# Dynamic test: various variable names + +@test "bash-empty-array-expansions: flags different variable names" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/t" <<'SCRIPT' +#!/usr/bin/env bash +set -euo pipefail +echo "${names[@]}" +echo "${paths[@]}" +echo "${flags[*]}" +SCRIPT + + run codebase lint:bash-empty-array-expansions "$tmp" + [ "$status" -ne 0 ] + [[ "$output" == *"names"* ]] + [[ "$output" == *"paths"* ]] + [[ "$output" == *"flags"* ]] + rm -rf "$tmp" +} + +@test "bash-empty-array-expansions: flags in redirect context" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/t" <<'SCRIPT' +#!/usr/bin/env bash +set -euo pipefail +some_command "${args[@]}" > /tmp/output +SCRIPT + + run codebase lint:bash-empty-array-expansions "$tmp" + [ "$status" -ne 0 ] + [[ "$output" == *'"${args[@]}"'* ]] + rm -rf "$tmp" +} + +@test "bash-empty-array-expansions: does not flag local array assignment then for loop (combined safe)" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/t" <<'SCRIPT' +#!/usr/bin/env bash +set -euo pipefail +local args=("$@") +for arg in "${args[@]}"; do + echo "$arg" +done +SCRIPT + + run codebase lint:bash-empty-array-expansions "$tmp" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + rm -rf "$tmp" +} diff --git a/test/lint/bash-empty-array-expansions/fixtures/already-safe/.mise/tasks/t b/test/lint/bash-empty-array-expansions/fixtures/already-safe/.mise/tasks/t new file mode 100644 index 0000000..91fc753 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/already-safe/.mise/tasks/t @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +args=() +cmd ${args[@]+"${args[@]}"} +cmd ${args[*]+"${args[*]}"} +echo done diff --git a/test/lint/bash-empty-array-expansions/fixtures/already-safe/mise.toml b/test/lint/bash-empty-array-expansions/fixtures/already-safe/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/already-safe/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-array-expansions/fixtures/broad-walk/lib/helper.sh b/test/lint/bash-empty-array-expansions/fixtures/broad-walk/lib/helper.sh new file mode 100644 index 0000000..bc77ba0 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/broad-walk/lib/helper.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This is outside .mise/tasks — should still be found in broad walk +args=() +cmd "${args[@]}" diff --git a/test/lint/bash-empty-array-expansions/fixtures/broad-walk/mise.toml b/test/lint/bash-empty-array-expansions/fixtures/broad-walk/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/broad-walk/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-array-expansions/fixtures/clean/.mise/tasks/t b/test/lint/bash-empty-array-expansions/fixtures/clean/.mise/tasks/t new file mode 100644 index 0000000..66be494 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/clean/.mise/tasks/t @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +no_nounset_here=true +cmd "${args[@]}" +echo done diff --git a/test/lint/bash-empty-array-expansions/fixtures/clean/mise.toml b/test/lint/bash-empty-array-expansions/fixtures/clean/mise.toml new file mode 100644 index 0000000..7692013 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/clean/mise.toml @@ -0,0 +1,3 @@ +# Clean codebase — no shell files with nounset + array expansions +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-array-expansions/fixtures/dirty-multiple/.mise/tasks/t b/test/lint/bash-empty-array-expansions/fixtures/dirty-multiple/.mise/tasks/t new file mode 100644 index 0000000..cc2164a --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty-multiple/.mise/tasks/t @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +names=() +paths=() +flags=() + +echo "${names[@]}" +echo "${paths[@]}" +echo "${flags[*]}" diff --git a/test/lint/bash-empty-array-expansions/fixtures/dirty-multiple/mise.toml b/test/lint/bash-empty-array-expansions/fixtures/dirty-multiple/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty-multiple/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-array-expansions/fixtures/dirty-star/.mise/tasks/t b/test/lint/bash-empty-array-expansions/fixtures/dirty-star/.mise/tasks/t new file mode 100644 index 0000000..ca0c332 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty-star/.mise/tasks/t @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +all_flags=() +[[ -n "${FLAGS:-}" ]] && all_flags=(--flag "$FLAGS") + +printf '%s\n' "${all_flags[*]}" +echo done diff --git a/test/lint/bash-empty-array-expansions/fixtures/dirty-star/mise.toml b/test/lint/bash-empty-array-expansions/fixtures/dirty-star/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty-star/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-array-expansions/fixtures/dirty/.mise/tasks/t b/test/lint/bash-empty-array-expansions/fixtures/dirty/.mise/tasks/t new file mode 100644 index 0000000..9cb3175 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty/.mise/tasks/t @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +arch_args=() +[[ -n "${ARCH_FLAG:-}" ]] && arch_args=(--arch "$ARCH_FLAG") + +mise run catalog -- "${arch_args[@]}" +echo done diff --git a/test/lint/bash-empty-array-expansions/fixtures/dirty/mise.toml b/test/lint/bash-empty-array-expansions/fixtures/dirty/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-array-expansions/fixtures/ignored-file/.mise/tasks/t b/test/lint/bash-empty-array-expansions/fixtures/ignored-file/.mise/tasks/t new file mode 100644 index 0000000..e5a6d8f --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/ignored-file/.mise/tasks/t @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +args=() +echo "${args[@]}" +echo "${args[*]}" diff --git a/test/lint/bash-empty-array-expansions/fixtures/ignored-file/mise.toml b/test/lint/bash-empty-array-expansions/fixtures/ignored-file/mise.toml new file mode 100644 index 0000000..0532e0e --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/ignored-file/mise.toml @@ -0,0 +1 @@ +codebase:ignore bash-empty-array-expansions diff --git a/test/lint/bash-empty-array-expansions/fixtures/ignored-inline/.mise/tasks/t b/test/lint/bash-empty-array-expansions/fixtures/ignored-inline/.mise/tasks/t new file mode 100644 index 0000000..050e4ae --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/ignored-inline/.mise/tasks/t @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +args=() +cmd "${args[@]}" # codebase:ignore bash-empty-array-expansions -- test fixture +echo done diff --git a/test/lint/bash-empty-array-expansions/fixtures/ignored-inline/mise.toml b/test/lint/bash-empty-array-expansions/fixtures/ignored-inline/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/ignored-inline/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-array-expansions/fixtures/no-nounset/.mise/tasks/t b/test/lint/bash-empty-array-expansions/fixtures/no-nounset/.mise/tasks/t new file mode 100644 index 0000000..4b595d1 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/no-nounset/.mise/tasks/t @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# No set -u — should NOT be flagged +cmd "${args[@]}" +echo "${items[*]}" diff --git a/test/lint/bash-empty-array-expansions/fixtures/no-nounset/mise.toml b/test/lint/bash-empty-array-expansions/fixtures/no-nounset/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/no-nounset/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-array-expansions/fixtures/no-toml/.mise/tasks/t b/test/lint/bash-empty-array-expansions/fixtures/no-toml/.mise/tasks/t new file mode 100644 index 0000000..9028505 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/no-toml/.mise/tasks/t @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# No mise.toml — should still scan gracefully +# No nounset, so no risk of unbound variable +echo "hello" diff --git a/test/lint/bash-empty-array-expansions/fixtures/safe-context-local/.mise/tasks/t b/test/lint/bash-empty-array-expansions/fixtures/safe-context-local/.mise/tasks/t new file mode 100644 index 0000000..7ad8cc4 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/safe-context-local/.mise/tasks/t @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +items=("a" "b" "c") +local copy=("${items[@]}") +for item in "${copy[@]}"; do + echo "$item" +done diff --git a/test/lint/bash-empty-array-expansions/fixtures/safe-context-local/mise.toml b/test/lint/bash-empty-array-expansions/fixtures/safe-context-local/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/safe-context-local/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" diff --git a/test/lint/bash-empty-array-expansions/fixtures/safe-context/.mise/tasks/t b/test/lint/bash-empty-array-expansions/fixtures/safe-context/.mise/tasks/t new file mode 100644 index 0000000..d72d477 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/safe-context/.mise/tasks/t @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +items=("a" "b" "c") +for item in "${items[@]}"; do + echo "$item" +done diff --git a/test/lint/bash-empty-array-expansions/fixtures/safe-context/mise.toml b/test/lint/bash-empty-array-expansions/fixtures/safe-context/mise.toml new file mode 100644 index 0000000..26bb173 --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/safe-context/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" 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/bats-test-task/fixtures/clean/mise.toml b/test/lint/bats-test-task/fixtures/clean/mise.toml index 9487f20..89e6b9c 100644 --- a/test/lint/bats-test-task/fixtures/clean/mise.toml +++ b/test/lint/bats-test-task/fixtures/clean/mise.toml @@ -1 +1 @@ -[tools] +codebase:ignore bash-empty-array-expansions \ No newline at end of file diff --git a/test/lint/ci-lint-enforcement/ci-lint-enforcement.bats b/test/lint/ci-lint-enforcement/ci-lint-enforcement.bats new file mode 100644 index 0000000..0e69ce8 --- /dev/null +++ b/test/lint/ci-lint-enforcement/ci-lint-enforcement.bats @@ -0,0 +1,143 @@ +#!/usr/bin/env bats +# Tests for lint:ci-lint-enforcement rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +copy_fixture() { + local name="$1" + local dest="$BATS_TEST_TMPDIR/$name" + cp -R "$FIXTURES/$name" "$dest" + echo "$dest" +} + +# === direct enforcement === + +@test "lint: passes when aggregate codebase lint is in workflow" { + run codebase lint:ci-lint-enforcement "$FIXTURES/enforced-direct" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"enforced-direct"* ]] + [[ "$output" == *"2 rule(s)"* ]] +} + +# === indirect enforcement via local task === + +@test "lint: passes when aggregate lint runs through local task delegation" { + run codebase lint:ci-lint-enforcement "$FIXTURES/enforced-indirect" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"enforced-indirect"* ]] + [[ "$output" == *"3 rule(s)"* ]] +} + +# === missing enforcement === + +@test "lint: fails when lint configured but not enforced in CI" { + run codebase lint:ci-lint-enforcement "$FIXTURES/missing-enforcement" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"missing-enforcement"* ]] + [[ "$output" == *"no aggregate enforcement in CI"* ]] +} + +# === hard-coded per-rule loop === + +@test "lint: warns on hard-coded per-rule loops" { + run codebase lint:ci-lint-enforcement "$FIXTURES/hardcoded-loop" + [ "$status" -eq 0 ] + [[ "$output" == *"WARN"*"hardcoded-loop"* ]] + [[ "$output" == *"per-rule invocation"* ]] + [[ "$output" == *"3 per-rule invocation(s)"* ]] +} + +# === no lint config → skip === + +@test "lint: skips when no [_.codebase].lint is configured" { + run codebase lint:ci-lint-enforcement "$FIXTURES/no-config" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"*"no-config"* ]] + [[ "$output" == *"no [_.codebase].lint configured"* ]] +} + +# === codebase:ignore === + +@test "lint: skips when codebase:ignore ci-lint-enforcement is set" { + run codebase lint:ci-lint-enforcement "$FIXTURES/ignored" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"*"ignored"* ]] +} + +# === --fix mode === + +@test "fix: adds aggregate lint step to workflow when missing" { + target=$(copy_fixture missing-enforcement) + + run codebase lint:ci-lint-enforcement --fix "$target" + [ "$status" -eq 0 ] + [[ "$output" == *"FIXED"*"missing-enforcement"* ]] + + workflow="$target/.github/workflows/test.yml" + [ -f "$workflow" ] + grep -q 'Run codebase lints' "$workflow" + grep -q 'codebase lint "\$PWD"' "$workflow" +} + +@test "fix: provisions codebase CLI tooling when missing" { + target=$(copy_fixture missing-enforcement) + + run codebase lint:ci-lint-enforcement --fix "$target" + [ "$status" -eq 0 ] + [[ "$output" == *"provisioned"* ]] + + grep -q 'shiv = "https://github.com/KnickKnackLabs/vfox-shiv"' "$target/mise.toml" + grep -q '"shiv:codebase"' "$target/mise.toml" +} + +@test "fix: does not duplicate existing provisioning" { + target=$(copy_fixture enforced-direct) + + run codebase lint:ci-lint-enforcement --fix "$target" + [ "$status" -eq 0 ] + + # Should not change the workflow since it already has enforcement + grep -q 'Run codebase lints' "$target/.github/workflows/test.yml" + [ "$(grep -c 'Run codebase lints' "$target/.github/workflows/test.yml")" -eq 1 ] +} + +@test "fix: warns about remaining per-rule loops" { + target=$(copy_fixture loop-only) + + run codebase lint:ci-lint-enforcement --fix "$target" + [ "$status" -eq 0 ] + [[ "$output" == *"FIXED"*"loop-only"* ]] + [[ "$output" == *"WARN"*"loop-only"* ]] + [[ "$output" == *"3 per-rule loop(s) remain"* ]] + + # Should still have added aggregate step + grep -q 'Run codebase lints' "$target/.github/workflows/test.yml" +} + +# === edge cases === + +@test "lint: fails when lint configured but no workflows exist" { + run codebase lint:ci-lint-enforcement "$FIXTURES/no-workflows" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"no-workflows"* ]] + [[ "$output" == *"no CI workflows found"* ]] +} + +@test "lint: passes multiple targets — mixed pass and fail" { + run codebase lint:ci-lint-enforcement \ + "$FIXTURES/enforced-direct" \ + "$FIXTURES/missing-enforcement" + [ "$status" -ne 0 ] + [[ "$output" == *"OK"*"enforced-direct"* ]] + [[ "$output" == *"FAIL"*"missing-enforcement"* ]] +} + +@test "lint: fails when target does not exist" { + run codebase lint:ci-lint-enforcement "$FIXTURES/does-not-exist" + [ "$status" -ne 0 ] + [[ "$output" == *"does not exist"* ]] +} \ No newline at end of file diff --git a/test/lint/ci-lint-enforcement/fixtures/enforced-direct/.github/workflows/test.yml b/test/lint/ci-lint-enforcement/fixtures/enforced-direct/.github/workflows/test.yml new file mode 100644 index 0000000..f1f6d5c --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/enforced-direct/.github/workflows/test.yml @@ -0,0 +1,14 @@ +name: Test +on: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up mise + uses: jdx/mise-action@v4 + - name: Run codebase lints + run: codebase lint "$PWD" + - name: Run tests + run: mise run test diff --git a/test/lint/ci-lint-enforcement/fixtures/enforced-direct/mise.toml b/test/lint/ci-lint-enforcement/fixtures/enforced-direct/mise.toml new file mode 100644 index 0000000..146b5c1 --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/enforced-direct/mise.toml @@ -0,0 +1,5 @@ +[settings] +quiet = true + +[_.codebase] +lint = ["mise-settings", "shellcheck"] \ No newline at end of file diff --git a/test/lint/ci-lint-enforcement/fixtures/enforced-indirect/.github/workflows/test.yml b/test/lint/ci-lint-enforcement/fixtures/enforced-indirect/.github/workflows/test.yml new file mode 100644 index 0000000..c7e06d6 --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/enforced-indirect/.github/workflows/test.yml @@ -0,0 +1,10 @@ +name: Test +on: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Run tests + run: mise run test diff --git a/test/lint/ci-lint-enforcement/fixtures/enforced-indirect/.mise/tasks/test b/test/lint/ci-lint-enforcement/fixtures/enforced-indirect/.mise/tasks/test new file mode 100644 index 0000000..e4b8c61 --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/enforced-indirect/.mise/tasks/test @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +"$CODEBASE_BIN" lint "$PWD" +# ... run other tests \ No newline at end of file diff --git a/test/lint/ci-lint-enforcement/fixtures/enforced-indirect/mise.toml b/test/lint/ci-lint-enforcement/fixtures/enforced-indirect/mise.toml new file mode 100644 index 0000000..590cd17 --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/enforced-indirect/mise.toml @@ -0,0 +1,5 @@ +[settings] +quiet = true + +[_.codebase] +lint = ["mise-settings", "gum-table", "shellcheck"] \ No newline at end of file diff --git a/test/lint/ci-lint-enforcement/fixtures/hardcoded-loop/.github/workflows/test.yml b/test/lint/ci-lint-enforcement/fixtures/hardcoded-loop/.github/workflows/test.yml new file mode 100644 index 0000000..3760890 --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/hardcoded-loop/.github/workflows/test.yml @@ -0,0 +1,16 @@ +name: Test +on: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Run aggregate codebase lints + run: codebase lint "$PWD" + - name: Lint mise-settings + run: codebase lint:mise-settings "$PWD" + - name: Lint shellcheck + run: codebase lint:shellcheck "$PWD" + - name: Lint gum-table + run: codebase lint:gum-table "$PWD" diff --git a/test/lint/ci-lint-enforcement/fixtures/hardcoded-loop/mise.toml b/test/lint/ci-lint-enforcement/fixtures/hardcoded-loop/mise.toml new file mode 100644 index 0000000..603eb6c --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/hardcoded-loop/mise.toml @@ -0,0 +1,5 @@ +[settings] +quiet = true + +[_.codebase] +lint = ["mise-settings", "shellcheck", "gum-table"] \ No newline at end of file diff --git a/test/lint/ci-lint-enforcement/fixtures/ignored/mise.toml b/test/lint/ci-lint-enforcement/fixtures/ignored/mise.toml new file mode 100644 index 0000000..22d70db --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/ignored/mise.toml @@ -0,0 +1,7 @@ +# codebase:ignore ci-lint-enforcement — repo handles lint differently + +[settings] +quiet = true + +[_.codebase] +lint = ["mise-settings"] \ No newline at end of file diff --git a/test/lint/ci-lint-enforcement/fixtures/loop-only/.github/workflows/test.yml b/test/lint/ci-lint-enforcement/fixtures/loop-only/.github/workflows/test.yml new file mode 100644 index 0000000..192b849 --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/loop-only/.github/workflows/test.yml @@ -0,0 +1,14 @@ +name: Test +on: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Lint mise-settings + run: codebase lint:mise-settings "$PWD" + - name: Lint shellcheck + run: codebase lint:shellcheck "$PWD" + - name: Lint gum-table + run: codebase lint:gum-table "$PWD" \ No newline at end of file diff --git a/test/lint/ci-lint-enforcement/fixtures/loop-only/mise.toml b/test/lint/ci-lint-enforcement/fixtures/loop-only/mise.toml new file mode 100644 index 0000000..603eb6c --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/loop-only/mise.toml @@ -0,0 +1,5 @@ +[settings] +quiet = true + +[_.codebase] +lint = ["mise-settings", "shellcheck", "gum-table"] \ No newline at end of file diff --git a/test/lint/ci-lint-enforcement/fixtures/missing-enforcement/.github/workflows/test.yml b/test/lint/ci-lint-enforcement/fixtures/missing-enforcement/.github/workflows/test.yml new file mode 100644 index 0000000..c7e06d6 --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/missing-enforcement/.github/workflows/test.yml @@ -0,0 +1,10 @@ +name: Test +on: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Run tests + run: mise run test diff --git a/test/lint/ci-lint-enforcement/fixtures/missing-enforcement/mise.toml b/test/lint/ci-lint-enforcement/fixtures/missing-enforcement/mise.toml new file mode 100644 index 0000000..146b5c1 --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/missing-enforcement/mise.toml @@ -0,0 +1,5 @@ +[settings] +quiet = true + +[_.codebase] +lint = ["mise-settings", "shellcheck"] \ No newline at end of file diff --git a/test/lint/ci-lint-enforcement/fixtures/no-config/mise.toml b/test/lint/ci-lint-enforcement/fixtures/no-config/mise.toml new file mode 100644 index 0000000..066be7e --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/no-config/mise.toml @@ -0,0 +1,2 @@ +[settings] +quiet = true \ No newline at end of file diff --git a/test/lint/ci-lint-enforcement/fixtures/no-workflows/mise.toml b/test/lint/ci-lint-enforcement/fixtures/no-workflows/mise.toml new file mode 100644 index 0000000..146b5c1 --- /dev/null +++ b/test/lint/ci-lint-enforcement/fixtures/no-workflows/mise.toml @@ -0,0 +1,5 @@ +[settings] +quiet = true + +[_.codebase] +lint = ["mise-settings", "shellcheck"] \ No newline at end of file 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/exec-stderr-silence/exec-stderr-silence.bats b/test/lint/exec-stderr-silence/exec-stderr-silence.bats new file mode 100644 index 0000000..11edbd9 --- /dev/null +++ b/test/lint/exec-stderr-silence/exec-stderr-silence.bats @@ -0,0 +1,150 @@ +#!/usr/bin/env bats +# Tests for lint:exec-stderr-silence rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +# Detection — basic + +@test "exec-stderr-silence: passes on a clean codebase" { + run codebase lint:exec-stderr-silence "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"clean"* ]] +} + +@test "exec-stderr-silence: flags 'exec 3<>path 2>/dev/null'" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty"* ]] + [[ "$output" == *"exec 3<>"* ]] + [[ "$output" == *"2>/dev/null"* ]] +} + +@test "exec-stderr-silence: flags 'exec 2>/dev/null' standalone" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"exec 2>/dev/null"* ]] +} + +@test "exec-stderr-silence: flags 'exec ... 2>&-' (close stderr)" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"2>&-"* ]] +} + +@test "exec-stderr-silence: flags 'exec ... 2>!' (noclobber override)" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"2>!"* ]] +} + +@test "exec-stderr-silence: flags exec inside a function body" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"exec 3<>\"\$tty\" 2>/dev/null"* ]] +} + +# Safe patterns — no false positives + +@test "exec-stderr-silence: does NOT flag 'exec 3<>path' without stderr redirect" { + run codebase lint:exec-stderr-silence "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"clean"* ]] +} + +# Counting + +@test "exec-stderr-silence: fail output names exec suppressions in dirty fixture" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + # The dirty fixture has these violations (excluding the annotated line 19): + # Line 5: exec 3<>"$tty_path" 2>/dev/null + # Line 10: exec 2>/dev/null cat /tmp/nope + # Line 13: exec 2>/dev/null + # Line 16: exec 3<>"$path" 2>&- + # Line 25: exec 3<>"$path" 2>!"$tty_path" + # Line 30: exec 3<>"$tty" 2>/dev/null + # = 6 violations + [[ "$output" == *"6 exec stderr suppression"* ]] +} + +# Diagnostic content + +@test "exec-stderr-silence: fail output includes the violating file path" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"probe"* ]] +} + +@test "exec-stderr-silence: fail output includes the grouping hint" { + run codebase lint:exec-stderr-silence "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"grouping construct"* ]] || [[ "$output" == *"{ exec ...; }"* ]] +} + +# Ignore directives + +@test "exec-stderr-silence: inline '# codebase:ignore exec-stderr-silence — reason' skips the line" { + run codebase lint:exec-stderr-silence "$FIXTURES/ignored-inline" + [ "$status" -ne 0 ] + # The annotated lines are skipped, but the unannotated line 13 should still be flagged + [[ "$output" == *"FAIL"*"ignored-inline"* ]] + [[ "$output" != *"intentional exec fd setup"* ]] +} + +@test "exec-stderr-silence: 'codebase:ignore exec-stderr-silence' in mise.toml skips the whole target" { + run codebase lint:exec-stderr-silence "$FIXTURES/ignored-file" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"*"ignored-file"* ]] +} + +# Scope / discovery + +@test "exec-stderr-silence: walks the whole target — finds hits outside .mise/tasks/" { + run codebase lint:exec-stderr-silence "$FIXTURES/broad-walk" + [ "$status" -ne 0 ] + [[ "$output" == *"scripts/deploy.sh"* ]] + [[ "$output" == *"bin/tool"* ]] +} + +@test "exec-stderr-silence: works on a codebase with no mise.toml" { + run codebase lint:exec-stderr-silence "$FIXTURES/no-toml" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"no-toml"* ]] +} + +@test "exec-stderr-silence: handles empty directory (no shell files)" { + run codebase lint:exec-stderr-silence "$FIXTURES/empty" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"empty"* ]] +} + +# Codebase: ignore mechanism — inline ignore without reason is not enough + +@test "exec-stderr-silence: generic inline ignore without rule and reason does not suppress" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/probe" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +exec 3<>"$path" 2>/dev/null # codebase:ignore +EOF + + run codebase lint:exec-stderr-silence "$tmp" + [ "$status" -ne 0 ] + [[ "$output" == *"exec 3<>"* ]] + rm -rf "$tmp" +} + +# Help output + +@test "exec-stderr-silence: help output describes the rule" { + run bash -c 'cd "$REPO_DIR" && mise tasks info lint:exec-stderr-silence' + [ "$status" -eq 0 ] + [[ "$output" == *"exec"* ]] + [[ "$output" == *"persists"* ]] +} \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/broad-walk/bin/tool b/test/lint/exec-stderr-silence/fixtures/broad-walk/bin/tool new file mode 100644 index 0000000..7f87d18 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/broad-walk/bin/tool @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Extensionless shebang file — broad walk must find it. +echo "ok" || : +exec 2>/dev/null \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/broad-walk/mise.toml b/test/lint/exec-stderr-silence/fixtures/broad-walk/mise.toml new file mode 100644 index 0000000..9dddbab --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/broad-walk/mise.toml @@ -0,0 +1,2 @@ +[tools] +bash = "latest" \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/broad-walk/scripts/deploy.sh b/test/lint/exec-stderr-silence/fixtures/broad-walk/scripts/deploy.sh new file mode 100644 index 0000000..53a1919 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/broad-walk/scripts/deploy.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Not under .mise/tasks/ — broad walk must still find this. +set -euo pipefail +if ! exec 3<>"$remote_path" 2>/dev/null; then + echo "probe failed" >&2 + exit 1 +fi \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/clean/.mise/tasks/probe b/test/lint/exec-stderr-silence/fixtures/clean/.mise/tasks/probe new file mode 100644 index 0000000..fb35047 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/clean/.mise/tasks/probe @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Helper — no exec redirections. + +safe_cmd() { + local arg="$1" + echo "$arg" +} + +other_cmd() { + exec 3<>"$1" + # No stderr suppression — safe +} \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/clean/mise.toml b/test/lint/exec-stderr-silence/fixtures/clean/mise.toml new file mode 100644 index 0000000..9dddbab --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/clean/mise.toml @@ -0,0 +1,2 @@ +[tools] +bash = "latest" \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/dirty/.mise/tasks/probe b/test/lint/exec-stderr-silence/fixtures/dirty/.mise/tasks/probe new file mode 100644 index 0000000..28c85ce --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/dirty/.mise/tasks/probe @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Line 5: Exec with fd redirect and stderr suppression — DANGEROUS +if ! exec 3<>"$tty_path" 2>/dev/null; then + echo "Error: confirmation required" >&2 + return 2 +fi + +# Line 10: Exec with stderr redirected to /dev/null standalone +var=$(exec 2>/dev/null cat /tmp/nope || true) + +# Line 13: Standalone exec suppressing stderr +exec 2>/dev/null + +# Line 16: Exec with close-stderr +exec 3<>"$path" 2>&- + +# Line 19: Intentional fd setup — this one should be ignored because annotated +exec 3<>/dev/tty 2>&- # codebase:ignore exec-stderr-silence — intentional fd setup + +# Line 22: This is safe — redirects stdout not stderr +exec 3<>"$path" + +# Line 25: Exec with noclobber override redirect +exec 3<>"$path" 2>!"$tty_path" + +# Line 28: Another exec with stderr to /dev/null inside function +probe_tty() { + local tty="$1" + exec 3<>"$tty" 2>/dev/null + echo "probed $tty" >&2 +} \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/dirty/mise.toml b/test/lint/exec-stderr-silence/fixtures/dirty/mise.toml new file mode 100644 index 0000000..9dddbab --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/dirty/mise.toml @@ -0,0 +1,2 @@ +[tools] +bash = "latest" \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/empty/mise.toml b/test/lint/exec-stderr-silence/fixtures/empty/mise.toml new file mode 100644 index 0000000..3a055a3 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/empty/mise.toml @@ -0,0 +1,2 @@ +[tools] +# Empty fixture — no shell files. \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/ignored-file/.mise/tasks/probe b/test/lint/exec-stderr-silence/fixtures/ignored-file/.mise/tasks/probe new file mode 100644 index 0000000..74beb76 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/ignored-file/.mise/tasks/probe @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Whole file is intentionally ignored via mise.toml +if ! exec 3<>"$tty_path" 2>/dev/null; then + echo "Error" >&2 + return 2 +fi + +exec 2>/dev/null \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/ignored-file/mise.toml b/test/lint/exec-stderr-silence/fixtures/ignored-file/mise.toml new file mode 100644 index 0000000..cdfc9f9 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/ignored-file/mise.toml @@ -0,0 +1,3 @@ +# codebase:ignore exec-stderr-silence +[tools] +bash = "latest" \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/ignored-inline/.mise/tasks/probe b/test/lint/exec-stderr-silence/fixtures/ignored-inline/.mise/tasks/probe new file mode 100644 index 0000000..840a3d2 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/ignored-inline/.mise/tasks/probe @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This usage is legitimate (intentional fd setup) and annotated. +exec 3<>"$path" 2>/dev/null # codebase:ignore exec-stderr-silence — intentional exec fd setup for subprocess + +# Also annotated +exec 2>/dev/null # codebase:ignore exec-stderr-silence — intentional: discarding stderr for a specific probe + +# But this one is NOT annotated — should still be flagged +exec 3<>"$tty" 2>/dev/null \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/ignored-inline/mise.toml b/test/lint/exec-stderr-silence/fixtures/ignored-inline/mise.toml new file mode 100644 index 0000000..9dddbab --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/ignored-inline/mise.toml @@ -0,0 +1,2 @@ +[tools] +bash = "latest" \ No newline at end of file diff --git a/test/lint/exec-stderr-silence/fixtures/no-toml/.mise/tasks/greet b/test/lint/exec-stderr-silence/fixtures/no-toml/.mise/tasks/greet new file mode 100644 index 0000000..150d440 --- /dev/null +++ b/test/lint/exec-stderr-silence/fixtures/no-toml/.mise/tasks/greet @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# No mise.toml in this fixture — rule must run without one. +echo "hi" \ No newline at end of file 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-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/mise-usage-examples/fixtures/boolean-only/.mise/tasks/verbose b/test/lint/mise-usage-examples/fixtures/boolean-only/.mise/tasks/verbose new file mode 100644 index 0000000..4cd81d0 --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/boolean-only/.mise/tasks/verbose @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +#MISE description="Enable verbose output" +#USAGE flag "--verbose" help="Enable verbose output" +set -euo pipefail +echo "Verbose mode: ${usage_verbose:-off}" \ No newline at end of file diff --git a/test/lint/mise-usage-examples/fixtures/clean/.mise/tasks/deploy b/test/lint/mise-usage-examples/fixtures/clean/.mise/tasks/deploy new file mode 100644 index 0000000..9f2acf7 --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/clean/.mise/tasks/deploy @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Deploy the app" +#USAGE flag "--env " help="Target environment" +#USAGE example "mise run deploy --env staging" header="Deploy to staging" +set -euo pipefail +echo "Deploying to ${usage_env:-production}..." \ No newline at end of file diff --git a/test/lint/mise-usage-examples/fixtures/clean/.mise/tasks/greet b/test/lint/mise-usage-examples/fixtures/clean/.mise/tasks/greet new file mode 100644 index 0000000..8bd1880 --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/clean/.mise/tasks/greet @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Greet the user" +#USAGE arg "" help="Name to greet" +#USAGE example "mise run greet Alice" header="Greet Alice" +set -euo pipefail +echo "Hello, ${usage_name}!" \ No newline at end of file diff --git a/test/lint/mise-usage-examples/fixtures/dirty/.mise/tasks/deploy b/test/lint/mise-usage-examples/fixtures/dirty/.mise/tasks/deploy new file mode 100644 index 0000000..9f7d3fa --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/dirty/.mise/tasks/deploy @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +#MISE description="Deploy the app" +#USAGE flag "--env " help="Target environment" +set -euo pipefail +echo "Deploying to ${usage_env:-production}..." \ No newline at end of file diff --git a/test/lint/mise-usage-examples/fixtures/dirty/.mise/tasks/greet b/test/lint/mise-usage-examples/fixtures/dirty/.mise/tasks/greet new file mode 100644 index 0000000..1236be6 --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/dirty/.mise/tasks/greet @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +#MISE description="Greet the user" +#USAGE arg "" help="Name to greet" +set -euo pipefail +echo "Hello, ${usage_name}!" \ No newline at end of file diff --git a/test/lint/mise-usage-examples/fixtures/empty/.mise/tasks/.gitkeep b/test/lint/mise-usage-examples/fixtures/empty/.mise/tasks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/lint/mise-usage-examples/fixtures/hidden/.mise/tasks/internal-tool b/test/lint/mise-usage-examples/fixtures/hidden/.mise/tasks/internal-tool new file mode 100644 index 0000000..7897395 --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/hidden/.mise/tasks/internal-tool @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Internal tool — hidden from --help" +#MISE hide=true +#USAGE arg "" help="Auth token" +set -euo pipefail +echo "Internal task running with token: ${usage_token}" \ No newline at end of file diff --git a/test/lint/mise-usage-examples/fixtures/ignored-inline/.mise/tasks/greet b/test/lint/mise-usage-examples/fixtures/ignored-inline/.mise/tasks/greet new file mode 100644 index 0000000..5dcd37f --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/ignored-inline/.mise/tasks/greet @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Greet the user" +#USAGE arg "" help="Name to greet" +# codebase:ignore mise-usage-examples -- intentionally bare, this is an internal wrapper +set -euo pipefail +echo "Hello, ${usage_name}!" \ No newline at end of file diff --git a/test/lint/mise-usage-examples/fixtures/ignored-repo/.mise/tasks/deploy b/test/lint/mise-usage-examples/fixtures/ignored-repo/.mise/tasks/deploy new file mode 100644 index 0000000..9f7d3fa --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/ignored-repo/.mise/tasks/deploy @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +#MISE description="Deploy the app" +#USAGE flag "--env " help="Target environment" +set -euo pipefail +echo "Deploying to ${usage_env:-production}..." \ No newline at end of file diff --git a/test/lint/mise-usage-examples/fixtures/ignored-repo/mise.toml b/test/lint/mise-usage-examples/fixtures/ignored-repo/mise.toml new file mode 100644 index 0000000..aa4ad5d --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/ignored-repo/mise.toml @@ -0,0 +1,4 @@ +[_.codebase] +lint = ["mise-settings"] + +codebase:ignore mise-usage-examples \ No newline at end of file diff --git a/test/lint/mise-usage-examples/fixtures/mixed/.mise/tasks/broken-task b/test/lint/mise-usage-examples/fixtures/mixed/.mise/tasks/broken-task new file mode 100644 index 0000000..3c566e8 --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/mixed/.mise/tasks/broken-task @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +#MISE description="Broken task — missing example" +#USAGE arg "" help="Path to process" +set -euo pipefail +echo "Processing ${usage_path}..." \ No newline at end of file diff --git a/test/lint/mise-usage-examples/fixtures/mixed/.mise/tasks/greet b/test/lint/mise-usage-examples/fixtures/mixed/.mise/tasks/greet new file mode 100644 index 0000000..8bd1880 --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/mixed/.mise/tasks/greet @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Greet the user" +#USAGE arg "" help="Name to greet" +#USAGE example "mise run greet Alice" header="Greet Alice" +set -euo pipefail +echo "Hello, ${usage_name}!" \ No newline at end of file diff --git a/test/lint/mise-usage-examples/fixtures/no-args/.mise/tasks/helper b/test/lint/mise-usage-examples/fixtures/no-args/.mise/tasks/helper new file mode 100644 index 0000000..1302be1 --- /dev/null +++ b/test/lint/mise-usage-examples/fixtures/no-args/.mise/tasks/helper @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#MISE description="A simple helper — no USAGE directives at all" +set -euo pipefail +echo "Hello" \ No newline at end of file diff --git a/test/lint/mise-usage-examples/mise-usage-examples.bats b/test/lint/mise-usage-examples/mise-usage-examples.bats new file mode 100644 index 0000000..79fe846 --- /dev/null +++ b/test/lint/mise-usage-examples/mise-usage-examples.bats @@ -0,0 +1,91 @@ +#!/usr/bin/env bats +# Tests for lint:mise-usage-examples rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +# === Detection === + +@test "mise-usage-examples: passes on a repo where all tasks with args have examples" { + run codebase lint:mise-usage-examples "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"clean"* ]] +} + +@test "mise-usage-examples: flags a task that has #USAGE arg but no #USAGE example" { + run codebase lint:mise-usage-examples "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty"* ]] + [[ "$output" == *"missing #USAGE example"* ]] +} + +@test "mise-usage-examples: flags a task that has #USAGE flag but no #USAGE example" { + run codebase lint:mise-usage-examples "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty"* ]] + [[ "$output" == *"deploy"*"missing #USAGE example"* ]] +} + +@test "mise-usage-examples: flags boolean-only flags (no placeholder) without examples" { + run codebase lint:mise-usage-examples "$FIXTURES/boolean-only" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"boolean-only"* ]] + [[ "$output" == *"verbose"*"missing #USAGE example"* ]] +} + +@test "mise-usage-examples: skips hidden tasks (#MISE hide=true) even without examples" { + run codebase lint:mise-usage-examples "$FIXTURES/hidden" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"hidden"* ]] + [[ "$output" != *"internal-tool"* ]] +} + +@test "mise-usage-examples: skips tasks with no #USAGE arg/flag at all" { + run codebase lint:mise-usage-examples "$FIXTURES/no-args" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"no-args"* ]] +} + +@test "mise-usage-examples: respects inline codebase:ignore comment" { + run codebase lint:mise-usage-examples "$FIXTURES/ignored-inline" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"ignored-inline"* ]] +} + +@test "mise-usage-examples: respects repo-level codebase:ignore in mise.toml" { + run codebase lint:mise-usage-examples "$FIXTURES/ignored-repo" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"*"ignored-repo (codebase:ignore)"* ]] +} + +@test "mise-usage-examples: passes on a repo with no .mise/tasks directory" { + run codebase lint:mise-usage-examples "$FIXTURES/no-tasks" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"no-tasks"* ]] +} + +@test "mise-usage-examples: passes on empty repo (tasks dir exists but is empty)" { + run codebase lint:mise-usage-examples "$FIXTURES/empty" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"empty"* ]] +} + +@test "mise-usage-examples: flags only the offending task when others are clean" { + run codebase lint:mise-usage-examples "$FIXTURES/mixed" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"mixed"* ]] + # The failing task + [[ "$output" == *"broken-task"* ]] + # The clean task should not appear as FAIL + [[ "$output" != *"greet"* ]] +} + +@test "mise-usage-examples: flag output includes the task path relative to .mise/tasks" { + run codebase lint:mise-usage-examples "$FIXTURES/dirty" + [ "$status" -ne 0 ] + [[ "$output" == *"greet"*"missing #USAGE example"* ]] + [[ "$output" == *"deploy"*"missing #USAGE example"* ]] +} \ No newline at end of file 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/lint/usage-flag-naming/fixtures/clean-boolean/.mise/tasks/bar b/test/lint/usage-flag-naming/fixtures/clean-boolean/.mise/tasks/bar new file mode 100644 index 0000000..6ff671d --- /dev/null +++ b/test/lint/usage-flag-naming/fixtures/clean-boolean/.mise/tasks/bar @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +#USAGE flag "--verbose" help="Enable verbose output" +echo "boolean only" diff --git a/test/lint/usage-flag-naming/fixtures/clean-match/.mise/tasks/foo b/test/lint/usage-flag-naming/fixtures/clean-match/.mise/tasks/foo new file mode 100644 index 0000000..f3c7746 --- /dev/null +++ b/test/lint/usage-flag-naming/fixtures/clean-match/.mise/tasks/foo @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +#USAGE flag "--exclude " help="Patterns to exclude" +echo "clean task" diff --git a/test/lint/usage-flag-naming/fixtures/clean-short-only/.mise/tasks/baz b/test/lint/usage-flag-naming/fixtures/clean-short-only/.mise/tasks/baz new file mode 100644 index 0000000..4e57151 --- /dev/null +++ b/test/lint/usage-flag-naming/fixtures/clean-short-only/.mise/tasks/baz @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +#USAGE flag "-v" help="Verbose mode" +echo "short only" diff --git a/test/lint/usage-flag-naming/fixtures/dirty-mismatch/.mise/tasks/scan b/test/lint/usage-flag-naming/fixtures/dirty-mismatch/.mise/tasks/scan new file mode 100644 index 0000000..811231a --- /dev/null +++ b/test/lint/usage-flag-naming/fixtures/dirty-mismatch/.mise/tasks/scan @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#USAGE flag "-e --exclude " help="Patterns to exclude" +usage_excludes="*.tmp" +echo "mismatched flag/placeholder" diff --git a/test/lint/usage-flag-naming/fixtures/dirty-multi-word/.mise/tasks/fetch b/test/lint/usage-flag-naming/fixtures/dirty-multi-word/.mise/tasks/fetch new file mode 100644 index 0000000..1f8812a --- /dev/null +++ b/test/lint/usage-flag-naming/fixtures/dirty-multi-word/.mise/tasks/fetch @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#USAGE flag "--search-path " help="Directories to search" +usage_search_paths="./src" +echo "multi-word mismatch" diff --git a/test/lint/usage-flag-naming/fixtures/ignored-file/.mise/tasks/task b/test/lint/usage-flag-naming/fixtures/ignored-file/.mise/tasks/task new file mode 100644 index 0000000..dbbadcb --- /dev/null +++ b/test/lint/usage-flag-naming/fixtures/ignored-file/.mise/tasks/task @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#USAGE flag "-e --exclude " help="Patterns to exclude" +usage_excludes="*.tmp" +echo "file-level ignored task" diff --git a/test/lint/usage-flag-naming/fixtures/ignored-file/mise.toml b/test/lint/usage-flag-naming/fixtures/ignored-file/mise.toml new file mode 100644 index 0000000..c23777d --- /dev/null +++ b/test/lint/usage-flag-naming/fixtures/ignored-file/mise.toml @@ -0,0 +1,2 @@ +[_.codebase] +codebase:ignore usage-flag-naming diff --git a/test/lint/usage-flag-naming/fixtures/ignored-inline/.mise/tasks/task b/test/lint/usage-flag-naming/fixtures/ignored-inline/.mise/tasks/task new file mode 100644 index 0000000..d30d229 --- /dev/null +++ b/test/lint/usage-flag-naming/fixtures/ignored-inline/.mise/tasks/task @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#USAGE flag "-e --exclude " help="Patterns to exclude" # codebase:ignore=usage-flag-naming -- legacy name mismatch +usage_excludes="*.tmp" +echo "inline ignore" diff --git a/test/lint/usage-flag-naming/fixtures/mixed/.mise/tasks/bad b/test/lint/usage-flag-naming/fixtures/mixed/.mise/tasks/bad new file mode 100644 index 0000000..4a5b396 --- /dev/null +++ b/test/lint/usage-flag-naming/fixtures/mixed/.mise/tasks/bad @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#USAGE flag "-e --exclude " help="Patterns to exclude" +usage_excludes="*.tmp" +echo "bad task" diff --git a/test/lint/usage-flag-naming/fixtures/mixed/.mise/tasks/good b/test/lint/usage-flag-naming/fixtures/mixed/.mise/tasks/good new file mode 100644 index 0000000..c38f82e --- /dev/null +++ b/test/lint/usage-flag-naming/fixtures/mixed/.mise/tasks/good @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#USAGE flag "--model " help="Model name" +usage_model="gpt-4" +echo "good task" diff --git a/test/lint/usage-flag-naming/fixtures/multi-flag/.mise/tasks/task b/test/lint/usage-flag-naming/fixtures/multi-flag/.mise/tasks/task new file mode 100644 index 0000000..dee42f8 --- /dev/null +++ b/test/lint/usage-flag-naming/fixtures/multi-flag/.mise/tasks/task @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#USAGE flag "--verbose" +#USAGE flag "--name " help="Name to use" +usage_verbose=true +usage_name="default" +echo "multi-flag task" diff --git a/test/lint/usage-flag-naming/usage-flag-naming.bats b/test/lint/usage-flag-naming/usage-flag-naming.bats new file mode 100644 index 0000000..bb5d9a0 --- /dev/null +++ b/test/lint/usage-flag-naming/usage-flag-naming.bats @@ -0,0 +1,159 @@ +#!/usr/bin/env bats +# Tests for lint:usage-flag-naming rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +# Clean cases — should pass + +@test "usage-flag-naming: passes on clean task (flag/placeholder match)" { + run codebase lint:usage-flag-naming "$FIXTURES/clean-match" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "usage-flag-naming: passes on boolean flag (no placeholder)" { + run codebase lint:usage-flag-naming "$FIXTURES/clean-boolean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "usage-flag-naming: passes on short-only flag (no long name)" { + run codebase lint:usage-flag-naming "$FIXTURES/clean-short-only" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "usage-flag-naming: passes on multi-flag task when all match" { + run codebase lint:usage-flag-naming "$FIXTURES/multi-flag" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "usage-flag-naming: passes on target with no .mise/tasks directory" { + run codebase lint:usage-flag-naming "$FIXTURES/no-tasks" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +# Dirty cases — should fail + +@test "usage-flag-naming: flags mismatched flag/placeholder" { + run codebase lint:usage-flag-naming "$FIXTURES/dirty-mismatch" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-mismatch"* ]] + # --exclude (usage_exclude) vs (usage_excludes) + [[ "$output" == *"--exclude"* ]] + [[ "$output" == *"usage_exclude"* ]] + [[ "$output" == *"excludes"* ]] + [[ "$output" == *"usage_excludes"* ]] +} + +@test "usage-flag-naming: flags multi-word placeholder mismatch" { + run codebase lint:usage-flag-naming "$FIXTURES/dirty-multi-word" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-multi-word"* ]] + # --search-path (usage_search_path) vs (usage_search_paths) + [[ "$output" == *"search-path"* ]] + [[ "$output" == *"usage_search_path"* ]] + [[ "$output" == *"search-paths"* ]] + [[ "$output" == *"usage_search_paths"* ]] +} + +# Mixed cases + +@test "usage-flag-naming: flags only dirty tasks in mixed target" { + run codebase lint:usage-flag-naming "$FIXTURES/mixed" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"mixed"* ]] + # Only the bad task should be flagged, good task should not appear in findings + [[ "$output" == *".mise/tasks/bad"* ]] + [[ "$output" != *".mise/tasks/good"* ]] +} + +# Ignore mechanisms + +@test "usage-flag-naming: respects inline codebase:ignore" { + run codebase lint:usage-flag-naming "$FIXTURES/ignored-inline" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "usage-flag-naming: respects file-level codebase:ignore in mise.toml" { + run codebase lint:usage-flag-naming "$FIXTURES/ignored-file" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"* ]] +} + +# Output format + +@test "usage-flag-naming: output includes task file path and line number" { + run codebase lint:usage-flag-naming "$FIXTURES/dirty-mismatch" + [ "$status" -ne 0 ] + [[ "$output" == *".mise/tasks/scan:"* ]] + [[ "$output" == *"2:"* ]] # line 2 has the #USAGE flag directive (line 1 is shebang) +} + +@test "usage-flag-naming: output includes hint for fix" { + run codebase lint:usage-flag-naming "$FIXTURES/dirty-mismatch" + [ "$status" -ne 0 ] + [[ "$output" == *"hint"* ]] + [[ "$output" == *"mise"* ]] +} + +# Edge cases + +@test "usage-flag-naming: handles empty target directory" { + run codebase lint:usage-flag-naming "$FIXTURES/no-tasks" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] +} + +@test "usage-flag-naming: does not self-flag on own fixtures tree" { + # When scanning the codebase repo with this rule, the rule's own fixture + # directories contain intentional mismatches under .mise/tasks/. The fd + # --exclude fixtures flag skips subdirectories named fixtures/ within the + # tasks tree, so self-hosting should pass clean. This test verifies that + # behavior by scanning the entire fixtures tree. Note: this passes because + # fd --exclude fixtures skips the actual dirty fixture content; if it ever + # fails, the exclusion mechanism broke. + run codebase lint:usage-flag-naming "$FIXTURES" + [ "$status" -eq 0 ] +} + +@test "usage-flag-naming: does NOT flag matching hyphens (file-path → file_path)" { + # --file-path should match: both normalize to usage_file_path. + local tmpdir + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/.mise/tasks" + cat > "$tmpdir/.mise/tasks/hyphen-match" << 'TASK' +#!/usr/bin/env bash +#USAGE flag "--file-path " help="File path" +echo "hyphen match" +TASK + + run codebase lint:usage-flag-naming "$tmpdir" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + rm -rf "$tmpdir" +} + +@test "usage-flag-naming: does NOT flag matching plural (exclude → exclude)" { + # --exclude should match: both normalize to usage_exclude. + local tmpdir + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/.mise/tasks" + cat > "$tmpdir/.mise/tasks/plural-match" << 'TASK' +#!/usr/bin/env bash +#USAGE flag "--exclude " help="Patterns to exclude" +echo "plural match" +TASK + + run codebase lint:usage-flag-naming "$tmpdir" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + rm -rf "$tmpdir" +} diff --git a/test/lint/variadic-args/fixtures/clean/.mise/tasks/my-task b/test/lint/variadic-args/fixtures/clean/.mise/tasks/my-task new file mode 100644 index 0000000..78ff63c --- /dev/null +++ b/test/lint/variadic-args/fixtures/clean/.mise/tasks/my-task @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +#MISE description="Good task — uses xargs pattern" +#USAGE arg "[args]..." var=#true help="Arguments" +set -euo pipefail + +ARGS=() +if [ -n "${usage_args:-}" ]; then + while IFS= read -r arg; do + ARGS+=("$arg") + done < <(printf '%s' "$usage_args" | xargs printf '%s\n') +fi + +echo "${ARGS[@]}" diff --git a/test/lint/variadic-args/fixtures/dirty-eval/.mise/tasks/my-task b/test/lint/variadic-args/fixtures/dirty-eval/.mise/tasks/my-task new file mode 100644 index 0000000..fd229fe --- /dev/null +++ b/test/lint/variadic-args/fixtures/dirty-eval/.mise/tasks/my-task @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +#MISE description="Bad task — uses eval" +#USAGE arg "[args]..." var=#true help="Arguments" +set -euo pipefail + +eval "ARGS=(${usage_args:-})" + +echo "${ARGS[@]}" diff --git a/test/lint/variadic-args/fixtures/dirty-read-ra/.mise/tasks/my-task b/test/lint/variadic-args/fixtures/dirty-read-ra/.mise/tasks/my-task new file mode 100644 index 0000000..a19c6ac --- /dev/null +++ b/test/lint/variadic-args/fixtures/dirty-read-ra/.mise/tasks/my-task @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +#MISE description="Bad task — uses read -ra" +#USAGE flag "--query " var=#true help="Search queries" +set -euo pipefail + +read -ra QUERIES <<< "$usage_query" + +for q in "${QUERIES[@]}"; do + echo "query: $q" +done diff --git a/test/lint/variadic-args/fixtures/ignored-file/.mise/tasks/my-task b/test/lint/variadic-args/fixtures/ignored-file/.mise/tasks/my-task new file mode 100644 index 0000000..0f73394 --- /dev/null +++ b/test/lint/variadic-args/fixtures/ignored-file/.mise/tasks/my-task @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="This task has violations but is file-ignored" +#USAGE arg "[args]..." var=#true help="Arguments" +set -euo pipefail + +eval "ARGS=(${usage_args:-})" diff --git a/test/lint/variadic-args/fixtures/ignored-file/mise.toml b/test/lint/variadic-args/fixtures/ignored-file/mise.toml new file mode 100644 index 0000000..b84bd14 --- /dev/null +++ b/test/lint/variadic-args/fixtures/ignored-file/mise.toml @@ -0,0 +1,5 @@ +[tools] +bash = "latest" + +[_.codebase] +codebase:ignore variadic-args diff --git a/test/lint/variadic-args/fixtures/ignored-inline/.mise/tasks/count b/test/lint/variadic-args/fixtures/ignored-inline/.mise/tasks/count new file mode 100644 index 0000000..0bd55f9 --- /dev/null +++ b/test/lint/variadic-args/fixtures/ignored-inline/.mise/tasks/count @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Has inline ignore" +#USAGE flag "--query " var=#true help="Search queries" +set -euo pipefail + +read -ra QUERIES <<< "$usage_query" # codebase:ignore variadic-args — intentional, only single-word queries diff --git a/test/lint/variadic-args/fixtures/mixed/.mise/tasks/search b/test/lint/variadic-args/fixtures/mixed/.mise/tasks/search new file mode 100644 index 0000000..fd26f40 --- /dev/null +++ b/test/lint/variadic-args/fixtures/mixed/.mise/tasks/search @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +#MISE description="Mixed — one good, one bad" +#USAGE flag "--query " var=#true help="Search queries" +#USAGE flag "--exclude " var=#true help="Exclusion patterns" +set -euo pipefail + +# Good: uses xargs pattern for --exclude +EXCLUDE=() +if [ -n "${usage_exclude:-}" ]; then + while IFS= read -r arg; do + EXCLUDE+=("$arg") + done < <(printf '%s' "$usage_exclude" | xargs printf '%s\n') +fi + +# Bad: uses read -ra for --query (loses quoting) +read -ra QUERIES <<< "$usage_query" + +for q in "${QUERIES[@]}"; do + echo "searching: $q" +done diff --git a/test/lint/variadic-args/fixtures/multi-task/.mise/tasks/clean-task b/test/lint/variadic-args/fixtures/multi-task/.mise/tasks/clean-task new file mode 100644 index 0000000..78ff63c --- /dev/null +++ b/test/lint/variadic-args/fixtures/multi-task/.mise/tasks/clean-task @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +#MISE description="Good task — uses xargs pattern" +#USAGE arg "[args]..." var=#true help="Arguments" +set -euo pipefail + +ARGS=() +if [ -n "${usage_args:-}" ]; then + while IFS= read -r arg; do + ARGS+=("$arg") + done < <(printf '%s' "$usage_args" | xargs printf '%s\n') +fi + +echo "${ARGS[@]}" diff --git a/test/lint/variadic-args/fixtures/multi-task/.mise/tasks/dirty-task b/test/lint/variadic-args/fixtures/multi-task/.mise/tasks/dirty-task new file mode 100644 index 0000000..fd229fe --- /dev/null +++ b/test/lint/variadic-args/fixtures/multi-task/.mise/tasks/dirty-task @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +#MISE description="Bad task — uses eval" +#USAGE arg "[args]..." var=#true help="Arguments" +set -euo pipefail + +eval "ARGS=(${usage_args:-})" + +echo "${ARGS[@]}" diff --git a/test/lint/variadic-args/fixtures/no-tasks/.gitkeep b/test/lint/variadic-args/fixtures/no-tasks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/lint/variadic-args/fixtures/no-variadic/.mise/tasks/my-task b/test/lint/variadic-args/fixtures/no-variadic/.mise/tasks/my-task new file mode 100644 index 0000000..0778adb --- /dev/null +++ b/test/lint/variadic-args/fixtures/no-variadic/.mise/tasks/my-task @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +#MISE description="No variadic — read -ra on non-variadic var" +#USAGE flag "--name " help="A single name (not variadic)" +set -euo pipefail + +read -ra PARTS <<< "$usage_name" +echo "first part: ${PARTS[0]}" diff --git a/test/lint/variadic-args/variadic-args.bats b/test/lint/variadic-args/variadic-args.bats new file mode 100755 index 0000000..15cd4c9 --- /dev/null +++ b/test/lint/variadic-args/variadic-args.bats @@ -0,0 +1,199 @@ +#!/usr/bin/env bats +# Tests for lint:variadic-args rule + +load ../../test_helper + +setup() { + FIXTURES="$BATS_TEST_DIRNAME/fixtures" +} + +# === Pass paths === + +@test "variadic-args: passes on task using xargs pattern (correct)" { + run codebase lint:variadic-args "$FIXTURES/clean" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"clean"* ]] +} + +@test "variadic-args: passes on task with no variadic directives (no false positives)" { + run codebase lint:variadic-args "$FIXTURES/no-variadic" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"no-variadic"* ]] +} + +@test "variadic-args: passes on target with no .mise/tasks/ (nothing to check)" { + run codebase lint:variadic-args "$FIXTURES/no-tasks" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"no-tasks"* ]] + [[ "$output" == *"no .mise/tasks/ files found"* ]] +} + +@test "variadic-args: does NOT flag read -ra on non-variadic var (no false positive)" { + # no-variadic fixture has a read -ra on $usage_name which is NOT declared + # with var=#true. The rule should not flag it. + run codebase lint:variadic-args "$FIXTURES/no-variadic" + [ "$status" -eq 0 ] + [[ "$output" != *"read -ra"* ]] +} + +# === Failure modes === + +@test "variadic-args: flags eval ARGS=(\${usage_args:-}) as ERROR" { + run codebase lint:variadic-args "$FIXTURES/dirty-eval" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-eval"* ]] + [[ "$output" == *"ERROR: eval is a shell injection vector"* ]] +} + +@test "variadic-args: flags read -ra <<< \"\$usage_query\" as WARN" { + run codebase lint:variadic-args "$FIXTURES/dirty-read-ra" + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"*"dirty-read-ra"* ]] + [[ "$output" == *"WARN: read -ra loses quoting"* ]] +} + +@test "variadic-args: flags eval on variadic flag (not just positional arg)" { + # dirty-read-ra uses a flag --query with var=#true, not a positional arg + run codebase lint:variadic-args "$FIXTURES/dirty-read-ra" + [ "$status" -ne 0 ] + [[ "$output" == *"usage_query"* ]] +} + +@test "variadic-args: reports correct line numbers" { + run codebase lint:variadic-args "$FIXTURES/dirty-eval" + [ "$status" -ne 0 ] + # The eval is on line 6 of the fixture + [[ "$output" == *"my-task:6"* ]] +} + +@test "variadic-args: only flags variadic-var consumption, not non-variadic" { + # mixed fixture has --exclude variadic used correctly (xargs pattern) + # and --query variadic used with read -ra. Only the read -ra should be flagged. + run codebase lint:variadic-args "$FIXTURES/mixed" + [ "$status" -ne 0 ] + [[ "$output" == *"1 dangerous variadic-arg consumption"* ]] + [[ "$output" == *"usage_query"* ]] + [[ "$output" != *"usage_exclude"* ]] +} + +@test "variadic-args: handles mix of clean and dirty tasks in one target" { + run codebase lint:variadic-args "$FIXTURES/multi-task" + [ "$status" -ne 0 ] + [[ "$output" == *"1 dangerous variadic-arg consumption"* ]] + [[ "$output" == *"dirty-task"* ]] + [[ "$output" != *"clean-task"* ]] +} + +# === Ignore mechanisms === + +@test "variadic-args: respects inline # codebase:ignore — reason" { + run codebase lint:variadic-args "$FIXTURES/ignored-inline" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"*"ignored-inline"* ]] +} + +@test "variadic-args: respects file-level codebase:ignore in mise.toml" { + run codebase lint:variadic-args "$FIXTURES/ignored-file" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP"*"ignored-file"* ]] +} + +# === Multi-target === + +@test "variadic-args: checks multiple targets and reports each" { + run codebase lint:variadic-args "$FIXTURES/clean" "$FIXTURES/dirty-eval" + [ "$status" -ne 0 ] + [[ "$output" == *"OK"*"clean"* ]] + [[ "$output" == *"FAIL"*"dirty-eval"* ]] +} + +@test "variadic-args: exit code is the number of failing targets" { + run codebase lint:variadic-args "$FIXTURES/dirty-eval" "$FIXTURES/dirty-read-ra" + [ "$status" -eq 2 ] +} + +# === Error paths === + +@test "variadic-args: fails when target does not exist" { + run codebase lint:variadic-args "$FIXTURES/does-not-exist" + [ "$status" -ne 0 ] + [[ "$output" == *"does not exist"* ]] +} + +@test "variadic-args: fails when no targets given" { + run codebase lint:variadic-args + [ "$status" -ne 0 ] + [[ "$output" == *"Missing required arg"* ]] + [[ "$output" == *""* ]] +} + +# === Edge cases === + +@test "variadic-args: does NOT flag eval/read -ra on non-variadic env vars" { + # no-variadic fixture has read -ra on $usage_name which is not variadic + run codebase lint:variadic-args "$FIXTURES/no-variadic" + [ "$status" -eq 0 ] +} + +@test "variadic-args: handles usage_args positional arg variadic" { + # Test that positional args with var=#true are also checked + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/foo" <<'EOF' +#!/usr/bin/env bash +#USAGE arg "[args]..." var=#true help="Args" +set -euo pipefail +read -ra ARGS <<< "$usage_args" +echo "${ARGS[@]}" +EOF + + run codebase lint:variadic-args "$tmp" + [ "$status" -ne 0 ] + [[ "$output" == *"read -ra"* ]] + [[ "$output" == *"usage_args"* ]] + rm -rf "$tmp" +} + +@test "variadic-args: passes on file with variadic arg used via xargs (correct pattern)" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/foo" <<'EOF' +#!/usr/bin/env bash +#USAGE flag "--name " var=#true help="Names" +set -euo pipefail + +NAMES=() +if [ -n "${usage_name:-}" ]; then + while IFS= read -r name; do + NAMES+=("$name") + done < <(printf '%s' "$usage_name" | xargs printf '%s\n') +fi +EOF + + run codebase lint:variadic-args "$tmp" + [ "$status" -eq 0 ] + [[ "$output" == *"OK"* ]] + rm -rf "$tmp" +} + +# === Relative path resolution (regression: codebase#24) === + +@test "variadic-args: relative path resolves against CODEBASE_CALLER_PWD, not codebase install dir" { + local tmp + tmp=$(mktemp -d) + mkdir -p "$tmp/.mise/tasks" + cat > "$tmp/.mise/tasks/t" <<'EOF' +#!/usr/bin/env bash +#USAGE arg "[args]..." var=#true help="Args" +set -euo pipefail +eval "ARGS=(${usage_args:-})" +EOF + + CODEBASE_CALLER_PWD="$tmp" run codebase lint:variadic-args . + [ "$status" -ne 0 ] + [[ "$output" == *"FAIL"* ]] + [[ "$output" == *"eval"* ]] + rm -rf "$tmp" +} 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"