@faircopy/rules-nlp 1.14.0 → 1.16.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/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/utils.ts
81
- import nlp from "compromise";
82
- function createDoc(text) {
83
- return nlp(text);
84
- }
85
- function getMatchOccurrences(text, matches) {
86
- const json = matches.json({ offset: true, text: true, terms: { offset: true } });
87
- return json.flatMap((entry) => {
88
- const start = entry.offset?.start ?? entry.terms?.[0]?.offset?.start;
89
- const length = entry.offset?.length ?? sumTermLengths(entry.terms);
90
- if (typeof start !== "number" || typeof length !== "number" || length <= 0) {
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
- text: entry.text ?? text.slice(start, start + length),
95
- start,
96
- end: start + length
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 getOccurrenceRange(sourceMap, occurrence) {
101
- const start = sourceMap[occurrence.start];
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 sumTermLengths(terms) {
107
- if (!terms?.length) return void 0;
108
- let total = 0;
109
- for (const term of terms) {
110
- const length = term.offset?.length;
111
- if (typeof length !== "number") return void 0;
112
- total += length;
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 total;
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${escapeRegExp(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
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 escapeRegExp(value) {
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${escapeRegExp2(hedge).replace(/\\s+/g, "\\s+")}\\b`, "gi");
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 escapeRegExp2(value) {
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${escapeRegExp3(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
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 escapeRegExp3(value) {
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${escapeRegExp4(modifier).replace(/\\s+/g, "\\s+")}\\b`, "gi");
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,7 +609,7 @@ var noMeaninglessModifiers = {
408
609
  return diagnostics.sort((left, right) => left.range.start - right.range.start);
409
610
  }
410
611
  };
411
- function escapeRegExp4(value) {
612
+ function escapeRegExp5(value) {
412
613
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
413
614
  }
414
615
 
@@ -459,6 +660,192 @@ function escapeRegex2(value) {
459
660
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
460
661
  }
461
662
 
663
+ // src/no-overly-complex-sentences.ts
664
+ var DEFAULT_MAX_CONJUNCTIONS = 4;
665
+ var DEFAULT_MAX_COORDINATING = 3;
666
+ var DEFAULT_MAX_SUBORDINATING = 2;
667
+ var DEFAULT_COORDINATING = ["and", "but", "or", "nor", "yet", "so"];
668
+ var DEFAULT_SUBORDINATING = [
669
+ "because",
670
+ "although",
671
+ "though",
672
+ "while",
673
+ "since",
674
+ "unless",
675
+ "if",
676
+ "when",
677
+ "after",
678
+ "before",
679
+ "until",
680
+ "whether",
681
+ "once"
682
+ ];
683
+ var noOverlyComplexSentences = {
684
+ id: "no-overly-complex-sentences",
685
+ description: "Flag sentences with too many coordinating or subordinating conjunctions",
686
+ defaults: {
687
+ maxConjunctions: DEFAULT_MAX_CONJUNCTIONS,
688
+ maxCoordinating: DEFAULT_MAX_COORDINATING,
689
+ maxSubordinating: DEFAULT_MAX_SUBORDINATING,
690
+ coordinating: DEFAULT_COORDINATING,
691
+ subordinating: DEFAULT_SUBORDINATING,
692
+ allowList: []
693
+ },
694
+ 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.",
695
+ check({ text, sourceMap, options }) {
696
+ const maxConjunctions = options.maxConjunctions ?? DEFAULT_MAX_CONJUNCTIONS;
697
+ const maxCoordinating = options.maxCoordinating ?? DEFAULT_MAX_COORDINATING;
698
+ const maxSubordinating = options.maxSubordinating ?? DEFAULT_MAX_SUBORDINATING;
699
+ const coordinating = (options.coordinating ?? DEFAULT_COORDINATING).map(
700
+ (value) => value.toLowerCase().trim()
701
+ );
702
+ const subordinating = (options.subordinating ?? DEFAULT_SUBORDINATING).map(
703
+ (value) => value.toLowerCase().trim()
704
+ );
705
+ const allowList = new Set(
706
+ (options.allowList ?? []).map((value) => value.toLowerCase().trim())
707
+ );
708
+ const patterns = [
709
+ ...coordinating.map((phrase) => ({
710
+ phrase,
711
+ category: "coordinating",
712
+ regex: phraseToRegex(phrase)
713
+ })),
714
+ ...subordinating.map((phrase) => ({
715
+ phrase,
716
+ category: "subordinating",
717
+ regex: phraseToRegex(phrase)
718
+ }))
719
+ ].sort((a, b) => b.phrase.length - a.phrase.length);
720
+ const doc = createDoc(text);
721
+ const sentences = doc.sentences().json({ offset: true, terms: { offset: true } });
722
+ const diagnostics = [];
723
+ for (const sentence of sentences) {
724
+ if (!sentence.offset || !sentence.terms) continue;
725
+ const sentenceStart = sentence.offset.start ?? 0;
726
+ const sentenceText = sentence.text ?? "";
727
+ const matchedRanges = [];
728
+ let coordinatingCount = 0;
729
+ let subordinatingCount = 0;
730
+ for (const { phrase, category, regex } of patterns) {
731
+ if (allowList.has(phrase)) continue;
732
+ let match;
733
+ while ((match = regex.exec(sentenceText)) !== null) {
734
+ const matchStart = match.index;
735
+ const matchEnd = match.index + match[0].length;
736
+ if (matchedRanges.some((range) => matchStart < range.end && matchEnd > range.start)) {
737
+ continue;
738
+ }
739
+ matchedRanges.push({ start: matchStart, end: matchEnd });
740
+ if (category === "coordinating") {
741
+ coordinatingCount += 1;
742
+ } else {
743
+ subordinatingCount += 1;
744
+ }
745
+ }
746
+ }
747
+ const total = coordinatingCount + subordinatingCount;
748
+ if (total <= maxConjunctions && coordinatingCount <= maxCoordinating && subordinatingCount <= maxSubordinating) {
749
+ continue;
750
+ }
751
+ const length = sentence.offset.length ?? 0;
752
+ const sentenceEnd = sentenceStart + length;
753
+ const sourceStart = sourceMap[sentenceStart];
754
+ const sourceEnd = sourceMap[sentenceEnd - 1];
755
+ if (sourceStart === void 0 || sourceEnd === void 0) continue;
756
+ const reasons = [];
757
+ if (total > maxConjunctions) {
758
+ reasons.push(`${total} conjunctions (max ${maxConjunctions})`);
759
+ }
760
+ if (coordinatingCount > maxCoordinating) {
761
+ reasons.push(`${coordinatingCount} coordinating (max ${maxCoordinating})`);
762
+ }
763
+ if (subordinatingCount > maxSubordinating) {
764
+ reasons.push(`${subordinatingCount} subordinating (max ${maxSubordinating})`);
765
+ }
766
+ diagnostics.push({
767
+ ruleId: "no-overly-complex-sentences",
768
+ severity: "warn",
769
+ message: `sentence is overly complex: ${reasons.join(", ")} \u2014 consider splitting it`,
770
+ range: { start: sourceStart, end: sourceEnd + 1 },
771
+ help: noOverlyComplexSentences.help,
772
+ suggest: {
773
+ description: "Split this sentence into shorter sentences, one idea each.",
774
+ edits: []
775
+ }
776
+ });
777
+ }
778
+ return diagnostics;
779
+ }
780
+ };
781
+ function phraseToRegex(phrase) {
782
+ const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
783
+ const spaced = escaped.replace(/\s+/g, "\\s+");
784
+ const source = spaced.includes("\\s+") ? spaced : `\\b${spaced}\\b`;
785
+ return new RegExp(source, "gi");
786
+ }
787
+
788
+ // src/no-overused-adverbs.ts
789
+ var DEFAULT_THRESHOLD = 3;
790
+ var DEFAULT_MIN_LENGTH = 3;
791
+ var DEFAULT_ALLOWED_ADVERBS2 = ["only", "not"];
792
+ var noOverusedAdverbs = {
793
+ id: "no-overused-adverbs",
794
+ description: "Flag adverbs that appear excessively across the text",
795
+ defaults: {
796
+ threshold: DEFAULT_THRESHOLD,
797
+ minLength: DEFAULT_MIN_LENGTH,
798
+ allowedAdverbs: DEFAULT_ALLOWED_ADVERBS2
799
+ },
800
+ 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.",
801
+ check({ text, sourceMap, options }) {
802
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
803
+ const minLength = options.minLength ?? DEFAULT_MIN_LENGTH;
804
+ const allowed = new Set((options.allowedAdverbs ?? DEFAULT_ALLOWED_ADVERBS2).map((value) => value.toLowerCase()));
805
+ const adverbFilter = options.adverbs ? new Set(options.adverbs.map((value) => value.toLowerCase())) : null;
806
+ const doc = createDoc(text);
807
+ const sentences = doc.json({ offset: true, terms: { offset: true, tags: true } });
808
+ const occurrencesByAdverb = /* @__PURE__ */ new Map();
809
+ for (const sentence of sentences) {
810
+ for (const term of sentence.terms ?? []) {
811
+ if (!term.tags?.includes("Adverb")) continue;
812
+ const rawText = term.text?.replace(/[^a-zA-Z]+$/, "") ?? "";
813
+ const normalized = rawText.toLowerCase();
814
+ if (!normalized || normalized.length < minLength) continue;
815
+ if (allowed.has(normalized)) continue;
816
+ if (adverbFilter && !adverbFilter.has(normalized)) continue;
817
+ const start = term.offset?.start;
818
+ const length = term.offset?.length;
819
+ if (typeof start !== "number" || typeof length !== "number") continue;
820
+ const occurrence = { text: rawText, start, end: start + length };
821
+ const existing = occurrencesByAdverb.get(normalized);
822
+ if (existing) {
823
+ existing.push(occurrence);
824
+ } else {
825
+ occurrencesByAdverb.set(normalized, [occurrence]);
826
+ }
827
+ }
828
+ }
829
+ const diagnostics = [];
830
+ for (const [, occurrences] of occurrencesByAdverb) {
831
+ if (occurrences.length <= threshold) continue;
832
+ for (let index = threshold; index < occurrences.length; index += 1) {
833
+ const occurrence = occurrences[index];
834
+ const range = getOccurrenceRange(sourceMap, occurrence);
835
+ if (!range) continue;
836
+ diagnostics.push({
837
+ ruleId: "no-overused-adverbs",
838
+ severity: "warn",
839
+ message: `reduce overused adverb: "${occurrence.text}" appears ${occurrences.length} times`,
840
+ range,
841
+ help: noOverusedAdverbs.help
842
+ });
843
+ }
844
+ }
845
+ return diagnostics.sort((left, right) => left.range.start - right.range.start);
846
+ }
847
+ };
848
+
462
849
  // src/no-passive-voice.ts
463
850
  var DEFAULT_ALLOWED_AUXILIARIES = ["is", "are", "was", "were", "be", "been", "being"];
464
851
  var noPassiveVoice = {
@@ -549,6 +936,65 @@ function escapeRegex3(value) {
549
936
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
550
937
  }
551
938
 
939
+ // src/no-qualifier-creep.ts
940
+ var DEFAULT_QUALIFIERS = [
941
+ "very",
942
+ "really",
943
+ "quite",
944
+ "fairly",
945
+ "somewhat",
946
+ "rather",
947
+ "pretty",
948
+ "basically",
949
+ "actually",
950
+ "literally",
951
+ "essentially",
952
+ "truly",
953
+ "definitely",
954
+ "clearly",
955
+ "obviously",
956
+ "absolutely",
957
+ "completely",
958
+ "totally",
959
+ "utterly"
960
+ ];
961
+ var DEFAULT_MAX_QUALIFIERS = 1;
962
+ var noQualifierCreep = {
963
+ id: "no-qualifier-creep",
964
+ description: "Flag stacked qualifiers or intensifiers before an adjective or adverb",
965
+ defaults: { qualifiers: DEFAULT_QUALIFIERS, maxQualifiers: DEFAULT_MAX_QUALIFIERS },
966
+ help: "Stacked qualifiers dilute the claim and feel hedged. Keep the strongest qualifier or replace the phrase with concrete evidence.",
967
+ check({ text, sourceMap, options }) {
968
+ const qualifiers = new Set((options.qualifiers?.length ? options.qualifiers : DEFAULT_QUALIFIERS).map((value) => value.toLowerCase()));
969
+ const maxQualifiers = options.maxQualifiers ?? DEFAULT_MAX_QUALIFIERS;
970
+ if (maxQualifiers < 1) return [];
971
+ const doc = createDoc(text);
972
+ const diagnostics = [];
973
+ const seenRanges = [];
974
+ const patterns = ["#Adverb #Adverb+ #Adjective", "#Adverb #Adverb+ #Adverb"];
975
+ for (const pattern of patterns) {
976
+ const matches = doc.match(pattern);
977
+ for (const occurrence of getMatchOccurrences(text, matches)) {
978
+ const words = occurrence.text.trim().split(/\s+/);
979
+ const qualifierWords = words.filter((word) => qualifiers.has(word.toLowerCase().replace(/[^a-zA-Z]+$/, "")));
980
+ if (qualifierWords.length <= maxQualifiers) continue;
981
+ if (seenRanges.some((range2) => occurrence.start < range2.end && occurrence.end > range2.start)) continue;
982
+ seenRanges.push({ start: occurrence.start, end: occurrence.end });
983
+ const range = getOccurrenceRange(sourceMap, occurrence);
984
+ if (!range) continue;
985
+ diagnostics.push({
986
+ ruleId: "no-qualifier-creep",
987
+ severity: "warn",
988
+ message: `remove stacked qualifiers in "${occurrence.text}" \u2014 use one strong word or a concrete detail`,
989
+ range,
990
+ help: noQualifierCreep.help
991
+ });
992
+ }
993
+ }
994
+ return diagnostics.sort((left, right) => left.range.start - right.range.start);
995
+ }
996
+ };
997
+
552
998
  // src/no-redundant-pairs.ts
553
999
  var DEFAULT_PHRASES5 = [
554
1000
  "first and foremost",
@@ -570,7 +1016,7 @@ var noRedundantPairs = {
570
1016
  const diagnostics = [];
571
1017
  const phrases = options.phrases?.length ? options.phrases : DEFAULT_PHRASES5;
572
1018
  for (const phrase of phrases) {
573
- const re = new RegExp(`\\b${escapeRegExp5(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
1019
+ const re = new RegExp(`\\b${escapeRegExp6(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
574
1020
  let match;
575
1021
  while ((match = re.exec(text)) !== null) {
576
1022
  const matchedPhrase = match[0];
@@ -589,7 +1035,7 @@ var noRedundantPairs = {
589
1035
  return diagnostics;
590
1036
  }
591
1037
  };
592
- function escapeRegExp5(value) {
1038
+ function escapeRegExp6(value) {
593
1039
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
594
1040
  }
595
1041
 
@@ -644,7 +1090,7 @@ var noSuperlativeClaims = {
644
1090
  );
645
1091
  const claimedRanges = [];
646
1092
  for (const phrase of phrases) {
647
- const re = new RegExp(`\\b${escapeRegExp6(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
1093
+ const re = new RegExp(`\\b${escapeRegExp7(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
648
1094
  let match;
649
1095
  while ((match = re.exec(text)) !== null) {
650
1096
  const matchedPhrase = match[0];
@@ -673,10 +1119,89 @@ function getLongestPhrasesFirst(phrases) {
673
1119
  function rangesOverlap(range, start, end) {
674
1120
  return start < range.end && end > range.start;
675
1121
  }
676
- function escapeRegExp6(value) {
1122
+ function escapeRegExp7(value) {
677
1123
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
678
1124
  }
679
1125
 
1126
+ // src/no-vague-comparatives.ts
1127
+ import nlp2 from "compromise";
1128
+ var DEFAULT_OPTIONS2 = {
1129
+ comparatives: [
1130
+ "better",
1131
+ "worse",
1132
+ "more",
1133
+ "less",
1134
+ "faster",
1135
+ "slower",
1136
+ "easier",
1137
+ "harder",
1138
+ "stronger",
1139
+ "weaker",
1140
+ "higher",
1141
+ "lower",
1142
+ "bigger",
1143
+ "smaller",
1144
+ "greater"
1145
+ ],
1146
+ requireThan: true
1147
+ };
1148
+ var noVagueComparatives = {
1149
+ id: "no-vague-comparatives",
1150
+ description: "Flag comparative claims that omit a clear baseline",
1151
+ defaults: { ...DEFAULT_OPTIONS2 },
1152
+ 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.',
1153
+ check({ text, sourceMap, options }) {
1154
+ const comparatives = new Set(
1155
+ (options.comparatives ?? DEFAULT_OPTIONS2.comparatives).map((word) => word.toLowerCase())
1156
+ );
1157
+ const requireThan = options.requireThan ?? DEFAULT_OPTIONS2.requireThan;
1158
+ if (comparatives.size === 0) return [];
1159
+ const doc = nlp2(text);
1160
+ const diagnostics = [];
1161
+ const seenRanges = [];
1162
+ const sentences = doc.sentences().json({ offset: true, terms: { offset: true, text: true } });
1163
+ for (const sentence of sentences) {
1164
+ const sentenceStart = sentence.offset?.start ?? sentence.terms?.[0]?.offset?.start;
1165
+ const sentenceLength = sentence.offset?.length ?? sentence.terms?.reduce((sum, term) => sum + (term.offset?.length ?? 0), 0);
1166
+ if (typeof sentenceStart !== "number" || typeof sentenceLength !== "number") continue;
1167
+ const sentenceEnd = sentenceStart + sentenceLength;
1168
+ const sentenceText = sentence.text ?? text.slice(sentenceStart, sentenceEnd);
1169
+ const sentenceDoc = nlp2(sentenceText);
1170
+ const explicitComparatives = Array.from(comparatives).join("|");
1171
+ const patterns = [
1172
+ "#Comparative",
1173
+ `(more|less) #Adjective`,
1174
+ `(more|less) #Adverb`,
1175
+ `(${explicitComparatives})`
1176
+ ];
1177
+ for (const pattern of patterns) {
1178
+ const matches = sentenceDoc.match(pattern);
1179
+ for (const occurrence of getMatchOccurrences(sentenceText, matches)) {
1180
+ const words = occurrence.text.trim().toLowerCase().split(/\s+/);
1181
+ const matchedComparative = words.find((word) => comparatives.has(word.replace(/[^a-zA-Z]+$/, "")));
1182
+ if (!matchedComparative) continue;
1183
+ const trailingPunctuation = occurrence.text.match(/[^a-zA-Z\s]+$/)?.[0].length ?? 0;
1184
+ const occurrenceStart = sentenceStart + occurrence.start;
1185
+ const occurrenceEnd = sentenceStart + occurrence.end - trailingPunctuation;
1186
+ if (seenRanges.some((range2) => occurrenceStart < range2.end && occurrenceEnd > range2.start)) continue;
1187
+ seenRanges.push({ start: occurrenceStart, end: occurrenceEnd });
1188
+ if (requireThan && sentenceDoc.has("than")) continue;
1189
+ const range = getOccurrenceRange(sourceMap, { text: occurrence.text, start: occurrenceStart, end: occurrenceEnd });
1190
+ if (!range) continue;
1191
+ diagnostics.push({
1192
+ ruleId: "no-vague-comparatives",
1193
+ severity: "warn",
1194
+ message: `comparative "${occurrence.text.trim().replace(/[^a-zA-Z\s]+$/, "")}" needs a baseline \u2014 add "than" or a concrete comparison`,
1195
+ range,
1196
+ help: noVagueComparatives.help
1197
+ });
1198
+ }
1199
+ }
1200
+ }
1201
+ return diagnostics.sort((left, right) => left.range.start - right.range.start);
1202
+ }
1203
+ };
1204
+
680
1205
  // src/no-vague-quantifiers.ts
681
1206
  var DEFAULT_QUANTIFIERS = [
682
1207
  "many",
@@ -703,7 +1228,7 @@ var noVagueQuantifiers = {
703
1228
  const quantifiers = options.quantifiers?.length ? options.quantifiers : DEFAULT_QUANTIFIERS;
704
1229
  for (const quantifier of quantifiers) {
705
1230
  const re = new RegExp(
706
- `\\b${escapeRegExp7(quantifier).replace(/\\s+/g, "\\s+")}\\b`,
1231
+ `\\b${escapeRegExp8(quantifier).replace(/\\s+/g, "\\s+")}\\b`,
707
1232
  "gi"
708
1233
  );
709
1234
  let match;
@@ -724,7 +1249,7 @@ var noVagueQuantifiers = {
724
1249
  return diagnostics;
725
1250
  }
726
1251
  };
727
- function escapeRegExp7(value) {
1252
+ function escapeRegExp8(value) {
728
1253
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
729
1254
  }
730
1255
 
@@ -773,9 +1298,62 @@ var noWeakModals = {
773
1298
  }
774
1299
  };
775
1300
 
1301
+ // src/sentence-complexity.ts
1302
+ var DEFAULT_OPTIONS3 = {
1303
+ maxWordCount: 25,
1304
+ maxClauseCount: 3
1305
+ };
1306
+ var sentenceComplexity = {
1307
+ id: "sentence-complexity",
1308
+ description: "Flag sentences that exceed a word or clause threshold",
1309
+ defaults: { ...DEFAULT_OPTIONS3 },
1310
+ help: "Long, clause-heavy sentences are harder to read. Split them into shorter sentences that each make one point.",
1311
+ check({ text, sourceMap, options }) {
1312
+ const maxWordCount = options.maxWordCount ?? DEFAULT_OPTIONS3.maxWordCount;
1313
+ const maxClauseCount = options.maxClauseCount ?? DEFAULT_OPTIONS3.maxClauseCount;
1314
+ const doc = createDoc(text);
1315
+ const sentences = doc.sentences().json({ offset: true, terms: { offset: true, tags: true } });
1316
+ const diagnostics = [];
1317
+ for (const sentence of sentences) {
1318
+ if (!sentence.offset || !sentence.terms) continue;
1319
+ const terms = sentence.terms.filter((term) => /[a-zA-Z0-9]/.test(term.text ?? ""));
1320
+ const wordCount = terms.length;
1321
+ const clauseCount = terms.filter((term) => {
1322
+ const tags = term.tags ?? [];
1323
+ return tags.includes("Verb") && !tags.includes("Gerund") && !tags.includes("Infinitive") && !tags.includes("Particle");
1324
+ }).length;
1325
+ if (wordCount <= maxWordCount && clauseCount <= maxClauseCount) continue;
1326
+ const start = sentence.offset.start ?? 0;
1327
+ const length = sentence.offset.length ?? 0;
1328
+ const end = start + length;
1329
+ const sourceStart = sourceMap[start];
1330
+ const sourceEnd = sourceMap[end - 1];
1331
+ if (sourceStart === void 0 || sourceEnd === void 0) continue;
1332
+ const reasons = [];
1333
+ if (wordCount > maxWordCount) reasons.push(`${wordCount} words (max ${maxWordCount})`);
1334
+ if (clauseCount > maxClauseCount) reasons.push(`${clauseCount} clauses (max ${maxClauseCount})`);
1335
+ diagnostics.push({
1336
+ ruleId: "sentence-complexity",
1337
+ severity: "warn",
1338
+ message: `sentence is too complex: ${reasons.join(", ")} \u2014 consider splitting it`,
1339
+ range: { start: sourceStart, end: sourceEnd + 1 },
1340
+ help: sentenceComplexity.help,
1341
+ suggest: {
1342
+ description: "Split this sentence into shorter sentences, one idea each.",
1343
+ edits: []
1344
+ }
1345
+ });
1346
+ }
1347
+ return diagnostics;
1348
+ }
1349
+ };
1350
+
776
1351
  // src/index.ts
777
1352
  var ruleRegistry = /* @__PURE__ */ new Map([
1353
+ ["no-absolute-intensifiers", noAbsoluteIntensifiers],
1354
+ ["no-adverb-overuse", noAdverbOveruse],
778
1355
  ["no-buzzword-stacks", noBuzzwordStacks],
1356
+ ["no-complex-readability", noComplexReadability],
779
1357
  ["no-empty-transformation-claims", noEmptyTransformationClaims],
780
1358
  ["no-expletive-openers", noExpletiveOpeners],
781
1359
  ["no-filter-words", noFilterWords],
@@ -784,16 +1362,24 @@ var ruleRegistry = /* @__PURE__ */ new Map([
784
1362
  ["no-jargon", noJargon],
785
1363
  ["no-meaningless-modifiers", noMeaninglessModifiers],
786
1364
  ["no-nominalized-phrases", noNominalizedPhrases],
1365
+ ["no-overly-complex-sentences", noOverlyComplexSentences],
1366
+ ["no-overused-adverbs", noOverusedAdverbs],
787
1367
  ["no-passive-voice", noPassiveVoice],
788
1368
  ["no-pronoun-led-claims", noPronounLedClaims],
1369
+ ["no-qualifier-creep", noQualifierCreep],
789
1370
  ["no-redundant-pairs", noRedundantPairs],
790
1371
  ["no-stacked-adjectives", noStackedAdjectives],
791
1372
  ["no-superlative-claims", noSuperlativeClaims],
1373
+ ["no-vague-comparatives", noVagueComparatives],
792
1374
  ["no-vague-quantifiers", noVagueQuantifiers],
793
- ["no-weak-modals", noWeakModals]
1375
+ ["no-weak-modals", noWeakModals],
1376
+ ["sentence-complexity", sentenceComplexity]
794
1377
  ]);
795
1378
  export {
1379
+ noAbsoluteIntensifiers,
1380
+ noAdverbOveruse,
796
1381
  noBuzzwordStacks,
1382
+ noComplexReadability,
797
1383
  noEmptyTransformationClaims,
798
1384
  noExpletiveOpeners,
799
1385
  noFilterWords,
@@ -802,13 +1388,18 @@ export {
802
1388
  noJargon,
803
1389
  noMeaninglessModifiers,
804
1390
  noNominalizedPhrases,
1391
+ noOverlyComplexSentences,
1392
+ noOverusedAdverbs,
805
1393
  noPassiveVoice,
806
1394
  noPronounLedClaims,
1395
+ noQualifierCreep,
807
1396
  noRedundantPairs,
808
1397
  noStackedAdjectives,
809
1398
  noSuperlativeClaims,
1399
+ noVagueComparatives,
810
1400
  noVagueQuantifiers,
811
1401
  noWeakModals,
812
- ruleRegistry
1402
+ ruleRegistry,
1403
+ sentenceComplexity
813
1404
  };
814
1405
  //# sourceMappingURL=index.js.map