aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--Eask26
-rw-r--r--Makefile250
-rw-r--r--docs/developer-guide.org52
-rw-r--r--scripts/coverage-summary.el189
-rw-r--r--scripts/duet-complexity.el165
-rw-r--r--tests/.gitkeep0
-rw-r--r--tests/Makefile307
-rw-r--r--tests/check-deps.el43
-rw-r--r--tests/run-coverage-file.el50
-rw-r--r--tests/test-bootstrap.el40
-rw-r--r--tests/test-duet-complexity.el126
-rw-r--r--tests/test-duet-smoke.el36
13 files changed, 1290 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index 715b879..b78f809 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,8 +2,13 @@
*.elc
*.eln
/.eldev/
+/.eask/
/eln-cache/
+# Generated by eask
+/duet-autoloads.el
+/duet-pkg.el
+
# Emacs cruft
*~
\#*\#
@@ -12,6 +17,7 @@
# Coverage / test output
/coverage/
/.coverage/
+tests/*-output.log
# Local planning + tooling (kept out of the public repo).
/.ai/
diff --git a/Eask b/Eask
new file mode 100644
index 0000000..d7b8f52
--- /dev/null
+++ b/Eask
@@ -0,0 +1,26 @@
+;; -*- mode: eask; lexical-binding: t -*-
+
+(package "duet"
+ "0.1.0"
+ "Dual-pane file commander over dirvish/dired")
+
+(website-url "https://github.com/cjennings/duet")
+(keywords "files" "tools" "convenience")
+
+(package-file "duet.el")
+
+(source 'gnu)
+(source 'nongnu)
+(source 'melpa)
+
+(depends-on "emacs" "29.1")
+
+;; dirvish is the recommended renderer but optional at runtime (DUET degrades
+;; to plain dired), so it is not a hard package dependency. It is pulled in
+;; as a development dependency once pane code lands (Phase 4); until then the
+;; suite needs none of it.
+
+(development
+ (depends-on "elisp-lint")
+ (depends-on "package-lint")
+ (depends-on "undercover"))
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..9052026
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,250 @@
+# Makefile for duet.el
+# Test targets delegate to tests/Makefile.
+# setup / deps / compile / coverage / complexity / doctor operate at project root.
+# Run 'make help' for available commands.
+
+EASK ?= eask
+EMACS_BATCH = $(EASK) emacs --batch
+# Coverage / test loops need default-directory = tests/ so test files'
+# relative paths (../duet.el, sibling test files) resolve as they do under
+# tests/Makefile.
+EMACS_BATCH_TESTS = $(EASK) emacs --batch --eval '(cd "tests/")'
+
+TEST_DIR = tests
+SOURCE_FILE = duet.el
+
+# Transport CLIs DUET shells out to. rsync is the stage-1 default; rclone,
+# lftp, and unison arrive in later stages but `deps' installs the full set so
+# a contributor's environment is ready ahead of the code that uses them.
+TRANSPORT_CLIS = rsync rclone lftp unison
+
+# Cyclomatic-complexity gate (scripts/duet-complexity.el). The budget is soft
+# (the design spec allows a written justification past it); override per run
+# with `make complexity COMPLEXITY_THRESHOLD=12'.
+COMPLEXITY_THRESHOLD ?= 10
+COMPLEXITY_FILES = $(SOURCE_FILE)
+
+# Coverage configuration
+COVERAGE_DIR = .coverage
+COVERAGE_FILE = $(COVERAGE_DIR)/simplecov.json
+
+# Test-file list used by the coverage loop, mirroring tests/Makefile.
+# Coverage runs every test file so the report represents the full suite.
+ALL_TESTS = $(filter-out $(TEST_DIR)/test-bootstrap.el, \
+ $(wildcard $(TEST_DIR)/test-*.el))
+
+# Include local overrides if present (per-machine knobs, not committed)
+-include makefile-local
+
+.PHONY: help test test-all test-unit test-integration test-file test-one test-name \
+ count list validate lint check-deps clean clean-compiled clean-tests \
+ setup deps deps-elisp deps-system compile coverage coverage-summary coverage-clean \
+ complexity doctor test-live
+
+help:
+ @$(MAKE) -C $(TEST_DIR) help
+
+# Test target delegations
+test:
+ @$(MAKE) -C $(TEST_DIR) test
+
+test-all:
+ @$(MAKE) -C $(TEST_DIR) test-all
+
+test-unit:
+ @$(MAKE) -C $(TEST_DIR) test-unit
+
+test-integration:
+ @$(MAKE) -C $(TEST_DIR) test-integration
+
+test-file:
+ @$(MAKE) -C $(TEST_DIR) test-file FILE="$(FILE)"
+
+test-one:
+ @$(MAKE) -C $(TEST_DIR) test-one TEST="$(TEST)"
+
+test-name:
+ @$(MAKE) -C $(TEST_DIR) test-name TEST="$(TEST)"
+
+count:
+ @$(MAKE) -C $(TEST_DIR) count
+
+list:
+ @$(MAKE) -C $(TEST_DIR) list
+
+validate:
+ @$(MAKE) -C $(TEST_DIR) validate
+
+lint:
+ @$(MAKE) -C $(TEST_DIR) lint
+
+check-deps:
+ @$(MAKE) -C $(TEST_DIR) check-deps
+
+#
+# Dependencies
+#
+
+# Install everything DUET needs: Emacs Lisp deps (via eask) plus the transport
+# CLIs (via the system package manager).
+deps: deps-elisp deps-system
+
+# Install runtime + development Emacs Lisp dependencies via eask.
+deps-elisp setup:
+ @if ! command -v $(EASK) >/dev/null 2>&1; then \
+ echo "[✗] eask not found on PATH"; \
+ echo " Install: npm install -g @emacs-eask/cli"; \
+ echo " Or: https://emacs-eask.github.io/Getting-Started/Install-Eask/"; \
+ exit 1; \
+ fi
+ @echo "[i] Installing Emacs Lisp dependencies via eask..."
+ @$(EASK) install-deps --dev
+ @echo "[✓] Emacs Lisp dependencies installed in .eask/"
+
+# Install the transport CLIs via the detected system package manager. pacman
+# is checked first (the maintainer's environment); apt/dnf/zypper/brew follow.
+# An unknown manager is not a failure — it prints the package list so the user
+# can install by hand.
+deps-system:
+ @echo "[i] Installing transport CLIs: $(TRANSPORT_CLIS)"
+ @if command -v pacman >/dev/null 2>&1; then \
+ sudo pacman -S --needed $(TRANSPORT_CLIS); \
+ elif command -v apt-get >/dev/null 2>&1; then \
+ sudo apt-get install -y $(TRANSPORT_CLIS); \
+ elif command -v dnf >/dev/null 2>&1; then \
+ sudo dnf install -y $(TRANSPORT_CLIS); \
+ elif command -v zypper >/dev/null 2>&1; then \
+ sudo zypper install -y $(TRANSPORT_CLIS); \
+ elif command -v brew >/dev/null 2>&1; then \
+ brew install $(TRANSPORT_CLIS); \
+ else \
+ echo "[!] No supported package manager found."; \
+ echo " Install these yourself: $(TRANSPORT_CLIS)"; \
+ fi
+
+# Byte-compile duet.el — surfaces free-variable / unused-let / suspicious-call
+# warnings that checkdoc and elisp-lint don't catch. byte-compile-error-on-warn
+# is t so any warning fails the build.
+compile:
+ @echo "[i] Byte-compiling $(SOURCE_FILE)..."
+ @$(EMACS_BATCH) \
+ --eval "(progn \
+ (setq byte-compile-error-on-warn t) \
+ (batch-byte-compile))" $(SOURCE_FILE)
+ @echo "[✓] Compilation complete"
+
+#
+# Cyclomatic complexity (scripts/duet-complexity.el)
+#
+
+# Report per-function complexity and exit non-zero if any function exceeds the
+# soft budget. Advisory: run on demand, not wired into `make test'.
+complexity:
+ @echo "[i] Scanning complexity (budget = $(COMPLEXITY_THRESHOLD))..."
+ @emacs -Q --batch -L scripts -l duet-complexity \
+ --eval "(duet-complexity-batch '($(foreach f,$(COMPLEXITY_FILES),\"$(f)\")) $(COMPLEXITY_THRESHOLD))"
+
+#
+# Doctor — verify the runtime environment
+#
+
+# Report which transport CLIs are present and confirm duet loads. Missing CLIs
+# are warnings (rclone/lftp/unison land in later stages); a load failure is the
+# only hard error.
+doctor:
+ @echo "[i] DUET doctor"
+ @echo "Transport executables:"
+ @for c in $(TRANSPORT_CLIS); do \
+ if command -v $$c >/dev/null 2>&1; then \
+ printf " [✓] %-8s %s\n" "$$c" "$$(command -v $$c)"; \
+ else \
+ printf " [!] %-8s missing (install via 'make deps-system')\n" "$$c"; \
+ fi; \
+ done
+ @echo "Package load:"
+ @if $(EMACS_BATCH) -l $(SOURCE_FILE) --eval "(require 'duet)" >/dev/null 2>&1; then \
+ echo " [✓] (require 'duet) succeeds"; \
+ else \
+ echo " [✗] duet failed to load"; \
+ exit 1; \
+ fi
+
+# Run env-gated live remote tests. Skipped unless DUET_LIVE_TESTS is set, since
+# they need real remote hosts and credentials.
+test-live:
+ @if [ -z "$$DUET_LIVE_TESTS" ]; then \
+ echo "[i] DUET_LIVE_TESTS is unset — skipping live remote tests."; \
+ echo " Set DUET_LIVE_TESTS=1 (and the host/path vars the tests document) to run them."; \
+ else \
+ $(MAKE) -C $(TEST_DIR) test-name TEST='live'; \
+ fi
+
+#
+# Coverage (undercover + simplecov JSON)
+#
+# Each test file runs in its own Emacs process (matching test-unit);
+# tests/run-coverage-file.el instruments duet.el before the source is loaded,
+# and undercover merges per-file results into a single simplecov JSON.
+
+coverage: coverage-clean $(COVERAGE_DIR)
+ @echo "[i] Cleaning .elc files so undercover can instrument source..."
+ @find . -name "*.elc" -delete
+ @echo "[i] Running coverage across $(words $(ALL_TESTS)) test file(s)..."
+ @echo " (slower than 'make test' — each file runs in its own Emacs)"
+ @failed=0; \
+ for test in $(ALL_TESTS); do \
+ echo " Coverage: $$test..."; \
+ testfile=$$(basename $$test); \
+ $(EMACS_BATCH_TESTS) \
+ -l ert \
+ -l run-coverage-file.el \
+ -l ../$(SOURCE_FILE) \
+ -l $$testfile \
+ --eval "(ert-run-tests-batch-and-exit t)" || failed=$$((failed + 1)); \
+ done; \
+ if [ $$failed -gt 0 ]; then \
+ echo "[!] $$failed test file(s) failed during coverage run"; \
+ exit 1; \
+ fi
+ @coverage_file="$${COVERAGE_FILE_ACTUAL:-$(COVERAGE_FILE)}"; \
+ [ -n "$$CI" ] && coverage_file="$(COVERAGE_DIR)/coveralls.json"; \
+ if [ -f "$$coverage_file" ]; then \
+ echo "[✓] Coverage report: $$coverage_file ($$(du -h $$coverage_file | cut -f1))"; \
+ else \
+ echo "[!] No coverage file produced; check that undercover is installed"; \
+ exit 1; \
+ fi
+ @# Print the human-readable summary after a local run. CI emits
+ @# coveralls.json (not simplecov.json) and the upload action reports
+ @# instead, so skip the terminal summary there.
+ @if [ -z "$$CI" ] && [ -f $(COVERAGE_FILE) ]; then \
+ $(MAKE) --no-print-directory coverage-summary; \
+ fi
+
+# Print a human-readable summary of the SimpleCov report.
+coverage-summary:
+ @if [ ! -f $(COVERAGE_FILE) ]; then \
+ echo "[!] No coverage report at $(COVERAGE_FILE). Run 'make coverage' first."; \
+ exit 1; \
+ fi
+ @$(EMACS_BATCH) -L scripts -l coverage-summary \
+ --eval '(duet-coverage-print-summary "$(COVERAGE_FILE)" (list "$(SOURCE_FILE)") "$(CURDIR)")'
+
+coverage-clean:
+ @rm -f $(COVERAGE_FILE)
+
+$(COVERAGE_DIR):
+ @mkdir -p $(COVERAGE_DIR)
+
+#
+# Cleaning
+#
+
+clean: clean-compiled clean-tests
+ @rm -rf $(COVERAGE_DIR)
+
+clean-compiled:
+ @rm -f *.elc scripts/*.elc
+
+clean-tests:
+ @$(MAKE) -C $(TEST_DIR) clean
diff --git a/docs/developer-guide.org b/docs/developer-guide.org
new file mode 100644
index 0000000..df3199f
--- /dev/null
+++ b/docs/developer-guide.org
@@ -0,0 +1,52 @@
+#+TITLE: DUET Developer Guide
+#+AUTHOR: Craig Jennings
+
+* Status
+
+Stub. Fills out across the implementation phases; the backend API and
+transfer-spec contract land with Phase 2/3, the full release checklist with
+Phase 10. The authoritative design is [[file:design/duet-spec.org][docs/design/duet-spec.org]].
+
+* Repository layout
+
+- =duet.el= — the package. Public =duet-= commands and pure =duet--= helpers.
+ The only file that ships (Eask =package-file=).
+- =tests/= — ERT suites, one =test-duet-<area>.el= per source area, plus the
+ shared harness (=test-bootstrap.el=, =check-deps.el=, =run-coverage-file.el=).
+- =scripts/= — developer tooling, not part of the package: =coverage-summary.el=
+ (terminal coverage report) and =duet-complexity.el= (the McCabe gate).
+- =docs/= — this guide and the design spec.
+
+* Building and testing
+
+DUET uses [[https://emacs-eask.github.io/][Eask]] for dependency management and a Makefile for the common
+loops. From the project root:
+
+#+begin_src shell
+make deps # Emacs Lisp deps (eask) + transport CLIs (rsync/rclone/lftp/unison)
+make setup # Emacs Lisp deps only
+make test # full ERT suite (excludes :slow)
+make test-file FILE=complexity # one file
+make test-name TEST=pattern # ERT name selector
+make compile # byte-compile, warnings-as-errors
+make lint # elisp-lint
+make coverage # undercover + simplecov JSON, then a terminal summary
+make complexity # cyclomatic-complexity report, gated on the soft budget
+make doctor # transport executables present + duet loads
+make test-live # env-gated live remote tests (DUET_LIVE_TESTS)
+#+end_src
+
+* Complexity budget
+
+=make complexity= runs =scripts/duet-complexity.el=, a homegrown McCabe branch
+counter (no off-the-shelf tool measures Emacs Lisp). The budget is soft: a
+function above the threshold (default 10) is split or carries a written
+justification. The target is advisory — run it on demand; it is not part of
+=make test=.
+
+* Backend API
+
+To be written with Phase 2. The published seam is =duet-register-backend=; the
+stable surface is the classified-endpoint plist, the =duet-backend= struct, the
+=duet-transfer-spec= plist, and the transfer-event payload. See the spec's
+"Backend extension API and developer contract".
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
diff --git a/tests/.gitkeep b/tests/.gitkeep
deleted file mode 100644
index e69de29..0000000
--- a/tests/.gitkeep
+++ /dev/null
diff --git a/tests/Makefile b/tests/Makefile
new file mode 100644
index 0000000..9d2f89d
--- /dev/null
+++ b/tests/Makefile
@@ -0,0 +1,307 @@
+# Makefile for duet.el test suite
+# Usage:
+# make test - Run all tests (excluding :slow tagged)
+# make test-all - Run every test, including :slow tagged
+# make test-file FILE=complexity - Run tests in one file
+# make test-one TEST=name - Run one specific test
+# make test-unit - Run unit tests only
+# make test-integration - Run integration tests only
+# make clean - Remove byte-compiled files
+
+# Configuration
+EASK ?= eask
+
+# eask treats the CWD as its workspace and reads .eask/ from there. All eask
+# invocations must run from project root so the project's .eask/ is picked up.
+# The (cd "tests/") --eval restores Emacs default-directory so test files'
+# relative paths (../duet.el, test-bootstrap.el, ../scripts/) resolve correctly.
+PROJECT_ROOT := $(abspath ..)
+EMACS_BATCH = cd $(PROJECT_ROOT) && $(EASK) emacs --batch --eval '(cd "tests/")'
+
+# Include local overrides if present (per-machine knobs, not committed)
+-include makefile-local
+
+# Test files
+ALL_TESTS = $(filter-out test-bootstrap.el,$(wildcard test-*.el))
+UNIT_TESTS = $(filter-out test-integration-%.el,$(ALL_TESTS))
+INTEGRATION_TESTS = $(wildcard test-integration-*.el)
+
+# ERT selector that excludes tests tagged :slow. Applied to default test runs
+# so a slow integration suite doesn't dominate the fast feedback path.
+# test-all runs everything; test-one and test-name honor the user pattern.
+ERT_FAST_SELECTOR = (ert-run-tests-batch-and-exit '(not (tag :slow)))
+
+# Colors for output (if terminal supports it)
+RED = \033[0;31m
+GREEN = \033[0;32m
+YELLOW = \033[1;33m
+NC = \033[0m
+
+.PHONY: all test test-all test-file test-one test-name test-unit test-integration \
+ validate lint clean help check-deps count list
+
+all: test
+
+# Verify eask + installed deps are available
+check-deps:
+ @if ! command -v $(EASK) >/dev/null 2>&1; then \
+ printf "$(RED)Error: eask not found on PATH$(NC)\n"; \
+ echo "Install: npm install -g @emacs-eask/cli"; \
+ echo " or: https://emacs-eask.github.io/Getting-Started/Install-Eask/"; \
+ exit 1; \
+ fi
+ @if [ ! -d $(PROJECT_ROOT)/.eask ]; then \
+ printf "$(YELLOW)Warning: .eask not found — run 'make setup' from project root$(NC)\n"; \
+ exit 1; \
+ fi
+ @$(EMACS_BATCH) -l check-deps.el >$(PROJECT_ROOT)/tests/check-deps-output.log 2>&1 || { \
+ printf "$(RED)Error: required Emacs Lisp test dependencies are missing$(NC)\n"; \
+ cat $(PROJECT_ROOT)/tests/check-deps-output.log; \
+ exit 1; \
+ }
+ @printf "$(GREEN)✓ eask available, required Emacs Lisp deps loadable$(NC)\n"
+
+# Run all tests (excluding :slow)
+test: check-deps
+ @printf "$(YELLOW)Running all tests ($(words $(ALL_TESTS)) files, excluding :slow)...$(NC)\n"
+ @$(MAKE) --no-print-directory test-unit
+ @$(MAKE) --no-print-directory test-integration
+ @printf "$(GREEN)[✓] All tests complete$(NC)\n"
+
+# Run every test, including :slow tagged
+test-all: check-deps
+ @printf "$(YELLOW)Running all tests including :slow ($(words $(ALL_TESTS)) files)...$(NC)\n"
+ @failed=0; \
+ for testfile in $(ALL_TESTS); do \
+ echo " Testing $$testfile..."; \
+ $(EMACS_BATCH) -l ert -l "$$testfile" \
+ --eval '(ert-run-tests-batch-and-exit t)' || failed=$$((failed + 1)); \
+ done; \
+ if [ $$failed -eq 0 ]; then \
+ printf "$(GREEN)[✓] All tests passed$(NC)\n"; \
+ else \
+ printf "$(RED)[✗] $$failed test file(s) failed$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Run tests in one file
+test-file: check-deps
+ifndef FILE
+ @printf "$(RED)Error: FILE not specified$(NC)\n"
+ @echo "Usage: make test-file FILE=complexity"
+ @echo " make test-file FILE=test-duet-complexity.el"
+ @exit 1
+endif
+ @TESTFILE=$$( \
+ if [ -f "$(FILE)" ]; then echo "$(FILE)"; \
+ elif [ -f "$(FILE).el" ]; then echo "$(FILE).el"; \
+ else find . -maxdepth 1 -name "*$(FILE)*.el" -type f | sed 's|^\./||'; fi); \
+ if [ -z "$$TESTFILE" ]; then \
+ printf "$(RED)Error: No test file matching '$(FILE)' found$(NC)\n"; \
+ exit 1; \
+ fi; \
+ if [ "$$(printf '%s\n' "$$TESTFILE" | grep -c .)" -gt 1 ]; then \
+ printf "$(RED)Error: '$(FILE)' matches multiple files; pass the exact name (e.g. $(FILE).el):$(NC)\n"; \
+ printf '%s\n' "$$TESTFILE" | sed 's|^| |'; \
+ exit 1; \
+ fi; \
+ printf "$(YELLOW)Running tests in $$TESTFILE...$(NC)\n"; \
+ $(EMACS_BATCH) -l ert -l "$$TESTFILE" \
+ --eval "$(ERT_FAST_SELECTOR)" 2>&1 | tee $(PROJECT_ROOT)/tests/test-file-output.log; \
+ if [ $$? -eq 0 ]; then \
+ printf "$(GREEN)✓ All tests in $$TESTFILE passed!$(NC)\n"; \
+ else \
+ printf "$(RED)✗ Some tests failed.$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Run one specific test (fuzzy match by name)
+test-one: check-deps
+ifndef TEST
+ @printf "$(RED)Error: TEST not specified$(NC)\n"
+ @echo "Usage: make test-one TEST=cond"
+ @echo " make test-one TEST=test-duet-complexity-cond-one-per-clause"
+ @exit 1
+endif
+ @printf "$(YELLOW)Searching for test matching '$(TEST)'...$(NC)\n"
+ @TESTFILE=$$(grep -l "ert-deftest.*$(TEST)" test-*.el 2>/dev/null | head -1); \
+ if [ -z "$$TESTFILE" ]; then \
+ printf "$(RED)Error: No test matching '$(TEST)' found$(NC)\n"; \
+ exit 1; \
+ fi; \
+ TESTNAME=$$(grep "ert-deftest.*$(TEST)" "$$TESTFILE" | sed 's/^(ert-deftest \([^ ]*\).*/\1/' | head -1); \
+ printf "$(YELLOW)Running test '$$TESTNAME' in $$TESTFILE...$(NC)\n"; \
+ $(EMACS_BATCH) -l ert -l "$$TESTFILE" \
+ --eval "(ert-run-tests-batch-and-exit \"$$TESTNAME\")" 2>&1; \
+ if [ $$? -eq 0 ]; then \
+ printf "$(GREEN)✓ Test $$TESTNAME passed!$(NC)\n"; \
+ else \
+ printf "$(RED)✗ Test $$TESTNAME failed.$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Run only unit tests (excluding :slow)
+test-unit: check-deps
+ @printf "$(YELLOW)Running unit tests ($(words $(UNIT_TESTS)) files, excluding :slow)...$(NC)\n"
+ @failed=0; \
+ for testfile in $(UNIT_TESTS); do \
+ echo " Testing $$testfile..."; \
+ $(EMACS_BATCH) -l ert -l "$$testfile" \
+ --eval "$(ERT_FAST_SELECTOR)" || failed=$$((failed + 1)); \
+ done; \
+ if [ $$failed -eq 0 ]; then \
+ printf "$(GREEN)[✓] All unit tests passed$(NC)\n"; \
+ else \
+ printf "$(RED)[✗] $$failed unit test file(s) failed$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Run only integration tests (excluding :slow)
+test-integration: check-deps
+ @printf "$(YELLOW)Running integration tests ($(words $(INTEGRATION_TESTS)) files, excluding :slow)...$(NC)\n"
+ @if [ -z "$(INTEGRATION_TESTS)" ]; then \
+ printf "$(YELLOW) (no integration test files yet)$(NC)\n"; \
+ fi
+ @failed=0; \
+ for testfile in $(INTEGRATION_TESTS); do \
+ echo " Testing $$testfile..."; \
+ $(EMACS_BATCH) -l ert -l "$$testfile" \
+ --eval "$(ERT_FAST_SELECTOR)" || failed=$$((failed + 1)); \
+ done; \
+ if [ $$failed -eq 0 ]; then \
+ printf "$(GREEN)[✓] All integration tests passed$(NC)\n"; \
+ else \
+ printf "$(RED)[✗] $$failed integration test file(s) failed$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Run tests matching a name pattern (ERT selector)
+test-name: check-deps
+ifndef TEST
+ @printf "$(RED)Error: TEST not specified$(NC)\n"
+ @echo "Usage: make test-name TEST=test-duet-complexity"
+ @echo " make test-name TEST='test-duet-complexity-.*'"
+ @exit 1
+endif
+ @printf "$(YELLOW)Running tests matching pattern: $(TEST)...$(NC)\n"
+ @$(EMACS_BATCH) -l ert \
+ --eval "(dolist (f (directory-files \".\" t \"^test-.*\\\\.el$$\")) (load f))" \
+ --eval '(ert-run-tests-batch-and-exit "$(TEST)")'
+
+# Count tests
+count:
+ @echo "Test file inventory:"
+ @for f in $(ALL_TESTS); do \
+ count=$$(grep -c "^(ert-deftest" "$$f" 2>/dev/null || echo 0); \
+ printf "%3d tests - %s\n" "$$count" "$$f"; \
+ done | sort -rn
+ @total=$$(find . -name "test-*.el" -exec grep -c "^(ert-deftest" {} \; | awk '{sum+=$$1} END {print sum}'); \
+ printf "$(GREEN)Total: $$total tests across $(words $(ALL_TESTS)) files$(NC)\n"
+
+# List all available tests
+list:
+ @echo "Available tests:"
+ @grep -h "^(ert-deftest" test-*.el | sed 's/^(ert-deftest \([^ ]*\).*/ \1/' | sort
+
+# Validate Emacs Lisp syntax (parens balance — no deps needed)
+validate:
+ @printf "$(YELLOW)Validating Emacs Lisp syntax...$(NC)\n"
+ @failed=0; \
+ total=0; \
+ for file in ../duet.el test-*.el; do \
+ if [ -f "$$file" ] && [ ! -d "$$file" ]; then \
+ total=$$((total + 1)); \
+ output=$$(emacs --batch -Q --eval "(progn \
+ (setq byte-compile-error-on-warn nil) \
+ (find-file \"$$file\") \
+ (condition-case err \
+ (progn \
+ (check-parens) \
+ (message \"✓ $$file - parentheses balanced\")) \
+ (error \
+ (message \"✗ $$file: %s\" (error-message-string err)) \
+ (kill-emacs 1))))" 2>&1 | grep -E '(✓|✗)'); \
+ if [ $$? -eq 0 ]; then \
+ printf "$(GREEN)$$output$(NC)\n"; \
+ else \
+ printf "$(RED)$$output$(NC)\n"; \
+ failed=$$((failed + 1)); \
+ fi; \
+ fi; \
+ done; \
+ if [ $$failed -eq 0 ]; then \
+ printf "$(GREEN)✓ All $$total files validated successfully$(NC)\n"; \
+ else \
+ printf "$(RED)✗ $$failed of $$total files failed validation$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Comprehensive linting with elisp-lint (via eask-installed dev dep).
+# Validators disabled and why:
+# - checkdoc: covered by `eask lint checkdoc' as its own MELPA-prep step.
+# - package-lint: covered by `eask lint package' as its own step.
+# - indent-character: project uses spaces; validator defaults to requiring tabs.
+# - fill-column: validator default (70) is stricter than this project wants.
+# - indent: false positives on threading / alignment.
+lint: check-deps
+ @printf "$(YELLOW)Running elisp-lint...$(NC)\n"
+ @$(EMACS_BATCH) \
+ -l $(PROJECT_ROOT)/duet.el \
+ --eval "(require 'elisp-lint)" \
+ -f elisp-lint-files-batch \
+ --no-checkdoc \
+ --no-package-lint \
+ --no-indent-character \
+ --no-fill-column \
+ --no-indent \
+ $(PROJECT_ROOT)/duet.el 2>&1; \
+ if [ $$? -eq 0 ]; then \
+ printf "$(GREEN)✓ Linting completed successfully$(NC)\n"; \
+ else \
+ printf "$(RED)✗ Linting found issues (see above)$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Clean byte-compiled files
+clean:
+ @printf "$(YELLOW)Cleaning byte-compiled files...$(NC)\n"
+ @rm -f *.elc ../*.elc ../scripts/*.elc
+ @rm -f check-deps-output.log test-output.log test-file-output.log test-unit-output.log test-integration-output.log
+ @printf "$(GREEN)✓ Cleaned$(NC)\n"
+
+# Show help
+help:
+ @echo "duet Test Suite Makefile"
+ @echo ""
+ @echo "Usage:"
+ @echo " make test - Run all tests, excluding :slow"
+ @echo " make test-all - Run all tests including :slow"
+ @echo " make test-unit - Run unit tests only (excluding :slow)"
+ @echo " make test-integration - Run integration tests only (excluding :slow)"
+ @echo " make test-file FILE=complexity - Run tests in one file (fuzzy match)"
+ @echo " make test-one TEST=cond - Run one specific test (fuzzy match)"
+ @echo " make test-name TEST=pattern - Run tests matching ERT name pattern"
+ @echo " make validate - Validate Emacs Lisp syntax (parens balance)"
+ @echo " make lint - Comprehensive linting with elisp-lint"
+ @echo " make count - Count tests per file"
+ @echo " make list - List all test names"
+ @echo " make clean - Remove byte-compiled files and logs"
+ @echo " make check-deps - Verify eask + loadable Emacs Lisp deps"
+ @echo " make help - Show this help message"
+ @echo ""
+ @echo "Project-root targets (run from project root):"
+ @echo " make deps - Install Emacs + system (rsync/rclone/lftp/unison) deps"
+ @echo " make setup - Install Emacs Lisp deps via eask"
+ @echo " make compile - Byte-compile duet.el"
+ @echo " make coverage - Generate simplecov JSON via undercover (+ summary)"
+ @echo " make coverage-summary - Print covered/total + percent from the last report"
+ @echo " make complexity - Report cyclomatic complexity, gate on the soft budget"
+ @echo " make doctor - Check transport executables + that duet loads"
+ @echo " make test-live - Run env-gated live remote tests (DUET_LIVE_TESTS)"
+ @echo ""
+ @echo "Tagging tests as :slow:"
+ @echo " (ert-deftest test-foo () :tags '(:slow) ...) — excluded by default"
+ @echo " Run with 'make test-all' to include them."
+ @echo ""
+ @echo "Environment variables:"
+ @echo " EASK - eask executable (default: eask)"
diff --git a/tests/check-deps.el b/tests/check-deps.el
new file mode 100644
index 0000000..55aaae6
--- /dev/null
+++ b/tests/check-deps.el
@@ -0,0 +1,43 @@
+;;; check-deps.el --- Verify test dependencies are loadable -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+
+;; Loaded by tests/Makefile's check-deps target after eask has prepared the
+;; test environment. Keep dependency discovery inside Emacs so package.el,
+;; package-vc, Eask, Nix, and pre-populated load-path setups all work the same
+;; way: a dependency is available if Emacs can require it.
+;;
+;; DUET has no third-party runtime dependencies yet; cl-lib is the one feature
+;; the core leans on (cl-defstruct lands in Phase 2). The dev tooling
+;; (undercover, elisp-lint, package-lint) is verified by the targets that use
+;; it, not here.
+
+;;; Code:
+
+(when noninteractive
+ (package-initialize))
+
+(defconst duet-check-deps-required-features
+ '(cl-lib)
+ "Features required by the duet test suite.")
+
+(defun duet-check-deps--missing-features ()
+ "Return required test features that cannot be loaded."
+ (let (missing)
+ (dolist (feature duet-check-deps-required-features (nreverse missing))
+ (unless (require feature nil t)
+ (push feature missing)))))
+
+(let ((missing (duet-check-deps--missing-features)))
+ (if missing
+ (progn
+ (message "Missing Emacs Lisp test dependencies: %s"
+ (mapconcat #'symbol-name missing ", "))
+ (message "Run `make setup' from the project root, or make these features available on load-path.")
+ (kill-emacs 1))
+ (message "Required Emacs Lisp dependencies are loadable: %s"
+ (mapconcat #'symbol-name duet-check-deps-required-features ", "))))
+
+;;; check-deps.el ends here
diff --git a/tests/run-coverage-file.el b/tests/run-coverage-file.el
new file mode 100644
index 0000000..550fc90
--- /dev/null
+++ b/tests/run-coverage-file.el
@@ -0,0 +1,50 @@
+;;; run-coverage-file.el --- Undercover setup for per-file coverage runs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Loaded by `make coverage' before each test file runs, BEFORE duet.el is
+;; loaded. Instrumenting must happen first so the subsequent load picks up the
+;; instrumented source.
+;;
+;; Coverage data is merged across per-file invocations into a single simplecov
+;; JSON at .coverage/simplecov.json (under the project root).
+
+;;; Code:
+
+(unless (require 'undercover nil t)
+ (message "")
+ (message "ERROR: undercover not installed.")
+ (message "Run 'make setup' to install development dependencies.")
+ (message "")
+ (kill-emacs 1))
+
+;; Resolve project root from this file's location so undercover patterns and
+;; the report-file path don't depend on default-directory at load time.
+(defvar run-coverage--project-root
+ (file-name-directory
+ (directory-file-name
+ (file-name-directory (or load-file-name buffer-file-name))))
+ "Absolute path to the duet project root.")
+
+;; Force coverage collection in non-CI environments. Must be set after loading
+;; undercover because the library's top-level form
+;; `(setq undercover-force-coverage (getenv "UNDERCOVER_FORCE"))' would
+;; otherwise overwrite the value.
+(setq undercover-force-coverage t)
+
+;; Local runs emit simplecov for whatever local tooling wants it. CI sets
+;; CI=true (GitHub Actions does this automatically), so we emit a coveralls
+;; JSON instead and leave it on disk for the upload action to pick up. The
+;; `undercover' macro splices each configuration list into `(list ,@it)', which
+;; evaluates the elements. Wildcard strings have to stay atoms — using the
+;; `(:files ...)' form lets us evaluate `expand-file-name' to an absolute path.
+(undercover (:files (expand-file-name "duet.el" run-coverage--project-root))
+ (:report-format (if (getenv "CI") 'coveralls 'simplecov))
+ (:report-file (expand-file-name
+ (if (getenv "CI")
+ ".coverage/coveralls.json"
+ ".coverage/simplecov.json")
+ run-coverage--project-root))
+ (:merge-report t)
+ (:send-report nil))
+
+;;; run-coverage-file.el ends here
diff --git a/tests/test-bootstrap.el b/tests/test-bootstrap.el
new file mode 100644
index 0000000..a545cea
--- /dev/null
+++ b/tests/test-bootstrap.el
@@ -0,0 +1,40 @@
+;;; test-bootstrap.el --- Common test initialization 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:
+
+;; Shared initialization for all duet test files. Handles package setup,
+;; dependency loading, and loading the package source.
+;;
+;; Usage: (require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; Code:
+
+;; Initialize package system for batch mode
+(when noninteractive
+ (package-initialize))
+
+(require 'ert)
+(require 'cl-lib)
+
+;; Load duet from the parent directory.
+(load (expand-file-name "../duet.el") nil t)
+
+(provide 'test-bootstrap)
+;;; test-bootstrap.el ends here
diff --git a/tests/test-duet-complexity.el b/tests/test-duet-complexity.el
new file mode 100644
index 0000000..4929a74
--- /dev/null
+++ b/tests/test-duet-complexity.el
@@ -0,0 +1,126 @@
+;;; test-duet-complexity.el --- Tests for the duet complexity scanner -*- 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:
+
+;; Unit tests for scripts/duet-complexity.el — the homegrown McCabe branch
+;; counter behind `make complexity'. The counting logic is pure (operates on
+;; already-read forms), so Normal/Boundary/Error cases need no I/O; only the
+;; file-scanning tests touch a temp file.
+
+;;; Code:
+
+(require 'ert)
+(require 'duet-complexity
+ (expand-file-name "../scripts/duet-complexity.el"
+ (file-name-directory (or load-file-name buffer-file-name))))
+
+;;; Normal cases — base count and single constructs
+
+(ert-deftest test-duet-complexity-empty-body-is-one ()
+ "A function with no decision points has complexity 1."
+ (should (= 1 (duet-complexity-of-form '(defun f ())))))
+
+(ert-deftest test-duet-complexity-single-if-is-two ()
+ "One `if' adds a single decision point."
+ (should (= 2 (duet-complexity-of-form '(defun f (x) (if x 1 2))))))
+
+(ert-deftest test-duet-complexity-when-unless-each-add-one ()
+ "`when' and `unless' each add one decision point."
+ (should (= 2 (duet-complexity-of-form '(defun f (x) (when x 1)))))
+ (should (= 2 (duet-complexity-of-form '(defun f (x) (unless x 1))))))
+
+(ert-deftest test-duet-complexity-loops-add-one ()
+ "Looping forms each add one decision point."
+ (should (= 2 (duet-complexity-of-form '(defun f (xs) (dolist (x xs) x)))))
+ (should (= 2 (duet-complexity-of-form '(defun f (n) (dotimes (i n) i)))))
+ (should (= 2 (duet-complexity-of-form '(defun f (x) (while x (setq x nil)))))))
+
+(ert-deftest test-duet-complexity-cond-one-per-clause ()
+ "`cond' adds one decision point per clause."
+ (should (= 4 (duet-complexity-of-form
+ '(defun f (x) (cond (a 1) (b 2) (t 3)))))))
+
+(ert-deftest test-duet-complexity-nested-decisions-accumulate ()
+ "A nested branch inside another counts in addition to the outer one."
+ (should (= 3 (duet-complexity-of-form
+ '(defun f (x) (when x (if x 1 2)))))))
+
+;;; Boundary cases — boolean operators, quoting, pattern matching
+
+(ert-deftest test-duet-complexity-and-adds-operands-minus-one ()
+ "`and' adds one decision point per short-circuit (operands minus one)."
+ (should (= 3 (duet-complexity-of-form '(defun f (a b c) (and a b c))))))
+
+(ert-deftest test-duet-complexity-or-two-operands-adds-one ()
+ "`or' with two operands adds a single decision point."
+ (should (= 2 (duet-complexity-of-form '(defun f (a b) (or a b))))))
+
+(ert-deftest test-duet-complexity-quoted-data-is-not-counted ()
+ "Branch-looking forms inside a quote are data, not control flow."
+ (should (= 1 (duet-complexity-of-form '(defun f () '(if a b c))))))
+
+(ert-deftest test-duet-complexity-pcase-counts-clauses-not-patterns ()
+ "`pcase' counts one per clause; an `or' pattern is not a boolean `or'."
+ (should (= 4 (duet-complexity-of-form
+ '(defun f (x) (pcase x ((or 1 2) 'a) (3 'b) (_ 'c)))))))
+
+(ert-deftest test-duet-complexity-condition-case-counts-handlers ()
+ "`condition-case' adds one decision point per handler."
+ (should (= 3 (duet-complexity-of-form
+ '(defun f () (condition-case err (foo) (error 1) (quit 2)))))))
+
+(ert-deftest test-duet-complexity-handles-defmacro-and-cl-defun ()
+ "Defun-like heads other than `defun' are measured the same way."
+ (should (= 2 (duet-complexity-of-form '(defmacro f (x) (if x 1 2)))))
+ (should (= 2 (duet-complexity-of-form '(cl-defun f (x) (if x 1 2))))))
+
+;;; File scanning
+
+(ert-deftest test-duet-complexity-scan-file-returns-name-score-pairs ()
+ "Scanning a file returns one (NAME . COMPLEXITY) pair per defun-like form."
+ (let ((file (make-temp-file "duet-cx" nil ".el"
+ "(defun simple () 1)\n(defun branchy (x) (if x (when x 1) 2))\n")))
+ (unwind-protect
+ (let ((results (duet-complexity-scan-file file)))
+ (should (equal 1 (cdr (assq 'simple results))))
+ (should (equal 3 (cdr (assq 'branchy results))))
+ (should (= 2 (length results))))
+ (delete-file file))))
+
+(ert-deftest test-duet-complexity-scan-file-ignores-non-defuns ()
+ "Top-level forms that are not defun-like are skipped by the scanner."
+ (let ((file (make-temp-file "duet-cx" nil ".el"
+ "(defvar x 1)\n(require 'foo)\n(defun real (y) (if y 1 2))\n")))
+ (unwind-protect
+ (let ((results (duet-complexity-scan-file file)))
+ (should (= 1 (length results)))
+ (should (equal 2 (cdr (assq 'real results)))))
+ (delete-file file))))
+
+;;; Threshold gate
+
+(ert-deftest test-duet-complexity-over-threshold-filters ()
+ "Only functions above the threshold are returned."
+ (let ((results '((a . 3) (b . 11) (c . 10) (d . 15))))
+ (should (equal '((b . 11) (d . 15))
+ (duet-complexity-over-threshold results 10)))))
+
+(provide 'test-duet-complexity)
+;;; test-duet-complexity.el ends here
diff --git a/tests/test-duet-smoke.el b/tests/test-duet-smoke.el
new file mode 100644
index 0000000..a101dd2
--- /dev/null
+++ b/tests/test-duet-smoke.el
@@ -0,0 +1,36 @@
+;;; test-duet-smoke.el --- Harness smoke test 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:
+
+;; Plumbing-proof smoke test: confirms the harness loads the package and the
+;; entry command is defined. Behavior coverage lives in the per-area test
+;; files added by later phases.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+(ert-deftest test-duet-smoke-feature-loaded ()
+ "The package source loads and defines its entry command."
+ (should (featurep 'duet))
+ (should (commandp 'duet)))
+
+(provide 'test-duet-smoke)
+;;; test-duet-smoke.el ends here