aboutsummaryrefslogtreecommitdiff
path: root/scripts/lint.sh
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/lint.sh')
-rwxr-xr-xscripts/lint.sh47
1 files changed, 46 insertions, 1 deletions
diff --git a/scripts/lint.sh b/scripts/lint.sh
index ae30aa5..ca6abbd 100755
--- a/scripts/lint.sh
+++ b/scripts/lint.sh
@@ -21,10 +21,22 @@ warn() {
errors=$((errors + 1))
}
+# Print a rule file's body with any leading YAML frontmatter stripped, so the
+# structural checks below see the Markdown regardless of whether the file
+# carries a `paths:` block. Claude Code reads the frontmatter; the heading check
+# should not care that it is there.
+md_body() {
+ awk 'NR==1 && $0=="---" {fm=1; next}
+ fm && $0=="---" {fm=0; next}
+ !fm {print}' "$1"
+}
+
check_md_heading() {
local f="$1"
[ -f "$f" ] || return 0
- if ! head -1 "$f" | grep -q '^# '; then
+ # First non-blank line, so a blank separator after frontmatter doesn't read as
+ # a missing heading.
+ if ! md_body "$f" | grep -m1 -v '^[[:space:]]*$' | grep -q '^# '; then
warn "$f — missing top-level heading"
fi
}
@@ -37,6 +49,27 @@ check_md_applies_to() {
fi
}
+# A rule whose prose declares a file-type scope must also carry `paths:`
+# frontmatter, or Claude Code loads it into every session regardless of what the
+# prose says. Three rules declared a narrow scope this way and were loaded
+# universally for as long as they shipped, because the declaration lived only in
+# a line the loader never reads. The prose is for the human; the frontmatter is
+# what actually scopes the load. Keep them saying the same thing.
+check_md_paths_frontmatter() {
+ local f="$1" applies
+ [ -f "$f" ] || return 0
+ applies=$(grep -m1 '^Applies to:' "$f" 2>/dev/null) || return 0
+ # A scope naming a concrete extension (`**/*.org`, `**/*.el`) is path-scopable.
+ # A bare `**/*` is genuinely universal and wants no frontmatter.
+ case "$applies" in
+ *'**/*.'*)
+ if ! head -1 "$f" | grep -q '^---$'; then
+ warn "$f — declares a file-type scope in prose but has no 'paths:' frontmatter; it loads in every session"
+ fi
+ ;;
+ esac
+}
+
check_hook() {
local f="$1"
[ -f "$f" ] || return 0
@@ -81,6 +114,7 @@ for f in claude-rules/*.md; do
[ -f "$f" ] || continue
check_md_heading "$f"
check_md_applies_to "$f"
+ check_md_paths_frontmatter "$f"
done
# Per-language rule files
@@ -99,6 +133,9 @@ for claude_md in languages/*/CLAUDE.md; do
check_md_heading "$claude_md"
done
+# Language-neutral default CLAUDE.md (install-lang's fallback when a bundle ships none)
+[ -f languages/default-CLAUDE.md ] && check_md_heading languages/default-CLAUDE.md
+
# Hook scripts
for h in languages/*/claude/hooks/*.sh languages/*/githooks/*; do
[ -f "$h" ] || continue
@@ -111,6 +148,14 @@ for s in scripts/*.sh; do
check_hook "$s"
done
+# Scripts `make install` symlinks onto PATH. Extensionless by convention, so
+# they need their own glob — scripts/*.sh above never matched them, which left
+# the most exposed shell in the repo as the only shell with no gate over it.
+for s in claude-templates/bin/*; do
+ [ -f "$s" ] || continue
+ check_hook "$s"
+done
+
# Markdown link validation across rules and skills
for f in claude-rules/*.md */SKILL.md; do
[ -f "$f" ] || continue