Skip to content
Closed
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
57 changes: 39 additions & 18 deletions .mise/tasks/lint/_default
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -29,31 +36,45 @@ if [[ ${#RULES[@]} -eq 0 ]]; then
echo "Add to mise.toml:" >&2
echo ' [_.codebase]' >&2
echo ' lint = ["mise-settings", "gum-table"]' >&2
echo " # Or use a preset group:" >&2
echo ' # lint = ["@maintained-tool"]' >&2
echo " # See 'mise run lint:groups' for available groups" >&2
exit 1
fi

if $HAS_GROUPS; then
echo "codebase: expanded ${#RULES[@]} rule(s) from lint groups"
fi

failures=0
FAILED=()

for rule in "${RULES[@]}"; do
rule_target=$(codebase_target_for_rule "$REPO_ROOT" "$rule")
echo "codebase: lint:$rule $rule_target"

output=""
if output=$(mise run -q "lint:$rule" "$rule_target" 2>&1); then
status=0
else
status=$?
fi

if [[ -n "$output" ]]; then
printf '%s\n' "$output"
fi

if [[ "$status" -ne 0 ]]; then
failures=$((failures + 1))
FAILED+=("lint:$rule ($rule_target) exited $status")
fi
# Collect all targets for this rule (supports multi-path scopes)
TARGETS=()
while IFS= read -r t; do
[[ -n "$t" ]] && TARGETS+=("$t")
done < <(codebase_targets_for_rule "$REPO_ROOT" "$rule")

for rule_target in "${TARGETS[@]}"; do
echo "codebase: lint:$rule $rule_target"

output=""
if output=$(mise run -q "lint:$rule" "$rule_target" 2>&1); then
status=0
else
status=$?
fi

if [[ -n "$output" ]]; then
printf '%s\n' "$output"
fi

if [[ "$status" -ne 0 ]]; then
failures=$((failures + 1))
FAILED+=("lint:$rule ($rule_target) exited $status")
fi
done
done

if [[ "$failures" -gt 0 ]]; then
Expand Down
170 changes: 170 additions & 0 deletions .mise/tasks/lint/bash-empty-argv-forwarding
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
#MISE description="Flag direct empty-argv forwarding 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-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 <file>
# 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 <line>
# 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 <line>
# Returns 0 if the line already uses the alternate-value safe form.
has_safe_form() {
local line="$1"
# shellcheck disable=SC2016 # intentional: match literal '${@+"$@"}'
[[ "$line" == *'${@+"$@"}'* ]] && return 0
# shellcheck disable=SC2016 # intentional: match literal '${@+"${@}"}'
[[ "$line" == *'${@+"${@}"}'* ]] && return 0
return 1
}

# Pattern: "$@" or "${@}" in double quotes
ARGV_FORWARD_RE='"\$\{?@\}?"'

# has_line_ignore <line>
# 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 <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"
Loading