@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
package/src/config.ts ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * The configuration file `ste.config.json`, and the configuration that the
3
+ * linter uses after it reads the file, the environment and the defaults.
4
+ */
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { isRuleId, RULE_IDS, type RuleId, type RuleSetting, type Severity } from "./types.js";
10
+ import { DEFAULT_WORD_LIST, type WordListEntry } from "./word-list.js";
11
+
12
+ export const CONFIG_FILE_NAME = "ste.config.json";
13
+
14
+ /** The environment variable that gives the path of the local dictionary. It has priority over `dictionaryPath`. */
15
+ export const DICTIONARY_ENV = "STE_DICTIONARY";
16
+
17
+ /** An entry of `wordList` in the configuration file. */
18
+ export type WordListEntryConfig = {
19
+ /** The ID of a default entry to change. A new entry gets the first word of `match` as its ID. */
20
+ readonly id? : string;
21
+ readonly match? : string | readonly string[];
22
+ readonly suggest? : string | null;
23
+ readonly note? : string | null;
24
+ readonly severity? : RuleSetting;
25
+ readonly verbOnly? : boolean;
26
+ };
27
+
28
+ export type LimitsConfig = {
29
+ /** The maximum number of words in an instruction. Default: 20 (STE Rule 5.1). */
30
+ readonly instructionWords? : number;
31
+ /** The maximum number of words in a description. Default: 25 (STE Rule 6.3). */
32
+ readonly descriptionWords? : number;
33
+ /** The maximum number of sentences in a paragraph. Default: 6 (STE Rule 6.6). */
34
+ readonly paragraphSentences? : number;
35
+ /** The maximum number of nouns in a noun cluster. Default: 3 (STE Rule 2.1). */
36
+ readonly nounClusterNouns? : number;
37
+ };
38
+
39
+ /** The contents of `ste.config.json`. All properties are optional. */
40
+ export type SteConfigFile = {
41
+ readonly $schema? : string;
42
+ readonly include? : readonly string[];
43
+ readonly ignore? : readonly string[];
44
+ readonly technicalNouns? : readonly string[];
45
+ readonly technicalVerbs? : readonly string[];
46
+ readonly properNouns? : readonly string[];
47
+ readonly allowedWords? : readonly string[];
48
+ readonly rules? : Readonly<Record<string, RuleSetting>>;
49
+ readonly wordList? : readonly WordListEntryConfig[];
50
+ readonly limits? : LimitsConfig;
51
+ readonly dictionaryPath? : string | null;
52
+ };
53
+
54
+ export type Limits = Required<LimitsConfig>;
55
+
56
+ export type ResolvedConfig = {
57
+ /** The directory that `include` and `ignore` are relative to. */
58
+ readonly root : string;
59
+ /** The configuration file, or null when the linter uses the defaults. */
60
+ readonly configFile : string | null;
61
+ readonly include : readonly string[];
62
+ readonly ignore : readonly string[];
63
+ readonly technicalNouns : readonly string[];
64
+ readonly technicalVerbs : readonly string[];
65
+ readonly properNouns : readonly string[];
66
+ readonly allowedWords : readonly string[];
67
+ /** The rule settings from the configuration. A rule without a setting uses its default severity. */
68
+ readonly rules : Readonly<Partial<Record<RuleId, RuleSetting>>>;
69
+ readonly wordList : readonly WordListEntry[];
70
+ readonly limits : Limits;
71
+ /** The absolute path of the local dictionary, or null. */
72
+ readonly dictionaryPath : string | null;
73
+ /** Where the dictionary path came from. */
74
+ readonly dictionarySource : "env" | "config" | null;
75
+ };
76
+
77
+ export const DEFAULT_INCLUDE : readonly string[] = [
78
+ "README.md",
79
+ "docs/**/*.{md,mdx}",
80
+ "packages/*/README.md",
81
+ "packages/*/src/**/*.{ts,tsx}",
82
+ "packages/*/build/**/*.{ts,tsx}",
83
+ "packages/*/commands/**/*.{ts,tsx}",
84
+ "packages/*/*.config.{ts,mts}",
85
+ ];
86
+
87
+ export const DEFAULT_IGNORE : readonly string[] = [
88
+ "**/node_modules/**",
89
+ "**/dist/**",
90
+ "**/*.d.ts",
91
+ "**/*.test.ts",
92
+ "**/*.test.tsx",
93
+ "**/*.spec.ts",
94
+ "**/*.spec.tsx",
95
+ ];
96
+
97
+ export const DEFAULT_LIMITS : Limits = {
98
+ instructionWords : 20,
99
+ descriptionWords : 25,
100
+ paragraphSentences : 6,
101
+ nounClusterNouns : 3,
102
+ };
103
+
104
+ /** An error in the configuration. */
105
+ export class ConfigError extends Error {
106
+ override readonly name : string = "ConfigError";
107
+ }
108
+
109
+ const SETTINGS : readonly string[] = ["error", "warning", "off"];
110
+
111
+ const KNOWN_KEYS = new Set([
112
+ "$schema", "include", "ignore", "technicalNouns", "technicalVerbs", "properNouns", "allowedWords",
113
+ "rules", "wordList", "limits", "dictionaryPath",
114
+ ]);
115
+
116
+ const WORD_LIST_KEYS = new Set(["id", "match", "suggest", "note", "severity", "verbOnly"]);
117
+
118
+ /** This function makes sure that `value` is a valid configuration. It throws a `ConfigError` that lists all problems. */
119
+ export function validateConfig(value : unknown, source : string) : SteConfigFile {
120
+ const problems : string[] = [];
121
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
122
+ throw new ConfigError(`${source}: the configuration must be a JSON object.`);
123
+ }
124
+ const record = value as Record<string, unknown>;
125
+ for (const key of Object.keys(record)) {
126
+ if (!KNOWN_KEYS.has(key)) problems.push(`"${key}" is not a known property. Known properties: ${[...KNOWN_KEYS].join(", ")}.`);
127
+ }
128
+ for (const key of ["include", "ignore", "technicalNouns", "technicalVerbs", "properNouns", "allowedWords"]) {
129
+ const item = record[key];
130
+ if (item !== undefined && !(Array.isArray(item) && item.every((entry) => typeof entry === "string"))) {
131
+ problems.push(`"${key}" must be an array of strings.`);
132
+ }
133
+ }
134
+ const rules = record["rules"];
135
+ if (rules !== undefined) {
136
+ if (typeof rules !== "object" || rules === null || Array.isArray(rules)) {
137
+ problems.push(`"rules" must be an object.`);
138
+ } else {
139
+ for (const [id, setting] of Object.entries(rules)) {
140
+ if (!isRuleId(id)) problems.push(`"rules.${id}" is not a known rule. Known rules: ${RULE_IDS.join(", ")}.`);
141
+ if (typeof setting !== "string" || !SETTINGS.includes(setting)) problems.push(`"rules.${id}" must be "error", "warning" or "off".`);
142
+ }
143
+ }
144
+ }
145
+ const limits = record["limits"];
146
+ if (limits !== undefined) {
147
+ if (typeof limits !== "object" || limits === null || Array.isArray(limits)) {
148
+ problems.push(`"limits" must be an object.`);
149
+ } else {
150
+ for (const [key, limit] of Object.entries(limits)) {
151
+ if (!Object.hasOwn(DEFAULT_LIMITS, key)) problems.push(`"limits.${key}" is not a known limit. Known limits: ${Object.keys(DEFAULT_LIMITS).join(", ")}.`);
152
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1) problems.push(`"limits.${key}" must be an integer of 1 or more.`);
153
+ }
154
+ }
155
+ }
156
+ const dictionaryPath = record["dictionaryPath"];
157
+ if (dictionaryPath !== undefined && dictionaryPath !== null && typeof dictionaryPath !== "string") {
158
+ problems.push(`"dictionaryPath" must be a string or null.`);
159
+ }
160
+ const wordList = record["wordList"];
161
+ if (wordList !== undefined) {
162
+ if (!Array.isArray(wordList)) {
163
+ problems.push(`"wordList" must be an array.`);
164
+ } else {
165
+ wordList.forEach((item : unknown, index : number) => problems.push(...validateWordListEntry(item, `wordList[${index}]`)));
166
+ }
167
+ }
168
+ if (problems.length > 0) throw new ConfigError(`${source}:\n - ${problems.join("\n - ")}`);
169
+ return record as SteConfigFile;
170
+ }
171
+
172
+ function validateWordListEntry(item : unknown, where : string) : string[] {
173
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return [`"${where}" must be an object.`];
174
+ const problems : string[] = [];
175
+ const entry = item as Record<string, unknown>;
176
+ for (const key of Object.keys(entry)) {
177
+ if (!WORD_LIST_KEYS.has(key)) problems.push(`"${where}.${key}" is not a known property.`);
178
+ }
179
+ const match = entry["match"];
180
+ const matchValid = typeof match === "string" || (Array.isArray(match) && match.length > 0 && match.every((word) => typeof word === "string"));
181
+ if (match !== undefined && !matchValid) problems.push(`"${where}.match" must be a string or an array of strings.`);
182
+ if (entry["id"] !== undefined && typeof entry["id"] !== "string") problems.push(`"${where}.id" must be a string.`);
183
+ if (entry["id"] === undefined && match === undefined) problems.push(`"${where}" must have "id" or "match".`);
184
+ for (const key of ["suggest", "note"]) {
185
+ const text = entry[key];
186
+ if (text !== undefined && text !== null && typeof text !== "string") problems.push(`"${where}.${key}" must be a string or null.`);
187
+ }
188
+ const severity = entry["severity"];
189
+ if (severity !== undefined && (typeof severity !== "string" || !SETTINGS.includes(severity))) problems.push(`"${where}.severity" must be "error", "warning" or "off".`);
190
+ if (entry["verbOnly"] !== undefined && typeof entry["verbOnly"] !== "boolean") problems.push(`"${where}.verbOnly" must be true or false.`);
191
+ return problems;
192
+ }
193
+
194
+ /**
195
+ * This function merges the default word list and the entries of the
196
+ * configuration. An entry with the ID (or the first `match` word) of a
197
+ * default entry changes that entry. The severity "off" removes the entry
198
+ * with that ID, and it removes the `match` words from all entries.
199
+ */
200
+ export function mergeWordList(defaults : readonly WordListEntry[], overrides : readonly WordListEntryConfig[]) : WordListEntry[] {
201
+ const entries = new Map<string, WordListEntry | null>(defaults.map((entry) => [entry.id, entry]));
202
+ for (const override of overrides) {
203
+ const match = override.match === undefined ? undefined : typeof override.match === "string" ? [override.match] : [...override.match];
204
+ const id = override.id ?? match![0]!;
205
+ const base = entries.get(id) ?? null;
206
+ if (override.severity === "off") {
207
+ entries.set(id, null);
208
+ const removed = new Set((match ?? []).map((word) => word.toLowerCase()));
209
+ for (const [key, entry] of entries) {
210
+ if (entry === null) continue;
211
+ const kept = entry.match.filter((word) => !removed.has(word.toLowerCase()));
212
+ entries.set(key, kept.length === 0 ? null : { ...entry, match : kept });
213
+ }
214
+ continue;
215
+ }
216
+ const merged : WordListEntry = {
217
+ id,
218
+ match : match ?? base?.match ?? [id],
219
+ suggest : override.suggest !== undefined ? override.suggest : base?.suggest ?? null,
220
+ note : override.note !== undefined ? override.note : base?.note ?? null,
221
+ severity : (override.severity as Severity | undefined) ?? base?.severity ?? "error",
222
+ verbOnly : override.verbOnly ?? base?.verbOnly ?? false,
223
+ context : base?.context ?? null,
224
+ };
225
+ entries.set(id, merged);
226
+ }
227
+ return [...entries.values()].filter((entry) : entry is WordListEntry => entry !== null);
228
+ }
229
+
230
+ /** This function expands a leading `~/` to the home directory, and makes the path absolute. */
231
+ export function resolvePath(path : string, base : string) : string {
232
+ const expanded = path === "~" ? homedir() : path.startsWith("~/") || path.startsWith("~\\") ? join(homedir(), path.slice(2)) : path;
233
+ return resolve(base, expanded);
234
+ }
235
+
236
+ export type ResolveOptions = {
237
+ /** The directory of the configuration file, or the working directory. */
238
+ readonly root : string;
239
+ readonly configFile? : string | null;
240
+ /** The working directory. `STE_DICTIONARY` is relative to it. */
241
+ readonly cwd? : string;
242
+ readonly env? : Readonly<Record<string, string | undefined>>;
243
+ };
244
+
245
+ /** This function applies the defaults and the environment to a configuration file. */
246
+ export function resolveConfig(file : SteConfigFile, options : ResolveOptions) : ResolvedConfig {
247
+ const env = options.env ?? {};
248
+ const cwd = options.cwd ?? options.root;
249
+ const fromEnv = env[DICTIONARY_ENV];
250
+ let dictionaryPath : string | null = null;
251
+ let dictionarySource : ResolvedConfig["dictionarySource"] = null;
252
+ if (fromEnv !== undefined && fromEnv.trim().length > 0) {
253
+ dictionaryPath = resolvePath(fromEnv.trim(), cwd);
254
+ dictionarySource = "env";
255
+ } else if (typeof file.dictionaryPath === "string" && file.dictionaryPath.trim().length > 0) {
256
+ dictionaryPath = resolvePath(file.dictionaryPath.trim(), options.root);
257
+ dictionarySource = "config";
258
+ }
259
+ const rules : Partial<Record<RuleId, RuleSetting>> = {};
260
+ for (const [id, setting] of Object.entries(file.rules ?? {})) {
261
+ if (isRuleId(id)) rules[id] = setting;
262
+ }
263
+ return {
264
+ root : options.root,
265
+ configFile : options.configFile ?? null,
266
+ include : file.include ?? DEFAULT_INCLUDE,
267
+ ignore : file.ignore ?? DEFAULT_IGNORE,
268
+ technicalNouns : file.technicalNouns ?? [],
269
+ technicalVerbs : file.technicalVerbs ?? [],
270
+ properNouns : file.properNouns ?? [],
271
+ allowedWords : file.allowedWords ?? [],
272
+ rules,
273
+ wordList : mergeWordList(DEFAULT_WORD_LIST, file.wordList ?? []),
274
+ limits : { ...DEFAULT_LIMITS, ...file.limits },
275
+ dictionaryPath,
276
+ dictionarySource,
277
+ };
278
+ }
279
+
280
+ /** The first `ste.config.json` in `cwd` or one of its parent directories, or null. */
281
+ export function findConfigFile(cwd : string) : string | null {
282
+ let directory = resolve(cwd);
283
+ for (;;) {
284
+ const candidate = join(directory, CONFIG_FILE_NAME);
285
+ if (existsSync(candidate)) return candidate;
286
+ const parent = dirname(directory);
287
+ if (parent === directory) return null;
288
+ directory = parent;
289
+ }
290
+ }
291
+
292
+ export type LoadOptions = {
293
+ readonly cwd : string;
294
+ /** An explicit configuration file. Without it, the linter looks for `ste.config.json`. */
295
+ readonly configPath? : string;
296
+ readonly env? : Readonly<Record<string, string | undefined>>;
297
+ };
298
+
299
+ /** This function reads the configuration file, if there is one, and resolves it. */
300
+ export function loadConfig(options : LoadOptions) : ResolvedConfig {
301
+ const configFile = options.configPath !== undefined ? resolve(options.cwd, options.configPath) : findConfigFile(options.cwd);
302
+ const env = options.env ?? {};
303
+ if (configFile === null) return resolveConfig({}, { root : resolve(options.cwd), cwd : options.cwd, env });
304
+ let text : string;
305
+ try {
306
+ text = readFileSync(configFile, "utf8");
307
+ } catch (error) {
308
+ throw new ConfigError(`Cannot read the configuration file ${configFile}: ${(error as Error).message}`);
309
+ }
310
+ let value : unknown;
311
+ try {
312
+ value = JSON.parse(text);
313
+ } catch (error) {
314
+ throw new ConfigError(`${configFile}: the file is not valid JSON: ${(error as Error).message}`);
315
+ }
316
+ const file = validateConfig(value, configFile);
317
+ return resolveConfig(file, { root : dirname(configFile), configFile, cwd : options.cwd, env });
318
+ }
@@ -0,0 +1,82 @@
1
+ import { 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 } from "vitest";
5
+ import { Dictionary, DictionaryError, loadDictionary, parseDictionary } from "./dictionary.js";
6
+
7
+ let directory : string;
8
+
9
+ beforeEach(() => {
10
+ directory = mkdtempSync(join(tmpdir(), "ste-dictionary-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ rmSync(directory, { recursive : true, force : true });
15
+ });
16
+
17
+ describe("parseDictionary", () => {
18
+ it("reads the short form: an array of approved words", () => {
19
+ const dictionary = parseDictionary(["Make", "makes", "valve"], "d.json");
20
+ expect(dictionary.size).toBe(3);
21
+ expect(dictionary.lookup("make").status).toBe("approved");
22
+ expect(dictionary.lookup("MAKES").status).toBe("approved");
23
+ expect(dictionary.lookup("made").status).toBe("unknown");
24
+ });
25
+
26
+ it("reads the full form with parts of speech, forms and words that are not approved", () => {
27
+ const dictionary = parseDictionary({
28
+ format : "ste-lint-dictionary",
29
+ version : 1,
30
+ entries : [
31
+ "a",
32
+ { word : "make", pos : "verb", forms : ["makes", "made"] },
33
+ { word : "make sure", pos : "verb" },
34
+ { word : "valve", pos : "noun" },
35
+ { word : "battery", pos : "n" },
36
+ { word : "utilize", pos : "v", approved : false, alternatives : ["use"] },
37
+ { word : "frob", approved : false },
38
+ ],
39
+ }, "d.json");
40
+ expect(dictionary.lookup("made").status).toBe("approved");
41
+ expect(dictionary.lookup("sure").status).toBe("approved");
42
+ expect(dictionary.lookup("valves").status).toBe("approved");
43
+ expect(dictionary.lookup("batteries").status).toBe("approved");
44
+ expect(dictionary.lookup("valve's").status).toBe("approved");
45
+ expect(dictionary.lookup("makings").status).toBe("unknown");
46
+ expect(dictionary.lookup("Utilize")).toEqual({ status : "not-approved", alternatives : ["use"] });
47
+ expect(dictionary.lookup("frob")).toEqual({ status : "not-approved", alternatives : [] });
48
+ expect([...dictionary.nouns].sort()).toEqual(["battery", "valve"]);
49
+ expect([...dictionary.verbs].sort()).toEqual(["make", "make sure"]);
50
+ });
51
+
52
+ it("gives clear errors for a file that is not valid", () => {
53
+ expect(() => parseDictionary(["a", 1], "d.json")).toThrow("only strings");
54
+ expect(() => parseDictionary(3, "d.json")).toThrow(DictionaryError);
55
+ expect(() => parseDictionary({ format : "other", entries : [] }, "d.json")).toThrow("\"format\" must be");
56
+ expect(() => parseDictionary({ entries : {} }, "d.json")).toThrow("\"entries\" must be an array");
57
+ expect(() => parseDictionary({ entries : [5] }, "d.json")).toThrow("entries[0] must be a string or an object");
58
+ expect(() => parseDictionary({ entries : [{ word : "" }] }, "d.json")).toThrow("word must be a string");
59
+ expect(() => parseDictionary({ entries : [{ word : "a", pos : 1 }] }, "d.json")).toThrow("pos must be a string");
60
+ expect(() => parseDictionary({ entries : [{ word : "a", approved : "no" }] }, "d.json")).toThrow("approved must be true or false");
61
+ expect(() => parseDictionary({ entries : [{ word : "a", forms : "b" }] }, "d.json")).toThrow("forms must be an array of strings");
62
+ });
63
+
64
+ it("makes an empty dictionary", () => {
65
+ expect(new Dictionary([]).lookup("anything").status).toBe("unknown");
66
+ });
67
+ });
68
+
69
+ describe("loadDictionary", () => {
70
+ it("reads a JSON file", () => {
71
+ const path = join(directory, "dictionary.json");
72
+ writeFileSync(path, JSON.stringify(["valve"]));
73
+ expect(loadDictionary(path).lookup("valve").status).toBe("approved");
74
+ });
75
+
76
+ it("gives clear errors for a missing file and a file that is not JSON", () => {
77
+ expect(() => loadDictionary(join(directory, "missing.json"))).toThrow("Cannot read the dictionary file");
78
+ const path = join(directory, "broken.json");
79
+ writeFileSync(path, "[");
80
+ expect(() => loadDictionary(path)).toThrow("is not valid JSON");
81
+ });
82
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The optional local dictionary of approved words.
3
+ *
4
+ * The ASD-STE100 dictionary is copyrighted. This package does not contain
5
+ * it, and the repository must not contain it. Each user makes a local file
6
+ * from their own copy of the standard. Keep that file outside the
7
+ * repository, or in a git-ignored folder, for example `.ste/`.
8
+ *
9
+ * File format (JSON). The short form is an array of approved words:
10
+ *
11
+ * ```json
12
+ * ["a", "about", "make", "makes", "made"]
13
+ * ```
14
+ *
15
+ * The full form is an object with entries:
16
+ *
17
+ * ```json
18
+ * {
19
+ * "format": "ste-lint-dictionary",
20
+ * "version": 1,
21
+ * "entries": [
22
+ * { "word": "make", "pos": "verb", "forms": ["makes", "made"] },
23
+ * { "word": "valve", "pos": "noun" },
24
+ * { "word": "utilize", "pos": "verb", "approved": false, "alternatives": ["use"] }
25
+ * ]
26
+ * }
27
+ * ```
28
+ *
29
+ * - `word` (necessary): the headword. A phrase, for example "make sure", approves each of its words.
30
+ * - `pos`: the part of speech. A noun also permits its regular plural.
31
+ * - `approved`: `false` for a word that is not approved. The default is `true`.
32
+ * - `forms`: other approved forms, for example the past tense of a verb.
33
+ * - `alternatives`: approved words to use instead of a word that is not approved.
34
+ */
35
+
36
+ import { readFileSync } from "node:fs";
37
+
38
+ export type DictionaryEntry = {
39
+ readonly word : string;
40
+ readonly pos? : string;
41
+ readonly approved? : boolean;
42
+ readonly forms? : readonly string[];
43
+ readonly alternatives? : readonly string[];
44
+ };
45
+
46
+ export type DictionaryLookup =
47
+ | { readonly status : "approved" }
48
+ | { readonly status : "not-approved"; readonly alternatives : readonly string[] }
49
+ | { readonly status : "unknown" };
50
+
51
+ export const DICTIONARY_FORMAT = "ste-lint-dictionary";
52
+
53
+ /** An error in a dictionary file. */
54
+ export class DictionaryError extends Error {
55
+ override readonly name : string = "DictionaryError";
56
+ }
57
+
58
+ const normalize = (word : string) : string => word.trim().toLowerCase().replace(/[’‘]/g, "'");
59
+
60
+ /** The approved words of a local dictionary. */
61
+ export class Dictionary {
62
+ private readonly approved = new Set<string>();
63
+ private readonly approvedNouns = new Set<string>();
64
+ private readonly notApproved = new Map<string, readonly string[]>();
65
+ /** The approved nouns. The noun-cluster rule uses them. */
66
+ readonly nouns : Set<string> = new Set<string>();
67
+ /** The approved verbs. The imperative and missing-subject heuristics use them. */
68
+ readonly verbs : Set<string> = new Set<string>();
69
+
70
+ constructor(entries : readonly DictionaryEntry[]) {
71
+ for (const entry of entries) {
72
+ const words = [entry.word, ...(entry.forms ?? [])].flatMap((form) => normalize(form).split(/\s+/)).filter((form) => form.length > 0);
73
+ if (entry.approved === false) {
74
+ for (const word of words) this.notApproved.set(word, entry.alternatives ?? []);
75
+ continue;
76
+ }
77
+ for (const word of words) this.approved.add(word);
78
+ const pos = (entry.pos ?? "").toLowerCase();
79
+ const base = normalize(entry.word);
80
+ if (pos === "n" || pos === "noun") {
81
+ this.approvedNouns.add(base);
82
+ this.nouns.add(base);
83
+ }
84
+ if (pos === "v" || pos === "verb") this.verbs.add(base);
85
+ }
86
+ }
87
+
88
+ /** The number of approved words and forms. */
89
+ get size() : number {
90
+ return this.approved.size;
91
+ }
92
+
93
+ /** The status of `word` in the dictionary. */
94
+ lookup(word : string) : DictionaryLookup {
95
+ const lower = normalize(word);
96
+ const candidates = [lower];
97
+ if (lower.endsWith("'s")) candidates.push(lower.slice(0, -2));
98
+ for (const candidate of candidates) {
99
+ if (this.approved.has(candidate)) return { status : "approved" };
100
+ const plurals = [candidate.slice(0, -1), candidate.slice(0, -2), candidate.slice(0, -3) + "y"];
101
+ if (candidate.endsWith("s") && plurals.some((singular) => this.approvedNouns.has(singular))) return { status : "approved" };
102
+ }
103
+ for (const candidate of candidates) {
104
+ const alternatives = this.notApproved.get(candidate);
105
+ if (alternatives !== undefined) return { status : "not-approved", alternatives };
106
+ }
107
+ return { status : "unknown" };
108
+ }
109
+ }
110
+
111
+ /** This function makes a dictionary from parsed JSON. `source` is the file name for error messages. */
112
+ export function parseDictionary(value : unknown, source : string) : Dictionary {
113
+ if (Array.isArray(value)) {
114
+ if (!value.every((word) => typeof word === "string")) {
115
+ throw new DictionaryError(`${source}: the array form must contain only strings.`);
116
+ }
117
+ return new Dictionary(value.map((word : string) => ({ word })));
118
+ }
119
+ if (typeof value !== "object" || value === null) {
120
+ throw new DictionaryError(`${source}: the file must contain an array of words or an object with "entries".`);
121
+ }
122
+ const record = value as Record<string, unknown>;
123
+ const format = record["format"];
124
+ if (format !== undefined && format !== DICTIONARY_FORMAT) {
125
+ throw new DictionaryError(`${source}: "format" must be "${DICTIONARY_FORMAT}".`);
126
+ }
127
+ const entries = record["entries"];
128
+ if (!Array.isArray(entries)) throw new DictionaryError(`${source}: "entries" must be an array.`);
129
+ const parsed : DictionaryEntry[] = entries.map((entry : unknown, index : number) => parseEntry(entry, `${source}: entries[${index}]`));
130
+ return new Dictionary(parsed);
131
+ }
132
+
133
+ function stringArray(value : unknown, where : string) : string[] | undefined {
134
+ if (value === undefined) return undefined;
135
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
136
+ throw new DictionaryError(`${where} must be an array of strings.`);
137
+ }
138
+ return value as string[];
139
+ }
140
+
141
+ function parseEntry(entry : unknown, where : string) : DictionaryEntry {
142
+ if (typeof entry === "string") return { word : entry };
143
+ if (typeof entry !== "object" || entry === null) throw new DictionaryError(`${where} must be a string or an object.`);
144
+ const record = entry as Record<string, unknown>;
145
+ const word = record["word"];
146
+ if (typeof word !== "string" || word.trim().length === 0) throw new DictionaryError(`${where}.word must be a string that is not empty.`);
147
+ const pos = record["pos"];
148
+ if (pos !== undefined && typeof pos !== "string") throw new DictionaryError(`${where}.pos must be a string.`);
149
+ const approved = record["approved"];
150
+ if (approved !== undefined && typeof approved !== "boolean") throw new DictionaryError(`${where}.approved must be true or false.`);
151
+ const forms = stringArray(record["forms"], `${where}.forms`);
152
+ const alternatives = stringArray(record["alternatives"], `${where}.alternatives`);
153
+ return {
154
+ word,
155
+ ...(pos === undefined ? {} : { pos }),
156
+ ...(approved === undefined ? {} : { approved }),
157
+ ...(forms === undefined ? {} : { forms }),
158
+ ...(alternatives === undefined ? {} : { alternatives }),
159
+ };
160
+ }
161
+
162
+ /** This function reads a dictionary file. */
163
+ export function loadDictionary(path : string) : Dictionary {
164
+ let text : string;
165
+ try {
166
+ text = readFileSync(path, "utf8");
167
+ } catch (error) {
168
+ throw new DictionaryError(`Cannot read the dictionary file ${path}: ${(error as Error).message}`);
169
+ }
170
+ let value : unknown;
171
+ try {
172
+ value = JSON.parse(text);
173
+ } catch (error) {
174
+ throw new DictionaryError(`${path}: the file is not valid JSON: ${(error as Error).message}`);
175
+ }
176
+ return parseDictionary(value, path);
177
+ }