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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions .mise/tasks/lint/bash-empty-array-expansions
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
#MISE description="Flag empty array expansions under nounset (macOS Bash 3.2 compat)"
#USAGE arg "<targets>…" 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 <file>
# 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 <line>
# 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 <line>
# 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 <line>
# 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 <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"
2 changes: 1 addition & 1 deletion .mise/tasks/lint/github-actions
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
2 changes: 1 addition & 1 deletion .mise/tasks/lint/mise-settings
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .mise/tasks/lint/shellcheck
Original file line number Diff line number Diff line change
Expand Up @@ -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/^/ /'
Expand Down
2 changes: 0 additions & 2 deletions test/lib/shell-files.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
145 changes: 145 additions & 0 deletions test/lint/bash-empty-array-expansions/bash-empty-array-expansions.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#!/usr/bin/env bats
# Tests for lint:bash-empty-array-expansions rule

load ../../test_helper

setup() {
FIXTURES="$BATS_TEST_DIRNAME/fixtures"
}

# Detection

@test "bash-empty-array-expansions: passes on a clean codebase" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/clean"
[ "$status" -eq 0 ]
[[ "$output" == *"OK"* ]]
}

@test "bash-empty-array-expansions: flags '\"${args[@]}\"' under nounset" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/dirty"
[ "$status" -ne 0 ]
[[ "$output" == *"FAIL"*"dirty"* ]]
[[ "$output" == *'"${arch_args[@]}"'* ]]
}

@test "bash-empty-array-expansions: flags '\"${args[*]}\"' under nounset" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/dirty-star"
[ "$status" -ne 0 ]
[[ "$output" == *"FAIL"*"dirty-star"* ]]
[[ "$output" == *'"${all_flags[*]}"'* ]]
}

@test "bash-empty-array-expansions: flags multiple array expansions in one file" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/dirty-multiple"
[ "$status" -ne 0 ]
[[ "$output" == *"FAIL"*"dirty-multiple"* ]]
[[ "$output" == *'"${names[@]}"'* ]]
[[ "$output" == *'"${paths[@]}"'* ]]
[[ "$output" == *'"${flags[*]}"'* ]]
}

@test "bash-empty-array-expansions: does not flag '\"${args[@]}\"' in files without nounset" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/no-nounset"
[ "$status" -eq 0 ]
}

# Safe contexts

@test "bash-empty-array-expansions: does not flag 'for item in \"\${items[@]}\"'" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/safe-context"
[ "$status" -eq 0 ]
}

@test "bash-empty-array-expansions: does not flag 'local arr=(\"\${items[@]}\")'" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/safe-context-local"
[ "$status" -eq 0 ]
}

@test "bash-empty-array-expansions: does not flag already-safe '\${arr[@]+\"\${arr[@]}\"}" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/already-safe"
[ "$status" -eq 0 ]
}

# Ignore mechanisms

@test "bash-empty-array-expansions: respects inline ignore" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/ignored-inline"
[ "$status" -eq 0 ]
}

@test "bash-empty-array-expansions: respects file-level ignore via mise.toml" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/ignored-file"
[ "$status" -eq 0 ]
[[ "$output" == *"SKIP"* ]]
}

# Edge cases

@test "bash-empty-array-expansions: handles missing mise.toml gracefully" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/no-toml"
[ "$status" -eq 0 ]
[[ "$output" == *"OK"*"no-toml"* ]]
}

@test "bash-empty-array-expansions: finds hits outside .mise/tasks in broad walk" {
run codebase lint:bash-empty-array-expansions "$FIXTURES/broad-walk"
[ "$status" -ne 0 ]
[[ "$output" == *"lib/helper.sh"* ]]
}

# Dynamic test: various variable names

@test "bash-empty-array-expansions: flags different variable names" {
local tmp
tmp=$(mktemp -d)
mkdir -p "$tmp/.mise/tasks"
cat > "$tmp/.mise/tasks/t" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
echo "${names[@]}"
echo "${paths[@]}"
echo "${flags[*]}"
SCRIPT

run codebase lint:bash-empty-array-expansions "$tmp"
[ "$status" -ne 0 ]
[[ "$output" == *"names"* ]]
[[ "$output" == *"paths"* ]]
[[ "$output" == *"flags"* ]]
rm -rf "$tmp"
}

@test "bash-empty-array-expansions: flags in redirect context" {
local tmp
tmp=$(mktemp -d)
mkdir -p "$tmp/.mise/tasks"
cat > "$tmp/.mise/tasks/t" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
some_command "${args[@]}" > /tmp/output
SCRIPT

run codebase lint:bash-empty-array-expansions "$tmp"
[ "$status" -ne 0 ]
[[ "$output" == *'"${args[@]}"'* ]]
rm -rf "$tmp"
}

@test "bash-empty-array-expansions: does not flag local array assignment then for loop (combined safe)" {
local tmp
tmp=$(mktemp -d)
mkdir -p "$tmp/.mise/tasks"
cat > "$tmp/.mise/tasks/t" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
local args=("$@")
for arg in "${args[@]}"; do
echo "$arg"
done
SCRIPT

run codebase lint:bash-empty-array-expansions "$tmp"
[ "$status" -eq 0 ]
[[ "$output" == *"OK"* ]]
rm -rf "$tmp"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail

args=()
cmd ${args[@]+"${args[@]}"}
cmd ${args[*]+"${args[*]}"}
echo done
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
bats = "1.13.0"
Original file line number Diff line number Diff line change
@@ -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[@]}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
bats = "1.13.0"
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
no_nounset_here=true
cmd "${args[@]}"
echo done
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Clean codebase — no shell files with nounset + array expansions
[tools]
bats = "1.13.0"
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail

names=()
paths=()
flags=()

echo "${names[@]}"
echo "${paths[@]}"
echo "${flags[*]}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
bats = "1.13.0"
Loading