Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8525603
fix: support multi-path scope values in [_.codebase.scope]
olavostauros Jun 22, 2026
d98a783
feat(lint): add lint groups for standard convention bundles
olavostauros Jun 25, 2026
b5040ec
feat: add lint rule for Bash nounset empty array expansions
olavostauros Jun 23, 2026
d29959d
style: collapse 3-line section comment blocks to single line
olavostauros Jun 24, 2026
d809be9
fix: make bash-empty-array-expansions task executable for mise discovery
olavostauros Jun 25, 2026
827405e
feat: add lint rule for Bash nounset empty-argv forwarding
olavostauros Jun 23, 2026
b4f0f1e
style: collapse 3-line section comment blocks to single line
olavostauros Jun 24, 2026
bdf41bb
fix: make task executable, remove decorative rulers, suppress shellch…
olavostauros Jun 25, 2026
4e7b537
feat: add variadic-args lint rule (#21)
olavostauros Jun 24, 2026
6d1b69c
style: replace decorative rulers with single-line headers, add missin…
olavostauros Jun 25, 2026
2cb1d30
feat(lint): add exec-stderr-silence rule — flag persistent exec stder…
olavostauros Jun 24, 2026
70da2bd
feat(lib): add shared usage-parser.sh for #USAGE directive parsing
olavostauros Jun 24, 2026
aec74ec
feat(lint): add usage-flag-naming rule — detect USAGE flag/placeholde…
olavostauros Jun 24, 2026
334e8ff
fix: align scan USAGE placeholder <exclude> with flag name --exclude
olavostauros Jun 24, 2026
408355b
docs: add usage-flag-naming to AGENTS.md lint rules table
olavostauros Jun 24, 2026
1118024
style: collapse 3-line section comment blocks to single line
olavostauros Jun 24, 2026
befa66a
feat(lint): add mise-usage-examples rule — enforce #USAGE example for…
olavostauros Jun 25, 2026
2337baf
feat: add ci-lint-enforcement rule to require configured codebase lin…
olavostauros Jun 22, 2026
3cb68dc
style: replace decorative rulers with single-line headers
olavostauros Jun 25, 2026
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
58 changes: 40 additions & 18 deletions .mise/tasks/lint/_default
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
#MISE description="Run configured codebase convention lints"
#USAGE arg "[target]" default="." help="Path to the target repository"
#USAGE example "codebase lint /path/to/repo" header="Lint a repo"

set -euo pipefail

Expand All @@ -19,6 +20,13 @@ if [[ ! -f "$TOML" ]]; then
exit 1
fi

# Check if the original config uses groups, for preamble display.
ORIG_LINT="$(mise config get -f "$TOML" _.codebase.lint 2>/dev/null || true)"
HAS_GROUPS=false
if [[ "$ORIG_LINT" == *"@"* ]]; then
HAS_GROUPS=true
fi

RULES=()
while IFS= read -r rule; do
[[ -n "$rule" ]] && RULES+=("$rule")
Expand All @@ -29,31 +37,45 @@ if [[ ${#RULES[@]} -eq 0 ]]; then
echo "Add to mise.toml:" >&2
echo ' [_.codebase]' >&2
echo ' lint = ["mise-settings", "gum-table"]' >&2
echo " # Or use a preset group:" >&2
echo ' # lint = ["@maintained-tool"]' >&2
echo " # See 'mise run lint:groups' for available groups" >&2
exit 1
fi

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

failures=0
FAILED=()

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

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

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

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

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

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

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

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

if [[ "$failures" -gt 0 ]]; then
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