aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-06 10:31:30 -0500
committerCraig Jennings <c@cjennings.net>2026-06-06 10:31:30 -0500
commit95dbb5abdbb746cf5da9f7926740d17205ac8d55 (patch)
tree0e807d43d8f8ce32b3790efc716c433d35ceca3c /scripts
parent6ecd1e9bf1e3d0cdd3861077318541e193ca4532 (diff)
downloadduet-95dbb5abdbb746cf5da9f7926740d17205ac8d55.tar.gz
duet-95dbb5abdbb746cf5da9f7926740d17205ac8d55.zip
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.
Diffstat (limited to 'scripts')
-rw-r--r--scripts/coverage-summary.el189
-rw-r--r--scripts/duet-complexity.el165
2 files changed, 354 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
diff --git a/scripts/duet-complexity.el b/scripts/duet-complexity.el
new file mode 100644
index 0000000..63d59cf
--- /dev/null
+++ b/scripts/duet-complexity.el
@@ -0,0 +1,165 @@
+;;; duet-complexity.el --- McCabe complexity scanner 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:
+
+;; A small, dependency-free cyclomatic-complexity scanner behind
+;; `make complexity'. No off-the-shelf tool measures Emacs Lisp (lizard does
+;; not support it; codemetrics is an interactive tree-sitter overlay, a poor
+;; fit for a headless gate), so DUET owns this one.
+;;
+;; The metric is McCabe's: a function's complexity is 1 plus the number of
+;; decision points in its body. A decision point is a branch-introducing form:
+;;
+;; - `if' / `when' / `unless' +1 each
+;; - `while' / `dolist' / `dotimes' / `cl-loop' +1 each (and cl- variants)
+;; - `cond' +1 per clause
+;; - `pcase' / `pcase-exhaustive' +1 per clause
+;; - `cl-case' / `case' / `cl-typecase' (+ e-) +1 per clause
+;; - `condition-case' +1 per handler
+;; - `and' / `or' +1 per operand beyond the first
+;;
+;; The counting is pure — it operates on already-read forms — so it is fully
+;; unit-testable without I/O. Only `duet-complexity-scan-file' touches disk.
+;;
+;; Known limitations (acceptable for an advisory soft-budget gate): an inline
+;; lambda's complexity is attributed to its enclosing function rather than
+;; counted separately, and backquoted templates are walked as ordinary code.
+;; The budget is soft (the spec allows a written justification past ~8-10), so
+;; the bias toward flagging is deliberate.
+
+;;; Code:
+
+(require 'seq)
+
+(defconst duet-complexity-default-threshold 10
+ "Default soft cyclomatic-complexity budget.
+A function above this is split or carries a written justification (see the
+design spec's \"Complexity and refactoring controls\").")
+
+(defconst duet-complexity-defun-heads
+ '(defun defmacro defsubst cl-defun cl-defmacro cl-defsubst)
+ "Top-level forms the scanner measures as functions.")
+
+(defun duet-complexity--clause-rest (clauses)
+ "Return every CLAUSE's `cdr' appended, skipping each clause head.
+Used where a clause head is data, not executable code: `pcase' patterns,
+`cl-case' key lists, and `condition-case' handler condition names. Skipping
+the head avoids miscounting a `pcase' `or'/`and' pattern as a boolean form."
+ (apply #'append
+ (mapcar (lambda (c) (and (consp c) (copy-sequence (cdr c)))) clauses)))
+
+(defun duet-complexity--clause-all (clauses)
+ "Return every element of each CLAUSE in CLAUSES appended.
+Used for `cond', where a clause's condition is itself executable code that may
+contain nested decisions."
+ (apply #'append
+ (mapcar (lambda (c) (and (consp c) (copy-sequence c))) clauses)))
+
+(defun duet-complexity--sum (forms)
+ "Return the total decision points across FORMS."
+ (apply #'+ (mapcar #'duet-complexity--decision-points forms)))
+
+(defun duet-complexity--decision-points (form)
+ "Return the number of McCabe decision points in FORM, recursively."
+ (if (not (consp form))
+ 0
+ (let ((head (car form)))
+ (cond
+ ((eq head 'quote) 0)
+ ((memq head '(if when unless while dolist dotimes
+ cl-dolist cl-dotimes cl-loop))
+ (+ 1 (duet-complexity--sum (cdr form))))
+ ((memq head '(and or))
+ (+ (max 0 (1- (length (cdr form))))
+ (duet-complexity--sum (cdr form))))
+ ((eq head 'cond)
+ (+ (length (cdr form))
+ (duet-complexity--sum (duet-complexity--clause-all (cdr form)))))
+ ((memq head '(pcase pcase-exhaustive
+ cl-case case cl-ecase cl-typecase cl-etypecase))
+ (+ (length (cddr form))
+ (duet-complexity--decision-points (cadr form))
+ (duet-complexity--sum (duet-complexity--clause-rest (cddr form)))))
+ ((eq head 'condition-case)
+ (+ (length (cdddr form))
+ (duet-complexity--decision-points (caddr form))
+ (duet-complexity--sum (duet-complexity--clause-rest (cdddr form)))))
+ (t
+ (duet-complexity--sum (cdr form)))))))
+
+(defun duet-complexity-of-form (form)
+ "Return the cyclomatic complexity of defun-like FORM.
+FORM is a `(HEAD NAME ARGLIST . BODY)' list; the result is 1 plus the decision
+points in its body."
+ (1+ (duet-complexity--sum (nthcdr 3 form))))
+
+(defun duet-complexity--read-forms (file)
+ "Return the list of top-level forms read from FILE."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ (let (forms)
+ (condition-case nil
+ (while t (push (read (current-buffer)) forms))
+ (end-of-file (nreverse forms))))))
+
+(defun duet-complexity-scan-file (file)
+ "Return `(NAME . COMPLEXITY)' pairs for each defun-like form in FILE."
+ (let (results)
+ (dolist (form (duet-complexity--read-forms file) (nreverse results))
+ (when (and (consp form)
+ (memq (car form) duet-complexity-defun-heads)
+ (symbolp (nth 1 form)))
+ (push (cons (nth 1 form) (duet-complexity-of-form form)) results)))))
+
+(defun duet-complexity-over-threshold (results threshold)
+ "Return RESULTS entries whose complexity exceeds THRESHOLD."
+ (seq-filter (lambda (r) (> (cdr r) threshold)) results))
+
+(defun duet-complexity-batch (files threshold)
+ "Scan FILES and print per-function complexity to standard output.
+Exit non-zero when any function's complexity exceeds THRESHOLD. Entry point
+for `make complexity'."
+ (let ((offenders nil)
+ (scanned 0))
+ (dolist (file files)
+ (let ((results (duet-complexity-scan-file file)))
+ (princ (format "\n%s\n" file))
+ (dolist (r (sort (copy-sequence results)
+ (lambda (a b) (> (cdr a) (cdr b)))))
+ (setq scanned (1+ scanned))
+ (princ (format " %3d %s%s\n"
+ (cdr r) (car r)
+ (if (> (cdr r) threshold) " <-- over budget" ""))))
+ (setq offenders
+ (append offenders
+ (mapcar (lambda (r) (cons file r))
+ (duet-complexity-over-threshold results threshold))))))
+ (princ (format "\nScanned %d function(s); budget = %d.\n" scanned threshold))
+ (if offenders
+ (progn
+ (princ (format "%d function(s) over budget:\n" (length offenders)))
+ (dolist (o offenders)
+ (princ (format " %s: %s (%d)\n" (car o) (cadr o) (cddr o))))
+ (kill-emacs 1))
+ (princ "All functions within budget.\n"))))
+
+(provide 'duet-complexity)
+;;; duet-complexity.el ends here