@mark1russell7/ste-lint 0.1.1 → 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 (49) hide show
  1. package/README.md +26 -5
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config-core.d.ts +92 -0
  4. package/dist/config-core.d.ts.map +1 -0
  5. package/dist/config-core.js +198 -0
  6. package/dist/config-core.js.map +1 -0
  7. package/dist/config.d.ts +2 -76
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +3 -186
  10. package/dist/config.js.map +1 -1
  11. package/dist/core.d.ts +16 -0
  12. package/dist/core.d.ts.map +1 -0
  13. package/dist/core.js +16 -0
  14. package/dist/core.js.map +1 -0
  15. package/dist/dictionary-core.d.ts +72 -0
  16. package/dist/dictionary-core.d.ts.map +1 -0
  17. package/dist/dictionary-core.js +150 -0
  18. package/dist/dictionary-core.js.map +1 -0
  19. package/dist/dictionary.d.ts +4 -69
  20. package/dist/dictionary.d.ts.map +1 -1
  21. package/dist/dictionary.js +4 -147
  22. package/dist/dictionary.js.map +1 -1
  23. package/dist/engine.d.ts +44 -0
  24. package/dist/engine.d.ts.map +1 -0
  25. package/dist/engine.js +122 -0
  26. package/dist/engine.js.map +1 -0
  27. package/dist/index.d.ts +3 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/lint.d.ts +6 -16
  32. package/dist/lint.d.ts.map +1 -1
  33. package/dist/lint.js +7 -116
  34. package/dist/lint.js.map +1 -1
  35. package/dist/source/code.d.ts +9 -0
  36. package/dist/source/code.d.ts.map +1 -0
  37. package/dist/source/code.js +16 -0
  38. package/dist/source/code.js.map +1 -0
  39. package/package.json +10 -2
  40. package/src/config-core.ts +258 -0
  41. package/src/config.ts +3 -238
  42. package/src/core.test.ts +87 -0
  43. package/src/core.ts +32 -0
  44. package/src/dictionary-core.ts +158 -0
  45. package/src/dictionary.ts +4 -155
  46. package/src/engine.ts +138 -0
  47. package/src/index.ts +4 -0
  48. package/src/lint.ts +10 -115
  49. package/src/source/code.ts +17 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The parts of the configuration that need no file system: the types of
3
+ * `ste.config.json`, the defaults, the validation and the word list merge.
4
+ * A browser can use this module.
5
+ */
6
+
7
+ import { isRuleId, RULE_IDS, type RuleId, type RuleSetting, type Severity } from "./types.js";
8
+ import { DEFAULT_WORD_LIST, type WordListEntry } from "./word-list.js";
9
+
10
+ export const CONFIG_FILE_NAME = "ste.config.json";
11
+
12
+ /** The environment variable that gives the path of the local dictionary. It has priority over `dictionaryPath`. */
13
+ export const DICTIONARY_ENV = "STE_DICTIONARY";
14
+
15
+ /** An entry of `wordList` in the configuration file. */
16
+ export type WordListEntryConfig = {
17
+ /** The ID of a default entry to change. A new entry gets the first word of `match` as its ID. */
18
+ readonly id? : string;
19
+ readonly match? : string | readonly string[];
20
+ readonly suggest? : string | null;
21
+ readonly note? : string | null;
22
+ readonly severity? : RuleSetting;
23
+ readonly verbOnly? : boolean;
24
+ };
25
+
26
+ export type LimitsConfig = {
27
+ /** The maximum number of words in an instruction. Default: 20 (STE Rule 5.1). */
28
+ readonly instructionWords? : number;
29
+ /** The maximum number of words in a description. Default: 25 (STE Rule 6.3). */
30
+ readonly descriptionWords? : number;
31
+ /** The maximum number of sentences in a paragraph. Default: 6 (STE Rule 6.6). */
32
+ readonly paragraphSentences? : number;
33
+ /** The maximum number of nouns in a noun cluster. Default: 3 (STE Rule 2.1). */
34
+ readonly nounClusterNouns? : number;
35
+ };
36
+
37
+ /** The contents of `ste.config.json`. All properties are optional. */
38
+ export type SteConfigFile = {
39
+ readonly $schema? : string;
40
+ readonly include? : readonly string[];
41
+ readonly ignore? : readonly string[];
42
+ readonly technicalNouns? : readonly string[];
43
+ readonly technicalVerbs? : readonly string[];
44
+ readonly properNouns? : readonly string[];
45
+ readonly allowedWords? : readonly string[];
46
+ readonly rules? : Readonly<Record<string, RuleSetting>>;
47
+ readonly wordList? : readonly WordListEntryConfig[];
48
+ readonly limits? : LimitsConfig;
49
+ readonly dictionaryPath? : string | null;
50
+ };
51
+
52
+ export type Limits = Required<LimitsConfig>;
53
+
54
+ export type ResolvedConfig = {
55
+ /** The directory that `include` and `ignore` are relative to. */
56
+ readonly root : string;
57
+ /** The configuration file, or null when the linter uses the defaults. */
58
+ readonly configFile : string | null;
59
+ readonly include : readonly string[];
60
+ readonly ignore : readonly string[];
61
+ readonly technicalNouns : readonly string[];
62
+ readonly technicalVerbs : readonly string[];
63
+ readonly properNouns : readonly string[];
64
+ readonly allowedWords : readonly string[];
65
+ /** The rule settings from the configuration. A rule without a setting uses its default severity. */
66
+ readonly rules : Readonly<Partial<Record<RuleId, RuleSetting>>>;
67
+ readonly wordList : readonly WordListEntry[];
68
+ readonly limits : Limits;
69
+ /** The absolute path of the local dictionary, or null. */
70
+ readonly dictionaryPath : string | null;
71
+ /** Where the dictionary path came from. */
72
+ readonly dictionarySource : "env" | "config" | null;
73
+ };
74
+
75
+ export const DEFAULT_INCLUDE : readonly string[] = [
76
+ "README.md",
77
+ "docs/**/*.{md,mdx}",
78
+ "packages/*/README.md",
79
+ "packages/*/src/**/*.{ts,tsx}",
80
+ "packages/*/build/**/*.{ts,tsx}",
81
+ "packages/*/commands/**/*.{ts,tsx}",
82
+ "packages/*/*.config.{ts,mts}",
83
+ ];
84
+
85
+ export const DEFAULT_IGNORE : readonly string[] = [
86
+ "**/node_modules/**",
87
+ "**/dist/**",
88
+ "**/*.d.ts",
89
+ "**/*.test.ts",
90
+ "**/*.test.tsx",
91
+ "**/*.spec.ts",
92
+ "**/*.spec.tsx",
93
+ ];
94
+
95
+ export const DEFAULT_LIMITS : Limits = {
96
+ instructionWords : 20,
97
+ descriptionWords : 25,
98
+ paragraphSentences : 6,
99
+ nounClusterNouns : 3,
100
+ };
101
+
102
+ /** An error in the configuration. */
103
+ export class ConfigError extends Error {
104
+ override readonly name : string = "ConfigError";
105
+ }
106
+
107
+ const SETTINGS : readonly string[] = ["error", "warning", "off"];
108
+
109
+ const KNOWN_KEYS = new Set([
110
+ "$schema", "include", "ignore", "technicalNouns", "technicalVerbs", "properNouns", "allowedWords",
111
+ "rules", "wordList", "limits", "dictionaryPath",
112
+ ]);
113
+
114
+ const WORD_LIST_KEYS = new Set(["id", "match", "suggest", "note", "severity", "verbOnly"]);
115
+
116
+ /** This function makes sure that `value` is a valid configuration. It throws a `ConfigError` that lists all problems. */
117
+ export function validateConfig(value : unknown, source : string) : SteConfigFile {
118
+ const problems : string[] = [];
119
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
120
+ throw new ConfigError(`${source}: the configuration must be a JSON object.`);
121
+ }
122
+ const record = value as Record<string, unknown>;
123
+ for (const key of Object.keys(record)) {
124
+ if (!KNOWN_KEYS.has(key)) problems.push(`"${key}" is not a known property. Known properties: ${[...KNOWN_KEYS].join(", ")}.`);
125
+ }
126
+ for (const key of ["include", "ignore", "technicalNouns", "technicalVerbs", "properNouns", "allowedWords"]) {
127
+ const item = record[key];
128
+ if (item !== undefined && !(Array.isArray(item) && item.every((entry) => typeof entry === "string"))) {
129
+ problems.push(`"${key}" must be an array of strings.`);
130
+ }
131
+ }
132
+ const rules = record["rules"];
133
+ if (rules !== undefined) {
134
+ if (typeof rules !== "object" || rules === null || Array.isArray(rules)) {
135
+ problems.push(`"rules" must be an object.`);
136
+ } else {
137
+ for (const [id, setting] of Object.entries(rules)) {
138
+ if (!isRuleId(id)) problems.push(`"rules.${id}" is not a known rule. Known rules: ${RULE_IDS.join(", ")}.`);
139
+ if (typeof setting !== "string" || !SETTINGS.includes(setting)) problems.push(`"rules.${id}" must be "error", "warning" or "off".`);
140
+ }
141
+ }
142
+ }
143
+ const limits = record["limits"];
144
+ if (limits !== undefined) {
145
+ if (typeof limits !== "object" || limits === null || Array.isArray(limits)) {
146
+ problems.push(`"limits" must be an object.`);
147
+ } else {
148
+ for (const [key, limit] of Object.entries(limits)) {
149
+ if (!Object.hasOwn(DEFAULT_LIMITS, key)) problems.push(`"limits.${key}" is not a known limit. Known limits: ${Object.keys(DEFAULT_LIMITS).join(", ")}.`);
150
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1) problems.push(`"limits.${key}" must be an integer of 1 or more.`);
151
+ }
152
+ }
153
+ }
154
+ const dictionaryPath = record["dictionaryPath"];
155
+ if (dictionaryPath !== undefined && dictionaryPath !== null && typeof dictionaryPath !== "string") {
156
+ problems.push(`"dictionaryPath" must be a string or null.`);
157
+ }
158
+ const wordList = record["wordList"];
159
+ if (wordList !== undefined) {
160
+ if (!Array.isArray(wordList)) {
161
+ problems.push(`"wordList" must be an array.`);
162
+ } else {
163
+ wordList.forEach((item : unknown, index : number) => problems.push(...validateWordListEntry(item, `wordList[${index}]`)));
164
+ }
165
+ }
166
+ if (problems.length > 0) throw new ConfigError(`${source}:\n - ${problems.join("\n - ")}`);
167
+ return record as SteConfigFile;
168
+ }
169
+
170
+ function validateWordListEntry(item : unknown, where : string) : string[] {
171
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return [`"${where}" must be an object.`];
172
+ const problems : string[] = [];
173
+ const entry = item as Record<string, unknown>;
174
+ for (const key of Object.keys(entry)) {
175
+ if (!WORD_LIST_KEYS.has(key)) problems.push(`"${where}.${key}" is not a known property.`);
176
+ }
177
+ const match = entry["match"];
178
+ const matchValid = typeof match === "string" || (Array.isArray(match) && match.length > 0 && match.every((word) => typeof word === "string"));
179
+ if (match !== undefined && !matchValid) problems.push(`"${where}.match" must be a string or an array of strings.`);
180
+ if (entry["id"] !== undefined && typeof entry["id"] !== "string") problems.push(`"${where}.id" must be a string.`);
181
+ if (entry["id"] === undefined && match === undefined) problems.push(`"${where}" must have "id" or "match".`);
182
+ for (const key of ["suggest", "note"]) {
183
+ const text = entry[key];
184
+ if (text !== undefined && text !== null && typeof text !== "string") problems.push(`"${where}.${key}" must be a string or null.`);
185
+ }
186
+ const severity = entry["severity"];
187
+ if (severity !== undefined && (typeof severity !== "string" || !SETTINGS.includes(severity))) problems.push(`"${where}.severity" must be "error", "warning" or "off".`);
188
+ if (entry["verbOnly"] !== undefined && typeof entry["verbOnly"] !== "boolean") problems.push(`"${where}.verbOnly" must be true or false.`);
189
+ return problems;
190
+ }
191
+
192
+ /**
193
+ * This function merges the default word list and the entries of the
194
+ * configuration. An entry with the ID (or the first `match` word) of a
195
+ * default entry changes that entry. The severity "off" removes the entry
196
+ * with that ID, and it removes the `match` words from all entries.
197
+ */
198
+ export function mergeWordList(defaults : readonly WordListEntry[], overrides : readonly WordListEntryConfig[]) : WordListEntry[] {
199
+ const entries = new Map<string, WordListEntry | null>(defaults.map((entry) => [entry.id, entry]));
200
+ for (const override of overrides) {
201
+ const match = override.match === undefined ? undefined : typeof override.match === "string" ? [override.match] : [...override.match];
202
+ const id = override.id ?? match![0]!;
203
+ const base = entries.get(id) ?? null;
204
+ if (override.severity === "off") {
205
+ entries.set(id, null);
206
+ const removed = new Set((match ?? []).map((word) => word.toLowerCase()));
207
+ for (const [key, entry] of entries) {
208
+ if (entry === null) continue;
209
+ const kept = entry.match.filter((word) => !removed.has(word.toLowerCase()));
210
+ entries.set(key, kept.length === 0 ? null : { ...entry, match : kept });
211
+ }
212
+ continue;
213
+ }
214
+ const merged : WordListEntry = {
215
+ id,
216
+ match : match ?? base?.match ?? [id],
217
+ suggest : override.suggest !== undefined ? override.suggest : base?.suggest ?? null,
218
+ note : override.note !== undefined ? override.note : base?.note ?? null,
219
+ severity : (override.severity as Severity | undefined) ?? base?.severity ?? "error",
220
+ verbOnly : override.verbOnly ?? base?.verbOnly ?? false,
221
+ context : base?.context ?? null,
222
+ };
223
+ entries.set(id, merged);
224
+ }
225
+ return [...entries.values()].filter((entry) : entry is WordListEntry => entry !== null);
226
+ }
227
+
228
+ export type BaseConfigOptions = {
229
+ /** The directory that `include` and `ignore` are relative to. Default: the empty string. */
230
+ readonly root? : string;
231
+ readonly configFile? : string | null;
232
+ };
233
+
234
+ /**
235
+ * This function applies the defaults to a configuration file. It reads no
236
+ * file and no environment variable, thus the result has no dictionary path.
237
+ */
238
+ export function baseConfig(file : SteConfigFile = {}, options : BaseConfigOptions = {}) : ResolvedConfig {
239
+ const rules : Partial<Record<RuleId, RuleSetting>> = {};
240
+ for (const [id, setting] of Object.entries(file.rules ?? {})) {
241
+ if (isRuleId(id)) rules[id] = setting;
242
+ }
243
+ return {
244
+ root : options.root ?? "",
245
+ configFile : options.configFile ?? null,
246
+ include : file.include ?? DEFAULT_INCLUDE,
247
+ ignore : file.ignore ?? DEFAULT_IGNORE,
248
+ technicalNouns : file.technicalNouns ?? [],
249
+ technicalVerbs : file.technicalVerbs ?? [],
250
+ properNouns : file.properNouns ?? [],
251
+ allowedWords : file.allowedWords ?? [],
252
+ rules,
253
+ wordList : mergeWordList(DEFAULT_WORD_LIST, file.wordList ?? []),
254
+ limits : { ...DEFAULT_LIMITS, ...file.limits },
255
+ dictionaryPath : null,
256
+ dictionarySource : null,
257
+ };
258
+ }
package/src/config.ts CHANGED
@@ -6,226 +6,9 @@
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
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";
9
+ import { baseConfig, CONFIG_FILE_NAME, ConfigError, DICTIONARY_ENV, validateConfig, type ResolvedConfig, type SteConfigFile } from "./config-core.js";
11
10
 
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
- }
11
+ export * from "./config-core.js";
229
12
 
230
13
  /** This function expands a leading `~/` to the home directory, and makes the path absolute. */
231
14
  export function resolvePath(path : string, base : string) : string {
@@ -256,25 +39,7 @@ export function resolveConfig(file : SteConfigFile, options : ResolveOptions) :
256
39
  dictionaryPath = resolvePath(file.dictionaryPath.trim(), options.root);
257
40
  dictionarySource = "config";
258
41
  }
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
- };
42
+ return { ...baseConfig(file, { root : options.root, configFile : options.configFile ?? null }), dictionaryPath, dictionarySource };
278
43
  }
279
44
 
280
45
  /** The first `ste.config.json` in `cwd` or one of its parent directories, or null. */
@@ -0,0 +1,87 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+ import * as core from "./core.js";
6
+ import { extractCode } from "./source/code.js";
7
+ import { Linter, resolveConfig } from "./index.js";
8
+
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+
11
+ /** The relative imports and the package imports of each module that `entry` reaches. */
12
+ function importGraph(entry : string) : { modules : string[]; packages : string[] } {
13
+ const seen = new Set<string>();
14
+ const packages = new Set<string>();
15
+ const visit = (file : string) : void => {
16
+ if (seen.has(file)) return;
17
+ seen.add(file);
18
+ const text = readFileSync(file, "utf8");
19
+ // Type-only imports are erased, so they do not count.
20
+ for (const match of text.matchAll(/^(?:import|export)\s+(?!type\b)[^;]*?from\s+"([^"]+)"/gms)) {
21
+ const specifier = match[1]!;
22
+ if (specifier.startsWith(".")) visit(join(dirname(file), specifier.replace(/\.js$/, ".ts")));
23
+ else packages.add(specifier);
24
+ }
25
+ };
26
+ visit(join(here, entry));
27
+ return { modules : [...seen], packages : [...packages] };
28
+ }
29
+
30
+ const FIXTURE = [
31
+ "# Getting started",
32
+ "",
33
+ "You should utilize the CLI in order to check the docs; it is basically simple.",
34
+ "The values are about 5 ms, e.g. in the test run.",
35
+ "",
36
+ "Please make sure that the file is deleted. Stop the tool by closing the tab.",
37
+ "",
38
+ "- The user can edit his settings via the menu.",
39
+ "",
40
+ "<!-- ste-disable-next modal-verb -->",
41
+ "You should not see this modal verb.",
42
+ "",
43
+ ].join("\n");
44
+
45
+ describe("the core entry", () => {
46
+ it("imports no Node module and not the TypeScript compiler", () => {
47
+ const { packages, modules } = importGraph("core.ts");
48
+ expect(packages).toEqual([]);
49
+ expect(modules.length).toBeGreaterThan(20);
50
+ });
51
+
52
+ it("lets the code extractor import the compiler, but no Node module", () => {
53
+ expect(importGraph("source/code.ts").packages).toEqual(["typescript"]);
54
+ });
55
+
56
+ it("gives the same Markdown findings as the linter of the command line", () => {
57
+ const config = resolveConfig({}, { root : "/" });
58
+ const cli = new Linter(config).lintText(FIXTURE, "README.md");
59
+ const browser = new core.ProseLinter(core.baseConfig()).lintText(FIXTURE, "README.md");
60
+ expect(browser).toEqual(cli);
61
+ expect(new Set(cli.map((finding) => finding.ruleId))).toEqual(new Set([
62
+ "modal-verb", "semicolon", "word-list", "about-quantity", "latin-abbreviation", "gendered-pronoun", "ing-verb",
63
+ ]));
64
+ });
65
+
66
+ it("gives the same doc comment findings with the code extractor", () => {
67
+ const source = "/** Returns the value. You should utilize it. */\nexport const x = 1;\n";
68
+ const cli = new Linter(resolveConfig({}, { root : "/" })).lintText(source, "a.ts");
69
+ const browser = new core.ProseLinter(core.baseConfig(), undefined, { extractCode }).lintText(source, "a.ts");
70
+ expect(browser).toEqual(cli);
71
+ expect(cli.map((finding) => finding.ruleId)).toContain("missing-subject");
72
+ expect(new core.ProseLinter(core.baseConfig()).lintText(source, "a.ts")).toEqual([]);
73
+ });
74
+
75
+ it("applies the settings of a configuration file and a dictionary", () => {
76
+ const config = core.baseConfig({ rules : { "modal-verb" : "off" } });
77
+ expect(new core.ProseLinter(config).lintText("You should stop.", "a.md")).toEqual([]);
78
+ const dictionary = core.parseDictionary(["you", "stop", "the"], "words.json");
79
+ const linter = new core.ProseLinter(core.baseConfig(), core.buildLexicon(core.baseConfig(), dictionary));
80
+ expect(linter.lintText("You stop the valve.", "a.md").map((finding) => finding.ruleId)).toEqual(["unknown-word"]);
81
+ });
82
+
83
+ it("gives the same configuration as resolveConfig without a dictionary", () => {
84
+ const file = { technicalNouns : ["valve"], limits : { instructionWords : 12 } };
85
+ expect(core.baseConfig(file, { root : "/" })).toEqual(resolveConfig(file, { root : "/" }));
86
+ });
87
+ });
package/src/core.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `@mark1russell7/ste-lint/core`: the linter without a file system, for a
3
+ * browser or another host that has the text in memory. It examines Markdown
4
+ * and MDX. For TypeScript doc comments, give the extractor of
5
+ * `@mark1russell7/ste-lint/code` to `ProseLinter`. The rules are the same
6
+ * rules that the command line uses.
7
+ */
8
+
9
+ export {
10
+ baseConfig,
11
+ CONFIG_FILE_NAME,
12
+ ConfigError,
13
+ DEFAULT_IGNORE,
14
+ DEFAULT_INCLUDE,
15
+ DEFAULT_LIMITS,
16
+ DICTIONARY_ENV,
17
+ mergeWordList,
18
+ validateConfig,
19
+ type BaseConfigOptions,
20
+ type Limits,
21
+ type LimitsConfig,
22
+ type ResolvedConfig,
23
+ type SteConfigFile,
24
+ type WordListEntryConfig,
25
+ } from "./config-core.js";
26
+ export { Dictionary, DICTIONARY_FORMAT, DictionaryError, parseDictionary, type DictionaryEntry, type DictionaryLookup } from "./dictionary-core.js";
27
+ export { fileKind, ProseLinter, type CodeExtractor, type FileKind, type ProseLinterOptions } from "./engine.js";
28
+ export { formatJson, formatText, summarize, type RuleCount, type Summary } from "./format.js";
29
+ export { buildLexicon, type Lexicon } from "./lexicon.js";
30
+ export { getRule, RULES, type Rule } from "./rules/index.js";
31
+ export { isRuleId, RULE_IDS, type Finding, type RuleId, type RuleSetting, type Severity } from "./types.js";
32
+ export { DEFAULT_WORD_LIST, type WordListEntry } from "./word-list.js";