From d9d8ce79da82b8c0fbbc8d6090548cd1f508b4c0 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 30 Jun 2026 13:50:22 -0400 Subject: 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. --- .ai/scripts/lint-org.el | 115 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) (limited to '.ai/scripts/lint-org.el') 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: @@ -392,6 +398,110 @@ Emits one judgment item per offending heading (checker 'level-2-dated-header (line-number-at-pos) "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 @@ -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)) -- cgit v1.2.3