From 95dbb5abdbb746cf5da9f7926740d17205ac8d55 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 6 Jun 2026 10:31:30 -0500 Subject: build: add Eask, test harness, and dev tooling I brought the skeleton up to a working package baseline (Phase 0 in the design spec). Eask defines the package and its dev deps. A root Makefile delegates test targets to tests/Makefile and adds compile, coverage, lint, doctor, and clean, matching the layout the other packages use. deps installs both halves DUET needs: the Emacs Lisp deps via eask, and the transport CLIs (rsync, rclone, lftp, unison) via the system package manager, so a contributor's environment is ready before the code that shells out to them. make complexity runs a small homegrown McCabe branch counter (scripts/duet-complexity.el). No off-the-shelf tool measures Emacs Lisp: lizard doesn't support it and codemetrics is an interactive overlay, so DUET owns one. The counting is pure and covered by Normal/Boundary/Error tests. The budget is soft and the target is advisory. The ERT harness (bootstrap, check-deps, per-file undercover coverage) and a smoke test prove the loop works end to end. --- scripts/coverage-summary.el | 189 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 scripts/coverage-summary.el (limited to 'scripts/coverage-summary.el') 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 + +;; 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 . + +;;; 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 -- cgit v1.2.3