aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-23 01:28:35 -0500
committerCraig Jennings <c@cjennings.net>2026-04-23 01:28:35 -0500
commit305a58c6fcc5e9321f5b94011124b16ea73e2f00 (patch)
treeac605320bdf8ab890bb025c6df1c9770a2e6ec42
parentfe142a8d9268c36b6b8fd363e60cb587dded1602 (diff)
downloaddotemacs-305a58c6fcc5e9321f5b94011124b16ea73e2f00.tar.gz
dotemacs-305a58c6fcc5e9321f5b94011124b16ea73e2f00.zip
feat(coverage): add whole-project scope to cj/coverage-report
Adds a fifth entry to the scope completing-read menu: "Whole project — all executable lines". Uses the existing cj/coverage-report flow, so the user still hits F7 and picks from the menu; the command dispatches based on the chosen scope. Two new pure helpers back the scope: - cj/--coverage-simplecov-executable-lines parses the simplecov JSON and returns every executable line per file (both hit lines and 0-hit lines, excluding null/non-executable entries). Symmetric with cj/--coverage-parse-simplecov, which returns only hit lines. - cj/--coverage-format-summary renders intersect records as a per-file percentage summary sorted ascending by coverage (worst-covered first). Used instead of the line-detail format-report because an entire project's uncovered lines would be thousands of entries. cj/--coverage-read-and-display now branches on scope: whole-project feeds executable-lines as the "changed" input to intersect; diff-aware scopes still shell git diff as before. cj/--coverage-render-to-buffer branches similarly to pick the format helper. Tests cover the two new helpers: Normal (basic extraction, sorted output, percentages), Boundary (all-null coverage, multiple test-name keys unioned, empty records, not-tracked files excluded), and Error (missing file signals user-error). Verified end-to-end on the current .coverage/simplecov.json: 2717 of 4559 lines covered across 44 files, sorted from keybindings.el at 0% up through high-coverage modules.
-rw-r--r--modules/coverage-core.el116
-rw-r--r--tests/test-coverage-core--whole-project.el137
2 files changed, 247 insertions, 6 deletions
diff --git a/modules/coverage-core.el b/modules/coverage-core.el
index a801b1d5..b6723eca 100644
--- a/modules/coverage-core.el
+++ b/modules/coverage-core.el
@@ -65,6 +65,47 @@ Returns the backend plist, or nil when no backend matches."
(funcall (plist-get backend :detect) root))
cj/coverage-backends))))
+(defun cj/--coverage-simplecov-executable-lines (file)
+ "Parse FILE (a simplecov JSON report) and return executable lines per file.
+Symmetric with `cj/--coverage-parse-simplecov', but includes every line
+whose simplecov array entry is a number (hits > 0 AND 0-hit lines).
+Only null entries (not executable: blank, comment) are excluded.
+
+Used by the whole-project scope of `cj/coverage-report', where we treat
+every executable line as if it were a changed line so the same intersect
++ format pipeline applies.
+
+Signals `user-error' if FILE does not exist or contains malformed JSON."
+ (unless (file-exists-p file)
+ (user-error "Simplecov report not found: %s" file))
+ (require 'json)
+ (let* ((json-object-type 'hash-table)
+ (json-array-type 'list)
+ (json-key-type 'string)
+ (data (condition-case err
+ (json-read-file file)
+ (error (user-error "Malformed simplecov JSON in %s: %s"
+ file (error-message-string err)))))
+ (result (make-hash-table :test 'equal)))
+ (maphash
+ (lambda (_test-name section)
+ (when (hash-table-p section)
+ (let ((coverage (gethash "coverage" section)))
+ (when (hash-table-p coverage)
+ (maphash
+ (lambda (path hits-list)
+ (let ((lines (or (gethash path result)
+ (make-hash-table :test 'eql)))
+ (line-num 1))
+ (dolist (hits hits-list)
+ (when (numberp hits)
+ (puthash line-num t lines))
+ (setq line-num (1+ line-num)))
+ (puthash path lines result)))
+ coverage)))))
+ data)
+ result))
+
(defun cj/--coverage-parse-simplecov (file)
"Parse FILE as a simplecov JSON report and return covered lines per file.
Keys are source-file paths (strings). Values are hash tables whose
@@ -297,16 +338,66 @@ omitted from the display. The summary counts only tracked files."
(insert "\n"))
(buffer-string)))))
+(defun cj/--coverage-format-summary (records scope-label)
+ "Render RECORDS as a per-file percentage summary for SCOPE-LABEL.
+Used for the whole-project scope where line-level drill-down would
+be thousands of entries. Shows totals plus per-file coverage (sorted
+by percentage ascending — worst-covered first). Files with :tracked
+nil are excluded. Empty RECORDS produces a \"No coverage data\" note."
+ (let ((tracked (seq-filter (lambda (rec) (plist-get rec :tracked))
+ records)))
+ (if (null tracked)
+ (format "Coverage Summary — %s\n\nNo coverage data available.\n"
+ scope-label)
+ (let* ((rows (mapcar
+ (lambda (rec)
+ (let* ((path (plist-get rec :path))
+ (covered (length (plist-get rec :covered-lines)))
+ (total (length (plist-get rec :changed-lines)))
+ (pct (if (> total 0)
+ (/ (* 100.0 covered) total)
+ 0.0)))
+ (list pct covered total path)))
+ tracked))
+ (sorted (sort rows (lambda (a b) (< (car a) (car b)))))
+ (total-covered (apply #'+ (mapcar #'cadr rows)))
+ (total-lines (apply #'+ (mapcar #'caddr rows)))
+ (overall-pct (if (> total-lines 0)
+ (/ (* 100.0 total-covered) total-lines)
+ 0.0))
+ (max-path-len (apply #'max
+ (mapcar (lambda (r) (length (cadddr r)))
+ rows)))
+ (row-format (format " %%-%ds %%4d/%%-4d (%%5.1f%%%%)\n"
+ max-path-len)))
+ (with-temp-buffer
+ (let ((header (format "Coverage Summary — %s" scope-label)))
+ (insert header "\n")
+ (insert (make-string (length header) ?=) "\n\n"))
+ (insert (format "Total: %d of %d lines covered (%.1f%%) across %d files\n\n"
+ total-covered total-lines overall-pct (length rows)))
+ (insert "Per-file coverage (worst first):\n")
+ (dolist (row sorted)
+ (pcase-let ((`(,pct ,covered ,total ,path) row))
+ (insert (format row-format path covered total pct))))
+ (buffer-string))))))
+
;;; --- Scope selection ---
(defconst cj/--coverage-scope-alist
'(("Working tree — all uncommitted changes" . working-tree)
("Staged — about to commit" . staged)
("Branch vs parent" . branch-vs-parent)
- ("Branch vs main" . branch-vs-main))
+ ("Branch vs main" . branch-vs-main)
+ ("Whole project — all executable lines" . whole-project))
"Alist mapping human-readable scope labels to scope symbols.
Used by `cj/--coverage-select-scope' for the `completing-read' prompt
-and by the report-buffer header to show which scope was picked.")
+and by the report-buffer header to show which scope was picked.
+
+The diff-aware scopes (working-tree, staged, branch-vs-*) render the
+line-level \"Uncovered lines\" report. The whole-project scope renders
+a per-file percentage summary instead, since line-level drill-down
+across an entire codebase would be thousands of entries.")
(defun cj/--coverage-scope-from-label (label)
"Return the scope symbol for human-readable LABEL, or nil if unknown."
@@ -334,9 +425,16 @@ Returns the selected scope symbol (e.g. `staged')."
(defun cj/--coverage-render-to-buffer (records scope)
"Render RECORDS for SCOPE into the coverage report buffer.
Does the buffer setup, the insert, and switches it into
-`cj/coverage-report-mode' for compilation-mode navigation."
+`cj/coverage-report-mode' for compilation-mode navigation.
+
+For the whole-project scope, uses `cj/--coverage-format-summary'
+(per-file percentages only; line-level drill-down would be thousands
+of entries). For diff-aware scopes, uses `cj/--coverage-format-report'
+which preserves the compilation-mode-navigable uncovered-line list."
(let* ((label (cj/--coverage-label-from-scope scope))
- (text (cj/--coverage-format-report records label))
+ (text (if (eq scope 'whole-project)
+ (cj/--coverage-format-summary records label)
+ (cj/--coverage-format-report records label)))
(buf (get-buffer-create "*Coverage Report*")))
(with-current-buffer buf
(let ((inhibit-read-only t))
@@ -348,10 +446,16 @@ Does the buffer setup, the insert, and switches it into
buf))
(defun cj/--coverage-read-and-display (backend scope)
- "Parse BACKEND's report file, intersect with SCOPE, display result."
+ "Parse BACKEND's report file, intersect with SCOPE, display result.
+For the whole-project scope, the \"changed\" set is every executable
+line in the simplecov data — the intersect then classifies each line
+as covered or uncovered. For diff-aware scopes, the changed set
+comes from `git diff' via `cj/--coverage-changed-lines'."
(let* ((report-path (funcall (plist-get backend :report-path)))
(covered (cj/--coverage-parse-simplecov report-path))
- (changed (cj/--coverage-changed-lines scope))
+ (changed (if (eq scope 'whole-project)
+ (cj/--coverage-simplecov-executable-lines report-path)
+ (cj/--coverage-changed-lines scope)))
(records (cj/--coverage-intersect covered changed)))
(cj/--coverage-render-to-buffer records scope)))
diff --git a/tests/test-coverage-core--whole-project.el b/tests/test-coverage-core--whole-project.el
new file mode 100644
index 00000000..946ff304
--- /dev/null
+++ b/tests/test-coverage-core--whole-project.el
@@ -0,0 +1,137 @@
+;;; test-coverage-core--whole-project.el --- Tests for whole-project coverage scope -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for the helpers that power the "Whole project" scope in
+;; `cj/coverage-report':
+;;
+;; `cj/--coverage-simplecov-executable-lines' — returns all executable
+;; lines per file from a simplecov JSON report, including lines with
+;; zero hits (symmetric with `cj/--coverage-parse-simplecov', which
+;; returns only hit lines).
+;;
+;; `cj/--coverage-format-summary' — renders intersect records as a
+;; per-file percentage summary, sorted by coverage ascending (worst
+;; first, most useful when scanning for what to test next).
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'coverage-core)
+
+(defun test-coverage-whole--write-json (content)
+ "Write CONTENT to a temp file; return its path."
+ (let ((file (make-temp-file "test-whole-" nil ".json")))
+ (with-temp-file file (insert content))
+ file))
+
+(defun test-coverage-whole--record (path changed covered uncovered tracked)
+ "Build an intersect record."
+ (list :path path
+ :changed-lines changed
+ :covered-lines covered
+ :uncovered-lines uncovered
+ :tracked tracked))
+
+;;; cj/--coverage-simplecov-executable-lines
+
+(ert-deftest test-coverage-simplecov-executable-lines-basic ()
+ "Normal: executable lines include both hit (>0) and 0-hit entries."
+ (let* ((content "{\"run\":{\"timestamp\":1,\"coverage\":{\"/foo.el\":[null,1,0,2,null,0]}}}")
+ (file (test-coverage-whole--write-json content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-simplecov-executable-lines file))
+ (lines (gethash "/foo.el" result)))
+ (should (= 4 (hash-table-count lines)))
+ (should-not (gethash 1 lines)) ; null
+ (should (gethash 2 lines)) ; 1 hit
+ (should (gethash 3 lines)) ; 0 hits (still executable)
+ (should (gethash 4 lines)) ; 2 hits
+ (should-not (gethash 5 lines)) ; null
+ (should (gethash 6 lines))) ; 0 hits
+ (delete-file file))))
+
+(ert-deftest test-coverage-simplecov-executable-lines-all-null ()
+ "Boundary: all-null array returns empty set (not nil)."
+ (let* ((content "{\"run\":{\"coverage\":{\"/foo.el\":[null,null,null]}}}")
+ (file (test-coverage-whole--write-json content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-simplecov-executable-lines file))
+ (lines (gethash "/foo.el" result)))
+ (should (hash-table-p lines))
+ (should (= 0 (hash-table-count lines))))
+ (delete-file file))))
+
+(ert-deftest test-coverage-simplecov-executable-lines-multiple-runs-unioned ()
+ "Boundary: multiple test-name keys are unioned (matches parse-simplecov semantics)."
+ (let* ((content (concat "{\"run1\":{\"coverage\":{\"/foo.el\":[1,null,null]}},"
+ "\"run2\":{\"coverage\":{\"/foo.el\":[null,null,0]}}}"))
+ (file (test-coverage-whole--write-json content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-simplecov-executable-lines file))
+ (lines (gethash "/foo.el" result)))
+ (should (= 2 (hash-table-count lines)))
+ (should (gethash 1 lines))
+ (should (gethash 3 lines)))
+ (delete-file file))))
+
+(ert-deftest test-coverage-simplecov-executable-lines-missing-file ()
+ "Error: nonexistent file signals user-error."
+ (should-error (cj/--coverage-simplecov-executable-lines
+ "/nonexistent/path/xyz.json")
+ :type 'user-error))
+
+;;; cj/--coverage-format-summary
+
+(ert-deftest test-coverage-format-summary-multiple-files-sorted ()
+ "Normal: per-file summary is sorted by coverage percentage ascending."
+ (let* ((records (list
+ (test-coverage-whole--record
+ "high.el" '(1 2 3 4) '(1 2 3 4) nil t) ; 100%
+ (test-coverage-whole--record
+ "low.el" '(1 2 3 4 5 6 7 8 9 10)
+ '(1) '(2 3 4 5 6 7 8 9 10) t) ; 10%
+ (test-coverage-whole--record
+ "mid.el" '(1 2) '(1) '(2) t))) ; 50%
+ (output (cj/--coverage-format-summary records "Whole project"))
+ ;; Position of each filename in the output string
+ (pos-low (string-match "low\\.el" output))
+ (pos-mid (string-match "mid\\.el" output))
+ (pos-high (string-match "high\\.el" output)))
+ (should pos-low)
+ (should pos-mid)
+ (should pos-high)
+ ;; Lower coverage files appear first
+ (should (< pos-low pos-mid))
+ (should (< pos-mid pos-high))
+ ;; Summary totals appear
+ (should (string-match-p "6 of 16" output))
+ (should (string-match-p "Whole project" output))))
+
+(ert-deftest test-coverage-format-summary-shows-percentages ()
+ "Normal: each file shows its own hit/total and percentage."
+ (let* ((records (list (test-coverage-whole--record
+ "foo.el" '(1 2 3 4) '(1 2 3) '(4) t))) ; 75%
+ (output (cj/--coverage-format-summary records "Whole project")))
+ (should (string-match-p "3/4" output))
+ (should (string-match-p "75" output))))
+
+(ert-deftest test-coverage-format-summary-empty ()
+ "Boundary: empty records produces a clear \"nothing to report\" message."
+ (let ((output (cj/--coverage-format-summary nil "Whole project")))
+ (should (string-match-p "No coverage data" output))))
+
+(ert-deftest test-coverage-format-summary-not-tracked-files-excluded ()
+ "Boundary: files with :tracked nil don't appear in the summary."
+ (let* ((records (list
+ (test-coverage-whole--record
+ "modules/foo.el" '(1) '(1) nil t)
+ (test-coverage-whole--record
+ "README.md" '(5 6) nil nil nil)))
+ (output (cj/--coverage-format-summary records "Whole project")))
+ (should (string-match-p "modules/foo\\.el" output))
+ (should-not (string-match-p "README\\.md" output))))
+
+(provide 'test-coverage-core--whole-project)
+;;; test-coverage-core--whole-project.el ends here