;;; 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 ") ;; cj-comment annotation block — Craig's convention #+begin_src cj: comment. ;; org-lint flags the language (cj:) as unknown and the comment header arg as ;; both missing-colon and empty-value. All three are false positives. (defconst lo-test--cj-comment-block "\ * Heading #+begin_src cj: comment my annotation text #+end_src ") ;; cj-comment block alongside a real suspicious-language warning — verifies ;; the cj suppression doesn't leak into other src blocks in the same file. (defconst lo-test--cj-comment-with-real-warning "\ * Heading #+begin_src cj: comment my annotation #+end_src #+begin_src markdown real suspicious-language warning here #+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-suppressed () (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))) ;; Verbatim =*** Foo= inside prose is never a real misplaced heading, so it ;; is suppressed — no judgment emitted (compare the cj-comment suppression). (should-not (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))))) ;; mu4e:msgid: links are registered by mu4e at runtime in a live Emacs; in ;; batch, org-lint parses them as fuzzy heading refs and reports "Unknown ;; fuzzy location" — a false positive (home's todo.org, 2026-06-11). ;; lint-org pre-registers known runtime link types so they parse as links. (defconst lo-test--runtime-link "\ * Heading See [[mu4e:msgid:abc123@mail.example.com][the thread with Jonathan]]. ") (ert-deftest lo-runtime-link-type-is-not-flagged () (let* ((out (lo-test--run lo-test--runtime-link)) (res (plist-get out :result)) (judgments (lo-test--judgments (plist-get out :issues)))) (should (equal lo-test--runtime-link res)) (should (= 0 (plist-get out :fixes))) (should-not (member 'invalid-fuzzy-link (lo-test--checkers judgments))))) ;;; --------------------------------------------------------------------------- ;;; cj-comment block — Craig's annotation convention is silently suppressed (ert-deftest lo-cj-comment-block-emits-no-judgments () "Normal: a `#+begin_src cj: comment ...' block must not trigger the three false-positive warnings org-lint emits for it (unknown language, empty header-arg value, missing colon in header arg)." (let* ((out (lo-test--run lo-test--cj-comment-block)) (res (plist-get out :result)) (judgments (lo-test--judgments (plist-get out :issues))) (checkers (lo-test--checkers judgments))) ;; File untouched, no fixes applied. (should (equal lo-test--cj-comment-block res)) (should (= 0 (plist-get out :fixes))) ;; None of the three false-positive checkers fired. (should-not (member 'suspicious-language-in-src-block checkers)) (should-not (member 'empty-header-argument checkers)) (should-not (member 'wrong-header-argument checkers)))) (ert-deftest lo-cj-comment-suppression-does-not-mask-real-warnings () "Boundary: the cj-comment suppression is scoped to cj-comment block openers only — a regular `#+begin_src markdown' in the same file still emits its suspicious-language judgment." (let* ((out (lo-test--run lo-test--cj-comment-with-real-warning)) (judgments (lo-test--judgments (plist-get out :issues))) (suspicious (cl-count 'suspicious-language-in-src-block (lo-test--checkers judgments)))) ;; Exactly one suspicious-language judgment — from the markdown block, ;; not the cj-comment block. (should (= 1 suspicious)))) ;;; --------------------------------------------------------------------------- ;;; --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 and suspicious-language 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)) ;; The verbatim-asterisk misplaced-heading is suppressed, not surfaced. (should-not (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-records-judgments-by-content () "Each judgment is recorded under a per-file section, keyed by checker + message with the line as a trailing annotation rather than the entry's identity." (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)))) ;; Per-file section header naming the file. (should (string-match-p "^\\* lint-org follow-ups — " content)) ;; Entries lead with the checker (the content key); line is annotation. (should (lo-test--has content "TODO link-to-local-file — ")) (should (lo-test--has content "TODO invalid-fuzzy-link — ")) (should (lo-test--has content "TODO suspicious-language-in-src-block — ")) (should (string-match-p "(line [0-9]+)" 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-dedupes-across-runs () "Running twice on the same file reconciles to one section, not two appended sections." (let ((followups (make-temp-file "lo-followups-" nil ".org")) (file (make-temp-file "lo-test-fup-ded-" nil ".org"))) (unwind-protect (progn (with-temp-file file (insert lo-test--mixed)) (with-temp-file followups (insert "")) (dotimes (_ 2) (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))) (n 0) (start 0)) (while (string-match "^\\* lint-org follow-ups — " content start) (setq n (1+ n) start (match-end 0))) (should (= n 1)))) (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-drops-resolved-finding () "A finding that no longer reproduces (its source was fixed) is dropped from the followups file on the next run." (let ((followups (make-temp-file "lo-followups-" nil ".org")) (file (make-temp-file "lo-test-fup-drop-" nil ".org"))) (unwind-protect (progn ;; First run: a broken file link produces a judgment. (with-temp-file file (insert "#+TITLE: t\n* H\n[[file:does-not-exist-zzz.org][broken]]\n")) (with-temp-file followups (insert "")) (lo-test--reset nil followups) (lo-process-file file) (lo-emit-report) (lo-test--drop-buffer file) (should (lo-test--has (with-temp-buffer (insert-file-contents followups) (buffer-string)) "link-to-local-file")) ;; Fix the source, re-run: the finding no longer reproduces. (with-temp-file file (insert "#+TITLE: t\n* H\nclean now\n")) (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)))) (should-not (lo-test--has content "link-to-local-file")))) (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-preserves-other-files-sections () "Reconciling one file's section leaves other files' sections intact." (let ((followups (make-temp-file "lo-followups-" nil ".org")) (file (make-temp-file "lo-test-fup-other-" nil ".org"))) (unwind-protect (progn (with-temp-file followups (insert "* lint-org follow-ups — other-file.org (2026-01-01)\n" "** TODO some-checker — a prior finding (line 5)\n")) (with-temp-file file (insert lo-test--mixed)) (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)))) ;; The unrelated file's section survives. (should (lo-test--has content "other-file.org")) (should (lo-test--has content "a prior finding")) ;; This file's section was added alongside it. (should (lo-test--has content "link-to-local-file")))) (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/.before-lint-pass. (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))))) ;;; --------------------------------------------------------------------------- ;;; org-table-standard check (width budget + rules between rows) (ert-deftest lo-table-over-budget-emits-judgment () "A table line rendering wider than 120 surfaces as an org-table-standard judgment." (let* ((wide (make-string 130 ?x)) (run (lo-test--run (format "* H\n\n| a |\n|---|\n| %s |\n|---|\n" wide))) (judgments (lo-test--judgments (plist-get run :issues)))) (should (memq 'org-table-standard (lo-test--checkers judgments))) (should (cl-some (lambda (i) (lo-test--has (plist-get i :msg) "120")) judgments)))) (ert-deftest lo-table-compliant-not-flagged () "A narrow table with rules under header and every row passes silently." (let* ((run (lo-test--run "* H\n\n| a |\n|---|\n| ok |\n|---|\n")) (judgments (lo-test--judgments (plist-get run :issues)))) (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) (ert-deftest lo-table-missing-interrow-rules-emits-judgment () "Data rows without a rule between them violate the standard." (let* ((run (lo-test--run "* H\n\n| a | b |\n|---+---|\n| 1 | 2 |\n| 3 | 4 |\n")) (judgments (lo-test--judgments (plist-get run :issues)))) (should (memq 'org-table-standard (lo-test--checkers judgments))) (should (cl-some (lambda (i) (lo-test--has (plist-get i :msg) "rule")) judgments)))) (ert-deftest lo-table-wide-link-source-measures-render-width () "A long link target doesn't trip the budget — width is render-measured." (let* ((target (concat "https://example.com/" (make-string 120 ?p))) (run (lo-test--run (format "* H\n\n| [[%s][ok]] |\n|---|\n| x |\n|---|\n" target))) (judgments (lo-test--judgments (plist-get run :issues)))) (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) (ert-deftest lo-table-conformant-wrapped-table-not-flagged () "Continuation rows inside a rule-delimited group are one logical row, not a missing-rules violation." (let* ((run (lo-test--run "* H\n\n| Name | Notes |\n|-------+-------------------|\n| alpha | wrapped text that |\n| | continues here |\n|-------+-------------------|\n")) (judgments (lo-test--judgments (plist-get run :issues)))) (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) (provide 'test-lint-org) ;;; test-lint-org.el ends here