@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.
- package/README.md +224 -1
- package/dist/.tsbuildinfo +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +103 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +251 -0
- package/dist/config.js.map +1 -0
- package/dist/dictionary.d.ts +74 -0
- package/dist/dictionary.d.ts.map +1 -0
- package/dist/dictionary.js +169 -0
- package/dist/dictionary.js.map +1 -0
- package/dist/files.d.ts +35 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +181 -0
- package/dist/files.js.map +1 -0
- package/dist/format.d.ts +21 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +61 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/lexicon.d.ts +33 -0
- package/dist/lexicon.d.ts.map +1 -0
- package/dist/lexicon.js +71 -0
- package/dist/lexicon.js.map +1 -0
- package/dist/lint.d.ts +28 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +132 -0
- package/dist/lint.js.map +1 -0
- package/dist/rules/about-quantity.d.ts +3 -0
- package/dist/rules/about-quantity.d.ts.map +1 -0
- package/dist/rules/about-quantity.js +34 -0
- package/dist/rules/about-quantity.js.map +1 -0
- package/dist/rules/index.d.ts +8 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +35 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/ing-verb.d.ts +9 -0
- package/dist/rules/ing-verb.d.ts.map +1 -0
- package/dist/rules/ing-verb.js +60 -0
- package/dist/rules/ing-verb.js.map +1 -0
- package/dist/rules/latin-abbreviation.d.ts +3 -0
- package/dist/rules/latin-abbreviation.d.ts.map +1 -0
- package/dist/rules/latin-abbreviation.js +48 -0
- package/dist/rules/latin-abbreviation.js.map +1 -0
- package/dist/rules/missing-subject.d.ts +16 -0
- package/dist/rules/missing-subject.d.ts.map +1 -0
- package/dist/rules/missing-subject.js +138 -0
- package/dist/rules/missing-subject.js.map +1 -0
- package/dist/rules/modal-verb.d.ts +3 -0
- package/dist/rules/modal-verb.d.ts.map +1 -0
- package/dist/rules/modal-verb.js +72 -0
- package/dist/rules/modal-verb.js.map +1 -0
- package/dist/rules/noun-cluster.d.ts +17 -0
- package/dist/rules/noun-cluster.d.ts.map +1 -0
- package/dist/rules/noun-cluster.js +83 -0
- package/dist/rules/noun-cluster.js.map +1 -0
- package/dist/rules/paragraph-length.d.ts +3 -0
- package/dist/rules/paragraph-length.d.ts.map +1 -0
- package/dist/rules/paragraph-length.js +19 -0
- package/dist/rules/paragraph-length.js.map +1 -0
- package/dist/rules/passive-instruction.d.ts +15 -0
- package/dist/rules/passive-instruction.d.ts.map +1 -0
- package/dist/rules/passive-instruction.js +38 -0
- package/dist/rules/passive-instruction.js.map +1 -0
- package/dist/rules/pronouns.d.ts +4 -0
- package/dist/rules/pronouns.d.ts.map +1 -0
- package/dist/rules/pronouns.js +57 -0
- package/dist/rules/pronouns.js.map +1 -0
- package/dist/rules/rule.d.ts +46 -0
- package/dist/rules/rule.d.ts.map +1 -0
- package/dist/rules/rule.js +31 -0
- package/dist/rules/rule.js.map +1 -0
- package/dist/rules/semicolon.d.ts +3 -0
- package/dist/rules/semicolon.d.ts.map +1 -0
- package/dist/rules/semicolon.js +20 -0
- package/dist/rules/semicolon.js.map +1 -0
- package/dist/rules/sentence-length.d.ts +3 -0
- package/dist/rules/sentence-length.d.ts.map +1 -0
- package/dist/rules/sentence-length.js +31 -0
- package/dist/rules/sentence-length.js.map +1 -0
- package/dist/rules/unknown-word.d.ts +9 -0
- package/dist/rules/unknown-word.d.ts.map +1 -0
- package/dist/rules/unknown-word.js +49 -0
- package/dist/rules/unknown-word.js.map +1 -0
- package/dist/rules/word-list-matcher.d.ts +25 -0
- package/dist/rules/word-list-matcher.d.ts.map +1 -0
- package/dist/rules/word-list-matcher.js +60 -0
- package/dist/rules/word-list-matcher.js.map +1 -0
- package/dist/rules/word-list.d.ts +3 -0
- package/dist/rules/word-list.d.ts.map +1 -0
- package/dist/rules/word-list.js +26 -0
- package/dist/rules/word-list.js.map +1 -0
- package/dist/run-cli.d.ts +27 -0
- package/dist/run-cli.d.ts.map +1 -0
- package/dist/run-cli.js +176 -0
- package/dist/run-cli.js.map +1 -0
- package/dist/source/blocks.d.ts +55 -0
- package/dist/source/blocks.d.ts.map +1 -0
- package/dist/source/blocks.js +464 -0
- package/dist/source/blocks.js.map +1 -0
- package/dist/source/inline.d.ts +42 -0
- package/dist/source/inline.d.ts.map +1 -0
- package/dist/source/inline.js +396 -0
- package/dist/source/inline.js.map +1 -0
- package/dist/source/jsx.d.ts +18 -0
- package/dist/source/jsx.d.ts.map +1 -0
- package/dist/source/jsx.js +137 -0
- package/dist/source/jsx.js.map +1 -0
- package/dist/source/lines.d.ts +34 -0
- package/dist/source/lines.d.ts.map +1 -0
- package/dist/source/lines.js +83 -0
- package/dist/source/lines.js.map +1 -0
- package/dist/source/markdown.d.ts +23 -0
- package/dist/source/markdown.d.ts.map +1 -0
- package/dist/source/markdown.js +42 -0
- package/dist/source/markdown.js.map +1 -0
- package/dist/source/suppressions.d.ts +38 -0
- package/dist/source/suppressions.d.ts.map +1 -0
- package/dist/source/suppressions.js +69 -0
- package/dist/source/suppressions.js.map +1 -0
- package/dist/source/tsdoc.d.ts +40 -0
- package/dist/source/tsdoc.d.ts.map +1 -0
- package/dist/source/tsdoc.js +176 -0
- package/dist/source/tsdoc.js.map +1 -0
- package/dist/text/grammar.d.ts +91 -0
- package/dist/text/grammar.d.ts.map +1 -0
- package/dist/text/grammar.js +289 -0
- package/dist/text/grammar.js.map +1 -0
- package/dist/text/phrases.d.ts +30 -0
- package/dist/text/phrases.d.ts.map +1 -0
- package/dist/text/phrases.js +65 -0
- package/dist/text/phrases.js.map +1 -0
- package/dist/text/sentences.d.ts +45 -0
- package/dist/text/sentences.d.ts.map +1 -0
- package/dist/text/sentences.js +141 -0
- package/dist/text/sentences.js.map +1 -0
- package/dist/text/tokens.d.ts +35 -0
- package/dist/text/tokens.d.ts.map +1 -0
- package/dist/text/tokens.js +113 -0
- package/dist/text/tokens.js.map +1 -0
- package/dist/text/words.d.ts +58 -0
- package/dist/text/words.d.ts.map +1 -0
- package/dist/text/words.js +159 -0
- package/dist/text/words.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/units.d.ts +47 -0
- package/dist/units.d.ts.map +1 -0
- package/dist/units.js +42 -0
- package/dist/units.js.map +1 -0
- package/dist/word-list.d.ts +34 -0
- package/dist/word-list.d.ts.map +1 -0
- package/dist/word-list.js +90 -0
- package/dist/word-list.js.map +1 -0
- package/package.json +41 -4
- package/src/cli.ts +9 -0
- package/src/config.test.ts +188 -0
- package/src/config.ts +318 -0
- package/src/dictionary.test.ts +82 -0
- package/src/dictionary.ts +177 -0
- package/src/files.test.ts +107 -0
- package/src/files.ts +191 -0
- package/src/format.ts +78 -0
- package/src/index.test.ts +28 -0
- package/src/index.ts +34 -0
- package/src/lexicon.ts +92 -0
- package/src/lint.test.ts +182 -0
- package/src/lint.ts +135 -0
- package/src/rules/about-quantity.ts +33 -0
- package/src/rules/index.ts +40 -0
- package/src/rules/ing-verb.test.ts +48 -0
- package/src/rules/ing-verb.ts +54 -0
- package/src/rules/latin-abbreviation.ts +46 -0
- package/src/rules/missing-subject.test.ts +53 -0
- package/src/rules/missing-subject.ts +129 -0
- package/src/rules/modal-verb.test.ts +45 -0
- package/src/rules/modal-verb.ts +72 -0
- package/src/rules/noun-cluster.test.ts +49 -0
- package/src/rules/noun-cluster.ts +82 -0
- package/src/rules/paragraph-length.test.ts +30 -0
- package/src/rules/paragraph-length.ts +19 -0
- package/src/rules/passive-instruction.test.ts +38 -0
- package/src/rules/passive-instruction.ts +37 -0
- package/src/rules/pronouns.test.ts +38 -0
- package/src/rules/pronouns.ts +54 -0
- package/src/rules/punctuation.test.ts +59 -0
- package/src/rules/rule.ts +73 -0
- package/src/rules/semicolon.ts +19 -0
- package/src/rules/sentence-length.test.ts +52 -0
- package/src/rules/sentence-length.ts +32 -0
- package/src/rules/unknown-word.test.ts +81 -0
- package/src/rules/unknown-word.ts +43 -0
- package/src/rules/word-list-matcher.ts +68 -0
- package/src/rules/word-list.test.ts +110 -0
- package/src/rules/word-list.ts +25 -0
- package/src/run-cli.test.ts +189 -0
- package/src/run-cli.ts +193 -0
- package/src/source/blocks.ts +534 -0
- package/src/source/inline.test.ts +92 -0
- package/src/source/inline.ts +416 -0
- package/src/source/jsx.test.ts +46 -0
- package/src/source/jsx.ts +147 -0
- package/src/source/lines.test.ts +51 -0
- package/src/source/lines.ts +91 -0
- package/src/source/markdown.test.ts +210 -0
- package/src/source/markdown.ts +49 -0
- package/src/source/suppressions.ts +79 -0
- package/src/source/tsdoc.test.ts +137 -0
- package/src/source/tsdoc.ts +189 -0
- package/src/text/grammar.ts +304 -0
- package/src/text/phrases.ts +72 -0
- package/src/text/sentences.ts +148 -0
- package/src/text/text.test.ts +262 -0
- package/src/text/tokens.ts +135 -0
- package/src/text/words.ts +183 -0
- package/src/types.ts +39 -0
- package/src/units.ts +86 -0
- package/src/word-list.ts +128 -0
- package/ste.config.schema.json +87 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Lexicon } from "../lexicon.js";
|
|
2
|
+
import { isParticiple, nextCounted, startsObject } from "../text/grammar.js";
|
|
3
|
+
import { isCapitalized, isCounted, isWord, type Token } from "../text/tokens.js";
|
|
4
|
+
import { BE_FORMS, CONJUNCTIONS, DETERMINERS, HAVE_FORMS, MODALS, PREPOSITIONS, SUBJECT_PRONOUNS } from "../text/words.js";
|
|
5
|
+
import { tokenRange, type Rule } from "./rule.js";
|
|
6
|
+
|
|
7
|
+
/** Auxiliary verbs that start a sentence without a subject: "Is true when ...", "Can be null.". */
|
|
8
|
+
const AUXILIARY_STARTS = new Set(["is", "are", "was", "were", "has", "have", "had", "does", "can", "cannot", "must", "will", "should", "may", "might", "would", "could"]);
|
|
9
|
+
|
|
10
|
+
/** Words that end in -s but are not verbs. */
|
|
11
|
+
const NOT_VERBS = new Set([
|
|
12
|
+
"this", "its", "always", "perhaps", "sometimes", "whereas", "unless", "plus", "thus", "yes", "less", "across",
|
|
13
|
+
"towards", "afterwards", "besides", "nevertheless", "regardless", "various", "numerous", "previous", "analysis",
|
|
14
|
+
"basis", "bias", "canvas", "status", "series", "species", "atlas", "alias", "chaos", "corpus", "focus", "bonus",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/** Words after a participle that show a missing subject: "Called when ...", "Used by ...". */
|
|
18
|
+
const AFTER_PARTICIPLE = new Set([...PREPOSITIONS, "when", "if", "once", "only", "while", "as", "whenever", "unless"]);
|
|
19
|
+
|
|
20
|
+
function stems(word : string) : string[] {
|
|
21
|
+
const forms = [word.slice(0, -1)];
|
|
22
|
+
if (word.endsWith("es")) forms.push(word.slice(0, -2));
|
|
23
|
+
if (word.endsWith("ies")) forms.push(word.slice(0, -3) + "y");
|
|
24
|
+
return forms;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when a comma comes later and a subject comes after it: "Based on X, the monitor ...". */
|
|
28
|
+
function hasLaterSubject(tokens : readonly Token[], from : number) : boolean {
|
|
29
|
+
for (let i = from; i < tokens.length; i++) {
|
|
30
|
+
const token = tokens[i]!;
|
|
31
|
+
if (token.kind !== "punct" || token.text !== ",") continue;
|
|
32
|
+
const next = tokens[nextCounted(tokens, i + 1)];
|
|
33
|
+
if (next === undefined) return false;
|
|
34
|
+
return next.kind === "code" || isCapitalized(next) || (isWord(next) && (DETERMINERS.has(next.lower) || SUBJECT_PRONOUNS.has(next.lower)));
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PLURAL_AUXILIARIES = new Set(["are", "were", "have", "do", "don't", "aren't", "weren't", "can", "cannot", "must", "will"]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True when a verb for a plural subject comes later, after a noun:
|
|
43
|
+
* "Samples at or above this value wait ...", "Values below zero are ...".
|
|
44
|
+
* Then the first word is a plural noun, not a verb.
|
|
45
|
+
*/
|
|
46
|
+
function hasPluralVerbLater(tokens : readonly Token[], from : number, lexicon : Lexicon) : boolean {
|
|
47
|
+
for (let i = from; i < tokens.length; i++) {
|
|
48
|
+
const token = tokens[i]!;
|
|
49
|
+
const previous = tokens[i - 1];
|
|
50
|
+
if (!isWord(token) || !isWord(previous)) continue;
|
|
51
|
+
if (DETERMINERS.has(previous.lower) || PREPOSITIONS.has(previous.lower) || MODALS.has(previous.lower) || previous.lower === "not") continue;
|
|
52
|
+
if (PLURAL_AUXILIARIES.has(token.lower)) return true;
|
|
53
|
+
if (lexicon.verbs.has(token.lower) && !token.lower.endsWith("s")) return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Relative pronouns. A word before one of them is a noun: "Tags whose content ...". */
|
|
59
|
+
const RELATIVE_PRONOUNS = new Set(["whose", "which", "who", "whom"]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* True when the words after the first word show that the first word is a
|
|
63
|
+
* plural noun, not a verb. These words show a noun:
|
|
64
|
+
*
|
|
65
|
+
* - An auxiliary verb, a conjunction or "of": "Values are ...", "Values of ..."
|
|
66
|
+
* - "per" or a relative pronoun: "Samples per round", "Tags whose ..."
|
|
67
|
+
* - A base form of a verb: "Monitors ask ..."
|
|
68
|
+
* - A participle and a preposition: "Handles returned by ...".
|
|
69
|
+
*/
|
|
70
|
+
function isNounSubject(tokens : readonly Token[], index : number, lexicon : Lexicon) : boolean {
|
|
71
|
+
const next = tokens[index + 1];
|
|
72
|
+
if (!isWord(next)) return false;
|
|
73
|
+
const lower = next.lower;
|
|
74
|
+
if (BE_FORMS.has(lower) || MODALS.has(lower) || HAVE_FORMS.has(lower) || CONJUNCTIONS.has(lower) || lower === "of") return true;
|
|
75
|
+
if (lower === "per" || RELATIVE_PRONOUNS.has(lower)) return true;
|
|
76
|
+
const after = tokens[index + 2];
|
|
77
|
+
const objectAfter = after === undefined || after.kind === "punct" || startsObject(after) || (isWord(after) && PREPOSITIONS.has(after.lower));
|
|
78
|
+
if (lexicon.verbs.has(lower) && !lower.endsWith("s") && !DETERMINERS.has(lower) && objectAfter) return true;
|
|
79
|
+
return isParticiple(lower) && isWord(after) && PREPOSITIONS.has(after.lower);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The message when the sentence at `tokens` starts without a subject, or null. */
|
|
83
|
+
export function missingSubject(tokens : readonly Token[], lexicon : Lexicon) : { index : number; message : string } | null {
|
|
84
|
+
const k = nextCounted(tokens, 0);
|
|
85
|
+
const first = tokens[k];
|
|
86
|
+
if (!isWord(first) || !isCapitalized(first)) return null;
|
|
87
|
+
const lower = first.lower;
|
|
88
|
+
const next = tokens[k + 1];
|
|
89
|
+
const last = [...tokens].reverse().find((token) => token.kind === "punct");
|
|
90
|
+
if (AUXILIARY_STARTS.has(lower)) {
|
|
91
|
+
if (last?.text === "?") return null;
|
|
92
|
+
return { index : k, message : `This sentence has no subject. Write the subject before "${first.text}", for example "This value ${lower} ...".` };
|
|
93
|
+
}
|
|
94
|
+
if (/^[a-z]{3,}s$/.test(lower) && !/(ss|us|is|ous|ics|ness)$/.test(lower) && !NOT_VERBS.has(lower)) {
|
|
95
|
+
const knownVerb = stems(lower).some((stem) => lexicon.verbs.has(stem));
|
|
96
|
+
if (next?.text === "," || isNounSubject(tokens, k, lexicon)) return null;
|
|
97
|
+
if (!knownVerb && !startsObject(next)) return null;
|
|
98
|
+
if (isWord(next) && PREPOSITIONS.has(next.lower) && hasPluralVerbLater(tokens, k + 2, lexicon)) return null;
|
|
99
|
+
const example = lower === "returns" ? "This function gives ..." : `This function ${lower} ...`;
|
|
100
|
+
return { index : k, message : `"${first.text}" has no subject. Do not omit the subject. Write a full sentence, for example "${example}".` };
|
|
101
|
+
}
|
|
102
|
+
if (isParticiple(lower) && isWord(next) && AFTER_PARTICIPLE.has(next.lower) && !hasLaterSubject(tokens, k + 1)) {
|
|
103
|
+
return { index : k, message : `"${first.text}" starts a sentence that has no subject. Write a full sentence with a subject in the active voice, for example "The monitor uses this value when ...".` };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The rule examines the summary and the `@remarks` text of doc comments.
|
|
110
|
+
* For example, "Returns the value." and "Called when the page is hidden."
|
|
111
|
+
* omit the subject. Text after `@param` and `@returns`, and list items, can
|
|
112
|
+
* be fragments, so the rule does not examine them.
|
|
113
|
+
*/
|
|
114
|
+
export const missingSubjectRule : Rule = {
|
|
115
|
+
id : "missing-subject",
|
|
116
|
+
description : "Find doc comment sentences that have no subject, for example \"Returns the value.\".",
|
|
117
|
+
ste : "4.2",
|
|
118
|
+
defaultSeverity : "error",
|
|
119
|
+
scope : "sentence",
|
|
120
|
+
check(unit, context) {
|
|
121
|
+
if (unit.origin.source !== "tsdoc" || unit.origin.tsdocSection === "tag" || unit.list !== null) return;
|
|
122
|
+
for (const sentence of unit.sentences) {
|
|
123
|
+
if (!sentence.tokens.some(isCounted)) continue;
|
|
124
|
+
const found = missingSubject(sentence.tokens, context.lexicon);
|
|
125
|
+
if (found === null) continue;
|
|
126
|
+
context.report({ ...tokenRange(sentence.tokens, found.index, found.index + 1), message : found.message });
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveConfig } from "../config.js";
|
|
3
|
+
import { Linter } from "../lint.js";
|
|
4
|
+
|
|
5
|
+
const linter = new Linter(resolveConfig({}, { root : "/" }));
|
|
6
|
+
const lint = (text : string, path : string = "doc.md") => linter.lintText(text, path).filter((finding) => finding.ruleId === "modal-verb");
|
|
7
|
+
const found = (text : string) : string[] => lint(text).map((finding) => text.slice(finding.column - 1, finding.endColumn - 1));
|
|
8
|
+
|
|
9
|
+
describe("modal-verb", () => {
|
|
10
|
+
it("reports the modal verbs that are not approved, without case", () => {
|
|
11
|
+
expect(found("You should stop. It may fail. It might fail. It would fail. You shall stop.")).toEqual(["should", "may", "might", "would", "shall"]);
|
|
12
|
+
expect(found("SHOULD and Should are the same.")).toEqual(["SHOULD", "Should"]);
|
|
13
|
+
expect(found("It shouldn't fail and wouldn't stop.")).toEqual(["shouldn't", "wouldn't"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("reports the modal phrases with \"to\"", () => {
|
|
17
|
+
expect(found("You have to stop. It has to stop. You need to stop. It needs to stop. You ought to stop.")).toEqual([
|
|
18
|
+
"have to", "has to", "need to", "needs to", "ought to",
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("gives the approved alternative in the message", () => {
|
|
23
|
+
expect(lint("You should stop.")[0]!.message).toContain("\"must\"");
|
|
24
|
+
expect(lint("It may fail.")[0]!.message).toContain("\"can\"");
|
|
25
|
+
expect(lint("You need to stop.")[0]!.message).toContain("imperative");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("uses word boundaries", () => {
|
|
29
|
+
expect(found("The shoulder, mayor, nightmare and wouldbe words are fine.")).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("does not report the approved modal verbs, the month May or a noun", () => {
|
|
33
|
+
expect(found("You can stop. You must stop. It will stop. It could stop.")).toEqual([]);
|
|
34
|
+
expect(found("The release is in May 2026. It came on 3 May. Since May, it works.")).toEqual([]);
|
|
35
|
+
expect(found("The need to stop is clear. I have two tools. It has tools.")).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not report modal verbs in code spans or in quoted text", () => {
|
|
39
|
+
expect(found("The `should` assertion and the word \"may\" are quoted.")).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("examines headings and table cells", () => {
|
|
43
|
+
expect(lint("# You should read this\n\n| a |\n| --- |\n| It may stop. |")).toHaveLength(2);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { isClauseStart } from "../text/grammar.js";
|
|
2
|
+
import { isNumber, isWord, type Token } from "../text/tokens.js";
|
|
3
|
+
import { DETERMINERS } from "../text/words.js";
|
|
4
|
+
import { forEachToken, surface, tokenRange, type Rule } from "./rule.js";
|
|
5
|
+
|
|
6
|
+
// These tables are maps, not object literals, so a word such as "constructor" does not find an Object property.
|
|
7
|
+
const SINGLE : ReadonlyMap<string, string> = new Map([
|
|
8
|
+
["should", "Write \"must\" for an obligation, or \"if\" for a condition (\"If X occurs, ...\")."],
|
|
9
|
+
["shouldn", "Write \"must not\" for an obligation."],
|
|
10
|
+
["may", "Write \"can\" for a possibility or a permission, or \"possibly\"."],
|
|
11
|
+
["might", "Write \"can\" or \"possibly\"."],
|
|
12
|
+
["would", "Write \"can\", or use a simple tense (\"is\", \"gives\")."],
|
|
13
|
+
["wouldn", "Write \"cannot\", or use a simple tense (\"is not\")."],
|
|
14
|
+
["shall", "Write \"must\"."],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const CONTRACTIONS : ReadonlyMap<string, string> = new Map([
|
|
18
|
+
["shouldn't", "shouldn"],
|
|
19
|
+
["wouldn't", "wouldn"],
|
|
20
|
+
["mightn't", "might"],
|
|
21
|
+
["shan't", "shall"],
|
|
22
|
+
["mayn't", "may"],
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const IMPERATIVE_HELP = "Write the instruction in the imperative (\"Do X\"), or use \"must\".";
|
|
26
|
+
const NECESSARY_HELP = "Write the instruction in the imperative (\"Do X\"), or use \"must\" or \"it is necessary to\".";
|
|
27
|
+
|
|
28
|
+
const WITH_TO : ReadonlyMap<string, string> = new Map([
|
|
29
|
+
["ought", "Write \"must\"."],
|
|
30
|
+
["have", IMPERATIVE_HELP],
|
|
31
|
+
["has", IMPERATIVE_HELP],
|
|
32
|
+
["had", "Use the simple past tense of the verb, or \"must\"."],
|
|
33
|
+
["having", "Rewrite the sentence with \"must\" or with the imperative."],
|
|
34
|
+
["need", NECESSARY_HELP],
|
|
35
|
+
["needs", NECESSARY_HELP],
|
|
36
|
+
["needed", "Write \"it was necessary to\", or rewrite the sentence."],
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/** True when "May" is the month: "May 2026", "3 May". */
|
|
40
|
+
function isMonth(tokens : readonly Token[], index : number) : boolean {
|
|
41
|
+
const token = tokens[index]!;
|
|
42
|
+
if (token.text !== "May") return false;
|
|
43
|
+
return !isClauseStart(tokens, index) || isNumber(tokens[index + 1]) || isNumber(tokens[index - 1]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const modalVerb : Rule = {
|
|
47
|
+
id : "modal-verb",
|
|
48
|
+
description : "Find modal verbs that are not approved: should, may, might, would, shall, ought to, have to, need to.",
|
|
49
|
+
ste : "3.1, 3.4, 5.3",
|
|
50
|
+
defaultSeverity : "error",
|
|
51
|
+
scope : "word",
|
|
52
|
+
check(unit, context) {
|
|
53
|
+
forEachToken(unit, (tokens, i) => {
|
|
54
|
+
const token = tokens[i]!;
|
|
55
|
+
if (!isWord(token)) return;
|
|
56
|
+
const key = CONTRACTIONS.get(token.lower) ?? token.lower;
|
|
57
|
+
const help = SINGLE.get(key);
|
|
58
|
+
if (help !== undefined) {
|
|
59
|
+
if (key === "may" && isMonth(tokens, i)) return;
|
|
60
|
+
context.report({ ...tokenRange(tokens, i, i + 1), message : `Do not use the modal verb "${token.text}". ${help}` });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const withTo = WITH_TO.get(key);
|
|
64
|
+
const next = tokens[i + 1];
|
|
65
|
+
if (withTo === undefined || !isWord(next) || next.lower !== "to") return;
|
|
66
|
+
const previous = tokens[i - 1];
|
|
67
|
+
// "the need to ..." is a noun phrase, not a modal verb.
|
|
68
|
+
if (key.startsWith("need") && isWord(previous) && DETERMINERS.has(previous.lower)) return;
|
|
69
|
+
context.report({ ...tokenRange(tokens, i, i + 2), message : `Do not use "${surface(tokens, i, i + 2)}". ${withTo}` });
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveConfig, type SteConfigFile } from "../config.js";
|
|
3
|
+
import { parseDictionary } from "../dictionary.js";
|
|
4
|
+
import { buildLexicon } from "../lexicon.js";
|
|
5
|
+
import { Linter } from "../lint.js";
|
|
6
|
+
|
|
7
|
+
const lint = (text : string, file : SteConfigFile = {}) =>
|
|
8
|
+
new Linter(resolveConfig(file, { root : "/" })).lintText(text, "doc.md").filter((finding) => finding.ruleId === "noun-cluster");
|
|
9
|
+
const found = (text : string, file : SteConfigFile = {}) : string[] => lint(text, file).map((finding) => text.slice(finding.column - 1, finding.endColumn - 1));
|
|
10
|
+
|
|
11
|
+
describe("noun-cluster", () => {
|
|
12
|
+
it("reports more than 3 known nouns in sequence", () => {
|
|
13
|
+
expect(found("Read the browser memory usage threshold value now.")).toEqual(["browser memory usage threshold value"]);
|
|
14
|
+
const findings = lint("Read the browser memory usage threshold now.");
|
|
15
|
+
expect(findings[0]!.severity).toBe("warning");
|
|
16
|
+
expect(findings[0]!.message).toContain("4 nouns");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("accepts 3 nouns, and a preposition or an article stops the group", () => {
|
|
20
|
+
expect(found("Read the memory usage threshold. Read the threshold of the memory usage value.")).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("counts glossary nouns, abbreviations and code, and a multi-word technical noun as one noun", () => {
|
|
24
|
+
expect(found("The heartbeat `seq` API timer value is late.", { technicalNouns : ["heartbeat"] })).toEqual(["heartbeat `seq` API timer value"]);
|
|
25
|
+
expect(found("The memory usage threshold value is low.")).toEqual(["memory usage threshold value"]);
|
|
26
|
+
expect(found("The memory usage threshold value is low.", { technicalNouns : ["memory usage"] })).toEqual([]);
|
|
27
|
+
expect(found("The memory usage threshold value unit is low.", { technicalNouns : ["memory usage"] })).toEqual(["memory usage threshold value unit"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("stops a group after a plural noun", () => {
|
|
31
|
+
expect(found("The browser timers memory usage threshold.")).toEqual([]);
|
|
32
|
+
expect(found("The browser memory usage thresholds are low.")).toEqual(["browser memory usage thresholds"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not count words that are frequently verbs", () => {
|
|
36
|
+
expect(found("The page queue update process value is fine.")).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("uses the nouns of the dictionary", () => {
|
|
40
|
+
const config = resolveConfig({}, { root : "/" });
|
|
41
|
+
const dictionary = parseDictionary({ entries : [{ word : "valve", pos : "noun" }, { word : "pump", pos : "noun" }] }, "d.json");
|
|
42
|
+
const linter = new Linter(config, buildLexicon(config, dictionary));
|
|
43
|
+
expect(linter.lintText("The pump valve memory usage is low.", "doc.md").filter((finding) => finding.ruleId === "noun-cluster")).toHaveLength(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("uses the limit of the configuration", () => {
|
|
47
|
+
expect(found("The memory usage threshold.", { limits : { nounClusterNouns : 2 } })).toEqual(["memory usage threshold"]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Lexicon } from "../lexicon.js";
|
|
2
|
+
import { isAbbreviation, isWord, type Token } from "../text/tokens.js";
|
|
3
|
+
import { surface, tokenRange, type Rule } from "./rule.js";
|
|
4
|
+
|
|
5
|
+
type NounUnit = {
|
|
6
|
+
/** The number of tokens in the noun. A multi-word technical noun is one noun. */
|
|
7
|
+
readonly length : number;
|
|
8
|
+
readonly plural : boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function singular(word : string) : string[] {
|
|
12
|
+
const forms : string[] = [];
|
|
13
|
+
if (word.endsWith("ies")) forms.push(word.slice(0, -3) + "y");
|
|
14
|
+
if (word.endsWith("es")) forms.push(word.slice(0, -2));
|
|
15
|
+
if (word.endsWith("s")) forms.push(word.slice(0, -1));
|
|
16
|
+
return forms;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The noun at `tokens[index]`, or null when the token is not a known noun. */
|
|
20
|
+
function nounAt(tokens : readonly Token[], index : number, lexicon : Lexicon) : NounUnit | null {
|
|
21
|
+
const token = tokens[index];
|
|
22
|
+
if (token === undefined) return null;
|
|
23
|
+
if (token.kind === "code") {
|
|
24
|
+
const kind = token.placeholder?.kind;
|
|
25
|
+
return kind === "code" || kind === "link" ? { length : 1, plural : false } : null;
|
|
26
|
+
}
|
|
27
|
+
if (!isWord(token)) return null;
|
|
28
|
+
const phrase = lexicon.technicalNounPhrases.longestMatch(tokens, index);
|
|
29
|
+
if (phrase > 1) return { length : phrase, plural : tokens[index + phrase - 1]!.lower.endsWith("s") };
|
|
30
|
+
if (isAbbreviation(token)) return { length : 1, plural : /[a-z]s$/.test(token.text) };
|
|
31
|
+
if (lexicon.nouns.has(token.lower)) return { length : 1, plural : false };
|
|
32
|
+
if (singular(token.lower).some((form) => lexicon.nouns.has(form))) return { length : 1, plural : true };
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The rule counts a group of nouns in sequence, with no article,
|
|
38
|
+
* preposition or punctuation between them. Only these known nouns count:
|
|
39
|
+
*
|
|
40
|
+
* - Technical nouns and proper nouns from the glossary
|
|
41
|
+
* - Nouns from the dictionary
|
|
42
|
+
* - A built-in list of common software nouns
|
|
43
|
+
* - Abbreviations and code.
|
|
44
|
+
*
|
|
45
|
+
* Only the last noun of a group can be plural. Without a part-of-speech
|
|
46
|
+
* tagger, the rule cannot know if other words are nouns, so it does not
|
|
47
|
+
* count them. Thus it misses some clusters, but it seldom reports a false
|
|
48
|
+
* one.
|
|
49
|
+
*/
|
|
50
|
+
export const nounCluster : Rule = {
|
|
51
|
+
id : "noun-cluster",
|
|
52
|
+
description : "Find groups of more than 3 nouns in sequence (noun clusters).",
|
|
53
|
+
ste : "2.1",
|
|
54
|
+
defaultSeverity : "warning",
|
|
55
|
+
scope : "word",
|
|
56
|
+
check(unit, context) {
|
|
57
|
+
const max = context.config.limits.nounClusterNouns;
|
|
58
|
+
for (const sentence of unit.sentences) {
|
|
59
|
+
const tokens = sentence.tokens;
|
|
60
|
+
let i = 0;
|
|
61
|
+
while (i < tokens.length) {
|
|
62
|
+
let count = 0;
|
|
63
|
+
let j = i;
|
|
64
|
+
let plural = false;
|
|
65
|
+
while (!plural) {
|
|
66
|
+
const noun = nounAt(tokens, j, context.lexicon);
|
|
67
|
+
if (noun === null) break;
|
|
68
|
+
count++;
|
|
69
|
+
j += noun.length;
|
|
70
|
+
plural = noun.plural;
|
|
71
|
+
}
|
|
72
|
+
if (count > max) {
|
|
73
|
+
context.report({
|
|
74
|
+
...tokenRange(tokens, i, j),
|
|
75
|
+
message : `"${surface(tokens, i, j)}" has ${count} nouns in sequence. Use no more than ${max}. Use a preposition to divide the group, for example "the interval of the heartbeat" instead of "heartbeat interval".`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
i = count > 0 ? j : i + 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveConfig, type SteConfigFile } from "../config.js";
|
|
3
|
+
import { Linter } from "../lint.js";
|
|
4
|
+
|
|
5
|
+
const lint = (text : string, file : SteConfigFile = {}) =>
|
|
6
|
+
new Linter(resolveConfig(file, { root : "/" })).lintText(text, "doc.md").filter((finding) => finding.ruleId === "paragraph-length");
|
|
7
|
+
|
|
8
|
+
const paragraph = (count : number) : string => Array.from({ length : count }, (_, i) => `Sentence ${i + 1} is short.`).join(" ");
|
|
9
|
+
|
|
10
|
+
describe("paragraph-length", () => {
|
|
11
|
+
it("reports a paragraph of more than 6 sentences as a warning", () => {
|
|
12
|
+
const findings = lint(paragraph(7));
|
|
13
|
+
expect(findings).toHaveLength(1);
|
|
14
|
+
expect(findings[0]).toMatchObject({ severity : "warning", line : 1, column : 1 });
|
|
15
|
+
expect(findings[0]!.message).toContain("7 sentences");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("accepts 6 sentences, and counts each paragraph separately", () => {
|
|
19
|
+
expect(lint(paragraph(6))).toEqual([]);
|
|
20
|
+
expect(lint(`${paragraph(4)}\n\n${paragraph(4)}`)).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not count an abbreviation as the end of a sentence", () => {
|
|
24
|
+
expect(lint("One, e.g. two. Three. Four. Five. Six. Seven, i.e. eight.")).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("uses the limit of the configuration", () => {
|
|
28
|
+
expect(lint(paragraph(3), { limits : { paragraphSentences : 2 } })).toHaveLength(1);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Rule } from "./rule.js";
|
|
2
|
+
|
|
3
|
+
export const paragraphLength : Rule = {
|
|
4
|
+
id : "paragraph-length",
|
|
5
|
+
description : "A paragraph can have no more than 6 sentences.",
|
|
6
|
+
ste : "6.6",
|
|
7
|
+
defaultSeverity : "warning",
|
|
8
|
+
scope : "paragraph",
|
|
9
|
+
check(unit, context) {
|
|
10
|
+
const limit = context.config.limits.paragraphSentences;
|
|
11
|
+
const count = unit.sentences.length;
|
|
12
|
+
if (count <= limit) return;
|
|
13
|
+
context.report({
|
|
14
|
+
start : unit.start,
|
|
15
|
+
end : unit.end,
|
|
16
|
+
message : `This paragraph has ${count} sentences. Use no more than ${limit} sentences in a paragraph. Divide it into two paragraphs, each with one topic.`,
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveConfig } from "../config.js";
|
|
3
|
+
import { Linter } from "../lint.js";
|
|
4
|
+
|
|
5
|
+
const linter = new Linter(resolveConfig({}, { root : "/" }));
|
|
6
|
+
const lint = (text : string, path : string = "doc.md") => linter.lintText(text, path).filter((finding) => finding.ruleId === "passive-instruction");
|
|
7
|
+
const found = (text : string) : string[] => lint(text).map((finding) => text.split("\n")[finding.line - 1]!.slice(finding.column - 1, finding.endColumn - 1));
|
|
8
|
+
|
|
9
|
+
describe("passive-instruction", () => {
|
|
10
|
+
it("reports an obligation in the passive voice in any sentence", () => {
|
|
11
|
+
expect(found("The token must be set before the start. The value has to be written first. The file should be removed.")).toEqual([
|
|
12
|
+
"must be set", "has to be written", "should be removed",
|
|
13
|
+
]);
|
|
14
|
+
const findings = lint("The token must be set first.");
|
|
15
|
+
expect(findings[0]!.severity).toBe("warning");
|
|
16
|
+
expect(findings[0]!.message).toContain("imperative");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("reports the passive voice in an imperative sentence", () => {
|
|
20
|
+
expect(found("Run the build, which is triggered by CI.")).toEqual(["is triggered"]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("reports the passive voice in a step of an ordered list", () => {
|
|
24
|
+
expect(found("1. The cache is cleared by the script.\n2. The tests are started.")).toEqual(["is cleared", "are started"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("does not report a participle in a condition, which shows a state", () => {
|
|
28
|
+
expect(found("Make sure that the server is started. Wait until the build is finished. If the file is deleted, start again.")).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does not report the passive voice in a description", () => {
|
|
32
|
+
expect(found("The file is created by the build. Samples are discarded when the page is hidden. It can be used.")).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not report \"be\" with an adjective", () => {
|
|
36
|
+
expect(found("Be careful. Make sure that the value is positive.")).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { findPassives } from "../text/grammar.js";
|
|
2
|
+
import { surface, tokenRange, type Rule } from "./rule.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rule examines these constructions:
|
|
6
|
+
*
|
|
7
|
+
* - An obligation in the passive voice ("must be set", "has to be called",
|
|
8
|
+
* "needs to be installed") in any sentence. This is an instruction in a
|
|
9
|
+
* passive form.
|
|
10
|
+
* - Any passive ("is cleared", "are triggered by CI") in a procedural
|
|
11
|
+
* sentence: an imperative sentence or an item of an ordered list. A
|
|
12
|
+
* participle after "be" in a condition clause ("if the file is deleted",
|
|
13
|
+
* "make sure that the server is started") shows a state, so the rule
|
|
14
|
+
* ignores it.
|
|
15
|
+
*/
|
|
16
|
+
export const passiveInstruction : Rule = {
|
|
17
|
+
id : "passive-instruction",
|
|
18
|
+
description : "Find the passive voice in instructions. An instruction uses the active voice and the imperative.",
|
|
19
|
+
ste : "3.4, 3.6, 5.3",
|
|
20
|
+
defaultSeverity : "warning",
|
|
21
|
+
scope : "sentence",
|
|
22
|
+
check(unit, context) {
|
|
23
|
+
for (const sentence of unit.sentences) {
|
|
24
|
+
const procedural = sentence.instruction || unit.list?.ordered === true;
|
|
25
|
+
for (const passive of findPassives(sentence.tokens)) {
|
|
26
|
+
if (!passive.obligation && !(procedural && !passive.inCondition)) continue;
|
|
27
|
+
const from = passive.start;
|
|
28
|
+
const to = passive.participle + 1;
|
|
29
|
+
const phrase = surface(sentence.tokens, from, to);
|
|
30
|
+
const message = passive.obligation
|
|
31
|
+
? `"${phrase}" is an instruction in the passive voice. Write the instruction in the imperative, for example "Set the value before you start the monitor."`
|
|
32
|
+
: `"${phrase}" is in the passive voice. In an instruction, use the active voice: write the imperative, or tell who or what does the action.`;
|
|
33
|
+
context.report({ ...tokenRange(sentence.tokens, from, to), message });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveConfig } from "../config.js";
|
|
3
|
+
import { Linter } from "../lint.js";
|
|
4
|
+
import type { RuleId } from "../types.js";
|
|
5
|
+
|
|
6
|
+
const linter = new Linter(resolveConfig({}, { root : "/" }));
|
|
7
|
+
const lint = (rule : RuleId, text : string) => linter.lintText(text, "doc.md").filter((finding) => finding.ruleId === rule);
|
|
8
|
+
const found = (rule : RuleId, text : string) : string[] => lint(rule, text).map((finding) => text.slice(finding.column - 1, finding.endColumn - 1));
|
|
9
|
+
|
|
10
|
+
describe("gendered-pronoun", () => {
|
|
11
|
+
it("reports gendered pronouns as errors", () => {
|
|
12
|
+
expect(found("gendered-pronoun", "He opens it. She saves his file. Give her the key, and him the code. It is hers. He did it himself, and she herself.")).toEqual([
|
|
13
|
+
"He", "She", "his", "her", "him", "hers", "He", "himself", "she", "herself",
|
|
14
|
+
]);
|
|
15
|
+
const findings = lint("gendered-pronoun", "The user saves his work.");
|
|
16
|
+
expect(findings[0]!.severity).toBe("error");
|
|
17
|
+
expect(findings[0]!.message).toContain("\"they\"");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not report other words, code or quoted text", () => {
|
|
21
|
+
expect(found("gendered-pronoun", "The theme, the shell and `he` and \"she\" are fine. They save their work.")).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("first-person", () => {
|
|
26
|
+
it("reports I, me, my, our and us as warnings", () => {
|
|
27
|
+
expect(found("first-person", "I run it. Give me the file. My file. Our tool. Tell us. Let's go. It is ours.")).toEqual([
|
|
28
|
+
"I", "me", "My", "Our", "us", "Let's", "ours",
|
|
29
|
+
]);
|
|
30
|
+
const findings = lint("first-person", "Our tool works.");
|
|
31
|
+
expect(findings[0]!.severity).toBe("warning");
|
|
32
|
+
expect(findings[0]!.message).toContain("\"we\"");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not report \"we\", the abbreviation US, or I/O", () => {
|
|
36
|
+
expect(found("first-person", "We release the tool in the US. The I/O is fast. You can use it.")).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isWord } from "../text/tokens.js";
|
|
2
|
+
import { forEachToken, tokenRange, type Rule } from "./rule.js";
|
|
3
|
+
|
|
4
|
+
const GENDERED = new Set([
|
|
5
|
+
"he", "she", "his", "her", "him", "hers", "himself", "herself",
|
|
6
|
+
"he's", "she's", "he'd", "she'd", "he'll", "she'll",
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export const genderedPronoun : Rule = {
|
|
10
|
+
id : "gendered-pronoun",
|
|
11
|
+
description : "Find gendered pronouns: he, she, his, her, him, hers, himself and herself.",
|
|
12
|
+
ste : "GR-3, GR-7",
|
|
13
|
+
defaultSeverity : "error",
|
|
14
|
+
scope : "word",
|
|
15
|
+
check(unit, context) {
|
|
16
|
+
forEachToken(unit, (tokens, i) => {
|
|
17
|
+
const token = tokens[i]!;
|
|
18
|
+
if (!isWord(token) || !GENDERED.has(token.lower)) return;
|
|
19
|
+
context.report({
|
|
20
|
+
...tokenRange(tokens, i, i + 1),
|
|
21
|
+
message : `Do not use the gendered pronoun "${token.text}". Write "they" or "it", or write the noun again.`,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const SINGULAR = new Set(["i", "me", "my", "mine", "myself", "i'm", "i've", "i'll", "i'd"]);
|
|
28
|
+
const PLURAL = new Set(["our", "ours", "ourselves"]);
|
|
29
|
+
|
|
30
|
+
export const firstPerson : Rule = {
|
|
31
|
+
id : "first-person",
|
|
32
|
+
description : "Find first-person pronouns other than \"we\": I, me, my, our and us. Use \"we\" only for the organization that writes the text.",
|
|
33
|
+
ste : "GR-3",
|
|
34
|
+
defaultSeverity : "warning",
|
|
35
|
+
scope : "word",
|
|
36
|
+
check(unit, context) {
|
|
37
|
+
forEachToken(unit, (tokens, i) => {
|
|
38
|
+
const token = tokens[i]!;
|
|
39
|
+
if (!isWord(token)) return;
|
|
40
|
+
const lower = token.lower;
|
|
41
|
+
let message : string | null = null;
|
|
42
|
+
if (SINGULAR.has(lower)) {
|
|
43
|
+
message = `Do not use "${token.text}". Write "you" for the reader, or "we" for the organization that writes the text.`;
|
|
44
|
+
} else if (PLURAL.has(lower)) {
|
|
45
|
+
message = `Do not use "${token.text}". Write the name of the project or the product, or rewrite the sentence with "we" as the subject.`;
|
|
46
|
+
} else if (lower === "us" && token.text !== "US") {
|
|
47
|
+
message = "Do not use \"us\". Rewrite the sentence with \"we\" as the subject.";
|
|
48
|
+
} else if (lower === "let's") {
|
|
49
|
+
message = "Do not use \"let's\". Write the instruction in the imperative.";
|
|
50
|
+
}
|
|
51
|
+
if (message !== null) context.report({ ...tokenRange(tokens, i, i + 1), message });
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
};
|