@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.
- package/README.md +26 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/config-core.d.ts +92 -0
- package/dist/config-core.d.ts.map +1 -0
- package/dist/config-core.js +198 -0
- package/dist/config-core.js.map +1 -0
- package/dist/config.d.ts +2 -76
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -186
- package/dist/config.js.map +1 -1
- package/dist/core.d.ts +16 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +16 -0
- package/dist/core.js.map +1 -0
- package/dist/dictionary-core.d.ts +72 -0
- package/dist/dictionary-core.d.ts.map +1 -0
- package/dist/dictionary-core.js +150 -0
- package/dist/dictionary-core.js.map +1 -0
- package/dist/dictionary.d.ts +4 -69
- package/dist/dictionary.d.ts.map +1 -1
- package/dist/dictionary.js +4 -147
- package/dist/dictionary.js.map +1 -1
- package/dist/engine.d.ts +44 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +122 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lint.d.ts +6 -16
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +7 -116
- package/dist/lint.js.map +1 -1
- package/dist/source/code.d.ts +9 -0
- package/dist/source/code.d.ts.map +1 -0
- package/dist/source/code.js +16 -0
- package/dist/source/code.js.map +1 -0
- package/package.json +10 -2
- package/src/config-core.ts +258 -0
- package/src/config.ts +3 -238
- package/src/core.test.ts +87 -0
- package/src/core.ts +32 -0
- package/src/dictionary-core.ts +158 -0
- package/src/dictionary.ts +4 -155
- package/src/engine.ts +138 -0
- package/src/index.ts +4 -0
- package/src/lint.ts +10 -115
- package/src/source/code.ts +17 -0
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
export type DictionaryEntry = {
|
|
37
|
+
readonly word : string;
|
|
38
|
+
readonly pos? : string;
|
|
39
|
+
readonly approved? : boolean;
|
|
40
|
+
readonly forms? : readonly string[];
|
|
41
|
+
readonly alternatives? : readonly string[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type DictionaryLookup =
|
|
45
|
+
| { readonly status : "approved" }
|
|
46
|
+
| { readonly status : "not-approved"; readonly alternatives : readonly string[] }
|
|
47
|
+
| { readonly status : "unknown" };
|
|
48
|
+
|
|
49
|
+
export const DICTIONARY_FORMAT = "ste-lint-dictionary";
|
|
50
|
+
|
|
51
|
+
/** An error in a dictionary file. */
|
|
52
|
+
export class DictionaryError extends Error {
|
|
53
|
+
override readonly name : string = "DictionaryError";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const normalize = (word : string) : string => word.trim().toLowerCase().replace(/[’‘]/g, "'");
|
|
57
|
+
|
|
58
|
+
/** The approved words of a local dictionary. */
|
|
59
|
+
export class Dictionary {
|
|
60
|
+
private readonly approved = new Set<string>();
|
|
61
|
+
private readonly approvedNouns = new Set<string>();
|
|
62
|
+
private readonly notApproved = new Map<string, readonly string[]>();
|
|
63
|
+
/** The approved nouns. The noun-cluster rule uses them. */
|
|
64
|
+
readonly nouns : Set<string> = new Set<string>();
|
|
65
|
+
/** The approved verbs. The imperative and missing-subject heuristics use them. */
|
|
66
|
+
readonly verbs : Set<string> = new Set<string>();
|
|
67
|
+
|
|
68
|
+
constructor(entries : readonly DictionaryEntry[]) {
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const words = [entry.word, ...(entry.forms ?? [])].flatMap((form) => normalize(form).split(/\s+/)).filter((form) => form.length > 0);
|
|
71
|
+
if (entry.approved === false) {
|
|
72
|
+
for (const word of words) this.notApproved.set(word, entry.alternatives ?? []);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
for (const word of words) this.approved.add(word);
|
|
76
|
+
const pos = (entry.pos ?? "").toLowerCase();
|
|
77
|
+
const base = normalize(entry.word);
|
|
78
|
+
if (pos === "n" || pos === "noun") {
|
|
79
|
+
this.approvedNouns.add(base);
|
|
80
|
+
this.nouns.add(base);
|
|
81
|
+
}
|
|
82
|
+
if (pos === "v" || pos === "verb") this.verbs.add(base);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The number of approved words and forms. */
|
|
87
|
+
get size() : number {
|
|
88
|
+
return this.approved.size;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The status of `word` in the dictionary. */
|
|
92
|
+
lookup(word : string) : DictionaryLookup {
|
|
93
|
+
const lower = normalize(word);
|
|
94
|
+
const candidates = [lower];
|
|
95
|
+
if (lower.endsWith("'s")) candidates.push(lower.slice(0, -2));
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
if (this.approved.has(candidate)) return { status : "approved" };
|
|
98
|
+
const plurals = [candidate.slice(0, -1), candidate.slice(0, -2), candidate.slice(0, -3) + "y"];
|
|
99
|
+
if (candidate.endsWith("s") && plurals.some((singular) => this.approvedNouns.has(singular))) return { status : "approved" };
|
|
100
|
+
}
|
|
101
|
+
for (const candidate of candidates) {
|
|
102
|
+
const alternatives = this.notApproved.get(candidate);
|
|
103
|
+
if (alternatives !== undefined) return { status : "not-approved", alternatives };
|
|
104
|
+
}
|
|
105
|
+
return { status : "unknown" };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** This function makes a dictionary from parsed JSON. `source` is the file name for error messages. */
|
|
110
|
+
export function parseDictionary(value : unknown, source : string) : Dictionary {
|
|
111
|
+
if (Array.isArray(value)) {
|
|
112
|
+
if (!value.every((word) => typeof word === "string")) {
|
|
113
|
+
throw new DictionaryError(`${source}: the array form must contain only strings.`);
|
|
114
|
+
}
|
|
115
|
+
return new Dictionary(value.map((word : string) => ({ word })));
|
|
116
|
+
}
|
|
117
|
+
if (typeof value !== "object" || value === null) {
|
|
118
|
+
throw new DictionaryError(`${source}: the file must contain an array of words or an object with "entries".`);
|
|
119
|
+
}
|
|
120
|
+
const record = value as Record<string, unknown>;
|
|
121
|
+
const format = record["format"];
|
|
122
|
+
if (format !== undefined && format !== DICTIONARY_FORMAT) {
|
|
123
|
+
throw new DictionaryError(`${source}: "format" must be "${DICTIONARY_FORMAT}".`);
|
|
124
|
+
}
|
|
125
|
+
const entries = record["entries"];
|
|
126
|
+
if (!Array.isArray(entries)) throw new DictionaryError(`${source}: "entries" must be an array.`);
|
|
127
|
+
const parsed : DictionaryEntry[] = entries.map((entry : unknown, index : number) => parseEntry(entry, `${source}: entries[${index}]`));
|
|
128
|
+
return new Dictionary(parsed);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stringArray(value : unknown, where : string) : string[] | undefined {
|
|
132
|
+
if (value === undefined) return undefined;
|
|
133
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
134
|
+
throw new DictionaryError(`${where} must be an array of strings.`);
|
|
135
|
+
}
|
|
136
|
+
return value as string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseEntry(entry : unknown, where : string) : DictionaryEntry {
|
|
140
|
+
if (typeof entry === "string") return { word : entry };
|
|
141
|
+
if (typeof entry !== "object" || entry === null) throw new DictionaryError(`${where} must be a string or an object.`);
|
|
142
|
+
const record = entry as Record<string, unknown>;
|
|
143
|
+
const word = record["word"];
|
|
144
|
+
if (typeof word !== "string" || word.trim().length === 0) throw new DictionaryError(`${where}.word must be a string that is not empty.`);
|
|
145
|
+
const pos = record["pos"];
|
|
146
|
+
if (pos !== undefined && typeof pos !== "string") throw new DictionaryError(`${where}.pos must be a string.`);
|
|
147
|
+
const approved = record["approved"];
|
|
148
|
+
if (approved !== undefined && typeof approved !== "boolean") throw new DictionaryError(`${where}.approved must be true or false.`);
|
|
149
|
+
const forms = stringArray(record["forms"], `${where}.forms`);
|
|
150
|
+
const alternatives = stringArray(record["alternatives"], `${where}.alternatives`);
|
|
151
|
+
return {
|
|
152
|
+
word,
|
|
153
|
+
...(pos === undefined ? {} : { pos }),
|
|
154
|
+
...(approved === undefined ? {} : { approved }),
|
|
155
|
+
...(forms === undefined ? {} : { forms }),
|
|
156
|
+
...(alternatives === undefined ? {} : { alternatives }),
|
|
157
|
+
};
|
|
158
|
+
}
|
package/src/dictionary.ts
CHANGED
|
@@ -1,163 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
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.
|
|
2
|
+
* The local dictionary file. The format and the lookup are in
|
|
3
|
+
* `dictionary-core.ts`, which a browser can use. This module reads the file.
|
|
34
4
|
*/
|
|
35
5
|
|
|
36
6
|
import { readFileSync } from "node:fs";
|
|
7
|
+
import { DictionaryError, parseDictionary, type Dictionary } from "./dictionary-core.js";
|
|
37
8
|
|
|
38
|
-
export
|
|
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
|
-
}
|
|
9
|
+
export * from "./dictionary-core.js";
|
|
161
10
|
|
|
162
11
|
/** This function reads a dictionary file. */
|
|
163
12
|
export function loadDictionary(path : string) : Dictionary {
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lint engine without a file system. It extracts the prose of a text,
|
|
3
|
+
* runs the rules on each prose unit, and gives the findings with their lines
|
|
4
|
+
* and columns. A browser can use this module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ResolvedConfig } from "./config-core.js";
|
|
8
|
+
import { buildLexicon, type Lexicon } from "./lexicon.js";
|
|
9
|
+
import { RULES, type Rule } from "./rules/index.js";
|
|
10
|
+
import { WordListMatcher } from "./rules/word-list-matcher.js";
|
|
11
|
+
import { LineIndex } from "./source/lines.js";
|
|
12
|
+
import { extractMarkdown, type Extraction } from "./source/markdown.js";
|
|
13
|
+
import { isSuppressed } from "./source/suppressions.js";
|
|
14
|
+
import type { Finding, RuleId, Severity } from "./types.js";
|
|
15
|
+
import { buildUnit, type ProseUnit } from "./units.js";
|
|
16
|
+
|
|
17
|
+
export type FileKind = "markdown" | "mdx" | "typescript";
|
|
18
|
+
|
|
19
|
+
/** The kind of a file, from its extension, or null when the linter cannot read it. */
|
|
20
|
+
export function fileKind(path : string) : FileKind | null {
|
|
21
|
+
const lower = path.toLowerCase();
|
|
22
|
+
if (lower.endsWith(".mdx")) return "mdx";
|
|
23
|
+
if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "markdown";
|
|
24
|
+
if (/\.(?:[mc]?ts|tsx|[mc]?js|jsx)$/.test(lower) && !lower.endsWith(".d.ts")) return "typescript";
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A function that extracts the prose of a TypeScript or JavaScript file:
|
|
30
|
+
* the doc comments, and the JSX text of a TSX or JSX file.
|
|
31
|
+
*/
|
|
32
|
+
export type CodeExtractor = (text : string, path : string) => Extraction;
|
|
33
|
+
|
|
34
|
+
export type ProseLinterOptions = {
|
|
35
|
+
/**
|
|
36
|
+
* The extractor for TypeScript and JavaScript files. Without it, the
|
|
37
|
+
* linter gives no findings for these files. The extractor of this
|
|
38
|
+
* package (`extractCode`) needs the `typescript` package.
|
|
39
|
+
*/
|
|
40
|
+
readonly extractCode? : CodeExtractor;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A linter for one configuration, for text in memory. It makes the lexicon
|
|
45
|
+
* and the word list matcher one time. It reads no file.
|
|
46
|
+
*/
|
|
47
|
+
export class ProseLinter {
|
|
48
|
+
readonly lexicon : Lexicon;
|
|
49
|
+
private readonly wordList : WordListMatcher;
|
|
50
|
+
private readonly rules : ReadonlyArray<{ readonly rule : Rule; readonly override : Severity | null }>;
|
|
51
|
+
private readonly extractCode : CodeExtractor | null;
|
|
52
|
+
|
|
53
|
+
constructor(readonly config : ResolvedConfig, lexicon? : Lexicon, options : ProseLinterOptions = {}) {
|
|
54
|
+
this.lexicon = lexicon ?? buildLexicon(config);
|
|
55
|
+
this.wordList = new WordListMatcher(config.wordList);
|
|
56
|
+
this.extractCode = options.extractCode ?? null;
|
|
57
|
+
this.rules = RULES.flatMap((rule) => {
|
|
58
|
+
const setting = config.rules[rule.id];
|
|
59
|
+
if (setting === "off") return [];
|
|
60
|
+
if (rule.id === "unknown-word" && this.lexicon.dictionary === null) return [];
|
|
61
|
+
return [{ rule, override : setting ?? null }];
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The prose units of a file. */
|
|
66
|
+
units(text : string, path : string) : ProseUnit[] {
|
|
67
|
+
return this.extract(text, path).blocks.flatMap((block) => buildUnit(block, this.lexicon) ?? []);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The findings in `text`. `path` gives the kind of file, and it is the file name in the findings. */
|
|
71
|
+
lintText(text : string, path : string) : Finding[] {
|
|
72
|
+
const extraction = this.extract(text, path);
|
|
73
|
+
const lineIndex = new LineIndex(text);
|
|
74
|
+
type Raw = { ruleId : RuleId; severity : Severity; start : number; end : number; message : string; eachWord : boolean };
|
|
75
|
+
const raw : Raw[] = [];
|
|
76
|
+
for (const block of extraction.blocks) {
|
|
77
|
+
const unit = buildUnit(block, this.lexicon);
|
|
78
|
+
if (unit === null) continue;
|
|
79
|
+
const wordOnly = unit.kind !== "paragraph";
|
|
80
|
+
for (const { rule, override } of this.rules) {
|
|
81
|
+
if (wordOnly && rule.scope !== "word") continue;
|
|
82
|
+
rule.check(unit, {
|
|
83
|
+
config : this.config,
|
|
84
|
+
lexicon : this.lexicon,
|
|
85
|
+
wordList : this.wordList,
|
|
86
|
+
report : (report) => {
|
|
87
|
+
raw.push({
|
|
88
|
+
ruleId : rule.id,
|
|
89
|
+
severity : override ?? report.severity ?? rule.defaultSeverity,
|
|
90
|
+
start : report.start,
|
|
91
|
+
end : report.end,
|
|
92
|
+
message : report.message,
|
|
93
|
+
eachWord : reportsEachWord(rule),
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// An unknown word that another rule reports already gets no second finding. A finding for a
|
|
100
|
+
// sentence, a paragraph or a noun cluster does not report each word in it.
|
|
101
|
+
const others = raw.filter((finding) => finding.ruleId !== "unknown-word" && finding.eachWord);
|
|
102
|
+
const kept = raw.filter((finding) => finding.ruleId !== "unknown-word"
|
|
103
|
+
|| !others.some((other) => other.start < finding.end && finding.start < other.end));
|
|
104
|
+
return kept
|
|
105
|
+
.filter((finding) => !isSuppressed(extraction.suppressions, finding.ruleId, finding.start))
|
|
106
|
+
.sort((a, b) => a.start - b.start || a.end - b.end || a.ruleId.localeCompare(b.ruleId))
|
|
107
|
+
.map((finding) => {
|
|
108
|
+
const start = lineIndex.position(finding.start);
|
|
109
|
+
const end = lineIndex.position(Math.max(finding.start, finding.end));
|
|
110
|
+
return {
|
|
111
|
+
file : path,
|
|
112
|
+
line : start.line,
|
|
113
|
+
column : start.column,
|
|
114
|
+
endLine : end.line,
|
|
115
|
+
endColumn : end.column,
|
|
116
|
+
ruleId : finding.ruleId,
|
|
117
|
+
severity : finding.severity,
|
|
118
|
+
message : finding.message,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private extract(text : string, path : string) : Extraction {
|
|
124
|
+
const kind = fileKind(path);
|
|
125
|
+
if (kind === "typescript") return this.extractCode === null ? { blocks : [], suppressions : [] } : this.extractCode(text, path);
|
|
126
|
+
if (kind === null) return { blocks : [], suppressions : [] };
|
|
127
|
+
return extractMarkdown(text, { mdx : kind === "mdx" });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* True when a finding of the rule is about each word in its range. A word rule reports a word or a
|
|
133
|
+
* phrase, for example a modal verb or a word list phrase. The noun-cluster rule reports a group of
|
|
134
|
+
* nouns, and the sentence and paragraph rules report a full sentence or paragraph.
|
|
135
|
+
*/
|
|
136
|
+
function reportsEachWord(rule : Rule) : boolean {
|
|
137
|
+
return rule.scope === "word" && rule.id !== "noun-cluster";
|
|
138
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export {
|
|
9
|
+
baseConfig,
|
|
9
10
|
CONFIG_FILE_NAME,
|
|
10
11
|
ConfigError,
|
|
11
12
|
DEFAULT_IGNORE,
|
|
@@ -17,6 +18,7 @@ export {
|
|
|
17
18
|
mergeWordList,
|
|
18
19
|
resolveConfig,
|
|
19
20
|
validateConfig,
|
|
21
|
+
type BaseConfigOptions,
|
|
20
22
|
type Limits,
|
|
21
23
|
type LimitsConfig,
|
|
22
24
|
type ResolvedConfig,
|
|
@@ -27,7 +29,9 @@ export { Dictionary, DICTIONARY_FORMAT, DictionaryError, loadDictionary, parseDi
|
|
|
27
29
|
export { findFiles, globToRegExp } from "./files.js";
|
|
28
30
|
export { formatJson, formatText, summarize, type RuleCount, type Summary } from "./format.js";
|
|
29
31
|
export { buildLexicon, type Lexicon } from "./lexicon.js";
|
|
32
|
+
export { ProseLinter, type CodeExtractor, type ProseLinterOptions } from "./engine.js";
|
|
30
33
|
export { fileKind, Linter, lintText, type FileKind } from "./lint.js";
|
|
34
|
+
export { extractCode } from "./source/code.js";
|
|
31
35
|
export { getRule, RULES, type Rule } from "./rules/index.js";
|
|
32
36
|
export { parseArgs, runCli, USAGE, type CliIo, type CliOptions } from "./run-cli.js";
|
|
33
37
|
export { isRuleId, RULE_IDS, type Finding, type RuleId, type RuleSetting, type Severity } from "./types.js";
|
package/src/lint.ts
CHANGED
|
@@ -1,105 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
2
|
+
* The linter for files: the engine of `engine.ts`, with the extractor of
|
|
3
|
+
* TypeScript doc comments and the file system.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import type { ResolvedConfig } from "./config.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { extractMarkdown, type Extraction } from "./source/markdown.js";
|
|
13
|
-
import { isSuppressed } from "./source/suppressions.js";
|
|
14
|
-
import { extractTsdoc } from "./source/tsdoc.js";
|
|
15
|
-
import { extractJsxText } from "./source/jsx.js";
|
|
16
|
-
import type { Finding, RuleId, Severity } from "./types.js";
|
|
17
|
-
import { buildUnit, type ProseUnit } from "./units.js";
|
|
8
|
+
import { ProseLinter } from "./engine.js";
|
|
9
|
+
import type { Lexicon } from "./lexicon.js";
|
|
10
|
+
import { extractCode } from "./source/code.js";
|
|
11
|
+
import type { Finding } from "./types.js";
|
|
18
12
|
|
|
19
|
-
export type FileKind
|
|
20
|
-
|
|
21
|
-
/** The kind of a file, from its extension, or null when the linter cannot read it. */
|
|
22
|
-
export function fileKind(path : string) : FileKind | null {
|
|
23
|
-
const lower = path.toLowerCase();
|
|
24
|
-
if (lower.endsWith(".mdx")) return "mdx";
|
|
25
|
-
if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "markdown";
|
|
26
|
-
if (/\.(?:[mc]?ts|tsx|[mc]?js|jsx)$/.test(lower) && !lower.endsWith(".d.ts")) return "typescript";
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
13
|
+
export { fileKind, type FileKind } from "./engine.js";
|
|
29
14
|
|
|
30
15
|
/** A linter for one configuration. It makes the lexicon and the word list matcher one time. */
|
|
31
|
-
export class Linter {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
private readonly rules : ReadonlyArray<{ readonly rule : Rule; readonly override : Severity | null }>;
|
|
35
|
-
|
|
36
|
-
constructor(readonly config : ResolvedConfig, lexicon? : Lexicon) {
|
|
37
|
-
this.lexicon = lexicon ?? buildLexicon(config);
|
|
38
|
-
this.wordList = new WordListMatcher(config.wordList);
|
|
39
|
-
this.rules = RULES.flatMap((rule) => {
|
|
40
|
-
const setting = config.rules[rule.id];
|
|
41
|
-
if (setting === "off") return [];
|
|
42
|
-
if (rule.id === "unknown-word" && this.lexicon.dictionary === null) return [];
|
|
43
|
-
return [{ rule, override : setting ?? null }];
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** The prose units of a file. */
|
|
48
|
-
units(text : string, path : string) : ProseUnit[] {
|
|
49
|
-
return extract(text, path).blocks.flatMap((block) => buildUnit(block, this.lexicon) ?? []);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** The findings in `text`. `path` gives the kind of file, and it is the file name in the findings. */
|
|
53
|
-
lintText(text : string, path : string) : Finding[] {
|
|
54
|
-
const extraction = extract(text, path);
|
|
55
|
-
const lineIndex = new LineIndex(text);
|
|
56
|
-
type Raw = { ruleId : RuleId; severity : Severity; start : number; end : number; message : string; eachWord : boolean };
|
|
57
|
-
const raw : Raw[] = [];
|
|
58
|
-
for (const block of extraction.blocks) {
|
|
59
|
-
const unit = buildUnit(block, this.lexicon);
|
|
60
|
-
if (unit === null) continue;
|
|
61
|
-
const wordOnly = unit.kind !== "paragraph";
|
|
62
|
-
for (const { rule, override } of this.rules) {
|
|
63
|
-
if (wordOnly && rule.scope !== "word") continue;
|
|
64
|
-
rule.check(unit, {
|
|
65
|
-
config : this.config,
|
|
66
|
-
lexicon : this.lexicon,
|
|
67
|
-
wordList : this.wordList,
|
|
68
|
-
report : (report) => {
|
|
69
|
-
raw.push({
|
|
70
|
-
ruleId : rule.id,
|
|
71
|
-
severity : override ?? report.severity ?? rule.defaultSeverity,
|
|
72
|
-
start : report.start,
|
|
73
|
-
end : report.end,
|
|
74
|
-
message : report.message,
|
|
75
|
-
eachWord : reportsEachWord(rule),
|
|
76
|
-
});
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
// An unknown word that another rule reports already gets no second finding. A finding for a
|
|
82
|
-
// sentence, a paragraph or a noun cluster does not report each word in it.
|
|
83
|
-
const others = raw.filter((finding) => finding.ruleId !== "unknown-word" && finding.eachWord);
|
|
84
|
-
const kept = raw.filter((finding) => finding.ruleId !== "unknown-word"
|
|
85
|
-
|| !others.some((other) => other.start < finding.end && finding.start < other.end));
|
|
86
|
-
return kept
|
|
87
|
-
.filter((finding) => !isSuppressed(extraction.suppressions, finding.ruleId, finding.start))
|
|
88
|
-
.sort((a, b) => a.start - b.start || a.end - b.end || a.ruleId.localeCompare(b.ruleId))
|
|
89
|
-
.map((finding) => {
|
|
90
|
-
const start = lineIndex.position(finding.start);
|
|
91
|
-
const end = lineIndex.position(Math.max(finding.start, finding.end));
|
|
92
|
-
return {
|
|
93
|
-
file : path,
|
|
94
|
-
line : start.line,
|
|
95
|
-
column : start.column,
|
|
96
|
-
endLine : end.line,
|
|
97
|
-
endColumn : end.column,
|
|
98
|
-
ruleId : finding.ruleId,
|
|
99
|
-
severity : finding.severity,
|
|
100
|
-
message : finding.message,
|
|
101
|
-
};
|
|
102
|
-
});
|
|
16
|
+
export class Linter extends ProseLinter {
|
|
17
|
+
constructor(config : ResolvedConfig, lexicon? : Lexicon) {
|
|
18
|
+
super(config, lexicon, { extractCode });
|
|
103
19
|
}
|
|
104
20
|
|
|
105
21
|
/** The findings in a file on disk. `displayPath` is the file name in the findings. */
|
|
@@ -108,27 +24,6 @@ export class Linter {
|
|
|
108
24
|
}
|
|
109
25
|
}
|
|
110
26
|
|
|
111
|
-
/**
|
|
112
|
-
* True when a finding of the rule is about each word in its range. A word rule reports a word or a
|
|
113
|
-
* phrase, for example a modal verb or a word list phrase. The noun-cluster rule reports a group of
|
|
114
|
-
* nouns, and the sentence and paragraph rules report a full sentence or paragraph.
|
|
115
|
-
*/
|
|
116
|
-
function reportsEachWord(rule : Rule) : boolean {
|
|
117
|
-
return rule.scope === "word" && rule.id !== "noun-cluster";
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function extract(text : string, path : string) : Extraction {
|
|
121
|
-
const kind = fileKind(path);
|
|
122
|
-
if (kind === "typescript") {
|
|
123
|
-
const comments = extractTsdoc(text, path);
|
|
124
|
-
if (!/\.[jt]sx$/i.test(path)) return comments;
|
|
125
|
-
// The text that the user interface shows
|
|
126
|
-
return { blocks : [...comments.blocks, ...extractJsxText(text, path).blocks], suppressions : comments.suppressions };
|
|
127
|
-
}
|
|
128
|
-
if (kind === null) return { blocks : [], suppressions : [] };
|
|
129
|
-
return extractMarkdown(text, { mdx : kind === "mdx" });
|
|
130
|
-
}
|
|
131
|
-
|
|
132
27
|
/** The findings in `text`, with a new linter. To lint many files, make one `Linter` and use it again. */
|
|
133
28
|
export function lintText(text : string, path : string, config : ResolvedConfig, lexicon? : Lexicon) : Finding[] {
|
|
134
29
|
return new Linter(config, lexicon).lintText(text, path);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The prose of a TypeScript or JavaScript file: the doc comments, and the
|
|
3
|
+
* text that the JSX of a TSX or JSX file shows. This module needs the
|
|
4
|
+
* `typescript` package, but no file system. A browser can use it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { extractJsxText } from "./jsx.js";
|
|
8
|
+
import type { Extraction } from "./markdown.js";
|
|
9
|
+
import { extractTsdoc } from "./tsdoc.js";
|
|
10
|
+
|
|
11
|
+
/** This function extracts the prose of the source text of a TypeScript or JavaScript file. `path` gives the kind of file. */
|
|
12
|
+
export function extractCode(text : string, path : string) : Extraction {
|
|
13
|
+
const comments = extractTsdoc(text, path);
|
|
14
|
+
if (!/\.[jt]sx$/i.test(path)) return comments;
|
|
15
|
+
// The text that the user interface shows
|
|
16
|
+
return { blocks : [...comments.blocks, ...extractJsxText(text, path).blocks], suppressions : comments.suppressions };
|
|
17
|
+
}
|