aboutsummaryrefslogtreecommitdiff
path: root/languages
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-31 11:43:03 -0500
committerCraig Jennings <c@cjennings.net>2026-05-31 11:43:03 -0500
commitb46619cd17ed4e36f2e59c1b600078521b2049ef (patch)
treef128aeef3f0f679a400595c896a98618266706d9 /languages
parent3640664e0fa11d7eb99c2900df57734b411e2d2b (diff)
downloadrulesets-b46619cd17ed4e36f2e59c1b600078521b2049ef.tar.gz
rulesets-b46619cd17ed4e36f2e59c1b600078521b2049ef.zip
feat(elisp): add coverage-summary to the Elisp bundle with missing-file detection
A line-weighted coverage total has a blind spot: a module no test loads never shows up in the SimpleCov report, so it can't drag the number down. The suite looks healthier than it is. This adds a summary that counts every source file on disk against the report and treats an absent file as 0%, weighting the project number by file instead of by line so untested modules stay visible. The script ships at languages/elisp/claude/scripts/coverage-summary.el, self-contained on stock Emacs (just the built-in json). It parses the undercover SimpleCov shape directly rather than depending on the editor's coverage engine, so it runs anywhere the bundle lands. I proved it against a real 103-file report: 93 tracked, 27 untested modules surfaced, project number 66.4%. Delivery follows the bundle convention. The script lives under the gitignored .claude/ footprint and gets auto-fixed on drift by sync-language-bundle.sh, which I made generic for any claude/scripts/* rather than coverage-specific. The Makefile targets ship as a project-owned fragment (languages/elisp/coverage-makefile.txt) that install-lang.sh seeds at the project root and sync drops into .ai/inbox/ when that convention exists. The bundle never edits the project's own Makefile. Tests: 12 ERT for the kernel (Normal/Boundary/Error per function), wired into make test via a new languages/*/tests/ discovery path, plus bats for the sync auto-fix and the inbox-drop guards. This is the Elisp pilot. The pattern is proven, so fanning out to Python, Go, and TypeScript is now a follow-up. Each one needs only its own parser and fragment. The plumbing is already generic.
Diffstat (limited to 'languages')
-rw-r--r--languages/elisp/claude/rules/elisp-testing.md6
-rw-r--r--languages/elisp/claude/scripts/coverage-summary.el172
-rw-r--r--languages/elisp/coverage-makefile.txt56
-rw-r--r--languages/elisp/tests/test-coverage-summary.el173
4 files changed, 407 insertions, 0 deletions
diff --git a/languages/elisp/claude/rules/elisp-testing.md b/languages/elisp/claude/rules/elisp-testing.md
index b727cbd..7c3a9ef 100644
--- a/languages/elisp/claude/rules/elisp-testing.md
+++ b/languages/elisp/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/languages/elisp/claude/scripts/coverage-summary.el b/languages/elisp/claude/scripts/coverage-summary.el
new file mode 100644
index 0000000..eb30c66
--- /dev/null
+++ b/languages/elisp/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
diff --git a/languages/elisp/coverage-makefile.txt b/languages/elisp/coverage-makefile.txt
new file mode 100644
index 0000000..c85ad90
--- /dev/null
+++ b/languages/elisp/coverage-makefile.txt
@@ -0,0 +1,56 @@
+# Elisp coverage — Makefile fragment + setup recommendation
+#
+# This file is owned by the project, not the rulesets bundle. The bundle never
+# edits your Makefile. Copy the two targets below into your own Makefile (and
+# adjust the variables at the top), then delete this file or keep it as a note.
+#
+# What you get:
+# make coverage runs the test suite under undercover, writing a
+# SimpleCov JSON report to .coverage/simplecov.json
+# make coverage-summary prints a per-file table, a unit-weighted project
+# number, and — the point — every source file on disk
+# that no test imported, counted as 0%.
+#
+# Why the summary matters: a module no test loads never appears in undercover's
+# output, so a line-weighted total silently skips it. The summary weights by
+# file and counts a missing file as 0%, so untested modules stay visible.
+#
+# ---------------------------------------------------------------------------
+# Prerequisite: undercover
+#
+# Add undercover to your test dependencies and arm it in your test runner
+# (e.g. tests/run-coverage-file.el) before the source under test is loaded:
+#
+# (when (require 'undercover nil t)
+# (undercover "modules/*.el"
+# (:report-format 'simplecov)
+# (:report-file ".coverage/simplecov.json")
+# (:merge-report t)))
+#
+# Sources must be loaded from .el (not byte-compiled .elc) for instrumentation
+# to attach — the coverage target deletes stale .elc first.
+# ---------------------------------------------------------------------------
+
+# Variables — adjust to your layout.
+EMACS ?= emacs
+SOURCE_DIR ?= modules
+COVERAGE_DIR ?= .coverage
+COVERAGE_FILE ?= $(COVERAGE_DIR)/simplecov.json
+# The summary script ships with the bundle under .claude/scripts/ (gitignored).
+COVERAGE_SUMMARY ?= .claude/scripts/coverage-summary.el
+
+coverage:
+ @rm -f $(COVERAGE_FILE) $(SOURCE_DIR)/*.elc
+ @mkdir -p $(COVERAGE_DIR)
+ @UNDERCOVER_FORCE=true $(EMACS) --batch -L $(SOURCE_DIR) -L tests \
+ $(foreach t,$(wildcard tests/test-*.el),-l $(t)) \
+ -f ert-run-tests-batch-and-exit
+ @$(MAKE) coverage-summary
+
+coverage-summary:
+ @if [ ! -f $(COVERAGE_FILE) ]; then \
+ echo "[!] No coverage file at $(COVERAGE_FILE). Run 'make coverage' first."; \
+ exit 1; \
+ fi
+ @$(EMACS) --batch -q -l $(COVERAGE_SUMMARY) \
+ --eval '(cj/coverage-print-module-summary "$(COVERAGE_FILE)" "$(SOURCE_DIR)" "$(CURDIR)")'
diff --git a/languages/elisp/tests/test-coverage-summary.el b/languages/elisp/tests/test-coverage-summary.el
new file mode 100644
index 0000000..5be03b3
--- /dev/null
+++ b/languages/elisp/tests/test-coverage-summary.el
@@ -0,0 +1,173 @@
+;;; test-coverage-summary.el --- Tests for the bundle coverage-summary kernel -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; ERT tests for languages/elisp/claude/scripts/coverage-summary.el.
+;;
+;; The kernel is the missing-file detection and the unit-weighted project
+;; number; the per-file table is incidental. Tests build a throwaway project
+;; tree (source files on disk) plus a SimpleCov JSON report, then assert the
+;; kernel's numbers against hand-computed values.
+;;
+;; Normal / Boundary / Error coverage for each pure function.
+
+;;; Code:
+
+(require 'ert)
+(require 'json)
+
+;; Load the script under test relative to this file, so the test runs from any cwd.
+(load (expand-file-name
+ "../claude/scripts/coverage-summary.el"
+ (file-name-directory (or load-file-name buffer-file-name)))
+ nil t)
+
+;; --- fixtures --------------------------------------------------------------
+
+(defun cs-test--write (path contents)
+ "Write CONTENTS to PATH, creating parent dirs."
+ (make-directory (file-name-directory path) t)
+ (with-temp-file path (insert contents)))
+
+(defmacro cs-test--with-project (spec &rest body)
+ "Build a temp project from SPEC and run BODY with `root', `src', `report' bound.
+
+SPEC is a plist:
+ :sources alist of (relpath . contents) source files written under src/
+ :report the SimpleCov JSON string written to .coverage/simplecov.json
+ (the literal string \"%SRC%\" is replaced with the absolute src dir)."
+ (declare (indent 1))
+ `(let* ((root (file-name-as-directory (make-temp-file "cs-proj-" t)))
+ (src (expand-file-name "src/" root))
+ (report (expand-file-name ".coverage/simplecov.json" root)))
+ (unwind-protect
+ (progn
+ (dolist (pair (plist-get ,spec :sources))
+ (cs-test--write (expand-file-name (car pair) src) (cdr pair)))
+ (cs-test--write
+ report
+ (replace-regexp-in-string
+ "%SRC%" (directory-file-name src) (plist-get ,spec :report) t t))
+ ,@body)
+ (delete-directory root t))))
+
+(defun cs-test--report (files)
+ "Build a SimpleCov JSON string for FILES.
+FILES is an alist of (relpath . line-vector-literal), where each line vector
+is a JSON array string like \"[1, 0, null]\"."
+ (concat
+ "{\"undercover.el\": {\"timestamp\": 0, \"coverage\": {"
+ (mapconcat
+ (lambda (pair)
+ (format "\"%%SRC%%/%s\": %s" (car pair) (cdr pair)))
+ files ", ")
+ "}}}"))
+
+;; --- parse: covered / total ------------------------------------------------
+
+(ert-deftest cs-parse-counts-covered-and-executable ()
+ "covered = entries > 0; total = numeric entries; null is non-executable."
+ (cs-test--with-project
+ (list :sources '(("a.el" . ";; a"))
+ :report (cs-test--report '(("a.el" . "[null, 1, 0, 3, null, 0]"))))
+ (let* ((table (cj/coverage-summary--parse-file report))
+ (rec (gethash (expand-file-name "a.el" src) table)))
+ ;; entries: null,1,0,3,null,0 -> numeric {1,0,3,0}=4 total, >0 {1,3}=2 covered
+ (should (equal rec '(2 . 4))))))
+
+(ert-deftest cs-parse-unions-multiple-suites ()
+ "Coverage data unions across multiple top-level suite keys."
+ (cs-test--with-project
+ (list :sources '(("a.el" . ";; a"))
+ :report (concat
+ "{\"s1\": {\"coverage\": {\"%SRC%/a.el\": [1, 0, 0]}},"
+ " \"s2\": {\"coverage\": {\"%SRC%/a.el\": [0, 1, 0]}}}"))
+ (let* ((table (cj/coverage-summary--parse-file report))
+ (rec (gethash (expand-file-name "a.el" src) table)))
+ ;; union of hits at line 1 (s1) and line 2 (s2): 2 covered of 3 total
+ (should (equal rec '(2 . 3))))))
+
+(ert-deftest cs-parse-missing-report-signals ()
+ "A nonexistent report file signals user-error."
+ (should-error (cj/coverage-summary--parse-file "/no/such/report.json")
+ :type 'user-error))
+
+(ert-deftest cs-parse-malformed-json-signals ()
+ "Malformed JSON signals user-error rather than a raw json error."
+ (cs-test--with-project
+ (list :sources '(("a.el" . ";; a")) :report "{not json")
+ (should-error (cj/coverage-summary--parse-file report) :type 'user-error)))
+
+;; --- file percentage -------------------------------------------------------
+
+(ert-deftest cs-file-pct-normal ()
+ (should (= 50.0 (cj/coverage-summary--file-pct 1 2))))
+
+(ert-deftest cs-file-pct-no-executable-lines-is-100 ()
+ "A tracked file with zero executable lines has nothing left uncovered."
+ (should (= 100.0 (cj/coverage-summary--file-pct 0 0))))
+
+(ert-deftest cs-file-pct-fully-covered ()
+ (should (= 100.0 (cj/coverage-summary--file-pct 4 4))))
+
+;; --- missing-file detection (the kernel) -----------------------------------
+
+(ert-deftest cs-missing-finds-ondisk-file-absent-from-report ()
+ "A source file on disk but not in the report is reported missing."
+ (cs-test--with-project
+ (list :sources '(("tracked.el" . ";; t") ("untested.el" . ";; u"))
+ :report (cs-test--report '(("tracked.el" . "[1, 1]"))))
+ (let* ((table (cj/coverage-summary--under-dir
+ (cj/coverage-summary--parse-file report) src root))
+ (tracked (let (ks) (maphash (lambda (k _v) (push k ks)) table) ks))
+ (missing (cj/coverage-summary--missing tracked src root)))
+ (should (equal missing (list (file-relative-name
+ (expand-file-name "src/untested.el" root)
+ root)))))))
+
+(ert-deftest cs-missing-empty-when-all-tracked ()
+ "No missing files when every source file appears in the report."
+ (cs-test--with-project
+ (list :sources '(("a.el" . ";; a"))
+ :report (cs-test--report '(("a.el" . "[1]"))))
+ (let* ((table (cj/coverage-summary--under-dir
+ (cj/coverage-summary--parse-file report) src root))
+ (tracked (let (ks) (maphash (lambda (k _v) (push k ks)) table) ks))
+ (missing (cj/coverage-summary--missing tracked src root)))
+ (should (null missing)))))
+
+;; --- project number (unit-weighted, missing as 0%) -------------------------
+
+(ert-deftest cs-project-pct-unit-weighted-with-missing-as-zero ()
+ "Project number averages per-file pct over tracked+missing; missing=0%."
+ (cs-test--with-project
+ (list :sources '(("full.el" . ";; f") ("half.el" . ";; h")
+ ("untested.el" . ";; u"))
+ :report (cs-test--report '(("full.el" . "[1, 1]")
+ ("half.el" . "[1, 0]"))))
+ ;; full=100%, half=50%, untested missing=0% -> (100+50+0)/3 = 50.0
+ (should (= 50.0 (cj/coverage-summary--project-pct report src root)))))
+
+(ert-deftest cs-project-pct-no-missing ()
+ "With every file tracked, the number is the plain unit average."
+ (cs-test--with-project
+ (list :sources '(("full.el" . ";; f") ("half.el" . ";; h"))
+ :report (cs-test--report '(("full.el" . "[1, 1]")
+ ("half.el" . "[1, 0]"))))
+ ;; (100 + 50) / 2 = 75.0
+ (should (= 75.0 (cj/coverage-summary--project-pct report src root)))))
+
+;; --- end-to-end text -------------------------------------------------------
+
+(ert-deftest cs-text-reports-missing-and-project-number ()
+ "The rendered summary names the missing file and the project percentage."
+ (cs-test--with-project
+ (list :sources '(("a.el" . ";; a") ("orphan.el" . ";; o"))
+ :report (cs-test--report '(("a.el" . "[1, 0]"))))
+ (let ((text (cj/coverage-summary-text report src root)))
+ (should (string-match-p "orphan\\.el" text))
+ (should (string-match-p "count as 0%" text))
+ ;; a.el = 50%, orphan missing = 0% -> 25.0%
+ (should (string-match-p "25\\.0%" text)))))
+
+(provide 'test-coverage-summary)
+;;; test-coverage-summary.el ends here