@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,52 @@
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, path : string = "doc.md", file : SteConfigFile = {}) =>
6
+ new Linter(resolveConfig(file, { root : "/" })).lintText(text, path).filter((finding) => finding.ruleId === "sentence-length");
7
+
8
+ const words = (count : number, first : string = "The") : string => [first, ...Array.from({ length : count - 1 }, (_, i) => `word${i}`)].join(" ");
9
+
10
+ describe("sentence-length", () => {
11
+ it("reports a description of more than 25 words", () => {
12
+ const findings = lint(`${words(26)}.`);
13
+ expect(findings).toHaveLength(1);
14
+ expect(findings[0]).toMatchObject({ line : 1, column : 1, severity : "error" });
15
+ expect(findings[0]!.message).toContain("26 words");
16
+ expect(findings[0]!.message).toContain("25 words");
17
+ });
18
+
19
+ it("accepts a description of 25 words", () => {
20
+ expect(lint(`${words(25)}.`)).toEqual([]);
21
+ });
22
+
23
+ it("uses the limit of 20 words for an instruction", () => {
24
+ expect(lint(`${words(21, "Remove")}.`)[0]!.message).toContain("This instruction has 21 words");
25
+ expect(lint(`${words(20, "Remove")}.`)).toEqual([]);
26
+ });
27
+
28
+ it("counts each code span as one word", () => {
29
+ const code = "`pnpm --filter @lag/ste-lint exec ste-lint --format json --max-warnings 10 README.md docs`";
30
+ expect(lint(`Run ${code} ${words(18, "now")}.`)).toEqual([]);
31
+ expect(lint(`Run ${code} ${words(19, "now")}.`)).toHaveLength(1);
32
+ });
33
+
34
+ it("reports a parenthesis with more than 25 words as a separate sentence", () => {
35
+ const findings = lint(`The value is fine (${words(26, "this")}).`);
36
+ expect(findings).toHaveLength(1);
37
+ expect(findings[0]!.message).toContain("text in parentheses has 26 words");
38
+ });
39
+
40
+ it("does not examine headings or table cells", () => {
41
+ expect(lint(`# ${words(30)}\n\n| ${words(30)} |\n| --- |\n| ${words(30)} |`)).toEqual([]);
42
+ });
43
+
44
+ it("uses the limits of the configuration", () => {
45
+ expect(lint(`${words(11)}.`, "doc.md", { limits : { descriptionWords : 10 } })).toHaveLength(1);
46
+ });
47
+
48
+ it("examines doc comments and the text of block tags", () => {
49
+ expect(lint(`/** ${words(26)}. */\nexport const a = 1;`, "a.ts")).toHaveLength(1);
50
+ expect(lint(`/**\n * @param a - ${words(26)}.\n */\nexport function f(a : number) : number { return a; }`, "a.ts")).toHaveLength(1);
51
+ });
52
+ });
@@ -0,0 +1,32 @@
1
+ import { countWords } from "../text/sentences.js";
2
+ import type { Rule } from "./rule.js";
3
+
4
+ export const sentenceLength : Rule = {
5
+ id : "sentence-length",
6
+ description : "An instruction can have no more than 20 words. A description can have no more than 25 words.",
7
+ ste : "5.1, 6.3, 8.4-8.7",
8
+ defaultSeverity : "error",
9
+ scope : "sentence",
10
+ check(unit, context) {
11
+ const { instructionWords, descriptionWords } = context.config.limits;
12
+ for (const sentence of unit.sentences) {
13
+ const limit = sentence.instruction ? instructionWords : descriptionWords;
14
+ if (sentence.words > limit) {
15
+ const message = sentence.instruction
16
+ ? `This instruction has ${sentence.words} words. Use no more than ${limit} words in an instruction. Divide it into shorter sentences.`
17
+ : `This sentence has ${sentence.words} words. Use no more than ${limit} words in a descriptive sentence. Divide it into shorter sentences.`;
18
+ context.report({ start : sentence.start, end : sentence.end, message });
19
+ }
20
+ for (const inner of sentence.parentheticals) {
21
+ const words = countWords(inner, { properNouns : context.lexicon.properNounPhrases }).words;
22
+ if (words > descriptionWords) {
23
+ context.report({
24
+ start : inner[0]!.start,
25
+ end : inner[inner.length - 1]!.end,
26
+ message : `The text in parentheses has ${words} words. It counts as a separate sentence. Use no more than ${descriptionWords} words, or write it as a sentence after this one.`,
27
+ });
28
+ }
29
+ }
30
+ }
31
+ },
32
+ };
@@ -0,0 +1,81 @@
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 dictionary = parseDictionary({
8
+ entries : [
9
+ ...["the", "a", "is", "it", "you", "and", "to", "of", "in", "on", "this", "with", "for", "can", "not", "do", "when", "file", "that"].map((word) => ({ word })),
10
+ { word : "make", pos : "verb", forms : ["makes", "made"] },
11
+ { word : "valve", pos : "noun" },
12
+ { word : "open", pos : "verb", forms : ["opens", "opened"] },
13
+ { word : "use", pos : "verb", forms : ["uses", "used"] },
14
+ { word : "utilize", pos : "verb", approved : false, alternatives : ["use"] },
15
+ { word : "frob", approved : false },
16
+ ],
17
+ }, "test.json");
18
+
19
+ const lint = (text : string, file : SteConfigFile = {}, path : string = "doc.md") => {
20
+ const config = resolveConfig(file, { root : "/" });
21
+ return new Linter(config, buildLexicon(config, dictionary)).lintText(text, path).filter((finding) => finding.ruleId === "unknown-word");
22
+ };
23
+ const found = (text : string, file : SteConfigFile = {}) : string[] => lint(text, file).map((finding) => text.slice(finding.column - 1, finding.endColumn - 1));
24
+
25
+ describe("unknown-word", () => {
26
+ it("is off without a dictionary", () => {
27
+ const linter = new Linter(resolveConfig({}, { root : "/" }));
28
+ expect(linter.lintText("Zorblat the frimble.", "doc.md").filter((finding) => finding.ruleId === "unknown-word")).toEqual([]);
29
+ });
30
+
31
+ it("reports words that are not in the dictionary or the glossary", () => {
32
+ expect(found("The valve opens the zorblat.")).toEqual(["zorblat"]);
33
+ const findings = lint("The valve opens the zorblat.");
34
+ expect(findings[0]!.severity).toBe("warning");
35
+ expect(findings[0]!.message).toContain("technicalNouns");
36
+ });
37
+
38
+ it("accepts approved forms, noun plurals and possessives, but not other verb forms", () => {
39
+ expect(found("The valves open. It made the valve's file. It opened.")).toEqual([]);
40
+ expect(found("It makings the file.")).toEqual(["makings"]);
41
+ });
42
+
43
+ it("gives the alternatives of a word that is not approved", () => {
44
+ expect(lint("You frob it.")[0]!.message).toContain("Use an approved word, or a different construction.");
45
+ expect(lint("Utilize it.", { rules : { "word-list" : "off" } })[0]!.message).toBe("\"utilize\" is not an approved word. Use \"use\".");
46
+ });
47
+
48
+ it("does not report glossary words, proper nouns, numbers, abbreviations, code or identifiers", () => {
49
+ const file : SteConfigFile = { technicalNouns : ["heartbeat", "worker thread"], technicalVerbs : ["flush"], properNouns : ["Grafana"], allowedWords : ["okay"] };
50
+ expect(found("The heartbeats flush in the worker thread. Grafana is okay. 42 API `zorblat` camelCase v1.2 x86.", file)).toEqual([]);
51
+ });
52
+
53
+ it("does not report a capitalized word after the start of a sentence, and checks each part of a hyphenated word", () => {
54
+ expect(found("The Zorblat opens it.")).toEqual([]);
55
+ expect(found("Zorblat opens it.")).toEqual(["Zorblat"]);
56
+ expect(found("The valve-zorblat opens.")).toEqual(["valve-zorblat"]);
57
+ });
58
+
59
+ it("does not add a finding to a word that another rule reports", () => {
60
+ expect(found("Utilize it.")).toEqual([]);
61
+ });
62
+
63
+ it("reports a word in a sentence, a paragraph or a noun cluster that a different rule reports", () => {
64
+ const all = (text : string, file : SteConfigFile = {}) => {
65
+ const config = resolveConfig(file, { root : "/" });
66
+ return new Linter(config, buildLexicon(config, dictionary)).lintText(text, "doc.md").map((finding) => finding.ruleId);
67
+ };
68
+ // A descriptive sentence with more than 25 words
69
+ const sentence = `The valve opens ${"the file and ".repeat(8)}the zorblat.`;
70
+ expect(all(sentence)).toContain("sentence-length");
71
+ expect(found(sentence)).toEqual(["zorblat"]);
72
+ // A paragraph with more than 6 sentences
73
+ const paragraph = `${"The valve opens the file. ".repeat(6)}The valve opens the zorblat.`;
74
+ expect(all(paragraph)).toContain("paragraph-length");
75
+ expect(found(paragraph)).toEqual(["zorblat"]);
76
+ // More than 3 nouns in sequence: "cache" and "buffer" are common nouns, but not in the dictionary
77
+ const cluster = "Open the valve file cache buffer.";
78
+ expect(all(cluster)).toContain("noun-cluster");
79
+ expect(found(cluster)).toEqual(["cache", "buffer"]);
80
+ });
81
+ });
@@ -0,0 +1,43 @@
1
+ import { isGlossaryWord } from "../lexicon.js";
2
+ import { nextCounted } from "../text/grammar.js";
3
+ import { isAbbreviation, isCapitalized, isIdentifierLike, isNumber, isWord } from "../text/tokens.js";
4
+ import { coveredByPhrases } from "./ing-verb.js";
5
+ import { tokenRange, type Rule } from "./rule.js";
6
+
7
+ /**
8
+ * This rule is active only when a local dictionary is configured. It does
9
+ * not examine numbers, abbreviations, words that look like code or glossary
10
+ * terms. It also does not examine a word with a capital letter after the
11
+ * start of a sentence, because that word is possibly a proper noun.
12
+ */
13
+ export const unknownWord : Rule = {
14
+ id : "unknown-word",
15
+ description : "Find words that are not in the local dictionary and not in the project glossary.",
16
+ ste : "1.1, 1.5, 1.12",
17
+ defaultSeverity : "warning",
18
+ scope : "word",
19
+ check(unit, context) {
20
+ const { lexicon } = context;
21
+ const dictionary = lexicon.dictionary;
22
+ if (dictionary === null) return;
23
+ for (const sentence of unit.sentences) {
24
+ const tokens = sentence.tokens;
25
+ const covered = coveredByPhrases(tokens, lexicon.glossaryPhrases);
26
+ const first = nextCounted(tokens, 0);
27
+ tokens.forEach((token, i) => {
28
+ if (!isWord(token) || covered.has(i)) return;
29
+ if (isNumber(token) || isAbbreviation(token) || isIdentifierLike(token) || /\d/.test(token.text)) return;
30
+ if (i !== first && isCapitalized(token)) return;
31
+ for (const part of token.lower.split("-")) {
32
+ if (part.length === 0 || isGlossaryWord(part, lexicon)) continue;
33
+ const lookup = dictionary.lookup(part);
34
+ if (lookup.status === "approved") continue;
35
+ const message = lookup.status === "not-approved"
36
+ ? `"${part}" is not an approved word. ${lookup.alternatives.length > 0 ? `Use ${lookup.alternatives.map((word) => `"${word}"`).join(" or ")}.` : "Use an approved word, or a different construction."}`
37
+ : `"${part}" is not in the dictionary or the project glossary. Use an approved word. If "${part}" is a technical term, add it to technicalNouns or technicalVerbs.`;
38
+ context.report({ ...tokenRange(tokens, i, i + 1), message });
39
+ }
40
+ });
41
+ }
42
+ },
43
+ };
@@ -0,0 +1,68 @@
1
+ import { isClauseStart, isVerbUse } from "../text/grammar.js";
2
+ import { PhraseMatcher, splitPhrase } from "../text/phrases.js";
3
+ import { isWord, type Token } from "../text/tokens.js";
4
+ import type { WordListEntry } from "../word-list.js";
5
+
6
+ export type WordListMatch = {
7
+ readonly entry : WordListEntry;
8
+ readonly from : number;
9
+ readonly to : number;
10
+ };
11
+
12
+ /** Words before "see" that show the meaning "refer to": "(see X)", "For details, see X", "See also X". */
13
+ const REFERENCE_BEFORE = new Set(["also", "please", "and", "or", "then"]);
14
+ const REFERENCE_PUNCTUATION = new Set(["(", ",", ":", ";", "—", "–", "-"]);
15
+
16
+ /** True when "see" at `index` tells the reader to look at other text, not to see with the eyes. */
17
+ export function isReferenceSee(tokens : readonly Token[], index : number) : boolean {
18
+ const previous = tokens[index - 1];
19
+ if (previous === undefined || isClauseStart(tokens, index)) return true;
20
+ if (previous.kind === "punct") return REFERENCE_PUNCTUATION.has(previous.text);
21
+ return isWord(previous) && REFERENCE_BEFORE.has(previous.lower);
22
+ }
23
+
24
+ /** This class finds the entries of the word list in a sentence. */
25
+ export class WordListMatcher {
26
+ private readonly phrases = new PhraseMatcher<WordListEntry>();
27
+ /** The single words of the list. Other rules skip these words, so that a word gets one finding. */
28
+ readonly words : ReadonlySet<string>;
29
+
30
+ constructor(entries : readonly WordListEntry[]) {
31
+ const words = new Set<string>();
32
+ for (const entry of entries) {
33
+ for (const match of entry.match) {
34
+ this.phrases.add(match, entry);
35
+ const parts = splitPhrase(match);
36
+ if (parts.length === 1) words.add(parts[0]!);
37
+ }
38
+ }
39
+ this.words = words;
40
+ }
41
+
42
+ /**
43
+ * The matches in `tokens`. The longest phrase wins. `skipWord` tells the
44
+ * matcher to ignore a single word, for example a glossary term.
45
+ */
46
+ find(tokens : readonly Token[], options : { readonly verbs : ReadonlySet<string>; readonly skipWord : (word : string) => boolean }) : WordListMatch[] {
47
+ const matches : WordListMatch[] = [];
48
+ let i = 0;
49
+ while (i < tokens.length) {
50
+ const match = this.phrases.match(tokens, i);
51
+ if (match === null) {
52
+ i++;
53
+ continue;
54
+ }
55
+ const entry = match.value;
56
+ const skip = (match.length === 1 && options.skipWord(tokens[i]!.lower))
57
+ || (entry.verbOnly && !isVerbUse(tokens, i, { verbs : options.verbs }))
58
+ || (entry.context === "reference" && !isReferenceSee(tokens, i));
59
+ if (skip) {
60
+ i++;
61
+ continue;
62
+ }
63
+ matches.push({ entry, from : i, to : i + match.length });
64
+ i += match.length;
65
+ }
66
+ return matches;
67
+ }
68
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveConfig, type SteConfigFile } from "../config.js";
3
+ import { Linter } from "../lint.js";
4
+ import { WordListMatcher } from "./word-list-matcher.js";
5
+ import { DEFAULT_WORD_LIST } from "../word-list.js";
6
+
7
+ const lint = (text : string, file : SteConfigFile = {}, path : string = "doc.md") =>
8
+ new Linter(resolveConfig(file, { root : "/" })).lintText(text, path).filter((finding) => finding.ruleId === "word-list");
9
+ const found = (text : string, file : SteConfigFile = {}) : string[] => lint(text, file).map((finding) => text.slice(finding.column - 1, finding.endColumn - 1));
10
+
11
+ describe("word-list", () => {
12
+ it("reports long words and gives the plain-language word", () => {
13
+ const findings = lint("Utilize the cache to initiate the run and ensure that it facilitates the work via the API.");
14
+ expect(findings.map((finding) => finding.message)).toEqual([
15
+ "Write \"use\", not \"Utilize\".",
16
+ "Write \"start\", not \"initiate\".",
17
+ "Write \"make sure\", not \"ensure\". Write \"that\" after it: \"make sure that ...\".",
18
+ "Write \"help\" or \"make ... easier\", not \"facilitates\".",
19
+ "Write \"through\" or \"with\", not \"via\".",
20
+ ]);
21
+ expect(findings.every((finding) => finding.severity === "error")).toBe(true);
22
+ });
23
+
24
+ it("reports phrases, and the longest phrase wins", () => {
25
+ expect(found("Prior to the run, and in order to stop, due to the fact that it fails, due to the load.")).toEqual([
26
+ "Prior to", "in order to", "due to the fact that", "due to",
27
+ ]);
28
+ expect(found("However, it works. Therefore, it stops. Ask whether or not it runs, or whether it stops. Tools such as this.")).toEqual([
29
+ "However", "Therefore", "whether or not", "runs", "whether", "such as",
30
+ ]);
31
+ });
32
+
33
+ it("reports the verbs that have simpler words", () => {
34
+ expect(found("It allows a restart. It requires a key. It provides data, provided that it runs. It executes code. It creates files. Kill it.")).toEqual([
35
+ "allows", "requires", "provides", "provided that", "runs", "executes", "creates", "Kill",
36
+ ]);
37
+ });
38
+
39
+ it("reports run, call, return and display only where they are verbs", () => {
40
+ expect(found("Run the tests. The monitor runs every second. Call `stop()`. It returns the value. The page displays a chart.")).toEqual([
41
+ "Run", "runs", "Call", "returns", "displays",
42
+ ]);
43
+ expect(found("A test run takes time. Each call is fast. The return value is 0. The display is dark.")).toEqual([]);
44
+ });
45
+
46
+ it("reports \"see\" only where it means \"refer to\"", () => {
47
+ expect(found("See the guide. For details, see `x`. Read it (see below). You can see the chart. I want to see it.")).toEqual([
48
+ "See", "see", "see",
49
+ ]);
50
+ });
51
+
52
+ it("reports filler words as warnings, and gives the time and negation alternatives", () => {
53
+ const findings = lint("It is simple. Simply run it. It is easily done. It is just a test. Please wait. It is currently off. Now it runs. It never stops.");
54
+ expect(findings.map((finding) => [finding.severity, finding.message.split(".")[0]])).toEqual([
55
+ ["warning", "Remove \"simple\""],
56
+ ["warning", "Remove \"Simply\""],
57
+ ["warning", "Write \"start\", \"starts\" or \"operates\", not \"run\""],
58
+ ["warning", "Remove \"easily\""],
59
+ ["warning", "Write \"only\", not \"just\""],
60
+ ["warning", "Remove \"Please\""],
61
+ ["error", "Write \"at this time\", not \"currently\""],
62
+ ["error", "Write \"at this time\", not \"Now\""],
63
+ ["warning", "Write \"start\", \"starts\" or \"operates\", not \"runs\""],
64
+ ["error", "Write \"do not\", not \"never\""],
65
+ ]);
66
+ });
67
+
68
+ it("does not report \"approximately\" or words in code", () => {
69
+ expect(found("It takes approximately 5 ms. Use `utilize()` and `createMonitor`.")).toEqual([]);
70
+ });
71
+
72
+ it("does not report a word that the glossary permits", () => {
73
+ expect(found("Run the tests.", { technicalVerbs : ["run"] })).toEqual([]);
74
+ expect(found("It creates a file.", { allowedWords : ["creates"] })).toEqual([]);
75
+ });
76
+
77
+ it("lets the configuration change, add and remove entries", () => {
78
+ const file : SteConfigFile = {
79
+ wordList : [
80
+ { id : "via", severity : "warning", suggest : "\"by\"" },
81
+ { match : ["blazingly fast"], suggest : "\"fast\"" },
82
+ { match : "simply", severity : "off" },
83
+ { id : "utilize", note : "Short words are better." },
84
+ ],
85
+ };
86
+ const findings = lint("Go via the API. It is blazingly fast. Simply utilize it.", file);
87
+ expect(findings.map((finding) => [finding.severity, finding.message])).toEqual([
88
+ ["warning", "Write \"by\", not \"via\"."],
89
+ ["error", "Write \"fast\", not \"blazingly fast\"."],
90
+ ["error", "Write \"use\", not \"utilize\". Short words are better."],
91
+ ]);
92
+ });
93
+
94
+ it("lets the rule setting override the severity of all entries", () => {
95
+ expect(lint("Utilize it.", { rules : { "word-list" : "warning" } })[0]!.severity).toBe("warning");
96
+ expect(lint("Utilize it.", { rules : { "word-list" : "off" } })).toEqual([]);
97
+ });
98
+
99
+ it("examines doc comments", () => {
100
+ expect(lint("/** This function returns the value. */\nexport const a = 1;", {}, "a.ts")).toHaveLength(1);
101
+ });
102
+ });
103
+
104
+ describe("WordListMatcher", () => {
105
+ it("gives the single words of the list, for the other rules", () => {
106
+ const matcher = new WordListMatcher(DEFAULT_WORD_LIST);
107
+ expect(matcher.words.has("running")).toBe(true);
108
+ expect(matcher.words.has("in order to")).toBe(false);
109
+ });
110
+ });
@@ -0,0 +1,25 @@
1
+ import { isGlossaryWord } from "../lexicon.js";
2
+ import { surface, tokenRange, type Rule } from "./rule.js";
3
+
4
+ export const wordList : Rule = {
5
+ id : "word-list",
6
+ description : "Find the words and phrases of the project word list, and give the plain-language words to use instead.",
7
+ ste : "1.1, 9.1",
8
+ defaultSeverity : "error",
9
+ scope : "word",
10
+ check(unit, context) {
11
+ for (const sentence of unit.sentences) {
12
+ const matches = context.wordList.find(sentence.tokens, {
13
+ verbs : context.lexicon.verbs,
14
+ skipWord : (word) => isGlossaryWord(word, context.lexicon),
15
+ });
16
+ for (const match of matches) {
17
+ const found = surface(sentence.tokens, match.from, match.to);
18
+ const { entry } = match;
19
+ let message = entry.suggest === null ? `Remove "${found}".` : `Write ${entry.suggest}, not "${found}".`;
20
+ if (entry.note !== null) message += ` ${entry.note}`;
21
+ context.report({ ...tokenRange(sentence.tokens, match.from, match.to), message, severity : entry.severity });
22
+ }
23
+ }
24
+ },
25
+ };
@@ -0,0 +1,189 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { Linter } from "./lint.js";
6
+ import { parseArgs, runCli, UsageError, USAGE } from "./run-cli.js";
7
+
8
+ let root : string;
9
+
10
+ beforeEach(() => {
11
+ root = mkdtempSync(join(tmpdir(), "ste-cli-"));
12
+ writeFileSync(join(root, "ste.config.json"), JSON.stringify({ include : ["README.md", "docs/**/*.md", "src/**/*.ts"] }));
13
+ mkdirSync(join(root, "docs"));
14
+ mkdirSync(join(root, "src"));
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(root, { recursive : true, force : true });
19
+ });
20
+
21
+ function run(argv : string[], env : Record<string, string | undefined> = {}) : { code : number; stdout : string; stderr : string } {
22
+ let stdout = "";
23
+ let stderr = "";
24
+ const code = runCli(argv, {
25
+ cwd : root,
26
+ env,
27
+ stdout : (text) => { stdout += text; },
28
+ stderr : (text) => { stderr += text; },
29
+ });
30
+ return { code, stdout, stderr };
31
+ }
32
+
33
+ describe("parseArgs", () => {
34
+ it("reads the options and the patterns", () => {
35
+ expect(parseArgs(["--format", "json", "--max-warnings=3", "--config", "c.json", "a.md", "docs/**"])).toEqual({
36
+ patterns : ["a.md", "docs/**"],
37
+ format : "json",
38
+ maxWarnings : 3,
39
+ configPath : "c.json",
40
+ help : false,
41
+ });
42
+ expect(parseArgs(["-h"]).help).toBe(true);
43
+ expect(parseArgs(["--format=text", "-"]).patterns).toEqual(["-"]);
44
+ });
45
+
46
+ it("rejects incorrect options", () => {
47
+ expect(() => parseArgs(["--format", "xml"])).toThrow(UsageError);
48
+ expect(() => parseArgs(["--max-warnings", "-1"])).toThrow("integer");
49
+ expect(() => parseArgs(["--max-warnings", "many"])).toThrow("integer");
50
+ expect(() => parseArgs(["--config"])).toThrow("needs a value");
51
+ expect(() => parseArgs(["--nope"])).toThrow("not a known option");
52
+ });
53
+ });
54
+
55
+ describe("runCli", () => {
56
+ it("shows the help", () => {
57
+ const result = run(["--help"]);
58
+ expect(result.code).toBe(0);
59
+ expect(result.stdout).toBe(USAGE);
60
+ expect(USAGE).toContain("automated approximation");
61
+ });
62
+
63
+ it("exits with 0 when there are no problems", () => {
64
+ writeFileSync(join(root, "README.md"), "The monitor measures lag.\n");
65
+ const result = run([]);
66
+ expect(result.code).toBe(0);
67
+ expect(result.stdout).toContain("No problems found in 1 file.");
68
+ });
69
+
70
+ it("exits with 1 when there are errors, and lints the default patterns of the configuration", () => {
71
+ writeFileSync(join(root, "README.md"), "You should stop.\n");
72
+ writeFileSync(join(root, "docs", "guide.md"), "Text; more text.\n");
73
+ writeFileSync(join(root, "src", "a.ts"), "/** Returns the value. */\nexport const a = 1;\n");
74
+ writeFileSync(join(root, "notes.txt"), "You should stop.\n");
75
+ const result = run([]);
76
+ expect(result.code).toBe(1);
77
+ expect(result.stdout).toContain("README.md\n 1:5");
78
+ expect(result.stdout).toContain("docs/guide.md");
79
+ expect(result.stdout).toContain("src/a.ts");
80
+ expect(result.stdout).not.toContain("notes.txt");
81
+ });
82
+
83
+ it("writes JSON", () => {
84
+ writeFileSync(join(root, "README.md"), "Our tool.\n");
85
+ const result = run(["--format", "json"]);
86
+ expect(result.code).toBe(0);
87
+ const json = JSON.parse(result.stdout) as { summary : { warnings : number }; findings : Array<{ file : string; ruleId : string }> };
88
+ expect(json.summary.warnings).toBe(1);
89
+ expect(json.findings[0]).toMatchObject({ file : "README.md", ruleId : "first-person" });
90
+ });
91
+
92
+ it("exits with 1 when there are more warnings than --max-warnings", () => {
93
+ writeFileSync(join(root, "README.md"), "Our tool. Our page.\n");
94
+ expect(run(["--max-warnings", "2"]).code).toBe(0);
95
+ const result = run(["--max-warnings", "1"]);
96
+ expect(result.code).toBe(1);
97
+ expect(result.stderr).toContain("2 warnings. The maximum is 1.");
98
+ expect(run(["--max-warnings", "1", "--format", "json"]).stderr).toBe("");
99
+ });
100
+
101
+ it("lints the patterns of the command line, relative to the working directory", () => {
102
+ writeFileSync(join(root, "other.md"), "You should stop.\n");
103
+ writeFileSync(join(root, "README.md"), "You should stop.\n");
104
+ const result = run(["other.md"]);
105
+ expect(result.stdout).toContain("other.md");
106
+ expect(result.stdout).not.toContain("README.md");
107
+ });
108
+
109
+ it("applies the ignore patterns of the configuration relative to its directory, also from a subdirectory", () => {
110
+ writeFileSync(join(root, "ste.config.json"), JSON.stringify({ include : ["docs/**/*.md"], ignore : ["docs/legacy/**"] }));
111
+ mkdirSync(join(root, "docs", "legacy"));
112
+ writeFileSync(join(root, "docs", "legacy", "old.md"), "You should stop.\n");
113
+ writeFileSync(join(root, "docs", "guide.md"), "You should stop.\n");
114
+ let stdout = "";
115
+ const code = runCli(["**/*.md"], { cwd : join(root, "docs"), env : {}, stdout : (text) => { stdout += text; }, stderr : () => {} });
116
+ expect(code).toBe(1);
117
+ expect(stdout).toContain("guide.md");
118
+ expect(stdout).not.toContain("old.md");
119
+ });
120
+
121
+ it("exits with 2 when it cannot read a directory", () => {
122
+ writeFileSync(join(root, "README.md"), "Text.\n");
123
+ // The walk reads README.md as a directory, and readdirSync fails with ENOTDIR
124
+ const result = run(["README.md/**/*.md"]);
125
+ expect(result.code).toBe(2);
126
+ expect(result.stderr).toMatch(/^ste-lint: cannot read the files: .*ENOTDIR/);
127
+ });
128
+
129
+ it("exits with 2 when the patterns match no files, and with 0 when the default patterns match no files", () => {
130
+ expect(run(["missing/*.md"])).toMatchObject({ code : 2, stderr : "ste-lint: no files match the patterns.\n" });
131
+ expect(run([])).toMatchObject({ code : 0, stderr : "ste-lint: no files to examine.\n" });
132
+ });
133
+
134
+ it("exits with 2 for an incorrect command line or configuration", () => {
135
+ expect(run(["--format", "xml"]).code).toBe(2);
136
+ writeFileSync(join(root, "ste.config.json"), JSON.stringify({ rules : { nope : "error" } }));
137
+ const result = run([]);
138
+ expect(result.code).toBe(2);
139
+ expect(result.stderr).toContain("is not a known rule");
140
+ });
141
+
142
+ it("uses a dictionary from STE_DICTIONARY", () => {
143
+ writeFileSync(join(root, "README.md"), "The valve opens the zorblat.\n");
144
+ writeFileSync(join(root, "dictionary.json"), JSON.stringify(["the", "valve", "opens"]));
145
+ const result = run(["--format", "json"], { STE_DICTIONARY : "dictionary.json" });
146
+ const json = JSON.parse(result.stdout) as { findings : Array<{ ruleId : string; message : string }> };
147
+ expect(json.findings.map((finding) => finding.ruleId)).toEqual(["unknown-word"]);
148
+ expect(json.findings[0]!.message).toContain("\"zorblat\"");
149
+ });
150
+
151
+ it("tells the user when the dictionary file does not exist, and continues without it", () => {
152
+ writeFileSync(join(root, "README.md"), "The valve opens the zorblat.\n");
153
+ const result = run([], { STE_DICTIONARY : "missing.json" });
154
+ expect(result.code).toBe(0);
155
+ expect(result.stderr).toContain("does not exist. The unknown-word rule is off.");
156
+ writeFileSync(join(root, "ste.config.json"), JSON.stringify({ include : ["README.md"], dictionaryPath : "missing.json" }));
157
+ expect(run([]).stderr).toContain("(from dictionaryPath)");
158
+ });
159
+
160
+ it("exits with 2 for a dictionary file that is not valid", () => {
161
+ writeFileSync(join(root, "README.md"), "Text.\n");
162
+ writeFileSync(join(root, "dictionary.json"), "{");
163
+ expect(run([], { STE_DICTIONARY : "dictionary.json" }).code).toBe(2);
164
+ });
165
+
166
+ it("exits with 2 when it cannot examine a file, and examines the other files", () => {
167
+ writeFileSync(join(root, "README.md"), "You should stop.\n");
168
+ writeFileSync(join(root, "docs", "guide.md"), "Text.\n");
169
+ const original = Linter.prototype.lintFile;
170
+ const spy = vi.spyOn(Linter.prototype, "lintFile").mockImplementation(function (this : Linter, path : string, shown? : string) {
171
+ if (path.endsWith("guide.md")) throw new Error("cannot read");
172
+ return original.call(this, path, shown);
173
+ });
174
+ try {
175
+ const result = run([]);
176
+ expect(result.code).toBe(2);
177
+ expect(result.stderr).toContain("cannot examine");
178
+ expect(result.stderr).toContain("cannot read");
179
+ expect(result.stdout).toContain("README.md");
180
+ } finally {
181
+ spy.mockRestore();
182
+ }
183
+ });
184
+
185
+ it("does not catch errors that are not configuration errors", () => {
186
+ const env = new Proxy({}, { get : () => { throw new TypeError("unexpected"); } });
187
+ expect(() => runCli([], { cwd : root, env, stdout : () => {}, stderr : () => {} })).toThrow("unexpected");
188
+ });
189
+ });