From fa4babd3651379c5c0ffc3458ccbad56942e34dd Mon Sep 17 00:00:00 2001 From: olavostauros Date: Wed, 24 Jun 2026 08:57:59 -0300 Subject: [PATCH 1/2] 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 8fc9c7dec8463878375c92fbc828cfb398b89094 Mon Sep 17 00:00:00 2001 From: olavostauros Date: Thu, 25 Jun 2026 15:54:28 -0300 Subject: [PATCH 2/2] 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