aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-30 13:50:22 -0400
committerCraig Jennings <c@cjennings.net>2026-06-30 13:50:22 -0400
commitd9d8ce79da82b8c0fbbc8d6090548cd1f508b4c0 (patch)
tree29e90ba96bb227fd7c4d8d0bf1c24aa863f6a331
parentf67e72430845236ab5ed4ca00ba13afe87eda53a (diff)
downloadrulesets-d9d8ce79da82b8c0fbbc8d6090548cd1f508b4c0.tar.gz
rulesets-d9d8ce79da82b8c0fbbc8d6090548cd1f508b4c0.zip
feat(lint-org): add four structural heading checkers org-lint misses
org-lint validates links, drawers, blocks, and babel, but not heading well-formedness. These four catch hand-edit defects it stays silent on: an indented heading demoted to body text (the task vanishes from the agenda and never archives), bare stars with no title, a malformed priority cookie org rejected, and a level-2 DONE/CANCELLED with no CLOSED line. All judgment-only and regex-based, wired in after the existing dated-header check. The last one pairs with the new aging step, which archives an undated completed task immediately. I tightened the indented-heading check to two-or-more stars. The proposed one-or-more-stars regex flagged indented single-star lines, but an indented single * is a valid plain-list bullet, not a lost heading, so it false-positived on legitimate lists (confirmed: three valid bullets flagged). A ** is never a bullet, so an indented one is unambiguously a demoted heading. Added a test that a single-star list stays silent.
-rw-r--r--.ai/scripts/lint-org.el115
-rw-r--r--.ai/scripts/tests/test-lint-org.el76
-rw-r--r--claude-templates/.ai/scripts/lint-org.el115
-rw-r--r--claude-templates/.ai/scripts/tests/test-lint-org.el76
-rw-r--r--docs/design/2026-06-29-lint-org-structural-checkers-proposal.org55
5 files changed, 437 insertions, 0 deletions
diff --git a/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el
index 3633dba..5447cb3 100644
--- a/.ai/scripts/lint-org.el
+++ b/.ai/scripts/lint-org.el
@@ -29,6 +29,12 @@
;; link-to-local-file broken file: links
;; invalid-fuzzy-link broken *Heading refs
;; suspicious-language-in-src-block unknown source-block language
+;; org-table-standard table wider than budget / missing rules
+;; level-2-dated-header ** dated header instead of a keyword
+;; indented-heading whitespace before stars (demoted to body)
+;; empty-heading bare stars with no title
+;; malformed-priority-cookie [#x]-shaped token org rejected
+;; level2-done-without-closed completed level-2 task with no CLOSED
;; (anything else) surfaced as judgment with checker name
;;
;; Output format on stdout:
@@ -393,6 +399,110 @@ Emits one judgment item per offending heading (checker
"level-2 dated header is a completion defect (todo-format.md): a ** task or VERIFY closes with DONE/CANCELLED + CLOSED:, not a dated heading — convert it so --archive-done can archive it"))))
;;; ---------------------------------------------------------------------------
+;;; structural heading checks (mistakes org-lint does not cover)
+;;
+;; org-lint validates links, drawers, blocks, and babel — but not heading
+;; well-formedness. These four catch hand-edit defects it misses, all
+;; judgment-only (each repair is a human call) and regex-based (no dependence on
+;; which TODO keywords the batch Emacs happens to recognize):
+;;
+;; indented-heading leading whitespace before two-or-more stars; org
+;; demotes it to body text, so the task vanishes from
+;; the agenda and never archives. The worst case — an
+;; invisible task — and silent. Single `*' is left
+;; alone (a valid indented plain-list bullet).
+;; empty-heading a line of bare stars with no title.
+;; malformed-priority-cookie a `[#x]'-shaped token org rejected (lowercase,
+;; multi-char, non-letter) sitting where a cookie
+;; would be.
+;; level2-done-without-closed a level-2 DONE/CANCELLED with no CLOSED line —
+;; directly relevant to todo-cleanup's aging step,
+;; which archives an undated completed task at once.
+
+(defconst lo-done-keywords '("DONE" "CANCELLED")
+ "Heading keywords treated as completed for `lo--check-level2-done-without-closed'.")
+
+(defun lo--check-indented-headings ()
+ "Flag lines that are whitespace + two-or-more stars + space outside any block.
+Org parses a heading only at column 0, so leading whitespace silently demotes a
+would-be heading to body text. Two-or-more stars is required: an indented
+single `*' is a valid plain-list bullet, not a lost heading, so flagging it
+false-positives on legitimate lists; `**'+ is never a bullet, so an indented one
+is unambiguously a demoted level-2+ heading turned invisible. Lines inside
+`#+begin_/#+end_' blocks are skipped — indented asterisks there are legitimate
+content."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((in-block nil))
+ (while (not (eobp))
+ (cond
+ ((looking-at-p "^[ \t]*#\\+begin_") (setq in-block t))
+ ((looking-at-p "^[ \t]*#\\+end_") (setq in-block nil))
+ ((and (not in-block) (looking-at-p "^[ \t]+\\*\\*+[ \t]"))
+ (lo--emit-judgment
+ 'indented-heading (line-number-at-pos)
+ "indented heading: leading whitespace before the stars demotes this to body text — org won't treat it as a heading (it vanishes from the agenda and never archives); dedent to column 0")))
+ (forward-line 1)))))
+
+(defun lo--check-empty-headings ()
+ "Flag headings that are bare stars with no title text.
+A line of nothing but stars is an empty heading — a stray heading-star carrying
+no content."
+ (save-excursion
+ (goto-char (point-min))
+ (while (re-search-forward "^\\*+[ \t]*$" nil t)
+ (lo--emit-judgment
+ 'empty-heading (line-number-at-pos)
+ "empty heading: a line of stars with no title — delete it or give it a title"))))
+
+(defun lo--check-malformed-priority-cookies ()
+ "Flag a heading whose first cookie-shaped token is not a valid priority.
+A valid cookie is a single uppercase letter in `[#A]' form. Verbatim-wrapped
+cookies (`=[#D]=' quoted in a dated-log title) are skipped. Only the first
+token on the line is checked, so a real cookie earlier on the line means a
+later `[#x]' in the title is left alone."
+ (save-excursion
+ (goto-char (point-min))
+ ;; Case-sensitive: a cookie is uppercase only, and case-fold-search defaults
+ ;; to t (which would accept [#a] as valid).
+ (let ((case-fold-search nil))
+ (while (re-search-forward "^\\*+ " nil t)
+ (let ((eol (line-end-position)) (hline (line-number-at-pos)))
+ (when (re-search-forward "\\[#\\([^]]*\\)\\]" eol t)
+ (let ((inner (match-string 1))
+ (before (char-before (match-beginning 0)))
+ (after (char-after (match-end 0))))
+ (unless (or (eq before ?=) (eq after ?=)
+ (string-match-p "\\`[A-Z]\\'" inner))
+ (lo--emit-judgment
+ 'malformed-priority-cookie hline
+ (format "malformed priority cookie [#%s] — a cookie is a single uppercase letter ([#A]) right after the keyword; fix or remove it"
+ inner)))))
+ (goto-char eol))))))
+
+(defun lo--check-level2-done-without-closed ()
+ "Flag a level-2 DONE/CANCELLED heading with no CLOSED line in its own entry.
+todo-cleanup's `--archive-done' aging step archives a completed task with no
+parseable CLOSED date immediately, so an undated completed task silently leaves
+the live file on the next `task-sorted'."
+ (save-excursion
+ (goto-char (point-min))
+ ;; Case-sensitive: DONE/CANCELLED are uppercase keywords, not the words
+ ;; "done"/"cancelled" in a heading title (case-fold-search defaults to t).
+ (let ((case-fold-search nil)
+ (re (format "^\\*\\* \\(%s\\) "
+ (mapconcat #'regexp-quote lo-done-keywords "\\|"))))
+ (while (re-search-forward re nil t)
+ (let ((hline (line-number-at-pos))
+ (entry-end (save-excursion (outline-next-heading) (point))))
+ (save-excursion
+ (forward-line 1)
+ (unless (re-search-forward "^[ \t]*CLOSED:[ \t]*\\[" entry-end t)
+ (lo--emit-judgment
+ 'level2-done-without-closed hline
+ "level-2 DONE/CANCELLED has no CLOSED date — add CLOSED: [YYYY-MM-DD Day]; task-sorted's aging step archives an undated completed task immediately"))))))))
+
+;;; ---------------------------------------------------------------------------
;;; File processing
(defun lo--backup (file)
@@ -428,6 +538,11 @@ left unmodified and mechanical entries are recorded with :preview t."
(lo--check-tables)
;; Same shape: flag level-2 dated headers (completion defects).
(lo--check-level2-dated-headers)
+ ;; Structural heading defects org-lint doesn't cover.
+ (lo--check-indented-headings)
+ (lo--check-empty-headings)
+ (lo--check-malformed-priority-cookies)
+ (lo--check-level2-done-without-closed)
(when (and (not lo-check-only) (buffer-modified-p))
(save-buffer)))
(with-current-buffer buf (set-buffer-modified-p nil))
diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el
index 242c35c..3b8a9bb 100644
--- a/.ai/scripts/tests/test-lint-org.el
+++ b/.ai/scripts/tests/test-lint-org.el
@@ -685,5 +685,81 @@ missing-rules violation."
(judgments (lo-test--judgments (plist-get out :issues))))
(should-not (member 'level-2-dated-header (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
diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el
index 3633dba..5447cb3 100644
--- a/claude-templates/.ai/scripts/lint-org.el
+++ b/claude-templates/.ai/scripts/lint-org.el
@@ -29,6 +29,12 @@
;; link-to-local-file broken file: links
;; invalid-fuzzy-link broken *Heading refs
;; suspicious-language-in-src-block unknown source-block language
+;; org-table-standard table wider than budget / missing rules
+;; level-2-dated-header ** dated header instead of a keyword
+;; indented-heading whitespace before stars (demoted to body)
+;; empty-heading bare stars with no title
+;; malformed-priority-cookie [#x]-shaped token org rejected
+;; level2-done-without-closed completed level-2 task with no CLOSED
;; (anything else) surfaced as judgment with checker name
;;
;; Output format on stdout:
@@ -393,6 +399,110 @@ Emits one judgment item per offending heading (checker
"level-2 dated header is a completion defect (todo-format.md): a ** task or VERIFY closes with DONE/CANCELLED + CLOSED:, not a dated heading — convert it so --archive-done can archive it"))))
;;; ---------------------------------------------------------------------------
+;;; structural heading checks (mistakes org-lint does not cover)
+;;
+;; org-lint validates links, drawers, blocks, and babel — but not heading
+;; well-formedness. These four catch hand-edit defects it misses, all
+;; judgment-only (each repair is a human call) and regex-based (no dependence on
+;; which TODO keywords the batch Emacs happens to recognize):
+;;
+;; indented-heading leading whitespace before two-or-more stars; org
+;; demotes it to body text, so the task vanishes from
+;; the agenda and never archives. The worst case — an
+;; invisible task — and silent. Single `*' is left
+;; alone (a valid indented plain-list bullet).
+;; empty-heading a line of bare stars with no title.
+;; malformed-priority-cookie a `[#x]'-shaped token org rejected (lowercase,
+;; multi-char, non-letter) sitting where a cookie
+;; would be.
+;; level2-done-without-closed a level-2 DONE/CANCELLED with no CLOSED line —
+;; directly relevant to todo-cleanup's aging step,
+;; which archives an undated completed task at once.
+
+(defconst lo-done-keywords '("DONE" "CANCELLED")
+ "Heading keywords treated as completed for `lo--check-level2-done-without-closed'.")
+
+(defun lo--check-indented-headings ()
+ "Flag lines that are whitespace + two-or-more stars + space outside any block.
+Org parses a heading only at column 0, so leading whitespace silently demotes a
+would-be heading to body text. Two-or-more stars is required: an indented
+single `*' is a valid plain-list bullet, not a lost heading, so flagging it
+false-positives on legitimate lists; `**'+ is never a bullet, so an indented one
+is unambiguously a demoted level-2+ heading turned invisible. Lines inside
+`#+begin_/#+end_' blocks are skipped — indented asterisks there are legitimate
+content."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((in-block nil))
+ (while (not (eobp))
+ (cond
+ ((looking-at-p "^[ \t]*#\\+begin_") (setq in-block t))
+ ((looking-at-p "^[ \t]*#\\+end_") (setq in-block nil))
+ ((and (not in-block) (looking-at-p "^[ \t]+\\*\\*+[ \t]"))
+ (lo--emit-judgment
+ 'indented-heading (line-number-at-pos)
+ "indented heading: leading whitespace before the stars demotes this to body text — org won't treat it as a heading (it vanishes from the agenda and never archives); dedent to column 0")))
+ (forward-line 1)))))
+
+(defun lo--check-empty-headings ()
+ "Flag headings that are bare stars with no title text.
+A line of nothing but stars is an empty heading — a stray heading-star carrying
+no content."
+ (save-excursion
+ (goto-char (point-min))
+ (while (re-search-forward "^\\*+[ \t]*$" nil t)
+ (lo--emit-judgment
+ 'empty-heading (line-number-at-pos)
+ "empty heading: a line of stars with no title — delete it or give it a title"))))
+
+(defun lo--check-malformed-priority-cookies ()
+ "Flag a heading whose first cookie-shaped token is not a valid priority.
+A valid cookie is a single uppercase letter in `[#A]' form. Verbatim-wrapped
+cookies (`=[#D]=' quoted in a dated-log title) are skipped. Only the first
+token on the line is checked, so a real cookie earlier on the line means a
+later `[#x]' in the title is left alone."
+ (save-excursion
+ (goto-char (point-min))
+ ;; Case-sensitive: a cookie is uppercase only, and case-fold-search defaults
+ ;; to t (which would accept [#a] as valid).
+ (let ((case-fold-search nil))
+ (while (re-search-forward "^\\*+ " nil t)
+ (let ((eol (line-end-position)) (hline (line-number-at-pos)))
+ (when (re-search-forward "\\[#\\([^]]*\\)\\]" eol t)
+ (let ((inner (match-string 1))
+ (before (char-before (match-beginning 0)))
+ (after (char-after (match-end 0))))
+ (unless (or (eq before ?=) (eq after ?=)
+ (string-match-p "\\`[A-Z]\\'" inner))
+ (lo--emit-judgment
+ 'malformed-priority-cookie hline
+ (format "malformed priority cookie [#%s] — a cookie is a single uppercase letter ([#A]) right after the keyword; fix or remove it"
+ inner)))))
+ (goto-char eol))))))
+
+(defun lo--check-level2-done-without-closed ()
+ "Flag a level-2 DONE/CANCELLED heading with no CLOSED line in its own entry.
+todo-cleanup's `--archive-done' aging step archives a completed task with no
+parseable CLOSED date immediately, so an undated completed task silently leaves
+the live file on the next `task-sorted'."
+ (save-excursion
+ (goto-char (point-min))
+ ;; Case-sensitive: DONE/CANCELLED are uppercase keywords, not the words
+ ;; "done"/"cancelled" in a heading title (case-fold-search defaults to t).
+ (let ((case-fold-search nil)
+ (re (format "^\\*\\* \\(%s\\) "
+ (mapconcat #'regexp-quote lo-done-keywords "\\|"))))
+ (while (re-search-forward re nil t)
+ (let ((hline (line-number-at-pos))
+ (entry-end (save-excursion (outline-next-heading) (point))))
+ (save-excursion
+ (forward-line 1)
+ (unless (re-search-forward "^[ \t]*CLOSED:[ \t]*\\[" entry-end t)
+ (lo--emit-judgment
+ 'level2-done-without-closed hline
+ "level-2 DONE/CANCELLED has no CLOSED date — add CLOSED: [YYYY-MM-DD Day]; task-sorted's aging step archives an undated completed task immediately"))))))))
+
+;;; ---------------------------------------------------------------------------
;;; File processing
(defun lo--backup (file)
@@ -428,6 +538,11 @@ left unmodified and mechanical entries are recorded with :preview t."
(lo--check-tables)
;; Same shape: flag level-2 dated headers (completion defects).
(lo--check-level2-dated-headers)
+ ;; Structural heading defects org-lint doesn't cover.
+ (lo--check-indented-headings)
+ (lo--check-empty-headings)
+ (lo--check-malformed-priority-cookies)
+ (lo--check-level2-done-without-closed)
(when (and (not lo-check-only) (buffer-modified-p))
(save-buffer)))
(with-current-buffer buf (set-buffer-modified-p nil))
diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el
index 242c35c..3b8a9bb 100644
--- a/claude-templates/.ai/scripts/tests/test-lint-org.el
+++ b/claude-templates/.ai/scripts/tests/test-lint-org.el
@@ -685,5 +685,81 @@ missing-rules violation."
(judgments (lo-test--judgments (plist-get out :issues))))
(should-not (member 'level-2-dated-header (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
diff --git a/docs/design/2026-06-29-lint-org-structural-checkers-proposal.org b/docs/design/2026-06-29-lint-org-structural-checkers-proposal.org
new file mode 100644
index 0000000..c464aca
--- /dev/null
+++ b/docs/design/2026-06-29-lint-org-structural-checkers-proposal.org
@@ -0,0 +1,55 @@
+#+TITLE: lint-org.el — four structural heading checkers org-lint doesn't cover
+
+* What changed (from .emacs.d, 2026-06-29)
+
+Added four custom judgment checkers to =lint-org.el=, following the existing
+=lo--check-tables= / =lo--check-level2-dated-headers= pattern (custom scans run
+after the org-lint pass, emitting judgment items, never auto-fixed):
+
+- =indented-heading= — a line of whitespace + stars + space OUTSIDE any block.
+ org parses a heading only at column 0, so leading whitespace silently demotes
+ a would-be heading to body text: the task vanishes from the agenda and never
+ archives. The worst defect class (an invisible task) and entirely silent
+ today. Skips indented stars inside =#+begin_/#+end_= blocks (legit content).
+- =empty-heading= — a line of bare stars with no title.
+- =malformed-priority-cookie= — a =[#x]=-shaped token org rejected (lowercase,
+ multi-char, non-letter) left stranded where a cookie would be. Checks only the
+ first cookie token per heading; skips verbatim-wrapped =[#D]= in dated-log
+ titles.
+- =level2-done-without-closed= — a level-2 DONE/CANCELLED with no CLOSED line.
+ Directly supports the todo-cleanup aging step (sent separately today): an
+ undated completed task gets force-archived immediately, so flagging it lets
+ the human add CLOSED first.
+
+Two attached files (edited canonical candidates): =lint-org.el=,
+=tests/test-lint-org.el=.
+
+* Why
+
+org-lint validates links, drawers, blocks, and babel — but NOT heading
+well-formedness. On Craig's .emacs.d todo.org a missing org-bullet in the live
+buffer prompted the question "is the file structurally okay?", and org-lint
+(even unfiltered, all checkers) reported nothing actionable. These four close
+the gap. They are general (any org file), not project-specific.
+
+* Design notes for the canonical
+
+- All four are regex-based, NOT org-element/keyword-based, so they don't depend
+ on which TODO keywords the batch Emacs happens to recognize (lint-org.el does
+ not set =org-todo-keywords=). The =level2-done-without-closed= done set is a
+ defconst =lo-done-keywords= (DONE/CANCELLED) for easy extension.
+- *Gotcha worth carrying in the canonical:* =case-fold-search= defaults to t, so
+ a naive =[A-Z]= cookie check accepts =[#a]= as valid and =\(DONE\|CANCELLED\)=
+ matches the title words "done"/"cancelled". Both letter-sensitive checkers
+ bind =case-fold-search nil=. (Caught by a failing test before it shipped.)
+- Wired into =lo-process-file= after =lo--check-level2-dated-headers=. Judgment
+ output already flows through the existing report + followups-file machinery.
+- 8 new ERT tests (good-input-silent + bad-input-flagged for each, plus
+ block-skip and verbatim-skip boundary cases). 44/44 green. Zero false
+ positives on a real 5600-line todo.org.
+
+* Note
+
+=make task-sorted= in .emacs.d now runs =lint-org.el todo.org= after the
+archive, so these checkers also gate the task-hygiene target. Makefiles aren't
+template-synced; that wiring is project-local (noted for context).