From 8525603a628bdd31398f3935b29f24a3fde4810b Mon Sep 17 00:00:00 2001 From: olavostauros Date: Mon, 22 Jun 2026 14:38:30 -0300 Subject: [PATCH 01/11] fix: support multi-path scope values in [_.codebase.scope] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codebase_target_for_rule() function treated the entire scope value as a single path, so a TOML string like or-true = ".mise/tasks lib" would produce a single target /repo/.mise/tasks lib (with space) instead of two separate targets /repo/.mise/tasks and /repo/lib. This meant multi-path scope overrides either errored (path doesn't exist) or silently scanned nothing — users thought they had multi-path coverage but got false passes. Changes: - lib/codebase-config.sh: Replace codebase_target_for_rule() with codebase_targets_for_rule() that splits the scope value on whitespace and emits one target line per token. - .mise/tasks/lint/_default: Iterate over the multi-line output so each target is linted independently. - test/lint/default.bats: Add test covering multi-path scope with space-separated path list. The fix uses shell word-splitting on the scope string (unquoted in a for loop), which naturally handles TOML string values with internal spaces. Each token is resolved as a relative path against the repo root, or passed through unchanged if absolute. Fixes #22 --- .mise/tasks/lint/_default | 37 ++++++++++++++++++++++--------------- lib/codebase-config.sh | 24 +++++++++++++++++------- test/lint/default.bats | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 22 deletions(-) diff --git a/.mise/tasks/lint/_default b/.mise/tasks/lint/_default index 297abc7..f7c1dfb 100755 --- a/.mise/tasks/lint/_default +++ b/.mise/tasks/lint/_default @@ -36,24 +36,31 @@ failures=0 FAILED=() for rule in "${RULES[@]}"; do - rule_target=$(codebase_target_for_rule "$REPO_ROOT" "$rule") - echo "codebase: lint:$rule $rule_target" + # 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") - output="" - if output=$(mise run -q "lint:$rule" "$rule_target" 2>&1); then - status=0 - else - status=$? - fi + for rule_target in "${TARGETS[@]}"; do + echo "codebase: lint:$rule $rule_target" - if [[ -n "$output" ]]; then - printf '%s\n' "$output" - fi + output="" + if output=$(mise run -q "lint:$rule" "$rule_target" 2>&1); then + status=0 + else + status=$? + fi - if [[ "$status" -ne 0 ]]; then - failures=$((failures + 1)) - FAILED+=("lint:$rule ($rule_target) exited $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/lib/codebase-config.sh b/lib/codebase-config.sh index 2d43820..7b917a1 100644 --- a/lib/codebase-config.sh +++ b/lib/codebase-config.sh @@ -109,19 +109,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/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] From d98a783545ccf68d3a2b24d33b14ed40c25f85cd Mon Sep 17 00:00:00 2001 From: olavostauros Date: Thu, 25 Jun 2026 13:28:24 -0300 Subject: [PATCH 02/11] feat(lint): add lint groups for standard convention bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class lint groups/presets so repos can opt into convention bundles with a single @maintained-tool reference instead of enumerating every lint rule individually. Includes: - lib/lint-groups.sh — built-in group registry, expansion, and discoverability functions - lib/codebase-config.sh — source lint-groups.sh, expand @group references in codebase_configured_lint_rules(), support lint_exclude - .mise/tasks/lint/_default — show expanded group count in preamble; updated error hint to mention groups - .mise/tasks/lint/groups — new lint:groups discoverability task - test/lint/lint-groups/ — 17 BATS tests Closes #69 --- .mise/tasks/lint/_default | 14 ++ .mise/tasks/lint/groups | 53 +++++ lib/codebase-config.sh | 54 +++++- lib/lint-groups.sh | 99 ++++++++++ test/lint/lint-groups/lint-groups.bats | 258 +++++++++++++++++++++++++ 5 files changed, 474 insertions(+), 4 deletions(-) create mode 100755 .mise/tasks/lint/groups create mode 100644 lib/lint-groups.sh create mode 100644 test/lint/lint-groups/lint-groups.bats diff --git a/.mise/tasks/lint/_default b/.mise/tasks/lint/_default index f7c1dfb..bc9aafa 100755 --- a/.mise/tasks/lint/_default +++ b/.mise/tasks/lint/_default @@ -19,6 +19,13 @@ if [[ ! -f "$TOML" ]]; then exit 1 fi +# Check if the original config uses groups, for preamble display. +ORIG_LINT="$(mise config get -f "$TOML" _.codebase.lint 2>/dev/null || true)" +HAS_GROUPS=false +if [[ "$ORIG_LINT" == *"@"* ]]; then + HAS_GROUPS=true +fi + RULES=() while IFS= read -r rule; do [[ -n "$rule" ]] && RULES+=("$rule") @@ -29,9 +36,16 @@ 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=() 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/lib/codebase-config.sh b/lib/codebase-config.sh index 7b917a1..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 diff --git a/lib/lint-groups.sh b/lib/lint-groups.sh new file mode 100644 index 0000000..9fd2274 --- /dev/null +++ b/lib/lint-groups.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Built-in lint group definitions and expansion helpers. +# +# Groups are named bundles of lint rules that repos can reference with +# the @prefix, e.g. lint = ["@maintained-tool"]. This eliminates the +# need to enumerate every convention rule in every repo's mise.toml. +# +# To add a new group: +# 1. Define it in _CODEBASE_LINT_GROUPS below +# 2. List its rules (lowercase-hyphenated, matching .mise/tasks/lint/) +# 3. Update docs in AGENTS.md and/or README +# +# To add a new rule to an existing group: +# 1. Add the rule name to the group's string +# 2. Ensure member repos can opt out via [_.codebase].lint_exclude if needed + +# === Built-in group definitions === + +declare -A _CODEBASE_LINT_GROUPS + +# @maintained-tool — the standard convention bundle for maintained KKL +# tool repos (CLI tools, libraries, SDK wrappers). Includes all shared +# conventions that every maintained-tool repo should follow. +_CODEBASE_LINT_GROUPS["@maintained-tool"]="mise-settings gum-table bats-test-helper bats-test-task mcr-scope or-true shellcheck caller-pwd-contract github-actions" + +# Future groups: +# _CODEBASE_LINT_GROUPS["@minimal"]="mise-settings shellcheck" +# _CODEBASE_LINT_GROUPS["@ci-only"]="github-actions mise-settings" + +# === Public API === + +# codebase_expand_lint_groups ... +# +# Accepts one or more lint entries (rule names or @group references). +# Emits concrete rule names, one per line, expanding @-groups inline. +# Unknown groups produce an ERROR message on stderr and return 1. +# Duplicates are preserved (caller may deduplicate if needed). +codebase_expand_lint_groups() { + local entry expanded rules + + for entry in "$@"; do + if [[ "$entry" == @* ]]; then + expanded="${_CODEBASE_LINT_GROUPS[$entry]:-}" + if [[ -z "$expanded" ]]; then + echo "ERROR: unknown lint group '$entry'" >&2 + printf ' known groups:' >&2 + local g + for g in "${!_CODEBASE_LINT_GROUPS[@]}"; do + printf ' %s' "$g" >&2 + done + echo >&2 + return 1 + fi + # Word-split the expanded string: each token is a rule name. + # shellcheck disable=SC2086 — intentional word-splitting + for _rule in $expanded; do + printf '%s\n' "$_rule" + done + else + printf '%s\n' "$entry" + fi + done +} + +# codebase_available_groups +# +# Emit all known group names, one per line, suitable for help display. +codebase_available_groups() { + local name + for name in "${!_CODEBASE_LINT_GROUPS[@]}"; do + printf '%s\n' "$name" + done +} + +# codebase_group_members +# +# Emit the rule names belonging to a group, one per line. +# Returns 1 if the group is unknown. +codebase_group_members() { + local group="$1" + local members="${_CODEBASE_LINT_GROUPS[$group]:-}" + if [[ -z "$members" ]]; then + return 1 + fi + printf '%s\n' $members +} + +# codebase_has_group_reference ... +# +# Returns 0 if any entry starts with @, 1 otherwise. +codebase_has_group_reference() { + local entry + for entry in "$@"; do + if [[ "$entry" == @* ]]; then + return 0 + fi + done + return 1 +} \ No newline at end of file diff --git a/test/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 From b5040ecf53e018ebb7eec4a5bb5f5d5c025bf849 Mon Sep 17 00:00:00 2001 From: olavostauros Date: Tue, 23 Jun 2026 17:26:28 -0300 Subject: [PATCH 03/11] feat: add lint rule for Bash nounset empty array expansions Adds bash-empty-array-expansions lint rule that flags ${array[@]} and ${array[*]} under set -u (nounset), suggesting the Bash-3-safe alternate-value form ${arr[@]+"${arr[@]}"}. Shares nounset detection pattern with bash-empty-argv-forwarding (#48): - has_nounset() - detect set -u / set -eu / set -euo pipefail - is_safe_context() - exclude for loops and local array assignments - has_safe_form() - exclude already-safe alternate-value forms Standard text-based ripgrep approach with 11 fixture scenarios and 15 BATS tests. ShellCheck-clean. Implements #57 (KnickKnackLabs/codebase) --- .mise/tasks/lint/bash-empty-array-expansions | 168 ++++++++++++++++++ .mise/tasks/lint/github-actions | 2 +- .mise/tasks/lint/mise-settings | 2 +- .mise/tasks/lint/shellcheck | 2 +- .../bash-empty-array-expansions.bats | 155 ++++++++++++++++ .../fixtures/already-safe/.mise/tasks/t | 7 + .../fixtures/already-safe/mise.toml | 2 + .../fixtures/broad-walk/lib/helper.sh | 6 + .../fixtures/broad-walk/mise.toml | 2 + .../fixtures/clean/.mise/tasks/t | 4 + .../fixtures/clean/mise.toml | 3 + .../fixtures/dirty-multiple/.mise/tasks/t | 10 ++ .../fixtures/dirty-multiple/mise.toml | 2 + .../fixtures/dirty-star/.mise/tasks/t | 8 + .../fixtures/dirty-star/mise.toml | 2 + .../fixtures/dirty/.mise/tasks/t | 8 + .../fixtures/dirty/mise.toml | 2 + .../fixtures/ignored-file/.mise/tasks/t | 6 + .../fixtures/ignored-file/mise.toml | 1 + .../fixtures/ignored-inline/.mise/tasks/t | 6 + .../fixtures/ignored-inline/mise.toml | 2 + .../fixtures/no-nounset/.mise/tasks/t | 4 + .../fixtures/no-nounset/mise.toml | 2 + .../fixtures/no-toml/.mise/tasks/t | 4 + .../fixtures/safe-context-local/.mise/tasks/t | 8 + .../fixtures/safe-context-local/mise.toml | 2 + .../fixtures/safe-context/.mise/tasks/t | 7 + .../fixtures/safe-context/mise.toml | 2 + .../bats-test-task/fixtures/clean/mise.toml | 2 +- 29 files changed, 427 insertions(+), 4 deletions(-) create mode 100755 .mise/tasks/lint/bash-empty-array-expansions create mode 100644 test/lint/bash-empty-array-expansions/bash-empty-array-expansions.bats create mode 100644 test/lint/bash-empty-array-expansions/fixtures/already-safe/.mise/tasks/t create mode 100644 test/lint/bash-empty-array-expansions/fixtures/already-safe/mise.toml create mode 100644 test/lint/bash-empty-array-expansions/fixtures/broad-walk/lib/helper.sh create mode 100644 test/lint/bash-empty-array-expansions/fixtures/broad-walk/mise.toml create mode 100644 test/lint/bash-empty-array-expansions/fixtures/clean/.mise/tasks/t create mode 100644 test/lint/bash-empty-array-expansions/fixtures/clean/mise.toml create mode 100644 test/lint/bash-empty-array-expansions/fixtures/dirty-multiple/.mise/tasks/t create mode 100644 test/lint/bash-empty-array-expansions/fixtures/dirty-multiple/mise.toml create mode 100644 test/lint/bash-empty-array-expansions/fixtures/dirty-star/.mise/tasks/t create mode 100644 test/lint/bash-empty-array-expansions/fixtures/dirty-star/mise.toml create mode 100644 test/lint/bash-empty-array-expansions/fixtures/dirty/.mise/tasks/t create mode 100644 test/lint/bash-empty-array-expansions/fixtures/dirty/mise.toml create mode 100644 test/lint/bash-empty-array-expansions/fixtures/ignored-file/.mise/tasks/t create mode 100644 test/lint/bash-empty-array-expansions/fixtures/ignored-file/mise.toml create mode 100644 test/lint/bash-empty-array-expansions/fixtures/ignored-inline/.mise/tasks/t create mode 100644 test/lint/bash-empty-array-expansions/fixtures/ignored-inline/mise.toml create mode 100644 test/lint/bash-empty-array-expansions/fixtures/no-nounset/.mise/tasks/t create mode 100644 test/lint/bash-empty-array-expansions/fixtures/no-nounset/mise.toml create mode 100644 test/lint/bash-empty-array-expansions/fixtures/no-toml/.mise/tasks/t create mode 100644 test/lint/bash-empty-array-expansions/fixtures/safe-context-local/.mise/tasks/t create mode 100644 test/lint/bash-empty-array-expansions/fixtures/safe-context-local/mise.toml create mode 100644 test/lint/bash-empty-array-expansions/fixtures/safe-context/.mise/tasks/t create mode 100644 test/lint/bash-empty-array-expansions/fixtures/safe-context/mise.toml diff --git a/.mise/tasks/lint/bash-empty-array-expansions b/.mise/tasks/lint/bash-empty-array-expansions new file mode 100755 index 0000000..ddfcd6c --- /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" \ No newline at end of file diff --git a/.mise/tasks/lint/github-actions b/.mise/tasks/lint/github-actions index ae3e426..092b71d 100755 --- a/.mise/tasks/lint/github-actions +++ b/.mise/tasks/lint/github-actions @@ -222,7 +222,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/mise-settings b/.mise/tasks/lint/mise-settings index aa41c08..1c9489b 100755 --- a/.mise/tasks/lint/mise-settings +++ b/.mise/tasks/lint/mise-settings @@ -77,7 +77,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/shellcheck b/.mise/tasks/lint/shellcheck index 222f0e4..ecffa32 100755 --- a/.mise/tasks/lint/shellcheck +++ b/.mise/tasks/lint/shellcheck @@ -69,7 +69,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/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..ee7c90b --- /dev/null +++ b/test/lint/bash-empty-array-expansions/bash-empty-array-expansions.bats @@ -0,0 +1,155 @@ +#!/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" +} \ No newline at end of file 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..bbbbe8a --- /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 \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/already-safe/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..559c21e --- /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[@]}" \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/broad-walk/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..54f633b --- /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 \ No newline at end of file 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..a73b57f --- /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" \ No newline at end of file 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..c078ae0 --- /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[*]}" \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty-multiple/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..a9907e8 --- /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 \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty-star/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..c0ce9a2 --- /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 \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..642c952 --- /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[*]}" \ No newline at end of file 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..89e6b9c --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/ignored-file/mise.toml @@ -0,0 +1 @@ +codebase:ignore bash-empty-array-expansions \ No newline at end of file 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..74cfad0 --- /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 \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/ignored-inline/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..b3e9007 --- /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[*]}" \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/no-nounset/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..852405e --- /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" \ No newline at end of file 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..a39a476 --- /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 \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/safe-context-local/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..222668b --- /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 \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-array-expansions/fixtures/safe-context/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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 From d29959d976302fc4987cb6462ce66e16aef1b12f Mon Sep 17 00:00:00 2001 From: olavostauros Date: Wed, 24 Jun 2026 10:31:18 -0300 Subject: [PATCH 04/11] style: collapse 3-line section comment blocks to single line --- .mise/tasks/lint/bash-empty-array-expansions | 2 +- test/lib/shell-files.bats | 2 -- .../bash-empty-array-expansions.bats | 12 +----------- .../fixtures/already-safe/.mise/tasks/t | 2 +- .../fixtures/already-safe/mise.toml | 2 +- .../fixtures/broad-walk/lib/helper.sh | 2 +- .../fixtures/broad-walk/mise.toml | 2 +- .../fixtures/clean/.mise/tasks/t | 2 +- .../fixtures/clean/mise.toml | 2 +- .../fixtures/dirty-multiple/.mise/tasks/t | 2 +- .../fixtures/dirty-multiple/mise.toml | 2 +- .../fixtures/dirty-star/.mise/tasks/t | 2 +- .../fixtures/dirty-star/mise.toml | 2 +- .../fixtures/dirty/.mise/tasks/t | 2 +- .../fixtures/dirty/mise.toml | 2 +- .../fixtures/ignored-file/.mise/tasks/t | 2 +- .../fixtures/ignored-file/mise.toml | 2 +- .../fixtures/ignored-inline/.mise/tasks/t | 2 +- .../fixtures/ignored-inline/mise.toml | 2 +- .../fixtures/no-nounset/.mise/tasks/t | 2 +- .../fixtures/no-nounset/mise.toml | 2 +- .../fixtures/no-toml/.mise/tasks/t | 2 +- .../fixtures/safe-context-local/.mise/tasks/t | 2 +- .../fixtures/safe-context-local/mise.toml | 2 +- .../fixtures/safe-context/.mise/tasks/t | 2 +- .../fixtures/safe-context/mise.toml | 2 +- .../bats-test-helper/bats-test-helper.bats | 14 -------------- test/lint/bats-test-task/bats-test-task.bats | 12 ------------ test/lint/gum-table/gum-table.bats | 16 ---------------- test/lint/mcr-scope/mcr-scope.bats | 10 ---------- test/lint/mise-settings/mise-settings.bats | 6 ------ test/lint/or-true/or-true.bats | 18 ------------------ test/lint/shellcheck/shellcheck.bats | 10 ---------- test/migrations/task-pattern/task-pattern.bats | 10 ---------- test/pre-commit/pre-commit.bats | 16 ---------------- test/scan/scan.bats | 10 ---------- 36 files changed, 25 insertions(+), 159 deletions(-) mode change 100755 => 100644 .mise/tasks/lint/bash-empty-array-expansions diff --git a/.mise/tasks/lint/bash-empty-array-expansions b/.mise/tasks/lint/bash-empty-array-expansions old mode 100755 new mode 100644 index ddfcd6c..df1c19f --- a/.mise/tasks/lint/bash-empty-array-expansions +++ b/.mise/tasks/lint/bash-empty-array-expansions @@ -165,4 +165,4 @@ HINT fi done -exit "$failures" \ No newline at end of file +exit "$failures" 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-array-expansions/bash-empty-array-expansions.bats b/test/lint/bash-empty-array-expansions/bash-empty-array-expansions.bats index ee7c90b..53178cb 100644 --- a/test/lint/bash-empty-array-expansions/bash-empty-array-expansions.bats +++ b/test/lint/bash-empty-array-expansions/bash-empty-array-expansions.bats @@ -7,9 +7,7 @@ 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" @@ -45,9 +43,7 @@ setup() { [ "$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" @@ -64,9 +60,7 @@ setup() { [ "$status" -eq 0 ] } -# ============================================================================ # Ignore mechanisms -# ============================================================================ @test "bash-empty-array-expansions: respects inline ignore" { run codebase lint:bash-empty-array-expansions "$FIXTURES/ignored-inline" @@ -79,9 +73,7 @@ setup() { [[ "$output" == *"SKIP"* ]] } -# ============================================================================ # Edge cases -# ============================================================================ @test "bash-empty-array-expansions: handles missing mise.toml gracefully" { run codebase lint:bash-empty-array-expansions "$FIXTURES/no-toml" @@ -95,9 +87,7 @@ setup() { [[ "$output" == *"lib/helper.sh"* ]] } -# ============================================================================ # Dynamic test: various variable names -# ============================================================================ @test "bash-empty-array-expansions: flags different variable names" { local tmp @@ -152,4 +142,4 @@ SCRIPT [ "$status" -eq 0 ] [[ "$output" == *"OK"* ]] rm -rf "$tmp" -} \ No newline at end of file +} 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 index bbbbe8a..91fc753 100644 --- 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 @@ -4,4 +4,4 @@ set -euo pipefail args=() cmd ${args[@]+"${args[@]}"} cmd ${args[*]+"${args[*]}"} -echo done \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/already-safe/mise.toml +++ b/test/lint/bash-empty-array-expansions/fixtures/already-safe/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index 559c21e..bc77ba0 100644 --- 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 @@ -3,4 +3,4 @@ set -euo pipefail # This is outside .mise/tasks — should still be found in broad walk args=() -cmd "${args[@]}" \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/broad-walk/mise.toml +++ b/test/lint/bash-empty-array-expansions/fixtures/broad-walk/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index 54f633b..66be494 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/clean/.mise/tasks/t +++ b/test/lint/bash-empty-array-expansions/fixtures/clean/.mise/tasks/t @@ -1,4 +1,4 @@ #!/usr/bin/env bash no_nounset_here=true cmd "${args[@]}" -echo done \ No newline at end of file +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 index a73b57f..7692013 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/clean/mise.toml +++ b/test/lint/bash-empty-array-expansions/fixtures/clean/mise.toml @@ -1,3 +1,3 @@ # Clean codebase — no shell files with nounset + array expansions [tools] -bats = "1.13.0" \ No newline at end of file +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 index c078ae0..cc2164a 100644 --- 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 @@ -7,4 +7,4 @@ flags=() echo "${names[@]}" echo "${paths[@]}" -echo "${flags[*]}" \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/dirty-multiple/mise.toml +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty-multiple/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index a9907e8..ca0c332 100644 --- 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 @@ -5,4 +5,4 @@ all_flags=() [[ -n "${FLAGS:-}" ]] && all_flags=(--flag "$FLAGS") printf '%s\n' "${all_flags[*]}" -echo done \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/dirty-star/mise.toml +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty-star/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index c0ce9a2..9cb3175 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/dirty/.mise/tasks/t +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty/.mise/tasks/t @@ -5,4 +5,4 @@ arch_args=() [[ -n "${ARCH_FLAG:-}" ]] && arch_args=(--arch "$ARCH_FLAG") mise run catalog -- "${arch_args[@]}" -echo done \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/dirty/mise.toml +++ b/test/lint/bash-empty-array-expansions/fixtures/dirty/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index 642c952..e5a6d8f 100644 --- 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 @@ -3,4 +3,4 @@ set -euo pipefail args=() echo "${args[@]}" -echo "${args[*]}" \ No newline at end of file +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 index 89e6b9c..0532e0e 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/ignored-file/mise.toml +++ b/test/lint/bash-empty-array-expansions/fixtures/ignored-file/mise.toml @@ -1 +1 @@ -codebase:ignore bash-empty-array-expansions \ No newline at end of file +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 index 74cfad0..050e4ae 100644 --- 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 @@ -3,4 +3,4 @@ set -euo pipefail args=() cmd "${args[@]}" # codebase:ignore bash-empty-array-expansions -- test fixture -echo done \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/ignored-inline/mise.toml +++ b/test/lint/bash-empty-array-expansions/fixtures/ignored-inline/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index b3e9007..4b595d1 100644 --- 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 @@ -1,4 +1,4 @@ #!/usr/bin/env bash # No set -u — should NOT be flagged cmd "${args[@]}" -echo "${items[*]}" \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/no-nounset/mise.toml +++ b/test/lint/bash-empty-array-expansions/fixtures/no-nounset/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index 852405e..9028505 100644 --- 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 @@ -1,4 +1,4 @@ #!/usr/bin/env bash # No mise.toml — should still scan gracefully # No nounset, so no risk of unbound variable -echo "hello" \ No newline at end of file +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 index a39a476..7ad8cc4 100644 --- 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 @@ -5,4 +5,4 @@ items=("a" "b" "c") local copy=("${items[@]}") for item in "${copy[@]}"; do echo "$item" -done \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- 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 @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index 222668b..d72d477 100644 --- 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 @@ -4,4 +4,4 @@ set -euo pipefail items=("a" "b" "c") for item in "${items[@]}"; do echo "$item" -done \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-array-expansions/fixtures/safe-context/mise.toml +++ b/test/lint/bash-empty-array-expansions/fixtures/safe-context/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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/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/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/or-true/or-true.bats b/test/lint/or-true/or-true.bats index 31d7776..68cfaca 100644 --- a/test/lint/or-true/or-true.bats +++ b/test/lint/or-true/or-true.bats @@ -7,9 +7,7 @@ setup() { FIXTURES="$BATS_TEST_DIRNAME/fixtures" } -# ============================================================================ # Detection -# ============================================================================ @test "or-true: passes on a clean codebase" { run codebase lint:or-true "$FIXTURES/clean" @@ -76,9 +74,7 @@ setup() { [[ "$output" == *"if !"* ]] } -# ============================================================================ # Corpus-calibrated diagnostics -# ============================================================================ @test "or-true: arithmetic increments get a safer arithmetic suggestion" { local tmp @@ -173,9 +169,7 @@ EOF [[ "$output" == *"Intentional cases need a rule-specific reason"* ]] } -# ============================================================================ # Ignore directives -# ============================================================================ @test "or-true: inline '# codebase:ignore or-true — reason' skips the line" { run codebase lint:or-true "$FIXTURES/ignored-inline" @@ -189,9 +183,7 @@ EOF [[ "$output" == *"SKIP"*"ignored-file"* ]] } -# ============================================================================ # Scope / discovery -# ============================================================================ @test "or-true: walks the whole target — finds hits outside .mise/tasks and lib/" { run codebase lint:or-true "$FIXTURES/broad-walk" @@ -213,9 +205,7 @@ EOF [[ "$output" == *"no shell files"* ]] } -# ============================================================================ # Discovery correctness -# ============================================================================ @test "or-true: discovery skips non-bash/sh shebangs (fish, zsh, …)" { # Regression: the shebang regex '^#!.*(bash|sh)\b' matched 'sh' as @@ -255,9 +245,7 @@ EOF rm -rf "$tmp" } -# ============================================================================ # Comment handling -# ============================================================================ @test "or-true: does not flag '|| true' inside a single-quoted string" { # Accidental protection: the closing quote ''' is not in the @@ -293,9 +281,7 @@ EOF rm -rf "$tmp" } -# ============================================================================ # Multi-target -# ============================================================================ @test "or-true: checks multiple targets and reports each" { run codebase lint:or-true "$FIXTURES/clean" "$FIXTURES/dirty" @@ -309,9 +295,7 @@ EOF [ "$status" -eq 2 ] } -# ============================================================================ # Error paths -# ============================================================================ @test "or-true: fails when target does not exist" { run codebase lint:or-true "$FIXTURES/does-not-exist" @@ -328,9 +312,7 @@ EOF [[ "$output" == *""* ]] } -# ============================================================================ # Relative path resolution (regression: codebase#24) -# ============================================================================ @test "or-true: relative path resolves against CODEBASE_CALLER_PWD, not codebase install dir" { # Regression: when invoked via the shiv shim, relative paths resolved diff --git a/test/lint/shellcheck/shellcheck.bats b/test/lint/shellcheck/shellcheck.bats index 370c7d6..f0ac032 100644 --- a/test/lint/shellcheck/shellcheck.bats +++ b/test/lint/shellcheck/shellcheck.bats @@ -7,9 +7,7 @@ setup() { FIXTURES="$BATS_TEST_DIRNAME/fixtures" } -# ============================================================================ # Detection -# ============================================================================ @test "lint: passes on a clean codebase" { run codebase lint:shellcheck "$FIXTURES/clean" @@ -37,9 +35,7 @@ setup() { [[ "$output" == *"SC"* ]] } -# ============================================================================ # Ignore directive -# ============================================================================ @test "lint: skips when codebase:ignore shellcheck is set in mise.toml" { run codebase lint:shellcheck "$FIXTURES/ignored" @@ -76,9 +72,7 @@ setup() { [[ "$output" == *"SC2154"* ]] } -# ============================================================================ # Scope -# ============================================================================ @test "lint: works on a codebase with no mise.toml" { run codebase lint:shellcheck "$FIXTURES/no-toml" @@ -100,9 +94,7 @@ setup() { [[ "$output" == *"bad.sh"* ]] } -# ============================================================================ # Multi-target -# ============================================================================ @test "lint: checks multiple targets and reports each" { run codebase lint:shellcheck "$FIXTURES/clean" "$FIXTURES/dirty" @@ -116,9 +108,7 @@ setup() { [ "$status" -eq 2 ] } -# ============================================================================ # Error paths -# ============================================================================ @test "lint: fails when target does not exist" { run codebase lint:shellcheck "$FIXTURES/does-not-exist" diff --git a/test/migrations/task-pattern/task-pattern.bats b/test/migrations/task-pattern/task-pattern.bats index d86c33e..5f8a90d 100644 --- a/test/migrations/task-pattern/task-pattern.bats +++ b/test/migrations/task-pattern/task-pattern.bats @@ -23,9 +23,7 @@ assert_matches_before() { diff -u "$FIXTURES/before/$file" "$WORK_DIR/$file" } -# ============================================================================ # Individual variant tests -# ============================================================================ @test "migrate: simple mise run → _task" { codebase migrate:task-pattern "$WORK_DIR" @@ -57,9 +55,7 @@ assert_matches_before() { assert_matches_after ".mise/tasks/error-strings" } -# ============================================================================ # Full migration test -# ============================================================================ @test "migrate: all files match expected after state" { codebase migrate:task-pattern "$WORK_DIR" @@ -68,9 +64,7 @@ assert_matches_before() { [ "$status" -eq 0 ] } -# ============================================================================ # Reverse migration tests -# ============================================================================ @test "reverse: _task → mise run" { # Start from the after state @@ -101,9 +95,7 @@ assert_matches_before() { ! grep -q '_task' "$WORK_DIR/.mise/tasks/in-subshell" } -# ============================================================================ # Round-trip tests -# ============================================================================ @test "round-trip: forward then reverse restores lossless fixtures" { # Only test fixtures where forward is lossless (no -q flag) @@ -114,9 +106,7 @@ assert_matches_before() { assert_matches_before ".mise/tasks/error-strings" } -# ============================================================================ # Error handling -# ============================================================================ @test "migrate: fails when target does not exist" { run codebase migrate:task-pattern /nonexistent diff --git a/test/pre-commit/pre-commit.bats b/test/pre-commit/pre-commit.bats index 96bd4f2..c0fbddc 100644 --- a/test/pre-commit/pre-commit.bats +++ b/test/pre-commit/pre-commit.bats @@ -24,9 +24,7 @@ EOF export CODEBASE_CALLER_PWD="$REPO" } -# ============================================================================ # Install — fresh repo -# ============================================================================ @test "install: creates dispatcher" { codebase pre-commit @@ -97,9 +95,7 @@ EOF [ -x "$REPO/.git/hooks/pre-commit" ] } -# ============================================================================ # Install — existing dispatcher -# ============================================================================ @test "install: preserves existing dispatcher and other hooks" { mkdir -p "$REPO/.git/hooks/pre-commit.d" @@ -121,9 +117,7 @@ EOF [ -f "$REPO/.git/hooks/pre-commit.d/codebase" ] } -# ============================================================================ # Install — existing plain hook (not a dispatcher) -# ============================================================================ @test "install: errors when existing plain hook is not a dispatcher" { cat > "$REPO/.git/hooks/pre-commit" <<'EOF' @@ -137,9 +131,7 @@ EOF [[ "$output" == *"not a dispatcher"* ]] } -# ============================================================================ # Idempotent -# ============================================================================ @test "install: running twice is safe" { codebase pre-commit @@ -149,9 +141,7 @@ EOF [ -f "$REPO/.git/hooks/pre-commit.d/codebase" ] } -# ============================================================================ # --check -# ============================================================================ @test "check: exits 0 when hook is current" { codebase pre-commit @@ -189,9 +179,7 @@ EOF [ "$status" -ne 0 ] } -# ============================================================================ # --revert -# ============================================================================ @test "revert: removes codebase hook" { codebase pre-commit @@ -225,9 +213,7 @@ EOF [[ "$output" == *"No codebase hook"* ]] } -# ============================================================================ # Scope -# ============================================================================ @test "scope: default scopes are delegated to aggregate lint" { cat > "$REPO/mise.toml" <<'EOF' @@ -258,9 +244,7 @@ EOF ! grep -q 'src/scripts' "$REPO/.git/hooks/pre-commit.d/codebase" } -# ============================================================================ # Error handling -# ============================================================================ @test "error: fails outside git repo" { export CODEBASE_CALLER_PWD="$BATS_TEST_TMPDIR" diff --git a/test/scan/scan.bats b/test/scan/scan.bats index 9552433..e52a500 100644 --- a/test/scan/scan.bats +++ b/test/scan/scan.bats @@ -8,9 +8,7 @@ setup() { FIXTURES_B="$BATS_TEST_DIRNAME/fixtures-b" } -# ============================================================================ # Single target (basic matching) -# ============================================================================ @test "scan: finds mise run calls in extension-less task files" { run codebase scan -p 'mise run $$$ARGS' "$FIXTURES_A" @@ -39,9 +37,7 @@ setup() { [[ -z "$output" ]] } -# ============================================================================ # Different patterns -# ============================================================================ @test "scan: finds _task calls with custom pattern" { run codebase scan -p '_task $$$ARGS' "$FIXTURES_A" @@ -56,9 +52,7 @@ setup() { [ "$count" -eq 4 ] } -# ============================================================================ # Multiple targets -# ============================================================================ @test "multi: finds matches across multiple codebases" { run codebase scan -p 'mise run $$$ARGS' "$FIXTURES_A" "$FIXTURES_B" @@ -84,9 +78,7 @@ setup() { [[ "$output" != *"fixtures-b:"* ]] } -# ============================================================================ # Exclude filter -# ============================================================================ @test "exclude: filters out files matching glob" { run codebase scan -p 'mise run $$$ARGS' -e '.mise/tasks/ci/*' "$FIXTURES_A" @@ -110,9 +102,7 @@ setup() { [[ "$output" != *"ci/deploy"* ]] } -# ============================================================================ # Error handling -# ============================================================================ @test "error: fails when no pattern provided" { run codebase scan "$FIXTURES_A" From d809be9d1c929a566b6102a432c7aa4846172c3a Mon Sep 17 00:00:00 2001 From: olavostauros Date: Thu, 25 Jun 2026 16:33:38 -0300 Subject: [PATCH 05/11] fix: make bash-empty-array-expansions task executable for mise discovery --- .mise/tasks/lint/bash-empty-array-expansions | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .mise/tasks/lint/bash-empty-array-expansions diff --git a/.mise/tasks/lint/bash-empty-array-expansions b/.mise/tasks/lint/bash-empty-array-expansions old mode 100644 new mode 100755 From 827405ec55513f5557c40535cb16055691303666 Mon Sep 17 00:00:00 2001 From: olavostauros Date: Tue, 23 Jun 2026 11:37:31 -0300 Subject: [PATCH 06/11] feat: add lint rule for Bash nounset empty-argv forwarding Adds bash-empty-argv-forwarding lint rule that flags "$@" and "${@}" as command arguments in files with nounset enabled (set -u). This pattern causes fatal unbound-variable errors on macOS Bash 3.2 when the argument list is empty, while Linux Bash 5+ handles it silently. The rule follows the established text-based ripgrep pattern from or-true, caller-pwd-contract, etc. It correctly excludes safe contexts (for loops, array assignments), already-safe alternate-value forms (${@+"$@"}), and files without nounset. Implements #48 (KnickKnackLabs/codebase) --- .mise/tasks/lint/bash-empty-argv-forwarding | 168 +++++++++++ .../bash-empty-argv-forwarding.bats | 281 ++++++++++++++++++ .../fixtures/already-safe/.mise/tasks/task | 3 + .../fixtures/already-safe/mise.toml | 2 + .../fixtures/broad-walk/bin/tool | 3 + .../fixtures/broad-walk/mise.toml | 2 + .../fixtures/broad-walk/scripts/deploy.sh | 3 + .../fixtures/clean/.mise/tasks/greet | 3 + .../fixtures/clean/mise.toml | 2 + .../fixtures/dirty/.mise/tasks/delegate | 3 + .../fixtures/dirty/mise.toml | 2 + .../fixtures/ignored-file/.mise/tasks/broken | 3 + .../fixtures/ignored-file/mise.toml | 2 + .../fixtures/ignored-inline/.mise/tasks/task | 3 + .../fixtures/ignored-inline/mise.toml | 2 + .../fixtures/no-nounset/.mise/tasks/delegate | 3 + .../fixtures/no-nounset/mise.toml | 2 + .../fixtures/no-shell-files/mise.toml | 2 + .../fixtures/no-toml/.mise/tasks/greet | 2 + .../fixtures/safe-context/.mise/tasks/loop | 5 + .../fixtures/safe-context/mise.toml | 2 + 21 files changed, 498 insertions(+) create mode 100755 .mise/tasks/lint/bash-empty-argv-forwarding create mode 100644 test/lint/bash-empty-argv-forwarding/bash-empty-argv-forwarding.bats create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/already-safe/.mise/tasks/task create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/already-safe/mise.toml create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/bin/tool create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/mise.toml create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/scripts/deploy.sh create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/clean/.mise/tasks/greet create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/clean/mise.toml create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/dirty/.mise/tasks/delegate create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/dirty/mise.toml create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/ignored-file/.mise/tasks/broken create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/ignored-file/mise.toml create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/ignored-inline/.mise/tasks/task create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/ignored-inline/mise.toml create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/no-nounset/.mise/tasks/delegate create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/no-nounset/mise.toml create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/no-shell-files/mise.toml create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/no-toml/.mise/tasks/greet create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/safe-context/.mise/tasks/loop create mode 100644 test/lint/bash-empty-argv-forwarding/fixtures/safe-context/mise.toml diff --git a/.mise/tasks/lint/bash-empty-argv-forwarding b/.mise/tasks/lint/bash-empty-argv-forwarding new file mode 100755 index 0000000..29cb621 --- /dev/null +++ b/.mise/tasks/lint/bash-empty-argv-forwarding @@ -0,0 +1,168 @@ +#!/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" + [[ "$line" == *'${@+"$@"}'* ]] && return 0 + [[ "$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" \ No newline at end of file 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..9c028f6 --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/bash-empty-argv-forwarding.bats @@ -0,0 +1,281 @@ +#!/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" +} \ No newline at end of file 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..799536d --- /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 ${@+"$@"} \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/already-safe/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..233c046 --- /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 "$@" \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..058dd7d --- /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/ \ No newline at end of file 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..f0c3900 --- /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" \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/clean/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..b779308 --- /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 "$@" \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/dirty/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..f5a209b --- /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 "$@" \ No newline at end of file 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..a9e06e9 --- /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 \ No newline at end of file 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..6cc468a --- /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 \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/ignored-inline/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..527cbdc --- /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 "$@" \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/no-nounset/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/no-shell-files/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file 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..70c8c0b --- /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" \ No newline at end of file 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..c17672f --- /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 \ No newline at end of file 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..1c87eee --- /dev/null +++ b/test/lint/bash-empty-argv-forwarding/fixtures/safe-context/mise.toml @@ -0,0 +1,2 @@ +[tools] +bats = "1.13.0" \ No newline at end of file From b4f0f1e4faa2f30c5cac8b9525ec97bab2e64526 Mon Sep 17 00:00:00 2001 From: olavostauros Date: Wed, 24 Jun 2026 10:31:14 -0300 Subject: [PATCH 07/11] style: collapse 3-line section comment blocks to single line --- .mise/tasks/lint/bash-empty-argv-forwarding | 2 +- .../bash-empty-argv-forwarding.bats | 18 +----------------- .../fixtures/already-safe/.mise/tasks/task | 2 +- .../fixtures/already-safe/mise.toml | 2 +- .../fixtures/broad-walk/bin/tool | 2 +- .../fixtures/broad-walk/mise.toml | 2 +- .../fixtures/broad-walk/scripts/deploy.sh | 2 +- .../fixtures/clean/.mise/tasks/greet | 2 +- .../fixtures/clean/mise.toml | 2 +- .../fixtures/dirty/.mise/tasks/delegate | 2 +- .../fixtures/dirty/mise.toml | 2 +- .../fixtures/ignored-file/.mise/tasks/broken | 2 +- .../fixtures/ignored-file/mise.toml | 2 +- .../fixtures/ignored-inline/.mise/tasks/task | 2 +- .../fixtures/ignored-inline/mise.toml | 2 +- .../fixtures/no-nounset/.mise/tasks/delegate | 2 +- .../fixtures/no-nounset/mise.toml | 2 +- .../fixtures/no-shell-files/mise.toml | 2 +- .../fixtures/no-toml/.mise/tasks/greet | 2 +- .../fixtures/safe-context/.mise/tasks/loop | 2 +- .../fixtures/safe-context/mise.toml | 2 +- 21 files changed, 21 insertions(+), 37 deletions(-) mode change 100755 => 100644 .mise/tasks/lint/bash-empty-argv-forwarding diff --git a/.mise/tasks/lint/bash-empty-argv-forwarding b/.mise/tasks/lint/bash-empty-argv-forwarding old mode 100755 new mode 100644 index 29cb621..e11638e --- a/.mise/tasks/lint/bash-empty-argv-forwarding +++ b/.mise/tasks/lint/bash-empty-argv-forwarding @@ -165,4 +165,4 @@ HINT fi done -exit "$failures" \ No newline at end of file +exit "$failures" 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 index 9c028f6..571038e 100644 --- a/test/lint/bash-empty-argv-forwarding/bash-empty-argv-forwarding.bats +++ b/test/lint/bash-empty-argv-forwarding/bash-empty-argv-forwarding.bats @@ -7,9 +7,7 @@ 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" @@ -73,9 +71,7 @@ EOF [[ "$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" @@ -89,9 +85,7 @@ EOF [[ "$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" @@ -151,9 +145,7 @@ EOF rm -rf "$tmp" } -# ============================================================================ # Comment handling -# ============================================================================ @test "bash-empty-argv-forwarding: does not flag '\"$@\"' inside a full-line comment" { local tmp @@ -171,9 +163,7 @@ EOF 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" @@ -187,9 +177,7 @@ EOF [ "$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" @@ -204,9 +192,7 @@ EOF [[ "$output" == *""* ]] } -# ============================================================================ # Relative path resolution -# ============================================================================ @test "bash-empty-argv-forwarding: relative path resolves against CODEBASE_CALLER_PWD" { local tmp @@ -225,9 +211,7 @@ EOF rm -rf "$tmp" } -# ============================================================================ # Explicit braces detection -# ============================================================================ @test "bash-empty-argv-forwarding: flags '\"\${@}\"' (explicit braces) under nounset" { local tmp @@ -278,4 +262,4 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"OK"* ]] rm -rf "$tmp" -} \ No newline at end of file +} 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 index 799536d..275732c 100644 --- 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 @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -some_command ${@+"$@"} \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-argv-forwarding/fixtures/already-safe/mise.toml +++ b/test/lint/bash-empty-argv-forwarding/fixtures/already-safe/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index 233c046..c2b2759 100644 --- a/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/bin/tool +++ b/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/bin/tool @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -command "$@" \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/mise.toml +++ b/test/lint/bash-empty-argv-forwarding/fixtures/broad-walk/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index 058dd7d..076a2b5 100644 --- 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 @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -rsync "$@" deploy@example.com:/srv/app/ \ No newline at end of file +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 index f0c3900..ec8a236 100644 --- a/test/lint/bash-empty-argv-forwarding/fixtures/clean/.mise/tasks/greet +++ b/test/lint/bash-empty-argv-forwarding/fixtures/clean/.mise/tasks/greet @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -echo "hello $USER" \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-argv-forwarding/fixtures/clean/mise.toml +++ b/test/lint/bash-empty-argv-forwarding/fixtures/clean/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index b779308..2a429fc 100644 --- a/test/lint/bash-empty-argv-forwarding/fixtures/dirty/.mise/tasks/delegate +++ b/test/lint/bash-empty-argv-forwarding/fixtures/dirty/.mise/tasks/delegate @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -mise run child "$@" \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-argv-forwarding/fixtures/dirty/mise.toml +++ b/test/lint/bash-empty-argv-forwarding/fixtures/dirty/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index f5a209b..258bc84 100644 --- 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 @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -some_command "$@" \ No newline at end of file +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 index a9e06e9..c663d19 100644 --- a/test/lint/bash-empty-argv-forwarding/fixtures/ignored-file/mise.toml +++ b/test/lint/bash-empty-argv-forwarding/fixtures/ignored-file/mise.toml @@ -1,2 +1,2 @@ [_.codebase] -# codebase:ignore bash-empty-argv-forwarding \ No newline at end of file +# 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 index 6cc468a..de98463 100644 --- 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 @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -some_command "$@" # codebase:ignore bash-empty-argv-forwarding — intentional: always called with args \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-argv-forwarding/fixtures/ignored-inline/mise.toml +++ b/test/lint/bash-empty-argv-forwarding/fixtures/ignored-inline/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index 527cbdc..f19e98c 100644 --- 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 @@ -1,3 +1,3 @@ #!/usr/bin/env bash # No set -u here — this is safe on all platforms -mise run child "$@" \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-argv-forwarding/fixtures/no-nounset/mise.toml +++ b/test/lint/bash-empty-argv-forwarding/fixtures/no-nounset/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- 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 @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +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 index 70c8c0b..f90f3e8 100644 --- 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 @@ -1,2 +1,2 @@ #!/usr/bin/env bash -echo "greetings" \ No newline at end of file +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 index c17672f..aafd136 100644 --- 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 @@ -2,4 +2,4 @@ set -euo pipefail for arg in "$@"; do echo "$arg" -done \ No newline at end of file +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 index 1c87eee..26bb173 100644 --- a/test/lint/bash-empty-argv-forwarding/fixtures/safe-context/mise.toml +++ b/test/lint/bash-empty-argv-forwarding/fixtures/safe-context/mise.toml @@ -1,2 +1,2 @@ [tools] -bats = "1.13.0" \ No newline at end of file +bats = "1.13.0" From bdf41bbeab6a926ede33886c50b00395284a658c Mon Sep 17 00:00:00 2001 From: olavostauros Date: Thu, 25 Jun 2026 16:05:14 -0300 Subject: [PATCH 08/11] fix: make task executable, remove decorative rulers, suppress shellcheck SC2016 --- .mise/tasks/lint/bash-empty-argv-forwarding | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) mode change 100644 => 100755 .mise/tasks/lint/bash-empty-argv-forwarding diff --git a/.mise/tasks/lint/bash-empty-argv-forwarding b/.mise/tasks/lint/bash-empty-argv-forwarding old mode 100644 new mode 100755 index e11638e..87e3fb1 --- a/.mise/tasks/lint/bash-empty-argv-forwarding +++ b/.mise/tasks/lint/bash-empty-argv-forwarding @@ -36,7 +36,7 @@ for i in "${!TARGETS[@]}"; do TARGETS[i]=$(resolve_target "${TARGETS[i]}") done -# ── Helpers ────────────────────────────────────────────────────────────── +# Helpers # has_nounset # Returns 0 if the file enables nounset via any form: @@ -62,7 +62,9 @@ is_safe_context() { # 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 } @@ -108,7 +110,7 @@ scan_file() { done < "$file" } -# ── Main ────────────────────────────────────────────────────────────────── +# Main failures=0 From 4e7b537a9618a711335542e415de1367875bba61 Mon Sep 17 00:00:00 2001 From: olavostauros Date: Wed, 24 Jun 2026 08:57:59 -0300 Subject: [PATCH 09/11] feat: add variadic-args lint rule (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scans .mise/tasks/* for #USAGE directives with var=#true, then flags dangerous consumption of the resulting usage_* vars: - eval 'ARRAY=${usage_X}' → ERROR: injection vector - read -ra ARRAY <<< "$usage_X" → WARN: loses quoting The correct pattern uses xargs printf for bash or shlex.split() for Python tasks, per mise-conventions.md. --- .mise/tasks/lint/variadic-args | 300 ++++++++++++++++++ .../fixtures/clean/.mise/tasks/my-task | 13 + .../fixtures/dirty-eval/.mise/tasks/my-task | 8 + .../dirty-read-ra/.mise/tasks/my-task | 10 + .../fixtures/ignored-file/.mise/tasks/my-task | 6 + .../fixtures/ignored-file/mise.toml | 5 + .../fixtures/ignored-inline/.mise/tasks/count | 6 + .../fixtures/mixed/.mise/tasks/search | 20 ++ .../multi-task/.mise/tasks/clean-task | 13 + .../multi-task/.mise/tasks/dirty-task | 8 + .../fixtures/no-variadic/.mise/tasks/my-task | 7 + test/lint/variadic-args/variadic-args.bats | 213 +++++++++++++ 12 files changed, 609 insertions(+) create mode 100755 .mise/tasks/lint/variadic-args create mode 100644 test/lint/variadic-args/fixtures/clean/.mise/tasks/my-task create mode 100644 test/lint/variadic-args/fixtures/dirty-eval/.mise/tasks/my-task create mode 100644 test/lint/variadic-args/fixtures/dirty-read-ra/.mise/tasks/my-task create mode 100644 test/lint/variadic-args/fixtures/ignored-file/.mise/tasks/my-task create mode 100644 test/lint/variadic-args/fixtures/ignored-file/mise.toml create mode 100644 test/lint/variadic-args/fixtures/ignored-inline/.mise/tasks/count create mode 100644 test/lint/variadic-args/fixtures/mixed/.mise/tasks/search create mode 100644 test/lint/variadic-args/fixtures/multi-task/.mise/tasks/clean-task create mode 100644 test/lint/variadic-args/fixtures/multi-task/.mise/tasks/dirty-task create mode 100644 test/lint/variadic-args/fixtures/no-variadic/.mise/tasks/my-task create mode 100755 test/lint/variadic-args/variadic-args.bats diff --git a/.mise/tasks/lint/variadic-args b/.mise/tasks/lint/variadic-args new file mode 100755 index 0000000..5246a7b --- /dev/null +++ b/.mise/tasks/lint/variadic-args @@ -0,0 +1,300 @@ +#!/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/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-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..2d1250e --- /dev/null +++ b/test/lint/variadic-args/variadic-args.bats @@ -0,0 +1,213 @@ +#!/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" +} From 6d1b69c0b1c1457a27e5b248541c9e50efbf86ef Mon Sep 17 00:00:00 2001 From: olavostauros Date: Thu, 25 Jun 2026 15:54:28 -0300 Subject: [PATCH 10/11] style: replace decorative rulers with single-line headers, add missing no-tasks fixture --- .mise/tasks/lint/variadic-args | 12 ++------ .../variadic-args/fixtures/no-tasks/.gitkeep | 0 test/lint/variadic-args/variadic-args.bats | 28 +++++-------------- 3 files changed, 10 insertions(+), 30 deletions(-) create mode 100644 test/lint/variadic-args/fixtures/no-tasks/.gitkeep diff --git a/.mise/tasks/lint/variadic-args b/.mise/tasks/lint/variadic-args index 5246a7b..36eeec3 100755 --- a/.mise/tasks/lint/variadic-args +++ b/.mise/tasks/lint/variadic-args @@ -43,9 +43,7 @@ for i in "${!TARGETS[@]}"; do TARGETS[$i]=$(resolve_target "${TARGETS[$i]}") done -# --------------------------------------------------------------------------- -# Phase 1 helpers: parse #USAGE directives to extract variadic env var names -# --------------------------------------------------------------------------- +# === Phase 1 helpers: parse #USAGE directives to extract variadic env var names === # collect_variadic_vars # @@ -85,9 +83,7 @@ collect_variadic_vars() { done < "$file" } -# --------------------------------------------------------------------------- -# Phase 2 helpers: scan file for dangerous consumption patterns -# --------------------------------------------------------------------------- +# === Phase 2 helpers: scan file for dangerous consumption patterns === # Patterns for dangerous variadic consumption. # These match the *variable reference* side — we cross-reference against @@ -214,9 +210,7 @@ scan_file() { fi } -# --------------------------------------------------------------------------- -# Main loop -# --------------------------------------------------------------------------- +# === Main loop === failures=0 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/variadic-args.bats b/test/lint/variadic-args/variadic-args.bats index 2d1250e..15cd4c9 100755 --- a/test/lint/variadic-args/variadic-args.bats +++ b/test/lint/variadic-args/variadic-args.bats @@ -7,9 +7,7 @@ setup() { FIXTURES="$BATS_TEST_DIRNAME/fixtures" } -# ============================================================================ -# Pass paths -# ============================================================================ +# === Pass paths === @test "variadic-args: passes on task using xargs pattern (correct)" { run codebase lint:variadic-args "$FIXTURES/clean" @@ -38,9 +36,7 @@ setup() { [[ "$output" != *"read -ra"* ]] } -# ============================================================================ -# Failure modes -# ============================================================================ +# === Failure modes === @test "variadic-args: flags eval ARGS=(\${usage_args:-}) as ERROR" { run codebase lint:variadic-args "$FIXTURES/dirty-eval" @@ -88,9 +84,7 @@ setup() { [[ "$output" != *"clean-task"* ]] } -# ============================================================================ -# Ignore mechanisms -# ============================================================================ +# === Ignore mechanisms === @test "variadic-args: respects inline # codebase:ignore — reason" { run codebase lint:variadic-args "$FIXTURES/ignored-inline" @@ -104,9 +98,7 @@ setup() { [[ "$output" == *"SKIP"*"ignored-file"* ]] } -# ============================================================================ -# Multi-target -# ============================================================================ +# === Multi-target === @test "variadic-args: checks multiple targets and reports each" { run codebase lint:variadic-args "$FIXTURES/clean" "$FIXTURES/dirty-eval" @@ -120,9 +112,7 @@ setup() { [ "$status" -eq 2 ] } -# ============================================================================ -# Error paths -# ============================================================================ +# === Error paths === @test "variadic-args: fails when target does not exist" { run codebase lint:variadic-args "$FIXTURES/does-not-exist" @@ -137,9 +127,7 @@ setup() { [[ "$output" == *""* ]] } -# ============================================================================ -# Edge cases -# ============================================================================ +# === 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 @@ -190,9 +178,7 @@ EOF rm -rf "$tmp" } -# ============================================================================ -# Relative path resolution (regression: codebase#24) -# ============================================================================ +# === Relative path resolution (regression: codebase#24) === @test "variadic-args: relative path resolves against CODEBASE_CALLER_PWD, not codebase install dir" { local tmp From 2cb1d30599a2b87fec2d7216ccd0c4a4cd0e984b Mon Sep 17 00:00:00 2001 From: olavostauros Date: Wed, 24 Jun 2026 11:26:45 -0300 Subject: [PATCH 11/11] =?UTF-8?q?feat(lint):=20add=20exec-stderr-silence?= =?UTF-8?q?=20rule=20=E2=80=94=20flag=20persistent=20exec=20stderr=20suppr?= =?UTF-8?q?ession?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds text-based detection for 'exec' with stderr redirection (2>/dev/null, 2>&-, 2>!) that persists for the current shell. - Detection via bash regex: \bexec\b[^#]*2> with dangerous target - Fixtures: clean, dirty, ignored-inline, ignored-file, broad-walk, no-toml, empty - 17 BATS tests covering detection, ignore mechanisms, discovery, edge cases - Self-hosting verified: task file does not self-report - Follows or-true pattern for output format, ignore conventions, and scope --- .mise/tasks/lint/exec-stderr-silence | 128 +++++++++++++++ .../exec-stderr-silence.bats | 150 ++++++++++++++++++ .../fixtures/broad-walk/bin/tool | 4 + .../fixtures/broad-walk/mise.toml | 2 + .../fixtures/broad-walk/scripts/deploy.sh | 7 + .../fixtures/clean/.mise/tasks/probe | 12 ++ .../fixtures/clean/mise.toml | 2 + .../fixtures/dirty/.mise/tasks/probe | 33 ++++ .../fixtures/dirty/mise.toml | 2 + .../fixtures/empty/mise.toml | 2 + .../fixtures/ignored-file/.mise/tasks/probe | 10 ++ .../fixtures/ignored-file/mise.toml | 3 + .../fixtures/ignored-inline/.mise/tasks/probe | 11 ++ .../fixtures/ignored-inline/mise.toml | 2 + .../fixtures/no-toml/.mise/tasks/greet | 3 + 15 files changed, 371 insertions(+) create mode 100755 .mise/tasks/lint/exec-stderr-silence create mode 100644 test/lint/exec-stderr-silence/exec-stderr-silence.bats create mode 100644 test/lint/exec-stderr-silence/fixtures/broad-walk/bin/tool create mode 100644 test/lint/exec-stderr-silence/fixtures/broad-walk/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/broad-walk/scripts/deploy.sh create mode 100644 test/lint/exec-stderr-silence/fixtures/clean/.mise/tasks/probe create mode 100644 test/lint/exec-stderr-silence/fixtures/clean/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/dirty/.mise/tasks/probe create mode 100644 test/lint/exec-stderr-silence/fixtures/dirty/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/empty/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/ignored-file/.mise/tasks/probe create mode 100644 test/lint/exec-stderr-silence/fixtures/ignored-file/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/ignored-inline/.mise/tasks/probe create mode 100644 test/lint/exec-stderr-silence/fixtures/ignored-inline/mise.toml create mode 100644 test/lint/exec-stderr-silence/fixtures/no-toml/.mise/tasks/greet 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/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