diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-15 02:13:37 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-15 02:13:37 -0500 |
| commit | c3420106b57b999db6526c62c1ce0e33c28ef121 (patch) | |
| tree | dd5886f3f690680a7b7fd4420181f875abb283c0 | |
| parent | 4d8f979948d5349404a36fe335eb77955d068a8d (diff) | |
| download | dotemacs-c3420106b57b999db6526c62c1ce0e33c28ef121.tar.gz dotemacs-c3420106b57b999db6526c62c1ce0e33c28ef121.zip | |
test(architecture): guard top-level timers + add startup-contract smoke test
Add a tiny source-level architecture suite at
tests/test-architecture-startup-contracts.el with two checks:
- Only keybindings.el may globally own the exact C-; prefix. Catches
accidental cross-module rebinding before it ships.
- Top-level timer scheduling (run-with-timer / run-at-time /
run-with-idle-timer) must be guarded by (unless noninteractive ...)
so requiring a module in batch / test mode does not schedule
startup timers. Timer calls inside defuns are exempt -- the test
only rejects forms that execute their body when the module loads.
Four modules had unguarded top-level timer scheduling and would have
tripped the new test. Wrap their startup hooks/timers in
(unless noninteractive ...):
- modules/org-agenda-config.el: 10s idle cache build
- modules/org-refile-config.el: 5s idle cache build
- modules/quick-video-capture.el: after-init-hook + 2s fallback
- modules/wrap-up.el: emacs-startup-hook bury-buffers delay
The contract being protected is "requiring a module in batch should
not start a clock running." Test failures will now point straight at
the offending file/form.
| -rw-r--r-- | modules/org-agenda-config.el | 15 | ||||
| -rw-r--r-- | modules/org-refile-config.el | 15 | ||||
| -rw-r--r-- | modules/quick-video-capture.el | 13 | ||||
| -rw-r--r-- | modules/wrap-up.el | 3 | ||||
| -rw-r--r-- | tests/test-architecture-startup-contracts.el | 104 | ||||
| -rw-r--r-- | todo.org | 14 |
6 files changed, 142 insertions, 22 deletions
diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el index 9e64a04b..c37ec1c9 100644 --- a/modules/org-agenda-config.el +++ b/modules/org-agenda-config.el @@ -199,13 +199,14 @@ improves performance from several seconds to instant." (- (float-time) (float-time start-time))))))) (setq org-agenda-files files))) -;; Build cache asynchronously after startup to avoid blocking Emacs -(run-with-idle-timer - 10 ; Wait 10 seconds after Emacs is idle - nil ; Don't repeat - (lambda () - (cj/log-silently "Building org-agenda files cache in background...") - (cj/build-org-agenda-list))) +;; Build cache asynchronously after startup to avoid blocking Emacs. +(unless noninteractive + (run-with-idle-timer + 10 ; Wait 10 seconds after Emacs is idle + nil ; Don't repeat + (lambda () + (cj/log-silently "Building org-agenda files cache in background...") + (cj/build-org-agenda-list)))) (defun cj/org-agenda-refresh-files () "Force rebuild of agenda files cache. diff --git a/modules/org-refile-config.el b/modules/org-refile-config.el index 63343e9f..16b37bf9 100644 --- a/modules/org-refile-config.el +++ b/modules/org-refile-config.el @@ -101,13 +101,14 @@ so caching improves performance from 15-20 seconds to instant." (- (float-time) (float-time start-time))))))) (setq org-refile-targets targets))) -;; Build cache asynchronously after startup to avoid blocking Emacs -(run-with-idle-timer - 5 ; Wait 5 seconds after Emacs is idle - nil ; Don't repeat - (lambda () - (cj/log-silently "Building org-refile targets cache in background...") - (cj/build-org-refile-targets))) +;; Build cache asynchronously after startup to avoid blocking Emacs. +(unless noninteractive + (run-with-idle-timer + 5 ; Wait 5 seconds after Emacs is idle + nil ; Don't repeat + (lambda () + (cj/log-silently "Building org-refile targets cache in background...") + (cj/build-org-refile-targets)))) (defun cj/org-refile-refresh-targets () "Force rebuild of refile targets cache. diff --git a/modules/quick-video-capture.el b/modules/quick-video-capture.el index 4e62309e..0e9567dd 100644 --- a/modules/quick-video-capture.el +++ b/modules/quick-video-capture.el @@ -125,12 +125,13 @@ It's designed to be idempotent - safe to call multiple times." ;; Deferred initialization strategy: ;; 1. Try to load shortly after Emacs is idle following init ;; 2. Fallback timer ensures loading within 2 seconds regardless -(add-hook 'after-init-hook - (lambda () - (run-with-idle-timer 0.5 nil #'cj/setup-video-download))) +(unless noninteractive + (add-hook 'after-init-hook + (lambda () + (run-with-idle-timer 0.5 nil #'cj/setup-video-download))) -;; Fallback: ensure initialization within 2 seconds of loading this file -(run-with-timer 2 nil #'cj/setup-video-download) + ;; Fallback: ensure initialization within 2 seconds of loading this file + (run-with-timer 2 nil #'cj/setup-video-download)) ;; If someone manually triggers capture before initialization (with-eval-after-load 'org-capture @@ -145,4 +146,4 @@ It's designed to be idempotent - safe to call multiple times." ;; the download through yt-dlp and task-spooler. (provide 'quick-video-capture) -;;; quick-video-capture.el ends here
\ No newline at end of file +;;; quick-video-capture.el ends here diff --git a/modules/wrap-up.el b/modules/wrap-up.el index 523d55b2..93b74776 100644 --- a/modules/wrap-up.el +++ b/modules/wrap-up.el @@ -25,7 +25,8 @@ (defun cj/bury-buffers-after-delay () "Run cj/bury-buffers after a delay." (run-with-timer 1 nil 'cj/bury-buffers)) -(add-hook 'emacs-startup-hook 'cj/bury-buffers-after-delay) +(unless noninteractive + (add-hook 'emacs-startup-hook 'cj/bury-buffers-after-delay)) (cj/log-silently "<-- end of init file.") diff --git a/tests/test-architecture-startup-contracts.el b/tests/test-architecture-startup-contracts.el new file mode 100644 index 00000000..a3a0e09b --- /dev/null +++ b/tests/test-architecture-startup-contracts.el @@ -0,0 +1,104 @@ +;;; test-architecture-startup-contracts.el --- Startup architecture smoke tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; Lightweight source-level checks for cross-module startup contracts. These +;; deliberately avoid requiring package-heavy modules; the goal is to catch +;; accidental load-order and batch-startup regressions early without building a +;; full static analyzer. + +;;; Code: + +(require 'cl-lib) +(require 'ert) + +(defconst test-architecture--repo-root + (file-name-directory + (directory-file-name + (file-name-directory (or load-file-name buffer-file-name)))) + "Repository root for architecture contract tests.") + +(defun test-architecture--module-files () + "Return all direct module source files." + (directory-files (expand-file-name "modules" test-architecture--repo-root) + t "\\.el\\'")) + +(defun test-architecture--file-string (file) + "Return FILE contents as a string." + (with-temp-buffer + (insert-file-contents file) + (buffer-string))) + +(defun test-architecture--read-top-level-forms (file) + "Read top-level forms 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 test-architecture--contains-timer-form-p (form) + "Return non-nil when FORM contains a timer scheduling call." + (cond + ((atom form) nil) + ((memq (car form) '(run-with-timer run-at-time run-with-idle-timer)) t) + (t (or (test-architecture--contains-timer-form-p (car form)) + (test-architecture--contains-timer-form-p (cdr form)))))) + +(defun test-architecture--noninteractive-guard-p (form) + "Return non-nil when FORM is guarded against batch/noninteractive startup." + (and (consp form) + (or (and (eq (car form) 'unless) + (eq (cadr form) 'noninteractive)) + (and (eq (car form) 'when) + (equal (cadr form) '(not noninteractive)))))) + +(defun test-architecture--definition-form-p (form) + "Return non-nil when FORM defines code but does not execute its body now." + (and (consp form) + (memq (car form) '(defun defmacro defsubst cl-defun cl-defmacro)))) + +(defun test-architecture--unguarded-top-level-timer-forms (file) + "Return top-level timer scheduling forms in FILE that are not batch-guarded." + (let (violations) + (dolist (form (test-architecture--read-top-level-forms file)) + (when (and (test-architecture--contains-timer-form-p form) + (not (test-architecture--definition-form-p form)) + (not (test-architecture--noninteractive-guard-p form))) + (push (prin1-to-string form) violations))) + (nreverse violations))) + +(ert-deftest test-architecture-custom-prefix-owned-by-keybindings () + "Only keybindings.el may globally own the exact C-; prefix." + (let ((owner (expand-file-name "modules/keybindings.el" + test-architecture--repo-root)) + offenders) + (dolist (file (test-architecture--module-files)) + (let ((contents (test-architecture--file-string file))) + (when (and (not (string= file owner)) + (or (string-match-p "(keymap-global-set[[:space:]\n]+\"C-;\"" contents) + (string-match-p "(global-set-key[[:space:]\n]+(kbd[[:space:]\n]+\"C-;\"" contents))) + (push (file-relative-name file test-architecture--repo-root) offenders)))) + (should (string-match-p "(keymap-global-set[[:space:]\n]+\"C-;\"" + (test-architecture--file-string owner))) + (should-not offenders))) + +(ert-deftest test-architecture-top-level-timers-are-batch-guarded () + "Top-level timer scheduling must be guarded by noninteractive. + +Function definitions may contain timer calls; this test only rejects timer +scheduling that can run while a module is being required in batch/test mode." + (let (offenders) + (dolist (file (test-architecture--module-files)) + (let ((violations (test-architecture--unguarded-top-level-timer-forms file))) + (when violations + (push (format "%s: %s" + (file-relative-name file test-architecture--repo-root) + (string-join violations " ")) + offenders)))) + (should-not offenders))) + +(provide 'test-architecture-startup-contracts) +;;; test-architecture-startup-contracts.el ends here @@ -853,7 +853,8 @@ Done 2026-05-15: - Updated =tests/test-coverage-summary.el= to assert the policy and the displayed project-module percentage. -*** TODO [#B] Add a lightweight architecture smoke test for startup contracts :tests: +*** DONE [#B] Add a lightweight architecture smoke test for startup contracts :tests: +CLOSED: [2026-05-15 Fri] After the above refactors start, add one or two smoke tests that protect the architecture instead of individual functions. @@ -867,6 +868,17 @@ Candidate checks: Keep this small. The goal is to catch accidental return to hidden load-order coupling, not to build a full static analyzer. +Done 2026-05-15: +- Added =tests/test-architecture-startup-contracts.el= with two source-level + smoke checks: + - only =keybindings.el= may globally own the exact =C-;= prefix; + - top-level timer scheduling forms must be guarded by =noninteractive= so + batch/test loads do not schedule startup timers. +- Gated existing startup timers in =org-agenda-config.el=, + =org-refile-config.el=, =quick-video-capture.el=, and =wrap-up.el=. +- Focused tests passed for the new architecture smoke file and the affected + agenda/refile helpers. + ** PROJECT [#B] Module-by-module review and hardening :review: Review every file in =modules/= and capture concrete bugs, tests, refactors, |
