aboutsummaryrefslogtreecommitdiff
path: root/scripts/duet-complexity.el
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/duet-complexity.el')
-rw-r--r--scripts/duet-complexity.el165
1 files changed, 165 insertions, 0 deletions
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