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