aboutsummaryrefslogtreecommitdiff
path: root/scripts/coverage-summary.el
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/coverage-summary.el')
-rw-r--r--scripts/coverage-summary.el189
1 files changed, 189 insertions, 0 deletions
diff --git a/scripts/coverage-summary.el b/scripts/coverage-summary.el
new file mode 100644
index 0000000..51ddda9
--- /dev/null
+++ b/scripts/coverage-summary.el
@@ -0,0 +1,189 @@
+;;; coverage-summary.el --- Terminal coverage summary for duet -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Batch helper behind `make coverage-summary'. Parses the SimpleCov JSON that
+;; `make coverage' writes and prints a terminal summary instead of just the
+;; report's file size:
+;;
+;; - per tracked source file: covered/total executable lines and a percent,
+;; worst-covered first;
+;; - a line-weighted project figure over files present in the report;
+;; - a source-weighted figure where a tracked source missing from the report
+;; counts as 0% rather than being silently dropped; and
+;; - an explicit list of tracked sources absent from the report.
+;;
+;; The missing-source handling is the robustness win: a source that never got
+;; instrumented shows as 0% instead of vanishing from the numbers. DUET ships
+;; a single source file today, so this is generalized over a list of tracked
+;; sources and stays self-contained.
+
+;;; Code:
+
+(require 'json)
+(require 'seq)
+(require 'subr-x)
+
+(defun duet-coverage--read-json (file)
+ "Parse FILE as SimpleCov JSON and return the decoded hash table.
+Signal `user-error' when FILE is missing or the JSON is malformed."
+ (unless (file-exists-p file)
+ (user-error "Coverage report not found: %s (run 'make coverage' first)" file))
+ (let ((json-object-type 'hash-table)
+ (json-array-type 'list)
+ (json-key-type 'string))
+ (condition-case err
+ (json-read-file file)
+ (error (user-error "Malformed coverage JSON in %s: %s"
+ file (error-message-string err))))))
+
+(defun duet-coverage--collect (file predicate)
+ "Return path -> (line -> t) hash for lines in FILE matching PREDICATE.
+PREDICATE receives each line's SimpleCov hits entry (a number, or nil for a
+non-executable line). Coverage is unioned across every top-level test-name key
+so undercover's `:merge-report' output accumulates correctly. A path is
+recorded even when no line matches, so callers can tell \"present but empty\"
+from \"absent\"."
+ (let ((data (duet-coverage--read-json file))
+ (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 (funcall predicate hits)
+ (puthash line-num t lines))
+ (setq line-num (1+ line-num)))
+ (puthash path lines result)))
+ coverage)))))
+ data)
+ result))
+
+(defun duet-coverage--parse-simplecov (file)
+ "Return path -> (line -> t) hash for HIT lines (hits > 0) in FILE."
+ (duet-coverage--collect file (lambda (h) (and (numberp h) (> h 0)))))
+
+(defun duet-coverage--executable-lines (file)
+ "Return path -> (line -> t) hash for EXECUTABLE lines in FILE.
+Executable means the SimpleCov entry is a number (hit or unhit); null entries
+\(blank lines, comments) are excluded."
+ (duet-coverage--collect file #'numberp))
+
+(defun duet-coverage--line-count (table path)
+ "Return the number of recorded lines for PATH in TABLE, or 0 when absent."
+ (let ((lines (gethash path table)))
+ (if (hash-table-p lines) (hash-table-count lines) 0)))
+
+(defun duet-coverage--find-key (table abs)
+ "Return the key in TABLE matching ABS, or nil.
+Matches by expanded path first, then by basename, so a report whose paths are
+written in a slightly different but equivalent form still resolves."
+ (let ((want-base (file-name-nondirectory abs))
+ (found nil))
+ (catch 'done
+ (maphash (lambda (k _v)
+ (when (or (string= (expand-file-name k) abs)
+ (string= (file-name-nondirectory k) want-base))
+ (setq found k)
+ (throw 'done k)))
+ table))
+ found))
+
+(defun duet-coverage--records (report-file source-files project-root)
+ "Return per-file coverage records for SOURCE-FILES from REPORT-FILE.
+SOURCE-FILES are paths relative to PROJECT-ROOT. Each record is a plist:
+
+ (:path REL :covered N :total N :present BOOL :percent FLOAT)
+
+A source absent from the report has :present nil, :covered 0, :total 0, and
+:percent 0.0 — it counts as 0%, not 100%. A present source with no executable
+lines is 100% (nothing left to cover)."
+ (let ((covered (duet-coverage--parse-simplecov report-file))
+ (executable (duet-coverage--executable-lines report-file))
+ (root (file-name-as-directory (expand-file-name project-root))))
+ (mapcar
+ (lambda (rel)
+ (let* ((abs (expand-file-name rel root))
+ (key (duet-coverage--find-key executable abs))
+ (present (and key t))
+ (total (if key (duet-coverage--line-count executable key) 0))
+ (cov (if key (duet-coverage--line-count covered key) 0))
+ (percent (cond ((not present) 0.0)
+ ((zerop total) 100.0)
+ (t (/ (* 100.0 cov) total)))))
+ (list :path rel :covered cov :total total
+ :present present :percent percent)))
+ source-files)))
+
+(defun duet-coverage-summary-text (report-file source-files project-root)
+ "Return a coverage summary string for SOURCE-FILES from REPORT-FILE.
+SOURCE-FILES are paths relative to PROJECT-ROOT."
+ (let* ((records (duet-coverage--records report-file source-files project-root))
+ (present (seq-filter (lambda (r) (plist-get r :present)) records))
+ (missing (seq-remove (lambda (r) (plist-get r :present)) records))
+ (total-cov (apply #'+ (mapcar (lambda (r) (plist-get r :covered)) present)))
+ (total-lines (apply #'+ (mapcar (lambda (r) (plist-get r :total)) present)))
+ (line-pct (if (> total-lines 0) (/ (* 100.0 total-cov) total-lines) 100.0))
+ (file-score (apply #'+ (mapcar (lambda (r) (plist-get r :percent)) records)))
+ (file-pct (if records (/ file-score (length records)) 0.0))
+ (sorted (sort (copy-sequence records)
+ (lambda (a b) (< (plist-get a :percent) (plist-get b :percent)))))
+ (width (apply #'max 8 (mapcar (lambda (r) (length (plist-get r :path))) records)))
+ (row-format (format " %%-%ds %%5d/%%-5d (%%5.1f%%%%)\n" width))
+ (miss-format (format " %%-%ds not in report (counts as 0%%%%)\n" width)))
+ (with-temp-buffer
+ (insert "Coverage Summary\n================\n\n")
+ (insert "Per-file coverage (worst first):\n")
+ (dolist (r sorted)
+ (if (plist-get r :present)
+ (insert (format row-format
+ (plist-get r :path)
+ (plist-get r :covered)
+ (plist-get r :total)
+ (plist-get r :percent)))
+ (insert (format miss-format (plist-get r :path)))))
+ (insert "\n")
+ (insert (format "Line coverage: %d/%d lines (%.1f%%) across %d file%s in report\n"
+ total-cov total-lines line-pct (length present)
+ (if (= 1 (length present)) "" "s")))
+ (insert (format "Source coverage: %.1f%% (%d tracked, %d missing; missing counts as 0%%)\n"
+ file-pct (length present) (length missing)))
+ (when missing
+ (insert (format "\nNot in coverage report: %d source%s\n"
+ (length missing) (if (= 1 (length missing)) "" "s")))
+ (dolist (r missing)
+ (insert (format " %s\n" (plist-get r :path)))))
+ (buffer-string))))
+
+(defun duet-coverage-print-summary (report-file source-files project-root)
+ "Print the coverage summary for SOURCE-FILES to standard output.
+SOURCE-FILES are paths relative to PROJECT-ROOT. Entry point for
+`make coverage-summary'."
+ (princ "\n")
+ (princ (duet-coverage-summary-text report-file source-files project-root)))
+
+(provide 'duet-coverage-summary)
+;;; coverage-summary.el ends here