@mark1russell7/ste-lint 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/README.md +246 -2
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +9 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/config-core.d.ts +92 -0
  8. package/dist/config-core.d.ts.map +1 -0
  9. package/dist/config-core.js +198 -0
  10. package/dist/config-core.js.map +1 -0
  11. package/dist/config.d.ts +29 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +68 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core.d.ts +16 -0
  16. package/dist/core.d.ts.map +1 -0
  17. package/dist/core.js +16 -0
  18. package/dist/core.js.map +1 -0
  19. package/dist/dictionary-core.d.ts +72 -0
  20. package/dist/dictionary-core.d.ts.map +1 -0
  21. package/dist/dictionary-core.js +150 -0
  22. package/dist/dictionary-core.js.map +1 -0
  23. package/dist/dictionary.d.ts +9 -0
  24. package/dist/dictionary.d.ts.map +1 -0
  25. package/dist/dictionary.js +26 -0
  26. package/dist/dictionary.js.map +1 -0
  27. package/dist/engine.d.ts +44 -0
  28. package/dist/engine.d.ts.map +1 -0
  29. package/dist/engine.js +122 -0
  30. package/dist/engine.js.map +1 -0
  31. package/dist/files.d.ts +35 -0
  32. package/dist/files.d.ts.map +1 -0
  33. package/dist/files.js +181 -0
  34. package/dist/files.js.map +1 -0
  35. package/dist/format.d.ts +21 -0
  36. package/dist/format.d.ts.map +1 -0
  37. package/dist/format.js +61 -0
  38. package/dist/format.js.map +1 -0
  39. package/dist/index.d.ts +19 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +19 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/lexicon.d.ts +33 -0
  44. package/dist/lexicon.d.ts.map +1 -0
  45. package/dist/lexicon.js +71 -0
  46. package/dist/lexicon.js.map +1 -0
  47. package/dist/lint.d.ts +18 -0
  48. package/dist/lint.d.ts.map +1 -0
  49. package/dist/lint.js +23 -0
  50. package/dist/lint.js.map +1 -0
  51. package/dist/rules/about-quantity.d.ts +3 -0
  52. package/dist/rules/about-quantity.d.ts.map +1 -0
  53. package/dist/rules/about-quantity.js +34 -0
  54. package/dist/rules/about-quantity.js.map +1 -0
  55. package/dist/rules/index.d.ts +8 -0
  56. package/dist/rules/index.d.ts.map +1 -0
  57. package/dist/rules/index.js +35 -0
  58. package/dist/rules/index.js.map +1 -0
  59. package/dist/rules/ing-verb.d.ts +9 -0
  60. package/dist/rules/ing-verb.d.ts.map +1 -0
  61. package/dist/rules/ing-verb.js +60 -0
  62. package/dist/rules/ing-verb.js.map +1 -0
  63. package/dist/rules/latin-abbreviation.d.ts +3 -0
  64. package/dist/rules/latin-abbreviation.d.ts.map +1 -0
  65. package/dist/rules/latin-abbreviation.js +48 -0
  66. package/dist/rules/latin-abbreviation.js.map +1 -0
  67. package/dist/rules/missing-subject.d.ts +16 -0
  68. package/dist/rules/missing-subject.d.ts.map +1 -0
  69. package/dist/rules/missing-subject.js +138 -0
  70. package/dist/rules/missing-subject.js.map +1 -0
  71. package/dist/rules/modal-verb.d.ts +3 -0
  72. package/dist/rules/modal-verb.d.ts.map +1 -0
  73. package/dist/rules/modal-verb.js +72 -0
  74. package/dist/rules/modal-verb.js.map +1 -0
  75. package/dist/rules/noun-cluster.d.ts +17 -0
  76. package/dist/rules/noun-cluster.d.ts.map +1 -0
  77. package/dist/rules/noun-cluster.js +83 -0
  78. package/dist/rules/noun-cluster.js.map +1 -0
  79. package/dist/rules/paragraph-length.d.ts +3 -0
  80. package/dist/rules/paragraph-length.d.ts.map +1 -0
  81. package/dist/rules/paragraph-length.js +19 -0
  82. package/dist/rules/paragraph-length.js.map +1 -0
  83. package/dist/rules/passive-instruction.d.ts +15 -0
  84. package/dist/rules/passive-instruction.d.ts.map +1 -0
  85. package/dist/rules/passive-instruction.js +38 -0
  86. package/dist/rules/passive-instruction.js.map +1 -0
  87. package/dist/rules/pronouns.d.ts +4 -0
  88. package/dist/rules/pronouns.d.ts.map +1 -0
  89. package/dist/rules/pronouns.js +57 -0
  90. package/dist/rules/pronouns.js.map +1 -0
  91. package/dist/rules/rule.d.ts +46 -0
  92. package/dist/rules/rule.d.ts.map +1 -0
  93. package/dist/rules/rule.js +31 -0
  94. package/dist/rules/rule.js.map +1 -0
  95. package/dist/rules/semicolon.d.ts +3 -0
  96. package/dist/rules/semicolon.d.ts.map +1 -0
  97. package/dist/rules/semicolon.js +20 -0
  98. package/dist/rules/semicolon.js.map +1 -0
  99. package/dist/rules/sentence-length.d.ts +3 -0
  100. package/dist/rules/sentence-length.d.ts.map +1 -0
  101. package/dist/rules/sentence-length.js +31 -0
  102. package/dist/rules/sentence-length.js.map +1 -0
  103. package/dist/rules/unknown-word.d.ts +9 -0
  104. package/dist/rules/unknown-word.d.ts.map +1 -0
  105. package/dist/rules/unknown-word.js +49 -0
  106. package/dist/rules/unknown-word.js.map +1 -0
  107. package/dist/rules/word-list-matcher.d.ts +25 -0
  108. package/dist/rules/word-list-matcher.d.ts.map +1 -0
  109. package/dist/rules/word-list-matcher.js +60 -0
  110. package/dist/rules/word-list-matcher.js.map +1 -0
  111. package/dist/rules/word-list.d.ts +3 -0
  112. package/dist/rules/word-list.d.ts.map +1 -0
  113. package/dist/rules/word-list.js +26 -0
  114. package/dist/rules/word-list.js.map +1 -0
  115. package/dist/run-cli.d.ts +27 -0
  116. package/dist/run-cli.d.ts.map +1 -0
  117. package/dist/run-cli.js +176 -0
  118. package/dist/run-cli.js.map +1 -0
  119. package/dist/source/blocks.d.ts +55 -0
  120. package/dist/source/blocks.d.ts.map +1 -0
  121. package/dist/source/blocks.js +464 -0
  122. package/dist/source/blocks.js.map +1 -0
  123. package/dist/source/code.d.ts +9 -0
  124. package/dist/source/code.d.ts.map +1 -0
  125. package/dist/source/code.js +16 -0
  126. package/dist/source/code.js.map +1 -0
  127. package/dist/source/inline.d.ts +42 -0
  128. package/dist/source/inline.d.ts.map +1 -0
  129. package/dist/source/inline.js +396 -0
  130. package/dist/source/inline.js.map +1 -0
  131. package/dist/source/jsx.d.ts +18 -0
  132. package/dist/source/jsx.d.ts.map +1 -0
  133. package/dist/source/jsx.js +137 -0
  134. package/dist/source/jsx.js.map +1 -0
  135. package/dist/source/lines.d.ts +34 -0
  136. package/dist/source/lines.d.ts.map +1 -0
  137. package/dist/source/lines.js +83 -0
  138. package/dist/source/lines.js.map +1 -0
  139. package/dist/source/markdown.d.ts +23 -0
  140. package/dist/source/markdown.d.ts.map +1 -0
  141. package/dist/source/markdown.js +42 -0
  142. package/dist/source/markdown.js.map +1 -0
  143. package/dist/source/suppressions.d.ts +38 -0
  144. package/dist/source/suppressions.d.ts.map +1 -0
  145. package/dist/source/suppressions.js +69 -0
  146. package/dist/source/suppressions.js.map +1 -0
  147. package/dist/source/tsdoc.d.ts +40 -0
  148. package/dist/source/tsdoc.d.ts.map +1 -0
  149. package/dist/source/tsdoc.js +176 -0
  150. package/dist/source/tsdoc.js.map +1 -0
  151. package/dist/text/grammar.d.ts +91 -0
  152. package/dist/text/grammar.d.ts.map +1 -0
  153. package/dist/text/grammar.js +289 -0
  154. package/dist/text/grammar.js.map +1 -0
  155. package/dist/text/phrases.d.ts +30 -0
  156. package/dist/text/phrases.d.ts.map +1 -0
  157. package/dist/text/phrases.js +65 -0
  158. package/dist/text/phrases.js.map +1 -0
  159. package/dist/text/sentences.d.ts +45 -0
  160. package/dist/text/sentences.d.ts.map +1 -0
  161. package/dist/text/sentences.js +141 -0
  162. package/dist/text/sentences.js.map +1 -0
  163. package/dist/text/tokens.d.ts +35 -0
  164. package/dist/text/tokens.d.ts.map +1 -0
  165. package/dist/text/tokens.js +113 -0
  166. package/dist/text/tokens.js.map +1 -0
  167. package/dist/text/words.d.ts +58 -0
  168. package/dist/text/words.d.ts.map +1 -0
  169. package/dist/text/words.js +159 -0
  170. package/dist/text/words.js.map +1 -0
  171. package/dist/types.d.ts +18 -0
  172. package/dist/types.d.ts.map +1 -0
  173. package/dist/types.js +20 -0
  174. package/dist/types.js.map +1 -0
  175. package/dist/units.d.ts +47 -0
  176. package/dist/units.d.ts.map +1 -0
  177. package/dist/units.js +42 -0
  178. package/dist/units.js.map +1 -0
  179. package/dist/word-list.d.ts +34 -0
  180. package/dist/word-list.d.ts.map +1 -0
  181. package/dist/word-list.js +90 -0
  182. package/dist/word-list.js.map +1 -0
  183. package/package.json +54 -3
  184. package/src/cli.ts +9 -0
  185. package/src/config-core.ts +258 -0
  186. package/src/config.test.ts +188 -0
  187. package/src/config.ts +83 -0
  188. package/src/core.test.ts +87 -0
  189. package/src/core.ts +32 -0
  190. package/src/dictionary-core.ts +158 -0
  191. package/src/dictionary.test.ts +82 -0
  192. package/src/dictionary.ts +26 -0
  193. package/src/engine.ts +138 -0
  194. package/src/files.test.ts +107 -0
  195. package/src/files.ts +191 -0
  196. package/src/format.ts +78 -0
  197. package/src/index.test.ts +28 -0
  198. package/src/index.ts +38 -0
  199. package/src/lexicon.ts +92 -0
  200. package/src/lint.test.ts +182 -0
  201. package/src/lint.ts +30 -0
  202. package/src/rules/about-quantity.ts +33 -0
  203. package/src/rules/index.ts +40 -0
  204. package/src/rules/ing-verb.test.ts +48 -0
  205. package/src/rules/ing-verb.ts +54 -0
  206. package/src/rules/latin-abbreviation.ts +46 -0
  207. package/src/rules/missing-subject.test.ts +53 -0
  208. package/src/rules/missing-subject.ts +129 -0
  209. package/src/rules/modal-verb.test.ts +45 -0
  210. package/src/rules/modal-verb.ts +72 -0
  211. package/src/rules/noun-cluster.test.ts +49 -0
  212. package/src/rules/noun-cluster.ts +82 -0
  213. package/src/rules/paragraph-length.test.ts +30 -0
  214. package/src/rules/paragraph-length.ts +19 -0
  215. package/src/rules/passive-instruction.test.ts +38 -0
  216. package/src/rules/passive-instruction.ts +37 -0
  217. package/src/rules/pronouns.test.ts +38 -0
  218. package/src/rules/pronouns.ts +54 -0
  219. package/src/rules/punctuation.test.ts +59 -0
  220. package/src/rules/rule.ts +73 -0
  221. package/src/rules/semicolon.ts +19 -0
  222. package/src/rules/sentence-length.test.ts +52 -0
  223. package/src/rules/sentence-length.ts +32 -0
  224. package/src/rules/unknown-word.test.ts +81 -0
  225. package/src/rules/unknown-word.ts +43 -0
  226. package/src/rules/word-list-matcher.ts +68 -0
  227. package/src/rules/word-list.test.ts +110 -0
  228. package/src/rules/word-list.ts +25 -0
  229. package/src/run-cli.test.ts +189 -0
  230. package/src/run-cli.ts +193 -0
  231. package/src/source/blocks.ts +534 -0
  232. package/src/source/code.ts +17 -0
  233. package/src/source/inline.test.ts +92 -0
  234. package/src/source/inline.ts +416 -0
  235. package/src/source/jsx.test.ts +46 -0
  236. package/src/source/jsx.ts +147 -0
  237. package/src/source/lines.test.ts +51 -0
  238. package/src/source/lines.ts +91 -0
  239. package/src/source/markdown.test.ts +210 -0
  240. package/src/source/markdown.ts +49 -0
  241. package/src/source/suppressions.ts +79 -0
  242. package/src/source/tsdoc.test.ts +137 -0
  243. package/src/source/tsdoc.ts +189 -0
  244. package/src/text/grammar.ts +304 -0
  245. package/src/text/phrases.ts +72 -0
  246. package/src/text/sentences.ts +148 -0
  247. package/src/text/text.test.ts +262 -0
  248. package/src/text/tokens.ts +135 -0
  249. package/src/text/words.ts +183 -0
  250. package/src/types.ts +39 -0
  251. package/src/units.ts +86 -0
  252. package/src/word-list.ts +128 -0
  253. package/ste.config.schema.json +87 -0
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveConfig } from "../config.js";
3
+ import { Linter } from "../lint.js";
4
+
5
+ const linter = new Linter(resolveConfig({}, { root : "/" }));
6
+ const lint = (text : string, path : string = "doc.md") => linter.lintText(text, path).filter((finding) => finding.ruleId === "modal-verb");
7
+ const found = (text : string) : string[] => lint(text).map((finding) => text.slice(finding.column - 1, finding.endColumn - 1));
8
+
9
+ describe("modal-verb", () => {
10
+ it("reports the modal verbs that are not approved, without case", () => {
11
+ expect(found("You should stop. It may fail. It might fail. It would fail. You shall stop.")).toEqual(["should", "may", "might", "would", "shall"]);
12
+ expect(found("SHOULD and Should are the same.")).toEqual(["SHOULD", "Should"]);
13
+ expect(found("It shouldn't fail and wouldn't stop.")).toEqual(["shouldn't", "wouldn't"]);
14
+ });
15
+
16
+ it("reports the modal phrases with \"to\"", () => {
17
+ expect(found("You have to stop. It has to stop. You need to stop. It needs to stop. You ought to stop.")).toEqual([
18
+ "have to", "has to", "need to", "needs to", "ought to",
19
+ ]);
20
+ });
21
+
22
+ it("gives the approved alternative in the message", () => {
23
+ expect(lint("You should stop.")[0]!.message).toContain("\"must\"");
24
+ expect(lint("It may fail.")[0]!.message).toContain("\"can\"");
25
+ expect(lint("You need to stop.")[0]!.message).toContain("imperative");
26
+ });
27
+
28
+ it("uses word boundaries", () => {
29
+ expect(found("The shoulder, mayor, nightmare and wouldbe words are fine.")).toEqual([]);
30
+ });
31
+
32
+ it("does not report the approved modal verbs, the month May or a noun", () => {
33
+ expect(found("You can stop. You must stop. It will stop. It could stop.")).toEqual([]);
34
+ expect(found("The release is in May 2026. It came on 3 May. Since May, it works.")).toEqual([]);
35
+ expect(found("The need to stop is clear. I have two tools. It has tools.")).toEqual([]);
36
+ });
37
+
38
+ it("does not report modal verbs in code spans or in quoted text", () => {
39
+ expect(found("The `should` assertion and the word \"may\" are quoted.")).toEqual([]);
40
+ });
41
+
42
+ it("examines headings and table cells", () => {
43
+ expect(lint("# You should read this\n\n| a |\n| --- |\n| It may stop. |")).toHaveLength(2);
44
+ });
45
+ });
@@ -0,0 +1,72 @@
1
+ import { isClauseStart } from "../text/grammar.js";
2
+ import { isNumber, isWord, type Token } from "../text/tokens.js";
3
+ import { DETERMINERS } from "../text/words.js";
4
+ import { forEachToken, surface, tokenRange, type Rule } from "./rule.js";
5
+
6
+ // These tables are maps, not object literals, so a word such as "constructor" does not find an Object property.
7
+ const SINGLE : ReadonlyMap<string, string> = new Map([
8
+ ["should", "Write \"must\" for an obligation, or \"if\" for a condition (\"If X occurs, ...\")."],
9
+ ["shouldn", "Write \"must not\" for an obligation."],
10
+ ["may", "Write \"can\" for a possibility or a permission, or \"possibly\"."],
11
+ ["might", "Write \"can\" or \"possibly\"."],
12
+ ["would", "Write \"can\", or use a simple tense (\"is\", \"gives\")."],
13
+ ["wouldn", "Write \"cannot\", or use a simple tense (\"is not\")."],
14
+ ["shall", "Write \"must\"."],
15
+ ]);
16
+
17
+ const CONTRACTIONS : ReadonlyMap<string, string> = new Map([
18
+ ["shouldn't", "shouldn"],
19
+ ["wouldn't", "wouldn"],
20
+ ["mightn't", "might"],
21
+ ["shan't", "shall"],
22
+ ["mayn't", "may"],
23
+ ]);
24
+
25
+ const IMPERATIVE_HELP = "Write the instruction in the imperative (\"Do X\"), or use \"must\".";
26
+ const NECESSARY_HELP = "Write the instruction in the imperative (\"Do X\"), or use \"must\" or \"it is necessary to\".";
27
+
28
+ const WITH_TO : ReadonlyMap<string, string> = new Map([
29
+ ["ought", "Write \"must\"."],
30
+ ["have", IMPERATIVE_HELP],
31
+ ["has", IMPERATIVE_HELP],
32
+ ["had", "Use the simple past tense of the verb, or \"must\"."],
33
+ ["having", "Rewrite the sentence with \"must\" or with the imperative."],
34
+ ["need", NECESSARY_HELP],
35
+ ["needs", NECESSARY_HELP],
36
+ ["needed", "Write \"it was necessary to\", or rewrite the sentence."],
37
+ ]);
38
+
39
+ /** True when "May" is the month: "May 2026", "3 May". */
40
+ function isMonth(tokens : readonly Token[], index : number) : boolean {
41
+ const token = tokens[index]!;
42
+ if (token.text !== "May") return false;
43
+ return !isClauseStart(tokens, index) || isNumber(tokens[index + 1]) || isNumber(tokens[index - 1]);
44
+ }
45
+
46
+ export const modalVerb : Rule = {
47
+ id : "modal-verb",
48
+ description : "Find modal verbs that are not approved: should, may, might, would, shall, ought to, have to, need to.",
49
+ ste : "3.1, 3.4, 5.3",
50
+ defaultSeverity : "error",
51
+ scope : "word",
52
+ check(unit, context) {
53
+ forEachToken(unit, (tokens, i) => {
54
+ const token = tokens[i]!;
55
+ if (!isWord(token)) return;
56
+ const key = CONTRACTIONS.get(token.lower) ?? token.lower;
57
+ const help = SINGLE.get(key);
58
+ if (help !== undefined) {
59
+ if (key === "may" && isMonth(tokens, i)) return;
60
+ context.report({ ...tokenRange(tokens, i, i + 1), message : `Do not use the modal verb "${token.text}". ${help}` });
61
+ return;
62
+ }
63
+ const withTo = WITH_TO.get(key);
64
+ const next = tokens[i + 1];
65
+ if (withTo === undefined || !isWord(next) || next.lower !== "to") return;
66
+ const previous = tokens[i - 1];
67
+ // "the need to ..." is a noun phrase, not a modal verb.
68
+ if (key.startsWith("need") && isWord(previous) && DETERMINERS.has(previous.lower)) return;
69
+ context.report({ ...tokenRange(tokens, i, i + 2), message : `Do not use "${surface(tokens, i, i + 2)}". ${withTo}` });
70
+ });
71
+ },
72
+ };
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveConfig, type SteConfigFile } from "../config.js";
3
+ import { parseDictionary } from "../dictionary.js";
4
+ import { buildLexicon } from "../lexicon.js";
5
+ import { Linter } from "../lint.js";
6
+
7
+ const lint = (text : string, file : SteConfigFile = {}) =>
8
+ new Linter(resolveConfig(file, { root : "/" })).lintText(text, "doc.md").filter((finding) => finding.ruleId === "noun-cluster");
9
+ const found = (text : string, file : SteConfigFile = {}) : string[] => lint(text, file).map((finding) => text.slice(finding.column - 1, finding.endColumn - 1));
10
+
11
+ describe("noun-cluster", () => {
12
+ it("reports more than 3 known nouns in sequence", () => {
13
+ expect(found("Read the browser memory usage threshold value now.")).toEqual(["browser memory usage threshold value"]);
14
+ const findings = lint("Read the browser memory usage threshold now.");
15
+ expect(findings[0]!.severity).toBe("warning");
16
+ expect(findings[0]!.message).toContain("4 nouns");
17
+ });
18
+
19
+ it("accepts 3 nouns, and a preposition or an article stops the group", () => {
20
+ expect(found("Read the memory usage threshold. Read the threshold of the memory usage value.")).toEqual([]);
21
+ });
22
+
23
+ it("counts glossary nouns, abbreviations and code, and a multi-word technical noun as one noun", () => {
24
+ expect(found("The heartbeat `seq` API timer value is late.", { technicalNouns : ["heartbeat"] })).toEqual(["heartbeat `seq` API timer value"]);
25
+ expect(found("The memory usage threshold value is low.")).toEqual(["memory usage threshold value"]);
26
+ expect(found("The memory usage threshold value is low.", { technicalNouns : ["memory usage"] })).toEqual([]);
27
+ expect(found("The memory usage threshold value unit is low.", { technicalNouns : ["memory usage"] })).toEqual(["memory usage threshold value unit"]);
28
+ });
29
+
30
+ it("stops a group after a plural noun", () => {
31
+ expect(found("The browser timers memory usage threshold.")).toEqual([]);
32
+ expect(found("The browser memory usage thresholds are low.")).toEqual(["browser memory usage thresholds"]);
33
+ });
34
+
35
+ it("does not count words that are frequently verbs", () => {
36
+ expect(found("The page queue update process value is fine.")).toEqual([]);
37
+ });
38
+
39
+ it("uses the nouns of the dictionary", () => {
40
+ const config = resolveConfig({}, { root : "/" });
41
+ const dictionary = parseDictionary({ entries : [{ word : "valve", pos : "noun" }, { word : "pump", pos : "noun" }] }, "d.json");
42
+ const linter = new Linter(config, buildLexicon(config, dictionary));
43
+ expect(linter.lintText("The pump valve memory usage is low.", "doc.md").filter((finding) => finding.ruleId === "noun-cluster")).toHaveLength(1);
44
+ });
45
+
46
+ it("uses the limit of the configuration", () => {
47
+ expect(found("The memory usage threshold.", { limits : { nounClusterNouns : 2 } })).toEqual(["memory usage threshold"]);
48
+ });
49
+ });
@@ -0,0 +1,82 @@
1
+ import type { Lexicon } from "../lexicon.js";
2
+ import { isAbbreviation, isWord, type Token } from "../text/tokens.js";
3
+ import { surface, tokenRange, type Rule } from "./rule.js";
4
+
5
+ type NounUnit = {
6
+ /** The number of tokens in the noun. A multi-word technical noun is one noun. */
7
+ readonly length : number;
8
+ readonly plural : boolean;
9
+ };
10
+
11
+ function singular(word : string) : string[] {
12
+ const forms : string[] = [];
13
+ if (word.endsWith("ies")) forms.push(word.slice(0, -3) + "y");
14
+ if (word.endsWith("es")) forms.push(word.slice(0, -2));
15
+ if (word.endsWith("s")) forms.push(word.slice(0, -1));
16
+ return forms;
17
+ }
18
+
19
+ /** The noun at `tokens[index]`, or null when the token is not a known noun. */
20
+ function nounAt(tokens : readonly Token[], index : number, lexicon : Lexicon) : NounUnit | null {
21
+ const token = tokens[index];
22
+ if (token === undefined) return null;
23
+ if (token.kind === "code") {
24
+ const kind = token.placeholder?.kind;
25
+ return kind === "code" || kind === "link" ? { length : 1, plural : false } : null;
26
+ }
27
+ if (!isWord(token)) return null;
28
+ const phrase = lexicon.technicalNounPhrases.longestMatch(tokens, index);
29
+ if (phrase > 1) return { length : phrase, plural : tokens[index + phrase - 1]!.lower.endsWith("s") };
30
+ if (isAbbreviation(token)) return { length : 1, plural : /[a-z]s$/.test(token.text) };
31
+ if (lexicon.nouns.has(token.lower)) return { length : 1, plural : false };
32
+ if (singular(token.lower).some((form) => lexicon.nouns.has(form))) return { length : 1, plural : true };
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * The rule counts a group of nouns in sequence, with no article,
38
+ * preposition or punctuation between them. Only these known nouns count:
39
+ *
40
+ * - Technical nouns and proper nouns from the glossary
41
+ * - Nouns from the dictionary
42
+ * - A built-in list of common software nouns
43
+ * - Abbreviations and code.
44
+ *
45
+ * Only the last noun of a group can be plural. Without a part-of-speech
46
+ * tagger, the rule cannot know if other words are nouns, so it does not
47
+ * count them. Thus it misses some clusters, but it seldom reports a false
48
+ * one.
49
+ */
50
+ export const nounCluster : Rule = {
51
+ id : "noun-cluster",
52
+ description : "Find groups of more than 3 nouns in sequence (noun clusters).",
53
+ ste : "2.1",
54
+ defaultSeverity : "warning",
55
+ scope : "word",
56
+ check(unit, context) {
57
+ const max = context.config.limits.nounClusterNouns;
58
+ for (const sentence of unit.sentences) {
59
+ const tokens = sentence.tokens;
60
+ let i = 0;
61
+ while (i < tokens.length) {
62
+ let count = 0;
63
+ let j = i;
64
+ let plural = false;
65
+ while (!plural) {
66
+ const noun = nounAt(tokens, j, context.lexicon);
67
+ if (noun === null) break;
68
+ count++;
69
+ j += noun.length;
70
+ plural = noun.plural;
71
+ }
72
+ if (count > max) {
73
+ context.report({
74
+ ...tokenRange(tokens, i, j),
75
+ message : `"${surface(tokens, i, j)}" has ${count} nouns in sequence. Use no more than ${max}. Use a preposition to divide the group, for example "the interval of the heartbeat" instead of "heartbeat interval".`,
76
+ });
77
+ }
78
+ i = count > 0 ? j : i + 1;
79
+ }
80
+ }
81
+ },
82
+ };
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveConfig, type SteConfigFile } from "../config.js";
3
+ import { Linter } from "../lint.js";
4
+
5
+ const lint = (text : string, file : SteConfigFile = {}) =>
6
+ new Linter(resolveConfig(file, { root : "/" })).lintText(text, "doc.md").filter((finding) => finding.ruleId === "paragraph-length");
7
+
8
+ const paragraph = (count : number) : string => Array.from({ length : count }, (_, i) => `Sentence ${i + 1} is short.`).join(" ");
9
+
10
+ describe("paragraph-length", () => {
11
+ it("reports a paragraph of more than 6 sentences as a warning", () => {
12
+ const findings = lint(paragraph(7));
13
+ expect(findings).toHaveLength(1);
14
+ expect(findings[0]).toMatchObject({ severity : "warning", line : 1, column : 1 });
15
+ expect(findings[0]!.message).toContain("7 sentences");
16
+ });
17
+
18
+ it("accepts 6 sentences, and counts each paragraph separately", () => {
19
+ expect(lint(paragraph(6))).toEqual([]);
20
+ expect(lint(`${paragraph(4)}\n\n${paragraph(4)}`)).toEqual([]);
21
+ });
22
+
23
+ it("does not count an abbreviation as the end of a sentence", () => {
24
+ expect(lint("One, e.g. two. Three. Four. Five. Six. Seven, i.e. eight.")).toEqual([]);
25
+ });
26
+
27
+ it("uses the limit of the configuration", () => {
28
+ expect(lint(paragraph(3), { limits : { paragraphSentences : 2 } })).toHaveLength(1);
29
+ });
30
+ });
@@ -0,0 +1,19 @@
1
+ import type { Rule } from "./rule.js";
2
+
3
+ export const paragraphLength : Rule = {
4
+ id : "paragraph-length",
5
+ description : "A paragraph can have no more than 6 sentences.",
6
+ ste : "6.6",
7
+ defaultSeverity : "warning",
8
+ scope : "paragraph",
9
+ check(unit, context) {
10
+ const limit = context.config.limits.paragraphSentences;
11
+ const count = unit.sentences.length;
12
+ if (count <= limit) return;
13
+ context.report({
14
+ start : unit.start,
15
+ end : unit.end,
16
+ message : `This paragraph has ${count} sentences. Use no more than ${limit} sentences in a paragraph. Divide it into two paragraphs, each with one topic.`,
17
+ });
18
+ },
19
+ };
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveConfig } from "../config.js";
3
+ import { Linter } from "../lint.js";
4
+
5
+ const linter = new Linter(resolveConfig({}, { root : "/" }));
6
+ const lint = (text : string, path : string = "doc.md") => linter.lintText(text, path).filter((finding) => finding.ruleId === "passive-instruction");
7
+ const found = (text : string) : string[] => lint(text).map((finding) => text.split("\n")[finding.line - 1]!.slice(finding.column - 1, finding.endColumn - 1));
8
+
9
+ describe("passive-instruction", () => {
10
+ it("reports an obligation in the passive voice in any sentence", () => {
11
+ expect(found("The token must be set before the start. The value has to be written first. The file should be removed.")).toEqual([
12
+ "must be set", "has to be written", "should be removed",
13
+ ]);
14
+ const findings = lint("The token must be set first.");
15
+ expect(findings[0]!.severity).toBe("warning");
16
+ expect(findings[0]!.message).toContain("imperative");
17
+ });
18
+
19
+ it("reports the passive voice in an imperative sentence", () => {
20
+ expect(found("Run the build, which is triggered by CI.")).toEqual(["is triggered"]);
21
+ });
22
+
23
+ it("reports the passive voice in a step of an ordered list", () => {
24
+ expect(found("1. The cache is cleared by the script.\n2. The tests are started.")).toEqual(["is cleared", "are started"]);
25
+ });
26
+
27
+ it("does not report a participle in a condition, which shows a state", () => {
28
+ expect(found("Make sure that the server is started. Wait until the build is finished. If the file is deleted, start again.")).toEqual([]);
29
+ });
30
+
31
+ it("does not report the passive voice in a description", () => {
32
+ expect(found("The file is created by the build. Samples are discarded when the page is hidden. It can be used.")).toEqual([]);
33
+ });
34
+
35
+ it("does not report \"be\" with an adjective", () => {
36
+ expect(found("Be careful. Make sure that the value is positive.")).toEqual([]);
37
+ });
38
+ });
@@ -0,0 +1,37 @@
1
+ import { findPassives } from "../text/grammar.js";
2
+ import { surface, tokenRange, type Rule } from "./rule.js";
3
+
4
+ /**
5
+ * The rule examines these constructions:
6
+ *
7
+ * - An obligation in the passive voice ("must be set", "has to be called",
8
+ * "needs to be installed") in any sentence. This is an instruction in a
9
+ * passive form.
10
+ * - Any passive ("is cleared", "are triggered by CI") in a procedural
11
+ * sentence: an imperative sentence or an item of an ordered list. A
12
+ * participle after "be" in a condition clause ("if the file is deleted",
13
+ * "make sure that the server is started") shows a state, so the rule
14
+ * ignores it.
15
+ */
16
+ export const passiveInstruction : Rule = {
17
+ id : "passive-instruction",
18
+ description : "Find the passive voice in instructions. An instruction uses the active voice and the imperative.",
19
+ ste : "3.4, 3.6, 5.3",
20
+ defaultSeverity : "warning",
21
+ scope : "sentence",
22
+ check(unit, context) {
23
+ for (const sentence of unit.sentences) {
24
+ const procedural = sentence.instruction || unit.list?.ordered === true;
25
+ for (const passive of findPassives(sentence.tokens)) {
26
+ if (!passive.obligation && !(procedural && !passive.inCondition)) continue;
27
+ const from = passive.start;
28
+ const to = passive.participle + 1;
29
+ const phrase = surface(sentence.tokens, from, to);
30
+ const message = passive.obligation
31
+ ? `"${phrase}" is an instruction in the passive voice. Write the instruction in the imperative, for example "Set the value before you start the monitor."`
32
+ : `"${phrase}" is in the passive voice. In an instruction, use the active voice: write the imperative, or tell who or what does the action.`;
33
+ context.report({ ...tokenRange(sentence.tokens, from, to), message });
34
+ }
35
+ }
36
+ },
37
+ };
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveConfig } from "../config.js";
3
+ import { Linter } from "../lint.js";
4
+ import type { RuleId } from "../types.js";
5
+
6
+ const linter = new Linter(resolveConfig({}, { root : "/" }));
7
+ const lint = (rule : RuleId, text : string) => linter.lintText(text, "doc.md").filter((finding) => finding.ruleId === rule);
8
+ const found = (rule : RuleId, text : string) : string[] => lint(rule, text).map((finding) => text.slice(finding.column - 1, finding.endColumn - 1));
9
+
10
+ describe("gendered-pronoun", () => {
11
+ it("reports gendered pronouns as errors", () => {
12
+ expect(found("gendered-pronoun", "He opens it. She saves his file. Give her the key, and him the code. It is hers. He did it himself, and she herself.")).toEqual([
13
+ "He", "She", "his", "her", "him", "hers", "He", "himself", "she", "herself",
14
+ ]);
15
+ const findings = lint("gendered-pronoun", "The user saves his work.");
16
+ expect(findings[0]!.severity).toBe("error");
17
+ expect(findings[0]!.message).toContain("\"they\"");
18
+ });
19
+
20
+ it("does not report other words, code or quoted text", () => {
21
+ expect(found("gendered-pronoun", "The theme, the shell and `he` and \"she\" are fine. They save their work.")).toEqual([]);
22
+ });
23
+ });
24
+
25
+ describe("first-person", () => {
26
+ it("reports I, me, my, our and us as warnings", () => {
27
+ expect(found("first-person", "I run it. Give me the file. My file. Our tool. Tell us. Let's go. It is ours.")).toEqual([
28
+ "I", "me", "My", "Our", "us", "Let's", "ours",
29
+ ]);
30
+ const findings = lint("first-person", "Our tool works.");
31
+ expect(findings[0]!.severity).toBe("warning");
32
+ expect(findings[0]!.message).toContain("\"we\"");
33
+ });
34
+
35
+ it("does not report \"we\", the abbreviation US, or I/O", () => {
36
+ expect(found("first-person", "We release the tool in the US. The I/O is fast. You can use it.")).toEqual([]);
37
+ });
38
+ });
@@ -0,0 +1,54 @@
1
+ import { isWord } from "../text/tokens.js";
2
+ import { forEachToken, tokenRange, type Rule } from "./rule.js";
3
+
4
+ const GENDERED = new Set([
5
+ "he", "she", "his", "her", "him", "hers", "himself", "herself",
6
+ "he's", "she's", "he'd", "she'd", "he'll", "she'll",
7
+ ]);
8
+
9
+ export const genderedPronoun : Rule = {
10
+ id : "gendered-pronoun",
11
+ description : "Find gendered pronouns: he, she, his, her, him, hers, himself and herself.",
12
+ ste : "GR-3, GR-7",
13
+ defaultSeverity : "error",
14
+ scope : "word",
15
+ check(unit, context) {
16
+ forEachToken(unit, (tokens, i) => {
17
+ const token = tokens[i]!;
18
+ if (!isWord(token) || !GENDERED.has(token.lower)) return;
19
+ context.report({
20
+ ...tokenRange(tokens, i, i + 1),
21
+ message : `Do not use the gendered pronoun "${token.text}". Write "they" or "it", or write the noun again.`,
22
+ });
23
+ });
24
+ },
25
+ };
26
+
27
+ const SINGULAR = new Set(["i", "me", "my", "mine", "myself", "i'm", "i've", "i'll", "i'd"]);
28
+ const PLURAL = new Set(["our", "ours", "ourselves"]);
29
+
30
+ export const firstPerson : Rule = {
31
+ id : "first-person",
32
+ description : "Find first-person pronouns other than \"we\": I, me, my, our and us. Use \"we\" only for the organization that writes the text.",
33
+ ste : "GR-3",
34
+ defaultSeverity : "warning",
35
+ scope : "word",
36
+ check(unit, context) {
37
+ forEachToken(unit, (tokens, i) => {
38
+ const token = tokens[i]!;
39
+ if (!isWord(token)) return;
40
+ const lower = token.lower;
41
+ let message : string | null = null;
42
+ if (SINGULAR.has(lower)) {
43
+ message = `Do not use "${token.text}". Write "you" for the reader, or "we" for the organization that writes the text.`;
44
+ } else if (PLURAL.has(lower)) {
45
+ message = `Do not use "${token.text}". Write the name of the project or the product, or rewrite the sentence with "we" as the subject.`;
46
+ } else if (lower === "us" && token.text !== "US") {
47
+ message = "Do not use \"us\". Rewrite the sentence with \"we\" as the subject.";
48
+ } else if (lower === "let's") {
49
+ message = "Do not use \"let's\". Write the instruction in the imperative.";
50
+ }
51
+ if (message !== null) context.report({ ...tokenRange(tokens, i, i + 1), message });
52
+ });
53
+ },
54
+ };
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveConfig } from "../config.js";
3
+ import { Linter } from "../lint.js";
4
+ import type { RuleId } from "../types.js";
5
+
6
+ const linter = new Linter(resolveConfig({}, { root : "/" }));
7
+ const lint = (rule : RuleId, text : string, path : string = "doc.md") => linter.lintText(text, path).filter((finding) => finding.ruleId === rule);
8
+ const found = (rule : RuleId, text : string) : string[] => lint(rule, text).map((finding) => text.slice(finding.column - 1, finding.endColumn - 1));
9
+
10
+ describe("semicolon", () => {
11
+ it("reports semicolons in prose", () => {
12
+ const findings = lint("semicolon", "The first part; the second part.");
13
+ expect(findings).toHaveLength(1);
14
+ expect(findings[0]).toMatchObject({ line : 1, column : 15, severity : "error" });
15
+ expect(findings[0]!.message).toContain("two sentences");
16
+ });
17
+
18
+ it("does not report semicolons in code spans, code blocks, HTML entities or URLs", () => {
19
+ expect(lint("semicolon", "Write `a; b` and &amp; &nbsp; here.\n\n```js\nf(); g();\n```\n\nRead https://x.dev/a;b now.")).toEqual([]);
20
+ });
21
+
22
+ it("reports semicolons in doc comments, but not in the code", () => {
23
+ expect(lint("semicolon", "/** A; b. */\nconst a = 1; const b = 2;", "a.ts")).toHaveLength(1);
24
+ });
25
+ });
26
+
27
+ describe("latin-abbreviation", () => {
28
+ it("reports e.g., i.e., etc., vs., viz., cf. and et al.", () => {
29
+ expect(found("latin-abbreviation", "Some, e.g. this, i.e. that, etc. A vs. B, viz. C, cf. D, Smith et al. wrote.")).toEqual([
30
+ "e.g.", "i.e.", "etc.", "vs.", "viz.", "cf.", "et al.",
31
+ ]);
32
+ });
33
+
34
+ it("reports the forms without periods that cannot be other words", () => {
35
+ expect(found("latin-abbreviation", "A vs B, and so on etc, and E.g., this.")).toEqual(["vs", "etc", "E.g."]);
36
+ });
37
+
38
+ it("gives the alternative in the message", () => {
39
+ expect(lint("latin-abbreviation", "Use a tool, e.g. a linter.")[0]!.message).toContain("\"for example\"");
40
+ });
41
+
42
+ it("does not report other words", () => {
43
+ expect(found("latin-abbreviation", "VS Code, IE and the cf flag are names. The etcetera word and /etc/hosts are fine. Et cetera, et cetera.")).toEqual([]);
44
+ expect(found("latin-abbreviation", "The constructor and the prototype are words.")).toEqual([]);
45
+ });
46
+ });
47
+
48
+ describe("about-quantity", () => {
49
+ it("reports \"about\" before a number or a quantity", () => {
50
+ expect(found("about-quantity", "It takes about 5 ms. About twenty runs. About a second. About an hour. About 10%. About ~3.")).toEqual([
51
+ "about", "About", "About", "About", "About", "About",
52
+ ]);
53
+ expect(lint("about-quantity", "It takes about 5 ms.")[0]!.message).toContain("\"approximately\"");
54
+ });
55
+
56
+ it("does not report \"about\" before a topic", () => {
57
+ expect(found("about-quantity", "Read about the monitor. It is about a page. Approximately 5 ms is fine.")).toEqual([]);
58
+ });
59
+ });
@@ -0,0 +1,73 @@
1
+ import type { ResolvedConfig } from "../config.js";
2
+ import type { Lexicon } from "../lexicon.js";
3
+ import type { Token } from "../text/tokens.js";
4
+ import type { RuleId, Severity } from "../types.js";
5
+ import type { ProseUnit, Sentence } from "../units.js";
6
+ import type { WordListMatcher } from "./word-list-matcher.js";
7
+
8
+ /**
9
+ * Which units a rule examines. Headings and table cells get only the
10
+ * "word" rules. Paragraphs and list items get all rules.
11
+ */
12
+ export type RuleScope = "word" | "sentence" | "paragraph";
13
+
14
+ export type Report = {
15
+ /** The file offset of the first character of the problem. */
16
+ readonly start : number;
17
+ /** The file offset after the last character of the problem. */
18
+ readonly end : number;
19
+ readonly message : string;
20
+ /** The severity of this finding, when it is not the default severity of the rule (for example a word list entry). */
21
+ readonly severity? : Severity;
22
+ };
23
+
24
+ export type RuleContext = {
25
+ readonly config : ResolvedConfig;
26
+ readonly lexicon : Lexicon;
27
+ readonly wordList : WordListMatcher;
28
+ report(report : Report) : void;
29
+ };
30
+
31
+ export type Rule = {
32
+ readonly id : RuleId;
33
+ /** One sentence that tells what the rule finds. */
34
+ readonly description : string;
35
+ /** The related ASD-STE100 rule numbers. */
36
+ readonly ste : string;
37
+ readonly defaultSeverity : Severity;
38
+ readonly scope : RuleScope;
39
+ check(unit : ProseUnit, context : RuleContext) : void;
40
+ };
41
+
42
+ /** The surface text of `tokens[from]` to `tokens[to - 1]`, with one space between tokens that had space between them. */
43
+ export function surface(tokens : readonly Token[], from : number, to : number) : string {
44
+ let text = "";
45
+ for (let i = from; i < to; i++) {
46
+ const token = tokens[i]!;
47
+ const value = token.kind === "code" && token.placeholder !== null ? placeholderText(token) : token.text;
48
+ text += (i > from && token.spaceBefore ? " " : "") + value;
49
+ }
50
+ return text;
51
+ }
52
+
53
+ function placeholderText(token : Token) : string {
54
+ const placeholder = token.placeholder!;
55
+ switch (placeholder.kind) {
56
+ case "code": return `\`${placeholder.text}\``;
57
+ case "quote": return `"${placeholder.text}"`;
58
+ case "link": return `{${placeholder.text}}`;
59
+ default: return placeholder.text;
60
+ }
61
+ }
62
+
63
+ /** A report for the tokens `from` to `to - 1` of a sentence. */
64
+ export function tokenRange(tokens : readonly Token[], from : number, to : number) : { start : number; end : number } {
65
+ return { start : tokens[from]!.start, end : tokens[to - 1]!.end };
66
+ }
67
+
68
+ /** This function gives each token of each sentence of the unit to `visit`. */
69
+ export function forEachToken(unit : ProseUnit, visit : (tokens : readonly Token[], index : number, sentence : Sentence) => void) : void {
70
+ for (const sentence of unit.sentences) {
71
+ for (let i = 0; i < sentence.tokens.length; i++) visit(sentence.tokens, i, sentence);
72
+ }
73
+ }
@@ -0,0 +1,19 @@
1
+ import { forEachToken, tokenRange, type Rule } from "./rule.js";
2
+
3
+ export const semicolon : Rule = {
4
+ id : "semicolon",
5
+ description : "Find semicolons in prose. Semicolons in code spans are not prose.",
6
+ ste : "8.1",
7
+ defaultSeverity : "error",
8
+ scope : "word",
9
+ check(unit, context) {
10
+ forEachToken(unit, (tokens, i) => {
11
+ const token = tokens[i]!;
12
+ if (token.kind !== "punct" || (token.text !== ";" && token.text !== ";")) return;
13
+ context.report({
14
+ ...tokenRange(tokens, i, i + 1),
15
+ message : "Do not use a semicolon. Write two sentences, or use a comma and \"and\" or \"but\". For a list of items, use a vertical list.",
16
+ });
17
+ });
18
+ },
19
+ };