@mark1russell7/ste-lint 0.0.0-stage → 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 +246 -2
- 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-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 +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +68 -0
- package/dist/config.js.map +1 -0
- 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 +9 -0
- package/dist/dictionary.d.ts.map +1 -0
- package/dist/dictionary.js +26 -0
- package/dist/dictionary.js.map +1 -0
- 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/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 +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -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 +18 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +23 -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/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/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 +54 -3
- package/src/cli.ts +9 -0
- package/src/config-core.ts +258 -0
- package/src/config.test.ts +188 -0
- package/src/config.ts +83 -0
- package/src/core.test.ts +87 -0
- package/src/core.ts +32 -0
- package/src/dictionary-core.ts +158 -0
- package/src/dictionary.test.ts +82 -0
- package/src/dictionary.ts +26 -0
- package/src/engine.ts +138 -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 +38 -0
- package/src/lexicon.ts +92 -0
- package/src/lint.test.ts +182 -0
- package/src/lint.ts +30 -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/code.ts +17 -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
package/src/lint.test.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { resolveConfig, type SteConfigFile } from "./config.js";
|
|
6
|
+
import { formatJson, formatText, summarize } from "./format.js";
|
|
7
|
+
import { fileKind, Linter, lintText } from "./lint.js";
|
|
8
|
+
import { getRule, RULES } from "./rules/index.js";
|
|
9
|
+
import { RULE_IDS, isRuleId, type Finding } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const config = (file : SteConfigFile = {}) => resolveConfig(file, { root : "/" });
|
|
12
|
+
const ids = (findings : readonly Finding[]) : string[] => findings.map((finding) => finding.ruleId);
|
|
13
|
+
|
|
14
|
+
describe("fileKind", () => {
|
|
15
|
+
it("knows Markdown, MDX and TypeScript files", () => {
|
|
16
|
+
expect(fileKind("a.md")).toBe("markdown");
|
|
17
|
+
expect(fileKind("a.markdown")).toBe("markdown");
|
|
18
|
+
expect(fileKind("a.MDX")).toBe("mdx");
|
|
19
|
+
expect(fileKind("a.ts")).toBe("typescript");
|
|
20
|
+
expect(fileKind("a.tsx")).toBe("typescript");
|
|
21
|
+
expect(fileKind("a.mjs")).toBe("typescript");
|
|
22
|
+
expect(fileKind("a.d.ts")).toBeNull();
|
|
23
|
+
expect(fileKind("a.json")).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("Linter on TSX files", () => {
|
|
28
|
+
it("examines the text that the user interface shows, and the doc comments", () => {
|
|
29
|
+
const source = [
|
|
30
|
+
"/** Returns the page. */",
|
|
31
|
+
"export function Page() {",
|
|
32
|
+
" return <p>The run may fail; <code>pnpm results</code> utilizes the data.</p>;",
|
|
33
|
+
"}",
|
|
34
|
+
].join("\n");
|
|
35
|
+
const findings = lintText(source, "Page.tsx", config());
|
|
36
|
+
expect(ids(findings)).toEqual(expect.arrayContaining(["missing-subject", "modal-verb", "semicolon", "word-list"]));
|
|
37
|
+
const modal = findings.find((finding) => finding.ruleId === "modal-verb")!;
|
|
38
|
+
expect([modal.line, modal.column]).toEqual([3, source.split("\n")[2]!.indexOf("may") + 1]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("does not examine the text of JSX in a .ts file", () => {
|
|
42
|
+
expect(lintText("const a = 'You should stop.';", "a.ts", config())).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("Linter", () => {
|
|
47
|
+
it("gives findings with the file, the position and the rule, in order", () => {
|
|
48
|
+
const findings = lintText("# Title\n\nYou should stop; it may fail.", "doc.md", config());
|
|
49
|
+
expect(findings.map((finding) => [finding.file, finding.line, finding.column, finding.ruleId, finding.severity])).toEqual([
|
|
50
|
+
["doc.md", 3, 5, "modal-verb", "error"],
|
|
51
|
+
["doc.md", 3, 16, "semicolon", "error"],
|
|
52
|
+
["doc.md", 3, 21, "modal-verb", "error"],
|
|
53
|
+
]);
|
|
54
|
+
expect(findings[0]).toMatchObject({ endLine : 3, endColumn : 11 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("gives correct positions in files with CRLF line ends", () => {
|
|
58
|
+
const markdown = lintText("# Title\r\n\r\nText. You should stop.\r\n", "doc.md", config());
|
|
59
|
+
expect(markdown.map((finding) => [finding.line, finding.column])).toEqual([[3, 11]]);
|
|
60
|
+
const source = lintText("/**\r\n * Text.\r\n * You should stop.\r\n */\r\nexport const a = 1;\r\n", "a.ts", config());
|
|
61
|
+
expect(source.map((finding) => [finding.line, finding.column])).toEqual([[3, 8]]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("gives correct positions after an HTML entity of a character outside the BMP", () => {
|
|
65
|
+
const text = "Text 😀 then you should stop.";
|
|
66
|
+
const findings = lintText(text, "doc.md", config());
|
|
67
|
+
expect(findings.map((finding) => [finding.ruleId, finding.column, finding.endColumn])).toEqual([["modal-verb", text.indexOf("should") + 1, text.indexOf("should") + 7]]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("gives no findings for a file kind that it cannot read", () => {
|
|
71
|
+
expect(lintText("You should stop.", "a.json", config())).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("applies severity overrides and turns rules off", () => {
|
|
75
|
+
const findings = lintText("You should stop; now.", "doc.md", config({ rules : { "modal-verb" : "warning", semicolon : "off" } }));
|
|
76
|
+
expect(findings.map((finding) => [finding.ruleId, finding.severity])).toEqual([
|
|
77
|
+
["modal-verb", "warning"],
|
|
78
|
+
["word-list", "error"],
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("runs only the word rules on headings and table cells", () => {
|
|
83
|
+
const long = Array.from({ length : 30 }, (_, i) => `word${i}`).join(" ");
|
|
84
|
+
const findings = lintText(`# ${long} should\n\n| ${long} may |\n| --- |\n| x |`, "doc.md", config());
|
|
85
|
+
expect(ids(findings)).toEqual(["modal-verb", "modal-verb"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("applies suppression comments in Markdown", () => {
|
|
89
|
+
const markdown = [
|
|
90
|
+
"<!-- ste-disable-next modal-verb -->",
|
|
91
|
+
"You should stop; it may fail.",
|
|
92
|
+
"",
|
|
93
|
+
"<!-- ste-disable -->",
|
|
94
|
+
"You should stop.",
|
|
95
|
+
"<!-- ste-enable -->",
|
|
96
|
+
"",
|
|
97
|
+
"You should stop.",
|
|
98
|
+
].join("\n");
|
|
99
|
+
expect(lintText(markdown, "doc.md", config()).map((finding) => [finding.line, finding.ruleId])).toEqual([
|
|
100
|
+
[2, "semicolon"],
|
|
101
|
+
[8, "modal-verb"],
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("applies suppression comments in TypeScript", () => {
|
|
106
|
+
const source = "// ste-disable-next\n/** You should stop; now. */\nexport const a = 1;\n/** You should stop. */\nexport const b = 2;\n";
|
|
107
|
+
expect(lintText(source, "a.ts", config()).map((finding) => [finding.line, finding.ruleId])).toEqual([[4, "modal-verb"]]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("keeps an ste-enable for one rule from stopping a range for all rules", () => {
|
|
111
|
+
const markdown = "<!-- ste-disable semicolon modal-verb -->\nYou should; stop.\n<!-- ste-enable semicolon -->\nYou should; stop.\n\n<!-- ste-disable -->\n<!-- ste-enable semicolon -->\nYou should; stop.";
|
|
112
|
+
expect(lintText(markdown, "doc.md", config()).map((finding) => [finding.line, finding.ruleId])).toEqual([[4, "semicolon"]]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("gives the prose units of a file", () => {
|
|
116
|
+
const linter = new Linter(config());
|
|
117
|
+
const units = linter.units("# Title\n\nOne. Two.\n\n```\ncode\n```", "doc.md");
|
|
118
|
+
expect(units.map((unit) => [unit.kind, unit.sentences.length])).toEqual([["heading", 1], ["paragraph", 2]]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("reads a file from the disk", () => {
|
|
122
|
+
const directory = mkdtempSync(join(tmpdir(), "ste-lint-"));
|
|
123
|
+
try {
|
|
124
|
+
const path = join(directory, "doc.md");
|
|
125
|
+
writeFileSync(path, "You should stop.");
|
|
126
|
+
const linter = new Linter(config());
|
|
127
|
+
expect(linter.lintFile(path, "doc.md")[0]!.file).toBe("doc.md");
|
|
128
|
+
expect(linter.lintFile(path)[0]!.file).toBe(path);
|
|
129
|
+
} finally {
|
|
130
|
+
rmSync(directory, { recursive : true, force : true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("rules", () => {
|
|
136
|
+
it("has one rule for each rule ID, with a description and an STE reference", () => {
|
|
137
|
+
expect(RULES.map((rule) => rule.id)).toEqual([...RULE_IDS]);
|
|
138
|
+
for (const id of RULE_IDS) {
|
|
139
|
+
expect(getRule(id).description.length).toBeGreaterThan(10);
|
|
140
|
+
expect(getRule(id).ste.length).toBeGreaterThan(0);
|
|
141
|
+
}
|
|
142
|
+
expect(isRuleId("semicolon")).toBe(true);
|
|
143
|
+
expect(isRuleId("nope")).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("format", () => {
|
|
148
|
+
const findings = lintText("You should stop; now.\n\nOur tool.", "doc.md", config());
|
|
149
|
+
const summary = summarize(findings, 3);
|
|
150
|
+
|
|
151
|
+
it("counts the findings by severity and by rule", () => {
|
|
152
|
+
expect(summary).toEqual({
|
|
153
|
+
files : 3,
|
|
154
|
+
filesWithFindings : 1,
|
|
155
|
+
errors : 3,
|
|
156
|
+
warnings : 1,
|
|
157
|
+
byRule : {
|
|
158
|
+
"modal-verb" : { errors : 1, warnings : 0 },
|
|
159
|
+
"semicolon" : { errors : 1, warnings : 0 },
|
|
160
|
+
"word-list" : { errors : 1, warnings : 0 },
|
|
161
|
+
"first-person" : { errors : 0, warnings : 1 },
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("writes text with one group for each file, a summary and the counts by rule", () => {
|
|
167
|
+
const text = formatText(findings, summary);
|
|
168
|
+
expect(text).toContain("doc.md\n 1:5 error Do not use the modal verb \"should\".");
|
|
169
|
+
expect(text).toContain(" modal-verb");
|
|
170
|
+
expect(text).toContain("4 problems (3 errors, 1 warning) in 1 file of 3.");
|
|
171
|
+
expect(text).toContain("Findings by rule:");
|
|
172
|
+
expect(text).toMatch(/first-person\s+1 {2}\(1 warning\)/);
|
|
173
|
+
expect(formatText([], summarize([], 1))).toBe("No problems found in 1 file.\n");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("writes JSON with the summary and the findings", () => {
|
|
177
|
+
const json = JSON.parse(formatJson(findings, summary)) as { tool : string; summary : unknown; findings : Finding[] };
|
|
178
|
+
expect(json.tool).toBe("ste-lint");
|
|
179
|
+
expect(json.summary).toEqual(summary);
|
|
180
|
+
expect(json.findings).toHaveLength(4);
|
|
181
|
+
});
|
|
182
|
+
});
|
package/src/lint.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The linter for files: the engine of `engine.ts`, with the extractor of
|
|
3
|
+
* TypeScript doc comments and the file system.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import type { ResolvedConfig } from "./config.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";
|
|
12
|
+
|
|
13
|
+
export { fileKind, type FileKind } from "./engine.js";
|
|
14
|
+
|
|
15
|
+
/** A linter for one configuration. It makes the lexicon and the word list matcher one time. */
|
|
16
|
+
export class Linter extends ProseLinter {
|
|
17
|
+
constructor(config : ResolvedConfig, lexicon? : Lexicon) {
|
|
18
|
+
super(config, lexicon, { extractCode });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The findings in a file on disk. `displayPath` is the file name in the findings. */
|
|
22
|
+
lintFile(path : string, displayPath : string = path) : Finding[] {
|
|
23
|
+
return this.lintText(readFileSync(path, "utf8"), displayPath);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The findings in `text`, with a new linter. To lint many files, make one `Linter` and use it again. */
|
|
28
|
+
export function lintText(text : string, path : string, config : ResolvedConfig, lexicon? : Lexicon) : Finding[] {
|
|
29
|
+
return new Linter(config, lexicon).lintText(text, path);
|
|
30
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isNumber, isWord } from "../text/tokens.js";
|
|
2
|
+
import { UNITS } from "../text/words.js";
|
|
3
|
+
import { forEachToken, tokenRange, type Rule } from "./rule.js";
|
|
4
|
+
|
|
5
|
+
/** Words after "about a" or "about an" that make a quantity: "about a second", "about an hour". */
|
|
6
|
+
const QUANTITY_AFTER_ARTICLE = new Set([
|
|
7
|
+
"dozen", "few", "hundred", "thousand", "million", "billion", "third", "quarter", "half", "factor", "tenth",
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export const aboutQuantity : Rule = {
|
|
11
|
+
id : "about-quantity",
|
|
12
|
+
description : "Find \"about\" before a number or a quantity, where the word means \"approximately\".",
|
|
13
|
+
ste : "1.3, 9.2",
|
|
14
|
+
defaultSeverity : "error",
|
|
15
|
+
scope : "word",
|
|
16
|
+
check(unit, context) {
|
|
17
|
+
forEachToken(unit, (tokens, i) => {
|
|
18
|
+
const token = tokens[i]!;
|
|
19
|
+
if (!isWord(token) || token.lower !== "about") return;
|
|
20
|
+
const next = tokens[i + 1];
|
|
21
|
+
const after = tokens[i + 2];
|
|
22
|
+
const quantity = isNumber(next)
|
|
23
|
+
|| (next !== undefined && next.text === "~")
|
|
24
|
+
|| (isWord(next) && /^\d/.test(next.text))
|
|
25
|
+
|| (isWord(next) && (next.lower === "a" || next.lower === "an") && isWord(after) && (QUANTITY_AFTER_ARTICLE.has(after.lower) || UNITS.has(after.lower)));
|
|
26
|
+
if (!quantity) return;
|
|
27
|
+
context.report({
|
|
28
|
+
...tokenRange(tokens, i, i + 1),
|
|
29
|
+
message : "Do not use \"about\" for a quantity. Write \"approximately\". Use \"about\" only for a topic, for example \"about the monitor\".",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { RuleId } from "../types.js";
|
|
2
|
+
import { aboutQuantity } from "./about-quantity.js";
|
|
3
|
+
import { ingVerb } from "./ing-verb.js";
|
|
4
|
+
import { latinAbbreviation } from "./latin-abbreviation.js";
|
|
5
|
+
import { missingSubjectRule } from "./missing-subject.js";
|
|
6
|
+
import { modalVerb } from "./modal-verb.js";
|
|
7
|
+
import { nounCluster } from "./noun-cluster.js";
|
|
8
|
+
import { paragraphLength } from "./paragraph-length.js";
|
|
9
|
+
import { passiveInstruction } from "./passive-instruction.js";
|
|
10
|
+
import { firstPerson, genderedPronoun } from "./pronouns.js";
|
|
11
|
+
import type { Rule } from "./rule.js";
|
|
12
|
+
import { semicolon } from "./semicolon.js";
|
|
13
|
+
import { sentenceLength } from "./sentence-length.js";
|
|
14
|
+
import { unknownWord } from "./unknown-word.js";
|
|
15
|
+
import { wordList } from "./word-list.js";
|
|
16
|
+
|
|
17
|
+
/** All rules, in the order of the documentation. */
|
|
18
|
+
export const RULES : readonly Rule[] = [
|
|
19
|
+
sentenceLength,
|
|
20
|
+
paragraphLength,
|
|
21
|
+
modalVerb,
|
|
22
|
+
semicolon,
|
|
23
|
+
latinAbbreviation,
|
|
24
|
+
aboutQuantity,
|
|
25
|
+
wordList,
|
|
26
|
+
genderedPronoun,
|
|
27
|
+
firstPerson,
|
|
28
|
+
passiveInstruction,
|
|
29
|
+
nounCluster,
|
|
30
|
+
missingSubjectRule,
|
|
31
|
+
ingVerb,
|
|
32
|
+
unknownWord,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** The rule with the ID `id`. */
|
|
36
|
+
export function getRule(id : RuleId) : Rule {
|
|
37
|
+
return RULES.find((rule) => rule.id === id)!;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type { Report, Rule, RuleContext, RuleScope } from "./rule.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveConfig, type SteConfigFile } from "../config.js";
|
|
3
|
+
import { Linter } from "../lint.js";
|
|
4
|
+
import { COMMON_VERBS } from "../text/words.js";
|
|
5
|
+
import { baseForm } from "./ing-verb.js";
|
|
6
|
+
|
|
7
|
+
const lint = (text : string, file : SteConfigFile = {}) =>
|
|
8
|
+
new Linter(resolveConfig(file, { root : "/" })).lintText(text, "doc.md").filter((finding) => finding.ruleId === "ing-verb");
|
|
9
|
+
const found = (text : string, file : SteConfigFile = {}) : string[] => lint(text, file).map((finding) => text.slice(finding.column - 1, finding.endColumn - 1));
|
|
10
|
+
|
|
11
|
+
describe("ing-verb", () => {
|
|
12
|
+
it("reports -ing forms used as verbs", () => {
|
|
13
|
+
expect(found("The browser is throttling timers.")).toEqual(["throttling"]);
|
|
14
|
+
expect(found("Stop it by closing the tab.")).toEqual(["closing"]);
|
|
15
|
+
expect(found("Using the worker, the monitor measures lag.")).toEqual(["Using"]);
|
|
16
|
+
expect(found("A timer firing every second is fine.")).toEqual(["firing"]);
|
|
17
|
+
expect(found("The monitor is still waiting.")).toEqual(["waiting"]);
|
|
18
|
+
const findings = lint("Stop it by closing the tab.");
|
|
19
|
+
expect(findings[0]!.severity).toBe("warning");
|
|
20
|
+
expect(findings[0]!.message).toContain("when you close");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not report nouns, adjectives or words that are not verbs", () => {
|
|
24
|
+
expect(found("The timing is good. The existing code works. Add something during the morning. The string is missing. The mark is outstanding.")).toEqual([]);
|
|
25
|
+
expect(found("Logging is fast.")).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("does not examine headings", () => {
|
|
29
|
+
expect(found("# Troubleshooting by restarting the worker")).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("does not report technical nouns and allowed words", () => {
|
|
33
|
+
expect(found("The page is blocking the thread.", { technicalNouns : ["blocking"] })).toEqual([]);
|
|
34
|
+
expect(found("The page is buffering the data.", { allowedWords : ["buffering"] })).toEqual([]);
|
|
35
|
+
expect(found("Read the logging pipeline docs about closing the logging pipeline.", { technicalNouns : ["logging pipeline"] })).toEqual(["closing"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not report a word that the word list reports", () => {
|
|
39
|
+
expect(found("The worker is running.")).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("gives the base form of a known verb for the message", () => {
|
|
43
|
+
expect(baseForm("running", COMMON_VERBS)).toBe("run");
|
|
44
|
+
expect(baseForm("using", COMMON_VERBS)).toBe("use");
|
|
45
|
+
expect(baseForm("frobnicating", COMMON_VERBS)).toBeNull();
|
|
46
|
+
expect(lint("It is frobnicating the data.")[0]!.message).not.toContain("for example");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isIngVerbUse, lemmas } from "../text/grammar.js";
|
|
2
|
+
import type { PhraseMatcher } from "../text/phrases.js";
|
|
3
|
+
import { isCounted, isWord, type Token } from "../text/tokens.js";
|
|
4
|
+
import { NON_VERB_ING } from "../text/words.js";
|
|
5
|
+
import { forEachToken, tokenRange, type Rule } from "./rule.js";
|
|
6
|
+
|
|
7
|
+
/** The token indices that a multi-word glossary phrase covers. */
|
|
8
|
+
export function coveredByPhrases(tokens : readonly Token[], phrases : PhraseMatcher) : Set<number> {
|
|
9
|
+
const covered = new Set<number>();
|
|
10
|
+
if (phrases.empty) return covered;
|
|
11
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
12
|
+
const length = phrases.longestMatch(tokens, i);
|
|
13
|
+
if (length > 1) for (let k = i; k < i + length; k++) covered.add(k);
|
|
14
|
+
}
|
|
15
|
+
return covered;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The base form of an -ing word when it is a known verb ("running" gives "run"), or null. */
|
|
19
|
+
export function baseForm(word : string, verbs : ReadonlySet<string>) : string | null {
|
|
20
|
+
return lemmas(word).find((form) => form !== word && verbs.has(form)) ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const ingVerb : Rule = {
|
|
24
|
+
id : "ing-verb",
|
|
25
|
+
description : "Find -ing forms used as verbs. Headings, technical nouns and allowed words are not examined.",
|
|
26
|
+
ste : "3.2, 3.5",
|
|
27
|
+
defaultSeverity : "warning",
|
|
28
|
+
scope : "word",
|
|
29
|
+
check(unit, context) {
|
|
30
|
+
if (unit.kind === "heading") return;
|
|
31
|
+
const { lexicon, wordList } = context;
|
|
32
|
+
let covered : Set<number> = new Set();
|
|
33
|
+
let current : readonly Token[] | null = null;
|
|
34
|
+
forEachToken(unit, (tokens, i) => {
|
|
35
|
+
if (tokens !== current) {
|
|
36
|
+
current = tokens;
|
|
37
|
+
covered = coveredByPhrases(tokens, lexicon.technicalNounPhrases);
|
|
38
|
+
}
|
|
39
|
+
const token = tokens[i]!;
|
|
40
|
+
if (!isWord(token) || !isCounted(token)) return;
|
|
41
|
+
const lower = token.lower;
|
|
42
|
+
if (lower.length < 5 || !/^[a-z]+ing$/.test(lower)) return;
|
|
43
|
+
if (NON_VERB_ING.has(lower) || lexicon.technicalNouns.has(lower) || lexicon.properNouns.has(lower) || lexicon.allowedWords.has(lower)) return;
|
|
44
|
+
if (wordList.words.has(lower) || covered.has(i)) return;
|
|
45
|
+
if (!isIngVerbUse(tokens, i)) return;
|
|
46
|
+
const base = baseForm(lower, lexicon.verbs);
|
|
47
|
+
const example = base === null ? "" : `, for example "when you ${base} ..." or "to ${base} ..."`;
|
|
48
|
+
context.report({
|
|
49
|
+
...tokenRange(tokens, i, i + 1),
|
|
50
|
+
message : `Do not use "${token.text}" as a verb. Use a simple tense, the infinitive or a noun${example}. If "${lower}" is a technical noun, add it to technicalNouns.`,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isWord } from "../text/tokens.js";
|
|
2
|
+
import { forEachToken, tokenRange, type Rule } from "./rule.js";
|
|
3
|
+
|
|
4
|
+
// A map, not an object literal, so that a word such as "constructor" does not find an Object property.
|
|
5
|
+
const HELP : ReadonlyMap<string, { readonly name : string; readonly help : string }> = new Map([
|
|
6
|
+
["e.g", { name : "e.g.", help : "Write \"for example\"." }],
|
|
7
|
+
["eg", { name : "eg.", help : "Write \"for example\"." }],
|
|
8
|
+
["i.e", { name : "i.e.", help : "Write \"that is\", or write a separate sentence." }],
|
|
9
|
+
["ie", { name : "ie.", help : "Write \"that is\", or write a separate sentence." }],
|
|
10
|
+
["etc", { name : "etc.", help : "Give all the items, or write \"for example\" before the items." }],
|
|
11
|
+
["vs", { name : "vs.", help : "Write \"compared to\", \"and\" or \"or\"." }],
|
|
12
|
+
["viz", { name : "viz.", help : "Write \"that is\" or \"for example\"." }],
|
|
13
|
+
["cf", { name : "cf.", help : "Write \"compare with\" or \"refer to\"." }],
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/** Abbreviations that are a problem only with a period after them, because the letters alone can be another word. */
|
|
17
|
+
const NEEDS_PERIOD = new Set(["eg", "ie", "cf"]);
|
|
18
|
+
|
|
19
|
+
export const latinAbbreviation : Rule = {
|
|
20
|
+
id : "latin-abbreviation",
|
|
21
|
+
description : "Find Latin abbreviations: e.g., i.e., etc., vs., viz., cf. and et al.",
|
|
22
|
+
ste : "GR-6",
|
|
23
|
+
defaultSeverity : "error",
|
|
24
|
+
scope : "word",
|
|
25
|
+
check(unit, context) {
|
|
26
|
+
forEachToken(unit, (tokens, i) => {
|
|
27
|
+
const token = tokens[i]!;
|
|
28
|
+
if (!isWord(token)) return;
|
|
29
|
+
const next = tokens[i + 1];
|
|
30
|
+
const period = next !== undefined && next.kind === "punct" && next.text === "." && !next.spaceBefore;
|
|
31
|
+
const end = period ? i + 2 : i + 1;
|
|
32
|
+
if (token.lower === "et" && isWord(next) && next.lower === "al") {
|
|
33
|
+
const after = tokens[i + 2];
|
|
34
|
+
const withPeriod = after !== undefined && after.text === "." && !after.spaceBefore;
|
|
35
|
+
context.report({ ...tokenRange(tokens, i, withPeriod ? i + 3 : i + 2), message : "Do not use \"et al.\". Write \"and others\", or give the names." });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const entry = HELP.get(token.lower);
|
|
39
|
+
if (entry === undefined) return;
|
|
40
|
+
// "VS" in capital letters is a name (for example "VS Code"), not "versus".
|
|
41
|
+
if (token.lower === "vs" && token.text === "VS") return;
|
|
42
|
+
if (NEEDS_PERIOD.has(token.lower) && (!period || token.text !== token.lower)) return;
|
|
43
|
+
context.report({ ...tokenRange(tokens, i, end), message : `Do not use the Latin abbreviation "${entry.name}". ${entry.help}` });
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
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({ technicalVerbs : ["flush"] }, { root : "/" }));
|
|
6
|
+
const lint = (comment : string, path : string = "a.ts") =>
|
|
7
|
+
linter.lintText(`${comment}\nexport const a = 1;\n`, path).filter((finding) => finding.ruleId === "missing-subject");
|
|
8
|
+
const found = (comment : string) : string[] => lint(comment).map((finding) => comment.split("\n")[finding.line - 1]!.slice(finding.column - 1, finding.endColumn - 1));
|
|
9
|
+
|
|
10
|
+
describe("missing-subject", () => {
|
|
11
|
+
it("reports a doc comment sentence that starts with a verb", () => {
|
|
12
|
+
expect(found("/** Returns the value. */")).toEqual(["Returns"]);
|
|
13
|
+
expect(found("/** Creates a monitor and starts it. */")).toEqual(["Creates"]);
|
|
14
|
+
expect(found("/** Stops `monitor` while the page is hidden. */")).toEqual(["Stops"]);
|
|
15
|
+
expect(found("/** Flushes the buffer. */")).toEqual(["Flushes"]);
|
|
16
|
+
expect(found("/** Applies the change. */")).toEqual(["Applies"]);
|
|
17
|
+
expect(found("/** Runs in the worker. */")).toEqual(["Runs"]);
|
|
18
|
+
const findings = lint("/** Returns the value. */");
|
|
19
|
+
expect(findings[0]!.severity).toBe("error");
|
|
20
|
+
expect(findings[0]!.message).toContain("This function gives");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("reports a sentence that starts with an auxiliary verb or a participle", () => {
|
|
24
|
+
expect(found("/** Can be null. */")).toEqual(["Can"]);
|
|
25
|
+
expect(found("/** Is true when the page is visible. */")).toEqual(["Is"]);
|
|
26
|
+
expect(found("/** Called when the worker stops. */")).toEqual(["Called"]);
|
|
27
|
+
expect(found("/** Used by the worker. */")).toEqual(["Used"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("examines each sentence of the summary and of @remarks", () => {
|
|
31
|
+
expect(found("/**\n * The monitor. Returns the value.\n * @remarks\n * Uses a timer.\n */")).toEqual(["Returns", "Uses"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("does not report a sentence with a subject, a question or a plural noun subject", () => {
|
|
35
|
+
expect(found("/** This function returns the value. */")).toEqual([]);
|
|
36
|
+
expect(found("/** Is it visible? */")).toEqual([]);
|
|
37
|
+
expect(found("/** Samples at or above this value wait for evidence. */")).toEqual([]);
|
|
38
|
+
expect(found("/** Values are clamped. */")).toEqual([]);
|
|
39
|
+
expect(found("/** Samples per calibration round. */")).toEqual([]);
|
|
40
|
+
expect(found("/** Tags whose content is code. */")).toEqual([]);
|
|
41
|
+
expect(found("/** Monitors ask whether the window overlaps. */")).toEqual([]);
|
|
42
|
+
expect(found("/** Handles returned by the setup. */")).toEqual([]);
|
|
43
|
+
expect(found("/** Settings for the run. */")).toEqual([]);
|
|
44
|
+
expect(found("/** Based on the frame time, the monitor reports lag. */")).toEqual([]);
|
|
45
|
+
expect(found("/** Status of the monitor. */")).toEqual([]);
|
|
46
|
+
expect(found("/** `stop()` releases the timers. */")).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not examine block tag text, list items or Markdown files", () => {
|
|
50
|
+
expect(found("/**\n * The function.\n * @returns Returns the value.\n * - Returns a list item.\n */")).toEqual([]);
|
|
51
|
+
expect(linter.lintText("Returns the value.", "doc.md").filter((finding) => finding.ruleId === "missing-subject")).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -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
|
+
};
|