@faircopy/rules-nlp 1.15.0 → 1.17.0
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 +242 -0
- package/dist/index.d.ts +91 -1
- package/dist/index.js +718 -44
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,171 @@
|
|
|
1
|
+
// src/no-absolute-intensifiers.ts
|
|
2
|
+
var DEFAULT_INTENSIFIERS = [
|
|
3
|
+
"very",
|
|
4
|
+
"really",
|
|
5
|
+
"completely",
|
|
6
|
+
"totally",
|
|
7
|
+
"absolutely",
|
|
8
|
+
"utterly",
|
|
9
|
+
"quite",
|
|
10
|
+
"extremely",
|
|
11
|
+
"perfectly",
|
|
12
|
+
"entirely"
|
|
13
|
+
];
|
|
14
|
+
var DEFAULT_ABSOLUTES = [
|
|
15
|
+
"unique",
|
|
16
|
+
"finished",
|
|
17
|
+
"destroyed",
|
|
18
|
+
"essential",
|
|
19
|
+
"perfect",
|
|
20
|
+
"impossible",
|
|
21
|
+
"dead",
|
|
22
|
+
"empty",
|
|
23
|
+
"full",
|
|
24
|
+
"silent",
|
|
25
|
+
"unanimous",
|
|
26
|
+
"infinite",
|
|
27
|
+
"eternal",
|
|
28
|
+
"flawless",
|
|
29
|
+
"ultimate",
|
|
30
|
+
"final",
|
|
31
|
+
"complete",
|
|
32
|
+
"total",
|
|
33
|
+
"absolute"
|
|
34
|
+
];
|
|
35
|
+
function escapeRegExp(value) {
|
|
36
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
37
|
+
}
|
|
38
|
+
function longestFirst(values) {
|
|
39
|
+
return [...values].sort((left, right) => right.length - left.length);
|
|
40
|
+
}
|
|
41
|
+
function buildPattern(intensifiers, absolutes) {
|
|
42
|
+
const intensifierPattern = longestFirst(intensifiers).map((phrase) => escapeRegExp(phrase).replace(/\\s+/g, "\\s+")).join("|");
|
|
43
|
+
const absolutePattern = longestFirst(absolutes).map((phrase) => escapeRegExp(phrase).replace(/\\s+/g, "\\s+")).join("|");
|
|
44
|
+
return new RegExp(
|
|
45
|
+
`\\b(${intensifierPattern})\\s+\\b(${absolutePattern})(?![\\w'-])`,
|
|
46
|
+
"gi"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
var noAbsoluteIntensifiers = {
|
|
50
|
+
id: "no-absolute-intensifiers",
|
|
51
|
+
description: "Flag intensifiers before absolute adjectives",
|
|
52
|
+
defaults: {
|
|
53
|
+
intensifiers: DEFAULT_INTENSIFIERS,
|
|
54
|
+
absolutes: DEFAULT_ABSOLUTES
|
|
55
|
+
},
|
|
56
|
+
help: 'Absolute adjectives already express an extreme. Intensifiers like "very" before them are redundant and weaken the claim. Remove the intensifier or replace the phrase with concrete evidence.',
|
|
57
|
+
check({ text, sourceMap, options }) {
|
|
58
|
+
const diagnostics = [];
|
|
59
|
+
const intensifiers = options.intensifiers?.length ? options.intensifiers : DEFAULT_INTENSIFIERS;
|
|
60
|
+
const absolutes = options.absolutes?.length ? options.absolutes : DEFAULT_ABSOLUTES;
|
|
61
|
+
if (!intensifiers.length || !absolutes.length) return diagnostics;
|
|
62
|
+
const re = buildPattern(intensifiers, absolutes);
|
|
63
|
+
let match;
|
|
64
|
+
while ((match = re.exec(text)) !== null) {
|
|
65
|
+
const matchedText = match[0];
|
|
66
|
+
const intensifier = match[1];
|
|
67
|
+
const absolute = match[2];
|
|
68
|
+
const matchStart = match.index;
|
|
69
|
+
const matchEnd = matchStart + matchedText.length;
|
|
70
|
+
const start = sourceMap[matchStart];
|
|
71
|
+
const end = sourceMap[matchEnd - 1];
|
|
72
|
+
if (start === void 0 || end === void 0) continue;
|
|
73
|
+
const suggest = {
|
|
74
|
+
description: `remove "${intensifier}" before "${absolute}"`,
|
|
75
|
+
edits: [{ range: { start, end: end + 1 }, replacement: absolute }]
|
|
76
|
+
};
|
|
77
|
+
diagnostics.push({
|
|
78
|
+
ruleId: "no-absolute-intensifiers",
|
|
79
|
+
severity: "warn",
|
|
80
|
+
message: `"${matchedText}" is redundant \u2014 "${absolute}" is already absolute`,
|
|
81
|
+
range: { start, end: end + 1 },
|
|
82
|
+
help: noAbsoluteIntensifiers.help,
|
|
83
|
+
suggest
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return diagnostics.sort((left, right) => left.range.start - right.range.start);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// src/utils.ts
|
|
91
|
+
import nlp from "compromise";
|
|
92
|
+
function createDoc(text) {
|
|
93
|
+
return nlp(text);
|
|
94
|
+
}
|
|
95
|
+
function getMatchOccurrences(text, matches) {
|
|
96
|
+
const json = matches.json({ offset: true, text: true, terms: { offset: true } });
|
|
97
|
+
return json.flatMap((entry) => {
|
|
98
|
+
const start = entry.offset?.start ?? entry.terms?.[0]?.offset?.start;
|
|
99
|
+
const length = entry.offset?.length ?? sumTermLengths(entry.terms);
|
|
100
|
+
if (typeof start !== "number" || typeof length !== "number" || length <= 0) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
return [{
|
|
104
|
+
text: entry.text ?? text.slice(start, start + length),
|
|
105
|
+
start,
|
|
106
|
+
end: start + length
|
|
107
|
+
}];
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function getOccurrenceRange(sourceMap, occurrence) {
|
|
111
|
+
const start = sourceMap[occurrence.start];
|
|
112
|
+
const end = sourceMap[occurrence.end - 1];
|
|
113
|
+
if (start === void 0 || end === void 0) return null;
|
|
114
|
+
return { start, end: end + 1 };
|
|
115
|
+
}
|
|
116
|
+
function sumTermLengths(terms) {
|
|
117
|
+
if (!terms?.length) return void 0;
|
|
118
|
+
let total = 0;
|
|
119
|
+
for (const term of terms) {
|
|
120
|
+
const length = term.offset?.length;
|
|
121
|
+
if (typeof length !== "number") return void 0;
|
|
122
|
+
total += length;
|
|
123
|
+
}
|
|
124
|
+
return total;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/no-adverb-overuse.ts
|
|
128
|
+
var DEFAULT_MAX_ADVERBS = 2;
|
|
129
|
+
var DEFAULT_ALLOWED_ADVERBS = ["only"];
|
|
130
|
+
var noAdverbOveruse = {
|
|
131
|
+
id: "no-adverb-overuse",
|
|
132
|
+
description: "Flag sentences with more than two -ly adverbs",
|
|
133
|
+
defaults: { maxAdverbs: DEFAULT_MAX_ADVERBS, allowedAdverbs: DEFAULT_ALLOWED_ADVERBS },
|
|
134
|
+
help: "Too many -ly adverbs in one sentence make copy feel padded. Remove the adverb, replace it with a stronger verb or adjective, or add it to allowedAdverbs if it is essential.",
|
|
135
|
+
check({ text, sourceMap, options }) {
|
|
136
|
+
const maxAdverbs = options.maxAdverbs ?? DEFAULT_MAX_ADVERBS;
|
|
137
|
+
const allowed = new Set((options.allowedAdverbs ?? DEFAULT_ALLOWED_ADVERBS).map((value) => value.toLowerCase()));
|
|
138
|
+
const doc = createDoc(text);
|
|
139
|
+
const sentences = doc.sentences().json({ offset: true, terms: { offset: true, tags: true } });
|
|
140
|
+
const diagnostics = [];
|
|
141
|
+
for (const sentence of sentences) {
|
|
142
|
+
if (!sentence.terms) continue;
|
|
143
|
+
const occurrences = sentence.terms.filter((term) => term.tags?.includes("Adverb")).map((term) => {
|
|
144
|
+
const start = term.offset?.start;
|
|
145
|
+
const length = term.offset?.length;
|
|
146
|
+
return {
|
|
147
|
+
text: term.text?.replace(/[^a-zA-Z]+$/, "") ?? "",
|
|
148
|
+
start: typeof start === "number" ? start : 0,
|
|
149
|
+
end: typeof start === "number" && typeof length === "number" ? start + length : 0
|
|
150
|
+
};
|
|
151
|
+
}).filter((occurrence) => /ly$/i.test(occurrence.text)).filter((occurrence) => !allowed.has(occurrence.text.toLowerCase())).sort((left, right) => left.start - right.start);
|
|
152
|
+
for (let index = maxAdverbs; index < occurrences.length; index += 1) {
|
|
153
|
+
const occurrence = occurrences[index];
|
|
154
|
+
const range = getOccurrenceRange(sourceMap, occurrence);
|
|
155
|
+
if (!range) continue;
|
|
156
|
+
diagnostics.push({
|
|
157
|
+
ruleId: "no-adverb-overuse",
|
|
158
|
+
severity: "warn",
|
|
159
|
+
message: `reduce adverb overuse: "${occurrence.text}" exceeds the limit of ${maxAdverbs} -ly adverb${maxAdverbs === 1 ? "" : "s"} per sentence`,
|
|
160
|
+
range,
|
|
161
|
+
help: noAdverbOveruse.help
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return diagnostics;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
1
169
|
// src/no-buzzword-stacks.ts
|
|
2
170
|
var DEFAULT_TERMS = [
|
|
3
171
|
"alignment",
|
|
@@ -77,41 +245,74 @@ function escapeRegex(value) {
|
|
|
77
245
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
78
246
|
}
|
|
79
247
|
|
|
80
|
-
// src/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
248
|
+
// src/no-complex-readability.ts
|
|
249
|
+
var DEFAULT_OPTIONS = {
|
|
250
|
+
maxGradeLevel: 12,
|
|
251
|
+
minSentences: 3,
|
|
252
|
+
minWords: 30
|
|
253
|
+
};
|
|
254
|
+
var noComplexReadability = {
|
|
255
|
+
id: "no-complex-readability",
|
|
256
|
+
description: "Flag prose whose Flesch-Kincaid grade level exceeds a target",
|
|
257
|
+
defaults: { ...DEFAULT_OPTIONS },
|
|
258
|
+
help: "Landing-page copy should be readable by a broad audience. Break long sentences, replace jargon, and front-load the point until the grade level drops.",
|
|
259
|
+
check({ text, sourceMap, options }) {
|
|
260
|
+
const maxGradeLevel = options.maxGradeLevel ?? DEFAULT_OPTIONS.maxGradeLevel;
|
|
261
|
+
const minSentences = options.minSentences ?? DEFAULT_OPTIONS.minSentences;
|
|
262
|
+
const minWords = options.minWords ?? DEFAULT_OPTIONS.minWords;
|
|
263
|
+
const sentences = getSentences(text);
|
|
264
|
+
const words = getWords(text);
|
|
265
|
+
if (sentences.length < minSentences || words.length < minWords) {
|
|
91
266
|
return [];
|
|
92
267
|
}
|
|
268
|
+
const syllables = words.reduce((sum, word) => sum + countSyllables(word), 0);
|
|
269
|
+
const grade = fleschKincaidGrade(words.length, sentences.length, syllables);
|
|
270
|
+
if (grade <= maxGradeLevel) {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
const start = sourceMap[0];
|
|
274
|
+
const end = sourceMap[sourceMap.length - 1];
|
|
275
|
+
if (start === void 0 || end === void 0) return [];
|
|
93
276
|
return [{
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
277
|
+
ruleId: "no-complex-readability",
|
|
278
|
+
severity: "warn",
|
|
279
|
+
message: `readability is grade ${grade.toFixed(1)} \u2014 simplify to ${maxGradeLevel} or below`,
|
|
280
|
+
range: { start, end: end + 1 },
|
|
281
|
+
help: noComplexReadability.help
|
|
97
282
|
}];
|
|
98
|
-
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
function getSentences(text) {
|
|
286
|
+
const parts = text.split(/([.!?]+)/);
|
|
287
|
+
const sentences = [];
|
|
288
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
289
|
+
const sentence = parts[i];
|
|
290
|
+
const terminator = parts[i + 1] ?? "";
|
|
291
|
+
const combined = (sentence ?? "") + terminator;
|
|
292
|
+
const trimmed = combined.trim();
|
|
293
|
+
if (trimmed) sentences.push(trimmed);
|
|
294
|
+
}
|
|
295
|
+
return sentences;
|
|
99
296
|
}
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
const end = sourceMap[occurrence.end - 1];
|
|
103
|
-
if (start === void 0 || end === void 0) return null;
|
|
104
|
-
return { start, end: end + 1 };
|
|
297
|
+
function getWords(text) {
|
|
298
|
+
return text.toLowerCase().replace(/[^a-z0-9\s'-]/g, " ").split(/\s+/).filter((word) => word.length > 0 && /[a-z0-9]/.test(word));
|
|
105
299
|
}
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
300
|
+
function countSyllables(word) {
|
|
301
|
+
const cleaned = word.toLowerCase().replace(/[^a-z]/g, "");
|
|
302
|
+
if (!cleaned) return 0;
|
|
303
|
+
if (cleaned.length <= 3) return 1;
|
|
304
|
+
const vowels = cleaned.match(/[aeiouy]+/g);
|
|
305
|
+
if (!vowels) return 1;
|
|
306
|
+
let count = vowels.length;
|
|
307
|
+
if (cleaned.endsWith("e")) count--;
|
|
308
|
+
if (cleaned.endsWith("le") && cleaned.length > 2 && !/[aeiouy]$/.test(cleaned[cleaned.length - 3] ?? "")) {
|
|
309
|
+
count++;
|
|
113
310
|
}
|
|
114
|
-
return
|
|
311
|
+
return Math.max(1, count);
|
|
312
|
+
}
|
|
313
|
+
function fleschKincaidGrade(words, sentences, syllables) {
|
|
314
|
+
if (sentences === 0 || words === 0) return 0;
|
|
315
|
+
return 0.39 * (words / sentences) + 11.8 * (syllables / words) - 15.59;
|
|
115
316
|
}
|
|
116
317
|
|
|
117
318
|
// src/no-expletive-openers.ts
|
|
@@ -255,7 +456,7 @@ var noFuturePromises = {
|
|
|
255
456
|
const diagnostics = [];
|
|
256
457
|
const phrases = options.phrases?.length ? options.phrases : DEFAULT_PHRASES3;
|
|
257
458
|
for (const phrase of phrases) {
|
|
258
|
-
const re = new RegExp(`\\b${
|
|
459
|
+
const re = new RegExp(`\\b${escapeRegExp2(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
|
|
259
460
|
let match;
|
|
260
461
|
while ((match = re.exec(text)) !== null) {
|
|
261
462
|
const matchedPhrase = match[0];
|
|
@@ -274,7 +475,7 @@ var noFuturePromises = {
|
|
|
274
475
|
return diagnostics.sort((left, right) => left.range.start - right.range.start);
|
|
275
476
|
}
|
|
276
477
|
};
|
|
277
|
-
function
|
|
478
|
+
function escapeRegExp2(value) {
|
|
278
479
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
279
480
|
}
|
|
280
481
|
|
|
@@ -300,7 +501,7 @@ var noHedgeWords = {
|
|
|
300
501
|
const diagnostics = [];
|
|
301
502
|
const hedges = options.hedges?.length ? options.hedges : DEFAULT_HEDGES;
|
|
302
503
|
for (const hedge of hedges) {
|
|
303
|
-
const re = new RegExp(`\\b${
|
|
504
|
+
const re = new RegExp(`\\b${escapeRegExp3(hedge).replace(/\\s+/g, "\\s+")}\\b`, "gi");
|
|
304
505
|
let match;
|
|
305
506
|
while ((match = re.exec(text)) !== null) {
|
|
306
507
|
const phrase = match[0];
|
|
@@ -319,7 +520,7 @@ var noHedgeWords = {
|
|
|
319
520
|
return diagnostics;
|
|
320
521
|
}
|
|
321
522
|
};
|
|
322
|
-
function
|
|
523
|
+
function escapeRegExp3(value) {
|
|
323
524
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
324
525
|
}
|
|
325
526
|
|
|
@@ -344,7 +545,7 @@ var noJargon = {
|
|
|
344
545
|
const diagnostics = [];
|
|
345
546
|
const phrases = options.phrases?.length ? options.phrases : DEFAULT_PHRASES4;
|
|
346
547
|
for (const phrase of phrases) {
|
|
347
|
-
const re = new RegExp(`\\b${
|
|
548
|
+
const re = new RegExp(`\\b${escapeRegExp4(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
|
|
348
549
|
let match;
|
|
349
550
|
while ((match = re.exec(text)) !== null) {
|
|
350
551
|
const matchedPhrase = match[0];
|
|
@@ -363,7 +564,7 @@ var noJargon = {
|
|
|
363
564
|
return diagnostics.sort((left, right) => left.range.start - right.range.start);
|
|
364
565
|
}
|
|
365
566
|
};
|
|
366
|
-
function
|
|
567
|
+
function escapeRegExp4(value) {
|
|
367
568
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
368
569
|
}
|
|
369
570
|
|
|
@@ -389,7 +590,7 @@ var noMeaninglessModifiers = {
|
|
|
389
590
|
const diagnostics = [];
|
|
390
591
|
const modifiers = options.modifiers?.length ? options.modifiers : DEFAULT_MODIFIERS;
|
|
391
592
|
for (const modifier of modifiers) {
|
|
392
|
-
const re = new RegExp(`\\b${
|
|
593
|
+
const re = new RegExp(`\\b${escapeRegExp5(modifier).replace(/\\s+/g, "\\s+")}\\b`, "gi");
|
|
393
594
|
let match;
|
|
394
595
|
while ((match = re.exec(text)) !== null) {
|
|
395
596
|
const matchedModifier = match[0];
|
|
@@ -408,10 +609,91 @@ var noMeaninglessModifiers = {
|
|
|
408
609
|
return diagnostics.sort((left, right) => left.range.start - right.range.start);
|
|
409
610
|
}
|
|
410
611
|
};
|
|
411
|
-
function
|
|
612
|
+
function escapeRegExp5(value) {
|
|
412
613
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
413
614
|
}
|
|
414
615
|
|
|
616
|
+
// src/no-non-inclusive-language.ts
|
|
617
|
+
var DEFAULT_TERMS2 = [
|
|
618
|
+
{ term: "guys", alternatives: ["everyone", "team", "folks"] },
|
|
619
|
+
{ term: "manpower", alternatives: ["workforce", "staffing", "personnel"] },
|
|
620
|
+
{ term: "whitelist", alternatives: ["allowlist"] },
|
|
621
|
+
{ term: "blacklist", alternatives: ["denylist", "blocklist"] },
|
|
622
|
+
{ term: "master", alternatives: ["primary", "main", "leader"] },
|
|
623
|
+
{ term: "slave", alternatives: ["secondary", "replica", "follower"] },
|
|
624
|
+
{ term: "crazy", alternatives: ["unexpected", "intense", "extreme"] },
|
|
625
|
+
{ term: "insane", alternatives: ["extreme", "unbelievable", "remarkable"] },
|
|
626
|
+
{ term: "dumb", alternatives: ["unhelpful", "poor", "uninformed"] },
|
|
627
|
+
{ term: "lame", alternatives: ["unimpressive", "inadequate", "weak"] },
|
|
628
|
+
{ term: "sanity check", alternatives: ["quick check", "confidence check", "verification"], exact: true },
|
|
629
|
+
{ term: "blind spot", alternatives: ["unaware area", "gap", "oversight"], exact: true },
|
|
630
|
+
{ term: "grandfathered", alternatives: ["legacy status", "exempted"] },
|
|
631
|
+
{ term: "mankind", alternatives: ["humanity", "humankind", "people"] }
|
|
632
|
+
];
|
|
633
|
+
var VERB_AMBIGUOUS_TERMS = /* @__PURE__ */ new Set(["master", "slave"]);
|
|
634
|
+
function toMatchPattern(rawTerm) {
|
|
635
|
+
return rawTerm;
|
|
636
|
+
}
|
|
637
|
+
function isProblematicUsage(term, terms) {
|
|
638
|
+
if (!VERB_AMBIGUOUS_TERMS.has(term.term.toLowerCase())) return true;
|
|
639
|
+
if (terms.length !== 1) return true;
|
|
640
|
+
const tags = terms[0].tags ?? [];
|
|
641
|
+
const isVerb = tags.includes("Verb");
|
|
642
|
+
const isNounOrAdjective = tags.includes("Noun") || tags.includes("Adjective");
|
|
643
|
+
return !isVerb || isNounOrAdjective;
|
|
644
|
+
}
|
|
645
|
+
function getOccurrenceFromMatch(entry, originalText) {
|
|
646
|
+
const terms = entry.terms;
|
|
647
|
+
if (!terms?.length) return null;
|
|
648
|
+
const firstOffset = terms[0].offset;
|
|
649
|
+
const lastOffset = terms[terms.length - 1].offset;
|
|
650
|
+
if (typeof firstOffset?.start !== "number" || typeof firstOffset.length !== "number" || typeof lastOffset?.start !== "number" || typeof lastOffset.length !== "number") {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
const start = firstOffset.start;
|
|
654
|
+
const end = lastOffset.start + lastOffset.length;
|
|
655
|
+
if (end <= start) return null;
|
|
656
|
+
return {
|
|
657
|
+
text: originalText.slice(start, end),
|
|
658
|
+
start,
|
|
659
|
+
end
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
var noNonInclusiveLanguage = {
|
|
663
|
+
id: "no-non-inclusive-language-nlp",
|
|
664
|
+
description: "Flag non-inclusive terms and suggest neutral alternatives using NLP-aware matching",
|
|
665
|
+
defaults: { terms: DEFAULT_TERMS2, allowedTerms: [] },
|
|
666
|
+
help: "Non-inclusive terms can alienate readers. Replace them with neutral alternatives that name the same idea without relying on identity, ability, or historical power metaphors.",
|
|
667
|
+
check({ text, sourceMap, options }) {
|
|
668
|
+
const terms = options.terms?.length ? options.terms : DEFAULT_TERMS2;
|
|
669
|
+
const allowed = new Set((options.allowedTerms ?? []).map((term) => term.toLowerCase()));
|
|
670
|
+
const doc = createDoc(text);
|
|
671
|
+
const diagnostics = [];
|
|
672
|
+
for (const term of terms) {
|
|
673
|
+
if (allowed.has(term.term.toLowerCase())) continue;
|
|
674
|
+
const pattern = toMatchPattern(term.term);
|
|
675
|
+
const matches = doc.match(pattern);
|
|
676
|
+
const json = matches.json({ offset: true, text: true, terms: { offset: true, text: true, tags: true } });
|
|
677
|
+
for (const entry of json) {
|
|
678
|
+
if (!isProblematicUsage(term, entry.terms ?? [])) continue;
|
|
679
|
+
const occurrence = getOccurrenceFromMatch(entry, text);
|
|
680
|
+
if (!occurrence) continue;
|
|
681
|
+
const range = getOccurrenceRange(sourceMap, occurrence);
|
|
682
|
+
if (!range) continue;
|
|
683
|
+
const suggestion = term.alternatives.join(", ");
|
|
684
|
+
diagnostics.push({
|
|
685
|
+
ruleId: "no-non-inclusive-language-nlp",
|
|
686
|
+
severity: "error",
|
|
687
|
+
message: `replace "${occurrence.text}" with a neutral alternative such as "${suggestion}"`,
|
|
688
|
+
range,
|
|
689
|
+
help: noNonInclusiveLanguage.help
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return diagnostics.sort((left, right) => left.range.start - right.range.start);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
|
|
415
697
|
// src/no-nominalized-phrases.ts
|
|
416
698
|
var DEFAULT_SUFFIXES = ["tion", "sion", "ment", "ance", "ence", "ity"];
|
|
417
699
|
var DEFAULT_ALLOWED_WORDS = [
|
|
@@ -459,6 +741,192 @@ function escapeRegex2(value) {
|
|
|
459
741
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
460
742
|
}
|
|
461
743
|
|
|
744
|
+
// src/no-overly-complex-sentences.ts
|
|
745
|
+
var DEFAULT_MAX_CONJUNCTIONS = 4;
|
|
746
|
+
var DEFAULT_MAX_COORDINATING = 3;
|
|
747
|
+
var DEFAULT_MAX_SUBORDINATING = 2;
|
|
748
|
+
var DEFAULT_COORDINATING = ["and", "but", "or", "nor", "yet", "so"];
|
|
749
|
+
var DEFAULT_SUBORDINATING = [
|
|
750
|
+
"because",
|
|
751
|
+
"although",
|
|
752
|
+
"though",
|
|
753
|
+
"while",
|
|
754
|
+
"since",
|
|
755
|
+
"unless",
|
|
756
|
+
"if",
|
|
757
|
+
"when",
|
|
758
|
+
"after",
|
|
759
|
+
"before",
|
|
760
|
+
"until",
|
|
761
|
+
"whether",
|
|
762
|
+
"once"
|
|
763
|
+
];
|
|
764
|
+
var noOverlyComplexSentences = {
|
|
765
|
+
id: "no-overly-complex-sentences",
|
|
766
|
+
description: "Flag sentences with too many coordinating or subordinating conjunctions",
|
|
767
|
+
defaults: {
|
|
768
|
+
maxConjunctions: DEFAULT_MAX_CONJUNCTIONS,
|
|
769
|
+
maxCoordinating: DEFAULT_MAX_COORDINATING,
|
|
770
|
+
maxSubordinating: DEFAULT_MAX_SUBORDINATING,
|
|
771
|
+
coordinating: DEFAULT_COORDINATING,
|
|
772
|
+
subordinating: DEFAULT_SUBORDINATING,
|
|
773
|
+
allowList: []
|
|
774
|
+
},
|
|
775
|
+
help: "Sentences packed with conjunctions are often run-ons or nested too deeply. Break them into shorter sentences so each point stands on its own.",
|
|
776
|
+
check({ text, sourceMap, options }) {
|
|
777
|
+
const maxConjunctions = options.maxConjunctions ?? DEFAULT_MAX_CONJUNCTIONS;
|
|
778
|
+
const maxCoordinating = options.maxCoordinating ?? DEFAULT_MAX_COORDINATING;
|
|
779
|
+
const maxSubordinating = options.maxSubordinating ?? DEFAULT_MAX_SUBORDINATING;
|
|
780
|
+
const coordinating = (options.coordinating ?? DEFAULT_COORDINATING).map(
|
|
781
|
+
(value) => value.toLowerCase().trim()
|
|
782
|
+
);
|
|
783
|
+
const subordinating = (options.subordinating ?? DEFAULT_SUBORDINATING).map(
|
|
784
|
+
(value) => value.toLowerCase().trim()
|
|
785
|
+
);
|
|
786
|
+
const allowList = new Set(
|
|
787
|
+
(options.allowList ?? []).map((value) => value.toLowerCase().trim())
|
|
788
|
+
);
|
|
789
|
+
const patterns = [
|
|
790
|
+
...coordinating.map((phrase) => ({
|
|
791
|
+
phrase,
|
|
792
|
+
category: "coordinating",
|
|
793
|
+
regex: phraseToRegex(phrase)
|
|
794
|
+
})),
|
|
795
|
+
...subordinating.map((phrase) => ({
|
|
796
|
+
phrase,
|
|
797
|
+
category: "subordinating",
|
|
798
|
+
regex: phraseToRegex(phrase)
|
|
799
|
+
}))
|
|
800
|
+
].sort((a, b) => b.phrase.length - a.phrase.length);
|
|
801
|
+
const doc = createDoc(text);
|
|
802
|
+
const sentences = doc.sentences().json({ offset: true, terms: { offset: true } });
|
|
803
|
+
const diagnostics = [];
|
|
804
|
+
for (const sentence of sentences) {
|
|
805
|
+
if (!sentence.offset || !sentence.terms) continue;
|
|
806
|
+
const sentenceStart = sentence.offset.start ?? 0;
|
|
807
|
+
const sentenceText = sentence.text ?? "";
|
|
808
|
+
const matchedRanges = [];
|
|
809
|
+
let coordinatingCount = 0;
|
|
810
|
+
let subordinatingCount = 0;
|
|
811
|
+
for (const { phrase, category, regex } of patterns) {
|
|
812
|
+
if (allowList.has(phrase)) continue;
|
|
813
|
+
let match;
|
|
814
|
+
while ((match = regex.exec(sentenceText)) !== null) {
|
|
815
|
+
const matchStart = match.index;
|
|
816
|
+
const matchEnd = match.index + match[0].length;
|
|
817
|
+
if (matchedRanges.some((range) => matchStart < range.end && matchEnd > range.start)) {
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
matchedRanges.push({ start: matchStart, end: matchEnd });
|
|
821
|
+
if (category === "coordinating") {
|
|
822
|
+
coordinatingCount += 1;
|
|
823
|
+
} else {
|
|
824
|
+
subordinatingCount += 1;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
const total = coordinatingCount + subordinatingCount;
|
|
829
|
+
if (total <= maxConjunctions && coordinatingCount <= maxCoordinating && subordinatingCount <= maxSubordinating) {
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
const length = sentence.offset.length ?? 0;
|
|
833
|
+
const sentenceEnd = sentenceStart + length;
|
|
834
|
+
const sourceStart = sourceMap[sentenceStart];
|
|
835
|
+
const sourceEnd = sourceMap[sentenceEnd - 1];
|
|
836
|
+
if (sourceStart === void 0 || sourceEnd === void 0) continue;
|
|
837
|
+
const reasons = [];
|
|
838
|
+
if (total > maxConjunctions) {
|
|
839
|
+
reasons.push(`${total} conjunctions (max ${maxConjunctions})`);
|
|
840
|
+
}
|
|
841
|
+
if (coordinatingCount > maxCoordinating) {
|
|
842
|
+
reasons.push(`${coordinatingCount} coordinating (max ${maxCoordinating})`);
|
|
843
|
+
}
|
|
844
|
+
if (subordinatingCount > maxSubordinating) {
|
|
845
|
+
reasons.push(`${subordinatingCount} subordinating (max ${maxSubordinating})`);
|
|
846
|
+
}
|
|
847
|
+
diagnostics.push({
|
|
848
|
+
ruleId: "no-overly-complex-sentences",
|
|
849
|
+
severity: "warn",
|
|
850
|
+
message: `sentence is overly complex: ${reasons.join(", ")} \u2014 consider splitting it`,
|
|
851
|
+
range: { start: sourceStart, end: sourceEnd + 1 },
|
|
852
|
+
help: noOverlyComplexSentences.help,
|
|
853
|
+
suggest: {
|
|
854
|
+
description: "Split this sentence into shorter sentences, one idea each.",
|
|
855
|
+
edits: []
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
return diagnostics;
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
function phraseToRegex(phrase) {
|
|
863
|
+
const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
864
|
+
const spaced = escaped.replace(/\s+/g, "\\s+");
|
|
865
|
+
const source = spaced.includes("\\s+") ? spaced : `\\b${spaced}\\b`;
|
|
866
|
+
return new RegExp(source, "gi");
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/no-overused-adverbs.ts
|
|
870
|
+
var DEFAULT_THRESHOLD = 3;
|
|
871
|
+
var DEFAULT_MIN_LENGTH = 3;
|
|
872
|
+
var DEFAULT_ALLOWED_ADVERBS2 = ["only", "not"];
|
|
873
|
+
var noOverusedAdverbs = {
|
|
874
|
+
id: "no-overused-adverbs",
|
|
875
|
+
description: "Flag adverbs that appear excessively across the text",
|
|
876
|
+
defaults: {
|
|
877
|
+
threshold: DEFAULT_THRESHOLD,
|
|
878
|
+
minLength: DEFAULT_MIN_LENGTH,
|
|
879
|
+
allowedAdverbs: DEFAULT_ALLOWED_ADVERBS2
|
|
880
|
+
},
|
|
881
|
+
help: "Repeating the same adverb throughout a passage weakens copy and reads as filler. Replace the adverb with a stronger verb or adjective, cut it, or add it to allowedAdverbs if it is essential.",
|
|
882
|
+
check({ text, sourceMap, options }) {
|
|
883
|
+
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
884
|
+
const minLength = options.minLength ?? DEFAULT_MIN_LENGTH;
|
|
885
|
+
const allowed = new Set((options.allowedAdverbs ?? DEFAULT_ALLOWED_ADVERBS2).map((value) => value.toLowerCase()));
|
|
886
|
+
const adverbFilter = options.adverbs ? new Set(options.adverbs.map((value) => value.toLowerCase())) : null;
|
|
887
|
+
const doc = createDoc(text);
|
|
888
|
+
const sentences = doc.json({ offset: true, terms: { offset: true, tags: true } });
|
|
889
|
+
const occurrencesByAdverb = /* @__PURE__ */ new Map();
|
|
890
|
+
for (const sentence of sentences) {
|
|
891
|
+
for (const term of sentence.terms ?? []) {
|
|
892
|
+
if (!term.tags?.includes("Adverb")) continue;
|
|
893
|
+
const rawText = term.text?.replace(/[^a-zA-Z]+$/, "") ?? "";
|
|
894
|
+
const normalized = rawText.toLowerCase();
|
|
895
|
+
if (!normalized || normalized.length < minLength) continue;
|
|
896
|
+
if (allowed.has(normalized)) continue;
|
|
897
|
+
if (adverbFilter && !adverbFilter.has(normalized)) continue;
|
|
898
|
+
const start = term.offset?.start;
|
|
899
|
+
const length = term.offset?.length;
|
|
900
|
+
if (typeof start !== "number" || typeof length !== "number") continue;
|
|
901
|
+
const occurrence = { text: rawText, start, end: start + length };
|
|
902
|
+
const existing = occurrencesByAdverb.get(normalized);
|
|
903
|
+
if (existing) {
|
|
904
|
+
existing.push(occurrence);
|
|
905
|
+
} else {
|
|
906
|
+
occurrencesByAdverb.set(normalized, [occurrence]);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const diagnostics = [];
|
|
911
|
+
for (const [, occurrences] of occurrencesByAdverb) {
|
|
912
|
+
if (occurrences.length <= threshold) continue;
|
|
913
|
+
for (let index = threshold; index < occurrences.length; index += 1) {
|
|
914
|
+
const occurrence = occurrences[index];
|
|
915
|
+
const range = getOccurrenceRange(sourceMap, occurrence);
|
|
916
|
+
if (!range) continue;
|
|
917
|
+
diagnostics.push({
|
|
918
|
+
ruleId: "no-overused-adverbs",
|
|
919
|
+
severity: "warn",
|
|
920
|
+
message: `reduce overused adverb: "${occurrence.text}" appears ${occurrences.length} times`,
|
|
921
|
+
range,
|
|
922
|
+
help: noOverusedAdverbs.help
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return diagnostics.sort((left, right) => left.range.start - right.range.start);
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
|
|
462
930
|
// src/no-passive-voice.ts
|
|
463
931
|
var DEFAULT_ALLOWED_AUXILIARIES = ["is", "are", "was", "were", "be", "been", "being"];
|
|
464
932
|
var noPassiveVoice = {
|
|
@@ -549,6 +1017,65 @@ function escapeRegex3(value) {
|
|
|
549
1017
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
550
1018
|
}
|
|
551
1019
|
|
|
1020
|
+
// src/no-qualifier-creep.ts
|
|
1021
|
+
var DEFAULT_QUALIFIERS = [
|
|
1022
|
+
"very",
|
|
1023
|
+
"really",
|
|
1024
|
+
"quite",
|
|
1025
|
+
"fairly",
|
|
1026
|
+
"somewhat",
|
|
1027
|
+
"rather",
|
|
1028
|
+
"pretty",
|
|
1029
|
+
"basically",
|
|
1030
|
+
"actually",
|
|
1031
|
+
"literally",
|
|
1032
|
+
"essentially",
|
|
1033
|
+
"truly",
|
|
1034
|
+
"definitely",
|
|
1035
|
+
"clearly",
|
|
1036
|
+
"obviously",
|
|
1037
|
+
"absolutely",
|
|
1038
|
+
"completely",
|
|
1039
|
+
"totally",
|
|
1040
|
+
"utterly"
|
|
1041
|
+
];
|
|
1042
|
+
var DEFAULT_MAX_QUALIFIERS = 1;
|
|
1043
|
+
var noQualifierCreep = {
|
|
1044
|
+
id: "no-qualifier-creep",
|
|
1045
|
+
description: "Flag stacked qualifiers or intensifiers before an adjective or adverb",
|
|
1046
|
+
defaults: { qualifiers: DEFAULT_QUALIFIERS, maxQualifiers: DEFAULT_MAX_QUALIFIERS },
|
|
1047
|
+
help: "Stacked qualifiers dilute the claim and feel hedged. Keep the strongest qualifier or replace the phrase with concrete evidence.",
|
|
1048
|
+
check({ text, sourceMap, options }) {
|
|
1049
|
+
const qualifiers = new Set((options.qualifiers?.length ? options.qualifiers : DEFAULT_QUALIFIERS).map((value) => value.toLowerCase()));
|
|
1050
|
+
const maxQualifiers = options.maxQualifiers ?? DEFAULT_MAX_QUALIFIERS;
|
|
1051
|
+
if (maxQualifiers < 1) return [];
|
|
1052
|
+
const doc = createDoc(text);
|
|
1053
|
+
const diagnostics = [];
|
|
1054
|
+
const seenRanges = [];
|
|
1055
|
+
const patterns = ["#Adverb #Adverb+ #Adjective", "#Adverb #Adverb+ #Adverb"];
|
|
1056
|
+
for (const pattern of patterns) {
|
|
1057
|
+
const matches = doc.match(pattern);
|
|
1058
|
+
for (const occurrence of getMatchOccurrences(text, matches)) {
|
|
1059
|
+
const words = occurrence.text.trim().split(/\s+/);
|
|
1060
|
+
const qualifierWords = words.filter((word) => qualifiers.has(word.toLowerCase().replace(/[^a-zA-Z]+$/, "")));
|
|
1061
|
+
if (qualifierWords.length <= maxQualifiers) continue;
|
|
1062
|
+
if (seenRanges.some((range2) => occurrence.start < range2.end && occurrence.end > range2.start)) continue;
|
|
1063
|
+
seenRanges.push({ start: occurrence.start, end: occurrence.end });
|
|
1064
|
+
const range = getOccurrenceRange(sourceMap, occurrence);
|
|
1065
|
+
if (!range) continue;
|
|
1066
|
+
diagnostics.push({
|
|
1067
|
+
ruleId: "no-qualifier-creep",
|
|
1068
|
+
severity: "warn",
|
|
1069
|
+
message: `remove stacked qualifiers in "${occurrence.text}" \u2014 use one strong word or a concrete detail`,
|
|
1070
|
+
range,
|
|
1071
|
+
help: noQualifierCreep.help
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return diagnostics.sort((left, right) => left.range.start - right.range.start);
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
|
|
552
1079
|
// src/no-redundant-pairs.ts
|
|
553
1080
|
var DEFAULT_PHRASES5 = [
|
|
554
1081
|
"first and foremost",
|
|
@@ -570,7 +1097,7 @@ var noRedundantPairs = {
|
|
|
570
1097
|
const diagnostics = [];
|
|
571
1098
|
const phrases = options.phrases?.length ? options.phrases : DEFAULT_PHRASES5;
|
|
572
1099
|
for (const phrase of phrases) {
|
|
573
|
-
const re = new RegExp(`\\b${
|
|
1100
|
+
const re = new RegExp(`\\b${escapeRegExp6(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
|
|
574
1101
|
let match;
|
|
575
1102
|
while ((match = re.exec(text)) !== null) {
|
|
576
1103
|
const matchedPhrase = match[0];
|
|
@@ -589,7 +1116,7 @@ var noRedundantPairs = {
|
|
|
589
1116
|
return diagnostics;
|
|
590
1117
|
}
|
|
591
1118
|
};
|
|
592
|
-
function
|
|
1119
|
+
function escapeRegExp6(value) {
|
|
593
1120
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
594
1121
|
}
|
|
595
1122
|
|
|
@@ -644,7 +1171,7 @@ var noSuperlativeClaims = {
|
|
|
644
1171
|
);
|
|
645
1172
|
const claimedRanges = [];
|
|
646
1173
|
for (const phrase of phrases) {
|
|
647
|
-
const re = new RegExp(`\\b${
|
|
1174
|
+
const re = new RegExp(`\\b${escapeRegExp7(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
|
|
648
1175
|
let match;
|
|
649
1176
|
while ((match = re.exec(text)) !== null) {
|
|
650
1177
|
const matchedPhrase = match[0];
|
|
@@ -673,10 +1200,89 @@ function getLongestPhrasesFirst(phrases) {
|
|
|
673
1200
|
function rangesOverlap(range, start, end) {
|
|
674
1201
|
return start < range.end && end > range.start;
|
|
675
1202
|
}
|
|
676
|
-
function
|
|
1203
|
+
function escapeRegExp7(value) {
|
|
677
1204
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
678
1205
|
}
|
|
679
1206
|
|
|
1207
|
+
// src/no-vague-comparatives.ts
|
|
1208
|
+
import nlp2 from "compromise";
|
|
1209
|
+
var DEFAULT_OPTIONS2 = {
|
|
1210
|
+
comparatives: [
|
|
1211
|
+
"better",
|
|
1212
|
+
"worse",
|
|
1213
|
+
"more",
|
|
1214
|
+
"less",
|
|
1215
|
+
"faster",
|
|
1216
|
+
"slower",
|
|
1217
|
+
"easier",
|
|
1218
|
+
"harder",
|
|
1219
|
+
"stronger",
|
|
1220
|
+
"weaker",
|
|
1221
|
+
"higher",
|
|
1222
|
+
"lower",
|
|
1223
|
+
"bigger",
|
|
1224
|
+
"smaller",
|
|
1225
|
+
"greater"
|
|
1226
|
+
],
|
|
1227
|
+
requireThan: true
|
|
1228
|
+
};
|
|
1229
|
+
var noVagueComparatives = {
|
|
1230
|
+
id: "no-vague-comparatives",
|
|
1231
|
+
description: "Flag comparative claims that omit a clear baseline",
|
|
1232
|
+
defaults: { ...DEFAULT_OPTIONS2 },
|
|
1233
|
+
help: 'Comparative words like "better" or "faster" only persuade when the reader knows what is being compared. Add "than" and a concrete baseline, or rephrase with a specific metric.',
|
|
1234
|
+
check({ text, sourceMap, options }) {
|
|
1235
|
+
const comparatives = new Set(
|
|
1236
|
+
(options.comparatives ?? DEFAULT_OPTIONS2.comparatives).map((word) => word.toLowerCase())
|
|
1237
|
+
);
|
|
1238
|
+
const requireThan = options.requireThan ?? DEFAULT_OPTIONS2.requireThan;
|
|
1239
|
+
if (comparatives.size === 0) return [];
|
|
1240
|
+
const doc = nlp2(text);
|
|
1241
|
+
const diagnostics = [];
|
|
1242
|
+
const seenRanges = [];
|
|
1243
|
+
const sentences = doc.sentences().json({ offset: true, terms: { offset: true, text: true } });
|
|
1244
|
+
for (const sentence of sentences) {
|
|
1245
|
+
const sentenceStart = sentence.offset?.start ?? sentence.terms?.[0]?.offset?.start;
|
|
1246
|
+
const sentenceLength = sentence.offset?.length ?? sentence.terms?.reduce((sum, term) => sum + (term.offset?.length ?? 0), 0);
|
|
1247
|
+
if (typeof sentenceStart !== "number" || typeof sentenceLength !== "number") continue;
|
|
1248
|
+
const sentenceEnd = sentenceStart + sentenceLength;
|
|
1249
|
+
const sentenceText = sentence.text ?? text.slice(sentenceStart, sentenceEnd);
|
|
1250
|
+
const sentenceDoc = nlp2(sentenceText);
|
|
1251
|
+
const explicitComparatives = Array.from(comparatives).join("|");
|
|
1252
|
+
const patterns = [
|
|
1253
|
+
"#Comparative",
|
|
1254
|
+
`(more|less) #Adjective`,
|
|
1255
|
+
`(more|less) #Adverb`,
|
|
1256
|
+
`(${explicitComparatives})`
|
|
1257
|
+
];
|
|
1258
|
+
for (const pattern of patterns) {
|
|
1259
|
+
const matches = sentenceDoc.match(pattern);
|
|
1260
|
+
for (const occurrence of getMatchOccurrences(sentenceText, matches)) {
|
|
1261
|
+
const words = occurrence.text.trim().toLowerCase().split(/\s+/);
|
|
1262
|
+
const matchedComparative = words.find((word) => comparatives.has(word.replace(/[^a-zA-Z]+$/, "")));
|
|
1263
|
+
if (!matchedComparative) continue;
|
|
1264
|
+
const trailingPunctuation = occurrence.text.match(/[^a-zA-Z\s]+$/)?.[0].length ?? 0;
|
|
1265
|
+
const occurrenceStart = sentenceStart + occurrence.start;
|
|
1266
|
+
const occurrenceEnd = sentenceStart + occurrence.end - trailingPunctuation;
|
|
1267
|
+
if (seenRanges.some((range2) => occurrenceStart < range2.end && occurrenceEnd > range2.start)) continue;
|
|
1268
|
+
seenRanges.push({ start: occurrenceStart, end: occurrenceEnd });
|
|
1269
|
+
if (requireThan && sentenceDoc.has("than")) continue;
|
|
1270
|
+
const range = getOccurrenceRange(sourceMap, { text: occurrence.text, start: occurrenceStart, end: occurrenceEnd });
|
|
1271
|
+
if (!range) continue;
|
|
1272
|
+
diagnostics.push({
|
|
1273
|
+
ruleId: "no-vague-comparatives",
|
|
1274
|
+
severity: "warn",
|
|
1275
|
+
message: `comparative "${occurrence.text.trim().replace(/[^a-zA-Z\s]+$/, "")}" needs a baseline \u2014 add "than" or a concrete comparison`,
|
|
1276
|
+
range,
|
|
1277
|
+
help: noVagueComparatives.help
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
return diagnostics.sort((left, right) => left.range.start - right.range.start);
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
|
|
680
1286
|
// src/no-vague-quantifiers.ts
|
|
681
1287
|
var DEFAULT_QUANTIFIERS = [
|
|
682
1288
|
"many",
|
|
@@ -703,7 +1309,7 @@ var noVagueQuantifiers = {
|
|
|
703
1309
|
const quantifiers = options.quantifiers?.length ? options.quantifiers : DEFAULT_QUANTIFIERS;
|
|
704
1310
|
for (const quantifier of quantifiers) {
|
|
705
1311
|
const re = new RegExp(
|
|
706
|
-
`\\b${
|
|
1312
|
+
`\\b${escapeRegExp8(quantifier).replace(/\\s+/g, "\\s+")}\\b`,
|
|
707
1313
|
"gi"
|
|
708
1314
|
);
|
|
709
1315
|
let match;
|
|
@@ -724,7 +1330,7 @@ var noVagueQuantifiers = {
|
|
|
724
1330
|
return diagnostics;
|
|
725
1331
|
}
|
|
726
1332
|
};
|
|
727
|
-
function
|
|
1333
|
+
function escapeRegExp8(value) {
|
|
728
1334
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
729
1335
|
}
|
|
730
1336
|
|
|
@@ -773,9 +1379,62 @@ var noWeakModals = {
|
|
|
773
1379
|
}
|
|
774
1380
|
};
|
|
775
1381
|
|
|
1382
|
+
// src/sentence-complexity.ts
|
|
1383
|
+
var DEFAULT_OPTIONS3 = {
|
|
1384
|
+
maxWordCount: 25,
|
|
1385
|
+
maxClauseCount: 3
|
|
1386
|
+
};
|
|
1387
|
+
var sentenceComplexity = {
|
|
1388
|
+
id: "sentence-complexity",
|
|
1389
|
+
description: "Flag sentences that exceed a word or clause threshold",
|
|
1390
|
+
defaults: { ...DEFAULT_OPTIONS3 },
|
|
1391
|
+
help: "Long, clause-heavy sentences are harder to read. Split them into shorter sentences that each make one point.",
|
|
1392
|
+
check({ text, sourceMap, options }) {
|
|
1393
|
+
const maxWordCount = options.maxWordCount ?? DEFAULT_OPTIONS3.maxWordCount;
|
|
1394
|
+
const maxClauseCount = options.maxClauseCount ?? DEFAULT_OPTIONS3.maxClauseCount;
|
|
1395
|
+
const doc = createDoc(text);
|
|
1396
|
+
const sentences = doc.sentences().json({ offset: true, terms: { offset: true, tags: true } });
|
|
1397
|
+
const diagnostics = [];
|
|
1398
|
+
for (const sentence of sentences) {
|
|
1399
|
+
if (!sentence.offset || !sentence.terms) continue;
|
|
1400
|
+
const terms = sentence.terms.filter((term) => /[a-zA-Z0-9]/.test(term.text ?? ""));
|
|
1401
|
+
const wordCount = terms.length;
|
|
1402
|
+
const clauseCount = terms.filter((term) => {
|
|
1403
|
+
const tags = term.tags ?? [];
|
|
1404
|
+
return tags.includes("Verb") && !tags.includes("Gerund") && !tags.includes("Infinitive") && !tags.includes("Particle");
|
|
1405
|
+
}).length;
|
|
1406
|
+
if (wordCount <= maxWordCount && clauseCount <= maxClauseCount) continue;
|
|
1407
|
+
const start = sentence.offset.start ?? 0;
|
|
1408
|
+
const length = sentence.offset.length ?? 0;
|
|
1409
|
+
const end = start + length;
|
|
1410
|
+
const sourceStart = sourceMap[start];
|
|
1411
|
+
const sourceEnd = sourceMap[end - 1];
|
|
1412
|
+
if (sourceStart === void 0 || sourceEnd === void 0) continue;
|
|
1413
|
+
const reasons = [];
|
|
1414
|
+
if (wordCount > maxWordCount) reasons.push(`${wordCount} words (max ${maxWordCount})`);
|
|
1415
|
+
if (clauseCount > maxClauseCount) reasons.push(`${clauseCount} clauses (max ${maxClauseCount})`);
|
|
1416
|
+
diagnostics.push({
|
|
1417
|
+
ruleId: "sentence-complexity",
|
|
1418
|
+
severity: "warn",
|
|
1419
|
+
message: `sentence is too complex: ${reasons.join(", ")} \u2014 consider splitting it`,
|
|
1420
|
+
range: { start: sourceStart, end: sourceEnd + 1 },
|
|
1421
|
+
help: sentenceComplexity.help,
|
|
1422
|
+
suggest: {
|
|
1423
|
+
description: "Split this sentence into shorter sentences, one idea each.",
|
|
1424
|
+
edits: []
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
return diagnostics;
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
|
|
776
1432
|
// src/index.ts
|
|
777
1433
|
var ruleRegistry = /* @__PURE__ */ new Map([
|
|
1434
|
+
["no-absolute-intensifiers", noAbsoluteIntensifiers],
|
|
1435
|
+
["no-adverb-overuse", noAdverbOveruse],
|
|
778
1436
|
["no-buzzword-stacks", noBuzzwordStacks],
|
|
1437
|
+
["no-complex-readability", noComplexReadability],
|
|
779
1438
|
["no-empty-transformation-claims", noEmptyTransformationClaims],
|
|
780
1439
|
["no-expletive-openers", noExpletiveOpeners],
|
|
781
1440
|
["no-filter-words", noFilterWords],
|
|
@@ -783,17 +1442,26 @@ var ruleRegistry = /* @__PURE__ */ new Map([
|
|
|
783
1442
|
["no-hedge-words", noHedgeWords],
|
|
784
1443
|
["no-jargon", noJargon],
|
|
785
1444
|
["no-meaningless-modifiers", noMeaninglessModifiers],
|
|
1445
|
+
["no-non-inclusive-language-nlp", noNonInclusiveLanguage],
|
|
786
1446
|
["no-nominalized-phrases", noNominalizedPhrases],
|
|
1447
|
+
["no-overly-complex-sentences", noOverlyComplexSentences],
|
|
1448
|
+
["no-overused-adverbs", noOverusedAdverbs],
|
|
787
1449
|
["no-passive-voice", noPassiveVoice],
|
|
788
1450
|
["no-pronoun-led-claims", noPronounLedClaims],
|
|
1451
|
+
["no-qualifier-creep", noQualifierCreep],
|
|
789
1452
|
["no-redundant-pairs", noRedundantPairs],
|
|
790
1453
|
["no-stacked-adjectives", noStackedAdjectives],
|
|
791
1454
|
["no-superlative-claims", noSuperlativeClaims],
|
|
1455
|
+
["no-vague-comparatives", noVagueComparatives],
|
|
792
1456
|
["no-vague-quantifiers", noVagueQuantifiers],
|
|
793
|
-
["no-weak-modals", noWeakModals]
|
|
1457
|
+
["no-weak-modals", noWeakModals],
|
|
1458
|
+
["sentence-complexity", sentenceComplexity]
|
|
794
1459
|
]);
|
|
795
1460
|
export {
|
|
1461
|
+
noAbsoluteIntensifiers,
|
|
1462
|
+
noAdverbOveruse,
|
|
796
1463
|
noBuzzwordStacks,
|
|
1464
|
+
noComplexReadability,
|
|
797
1465
|
noEmptyTransformationClaims,
|
|
798
1466
|
noExpletiveOpeners,
|
|
799
1467
|
noFilterWords,
|
|
@@ -802,13 +1470,19 @@ export {
|
|
|
802
1470
|
noJargon,
|
|
803
1471
|
noMeaninglessModifiers,
|
|
804
1472
|
noNominalizedPhrases,
|
|
1473
|
+
noNonInclusiveLanguage,
|
|
1474
|
+
noOverlyComplexSentences,
|
|
1475
|
+
noOverusedAdverbs,
|
|
805
1476
|
noPassiveVoice,
|
|
806
1477
|
noPronounLedClaims,
|
|
1478
|
+
noQualifierCreep,
|
|
807
1479
|
noRedundantPairs,
|
|
808
1480
|
noStackedAdjectives,
|
|
809
1481
|
noSuperlativeClaims,
|
|
1482
|
+
noVagueComparatives,
|
|
810
1483
|
noVagueQuantifiers,
|
|
811
1484
|
noWeakModals,
|
|
812
|
-
ruleRegistry
|
|
1485
|
+
ruleRegistry,
|
|
1486
|
+
sentenceComplexity
|
|
813
1487
|
};
|
|
814
1488
|
//# sourceMappingURL=index.js.map
|