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
53 changes: 53 additions & 0 deletions .mise/tasks/lint/groups
Original file line number Diff line number Diff line change
@@ -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"]'
78 changes: 67 additions & 11 deletions lib/codebase-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>
#
Expand Down Expand Up @@ -56,8 +58,9 @@ codebase_resolve_repo() {

# codebase_configured_lint_rules <repo-root>
#
# 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"
Expand All @@ -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 <rule>
Expand Down Expand Up @@ -109,19 +155,29 @@ codebase_scope_for_rule() {
codebase_default_scope_for_rule "$rule"
}

# codebase_target_for_rule <repo-root> <rule>
# codebase_targets_for_rule <repo-root> <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
}
99 changes: 99 additions & 0 deletions lib/lint-groups.sh
Original file line number Diff line number Diff line change
@@ -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/<name>)
# 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 <rule-entry>...
#
# 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 <group-name>
#
# 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 <rule-name>...
#
# 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
}
38 changes: 38 additions & 0 deletions test/lint/default.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading