aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-27 13:02:19 -0500
committerCraig Jennings <c@cjennings.net>2026-07-27 13:02:19 -0500
commit0adcb1afcf64a4638f110a3ab0c616c48d86ba4f (patch)
tree461ffdb509408d0a5298fd986573bad0ab677cf2
parent6c1ea8bbc3775fe7c481f41b4f30e0c0110a9339 (diff)
downloadrulesets-0adcb1afcf64a4638f110a3ab0c616c48d86ba4f.tar.gz
rulesets-0adcb1afcf64a4638f110a3ab0c616c48d86ba4f.zip
fix(rules): honor declared file-type scopes with paths frontmatter
Three rules already declared a narrow scope. todo-format and org-tables name org files, emacs names elisp. But they declared it in an "Applies to:" line, which is prose. Claude Code scopes a rule by a paths field in YAML frontmatter. None of them had one, so all three loaded into every session in every project regardless of what their own first line said. That's 5,896 words of a 25,386-word rules surface, resident whether or not the session ever opened an org or elisp file. They now carry the frontmatter. The prose line stays, because it's what a human reads. The frontmatter is what the loader reads. Keeping both means they can disagree, so lint.sh now warns when a rule names a concrete extension in prose without matching frontmatter. That check flags exactly these three and nothing else. The heading check needed teaching too. It read line 1 and expected a Markdown heading, which frontmatter displaces. It now skips a leading frontmatter block and the blank line after it. A file with no heading and no frontmatter still warns. I haven't confirmed that paths frontmatter applies to user-level rules rather than project-level ones only. The docs describe it as a property of rules files without drawing that distinction. If it turns out to be project-only the frontmatter is inert and the rules load as before, so the downside is nothing. I'll check with /context next session.
-rw-r--r--claude-rules/emacs.md5
-rw-r--r--claude-rules/org-tables.md5
-rw-r--r--claude-rules/todo-format.md5
-rwxr-xr-xscripts/lint.sh36
4 files changed, 50 insertions, 1 deletions
diff --git a/claude-rules/emacs.md b/claude-rules/emacs.md
index 2c3b729..846888d 100644
--- a/claude-rules/emacs.md
+++ b/claude-rules/emacs.md
@@ -1,3 +1,8 @@
+---
+paths:
+ - "**/*.el"
+---
+
# Working With Craig's Running Emacs
Applies to: `**/*.el` (and any task that edits Craig's Emacs configuration)
diff --git a/claude-rules/org-tables.md b/claude-rules/org-tables.md
index 1b70085..bc9d27d 100644
--- a/claude-rules/org-tables.md
+++ b/claude-rules/org-tables.md
@@ -1,3 +1,8 @@
+---
+paths:
+ - "**/*.org"
+---
+
# Org Table Standard
Applies to: `**/*.org`
diff --git a/claude-rules/todo-format.md b/claude-rules/todo-format.md
index dc5e3b3..58570b1 100644
--- a/claude-rules/todo-format.md
+++ b/claude-rules/todo-format.md
@@ -1,3 +1,8 @@
+---
+paths:
+ - "**/*.org"
+---
+
# Todo Entry Format
Applies to: `**/*.org` (org-mode todo and inbox files)
diff --git a/scripts/lint.sh b/scripts/lint.sh
index 82f3b34..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