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.

Commit
b185bf80d3b3fd8e4d0b9fc17fa918bab9e58f58
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
skills/ste-lint/SKILL.md
index 00000000..fddd5e29 000000..100644
@@ -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
index 00000000..b7ebc632 000000..100755
@@ -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
index 00000000..970b6342 000000..100644
@@ -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
index 00000000..6c935665 000000..100644
@@ -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