aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile5
-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
-rwxr-xr-xscripts/install-lang.sh11
-rwxr-xr-xscripts/sync-language-bundle.sh26
-rw-r--r--scripts/tests/install-lang.bats17
-rw-r--r--scripts/tests/sync-language-bundle.bats62
-rw-r--r--todo.org18
10 files changed, 545 insertions, 1 deletions
diff --git a/Makefile b/Makefile
index 01bd1ee..583ec1c 100644
--- a/Makefile
+++ b/Makefile
@@ -468,6 +468,11 @@ test: ## Run all test suites (pytest + ERT + bats)
echo "ert: $$(basename "$$f")"; \
emacs --batch -q -L .ai/scripts -l ert -l "$$f" -f ert-run-tests-batch-and-exit; \
done
+ @set -e; for f in languages/*/tests/test-*.el; do \
+ [ -e "$$f" ] || continue; \
+ echo "ert: $$(basename "$$f")"; \
+ emacs --batch -q -l ert -l "$$f" -f ert-run-tests-batch-and-exit; \
+ done
@set -e; for f in scripts/tests/*.bats .ai/scripts/tests/*.bats; do \
[ -e "$$f" ] || continue; \
echo "bats: $$(basename "$$f")"; \
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
diff --git a/scripts/install-lang.sh b/scripts/install-lang.sh
index 4097bde..0fc9ea8 100755
--- a/scripts/install-lang.sh
+++ b/scripts/install-lang.sh
@@ -76,6 +76,17 @@ if [ -f "$SRC/CLAUDE.md" ]; then
fi
fi
+# 3b. coverage-makefile.txt — project-owned Makefile fragment, seed on first
+# install. Never overwrites (the project edits its own copy) unless FORCE=1.
+if [ -f "$SRC/coverage-makefile.txt" ]; then
+ if [ -f "$PROJECT/coverage-makefile.txt" ] && [ "$FORCE" != "1" ]; then
+ echo " [skip] coverage-makefile.txt already exists (use FORCE=1 to overwrite)"
+ else
+ cp "$SRC/coverage-makefile.txt" "$PROJECT/coverage-makefile.txt"
+ echo " [ok] coverage-makefile.txt installed (copy its targets into your Makefile)"
+ fi
+fi
+
# 4. .gitignore — append missing lines (deduped, skip comments)
if [ -f "$SRC/gitignore-add.txt" ]; then
touch "$PROJECT/.gitignore"
diff --git a/scripts/sync-language-bundle.sh b/scripts/sync-language-bundle.sh
index b2db8cb..25af11b 100755
--- a/scripts/sync-language-bundle.sh
+++ b/scripts/sync-language-bundle.sh
@@ -84,6 +84,22 @@ surface() {
MANUAL=$((MANUAL + 1))
}
+# inbox_drop SRC BASENAME — offer a project-owned file via the .ai/ inbox.
+# Only acts when the project uses the .ai/ inbox convention (so a bundle target
+# without .ai/ is never given an empty one). No-op once the project has already
+# adopted the file at its root or has a copy waiting in the inbox.
+inbox_drop() {
+ local src="$1" base="$2" inbox="$PROJECT/.ai/inbox"
+ [ -f "$src" ] || return 0
+ [ -d "$inbox" ] || return 0
+ [ -f "$PROJECT/$base" ] && return 0 # already adopted at project root
+ ls "$inbox"/*"$base" >/dev/null 2>&1 && return 0 # already waiting in inbox
+ cp "$src" "$inbox/from-rulesets-$base"
+ ensure_header
+ OUT+=" inbox .ai/inbox/from-rulesets-$base (project-owned — adopt deliberately)"$'\n'
+ FIXED=$((FIXED + 1))
+}
+
# process_bundle KIND SRC_DIR — reconcile one bundle (KIND is language|team).
# A team overlay owns only its own rule files; a language bundle also owns the
# shared generic rules, its hooks/githooks, and surfaces settings.json.
@@ -131,6 +147,12 @@ process_bundle() {
fix "$f" "$PROJECT/.claude/$rel" exec
done < <(find "$src/claude/hooks" -type f)
fi
+ if [ -d "$src/claude/scripts" ]; then
+ while IFS= read -r f; do
+ rel="${f#"$src"/claude/}"
+ fix "$f" "$PROJECT/.claude/$rel"
+ done < <(find "$src/claude/scripts" -type f)
+ fi
if [ -d "$src/githooks" ]; then
while IFS= read -r f; do
rel="${f#"$src"/githooks/}"
@@ -138,6 +160,10 @@ process_bundle() {
done < <(find "$src/githooks" -type f)
fi
surface "$src/claude/settings.json" "$PROJECT/.claude/settings.json"
+ # The Makefile fragment is project-owned: never auto-fix it, never edit the
+ # project Makefile. If the project uses the .ai/ inbox convention and hasn't
+ # already adopted the fragment, drop a copy there for deliberate adoption.
+ inbox_drop "$src/coverage-makefile.txt" "coverage-makefile.txt"
fi
if [ "$MANUAL" -gt "$manual_before" ]; then
diff --git a/scripts/tests/install-lang.bats b/scripts/tests/install-lang.bats
index 523be99..a26c3d5 100644
--- a/scripts/tests/install-lang.bats
+++ b/scripts/tests/install-lang.bats
@@ -25,6 +25,23 @@ teardown() {
[ -d "$PROJECT/.claude/rules" ]
[ -d "$PROJECT/githooks" ]
[ -f "$PROJECT/CLAUDE.md" ]
+ # The coverage-summary script ships inside the gitignored .claude footprint.
+ [ -f "$PROJECT/.claude/scripts/coverage-summary.el" ]
+}
+
+@test "install-lang elisp: seeds the project-owned coverage Makefile fragment" {
+ run bash "$INSTALL_LANG" elisp "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ [ -f "$PROJECT/coverage-makefile.txt" ]
+}
+
+@test "install-lang elisp: does not overwrite an existing fragment without FORCE" {
+ echo "MY OWN VERSION" > "$PROJECT/coverage-makefile.txt"
+ run bash "$INSTALL_LANG" elisp "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ grep -qxF "MY OWN VERSION" "$PROJECT/coverage-makefile.txt"
}
@test "install-lang elisp: gitignores the full Claude tooling footprint" {
diff --git a/scripts/tests/sync-language-bundle.bats b/scripts/tests/sync-language-bundle.bats
index e641646..5e3b912 100644
--- a/scripts/tests/sync-language-bundle.bats
+++ b/scripts/tests/sync-language-bundle.bats
@@ -29,6 +29,10 @@ install_bundle() {
mkdir -p "$proj/.claude/hooks"
cp -r "$REAL_REPO/languages/$lang/claude/hooks/." "$proj/.claude/hooks/"
fi
+ if [ -d "$REAL_REPO/languages/$lang/claude/scripts" ]; then
+ mkdir -p "$proj/.claude/scripts"
+ cp -r "$REAL_REPO/languages/$lang/claude/scripts/." "$proj/.claude/scripts/"
+ fi
if [ -f "$REAL_REPO/languages/$lang/claude/settings.json" ]; then
cp "$REAL_REPO/languages/$lang/claude/settings.json" "$proj/.claude/settings.json"
fi
@@ -120,6 +124,64 @@ install_team_overlay() {
[ -x "$PROJ/.claude/hooks/validate-el.sh" ]
}
+# --- Auto-fix: .claude/scripts ---
+
+@test "sync: drifted bundle script is auto-fixed and restored" {
+ install_bundle elisp "$PROJ"
+ echo ";; junk drift" >> "$PROJ/.claude/scripts/coverage-summary.el"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *".claude/scripts/coverage-summary.el"* ]]
+ matches_canonical ".claude/scripts/coverage-summary.el" "$REAL_REPO/languages/elisp/claude/scripts/coverage-summary.el"
+}
+
+@test "sync: missing bundle script is re-copied" {
+ install_bundle elisp "$PROJ"
+ rm "$PROJ/.claude/scripts/coverage-summary.el"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *".claude/scripts/coverage-summary.el"* ]]
+ [ -f "$PROJ/.claude/scripts/coverage-summary.el" ]
+}
+
+# --- Project-owned: Makefile fragment via inbox ---
+
+@test "sync: coverage Makefile fragment is dropped into .ai/inbox when present" {
+ install_bundle elisp "$PROJ"
+ mkdir -p "$PROJ/.ai/inbox"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"inbox"* ]]
+ [ -f "$PROJ/.ai/inbox/from-rulesets-coverage-makefile.txt" ]
+ matches_canonical ".ai/inbox/from-rulesets-coverage-makefile.txt" "$REAL_REPO/languages/elisp/coverage-makefile.txt"
+}
+
+@test "sync: no .ai/inbox means no fragment drop and no empty .ai/ created" {
+ install_bundle elisp "$PROJ"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"inbox"* ]]
+ [ ! -d "$PROJ/.ai" ]
+}
+
+@test "sync: fragment already adopted at project root is not re-dropped" {
+ install_bundle elisp "$PROJ"
+ mkdir -p "$PROJ/.ai/inbox"
+ cp "$REAL_REPO/languages/elisp/coverage-makefile.txt" "$PROJ/coverage-makefile.txt"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ ! -f "$PROJ/.ai/inbox/from-rulesets-coverage-makefile.txt" ]
+}
+
+@test "sync: fragment already waiting in inbox is not duplicated" {
+ install_bundle elisp "$PROJ"
+ mkdir -p "$PROJ/.ai/inbox"
+ cp "$REAL_REPO/languages/elisp/coverage-makefile.txt" "$PROJ/.ai/inbox/from-rulesets-coverage-makefile.txt"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"inbox"* ]]
+}
+
# --- Surface-only: settings.json ---
@test "sync: drifted settings.json is surfaced, NOT modified, exit 3" {
diff --git a/todo.org b/todo.org
index 06deb72..f9bdf35 100644
--- a/todo.org
+++ b/todo.org
@@ -1124,11 +1124,14 @@ Scope:
Origin: came up while authoring =triage-intake.org= on 2026-05-11; body refreshed after the engine/plugin refactor on 2026-05-26.
-** TODO [#C] Templatize =make coverage-summary= into the language bundles :feature:solo:
+** DONE [#C] Templatize =make coverage-summary= into the language bundles (Elisp pilot) :feature:solo:
+CLOSED: [2026-05-31 Sun]
:PROPERTIES:
:LAST_REVIEWED: 2026-05-28
:END:
+Done 2026-05-31 (Elisp pilot, the scoped milestone): ported the kernel into the elisp bundle as a self-contained =languages/elisp/claude/scripts/coverage-summary.el= (no coverage-core dependency), proven end-to-end against the real dotemacs SimpleCov report (93 tracked, 27 untested modules surfaced, project number 66.4%). The missing-file-as-0% + unit-weighted number is the kernel. Delivery: the script ships under =.claude/scripts/= (gitignored, auto-fixed on drift by =sync-language-bundle.sh=); =languages/elisp/coverage-makefile.txt= holds the project-owned Makefile fragment, seeded at project root by =install-lang.sh= and dropped into =.ai/inbox/= by sync when that convention exists. Tests: 12 ERT (=languages/elisp/tests/=, wired into =make test=), 5 new sync bats, 2 new install-lang bats. The fan-out to Python/Go/TS is the follow-up below.
+
Borrow dotemacs's =make coverage-summary= into the language bundles. After =make coverage= writes a coverage file, =coverage-summary= prints per-unit covered/total with percentages, a unit-weighted project number, and a list of source files present on disk but missing from the coverage report.
*The kernel — the only part worth building.* Weight the project number by file/module rather than by line, and count a source file absent from the report as 0% instead of omitting it. A module no test imports just doesn't appear in coverage.py or nyc output, so it silently fails to drag the number down. That missing-file detection is the value; everything else (per-file table, total) the built-in reporters already print, so don't reimplement those.
@@ -1153,6 +1156,19 @@ Reference (dotemacs): =scripts/coverage-summary.el=, =modules/coverage-core.el=,
Origin: handoff from the .emacs.d session, 2026-05-25.
+** TODO [#C] Fan out coverage-summary to Python, Go, and TypeScript bundles :feature:
+:PROPERTIES:
+:CREATED: [2026-05-31 Sun]
+:END:
+
+The Elisp pilot proved the pattern (see the DONE task above). Each remaining bundle needs its own ~40-line parser over that tool's report format, plus a =coverage-makefile.txt= fragment and the prereq harness where one is missing. The bundle plumbing is already generic: =sync-language-bundle.sh= auto-fixes any =claude/scripts/*= and inbox-drops any =coverage-makefile.txt=; =install-lang.sh= seeds the fragment; =make test= discovers =languages/*/tests/test-*.el=. So each language is just: the parser script, its tests, and the fragment.
+
+- Python: =coverage json= per-file JSON, or lean on =coverage report=. Missing-file detection over the package's =*.py= on disk.
+- Go: =go test -coverprofile=cover.out=; parse =cover.out= (simple text), or =go tool cover -func=.
+- TypeScript/JS: nyc/Istanbul =coverage-final.json= / json-summary.
+
+Keep the kernel identical: file-weighted project number, source files absent from the report counted as 0%. Don't reimplement the per-file table where the built-in reporter already prints one — Python and JS both do, so those scripts can focus on the missing-file list and the project number.
+
** TODO [#B] Cross-project pattern catalog :spec:thinking:
:PROPERTIES:
:LAST_REVIEWED: 2026-05-28