;;; 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))))) ;;; --------------------------------------------------------------------------- ;;; level-2 dated-header check (claude-rules/todo-format.md) (ert-deftest lo-level2-dated-header-is-judgment () "A level-2 heading beginning with a YYYY-MM-DD date is flagged." (let* ((out (lo-test--run "* Open Work\n\n** 2026-06-20 Sat @ 10:00:00 -0500 Something resolved\nBody.\n")) (res (plist-get out :result)) (judgments (lo-test--judgments (plist-get out :issues)))) (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed (should (member 'level-2-dated-header (lo-test--checkers judgments))))) (ert-deftest lo-level2-done-task-not-flagged () "A level-2 task closed with a terminal keyword + CLOSED: is fine." (let* ((out (lo-test--run "* Open Work\n\n** DONE [#B] Something resolved\nCLOSED: [2026-06-20 Sat]\nBody.\n")) (judgments (lo-test--judgments (plist-get out :issues)))) (should-not (member 'level-2-dated-header (lo-test--checkers judgments))))) (ert-deftest lo-level3-dated-entry-not-flagged () "A dated event-log entry at level 3 is the correct sub-task shape, not a defect." (let* ((out (lo-test--run "* Open Work\n\n** TODO [#B] Parent task\n*** 2026-06-20 Sat @ 10:00:00 -0500 sub-entry landed\nBody.\n")) (judgments (lo-test--judgments (plist-get out :issues)))) (should-not (member 'level-2-dated-header (lo-test--checkers judgments))))) ;;; subtask-done-not-dated check (the inverse: level-3+ done keyword) (ert-deftest lo-subtask-done-not-dated-flags-level3 () "A level-3 DONE sub-task still carrying the keyword is flagged for conversion." (let* ((out (lo-test--run "* Open Work\n\n** TODO [#B] Parent\n*** DONE [#C] Sub-task done\nCLOSED: [2026-06-20 Sat 10:00]\nBody.\n")) (judgments (lo-test--judgments (plist-get out :issues)))) (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed (should (member 'subtask-done-not-dated (lo-test--checkers judgments))))) (ert-deftest lo-subtask-done-not-dated-flags-level4-cancelled () "A level-4 CANCELLED sub-task is flagged too." (let* ((out (lo-test--run "* Open Work\n\n** PROJECT [#B] Parent\n*** TODO Mid\n**** CANCELLED Deep abandoned\nCLOSED: [2026-06-20 Sat]\n")) (judgments (lo-test--judgments (plist-get out :issues)))) (should (member 'subtask-done-not-dated (lo-test--checkers judgments))))) (ert-deftest lo-subtask-done-not-dated-ignores-level2 () "A level-2 DONE task is a top-level task, not a sub-task — this checker skips it." (let* ((out (lo-test--run "* Open Work\n\n** DONE [#B] Top-level\nCLOSED: [2026-06-20 Sat]\nBody.\n")) (judgments (lo-test--judgments (plist-get out :issues)))) (should-not (member 'subtask-done-not-dated (lo-test--checkers judgments))))) (ert-deftest lo-subtask-done-not-dated-ignores-dated-and-lowercase () "An already-dated level-3 entry, and the word done in a title, are not flagged." (let* ((out (lo-test--run "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0400 landed\n*** TODO wrap the done cleanup\n")) (judgments (lo-test--judgments (plist-get out :issues)))) (should-not (member 'subtask-done-not-dated (lo-test--checkers judgments))))) ;;; --------------------------------------------------------------------------- ;;; structural heading checks (org-lint gaps) (defun lo-test--checker-lines (issues checker) "Lines of judgment ISSUES whose :checker is CHECKER, document order." (mapcar (lambda (i) (plist-get i :line)) (cl-remove-if-not (lambda (i) (and (eq (plist-get i :kind) 'judgment) (eq (plist-get i :checker) checker))) (reverse issues)))) (ert-deftest lo-indented-heading-flags-leading-whitespace () "Error: a heading indented off column 0 is flagged (org demotes it to body)." (let* ((out (lo-test--run "* Open\n ** TODO indented and lost\n** TODO fine\n")) (j (lo-test--judgments (plist-get out :issues)))) (should (member 'indented-heading (lo-test--checkers j))) (should (= 1 (length (lo-test--checker-lines (plist-get out :issues) 'indented-heading)))))) (ert-deftest lo-indented-heading-skips-stars-inside-blocks () "Boundary: indented stars inside a #+begin_/#+end_ block are legitimate content." (let* ((out (lo-test--run "* Open\n#+begin_example\n ** not a heading\n#+end_example\n")) (j (lo-test--judgments (plist-get out :issues)))) (should-not (member 'indented-heading (lo-test--checkers j))))) (ert-deftest lo-indented-heading-skips-single-star-list-bullets () "Normal: an indented single `*' is a valid plain-list bullet, not a demoted heading, so it is not flagged — only two-or-more indented stars are." (let* ((out (lo-test--run "* Open\nintro line\n * first bullet\n * second bullet\n * nested bullet\n")) (j (lo-test--judgments (plist-get out :issues)))) (should-not (member 'indented-heading (lo-test--checkers j))))) (ert-deftest lo-empty-heading-flags-bare-stars () "Error: a line of bare stars with no title is flagged." (let* ((out (lo-test--run "* Open\n** \n** TODO real\n")) (j (lo-test--judgments (plist-get out :issues)))) (should (member 'empty-heading (lo-test--checkers j))))) (ert-deftest lo-malformed-priority-flags-lowercase-and-skips-valid () "Error + Normal: a lowercase/oversized cookie flags; a valid [#B] stays silent." (let* ((bad (lo-test--run "* Open\n** TODO [#a] lowercase cookie\n** TODO [#BB] oversized\n")) (ok (lo-test--run "* Open\n** TODO [#B] valid cookie\n")) (jo (lo-test--judgments (plist-get ok :issues)))) (should (= 2 (length (lo-test--checker-lines (plist-get bad :issues) 'malformed-priority-cookie)))) (should-not (member 'malformed-priority-cookie (lo-test--checkers jo))))) (ert-deftest lo-malformed-priority-skips-verbatim-cookie-in-title () "Boundary: a dated-log title quoting =[#D]= verbatim is not a real cookie." (let* ((out (lo-test--run "* Open\n** TODO [#B] parent\n*** 2026-05-14 reprioritized =[#D]= -> =[#B]=\n")) (j (lo-test--judgments (plist-get out :issues)))) (should-not (member 'malformed-priority-cookie (lo-test--checkers j))))) (ert-deftest lo-done-without-closed-flags-undated-level2 () "Error: a level-2 DONE with no CLOSED line is flagged; a dated one is not." (let* ((bad (lo-test--run "* Resolved\n** DONE undated finished\nbody\n")) (jb (lo-test--judgments (plist-get bad :issues))) (ok (lo-test--run "* Resolved\n** DONE dated\nCLOSED: [2026-06-29 Mon]\n")) (jo (lo-test--judgments (plist-get ok :issues)))) (should (member 'level2-done-without-closed (lo-test--checkers jb))) (should-not (member 'level2-done-without-closed (lo-test--checkers jo))))) (ert-deftest lo-done-without-closed-ignores-deeper-levels () "Boundary: a level-3 DONE (a dated-log sub-entry) need not carry CLOSED." (let* ((out (lo-test--run "* Resolved\n** DONE parent\nCLOSED: [2026-06-29 Mon]\n*** DONE nested no-closed\n")) (j (lo-test--judgments (plist-get out :issues)))) (should-not (member 'level2-done-without-closed (lo-test--checkers j))))) (ert-deftest lo-structural-checks-silent-on-clean-file () "Normal: a well-formed file trips none of the four structural checkers." (let* ((out (lo-test--run "* Open Work\n** TODO [#A] a task :tag:\n** DOING [#B] another\n* Resolved\n** DONE [#C] done\nCLOSED: [2026-06-29 Mon]\n")) (checkers (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) (dolist (c '(indented-heading empty-heading malformed-priority-cookie level2-done-without-closed)) (should-not (member c checkers))))) (provide 'test-lint-org) ;;; test-lint-org.el ends here