aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/.ai/scripts/lint-org.el
blob: 8f55cc6a8b0e3cdb05ce6cd484e813b24216641a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
;;; 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.*
;;
;; Suppressed (false positives — neither fixed nor surfaced):
;;   misplaced-heading (verbatim-*)    =*** Foo= inside body prose — verbatim
;;                                     asterisks are never a real misplaced
;;                                     heading, so the item is dropped silently
;;   cj-comment src-block false flags  see `lo--cj-comment-block-opener-p'
;;
;; Judgment categories (emitted on stdout):
;;   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)

;; wrap-org-table.el lives beside this file. Consumers load lint-org with a
;; bare `-l path/lint-org.el` (wrap-it-up, /lint-org) — no -L on the load
;; path — so resolve the sibling from this file's own directory.
(eval-and-compile
  (add-to-list 'load-path
               (file-name-directory (or load-file-name buffer-file-name
                                        default-directory))))
(require 'wrap-org-table)  ; render-width + table parsing for the table check

(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.")

(defvar lo-runtime-link-types '("mu4e")
  "Org link types registered by packages at runtime in a live Emacs.
In batch, org doesn't know these types, so org-lint parses
=[[mu4e:msgid:...]]= as a fuzzy heading ref and reports a false
invalid-fuzzy-link (\"Unknown fuzzy location\").  Each type listed here is
registered as a no-op via `org-link-set-parameters' before linting, so the
link parses as a link.  Add a type when a live-Emacs-only link package's
links start false-flagging in batch runs.")

(defun lo--register-runtime-link-types ()
  "Register each of `lo-runtime-link-types' so batch org-lint parses them.
Idempotent: re-registering an already-known type is harmless, and a type
the running Emacs genuinely provides (e.g. linting interactively with mu4e
loaded) keeps its real parameters because `org-link-set-parameters' merges
rather than replaces."
  (dolist (type lo-runtime-link-types)
    (org-link-set-parameters type)))

;; misplaced-heading is split case-by-case in `lo--handle-item': markdown-bold
;; is auto-fixed, verbatim-asterisk is suppressed as a false positive, anything
;; else is surfaced as judgment.

;;; ---------------------------------------------------------------------------
;;; 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--verbatim-asterisk-at-line-p (line)
  "Non-nil if LINE (or LINE - 1) carries an =...=-wrapped run of heading
asterisks inside body prose, e.g. =** DONE= or =*** Foo=.  org-lint reads the
verbatim asterisks as a possible heading and flags the line, but verbatim
markup is never a real misplaced heading.  Like `lo--find-markdown-bold-line',
this checks LINE - 1 too, since org-lint often marks the blank line after the
offender.  The match is anywhere on the line (the span sits mid-sentence)."
  (save-excursion
    (cl-loop for candidate in (list (1- line) line)
             when (and (>= candidate 1)
                       (progn (lo--goto-line candidate)
                              (re-search-forward "=\\*+ [^=\n]*="
                                                 (line-end-position) t)))
             return candidate)))

(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--cj-comment-block-opener-p (line)
  "Non-nil when LINE in the current buffer is a `#+begin_src cj: ...' opener.
The cj-comment annotation convention puts `cj:' as the src-block language and
`comment' as the apparent header arg.  org-lint reads that shape three ways
(unknown language, empty header-arg value, missing colon in header arg) and
flags each — all three are false positives, since cj-comment is a
Craig-specific annotation marker rather than Babel src-block syntax."
  (save-excursion
    (lo--goto-line line)
    (looking-at-p "^[ \t]*#\\+begin_src[ \t]+cj:")))

(defun lo--handle-item (item)
  (let ((name (lo--checker-name item))
        (line (lo--line item))
        (msg (lo--message item)))
    (cond
     ;; Silent suppression of cj-comment false positives — see
     ;; `lo--cj-comment-block-opener-p'.  No fix counted, no judgment emitted.
     ((and (memq name '(suspicious-language-in-src-block
                        empty-header-argument
                        wrong-header-argument))
           (lo--cj-comment-block-opener-p line))
      nil)
     ((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)
      (cond
       ((lo--markdown-bold-at-line-p line)
        (lo--apply-or-preview name line msg #'lo-fix-markdown-bold))
       ;; Verbatim =** Foo= inside prose is never a real misplaced heading;
       ;; suppress silently like the cj-comment case — no fix, no judgment.
       ((lo--verbatim-asterisk-at-line-p line)
        nil)
       (t
        (lo--emit-judgment name line msg))))
     (t
      (lo--emit-judgment name line msg)))))

;;; ---------------------------------------------------------------------------
;;; org-table-standard check (claude-rules/org-tables.md)
;;
;; Not an org-lint checker — a custom scan run alongside the org-lint pass.
;; Violations surface as judgment items (checker `org-table-standard'), never
;; auto-fixed: reflowing a table is a visible layout change that
;; wrap-org-table.el performs on request, not something a lint sweep does
;; silently.

(defun lo--table-violations (lines)
  "Standard violations for the table given as LINES, as message strings.
Width is render-measured (links count as their labels, per wot-render-width).
Rules: an hline must follow the header and every logical data row, closing
rule included; continuation lines inside a rule-delimited group are one
logical row, matching wrap-org-table.el's grouping."
  (let ((violations nil)
        (max-width (apply #'max (mapcar #'wot-render-width lines))))
    (when (> max-width wot-default-budget)
      (push (format "renders %d wide (budget %d)" max-width wot-default-budget)
            violations))
    (let* ((parsed (mapcar #'wot--parse-row lines))
           (header-p (and (listp (car parsed)) (eq (cadr parsed) 'hline)))
           (data (if header-p (cddr parsed) parsed)))
      (when (and (cl-some #'listp data)
                 (not (eq (car (last data)) 'hline)))
        (push "no closing rule" violations))
      (let ((group nil))
        (cl-loop for e in data
                 if (eq e 'hline) do (setq group nil)
                 else do (push e group)
                 when (and (> (length group) 1)
                           (not (wot--continuation-group-p (reverse group))))
                 return (push "missing rule between rows" violations))))
    (nreverse violations)))

(defun lo--check-tables ()
  "Scan the current buffer for org tables violating the table standard.
Emits one judgment item per violating table."
  (save-excursion
    (goto-char (point-min))
    (while (re-search-forward "^[ \t]*|" nil t)
      (let ((start-line (line-number-at-pos))
            (lines nil))
        (beginning-of-line)
        (while (and (not (eobp)) (looking-at "[ \t]*|"))
          (push (buffer-substring-no-properties (line-beginning-position)
                                                (line-end-position))
                lines)
          (forward-line 1))
        (let ((violations (lo--table-violations (nreverse lines))))
          (when violations
            (lo--emit-judgment
             'org-table-standard start-line
             (format "table violates the org-table standard: %s — wrap-org-table.el reflows it"
                     (string-join violations "; ")))))))))

;;; ---------------------------------------------------------------------------
;;; 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)
  (lo--register-runtime-link-types)
  (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)))
          ;; After org-lint items: the custom table-standard scan. Runs on the
          ;; post-fix buffer; judgment-only, so order doesn't perturb fixes.
          (lo--check-tables)
          (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--followups-section (file judgments)
  "Org section text for FILE's JUDGMENTS, keyed by checker + message.
Empty string when there are no judgments.  The line number is a trailing
annotation, not the entry's identity, so a finding that shifts lines after an
edit is still recognized as the same finding."
  (if (null judgments)
      ""
    (concat
     (format "* lint-org follow-ups — %s (%s)\n"
             (file-name-nondirectory file)
             (format-time-string "%Y-%m-%d"))
     (mapconcat
      (lambda (i)
        (format "** TODO %s — %s (line %d)\n"
                (plist-get i :checker)
                (plist-get i :msg)
                (plist-get i :line)))
      judgments
      ""))))

(defun lo--strip-followups-section (content file)
  "Return CONTENT with FILE's existing follow-ups section(s) removed.
A section runs from its `* lint-org follow-ups — FILE ...' header to the next
top-level `* ' heading or end of buffer.  Matched on the file name, so it
survives line-number churn in the target file, and tolerates the older
`* DATE lint-org follow-ups — FILE' header shape so legacy runs migrate cleanly."
  (with-temp-buffer
    (insert content)
    (let ((header-re
           (format "^\\* \\(?:[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} \\)?lint-org follow-ups — %s "
                   (regexp-quote (file-name-nondirectory file)))))
      (while (progn (goto-char (point-min))
                    (re-search-forward header-re nil t))
        (let ((start (line-beginning-position))
              (end (if (re-search-forward "^\\* " nil t)
                       (line-beginning-position)
                     (point-max))))
          (delete-region start end))))
    (buffer-string)))

(defun lo--reconciled-followups (existing file judgments)
  "Reconcile EXISTING follow-ups content for FILE against the current JUDGMENTS.
FILE's prior section is replaced by the current findings (dropped entirely when
nothing reproduces); other files' sections are left intact.  This is what makes
re-runs dedupe and resolved findings disappear instead of piling up."
  (let ((stripped (string-trim (lo--strip-followups-section existing file)))
        (section (string-trim (lo--followups-section file judgments))))
    (cond
     ((and (string= stripped "") (string= section "")) "")
     ((string= section "") (concat stripped "\n"))
     ((string= stripped "") (concat section "\n"))
     (t (concat stripped "\n\n" section "\n")))))

(defun lo--append-followups ()
  "Reconcile the current run's judgment items into `lo-followups-file'.
Rewrites FILE's section from the current findings: entries are keyed by content
(checker + message) rather than line number, findings that no longer reproduce
are dropped, and re-runs dedupe instead of appending a fresh dated section.
No-op when the file path is unset, or when there are no judgments and no file to
reconcile."
  (when lo-followups-file
    (let ((judgments (cl-remove-if-not
                      (lambda (i) (eq (plist-get i :kind) 'judgment))
                      (reverse lo-issues))))
      (when (or judgments (file-exists-p lo-followups-file))
        (let ((existing (if (file-exists-p lo-followups-file)
                            (with-temp-buffer
                              (insert-file-contents lo-followups-file)
                              (buffer-string))
                          ""))
              (dir (file-name-directory (expand-file-name lo-followups-file))))
          (when dir (make-directory dir t))
          (with-temp-file lo-followups-file
            (insert (lo--reconciled-followups existing lo-current-file judgments))))))))

(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