@mark1russell7/ste-lint 0.0.0-bootstrap.0 → 0.1.1

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 (227) hide show
  1. package/README.md +224 -1
  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.d.ts +103 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +251 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/dictionary.d.ts +74 -0
  12. package/dist/dictionary.d.ts.map +1 -0
  13. package/dist/dictionary.js +169 -0
  14. package/dist/dictionary.js.map +1 -0
  15. package/dist/files.d.ts +35 -0
  16. package/dist/files.d.ts.map +1 -0
  17. package/dist/files.js +181 -0
  18. package/dist/files.js.map +1 -0
  19. package/dist/format.d.ts +21 -0
  20. package/dist/format.d.ts.map +1 -0
  21. package/dist/format.js +61 -0
  22. package/dist/format.js.map +1 -0
  23. package/dist/index.d.ts +17 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +17 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/lexicon.d.ts +33 -0
  28. package/dist/lexicon.d.ts.map +1 -0
  29. package/dist/lexicon.js +71 -0
  30. package/dist/lexicon.js.map +1 -0
  31. package/dist/lint.d.ts +28 -0
  32. package/dist/lint.d.ts.map +1 -0
  33. package/dist/lint.js +132 -0
  34. package/dist/lint.js.map +1 -0
  35. package/dist/rules/about-quantity.d.ts +3 -0
  36. package/dist/rules/about-quantity.d.ts.map +1 -0
  37. package/dist/rules/about-quantity.js +34 -0
  38. package/dist/rules/about-quantity.js.map +1 -0
  39. package/dist/rules/index.d.ts +8 -0
  40. package/dist/rules/index.d.ts.map +1 -0
  41. package/dist/rules/index.js +35 -0
  42. package/dist/rules/index.js.map +1 -0
  43. package/dist/rules/ing-verb.d.ts +9 -0
  44. package/dist/rules/ing-verb.d.ts.map +1 -0
  45. package/dist/rules/ing-verb.js +60 -0
  46. package/dist/rules/ing-verb.js.map +1 -0
  47. package/dist/rules/latin-abbreviation.d.ts +3 -0
  48. package/dist/rules/latin-abbreviation.d.ts.map +1 -0
  49. package/dist/rules/latin-abbreviation.js +48 -0
  50. package/dist/rules/latin-abbreviation.js.map +1 -0
  51. package/dist/rules/missing-subject.d.ts +16 -0
  52. package/dist/rules/missing-subject.d.ts.map +1 -0
  53. package/dist/rules/missing-subject.js +138 -0
  54. package/dist/rules/missing-subject.js.map +1 -0
  55. package/dist/rules/modal-verb.d.ts +3 -0
  56. package/dist/rules/modal-verb.d.ts.map +1 -0
  57. package/dist/rules/modal-verb.js +72 -0
  58. package/dist/rules/modal-verb.js.map +1 -0
  59. package/dist/rules/noun-cluster.d.ts +17 -0
  60. package/dist/rules/noun-cluster.d.ts.map +1 -0
  61. package/dist/rules/noun-cluster.js +83 -0
  62. package/dist/rules/noun-cluster.js.map +1 -0
  63. package/dist/rules/paragraph-length.d.ts +3 -0
  64. package/dist/rules/paragraph-length.d.ts.map +1 -0
  65. package/dist/rules/paragraph-length.js +19 -0
  66. package/dist/rules/paragraph-length.js.map +1 -0
  67. package/dist/rules/passive-instruction.d.ts +15 -0
  68. package/dist/rules/passive-instruction.d.ts.map +1 -0
  69. package/dist/rules/passive-instruction.js +38 -0
  70. package/dist/rules/passive-instruction.js.map +1 -0
  71. package/dist/rules/pronouns.d.ts +4 -0
  72. package/dist/rules/pronouns.d.ts.map +1 -0
  73. package/dist/rules/pronouns.js +57 -0
  74. package/dist/rules/pronouns.js.map +1 -0
  75. package/dist/rules/rule.d.ts +46 -0
  76. package/dist/rules/rule.d.ts.map +1 -0
  77. package/dist/rules/rule.js +31 -0
  78. package/dist/rules/rule.js.map +1 -0
  79. package/dist/rules/semicolon.d.ts +3 -0
  80. package/dist/rules/semicolon.d.ts.map +1 -0
  81. package/dist/rules/semicolon.js +20 -0
  82. package/dist/rules/semicolon.js.map +1 -0
  83. package/dist/rules/sentence-length.d.ts +3 -0
  84. package/dist/rules/sentence-length.d.ts.map +1 -0
  85. package/dist/rules/sentence-length.js +31 -0
  86. package/dist/rules/sentence-length.js.map +1 -0
  87. package/dist/rules/unknown-word.d.ts +9 -0
  88. package/dist/rules/unknown-word.d.ts.map +1 -0
  89. package/dist/rules/unknown-word.js +49 -0
  90. package/dist/rules/unknown-word.js.map +1 -0
  91. package/dist/rules/word-list-matcher.d.ts +25 -0
  92. package/dist/rules/word-list-matcher.d.ts.map +1 -0
  93. package/dist/rules/word-list-matcher.js +60 -0
  94. package/dist/rules/word-list-matcher.js.map +1 -0
  95. package/dist/rules/word-list.d.ts +3 -0
  96. package/dist/rules/word-list.d.ts.map +1 -0
  97. package/dist/rules/word-list.js +26 -0
  98. package/dist/rules/word-list.js.map +1 -0
  99. package/dist/run-cli.d.ts +27 -0
  100. package/dist/run-cli.d.ts.map +1 -0
  101. package/dist/run-cli.js +176 -0
  102. package/dist/run-cli.js.map +1 -0
  103. package/dist/source/blocks.d.ts +55 -0
  104. package/dist/source/blocks.d.ts.map +1 -0
  105. package/dist/source/blocks.js +464 -0
  106. package/dist/source/blocks.js.map +1 -0
  107. package/dist/source/inline.d.ts +42 -0
  108. package/dist/source/inline.d.ts.map +1 -0
  109. package/dist/source/inline.js +396 -0
  110. package/dist/source/inline.js.map +1 -0
  111. package/dist/source/jsx.d.ts +18 -0
  112. package/dist/source/jsx.d.ts.map +1 -0
  113. package/dist/source/jsx.js +137 -0
  114. package/dist/source/jsx.js.map +1 -0
  115. package/dist/source/lines.d.ts +34 -0
  116. package/dist/source/lines.d.ts.map +1 -0
  117. package/dist/source/lines.js +83 -0
  118. package/dist/source/lines.js.map +1 -0
  119. package/dist/source/markdown.d.ts +23 -0
  120. package/dist/source/markdown.d.ts.map +1 -0
  121. package/dist/source/markdown.js +42 -0
  122. package/dist/source/markdown.js.map +1 -0
  123. package/dist/source/suppressions.d.ts +38 -0
  124. package/dist/source/suppressions.d.ts.map +1 -0
  125. package/dist/source/suppressions.js +69 -0
  126. package/dist/source/suppressions.js.map +1 -0
  127. package/dist/source/tsdoc.d.ts +40 -0
  128. package/dist/source/tsdoc.d.ts.map +1 -0
  129. package/dist/source/tsdoc.js +176 -0
  130. package/dist/source/tsdoc.js.map +1 -0
  131. package/dist/text/grammar.d.ts +91 -0
  132. package/dist/text/grammar.d.ts.map +1 -0
  133. package/dist/text/grammar.js +289 -0
  134. package/dist/text/grammar.js.map +1 -0
  135. package/dist/text/phrases.d.ts +30 -0
  136. package/dist/text/phrases.d.ts.map +1 -0
  137. package/dist/text/phrases.js +65 -0
  138. package/dist/text/phrases.js.map +1 -0
  139. package/dist/text/sentences.d.ts +45 -0
  140. package/dist/text/sentences.d.ts.map +1 -0
  141. package/dist/text/sentences.js +141 -0
  142. package/dist/text/sentences.js.map +1 -0
  143. package/dist/text/tokens.d.ts +35 -0
  144. package/dist/text/tokens.d.ts.map +1 -0
  145. package/dist/text/tokens.js +113 -0
  146. package/dist/text/tokens.js.map +1 -0
  147. package/dist/text/words.d.ts +58 -0
  148. package/dist/text/words.d.ts.map +1 -0
  149. package/dist/text/words.js +159 -0
  150. package/dist/text/words.js.map +1 -0
  151. package/dist/types.d.ts +18 -0
  152. package/dist/types.d.ts.map +1 -0
  153. package/dist/types.js +20 -0
  154. package/dist/types.js.map +1 -0
  155. package/dist/units.d.ts +47 -0
  156. package/dist/units.d.ts.map +1 -0
  157. package/dist/units.js +42 -0
  158. package/dist/units.js.map +1 -0
  159. package/dist/word-list.d.ts +34 -0
  160. package/dist/word-list.d.ts.map +1 -0
  161. package/dist/word-list.js +90 -0
  162. package/dist/word-list.js.map +1 -0
  163. package/package.json +41 -4
  164. package/src/cli.ts +9 -0
  165. package/src/config.test.ts +188 -0
  166. package/src/config.ts +318 -0
  167. package/src/dictionary.test.ts +82 -0
  168. package/src/dictionary.ts +177 -0
  169. package/src/files.test.ts +107 -0
  170. package/src/files.ts +191 -0
  171. package/src/format.ts +78 -0
  172. package/src/index.test.ts +28 -0
  173. package/src/index.ts +34 -0
  174. package/src/lexicon.ts +92 -0
  175. package/src/lint.test.ts +182 -0
  176. package/src/lint.ts +135 -0
  177. package/src/rules/about-quantity.ts +33 -0
  178. package/src/rules/index.ts +40 -0
  179. package/src/rules/ing-verb.test.ts +48 -0
  180. package/src/rules/ing-verb.ts +54 -0
  181. package/src/rules/latin-abbreviation.ts +46 -0
  182. package/src/rules/missing-subject.test.ts +53 -0
  183. package/src/rules/missing-subject.ts +129 -0
  184. package/src/rules/modal-verb.test.ts +45 -0
  185. package/src/rules/modal-verb.ts +72 -0
  186. package/src/rules/noun-cluster.test.ts +49 -0
  187. package/src/rules/noun-cluster.ts +82 -0
  188. package/src/rules/paragraph-length.test.ts +30 -0
  189. package/src/rules/paragraph-length.ts +19 -0
  190. package/src/rules/passive-instruction.test.ts +38 -0
  191. package/src/rules/passive-instruction.ts +37 -0
  192. package/src/rules/pronouns.test.ts +38 -0
  193. package/src/rules/pronouns.ts +54 -0
  194. package/src/rules/punctuation.test.ts +59 -0
  195. package/src/rules/rule.ts +73 -0
  196. package/src/rules/semicolon.ts +19 -0
  197. package/src/rules/sentence-length.test.ts +52 -0
  198. package/src/rules/sentence-length.ts +32 -0
  199. package/src/rules/unknown-word.test.ts +81 -0
  200. package/src/rules/unknown-word.ts +43 -0
  201. package/src/rules/word-list-matcher.ts +68 -0
  202. package/src/rules/word-list.test.ts +110 -0
  203. package/src/rules/word-list.ts +25 -0
  204. package/src/run-cli.test.ts +189 -0
  205. package/src/run-cli.ts +193 -0
  206. package/src/source/blocks.ts +534 -0
  207. package/src/source/inline.test.ts +92 -0
  208. package/src/source/inline.ts +416 -0
  209. package/src/source/jsx.test.ts +46 -0
  210. package/src/source/jsx.ts +147 -0
  211. package/src/source/lines.test.ts +51 -0
  212. package/src/source/lines.ts +91 -0
  213. package/src/source/markdown.test.ts +210 -0
  214. package/src/source/markdown.ts +49 -0
  215. package/src/source/suppressions.ts +79 -0
  216. package/src/source/tsdoc.test.ts +137 -0
  217. package/src/source/tsdoc.ts +189 -0
  218. package/src/text/grammar.ts +304 -0
  219. package/src/text/phrases.ts +72 -0
  220. package/src/text/sentences.ts +148 -0
  221. package/src/text/text.test.ts +262 -0
  222. package/src/text/tokens.ts +135 -0
  223. package/src/text/words.ts +183 -0
  224. package/src/types.ts +39 -0
  225. package/src/units.ts +86 -0
  226. package/src/word-list.ts +128 -0
  227. package/ste.config.schema.json +87 -0
@@ -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 &   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
+ };
@@ -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
+ };