diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-06 10:31:30 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-06 10:31:30 -0500 |
| commit | 95dbb5abdbb746cf5da9f7926740d17205ac8d55 (patch) | |
| tree | 0e807d43d8f8ce32b3790efc716c433d35ceca3c /scripts | |
| parent | 6ecd1e9bf1e3d0cdd3861077318541e193ca4532 (diff) | |
| download | duet-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.el | 189 | ||||
| -rw-r--r-- | scripts/duet-complexity.el | 165 |
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 |
