diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-31 11:43:03 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-31 11:43:03 -0500 |
| commit | b46619cd17ed4e36f2e59c1b600078521b2049ef (patch) | |
| tree | f128aeef3f0f679a400595c896a98618266706d9 | |
| parent | 3640664e0fa11d7eb99c2900df57734b411e2d2b (diff) | |
| download | rulesets-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.
| -rw-r--r-- | Makefile | 5 | ||||
| -rw-r--r-- | languages/elisp/claude/rules/elisp-testing.md | 6 | ||||
| -rw-r--r-- | languages/elisp/claude/scripts/coverage-summary.el | 172 | ||||
| -rw-r--r-- | languages/elisp/coverage-makefile.txt | 56 | ||||
| -rw-r--r-- | languages/elisp/tests/test-coverage-summary.el | 173 | ||||
| -rwxr-xr-x | scripts/install-lang.sh | 11 | ||||
| -rwxr-xr-x | scripts/sync-language-bundle.sh | 26 | ||||
| -rw-r--r-- | scripts/tests/install-lang.bats | 17 | ||||
| -rw-r--r-- | scripts/tests/sync-language-bundle.bats | 62 | ||||
| -rw-r--r-- | todo.org | 18 |
10 files changed, 545 insertions, 1 deletions
@@ -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" { @@ -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 |
