aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-14 18:45:48 -0500
committerCraig Jennings <c@cjennings.net>2026-05-14 18:45:48 -0500
commitf5b8688aed8ec698220a67c2dbfbcae22e7575f4 (patch)
treec35f56d9018aa902c7b18021ff70bbe708267b80 /.ai/scripts/tests
parent686d6471191888d05bcdd1f194a9d5dd5ee65d7a (diff)
downloadrulesets-f5b8688aed8ec698220a67c2dbfbcae22e7575f4.tar.gz
rulesets-f5b8688aed8ec698220a67c2dbfbcae22e7575f4.zip
chore(ai): sync lint-org script and wrap-it-up from claude-templates
Byte-identical pull of .ai/scripts/lint-org.el, .ai/scripts/tests/test-lint-org.el, and the new Step 3 lint section in .ai/workflows/wrap-it-up.org. Upstream: claude-templates 138f35f (feat) and 4eba98c (docs).
Diffstat (limited to '.ai/scripts/tests')
-rw-r--r--.ai/scripts/tests/test-lint-org.el465
1 files changed, 465 insertions, 0 deletions
diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el
new file mode 100644
index 0000000..8e1ebc4
--- /dev/null
+++ b/.ai/scripts/tests/test-lint-org.el
@@ -0,0 +1,465 @@
+;;; test-lint-org.el --- ERT tests for lint-org.el -*- lexical-binding: t; -*-
+;;
+;; Run from the repo root:
+;; emacs --batch -q -L .ai/scripts -l ert \
+;; -l .ai/scripts/tests/test-lint-org.el \
+;; -f ert-run-tests-batch-and-exit
+;;
+;; or from .ai/scripts/tests/:
+;; emacs --batch -q -L .. -l ert -l test-lint-org.el \
+;; -f ert-run-tests-batch-and-exit
+;;
+;; Covers: mechanical auto-fixers (item-number, missing-language-in-src-block,
+;; misplaced-planning-info, markdown-bold case of misplaced-heading) and
+;; judgment-item emission (link-to-local-file, invalid-fuzzy-link,
+;; verbatim-asterisk case of misplaced-heading, suspicious-language-in-src-block,
+;; unhandled checkers).
+
+(require 'ert)
+(require 'cl-lib)
+
+(defconst lo-test--dir
+ (file-name-directory (or load-file-name buffer-file-name default-directory))
+ "Directory of this test file, captured at load time.")
+
+(add-to-list 'load-path (expand-file-name ".." lo-test--dir))
+(require 'lint-org)
+
+;;; ---------------------------------------------------------------------------
+;;; Harness
+
+(defun lo-test--reset (&optional check followups-file)
+ (setq lo-fixes 0 lo-issues nil
+ lo-check-only (and check t)
+ lo-current-file nil
+ lo-followups-file followups-file))
+
+(defun lo-test--drop-buffer (file)
+ (let ((buf (find-buffer-visiting file)))
+ (when buf
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (kill-buffer buf))))
+
+(defun lo-test--run (content &optional runs check)
+ "Write CONTENT to a temp .org file, run lint-org RUNS times (default 1).
+Return a plist :result (final file contents) :fixes (last run)
+:issues (last run). CHECK non-nil ⇒ --check (preview, no writes)."
+ (let ((file (make-temp-file "lo-test-" nil ".org"))
+ last-fixes last-issues)
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert content))
+ (dotimes (_ (or runs 1))
+ (lo-test--reset check)
+ (lo-process-file file)
+ (setq last-fixes lo-fixes last-issues lo-issues)
+ (lo-test--drop-buffer file))
+ (list :result (with-temp-buffer (insert-file-contents file)
+ (buffer-string))
+ :fixes last-fixes
+ :issues last-issues))
+ (lo-test--drop-buffer file)
+ (delete-file file))))
+
+(defun lo-test--judgments (issues)
+ "Return judgment items from ISSUES, in document order."
+ (reverse
+ (cl-remove-if-not (lambda (i) (eq (plist-get i :kind) 'judgment)) issues)))
+
+(defun lo-test--mechanical (issues)
+ "Return mechanical-fixed items from ISSUES, in document order."
+ (reverse
+ (cl-remove-if-not (lambda (i) (eq (plist-get i :kind) 'mechanical-fixed))
+ issues)))
+
+(defun lo-test--checkers (items)
+ (mapcar (lambda (i) (plist-get i :checker)) items))
+
+(defun lo-test--has (string substring)
+ (and (string-match-p (regexp-quote substring) string) t))
+
+;;; ---------------------------------------------------------------------------
+;;; Fixtures
+
+;; item-number — bullets 4. and 5. where org expects items 3 and 4.
+(defconst lo-test--item-number "\
+* Heading
+
+1. first
+2. second
+
+4. out-of-order
+5. and another
+")
+
+(defconst lo-test--item-number-already-tagged "\
+* Heading
+
+1. first
+2. second
+
+4. [@4] already tagged
+5. [@5] also already tagged
+")
+
+;; missing-language-in-src-block — bare #+begin_src ... #+end_src.
+(defconst lo-test--bare-src "\
+* Heading
+
+#+begin_src
+some prose without a language
+#+end_src
+")
+
+;; A src block with a language slug doesn't trip the missing-language checker.
+(defconst lo-test--src-with-language "\
+* Heading
+
+#+begin_src text
+some prose with a language
+#+end_src
+")
+
+;; misplaced-planning-info — CLOSED and DEADLINE on separate lines.
+(defconst lo-test--planning-split "\
+* DONE Task
+CLOSED: [2026-05-14]
+DEADLINE: <2026-05-20>
+
+Body.
+")
+
+;; misplaced-heading, markdown-bold case — **X.** at start of body paragraph.
+(defconst lo-test--md-bold "\
+* Heading
+
+**Important.** Body continues here.
+
+More body.
+")
+
+;; misplaced-heading, verbatim-asterisk case — =*** Foo= inside body prose.
+(defconst lo-test--verbatim-asterisk "\
+* Heading
+
+A reference to =*** Foo= inside body prose.
+")
+
+;; link-to-local-file — broken file: link.
+(defconst lo-test--broken-file-link "\
+* Heading
+
+See [[file:/tmp/does-not-exist-lo-test.org][a link]].
+")
+
+;; invalid-fuzzy-link — link to a heading that doesn't exist in this file.
+(defconst lo-test--broken-fuzzy-link "\
+* Heading
+
+See [[*Nonexistent Heading]].
+")
+
+;; suspicious-language-in-src-block — #+begin_src markdown.
+(defconst lo-test--suspicious-language "\
+* Heading
+
+#+begin_src markdown
+content
+#+end_src
+")
+
+;; Mixed fixture — each category once.
+(defconst lo-test--mixed "\
+* Mixed
+
+1. first
+2. second
+
+4. out-of-order
+
+** DONE Task
+CLOSED: [2026-05-14]
+DEADLINE: <2026-05-20>
+
+**Important.** Body.
+
+A reference to =*** Foo= inside body.
+
+See [[file:/tmp/does-not-exist-lo-test.org][a link]].
+
+See [[*Nonexistent Heading]].
+
+#+begin_src
+prose
+#+end_src
+
+#+begin_src markdown
+content
+#+end_src
+")
+
+;;; ---------------------------------------------------------------------------
+;;; item-number tests
+
+(ert-deftest lo-item-number-adds-counter-directive ()
+ (let* ((out (lo-test--run lo-test--item-number))
+ (res (plist-get out :result)))
+ (should (>= (plist-get out :fixes) 1))
+ (should (lo-test--has res "4. [@4] out-of-order"))
+ (should (lo-test--has res "5. [@5] and another"))
+ ;; well-formed bullets above stay alone
+ (should (lo-test--has res "1. first"))
+ (should (lo-test--has res "2. second"))))
+
+(ert-deftest lo-item-number-skips-already-tagged ()
+ (let ((out (lo-test--run lo-test--item-number-already-tagged)))
+ (should (= 0 (plist-get out :fixes)))
+ (should (equal lo-test--item-number-already-tagged (plist-get out :result)))))
+
+(ert-deftest lo-item-number-is-idempotent ()
+ (let ((once (plist-get (lo-test--run lo-test--item-number 1) :result))
+ (twice (plist-get (lo-test--run lo-test--item-number 2) :result)))
+ (should (equal once twice))))
+
+;;; ---------------------------------------------------------------------------
+;;; missing-language-in-src-block tests
+
+(ert-deftest lo-bare-src-becomes-example ()
+ (let* ((out (lo-test--run lo-test--bare-src))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :fixes)))
+ (should (lo-test--has res "#+begin_example"))
+ (should (lo-test--has res "#+end_example"))
+ (should-not (lo-test--has res "#+begin_src\n"))
+ (should-not (lo-test--has res "#+end_src"))
+ (should (lo-test--has res "some prose without a language"))))
+
+(ert-deftest lo-src-with-language-stays ()
+ (let ((out (lo-test--run lo-test--src-with-language)))
+ (should (= 0 (plist-get out :fixes)))
+ (should (equal lo-test--src-with-language (plist-get out :result)))))
+
+(ert-deftest lo-bare-src-is-idempotent ()
+ (let ((once (plist-get (lo-test--run lo-test--bare-src 1) :result))
+ (twice (plist-get (lo-test--run lo-test--bare-src 2) :result)))
+ (should (equal once twice))))
+
+;;; ---------------------------------------------------------------------------
+;;; misplaced-planning-info tests
+
+(ert-deftest lo-planning-info-merges-onto-one-line ()
+ (let* ((out (lo-test--run lo-test--planning-split))
+ (res (plist-get out :result)))
+ (should (>= (plist-get out :fixes) 1))
+ ;; Both keywords on the same line, exactly one blank space between values.
+ (should (string-match-p
+ "CLOSED: \\[2026-05-14\\][^\n]*DEADLINE: <2026-05-20"
+ res))
+ ;; No stray DEADLINE: line on its own.
+ (should-not (string-match-p "^DEADLINE: <2026-05-20" res))))
+
+(ert-deftest lo-planning-info-is-idempotent ()
+ (let ((once (plist-get (lo-test--run lo-test--planning-split 1) :result))
+ (twice (plist-get (lo-test--run lo-test--planning-split 2) :result)))
+ (should (equal once twice))))
+
+;;; ---------------------------------------------------------------------------
+;;; misplaced-heading tests
+
+(ert-deftest lo-markdown-bold-becomes-single-asterisk ()
+ (let* ((out (lo-test--run lo-test--md-bold))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :fixes)))
+ (should (lo-test--has res "*Important.* Body continues here."))
+ (should-not (lo-test--has res "**Important.**"))))
+
+(ert-deftest lo-markdown-bold-is-idempotent ()
+ (let ((once (plist-get (lo-test--run lo-test--md-bold 1) :result))
+ (twice (plist-get (lo-test--run lo-test--md-bold 2) :result)))
+ (should (equal once twice))))
+
+(ert-deftest lo-verbatim-asterisk-is-judgment ()
+ (let* ((out (lo-test--run lo-test--verbatim-asterisk))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ ;; File untouched.
+ (should (equal lo-test--verbatim-asterisk res))
+ (should (= 0 (plist-get out :fixes)))
+ ;; Emitted as judgment with the misplaced-heading checker.
+ (should (member 'misplaced-heading (lo-test--checkers judgments)))))
+
+;;; ---------------------------------------------------------------------------
+;;; Judgment-category emission tests
+
+(ert-deftest lo-broken-file-link-is-judgment ()
+ (let* ((out (lo-test--run lo-test--broken-file-link))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (equal lo-test--broken-file-link res))
+ (should (= 0 (plist-get out :fixes)))
+ (should (member 'link-to-local-file (lo-test--checkers judgments)))))
+
+(ert-deftest lo-broken-fuzzy-link-is-judgment ()
+ (let* ((out (lo-test--run lo-test--broken-fuzzy-link))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (equal lo-test--broken-fuzzy-link res))
+ (should (= 0 (plist-get out :fixes)))
+ (should (member 'invalid-fuzzy-link (lo-test--checkers judgments)))))
+
+(ert-deftest lo-suspicious-language-is-judgment ()
+ (let* ((out (lo-test--run lo-test--suspicious-language))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (equal lo-test--suspicious-language res))
+ (should (= 0 (plist-get out :fixes)))
+ (should (member 'suspicious-language-in-src-block
+ (lo-test--checkers judgments)))))
+
+;;; ---------------------------------------------------------------------------
+;;; --check mode
+
+(ert-deftest lo-check-mode-does-not-modify-file ()
+ (let* ((out (lo-test--run lo-test--mixed 1 t))
+ (res (plist-get out :result)))
+ (should (equal lo-test--mixed res))))
+
+(ert-deftest lo-check-mode-reports-mechanical-and-judgment ()
+ (let* ((out (lo-test--run lo-test--mixed 1 t))
+ (issues (plist-get out :issues))
+ (kinds (cl-remove-duplicates
+ (mapcar (lambda (i) (plist-get i :kind)) issues))))
+ ;; Both kinds appear — check mode reports would-fix entries as
+ ;; mechanical-fixed and judgment items as judgment, no writes.
+ (should (member 'mechanical-fixed kinds))
+ (should (member 'judgment kinds))))
+
+;;; ---------------------------------------------------------------------------
+;;; Mixed-fixture integration
+
+(ert-deftest lo-mixed-fixture-applies-all-mechanical-and-emits-judgment ()
+ (let* ((out (lo-test--run lo-test--mixed))
+ (res (plist-get out :result))
+ (judgment-checkers
+ (cl-remove-duplicates
+ (lo-test--checkers (lo-test--judgments (plist-get out :issues))))))
+ ;; Mechanical: every flagged item-number, bare-src, planning, md-bold fixed.
+ (should (>= (plist-get out :fixes) 4))
+ (should (lo-test--has res "4. [@4] out-of-order"))
+ (should (lo-test--has res "#+begin_example"))
+ (should (lo-test--has res "*Important.* Body."))
+ (should (string-match-p
+ "CLOSED: \\[2026-05-14\\][^\n]*DEADLINE: <2026-05-20"
+ res))
+ ;; Judgment: every flagged broken link, suspicious-language, verbatim-asterisk
+ ;; emitted untouched.
+ (should (member 'link-to-local-file judgment-checkers))
+ (should (member 'invalid-fuzzy-link judgment-checkers))
+ (should (member 'suspicious-language-in-src-block judgment-checkers))
+ (should (member 'misplaced-heading judgment-checkers))
+ ;; Verbatim-asterisk untouched in the file.
+ (should (lo-test--has res "=*** Foo="))))
+
+(ert-deftest lo-mixed-fixture-is-idempotent ()
+ (let ((once (plist-get (lo-test--run lo-test--mixed 1) :result))
+ (twice (plist-get (lo-test--run lo-test--mixed 2) :result)))
+ (should (equal once twice))))
+
+;;; ---------------------------------------------------------------------------
+;;; Backup file is created in /tmp
+
+;;; ---------------------------------------------------------------------------
+;;; Follow-ups file behavior
+
+(ert-deftest lo-followups-file-appends-judgments ()
+ (let ((followups (make-temp-file "lo-followups-" nil ".org"))
+ (file (make-temp-file "lo-test-fup-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert lo-test--mixed))
+ (with-temp-file followups (insert ""))
+ (lo-test--reset nil followups)
+ (lo-process-file file)
+ (lo-emit-report)
+ (lo-test--drop-buffer file)
+ (let ((content (with-temp-buffer
+ (insert-file-contents followups)
+ (buffer-string))))
+ ;; Dated section header.
+ (should (string-match-p
+ (format "^\\* %s lint-org follow-ups"
+ (format-time-string "%Y-%m-%d"))
+ content))
+ ;; Each judgment is a TODO line referencing checker + line number.
+ (should (string-match-p "TODO line [0-9]+ — link-to-local-file" content))
+ (should (string-match-p "TODO line [0-9]+ — invalid-fuzzy-link" content))
+ (should (string-match-p
+ "TODO line [0-9]+ — suspicious-language-in-src-block"
+ content))))
+ (lo-test--drop-buffer file)
+ (when (file-exists-p file) (delete-file file))
+ (when (file-exists-p followups) (delete-file followups)))))
+
+(ert-deftest lo-followups-file-skipped-in-check-mode ()
+ (let ((followups (make-temp-file "lo-followups-" nil ".org"))
+ (file (make-temp-file "lo-test-fup-check-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert lo-test--mixed))
+ (with-temp-file followups (insert ""))
+ (lo-test--reset t followups) ; check=t, followups set
+ (lo-process-file file)
+ (lo-emit-report)
+ (lo-test--drop-buffer file)
+ ;; followups untouched in check mode
+ (should (equal "" (with-temp-buffer
+ (insert-file-contents followups)
+ (buffer-string)))))
+ (lo-test--drop-buffer file)
+ (when (file-exists-p file) (delete-file file))
+ (when (file-exists-p followups) (delete-file followups)))))
+
+(ert-deftest lo-followups-file-noop-when-no-judgments ()
+ ;; A fixture with only mechanical issues should leave the followups file empty.
+ (let ((followups (make-temp-file "lo-followups-" nil ".org"))
+ (file (make-temp-file "lo-test-fup-empty-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert lo-test--item-number))
+ (with-temp-file followups (insert ""))
+ (lo-test--reset nil followups)
+ (lo-process-file file)
+ (lo-emit-report)
+ (lo-test--drop-buffer file)
+ (should (equal "" (with-temp-buffer
+ (insert-file-contents followups)
+ (buffer-string)))))
+ (lo-test--drop-buffer file)
+ (when (file-exists-p file) (delete-file file))
+ (when (file-exists-p followups) (delete-file followups)))))
+
+(ert-deftest lo-creates-backup-before-modifying ()
+ (let ((file (make-temp-file "lo-test-bak-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert lo-test--bare-src))
+ (lo-test--reset)
+ (lo-process-file file)
+ (lo-test--drop-buffer file)
+ ;; Backup pattern in lint-org.el: /tmp/<basename>.before-lint-pass.<timestamp>
+ (let* ((basename (file-name-nondirectory file))
+ (backups (directory-files "/tmp" t
+ (concat (regexp-quote basename)
+ "\\.before-lint-pass\\."))))
+ (should (>= (length backups) 1))
+ ;; Backup content matches pre-fix content.
+ (let ((backup (car backups)))
+ (with-temp-buffer
+ (insert-file-contents backup)
+ (should (equal lo-test--bare-src (buffer-string))))
+ (delete-file backup))))
+ (lo-test--drop-buffer file)
+ (when (file-exists-p file) (delete-file file)))))
+
+(provide 'test-lint-org)
+;;; test-lint-org.el ends here