diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-30 13:50:22 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-30 13:50:22 -0400 |
| commit | d9d8ce79da82b8c0fbbc8d6090548cd1f508b4c0 (patch) | |
| tree | 29e90ba96bb227fd7c4d8d0bf1c24aa863f6a331 /.ai | |
| parent | f67e72430845236ab5ed4ca00ba13afe87eda53a (diff) | |
| download | rulesets-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')
| -rw-r--r-- | .ai/scripts/lint-org.el | 115 | ||||
| -rw-r--r-- | .ai/scripts/tests/test-lint-org.el | 76 |
2 files changed, 191 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 |
