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
92 changes: 92 additions & 0 deletions .mise/tasks/lint/usage-flag-naming
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
#MISE description="Flag #USAGE flag directives where flag name and placeholder produce different usage_* env var names"
#USAGE arg "<targets>…" help="Paths to codebases to check (one or more)"
#USAGE example "codebase lint:usage-flag-naming ." header="Flag mismatched USAGE flag/placeholder"
#USAGE example "codebase lint:usage-flag-naming /path/to/repo" header="Check a specific repo"

set -euo pipefail

# shellcheck source=../../../lib/shell-files.sh
source "$MISE_CONFIG_ROOT/lib/shell-files.sh"
# shellcheck source=../../../lib/usage-parser.sh
source "$MISE_CONFIG_ROOT/lib/usage-parser.sh"

IFS=' ' read -ra TARGETS <<< "${usage_targets}"

if [[ ${#TARGETS[@]} -eq 0 ]]; then
echo "ERROR: at least one target is required" >&2
exit 1
fi

for i in "${!TARGETS[@]}"; do
TARGETS[$i]=$(resolve_target "${TARGETS[$i]}")
done

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 -q 'codebase:ignore usage-flag-naming' "$toml" 2>/dev/null; then
echo "SKIP $name (codebase:ignore)"
continue
fi

tasks_dir="$target/.mise/tasks"
if [[ ! -d "$tasks_dir" ]]; then
echo "OK $name (no .mise/tasks directory)"
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 task files found)"
continue
fi

target_failures=0
target_output=""

for task_file in "${task_files[@]}"; do
rel="${task_file#"$target/"}"

while IFS=$'\t' read -r lineno flag_name placeholder; do
[[ -z "$lineno" ]] && continue

# Boolean flag (no placeholder) — skip
[[ -z "$placeholder" ]] && continue

flag_env=$(usage_env_name "$flag_name")
placeholder_env=$(usage_env_name "$placeholder")

if [[ "$flag_env" != "$placeholder_env" ]]; then
target_failures=$((target_failures + 1))
target_output+=" $rel:$lineno: --${flag_name#--} ($flag_env) vs <$placeholder> ($placeholder_env)"$'\n'
fi
done < <(parse_usage_flags "$task_file")
done

if [[ "$target_failures" -gt 0 ]]; then
echo "FAIL $name: $target_failures flag/placeholder mismatch(es)"
printf '%s' "$target_output"
echo " hint: use '--flag-name <flag_name>' so mise's usage_* env var matches what the task reads"
echo " or annotate with '# codebase:ignore=usage-flag-naming -- reason'"
failures=$((failures + 1))
else
echo "OK $name (${#task_files[@]} file(s) clean)"
fi
done

exit "$failures"
2 changes: 1 addition & 1 deletion .mise/tasks/scan
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
#USAGE arg "<targets>…" help="Paths to codebases to scan (one or more)"
#USAGE flag "-p --pattern <pattern>" required=#true help="ast-grep pattern to search for"
#USAGE flag "-l --lang <lang>" help="Language (default: bash)"
#USAGE flag "-e --exclude <excludes>" help="Glob patterns to exclude (space-separated)"
#USAGE flag "-e --exclude <exclude>" help="Glob patterns to exclude (space-separated)"

set -euo pipefail

Expand Down
77 changes: 77 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# AGENTS.md — codebase

**Structural code analysis, linting & migrations.** A toolbox of convention lints, AST-based scanning, and codemod migrations for KnickKnackLabs repos. Designed to be used as a shiv dependency by other repos.

## Commands

| Command | Description |
|---------|-------------|
| `lint [target]` | Run all configured convention lints (from repo's `[_.codebase]` mise config) |
| `lint:<name> <targets...>` | Run a specific lint check by name |
| `scan <targets...> -p <pattern> -l <lang>` | AST pattern matching via ast-grep |
| `migrate task-pattern <target>` | Migrate mise run calls to `_task` pattern (or reverse) |
| `pre-commit [--check \| --revert]` | Install, check, or remove the codebase pre-commit hook |
| `test [args...]` | Run the BATS test suite |

### Available lint checks

| Lint | What it checks |
|------|---------------|
| `mise-settings` | `mise.toml` has required settings (`experimental`, `quiet`, `task_output`) |
| `gum-table` | Detects manual table formatting that should use `gum table` |
| `bats-test-helper` | Flags direct script invocation from BATS tests (should call the tool) |
| `bats-test-task` | Enforces canonical BATS test-task shape in `.mise/tasks/test` |
| `mcr-scope` | Forbids `MISE_CONFIG_ROOT` in `test/` and `lib/` |
| `or-true` | Classifies risky unannotated `\|\| true` / `\|\| :` failure suppression |
| `shellcheck` | Runs shellcheck against shell files |
| `caller-pwd-contract` | Checks shiv caller-cwd environment variable contract |
| `github-actions` | Lints workflows and creates a default KKL workflow when missing |
| `bash-empty-array-expansions` | Flags empty array expansions under nounset (macOS Bash 3.2 compat) |
| `usage-flag-naming` | Detects #USAGE flag directives where flag name and arg placeholder produce different `usage_*` env var names (flag silently does nothing) |

## Install

```bash
shiv install codebase
```

Projects add it to `mise.toml`:

```toml
[tools]
"shiv:codebase" = "latest"

[_.codebase]
lint = [
"mise-settings",
"bats-test-task",
"or-true",
"shellcheck",
"caller-pwd-contract",
"github-actions",
]
```

## Conventions

### Commentary style

Use single-line `# Section title` comments for test section headers. Do not use three-line `# ====== / # Title / # ======` dividers — they waste vertical space without adding information.

```bash
# Good — single line
# Detection

@test "..." {

# Bad — three-line divider
# ====================================================================
# Detection
# ====================================================================
```

## Key patterns

- Lint checks are per-repo opt-in via `[_.codebase]` in `mise.toml`
- Uses `ast-grep` for structural pattern matching (not regex)
- Sub-commands: `lint/` directory contains individual lint scripts, `migrate/` contains codemod scripts
117 changes: 117 additions & 0 deletions lib/usage-parser.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Shared helpers for parsing #USAGE directives in .mise/tasks files.
#
# Source from .mise/tasks/lint/* (task context):
# # shellcheck source=../../lib/usage-parser.sh
# source "$MISE_CONFIG_ROOT/lib/usage-parser.sh"
#
# MISE_CONFIG_ROOT is the right primitive here — mise dispatched the task, so
# it's set correctly. Do NOT source this file from test helpers or other lib
# files using $MISE_CONFIG_ROOT; see fold/notes/mise-gotchas.md.

# usage_flag_name <directive_text>
#
# Given a #USAGE flag directive like '#USAGE flag "-e --exclude <pattern>"',
# extract the long flag name with -- prefix. Returns empty if no long flag.
#
# Examples:
# '#USAGE flag "--verbose"' → "--verbose"
# '#USAGE flag "--model <model>"' → "--model"
# '#USAGE flag "-e --exclude <x>"' → "--exclude"
# '#USAGE flag "-e"' → ""
usage_flag_name() {
local directive="$1"
# Match a '--' word. The long flag appears before any placeholder.
if [[ "$directive" =~ --([a-zA-Z0-9_-]+) ]]; then
printf '%s\n' "--${BASH_REMATCH[1]}"
fi
}

# usage_placeholder <directive_text>
#
# Given a #USAGE flag directive, extract the arg placeholder <...> text
# (without angle brackets). Returns empty if no placeholder.
#
# Examples:
# '#USAGE flag "--model <model>"' → "model"
# '#USAGE flag "--exclude <x>"' → "x"
# '#USAGE flag "--verbose"' → ""
usage_placeholder() {
local directive="$1"
if [[ "$directive" =~ '<'([a-zA-Z0-9_-]+)'>' ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
fi
}

# usage_env_name <flag_or_placeholder_name>
#
# Convert a flag name (with --) or placeholder name to the mise usage_* env var
# name: strip leading --, replace hyphens with underscores.
#
# Examples:
# "--exclude" → "usage_exclude"
# "excludes" → "usage_excludes"
# "file-path" → "usage_file_path"
usage_env_name() {
local name="$1"
# Strip leading -- if present
name="${name#--}"
# Replace hyphens with underscores (mise normalises hyphens to underscores)
name="${name//-/_}"
printf 'usage_%s\n' "$name"
}

# parse_usage_flags <task_file>
#
# Read a task file and emit tab-separated lines of the form:
# <line_number>\t<flag_name>\t<placeholder>
# for each #USAGE flag directive found.
#
# - Empty placeholder means boolean flag (no arg).
# - Empty flag_name means short-only flag (e.g. -v, no --long form).
# - Lines with codebase:ignore are skipped (respect inline opt-out).
#
# Output is tab-separated for reliable parsing with IFS=$'\t' read -r.
parse_usage_flags() {
local file="$1"
local lineno=0
local line

while IFS= read -r line || [[ -n "$line" ]]; do
lineno=$((lineno + 1))

# Non-directive lines are skipped
[[ "$line" != *"#USAGE flag"* ]] && continue

# Inline opt-out
[[ "$line" == *"codebase:ignore"* ]] && continue

local flag_name
local placeholder

flag_name=$(usage_flag_name "$line")
placeholder=$(usage_placeholder "$line")

# Skip short-only flags (no long name) — nothing to compare
[[ -z "$flag_name" ]] && continue

printf '%s\t%s\t%s\n' "$lineno" "$flag_name" "$placeholder"
done < "$file"
}

# discover_task_files <target>
#
# Emit paths of task files under <target>/.mise/tasks/, one per line.
# Excludes subdirectories named fixtures/ (lint-rule fixtures intentionally
# contain synthetic patterns that should not trigger self-flagging during
# self-hosting scans).
discover_task_files() {
local target="$1"
local tasks_dir="$target/.mise/tasks"

if [[ ! -d "$tasks_dir" ]]; then
return
fi

fd --type f --exclude fixtures . "$tasks_dir" 2>/dev/null || true
}
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
14 changes: 0 additions & 14 deletions test/lint/bats-test-helper/bats-test-helper.bats
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ setup() {
FIXTURES="$BATS_TEST_DIRNAME/fixtures"
}

# ============================================================================
# Pass paths
# ============================================================================

@test "bats-test-helper: passes on clean wrapper-based tests" {
run codebase lint:bats-test-helper "$FIXTURES/clean"
Expand All @@ -24,9 +22,7 @@ setup() {
[[ "$output" == *"no test/ files found"* ]]
}

# ============================================================================
# Invocation signatures — each form fails
# ============================================================================

@test "bats-test-helper: flags 'bash \$TASK' (var whose name contains TASK)" {
run codebase lint:bats-test-helper "$FIXTURES/dirty-task-var"
Expand Down Expand Up @@ -84,9 +80,7 @@ setup() {
[[ "$output" == *"bash \$REPO_DIR/.mise/tasks/lint/check"* ]]
}

# ============================================================================
# False positives — none of these are actual invocations
# ============================================================================

@test "bats-test-helper: does NOT flag reading a task file as data (grep/cat)" {
# Regression guard: 'grep "$MCR/.mise/tasks/foo"' reads the script, doesn't
Expand All @@ -97,9 +91,7 @@ setup() {
[[ "$output" == *"OK"* ]]
}

# ============================================================================
# Ignore directives
# ============================================================================

@test "bats-test-helper: inline '# codebase:ignore' suppresses a single line" {
run codebase lint:bats-test-helper "$FIXTURES/ignored-inline"
Expand All @@ -113,9 +105,7 @@ setup() {
[[ "$output" == *"SKIP"*"ignored-file"* ]]
}

# ============================================================================
# Output details
# ============================================================================

@test "bats-test-helper: fail output includes file:line citations" {
run codebase lint:bats-test-helper "$FIXTURES/dirty-task-var"
Expand All @@ -130,9 +120,7 @@ setup() {
[[ "$output" == *"Call the Tool"* ]]
}

# ============================================================================
# Error handling
# ============================================================================

@test "bats-test-helper: fails when no targets given" {
run codebase lint:bats-test-helper
Expand All @@ -146,9 +134,7 @@ setup() {
[[ "$output" == *"does not exist"* ]]
}

# ============================================================================
# Multi-target
# ============================================================================

@test "bats-test-helper: accepts multiple targets and reports each" {
run codebase lint:bats-test-helper "$FIXTURES/clean" "$FIXTURES/dirty-task-var"
Expand Down
Loading