[Markdown] Tools for working with agents.
1
;;; ste-lint.el --- Lint Org prose against ASD-STE100 -*- lexical-binding: t; -*-
2
3
;; Copyright (C) 2026
4
5
;; This file is NOT part of GNU Emacs.
6
7
;;; Commentary:
8
9
;; This file finds Simplified Technical English (ASD-STE100) smells in the
10
;; prose of Org files. It has two entry points:
11
;;
12
;; - `ste-lint-batch' lints files from the command line:
13
;;
14
;; emacs -Q --batch -l ste-lint.el -f ste-lint-batch FILE-OR-DIR...
15
;;
16
;; Each finding goes to standard error as one line in the GNU format
17
;; "FILE:LINE:COLUMN: SEVERITY [CATEGORY] MATCH -- SUGGESTION". The exit
18
;; status is 1 when there is at least one ERROR finding, and 0 otherwise.
19
;; With no argument, the command lints every Org file below the current
20
;; directory.
21
;;
22
;; - `ste-lint-flymake-setup' adds a Flymake backend to an Org buffer.
23
;; Findings then show in the buffer while you type. Agents can read them
24
;; with the `get-diagnostics' MCP tool.
25
;;
26
;; The Org parser decides what is prose. The linter checks headline titles
27
;; and paragraphs. It does not check drawers, property drawers, planning
28
;; lines, keywords, tables, source blocks, example blocks, quote blocks, verse
29
;; blocks, or dynamic blocks. Inside the prose, the linter masks inline code,
30
;; verbatim text, timestamps, link targets, and similar markup. It keeps the
31
;; text positions, so that each finding points to its exact place.
32
;;
33
;; The rule tables below hold every rule. Each rule traces to ASD-STE100.
34
;; They are a port of the rule tables of the ste-lint.pl script in the
35
;; EUSCDanubeWikiCDK package.
36
;;
37
;; Severity:
38
;; ERROR -- an unambiguous STE100 violation. It fails the batch run.
39
;; WARN -- a judgment call with more false positives. It does not fail.
40
41
;;; Code:
42
43
(require 'cl-lib)
44
(require 'flymake)
45
(require 'org)
46
(require 'org-element)
47
48
(defgroup ste-lint nil
49
"Lint Org prose against ASD-STE100."
50
:group 'org)
51
52
(defcustom ste-lint-max-words-description 25
53
"Maximum number of words in a descriptive sentence."
54
:type 'integer)
55
56
(defcustom ste-lint-max-words-procedure 20
57
"Maximum number of words in a sentence of a procedure step.
58
The linter treats each item of an ordered list as a procedure step."
59
:type 'integer)
60
61
;;;; Rule tables
62
63
(defconst ste-lint-pattern-rules
64
(list
65
(list :category "contraction"
66
:severity 'error
67
;; An apostrophe followed by "s" is not a rule: it is almost always
68
;; a possessive.
69
:regexp (rx word-boundary
70
(group-n 1 (+ word)
71
(or (seq "n" (any "'’") "t")
72
(seq (any "'’") (or "ll" "re" "ve" "d" "m"))))
73
word-boundary)
74
:suggestion "Write the full form, for example \"do not\".")
75
(list :category "latin-abbreviation"
76
:severity 'error
77
:regexp (rx word-boundary
78
(group-n 1 (or "e.g." "i.e." "etc." "vs." "viz."
79
(seq "et" (+ blank) "al.") "cf." "n.b.")))
80
:suggestion (concat "Write plain English, for example \"for example\""
81
" or \"that is\", or list the items."))
82
(list :category "semicolon"
83
:severity 'error
84
:skip (rx word-boundary "TL;DR" word-boundary)
85
:regexp (rx (group-n 1 ";"))
86
:suggestion "Write two sentences.")
87
(list :category "ampersand"
88
:severity 'warn
89
;; Only an ampersand with white space on each side is the word "and".
90
:regexp (rx blank (group-n 1 "&") blank)
91
:suggestion "Write \"and\".")
92
(list :category "slash-conjunction"
93
:severity 'warn
94
:regexp (rx word-boundary
95
(group-n 1 (or "and/or" "he/she" "his/her" "him/her"
96
"she/he" "either/or"))
97
word-boundary)
98
:suggestion "Name one term, or write \"x or y\".")
99
(list :category "instruction-mood"
100
:severity 'warn
101
:regexp (rx word-boundary
102
(group-n 1 "you" (+ blank)
103
(or "should" "must" "can" "may" "will"
104
(seq "need" (+ blank) "to")
105
(seq "have" (+ blank) "to")))
106
word-boundary)
107
:suggestion "Use the imperative, for example \"Run the script\".")
108
(list :category "nominalization"
109
:severity 'warn
110
:regexp (rx word-boundary
111
(group-n 1
112
(or "do" "perform" (seq "carry" (+ blank) "out")
113
"conduct" "make" "provide")
114
(+ blank)
115
(? (or "a" "an" "the") (+ blank))
116
(+ word)
117
(or "tion" "ment" "ance" "ence" "al" "ing")
118
word-boundary
119
(? (+ blank) "of")))
120
:suggestion "Use the verb, for example \"deploy\"."))
121
"Rules that a regular expression triggers.
122
Each rule is a plist. `:regexp' is the trigger, and its group 1 is the
123
reported match. `:skip' is an optional regular expression for idioms that
124
the rule must ignore.")
125
126
(defconst ste-lint-substitution-rules
127
'(("wordy-phrase" warn
128
("utilize" . "use") ("utilizes" . "uses") ("utilized" . "used")
129
("utilizing" . "using") ("in order to" . "to")
130
("it is necessary to" . "you must") ("prior to" . "before")
131
("subsequent to" . "after") ("terminate" . "stop")
132
("terminates" . "stops") ("terminated" . "stopped")
133
("initiate" . "start") ("initiates" . "starts")
134
("initiated" . "started") ("execute" . "do or run")
135
("executes" . "does or runs") ("executed" . "done or run")
136
("a number of" . "several") ("a large number of" . "many")
137
("the majority of" . "most") ("in the event that" . "if")
138
("due to the fact that" . "because")
139
("in the process of" . "(drop it, or name the step)")
140
("at this point in time" . "now") ("with regard to" . "about")
141
("in terms of" . "for or about") ("in the case of" . "for")
142
("is able to" . "can") ("are able to" . "can"))
143
("phrasal-verb" warn
144
("carry out" . "do or run") ("carries out" . "does or runs")
145
("carried out" . "did or ran") ("put out" . "extinguish")
146
("give off" . "release") ("gives off" . "releases")
147
("gave off" . "released")
148
("set up" . "configure or install (as a verb)")
149
("shut down" . "stop") ("turn off" . "disable")
150
("turn on" . "enable") ("spin up" . "start or launch")
151
("kick off" . "start") ("roll out" . "deploy or release")
152
("back up" . "save a copy of (as a verb)"))
153
("weak-wording" warn
154
("make sure" . "confirm that") ("a lot of" . "many or much")
155
("lots of" . "many or much")
156
("and so on" . "(list the items, or omit)") ("as well as" . "and"))
157
("gendered-language" warn
158
("he" . "they, or name the role") ("she" . "they, or name the role")
159
("his" . "their, or name the role")
160
("hers" . "theirs, or name the role")
161
("him" . "them, or name the role") ("manpower" . "staff or effort")
162
("man-hours" . "person-hours")))
163
"Words and phrases with a preferred replacement.
164
Each entry is (CATEGORY SEVERITY (PHRASE . REPLACEMENT)...). A phrase
165
matches on word boundaries and without regard to case. The words of a
166
phrase can have any white space between them, which includes a line break.")
167
168
(defconst ste-lint-excluded-ancestors
169
'(drawer property-drawer quote-block verse-block dynamic-block)
170
"Element types whose paragraphs the linter does not check.
171
Drawers hold logbook history. Quote and verse blocks hold quoted text.
172
Dynamic blocks hold generated text.")
173
174
(defconst ste-lint--word-like-objects
175
'(code verbatim inline-src-block inline-babel-call timestamp entity
176
latex-fragment macro export-snippet)
177
"Object types that the linter replaces with one word.")
178
179
(defconst ste-lint--noise-objects
180
'(statistics-cookie footnote-reference line-break target radio-target)
181
"Object types that the linter replaces with white space.")
182
183
(defconst ste-lint--syntax-table
184
(let ((table (make-syntax-table text-mode-syntax-table)))
185
(modify-syntax-entry ?' "." table)
186
(modify-syntax-entry ?’ "." table)
187
table)
188
"Syntax table for the rule matches.
189
An apostrophe is punctuation, so that the rules see the parts of a
190
contraction as separate words.")
191
192
;;;; Findings
193
194
(cl-defstruct (ste-lint-finding (:constructor ste-lint--make-finding)
195
(:copier nil))
196
"One STE smell in a buffer."
197
beg end severity category match suggestion)
198
199
(defun ste-lint--squeeze (string)
200
"Return STRING with each run of white space changed to one space."
201
(string-trim (replace-regexp-in-string "[[:space:]]+" " " string)))
202
203
;;;; Prose extraction
204
205
(defun ste-lint--fill (text from to char base)
206
"Fill TEXT from buffer position FROM to TO with CHAR.
207
BASE is the buffer position of the first character of TEXT."
208
(let ((start (max 0 (- from base)))
209
(end (min (length text) (- to base))))
210
(cl-loop for index from start below end
211
do (aset text index char))))
212
213
(defun ste-lint--object-end (object)
214
"Return the end of OBJECT, without its trailing white space."
215
(- (org-element-property :end object)
216
(or (org-element-property :post-blank object) 0)))
217
218
(defun ste-lint--masked-text (data beg end)
219
"Return the prose of the buffer from BEG to END, masked.
220
DATA is the Org element or the secondary string that holds the prose.
221
The result has the same length as the buffer text. Inline code and
222
similar markup become a run of the letter x, which counts as one word.
223
Link targets become white space. Line breaks become spaces."
224
(let ((text (buffer-substring-no-properties beg end)))
225
(org-element-map data
226
(append '(link) ste-lint--word-like-objects ste-lint--noise-objects)
227
(lambda (object)
228
(let ((type (org-element-type object))
229
(object-beg (org-element-property :begin object))
230
(object-end (ste-lint--object-end object))
231
(contents-beg (org-element-property :contents-begin object))
232
(contents-end (org-element-property :contents-end object)))
233
(cond
234
((and (eq type 'link) contents-beg)
235
(ste-lint--fill text object-beg contents-beg ?\s beg)
236
(ste-lint--fill text contents-end object-end ?\s beg))
237
((or (eq type 'link) (memq type ste-lint--word-like-objects))
238
(ste-lint--fill text object-beg object-end ?x beg))
239
(t
240
(ste-lint--fill text object-beg object-end ?\s beg))))))
241
(subst-char-in-string ?\n ?\s text t)))
242
243
(defun ste-lint--title-region (headline)
244
"Return the region of the title of HEADLINE as (BEG . END), or nil.
245
The region excludes the stars, the keyword, the priority, and the tags."
246
(save-excursion
247
(goto-char (org-element-property :begin headline))
248
(let ((case-fold-search nil))
249
(when (and (looking-at org-complex-heading-regexp)
250
(match-beginning 4))
251
(cons (match-beginning 4) (match-end 4))))))
252
253
(defun ste-lint--procedure-p (paragraph)
254
"Return non-nil if PARAGRAPH is in an item of an ordered list."
255
(let ((item (org-element-lineage paragraph '(item))))
256
(and item
257
(eq (org-element-property
258
:type (org-element-property :parent item))
259
'ordered))))
260
261
;;;; Rule engine
262
263
(defun ste-lint--blank-matches (regexp text)
264
"Return a copy of TEXT with each match of REGEXP filled with spaces."
265
(replace-regexp-in-string
266
regexp (lambda (match) (make-string (length match) ?\s)) text t t))
267
268
(defun ste-lint--phrase-regexp (phrase)
269
"Return a regular expression for PHRASE on word boundaries.
270
Any white space can separate the words of PHRASE."
271
(concat "\\b\\("
272
(mapconcat #'regexp-quote (split-string phrase " ") "[[:space:]]+")
273
"\\)\\b"))
274
275
(defun ste-lint--collect (regexp text offset severity category suggestion)
276
"Return a finding for each match of group 1 of REGEXP in TEXT.
277
OFFSET is the buffer position of the first character of TEXT. SEVERITY,
278
CATEGORY, and SUGGESTION go into each finding."
279
(let ((start 0)
280
findings)
281
(while (string-match regexp text start)
282
(push (ste-lint--make-finding
283
:beg (+ offset (match-beginning 1))
284
:end (+ offset (match-end 1))
285
:severity severity
286
:category category
287
:match (ste-lint--squeeze (match-string 1 text))
288
:suggestion suggestion)
289
findings)
290
(setq start (max (match-end 0) (1+ (match-beginning 0)))))
291
(nreverse findings)))
292
293
(defun ste-lint--apply-pattern-rules (text offset)
294
"Return the findings of `ste-lint-pattern-rules' in TEXT at OFFSET."
295
(cl-loop
296
for rule in ste-lint-pattern-rules
297
for skip = (plist-get rule :skip)
298
nconc (ste-lint--collect
299
(plist-get rule :regexp)
300
(if skip (ste-lint--blank-matches skip text) text)
301
offset
302
(plist-get rule :severity)
303
(plist-get rule :category)
304
(plist-get rule :suggestion))))
305
306
(defun ste-lint--apply-substitution-rules (text offset)
307
"Return the findings of `ste-lint-substitution-rules' in TEXT at OFFSET."
308
(cl-loop
309
for (category severity . phrases) in ste-lint-substitution-rules
310
nconc (cl-loop
311
for (phrase . replacement) in phrases
312
nconc (ste-lint--collect
313
(ste-lint--phrase-regexp phrase) text offset severity
314
category (format "Consider \"%s\" instead." replacement)))))
315
316
(defun ste-lint--check-sentences (text offset max-words)
317
"Return a finding for each sentence of TEXT with more than MAX-WORDS words.
318
OFFSET is the buffer position of the first character of TEXT."
319
(let ((start 0)
320
(length (length text))
321
findings)
322
(while (and (< start length)
323
(string-match "[^[:space:]]" text start))
324
(let* ((sentence-beg (match-beginning 0))
325
(sentence-end (if (string-match
326
"[.!?]+\\(?:[[:space:]]\\|\\'\\)"
327
text sentence-beg)
328
(match-end 0)
329
length))
330
(words (cl-count-if
331
(lambda (word) (string-match-p "[[:word:]]" word))
332
(split-string
333
(substring text sentence-beg sentence-end)
334
"[[:space:]]+" t))))
335
(when (> words max-words)
336
(push (ste-lint--make-finding
337
:beg (+ offset sentence-beg)
338
:end (+ offset
339
(length (string-trim-right
340
(substring text 0 sentence-end))))
341
:severity 'warn
342
:category "sentence-length"
343
:match (format "%d words" words)
344
:suggestion (format "Split the sentence (maximum %d words)."
345
max-words))
346
findings))
347
(setq start sentence-end)))
348
(nreverse findings)))
349
350
(defun ste-lint--check-unit (data beg end max-words)
351
"Return the findings for the prose of DATA from BEG to END.
352
If MAX-WORDS is non-nil, also check the length of each sentence."
353
(let ((text (ste-lint--masked-text data beg end))
354
(case-fold-search t))
355
(with-syntax-table ste-lint--syntax-table
356
(append (ste-lint--apply-pattern-rules text beg)
357
(ste-lint--apply-substitution-rules text beg)
358
(and max-words
359
(ste-lint--check-sentences text beg max-words))))))
360
361
;;;; Buffer and file entry points
362
363
(defun ste-lint-buffer ()
364
"Return the STE findings for the Org prose of the current buffer.
365
The findings are sorted by position."
366
(let (findings)
367
(org-element-map (org-element-parse-buffer) '(headline paragraph)
368
(lambda (element)
369
(pcase (org-element-type element)
370
('headline
371
(let ((region (ste-lint--title-region element)))
372
(when region
373
(push (ste-lint--check-unit
374
(org-element-property :title element)
375
(car region) (cdr region) nil)
376
findings))))
377
('paragraph
378
(unless (org-element-lineage element ste-lint-excluded-ancestors)
379
(push (ste-lint--check-unit
380
element
381
(org-element-property :contents-begin element)
382
(org-element-property :contents-end element)
383
(if (ste-lint--procedure-p element)
384
ste-lint-max-words-procedure
385
ste-lint-max-words-description))
386
findings))))))
387
(sort (apply #'append findings)
388
(lambda (a b) (< (ste-lint-finding-beg a) (ste-lint-finding-beg b))))))
389
390
(defun ste-lint-file (file)
391
"Return the STE findings of the Org FILE.
392
Each element is (LINE COLUMN FINDING). COLUMN counts from 1."
393
(with-temp-buffer
394
(insert-file-contents file)
395
(let ((org-inhibit-startup t)
396
(org-mode-hook nil))
397
(org-mode))
398
(mapcar (lambda (finding)
399
(save-excursion
400
(goto-char (ste-lint-finding-beg finding))
401
(list (line-number-at-pos) (1+ (current-column)) finding)))
402
(ste-lint-buffer))))
403
404
(defun ste-lint--severity-label (severity)
405
"Return the printed label of SEVERITY."
406
(if (eq severity 'error) "ERROR" "WARN"))
407
408
(defun ste-lint--expand-files (paths)
409
"Return the Org files that PATHS name.
410
A directory gives every Org file below it, without hidden directories."
411
(cl-loop
412
for path in paths
413
nconc (if (file-directory-p path)
414
(directory-files-recursively
415
path "\\.org\\'" nil
416
(lambda (dir)
417
(not (string-prefix-p "." (file-name-nondirectory dir)))))
418
(list path))))
419
420
(defun ste-lint-batch ()
421
"Lint the files and directories on the command line, then exit.
422
With no argument, lint every Org file below `default-directory'. Exit
423
with status 1 if there is at least one ERROR finding."
424
(let ((files (ste-lint--expand-files
425
(or command-line-args-left (list default-directory))))
426
(errors 0)
427
(warnings 0))
428
(setq command-line-args-left nil)
429
(dolist (file files)
430
(if (not (file-readable-p file))
431
(message "warning: cannot read %s" file)
432
(pcase-dolist (`(,line ,column ,finding) (ste-lint-file file))
433
(if (eq (ste-lint-finding-severity finding) 'error)
434
(cl-incf errors)
435
(cl-incf warnings))
436
(message "%s:%d:%d: %s [%s] %s -- %s"
437
file line column
438
(ste-lint--severity-label
439
(ste-lint-finding-severity finding))
440
(ste-lint-finding-category finding)
441
(ste-lint-finding-match finding)
442
(ste-lint-finding-suggestion finding)))))
443
(message "%d error(s), %d warning(s)." errors warnings)
444
(kill-emacs (if (> errors 0) 1 0))))
445
446
;;;; Flymake
447
448
(defun ste-lint-flymake (report-fn &rest _args)
449
"Report the STE findings of the current buffer to Flymake.
450
REPORT-FN is the Flymake callback."
451
(funcall report-fn
452
(mapcar (lambda (finding)
453
(flymake-make-diagnostic
454
(current-buffer)
455
(ste-lint-finding-beg finding)
456
(ste-lint-finding-end finding)
457
(if (eq (ste-lint-finding-severity finding) 'error)
458
:error
459
:warning)
460
(format "STE [%s] %s -- %s"
461
(ste-lint-finding-category finding)
462
(ste-lint-finding-match finding)
463
(ste-lint-finding-suggestion finding))))
464
(ste-lint-buffer))))
465
466
;;;###autoload
467
(defun ste-lint-flymake-setup ()
468
"Turn on the STE checks of Flymake in the current Org buffer."
469
(interactive)
470
(add-hook 'flymake-diagnostic-functions #'ste-lint-flymake nil t)
471
(flymake-mode 1))
472
473
(provide 'ste-lint)
474
475
;;; ste-lint.el ends here
476