aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/lint-org.el
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 /.ai/scripts/lint-org.el
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.
Diffstat (limited to '.ai/scripts/lint-org.el')
-rw-r--r--.ai/scripts/lint-org.el115
1 files changed, 115 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))