diff options
Diffstat (limited to '.ai/scripts')
| -rw-r--r-- | .ai/scripts/lint-org.el | 365 | ||||
| -rw-r--r-- | .ai/scripts/tests/test-lint-org.el | 465 |
2 files changed, 830 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 |
