@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/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,10 +609,91 @@ 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
 
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${escapeRegExp5(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
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 escapeRegExp5(value) {
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${escapeRegExp6(phrase).replace(/\\s+/g, "\\s+")}\\b`, "gi");
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 escapeRegExp6(value) {
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${escapeRegExp7(quantifier).replace(/\\s+/g, "\\s+")}\\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 escapeRegExp7(value) {
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