aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-31 14:49:39 -0500
committerCraig Jennings <c@cjennings.net>2026-05-31 14:49:39 -0500
commit8d8a9b8ec79ec2252b098713283884aeae80038e (patch)
tree1e816cd004b408e91783ff516b3e5a028024b62f
parent12a2ce6a7eccf3a5093f7ac0dcd5b79a074a51f8 (diff)
downloaddotemacs-8d8a9b8ec79ec2252b098713283884aeae80038e.tar.gz
dotemacs-8d8a9b8ec79ec2252b098713283884aeae80038e.zip
chore(claude): sync bundle rules and add coverage-summary script
-rw-r--r--.claude/rules/elisp-testing.md6
-rw-r--r--.claude/rules/interaction.md10
-rw-r--r--.claude/scripts/coverage-summary.el172
3 files changed, 188 insertions, 0 deletions
diff --git a/.claude/rules/elisp-testing.md b/.claude/rules/elisp-testing.md
index b727cbd5..7c3a9efc 100644
--- a/.claude/rules/elisp-testing.md
+++ b/.claude/rules/elisp-testing.md
@@ -37,6 +37,12 @@ Every non-trivial function needs at least:
Missing a category is a test gap. If three cases look near-identical, parametrize with a loop or `dolist` rather than copy-pasting.
+### Measuring it — `make coverage-summary`
+
+The bundle ships a coverage summary at `.claude/scripts/coverage-summary.el` and a Makefile fragment (`coverage-makefile.txt`) with `coverage` and `coverage-summary` targets. After `make coverage` writes an undercover SimpleCov report, `make coverage-summary` prints a per-file table and a unit-weighted project number.
+
+The number to watch is the missing-file count. A module no test loads never appears in the SimpleCov report, so a line-weighted total skips it silently — the suite looks healthier than it is. The summary counts every `modules/*.el` on disk that's absent from the report as 0%, so an untested module drags the project number down where you can see it. Copy the fragment's targets into your own Makefile to adopt it; the bundle never edits your Makefile.
+
## TDD Workflow
Write the failing test first. A failing test proves you understand the change. Assume the bug is in production code until the test proves otherwise — never fix the test before proving the test is wrong.
diff --git a/.claude/rules/interaction.md b/.claude/rules/interaction.md
index 83afc45a..fa09d6d0 100644
--- a/.claude/rules/interaction.md
+++ b/.claude/rules/interaction.md
@@ -30,6 +30,16 @@ Reserve `AskUserQuestion` only when the user explicitly asks for the popup form
This rule applies to all three approval gates in the `commits.md` publish flow (commit message, PR description, PR review reply): print the draft inline, then offer numbered approve / changes / edit options inline. Do not switch to the popup form for the gate even though the prior protocol referenced it.
+### Order options with the recommendation at item 1
+
+When the question has a clear recommended option, put it at item 1. The other paths stay visible so the user can override without having to type a custom answer, but the common case collapses to a single keystroke.
+
+Default to this pattern whenever the user needs to weigh a genuine choice — task-review actions, brainstorm decisions, judgment calls, approve/edit/cancel gates, "what should I do next" prompts. Skip it only when the question is free-form (open prose, names, dates), when the user has already issued a directive and a single confirmation suffices, or when there is no clearly-recommended option (in which case state that plainly: "I don't have a recommendation here — what's your read?").
+
+Include a "Skip" / "Defer" / "Tell me how" as the last option when the user's answer might be "none of these."
+
+The convention reinforces the popup-denial rule above by giving every inline choice list a single canonical shape, and it forces the model to actually pick a recommendation rather than offering a neutrally-ordered enumerate-and-defer list. A neutrally-ordered list is a covert way to push the decision back; recommendation-first puts skin in the game.
+
**Enforcement:** a global `PreToolUse` hook (matcher `AskUserQuestion`) in `~/.claude/settings.json` hard-denies the popup and returns this rule as the reason — the prose alone proved too easy to forget. Because the deny is unconditional, the "use the popup for this one" exception above can't be honored in-turn; to get the popup, disable the hook via `/hooks` (or edit settings) first.
## No Reverse-Video Highlighting in Chat Output
diff --git a/.claude/scripts/coverage-summary.el b/.claude/scripts/coverage-summary.el
new file mode 100644
index 00000000..eb30c663
--- /dev/null
+++ b/.claude/scripts/coverage-summary.el
@@ -0,0 +1,172 @@
+;;; coverage-summary.el --- Whole-project coverage summary from a SimpleCov report -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Batch helper for `make coverage-summary'. After `make coverage' writes an
+;; undercover SimpleCov JSON report, this prints a per-file table, a project
+;; number, and the source files present on disk but absent from the report.
+;;
+;; The value here is the missing-file detection: a module no test imports never
+;; appears in the SimpleCov output, so it silently fails to drag the number
+;; down. This script counts such a file as 0% and weights the project number
+;; by file rather than by line, so untested modules are visible.
+;;
+;; Self-contained on purpose — it ships into a project's =.claude/scripts/= and
+;; must run with nothing but stock Emacs (`json' is built in). The SimpleCov
+;; JSON shape it parses is:
+;; { <suite>: { "coverage": { <abs-path>: [null | 0 | int, ...] } } }
+;; where a null entry is a non-executable line, 0 is executable-but-unhit, and
+;; any positive integer is a hit. Data unions across multiple suite keys.
+;;
+;; CLI contract (mirrors the dotemacs original):
+;; emacs --batch -l coverage-summary.el \
+;; --eval '(cj/coverage-print-module-summary REPORT SRC-DIR PROJECT-ROOT)'
+
+;;; Code:
+
+(require 'json)
+(require 'seq)
+
+(defun cj/coverage-summary--parse-file (report-file)
+ "Parse REPORT-FILE (SimpleCov JSON) into per-file (COVERED . TOTAL) counts.
+
+Keys are absolute source-file paths. TOTAL counts executable lines (numeric
+entries); COVERED counts hit lines (entries greater than zero). Data unions
+across every top-level suite key. Signals `user-error' when REPORT-FILE is
+missing or malformed."
+ (unless (file-exists-p report-file)
+ (user-error "Coverage report not found: %s" report-file))
+ (let* ((json-object-type 'hash-table)
+ (json-array-type 'list)
+ (json-key-type 'string)
+ (data (condition-case err
+ (json-read-file report-file)
+ (error (user-error "Malformed coverage JSON in %s: %s"
+ report-file (error-message-string err)))))
+ ;; path -> (covered-set . total-set), line numbers held in hash sets so
+ ;; unioning across suites never double-counts a shared line.
+ (acc (make-hash-table :test 'equal)))
+ (maphash
+ (lambda (_suite section)
+ (when (hash-table-p section)
+ (let ((coverage (gethash "coverage" section)))
+ (when (hash-table-p coverage)
+ (maphash
+ (lambda (path hits-list)
+ (let* ((cell (or (gethash path acc)
+ (puthash path
+ (cons (make-hash-table :test 'eql)
+ (make-hash-table :test 'eql))
+ acc)))
+ (covered (car cell))
+ (total (cdr cell))
+ (line 1))
+ (dolist (hits hits-list)
+ (when (numberp hits)
+ (puthash line t total)
+ (when (> hits 0) (puthash line t covered)))
+ (setq line (1+ line)))))
+ coverage)))))
+ data)
+ (let ((result (make-hash-table :test 'equal)))
+ (maphash (lambda (path cell)
+ (puthash path
+ (cons (hash-table-count (car cell))
+ (hash-table-count (cdr cell)))
+ result))
+ acc)
+ result)))
+
+(defun cj/coverage-summary--under-dir (table source-dir project-root)
+ "Filter TABLE to files under SOURCE-DIR, re-keyed relative to PROJECT-ROOT."
+ (let ((result (make-hash-table :test 'equal))
+ (source-dir (file-name-as-directory (expand-file-name source-dir)))
+ (project-root (file-name-as-directory (expand-file-name project-root))))
+ (maphash
+ (lambda (path counts)
+ (let ((abs (expand-file-name path)))
+ (when (string-prefix-p source-dir abs)
+ (puthash (file-relative-name abs project-root) counts result))))
+ table)
+ result))
+
+(defun cj/coverage-summary--source-files (source-dir project-root)
+ "Return *.el files directly under SOURCE-DIR, relative to PROJECT-ROOT.
+Sorted; compiled files and subdirectories are out of scope."
+ (let ((source-dir (file-name-as-directory (expand-file-name source-dir)))
+ (project-root (file-name-as-directory (expand-file-name project-root))))
+ (sort (mapcar (lambda (p) (file-relative-name p project-root))
+ (directory-files source-dir t "\\.el\\'"))
+ #'string<)))
+
+(defun cj/coverage-summary--missing (tracked source-dir project-root)
+ "Return source files present on disk but absent from TRACKED.
+TRACKED is a list of project-relative paths (the report's keys under
+SOURCE-DIR). The difference is the set of files no test exercised."
+ (seq-difference
+ (cj/coverage-summary--source-files source-dir project-root)
+ tracked
+ #'string=))
+
+(defun cj/coverage-summary--file-pct (covered total)
+ "Return COVERED/TOTAL as a percentage.
+A file with no executable lines (TOTAL 0) is 100% — nothing left uncovered."
+ (if (> total 0) (/ (* 100.0 covered) total) 100.0))
+
+(defun cj/coverage-summary--project-pct (report-file source-dir project-root)
+ "Return the unit-weighted project coverage percentage.
+Every tracked file contributes its own percentage; every source file missing
+from REPORT-FILE contributes 0%. The result is the mean over all files under
+SOURCE-DIR, so an untested module drags the number down instead of vanishing."
+ (let* ((tracked (cj/coverage-summary--under-dir
+ (cj/coverage-summary--parse-file report-file)
+ source-dir project-root))
+ (keys (let (ks) (maphash (lambda (k _v) (push k ks)) tracked) ks))
+ (missing (cj/coverage-summary--missing keys source-dir project-root))
+ (score 0.0)
+ (total-count (+ (hash-table-count tracked) (length missing))))
+ (maphash (lambda (_k counts)
+ (setq score (+ score (cj/coverage-summary--file-pct
+ (car counts) (cdr counts)))))
+ tracked)
+ (if (> total-count 0) (/ score total-count) 0.0)))
+
+(defun cj/coverage-summary-text (report-file source-dir project-root)
+ "Return a whole-project coverage summary for SOURCE-DIR from REPORT-FILE."
+ (let* ((tracked (cj/coverage-summary--under-dir
+ (cj/coverage-summary--parse-file report-file)
+ source-dir project-root))
+ (rel-src (file-relative-name
+ (expand-file-name source-dir)
+ (file-name-as-directory (expand-file-name project-root))))
+ (keys (let (ks) (maphash (lambda (k _v) (push k ks)) tracked) ks))
+ (missing (cj/coverage-summary--missing keys source-dir project-root))
+ (pct (cj/coverage-summary--project-pct report-file source-dir project-root)))
+ (with-temp-buffer
+ (insert (format "Coverage summary for %s\n\n" rel-src))
+ (dolist (path (sort keys #'string<))
+ (let* ((counts (gethash path tracked))
+ (covered (car counts))
+ (total (cdr counts)))
+ (insert (format " %6.1f%% %s (%d/%d lines)\n"
+ (cj/coverage-summary--file-pct covered total)
+ path covered total))))
+ (insert (format "\nProject coverage: %.1f%% (%d tracked, %d missing, %d total; missing files count as 0%%)\n"
+ pct (hash-table-count tracked) (length missing)
+ (+ (hash-table-count tracked) (length missing))))
+ (insert (format "\nNot in coverage report: %d file%s\n"
+ (length missing) (if (= 1 (length missing)) "" "s")))
+ (if missing
+ (progn
+ (insert "These files had no coverage entry; they count as 0% in project coverage.\n")
+ (dolist (path (sort missing #'string<))
+ (insert (format " %s\n" path))))
+ (insert "Every source file appears in the coverage report.\n"))
+ (buffer-string))))
+
+(defun cj/coverage-print-module-summary (report-file source-dir project-root)
+ "Print a whole-project coverage summary for SOURCE-DIR from REPORT-FILE."
+ (princ "\n")
+ (princ (cj/coverage-summary-text report-file source-dir project-root)))
+
+(provide 'coverage-summary)
+;;; coverage-summary.el ends here