aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-14 18:45:48 -0500
committerCraig Jennings <c@cjennings.net>2026-05-14 18:45:48 -0500
commitf5b8688aed8ec698220a67c2dbfbcae22e7575f4 (patch)
treec35f56d9018aa902c7b18021ff70bbe708267b80
parent686d6471191888d05bcdd1f194a9d5dd5ee65d7a (diff)
downloadrulesets-f5b8688aed8ec698220a67c2dbfbcae22e7575f4.tar.gz
rulesets-f5b8688aed8ec698220a67c2dbfbcae22e7575f4.zip
chore(ai): sync lint-org script and wrap-it-up from claude-templates
Byte-identical pull of .ai/scripts/lint-org.el, .ai/scripts/tests/test-lint-org.el, and the new Step 3 lint section in .ai/workflows/wrap-it-up.org. Upstream: claude-templates 138f35f (feat) and 4eba98c (docs).
-rw-r--r--.ai/scripts/lint-org.el365
-rw-r--r--.ai/scripts/tests/test-lint-org.el465
-rw-r--r--.ai/workflows/wrap-it-up.org33
3 files changed, 863 insertions, 0 deletions
diff --git a/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el
new file mode 100644
index 0000000..3e643d4
--- /dev/null
+++ b/.ai/scripts/lint-org.el
@@ -0,0 +1,365 @@
+;;; lint-org.el --- org-lint sweeper for tracked org files -*- lexical-binding: t; -*-
+;;
+;; Usage:
+;; emacs --batch -q -l lint-org.el FILE.org [FILE.org ...]
+;; apply mechanical fixes in place, emit judgment items on stdout for the
+;; command layer to walk
+;;
+;; emacs --batch -q -l lint-org.el --check FILE.org [FILE.org ...]
+;; report only — categorize without modifying the file
+;;
+;; emacs --batch -q -l lint-org.el --followups-file=PATH FILE.org
+;; apply mechanical fixes; if any judgment items remain, append them to
+;; PATH as an org section dated today. Used by wrap-it-up to defer the
+;; judgment walk to the next morning's review without blocking the wrap.
+;;
+;; Mechanical categories (auto-fixed):
+;; item-number add [@N] directive to drifted bullets
+;; missing-language-in-src-block convert bare #+begin_src to #+begin_example
+;; misplaced-planning-info merge multi-line CLOSED:/DEADLINE:/SCHEDULED:
+;; misplaced-heading (markdown-bold) **X.** at start of line → *X.*
+;;
+;; Judgment categories (emitted on stdout):
+;; misplaced-heading (verbatim-*) =*** Foo= inside body prose
+;; link-to-local-file broken file: links
+;; invalid-fuzzy-link broken *Heading refs
+;; suspicious-language-in-src-block unknown source-block language
+;; (anything else) surfaced as judgment with checker name
+;;
+;; Output format on stdout:
+;; first line: ;; lint-org: file=<path> mechanical=<N>[ (would-fix)] judgment=<M>
+;; each issue: (:kind mechanical-fixed|judgment :line <N> :checker <symbol> :msg "..." [:preview t])
+;;
+;; Before modifying a file, a backup is copied to
+;; /tmp/<basename>.before-lint-pass.<YYYYMMDD-HHMMSS>
+
+(require 'org)
+(require 'org-lint)
+(require 'cl-lib)
+(require 'subr-x)
+
+(defvar lo-fixes 0
+ "Count of mechanical fixes applied (or would-apply in --check) on the last file.")
+(defvar lo-issues nil
+ "Reverse-document-order list of plists describing each issue from the last file.
+Each plist has :kind (mechanical-fixed | judgment), :line, :checker, :msg.
+Mechanical entries from --check mode also carry :preview t.")
+(defvar lo-check-only nil
+ "Non-nil means run in report-only mode — no buffer writes.")
+(defvar lo-current-file nil
+ "Path of the file currently being processed.")
+(defvar lo-followups-file nil
+ "When non-nil, after a non-check run any judgment items are appended to this
+path as an org section dated today. The file is created if missing.")
+
+(defconst lo-mechanical-checkers
+ '(item-number missing-language-in-src-block misplaced-planning-info)
+ "org-lint checker names that are always treated as mechanical.")
+
+;; misplaced-heading is split case-by-case (markdown-bold vs verbatim-asterisk)
+;; in `lo--handle-item'.
+
+;;; ---------------------------------------------------------------------------
+;;; org-lint result accessors
+
+(defun lo--checker-name (item)
+ "Return the checker symbol name for ITEM."
+ (let* ((vec (cadr item))
+ (checker (aref vec 3)))
+ (org-lint-checker-name checker)))
+
+(defun lo--line (item)
+ "Return the 1-based line number for ITEM."
+ (let* ((vec (cadr item))
+ (marker-str (aref vec 0)))
+ (string-to-number (substring-no-properties marker-str))))
+
+(defun lo--message (item)
+ "Return the human-readable message for ITEM."
+ (let ((vec (cadr item))) (aref vec 2)))
+
+;;; ---------------------------------------------------------------------------
+;;; Mechanical fixers — each runs against the current buffer, returns
+;;; non-nil on success, nil if its preconditions don't hold (already
+;;; fixed, unexpected shape, etc.).
+
+(defun lo--goto-line (line)
+ (goto-char (point-min))
+ (forward-line (1- line)))
+
+(defun lo-fix-item-number (line)
+ "Insert an [@N] counter on the bullet at LINE, derived from its leading number."
+ (save-excursion
+ (lo--goto-line line)
+ (when (looking-at "^[ \t]*\\([0-9]+\\)[.)]\\([ \t]+\\)")
+ (let ((num (match-string 1)))
+ (goto-char (match-end 0))
+ (unless (looking-at "\\[@")
+ (insert (format "[@%s] " num))
+ t)))))
+
+(defun lo-fix-missing-language (line)
+ "Convert a bare `#+begin_src` block starting at LINE to `#+begin_example`.
+Locates the matching `#+end_src` directly below and rewrites it too."
+ (save-excursion
+ (lo--goto-line line)
+ (when (looking-at "^\\([ \t]*\\)#\\+begin_src[ \t]*$")
+ (let* ((indent (match-string 1))
+ (begin-bol (line-beginning-position))
+ (begin-eol (line-end-position))
+ ;; case-fold the end keyword search to match org's tolerance
+ (end-re (format "^%s#\\+end_src[ \t]*$" (regexp-quote indent))))
+ (delete-region begin-bol begin-eol)
+ (insert (format "%s#+begin_example" indent))
+ (forward-line 1)
+ (when (re-search-forward end-re nil t)
+ (replace-match (format "%s#+end_example" indent) t t)
+ t)))))
+
+(defun lo-fix-misplaced-planning (line)
+ "Collapse all planning lines under the heading containing LINE into a single
+canonical line right after the heading, ordered CLOSED → DEADLINE → SCHEDULED.
+LINE positions the search start — the fixer then rebuilds the whole entry's
+planning block at once, so it does the right thing whether the misplaced line
+is the first, last, or middle of the run."
+ (save-excursion
+ (lo--goto-line line)
+ (when (re-search-backward "^\\*+ " nil t)
+ (let* ((heading-bol (line-beginning-position))
+ (body-start (progn (forward-line 1) (point)))
+ (entry-end (save-excursion (outline-next-heading) (point)))
+ (parts nil)
+ (ranges nil))
+ (goto-char body-start)
+ (while (re-search-forward
+ "^[ \t]*\\(CLOSED\\|DEADLINE\\|SCHEDULED\\):.*$"
+ entry-end t)
+ (let* ((line-bol (match-beginning 0))
+ (line-eol (match-end 0))
+ (content (buffer-substring-no-properties line-bol line-eol))
+ (pos 0))
+ (while (string-match
+ "\\(CLOSED\\|DEADLINE\\|SCHEDULED\\):[ \t]*\\(\\[[^]]+\\]\\|<[^>]+>\\)"
+ content pos)
+ (push (cons (match-string 1 content)
+ (match-string 2 content))
+ parts)
+ (setq pos (match-end 0)))
+ ;; Record line-bol .. line-eol+1 so the trailing newline goes too.
+ (push (cons line-bol (min (1+ line-eol) (point-max))) ranges)))
+ (when (> (length parts) 1)
+ (let* ((order '("CLOSED" "DEADLINE" "SCHEDULED"))
+ (deduped (cl-remove-duplicates (nreverse parts) :test #'equal))
+ (sorted (sort deduped
+ (lambda (a b)
+ (< (or (cl-position (car a) order :test #'string=) 99)
+ (or (cl-position (car b) order :test #'string=) 99)))))
+ (merged (mapconcat (lambda (p) (format "%s: %s" (car p) (cdr p)))
+ sorted " ")))
+ (dolist (r (sort (copy-sequence ranges)
+ (lambda (a b) (> (car a) (car b)))))
+ (delete-region (car r) (cdr r)))
+ (goto-char heading-bol)
+ (forward-line 1)
+ (insert merged "\n")
+ t))))))
+
+(defun lo--find-markdown-bold-line (reported-line)
+ "Return the actual line number containing a leading `**X**` near REPORTED-LINE.
+org-lint's marker for misplaced-heading typically points at the blank line
+following the offender, so check (REPORTED-LINE - 1) before REPORTED-LINE.
+Returns nil if no nearby line matches the markdown-bold pattern."
+ (save-excursion
+ (cl-loop for candidate in (list (1- reported-line) reported-line)
+ when (and (>= candidate 1)
+ (progn (lo--goto-line candidate)
+ (looking-at "^\\*\\*[^*\n]+\\*\\*")))
+ return candidate)))
+
+(defun lo--markdown-bold-at-line-p (line)
+ "Non-nil if LINE (or LINE - 1) looks like a markdown-bold case of
+misplaced-heading. Pattern: `**X**` at the start of the line, X a short prose
+run without asterisks."
+ (and (lo--find-markdown-bold-line line) t))
+
+(defun lo-fix-markdown-bold (line)
+ "Convert a leading `**X**` near LINE to `*X*` (org single-asterisk bold).
+Uses `lo--find-markdown-bold-line' to locate the actual offender, since
+org-lint reports the blank line after the heading-like text."
+ (let ((actual (lo--find-markdown-bold-line line)))
+ (when actual
+ (save-excursion
+ (lo--goto-line actual)
+ (when (looking-at "^\\(\\*\\*\\)\\([^*\n]+\\)\\(\\*\\*\\)")
+ (let ((start (match-beginning 0))
+ (end (match-end 0))
+ (inner (match-string 2)))
+ (delete-region start end)
+ (goto-char start)
+ (insert (format "*%s*" inner))
+ t))))))
+
+;;; ---------------------------------------------------------------------------
+;;; Per-item dispatch
+
+(defun lo--emit-judgment (name line msg)
+ (push (list :kind 'judgment :line line :checker name :msg msg)
+ lo-issues))
+
+(defun lo--apply-or-preview (name line msg fixer)
+ (cond
+ (lo-check-only
+ (cl-incf lo-fixes)
+ (push (list :kind 'mechanical-fixed :line line :checker name :msg msg
+ :preview t)
+ lo-issues))
+ ((funcall fixer line)
+ (cl-incf lo-fixes)
+ (push (list :kind 'mechanical-fixed :line line :checker name :msg msg)
+ lo-issues))
+ (t
+ ;; Fixer declined — emit as judgment so nothing is silently swallowed.
+ (lo--emit-judgment name line msg))))
+
+(defun lo--handle-item (item)
+ (let ((name (lo--checker-name item))
+ (line (lo--line item))
+ (msg (lo--message item)))
+ (cond
+ ((eq name 'item-number)
+ (lo--apply-or-preview name line msg #'lo-fix-item-number))
+ ((eq name 'missing-language-in-src-block)
+ (lo--apply-or-preview name line msg #'lo-fix-missing-language))
+ ((eq name 'misplaced-planning-info)
+ (lo--apply-or-preview name line msg #'lo-fix-misplaced-planning))
+ ((eq name 'misplaced-heading)
+ (if (lo--markdown-bold-at-line-p line)
+ (lo--apply-or-preview name line msg #'lo-fix-markdown-bold)
+ (lo--emit-judgment name line msg)))
+ (t
+ (lo--emit-judgment name line msg)))))
+
+;;; ---------------------------------------------------------------------------
+;;; File processing
+
+(defun lo--backup (file)
+ "Copy FILE to /tmp before any modification. Skipped in --check mode."
+ (let ((backup (format "/tmp/%s.before-lint-pass.%s"
+ (file-name-nondirectory file)
+ (format-time-string "%Y%m%d-%H%M%S"))))
+ (copy-file file backup t)
+ backup))
+
+(defun lo-process-file (file)
+ "Run org-lint against FILE, apply mechanical fixes, collect judgment items.
+Resets `lo-fixes' and `lo-issues' for each call. In --check mode the file is
+left unmodified and mechanical entries are recorded with :preview t."
+ (setq lo-current-file file lo-fixes 0 lo-issues nil)
+ (unless lo-check-only
+ (lo--backup file))
+ (let ((buf (find-file-noselect file)))
+ (unwind-protect
+ (with-current-buffer buf
+ (revert-buffer t t t)
+ (let* ((report (org-lint))
+ ;; Descending line order: applying a fix that adds/removes
+ ;; lines doesn't perturb the line numbers of items at smaller
+ ;; line numbers that haven't been processed yet.
+ (sorted (sort (copy-sequence report)
+ (lambda (a b) (> (lo--line a) (lo--line b))))))
+ (dolist (item sorted)
+ (lo--handle-item item)))
+ (when (and (not lo-check-only) (buffer-modified-p))
+ (save-buffer)))
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (kill-buffer buf))))
+
+;;; ---------------------------------------------------------------------------
+;;; Reporting
+
+(defun lo--append-followups ()
+ "Append any judgment items from the current run to `lo-followups-file' as a
+dated org section. No-op when the file path is unset or there are no
+judgment items."
+ (when lo-followups-file
+ (let ((judgments (cl-remove-if-not
+ (lambda (i) (eq (plist-get i :kind) 'judgment))
+ (reverse lo-issues))))
+ (when judgments
+ (let ((dir (file-name-directory (expand-file-name lo-followups-file))))
+ (when dir (make-directory dir t)))
+ (with-temp-buffer
+ (insert (format "\n* %s lint-org follow-ups — %s\n"
+ (format-time-string "%Y-%m-%d")
+ (file-name-nondirectory lo-current-file)))
+ (dolist (i judgments)
+ (insert (format "** TODO line %d — %s — %s\n"
+ (plist-get i :line)
+ (plist-get i :checker)
+ (plist-get i :msg))))
+ (append-to-file (point-min) (point-max) lo-followups-file))))))
+
+(defun lo-emit-report ()
+ "Print the per-file summary line plus each issue as a readable plist.
+After printing, also append judgments to `lo-followups-file' when set."
+ (let ((mech (cl-count-if (lambda (i) (eq (plist-get i :kind) 'mechanical-fixed))
+ lo-issues))
+ (judg (cl-count-if (lambda (i) (eq (plist-get i :kind) 'judgment))
+ lo-issues)))
+ (princ (format ";; lint-org: file=%s mechanical=%d%s judgment=%d%s\n"
+ lo-current-file mech
+ (if lo-check-only " (would-fix)" "")
+ judg
+ (if (and lo-followups-file (> judg 0))
+ (format " followups=%s" lo-followups-file)
+ "")))
+ (dolist (i (reverse lo-issues))
+ (princ (format "%S\n" i)))
+ (unless lo-check-only
+ (lo--append-followups))))
+
+;;; ---------------------------------------------------------------------------
+;;; CLI
+
+(defun lo-main ()
+ (when (member "--check" command-line-args-left)
+ (setq lo-check-only t)
+ (setq command-line-args-left (delete "--check" command-line-args-left)))
+ (let ((followups (cl-find-if
+ (lambda (a) (string-prefix-p "--followups-file=" a))
+ command-line-args-left)))
+ (when followups
+ (setq lo-followups-file (substring followups (length "--followups-file=")))
+ (setq command-line-args-left (delete followups command-line-args-left))))
+ (if (null command-line-args-left)
+ (progn
+ (princ "Usage: emacs --batch -q -l lint-org.el [--check] [--followups-file=PATH] FILE.org ...\n")
+ (kill-emacs 1))
+ (let ((files command-line-args-left))
+ (setq command-line-args-left nil)
+ (dolist (file files)
+ (if (file-readable-p file)
+ (progn
+ (lo-process-file file)
+ (lo-emit-report))
+ (princ (format ";; lint-org: file=%s not readable — skipping\n"
+ file)))))))
+
+(defun lo--cli-invocation-p ()
+ "Non-nil when the trailing command-line arguments look like a real invocation:
+only recognized flags and/or readable file paths. Lets the ERT suite `require'
+this file without firing the CLI dispatch — under `ert-run-tests-batch-and-exit'
+the trailing args are things like `-f ert-run-tests-batch-and-exit'."
+ (and command-line-args-left
+ (cl-every (lambda (a)
+ (cond ((member a '("--check")) t)
+ ((string-prefix-p "--followups-file=" a) t)
+ ((string-prefix-p "-" a) nil)
+ (t (file-readable-p a))))
+ command-line-args-left)))
+
+(when (and noninteractive (lo--cli-invocation-p))
+ (lo-main))
+
+(provide 'lint-org)
+;;; lint-org.el ends here
diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el
new file mode 100644
index 0000000..8e1ebc4
--- /dev/null
+++ b/.ai/scripts/tests/test-lint-org.el
@@ -0,0 +1,465 @@
+;;; 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
+")
+
+;; 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-judgment ()
+ (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)))
+ ;; Emitted as judgment with the misplaced-heading checker.
+ (should (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)))))
+
+;;; ---------------------------------------------------------------------------
+;;; --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, suspicious-language, verbatim-asterisk
+ ;; 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))
+ (should (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-file-appends-judgments ()
+ (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))))
+ ;; Dated section header.
+ (should (string-match-p
+ (format "^\\* %s lint-org follow-ups"
+ (format-time-string "%Y-%m-%d"))
+ content))
+ ;; Each judgment is a TODO line referencing checker + line number.
+ (should (string-match-p "TODO line [0-9]+ — link-to-local-file" content))
+ (should (string-match-p "TODO line [0-9]+ — invalid-fuzzy-link" content))
+ (should (string-match-p
+ "TODO line [0-9]+ — suspicious-language-in-src-block"
+ 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-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/<basename>.before-lint-pass.<timestamp>
+ (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)))))
+
+(provide 'test-lint-org)
+;;; test-lint-org.el ends here
diff --git a/.ai/workflows/wrap-it-up.org b/.ai/workflows/wrap-it-up.org
index 03ee520..f6c13aa 100644
--- a/.ai/workflows/wrap-it-up.org
+++ b/.ai/workflows/wrap-it-up.org
@@ -131,6 +131,38 @@ emacs --batch -q -l .ai/scripts/todo-cleanup.el --check-child-priority todo.org
(=--check-child-priority= is the report-only alias for =--sync-child-priority --check=.)
+*** Lint org files (mechanical sweep, judgments deferred)
+
+#+begin_src bash
+followups="${LINT_ORG_FOLLOWUPS:-$HOME/projects/work/inbox/lint-followups.org}"
+[ ! -d "$(dirname "$followups")" ] && followups=".ai/lint-followups.org"
+[ -f todo.org ] && emacs --batch -q -l .ai/scripts/lint-org.el \
+ --followups-file="$followups" todo.org
+#+end_src
+
+=lint-org= runs =org-lint= over =todo.org=, auto-applies four mechanical
+categories (=item-number= counters, bare =#+begin_src= → =#+begin_example=,
+multi-line planning-info merged onto one line, =**X.**= → =*X.*=), and
+appends every remaining judgment item (broken file links, invalid fuzzy
+links, verbatim-asterisk inside body prose, suspicious src-block languages)
+to the follow-ups file as a dated org section. Mechanical fixes show up in
+the wrap-up commit's diff for review before push.
+
+The follow-up path defaults to =~/projects/work/inbox/lint-followups.org=
+(where the next morning's daily-prep merges it in). If that directory
+doesn't exist on the machine, the script falls back to
+=.ai/lint-followups.org= inside the current project. Override with
+=LINT_ORG_FOLLOWUPS=<path>= in the environment if needed.
+
+Preview without writing — same flags as =--check= on the other scripts:
+
+#+begin_src bash
+[ -f todo.org ] && emacs --batch -q -l .ai/scripts/lint-org.el --check todo.org
+#+end_src
+
+The wrap-up never blocks on judgment items — they're deferred by design.
+For an interactive walk of the judgments mid-day, run =/lint-org todo.org=.
+
** Step 3.5: Linear ticket-state hygiene (skip if project doesn't use Linear)
If the project uses Linear and has any tickets currently in *Dev Review* assigned to Craig, sweep them before the wrap-up commit. The check is fast and keeps the board honest — tickets stuck in Dev Review after their PR merges hide actual work-in-progress.
@@ -313,6 +345,7 @@ Before considering wrap-up complete:
- [ ] File renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=
- [ ] =.ai/session-context.org= no longer exists
- [ ] =todo-cleanup.el= ran — hygiene pass + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root)
+- [ ] =lint-org.el= ran on =todo.org= — mechanical fixes applied, judgments appended to follow-ups file (if =todo.org= exists)
- [ ] Any orphan-planning-line warnings reviewed (fix or accept)
- [ ] Linear Dev-Review sweep ran; any merged-PR tickets moved to Done or PM Acceptance (skip if project doesn't use Linear)
- [ ] After wrap-up commit + push, =git status --short= is empty OR every remaining line has an explicit user-deferred decision logged in the valediction