From 8d8a9b8ec79ec2252b098713283884aeae80038e Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 31 May 2026 14:49:39 -0500 Subject: chore(claude): sync bundle rules and add coverage-summary script --- .claude/rules/elisp-testing.md | 6 ++ .claude/rules/interaction.md | 10 +++ .claude/scripts/coverage-summary.el | 172 ++++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 .claude/scripts/coverage-summary.el 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: +;; { : { "coverage": { : [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 -- cgit v1.2.3