Skip to content
Merged
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
55 changes: 55 additions & 0 deletions .github/scripts/lint-shell.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# lint-shell.sh — shellcheck every shell script in the repo.
#
# Extracted from .github/workflows/ci.yml so the check is reproducible
# locally. CI calls this script; do not re-inline the logic into the workflow.
#
# Usage: bash .github/scripts/lint-shell.sh

set -euo pipefail

cd "$(dirname "${BASH_SOURCE[0]}")/../.."

if ! command -v shellcheck >/dev/null 2>&1; then
# Missing shellcheck is a hard failure in CI — skipping there would turn a
# broken runner image into a silently green build. Locally it is a warning,
# so the script stays usable without forcing an install.
if [[ -n "${CI:-}" ]]; then
echo "shellcheck not installed and CI is set — refusing to skip" >&2
exit 1
fi
echo "shellcheck not installed — skipping (CI still enforces it)" >&2
echo " install: sudo pacman -S shellcheck | brew install shellcheck" >&2
exit 0
fi

# Collect .sh files (exclude symlinks to avoid double-checking)
mapfile -t scripts < <(find . -name '*.sh' -not -path './.git/*' -not -type l | sort)

# Collect extensionless scripts in scripts/ dirs (exclude symlinks)
while IFS= read -r f; do
[[ -L "$f" ]] && continue
if head -1 "$f" 2>/dev/null | grep -qE '^#!/.*(bash|sh)'; then
scripts+=("$f")
fi
done < <(find . -path '*/scripts/*' -not -name '*.*' -not -path './.git/*' -type f | sort)

if [[ ${#scripts[@]} -eq 0 ]]; then
echo "No shell scripts found"
exit 0
fi

echo "Checking ${#scripts[@]} script(s)..."
shellcheck --severity=error "${scripts[@]}"

# Backstop: a PATH-injected mock must never delegate with `command <tool>`.
# `command` bypasses functions and aliases but NOT PATH lookup, so a mock whose
# directory is first on PATH re-executes itself without bound. Use
# tests/lib/mock-git.sh, which strips its own directory from PATH and carries a
# recursion depth guard.
if grep -rn --include='*.sh' -E '\)\s*command (git|gh|tea) ' tests/ 2>/dev/null; then
echo "" >&2
echo "ERROR: mock delegates via 'command <tool>' — this recurses through PATH." >&2
echo "Use write_mock_git from tests/lib/mock-git.sh instead." >&2
exit 1
fi
58 changes: 58 additions & 0 deletions .github/scripts/validate-all.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# validate-all.sh — run every check CI runs, in one command.
#
# This is the single source of truth for "what CI will check". CLAUDE.md points
# here rather than listing the commands, so the docs cannot drift out of sync
# with the workflow the way `rumdl .` did (it was never a valid invocation, so
# the documented markdown lint silently never ran).
#
# Each check is also a CI job in .github/workflows/ci.yml, which runs them in
# parallel for per-job status. Keep the two lists in step: if you add a job
# there, add it here.
#
# Usage: bash .github/scripts/validate-all.sh

set -uo pipefail

cd "$(dirname "${BASH_SOURCE[0]}")/../.."

declare -a names=() results=()
failed=0

run_check() {
local name="$1"
shift
echo "==============================="
echo "=== $name"
echo "==============================="
names+=("$name")
if "$@"; then
results+=("PASS")
else
results+=("FAIL")
failed=1
fi
echo ""
}

run_check "plugin tests" bash test.sh
run_check "plugin structure" bash .github/scripts/validate-plugins.sh
run_check "frontmatter" bash .github/scripts/validate-frontmatter.sh
run_check "markdown lint" rumdl check .
run_check "shell lint" bash .github/scripts/lint-shell.sh

echo "==============================="
echo "=== Summary"
echo "==============================="
for i in "${!names[@]}"; do
printf " %-20s [%s]\n" "${names[$i]}" "${results[$i]}"
done
echo ""

if [[ $failed -eq 0 ]]; then
echo "All checks passed."
else
echo "Some checks failed."
fi

exit "$failed"
20 changes: 1 addition & 19 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,22 +56,4 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Find and lint shell scripts
run: |
# Collect .sh files (exclude symlinks to avoid double-checking)
mapfile -t scripts < <(find . -name '*.sh' -not -path './.git/*' -not -type l | sort)

# Collect extensionless scripts in scripts/ dirs (exclude symlinks)
while IFS= read -r f; do
[[ -L "$f" ]] && continue
if head -1 "$f" 2>/dev/null | grep -qE '^#!/.*(bash|sh)'; then
scripts+=("$f")
fi
done < <(find . -path '*/scripts/*' -not -name '*.*' -not -path './.git/*' -type f | sort)

if [[ ${#scripts[@]} -eq 0 ]]; then
echo "No shell scripts found"
exit 0
fi

echo "Checking ${#scripts[@]} script(s)..."
shellcheck --severity=error "${scripts[@]}"
run: bash .github/scripts/lint-shell.sh
34 changes: 33 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,21 +210,53 @@ This is a GitHub-hosted repository. Use `gh` for all GitHub operations (PRs, iss

### Validation

CI runs four independent checks. Run all locally before pushing:
CI runs five independent checks. Run all of them locally with one command:

```bash
bash .github/scripts/validate-all.sh
```

**Prefer that over running the checks by hand.** It is the single source of
truth for what CI enforces, so the list cannot rot the way a copy in this file
does — it previously documented `rumdl .`, which is not a valid invocation and
silently linted nothing, and it omitted shell linting entirely.

The individual checks, if you need to run one in isolation:

```bash
bash test.sh # plugin tests
bash .github/scripts/validate-plugins.sh # plugin structure
bash .github/scripts/validate-frontmatter.sh # command/skill frontmatter
rumdl check . # markdown linting
bash .github/scripts/lint-shell.sh # shellcheck + mock-recursion guard
```

Adding a CI job means adding it to `validate-all.sh` too; the script says so.

Run a single test suite directly:

```bash
bash tests/permission-manager/test-*.sh
```

### Test mocks

Suites that need a fake `git` on `PATH` must use `write_mock_git` from
`tests/lib/mock-git.sh` rather than hand-writing one:

```bash
source "$SCRIPT_DIR/../lib/mock-git.sh"
write_mock_git "$MOCK_DIR" "https://github.com/owner/repo.git"
```

Never delegate unmatched subcommands with `command git "$@"`. `command` bypasses
functions and aliases but **not** `PATH` lookup, so a mock whose directory is
first on `PATH` re-executes itself without bound — this went unnoticed across
nine suites until something called a second git subcommand, then spawned 56k
processes and exhausted the terminal's cgroup pid limit. The shared helper
strips its own directory from `PATH` before delegating and carries a recursion
depth guard; `lint-shell.sh` fails the build if the old form reappears.

### Git hooks

Opt into the repo's pre-commit hooks (checks vendored utils drift) once per clone:
Expand Down
12 changes: 3 additions & 9 deletions tests/git-cli/test-body-args.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GIT_CLI="$SCRIPT_DIR/../../utils/git-cli"
source "$SCRIPT_DIR/../lib/mock-git.sh"

PASS=0
FAIL=0
Expand Down Expand Up @@ -43,15 +44,8 @@ skip_filtered() {
}

# Mock git so platform detection resolves to github.
cat >"$MOCK_DIR/git" <<'EOF'
#!/usr/bin/env bash
case "$*" in
"remote get-url origin") echo "https://github.com/owner/repo.git" ;;
"config user.name") echo "testuser" ;;
*) PATH=${PATH#"${0%/*}":}; exec git "$@" ;;
esac
EOF
chmod +x "$MOCK_DIR/git"
write_mock_git "$MOCK_DIR" "https://github.com/owner/repo.git" \
' "config user.name") echo "testuser" ;;'

# Mock gh: capture the inline body into BODY_FILE. issue/pr create still pass it
# via --body; issue/pr comment now post via `gh api ... -f body=VALUE`, so capture
Expand Down
19 changes: 3 additions & 16 deletions tests/git-cli/test-issue-write-json.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GIT_CLI="$SCRIPT_DIR/../../utils/git-cli"
source "$SCRIPT_DIR/../lib/mock-git.sh"

PASS=0
FAIL=0
Expand Down Expand Up @@ -103,14 +104,7 @@ EOF
# Platform selectors: choose the remote host so detect_platform resolves
# github (github.com fallback) vs gitea (matches the tea login host).
set_platform_github() {
cat >"$MOCK_DIR/git" <<'EOF'
#!/usr/bin/env bash
case "$*" in
"remote get-url origin") echo "https://github.com/owner/repo.git" ;;
*) PATH=${PATH#"${0%/*}":}; exec git "$@" ;;
esac
EOF
chmod +x "$MOCK_DIR/git"
write_mock_git "$MOCK_DIR" "https://github.com/owner/repo.git"
# tea present but with a non-github login → no match → github.com fallback.
cat >"$MOCK_DIR/tea" <<'EOF'
#!/usr/bin/env bash
Expand All @@ -122,14 +116,7 @@ EOF
}

set_platform_gitea() {
cat >"$MOCK_DIR/git" <<'EOF'
#!/usr/bin/env bash
case "$*" in
"remote get-url origin") echo "https://git.stonefish.tech/owner/repo.git" ;;
*) PATH=${PATH#"${0%/*}":}; exec git "$@" ;;
esac
EOF
chmod +x "$MOCK_DIR/git"
write_mock_git "$MOCK_DIR" "https://git.stonefish.tech/owner/repo.git"
write_tea_mock
}

Expand Down
12 changes: 3 additions & 9 deletions tests/git-cli/test-pr-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GIT_CLI="$SCRIPT_DIR/../../utils/git-cli"
source "$SCRIPT_DIR/../lib/mock-git.sh"

PASS=0
FAIL=0
Expand Down Expand Up @@ -70,15 +71,8 @@ MOCK_HEADER
}

# Mock git so platform detection works (returns github.com remote)
cat >"$MOCK_DIR/git" <<'EOF'
#!/usr/bin/env bash
case "$*" in
"remote get-url origin") echo "https://github.com/owner/repo.git" ;;
"config user.name") echo "testuser" ;;
*) PATH=${PATH#"${0%/*}":}; exec git "$@" ;;
esac
EOF
chmod +x "$MOCK_DIR/git"
write_mock_git "$MOCK_DIR" "https://github.com/owner/repo.git" \
' "config user.name") echo "testuser" ;;'

# ---------------------------------------------------------------------------
# Test: --base omitted → auto-detects default branch
Expand Down
10 changes: 2 additions & 8 deletions tests/git-cli/test-pr-show-gitea.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GIT_CLI="$SCRIPT_DIR/../../utils/git-cli"
source "$SCRIPT_DIR/../lib/mock-git.sh"

PASS=0
FAIL=0
Expand Down Expand Up @@ -46,14 +47,7 @@ skip_filter() {
# ---------------------------------------------------------------------------

# Mock git: report a Gitea remote so platform detection resolves to "gitea".
cat >"$MOCK_DIR/git" <<'EOF'
#!/usr/bin/env bash
case "$*" in
"remote get-url origin") echo "https://git.stonefish.tech/owner/repo.git" ;;
*) PATH=${PATH#"${0%/*}":}; exec git "$@" ;;
esac
EOF
chmod +x "$MOCK_DIR/git"
write_mock_git "$MOCK_DIR" "https://git.stonefish.tech/owner/repo.git"

# Mock tea:
# - login list → advertise a login for the remote host (so platform == gitea)
Expand Down
10 changes: 2 additions & 8 deletions tests/git-cli/test-run-show.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GIT_CLI="$SCRIPT_DIR/../../utils/git-cli"
source "$SCRIPT_DIR/../lib/mock-git.sh"

PASS=0
FAIL=0
Expand Down Expand Up @@ -45,14 +46,7 @@ skip_filter() {
# ---------------------------------------------------------------------------

# Mock git: report a Gitea remote so platform detection resolves to "gitea".
cat >"$MOCK_DIR/git" <<'EOF'
#!/usr/bin/env bash
case "$*" in
"remote get-url origin") echo "https://git.stonefish.tech/owner/repo.git" ;;
*) PATH=${PATH#"${0%/*}":}; exec git "$@" ;;
esac
EOF
chmod +x "$MOCK_DIR/git"
write_mock_git "$MOCK_DIR" "https://git.stonefish.tech/owner/repo.git"

# Mock tea:
# - login list → advertise a login for the remote host (so platform == gitea)
Expand Down
12 changes: 3 additions & 9 deletions tests/git-cli/test-run-watch-gitea.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GIT_CLI="$SCRIPT_DIR/../../utils/git-cli"
source "$SCRIPT_DIR/../lib/mock-git.sh"

PASS=0
FAIL=0
Expand Down Expand Up @@ -54,15 +55,8 @@ skip_filter() {
# Mock git: Gitea remote + a configurable rev-parse SHA for the branch.
write_git_mock() {
local sha="$1"
cat >"$MOCK_DIR/git" <<EOF
#!/usr/bin/env bash
case "\$*" in
"remote get-url origin") echo "https://git.stonefish.tech/owner/repo.git" ;;
"rev-parse feature-widget"|"rev-parse origin/feature-widget") echo "$sha" ;;
*) PATH=\${PATH#"\${0%/*}":}; exec git "\$@" ;;
esac
EOF
chmod +x "$MOCK_DIR/git"
write_mock_git "$MOCK_DIR" "https://git.stonefish.tech/owner/repo.git" \
" \"rev-parse feature-widget\"|\"rev-parse origin/feature-widget\") echo \"$sha\" ;;"
}

# Mock tea:
Expand Down
Loading
Loading