slopless 0.2.19 → 0.2.20

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.
Files changed (33) hide show
  1. package/dist/adapters/textlint/report.js +9 -0
  2. package/dist/presets/everything.js +5 -0
  3. package/dist/registries/phrases/builtin.js +23 -0
  4. package/dist/registries/phrases/families.js +14 -0
  5. package/dist/registries/phrases.js +4 -20
  6. package/dist/registries/words.js +2 -0
  7. package/dist/reporting/density.js +23 -0
  8. package/dist/reporting/reports.js +34 -0
  9. package/dist/rules/narrative-slop/perception-verb-density.js +16 -21
  10. package/dist/rules/orthography/fake-timestamps.js +53 -0
  11. package/dist/rules/phrases/data/cliches.json +39 -3
  12. package/dist/rules/phrases/data/corporate-speak.json +39 -1
  13. package/dist/rules/phrases/data/genre-cliches.json +53 -0
  14. package/dist/rules/phrases/data/self-help-cliches.json +62 -0
  15. package/dist/rules/phrases/data/seo-filler.json +34 -0
  16. package/dist/rules/phrases/data/skunked-terms.json +1 -10
  17. package/dist/rules/phrases/data/weasel-attribution.json +37 -0
  18. package/dist/rules/phrases/genre-cliches.js +17 -0
  19. package/dist/rules/phrases/humble-bragger.js +3 -5
  20. package/dist/rules/phrases/self-help-cliches.js +17 -0
  21. package/dist/rules/phrases/seo-filler.js +17 -0
  22. package/dist/rules/phrases/weasel-attribution.js +21 -0
  23. package/dist/rules/syntactic-patterns/contrast/private/negation-reframe-matcher.js +5 -0
  24. package/dist/rules/syntactic-patterns/generalization/softening-language.js +7 -1
  25. package/dist/rules/syntactic-patterns/generalization/universalizing-claims.js +5 -82
  26. package/dist/rules/syntactic-patterns/lead-ins/boilerplate-framing.js +16 -6
  27. package/dist/rules/syntactic-patterns/lead-ins/generic-signposting.js +3 -1
  28. package/dist/rules/syntactic-patterns/llm-artifacts/data/response-wrapper-patterns.json +0 -1
  29. package/dist/rules/syntactic-patterns/repetition/triple-repeat.js +28 -0
  30. package/dist/rules/words/actually-overuse.js +45 -0
  31. package/dist/rules/words/data/prohibited-words.json +0 -1
  32. package/dist/rules/words/llm-vocabulary-density.js +0 -1
  33. package/package.json +1 -1
@@ -8,6 +8,15 @@ export function emitTextlintReport(context, unitsById, report) {
8
8
  const padding = report.range.start === report.range.end
9
9
  ? context.locator.at(report.range.start)
10
10
  : context.locator.range([report.range.start, report.range.end]);
11
+ // textlint reads a per-report severity only from a plain reported object, not from a
12
+ // RuleError instance (kernel: `ruleReportedObject.severity || error`). The reporter sets
13
+ // report.severity when a policy judges the level (1 = warning, 2 = error); everything else
14
+ // keeps the default error severity via RuleError.
15
+ const { severity } = report;
16
+ if (typeof severity === "number") {
17
+ context.report(unit.node, { message: report.message, padding, severity });
18
+ return;
19
+ }
11
20
  context.report(unit.node, new context.RuleError(report.message, {
12
21
  padding
13
22
  }));
@@ -1,5 +1,6 @@
1
1
  export const everything = {
2
2
  rules: {
3
+ "actually-overuse": true,
3
4
  "affirmation-closers": true,
4
5
  "artifact-placeholders": true,
5
6
  "authority-padding": true,
@@ -50,6 +51,10 @@ export const everything = {
50
51
  "sentence-case": true,
51
52
  "semantic-thinness": true,
52
53
  "significance-density": true,
54
+ "seo-filler": true,
55
+ "self-help-cliches": true,
56
+ "genre-cliches": true,
57
+ "weasel-attribution": true,
53
58
  simplicity: true,
54
59
  "skunked-terms": true,
55
60
  "softening-language": true,
@@ -0,0 +1,23 @@
1
+ import cliches from "../../rules/phrases/cliches.js";
2
+ import corporateSpeak from "../../rules/phrases/corporate-speak.js";
3
+ import humbleBragger from "../../rules/phrases/humble-bragger.js";
4
+ import jargonFaker from "../../rules/phrases/jargon-faker.js";
5
+ import llmDisclaimer from "../../rules/phrases/llm-disclaimer.js";
6
+ import prohibitedPhrases from "../../rules/phrases/prohibited-phrases.js";
7
+ import redundancy from "../../rules/phrases/redundancy.js";
8
+ import skunkedTerms from "../../rules/phrases/skunked-terms.js";
9
+ import uncomparables from "../../rules/phrases/uncomparables.js";
10
+ import wordiness from "../../rules/phrases/wordiness.js";
11
+ export const phraseBuiltinRules = {
12
+ cliches,
13
+ "corporate-speak": corporateSpeak,
14
+ "humble-bragger": humbleBragger,
15
+ "jargon-faker": jargonFaker,
16
+ "llm-disclaimer": llmDisclaimer,
17
+ "prohibited-phrases": prohibitedPhrases,
18
+ redundancy,
19
+ "skunked-terms": skunkedTerms,
20
+ uncomparables,
21
+ wordiness
22
+ };
23
+ //# sourceMappingURL=builtin.js.map
@@ -0,0 +1,14 @@
1
+ import genreCliches from "../../rules/phrases/genre-cliches.js";
2
+ import seoFiller from "../../rules/phrases/seo-filler.js";
3
+ import selfHelpCliches from "../../rules/phrases/self-help-cliches.js";
4
+ import weaselAttribution from "../../rules/phrases/weasel-attribution.js";
5
+ // New slop families added from the corpus-driven expansion. Each is a phrase-list
6
+ // rule (findUnquotedPhraseMatches over a data JSON), screened for low false positives
7
+ // against a multi-topic human corpus.
8
+ export const phraseFamilyRules = {
9
+ "genre-cliches": genreCliches,
10
+ "seo-filler": seoFiller,
11
+ "self-help-cliches": selfHelpCliches,
12
+ "weasel-attribution": weaselAttribution
13
+ };
14
+ //# sourceMappingURL=families.js.map
@@ -1,23 +1,7 @@
1
- import cliches from "../rules/phrases/cliches.js";
2
- import corporateSpeak from "../rules/phrases/corporate-speak.js";
3
- import humbleBragger from "../rules/phrases/humble-bragger.js";
4
- import jargonFaker from "../rules/phrases/jargon-faker.js";
5
- import llmDisclaimer from "../rules/phrases/llm-disclaimer.js";
6
- import prohibitedPhrases from "../rules/phrases/prohibited-phrases.js";
7
- import redundancy from "../rules/phrases/redundancy.js";
8
- import skunkedTerms from "../rules/phrases/skunked-terms.js";
9
- import uncomparables from "../rules/phrases/uncomparables.js";
10
- import wordiness from "../rules/phrases/wordiness.js";
1
+ import { phraseBuiltinRules } from "./phrases/builtin.js";
2
+ import { phraseFamilyRules } from "./phrases/families.js";
11
3
  export const phraseRules = {
12
- cliches,
13
- "corporate-speak": corporateSpeak,
14
- "humble-bragger": humbleBragger,
15
- "jargon-faker": jargonFaker,
16
- "llm-disclaimer": llmDisclaimer,
17
- "prohibited-phrases": prohibitedPhrases,
18
- redundancy,
19
- "skunked-terms": skunkedTerms,
20
- uncomparables,
21
- wordiness
4
+ ...phraseBuiltinRules,
5
+ ...phraseFamilyRules
22
6
  };
23
7
  //# sourceMappingURL=phrases.js.map
@@ -1,9 +1,11 @@
1
+ import actuallyOveruse from "../rules/words/actually-overuse.js";
1
2
  import hedgeStacking from "../rules/words/hedge-stacking.js";
2
3
  import llmVocabularyDensity from "../rules/words/llm-vocabulary-density.js";
3
4
  import llmVocabulary from "../rules/words/llm-vocabulary.js";
4
5
  import prohibitedWords from "../rules/words/prohibited-words.js";
5
6
  import simplicity from "../rules/words/simplicity.js";
6
7
  export const wordRules = {
8
+ "actually-overuse": actuallyOveruse,
7
9
  "hedge-stacking": hedgeStacking,
8
10
  "llm-vocabulary-density": llmVocabularyDensity,
9
11
  "llm-vocabulary": llmVocabulary,
@@ -0,0 +1,23 @@
1
+ // Density calculation used by the reporter to judge raw detections. Rules emit occurrences
2
+ // only; the reporter hands the occurrence count and the unit word count here, and this
3
+ // module decides whether the density is acceptable, a warning, or an error. It has no rule
4
+ // or textlint knowledge so it stays reusable and unit-testable on its own.
5
+ export const WARNING_SEVERITY = 1;
6
+ export const ERROR_SEVERITY = 2;
7
+ // Occurrences per `wordsPerUnit` words. Below the warning rate (or below the minimum count)
8
+ // there is no finding; above the error rate it is an error; otherwise a warning. A single
9
+ // occurrence never fires when minimumOccurrences is 2 or more.
10
+ export function rateVerdict(occurrences, words, thresholds) {
11
+ if (occurrences < thresholds.minimumOccurrences || words <= 0) {
12
+ return undefined;
13
+ }
14
+ const perUnit = (occurrences * thresholds.wordsPerUnit) / words;
15
+ if (perUnit <= thresholds.warningPerUnit) {
16
+ return undefined;
17
+ }
18
+ return {
19
+ perUnit,
20
+ severity: perUnit > thresholds.errorPerUnit ? ERROR_SEVERITY : WARNING_SEVERITY
21
+ };
22
+ }
23
+ //# sourceMappingURL=density.js.map
@@ -1,5 +1,6 @@
1
1
  import { splitSentences } from "../shared/text/sentences.js";
2
2
  import { wordTokens } from "../shared/text/tokens.js";
3
+ import { rateVerdict } from "./density.js";
3
4
  function detectionWithin(detection, span) {
4
5
  return (detection.group !== undefined &&
5
6
  detection.range.start >= span.start &&
@@ -89,6 +90,37 @@ export function densityReports(unit, detections, config) {
89
90
  }
90
91
  ];
91
92
  }
93
+ export function densityRateReports(units, detections, policy, formatMessage) {
94
+ const reports = [];
95
+ for (const unit of units) {
96
+ const unitDetections = detections.filter((detection) => detection.unitId === unit.id);
97
+ const first = unitDetections[0];
98
+ const verdict = rateVerdict(unitDetections.length, wordTokens(unit.text).length, {
99
+ errorPerUnit: policy.errorPerUnit,
100
+ minimumOccurrences: policy.minimumOccurrences,
101
+ warningPerUnit: policy.warningPerUnit,
102
+ wordsPerUnit: policy.wordsPerUnit
103
+ });
104
+ if (verdict === undefined || first === undefined) {
105
+ continue;
106
+ }
107
+ const report = {
108
+ detections: unitDetections,
109
+ evidence: first.evidence,
110
+ message: "",
111
+ metric: {
112
+ count: unitDetections.length,
113
+ perUnit: Math.round(verdict.perUnit * 10) / 10
114
+ },
115
+ range: unit.sourceRangeFor(first.range),
116
+ ruleId: first.ruleId,
117
+ severity: verdict.severity,
118
+ unitId: unit.id
119
+ };
120
+ reports.push({ ...report, message: formatMessage(report) });
121
+ }
122
+ return reports;
123
+ }
92
124
  export function reportsForPolicy(units, detections, policy, formatMessage) {
93
125
  switch (policy.kind) {
94
126
  case "one-to-one": {
@@ -137,6 +169,8 @@ export function reportsForPolicy(units, detections, policy, formatMessage) {
137
169
  message: formatMessage(report)
138
170
  }));
139
171
  }
172
+ case "density-rate":
173
+ return densityRateReports(units, detections, policy, formatMessage);
140
174
  }
141
175
  }
142
176
  //# sourceMappingURL=reports.js.map
@@ -1,11 +1,14 @@
1
1
  import { defineTextlintRule } from "../../adapters/textlint/rule.js";
2
2
  import { paragraphUnits } from "../../adapters/textlint/units.js";
3
3
  import { wordTokens } from "../../shared/text/tokens.js";
4
+ // Dropped the tokens whose dominant sense is non-narrative and which drove the bulk of
5
+ // the human-corpus mis-fires: "see/sees/seeing" (generic: "you can see that"), the present
6
+ // "focus"/"focuses" ("focus on strategy"; narrative "focused"/"focusing" are kept),
7
+ // "peer/peers/peering" (the colleague noun and "peer review"),
8
+ // and the plural nouns "studies"/"notices" ("studies show", "post notices"). The narrative
9
+ // perception verbs - including observe/study/notice/scan, which genuine scene-slop stacks
10
+ // ("she scanned the room, noticed the door, studied his face") - are kept.
4
11
  const PERCEPTION_VERBS = new Set([
5
- "focus",
6
- "focuses",
7
- "focused",
8
- "focusing",
9
12
  "look",
10
13
  "looks",
11
14
  "looked",
@@ -26,34 +29,26 @@ const PERCEPTION_VERBS = new Set([
26
29
  "glances",
27
30
  "glanced",
28
31
  "glancing",
29
- "observe",
30
- "observes",
31
- "observed",
32
- "observing",
33
- "scan",
34
- "scans",
35
- "scanned",
36
- "scanning",
37
- "peer",
38
- "peers",
39
32
  "peered",
40
- "peering",
41
33
  "squint",
42
34
  "squints",
43
35
  "squinted",
44
36
  "squinting",
37
+ "saw",
38
+ "focused",
39
+ "focusing",
40
+ "observe",
41
+ "observed",
42
+ "observing",
45
43
  "study",
46
- "studies",
47
44
  "studied",
48
45
  "studying",
49
46
  "notice",
50
- "notices",
51
47
  "noticed",
52
48
  "noticing",
53
- "see",
54
- "sees",
55
- "saw",
56
- "seeing"
49
+ "scan",
50
+ "scanned",
51
+ "scanning"
57
52
  ]);
58
53
  const PURPOSE_LOOK_PARTICLES = new Set(["for", "up", "under", "into"]);
59
54
  const MAX_PARAGRAPH_TOKENS = 80;
@@ -3,6 +3,56 @@ import { oneToOneRule } from "../private/textlint-rule-builders.js";
3
3
  function isAsciiDigit(character) {
4
4
  return character !== undefined && character >= "0" && character <= "9";
5
5
  }
6
+ // A timestamp accompanied by a real date (weekday, month, year, or a date label) is a
7
+ // genuine timestamp - an email header, a logged event, a scheduled time - not fabricated
8
+ // AI clock specificity. Skip those.
9
+ const DATE_WORDS = [
10
+ "january",
11
+ "february",
12
+ "march",
13
+ "april",
14
+ "june",
15
+ "july",
16
+ "august",
17
+ "september",
18
+ "october",
19
+ "november",
20
+ "december",
21
+ "monday",
22
+ "tuesday",
23
+ "wednesday",
24
+ "thursday",
25
+ "friday",
26
+ "saturday",
27
+ "sunday"
28
+ ];
29
+ const DATE_LABELS = [
30
+ "date:",
31
+ "sent:",
32
+ "received:",
33
+ "posted",
34
+ "published",
35
+ "updated",
36
+ "timestamp"
37
+ ];
38
+ function hasFourDigitYear(text) {
39
+ for (let i = 0; i + 3 < text.length; i += 1) {
40
+ const a = text[i];
41
+ const b = text[i + 1];
42
+ if (((a === "1" && b === "9") || (a === "2" && b === "0")) &&
43
+ isAsciiDigit(text[i + 2]) &&
44
+ isAsciiDigit(text[i + 3])) {
45
+ return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+ function hasDateContext(sentence) {
51
+ const lower = sentence.toLocaleLowerCase("en");
52
+ return (DATE_LABELS.some((label) => lower.includes(label)) ||
53
+ DATE_WORDS.some((word) => lower.includes(word)) ||
54
+ hasFourDigitYear(lower));
55
+ }
6
56
  function isPeriodMarker(first, second) {
7
57
  if (first === undefined || second === undefined) {
8
58
  return false;
@@ -56,6 +106,9 @@ function findTimestampMatches(text) {
56
106
  function findSentenceTimestampMatches(text) {
57
107
  const matches = [];
58
108
  for (const sentence of splitSentences(text)) {
109
+ if (hasDateContext(sentence.text)) {
110
+ continue;
111
+ }
59
112
  const timestampMatches = findTimestampMatches(sentence.text);
60
113
  if (timestampMatches.length === 0) {
61
114
  continue;
@@ -331,7 +331,6 @@
331
331
  "in a pinch",
332
332
  "in a word",
333
333
  "in hot water",
334
- "in the final analysis",
335
334
  "in the gutter",
336
335
  "in the last analysis",
337
336
  "in the nick of time",
@@ -339,7 +338,6 @@
339
338
  "in your dreams",
340
339
  "innocent bystander",
341
340
  "it ain't over till the fat lady sings",
342
- "it goes without saying",
343
341
  "it takes all kinds",
344
342
  "it takes one to know one",
345
343
  "it's a small world",
@@ -791,5 +789,43 @@
791
789
  "you only live once",
792
790
  "you're the boss",
793
791
  "young and foolish",
794
- "young and vibrant"
792
+ "young and vibrant",
793
+ "a watershed moment",
794
+ "as we move forward",
795
+ "at a pivotal moment",
796
+ "beacon of hope",
797
+ "contributing to the fabric of",
798
+ "discover the wonders of",
799
+ "drive engagement",
800
+ "embrace the journey",
801
+ "empower individuals to",
802
+ "everything happens for a reason",
803
+ "gain a comprehensive understanding",
804
+ "gain valuable insights",
805
+ "harness the power of",
806
+ "has something for everyone",
807
+ "has transformed the landscape of",
808
+ "immerse yourself in",
809
+ "in the not-too-distant future",
810
+ "leave no stone unturned",
811
+ "left an indelible mark",
812
+ "navigate the complexities of",
813
+ "nestled in the heart of",
814
+ "offers valuable insights",
815
+ "plays a pivotal role in shaping",
816
+ "resonate with audiences",
817
+ "revolutionized the way we",
818
+ "rich cultural tapestry",
819
+ "sheds light on",
820
+ "significant contributions to the field",
821
+ "step out of your comfort zone",
822
+ "the stakes have never been higher",
823
+ "there is no one-size-fits-all answer",
824
+ "this changes everything",
825
+ "thriving ecosystem of",
826
+ "treasure trove of",
827
+ "unlock the potential of",
828
+ "unlock your true potential",
829
+ "vibrant hub of",
830
+ "we stand at a crossroads"
795
831
  ]
@@ -62,5 +62,43 @@
62
62
  "ecosystem of partners",
63
63
  "innovate at the speed of",
64
64
  "reimagine the future of",
65
- "built for the enterprise"
65
+ "built for the enterprise",
66
+ "10x growth",
67
+ "30,000-foot view",
68
+ "agile methodology",
69
+ "brand storytelling",
70
+ "core competencies",
71
+ "cross-functional alignment",
72
+ "cross-pollination of ideas",
73
+ "data-driven decisions",
74
+ "digital transformation",
75
+ "ducks in a row",
76
+ "eating your own dog food",
77
+ "end-to-end solution",
78
+ "excited to share",
79
+ "future-proof your",
80
+ "headwinds",
81
+ "ideation session",
82
+ "keep all stakeholders aligned",
83
+ "learnings",
84
+ "leverage synergies",
85
+ "move at the speed of business",
86
+ "open the kimono",
87
+ "out-of-the-box thinking",
88
+ "revolutionizing the way",
89
+ "rightsizing",
90
+ "robust ecosystem",
91
+ "robust pipeline",
92
+ "run it up the flagpole",
93
+ "scale our operations",
94
+ "seamless integration",
95
+ "stakeholder buy-in",
96
+ "strategic alignment",
97
+ "streamline processes",
98
+ "tailwinds",
99
+ "talent pipeline",
100
+ "that's where we come in",
101
+ "thought leader",
102
+ "thought leadership",
103
+ "tiger team"
66
104
  ]
@@ -0,0 +1,53 @@
1
+ [
2
+ "a culinary journey",
3
+ "a feast for the senses",
4
+ "a must for any itinerary",
5
+ "a true gem",
6
+ "after using this for a few weeks",
7
+ "best-kept secret",
8
+ "bucket list destination",
9
+ "bursting with flavor",
10
+ "bustling streets",
11
+ "charming village",
12
+ "city of contrasts",
13
+ "come hungry",
14
+ "completely transformed how i",
15
+ "crystal-clear waters",
16
+ "escape the crowds",
17
+ "even picky eaters will love it",
18
+ "exceeded my expectations",
19
+ "experience the magic of",
20
+ "final verdict",
21
+ "foodie paradise",
22
+ "great for weeknight dinners",
23
+ "hands-down the best",
24
+ "hidden gem",
25
+ "leave room for dessert",
26
+ "local cuisine",
27
+ "local gems",
28
+ "look no further",
29
+ "lose yourself in",
30
+ "my grandmother used to make this",
31
+ "nestled among",
32
+ "no product is perfect, but",
33
+ "off the beaten path",
34
+ "off the tourist trail",
35
+ "soak up the atmosphere",
36
+ "step back in time",
37
+ "stunning scenery",
38
+ "tantalizing flavors",
39
+ "the perfect blend of",
40
+ "the results speak for themselves",
41
+ "this dish brings back so many memories",
42
+ "this place has it all",
43
+ "this recipe is perfect for",
44
+ "trust me, you won't be disappointed",
45
+ "undiscovered paradise",
46
+ "unparalleled views",
47
+ "vibrant culture",
48
+ "what makes this recipe special is",
49
+ "whether you're a food lover or",
50
+ "world-class dining",
51
+ "you won't regret it",
52
+ "you'll love this"
53
+ ]
@@ -0,0 +1,62 @@
1
+ [
2
+ "a transformative experience",
3
+ "abundance mindset",
4
+ "align with your values",
5
+ "be authentically you",
6
+ "believe in yourself",
7
+ "break free from your limitations",
8
+ "choose yourself",
9
+ "create the life you want",
10
+ "deserve success",
11
+ "do the inner work",
12
+ "embrace change",
13
+ "embrace who you are",
14
+ "embrace your potential",
15
+ "find your purpose",
16
+ "growth mindset",
17
+ "heal your relationship with",
18
+ "honor your feelings",
19
+ "inner work",
20
+ "just stay positive",
21
+ "life is a journey, not a destination",
22
+ "live with intention",
23
+ "live your best life",
24
+ "live your purpose",
25
+ "manifest your dreams",
26
+ "not a destination but a journey",
27
+ "own your story",
28
+ "pour into yourself",
29
+ "practice gratitude",
30
+ "push past your limits",
31
+ "put in the work",
32
+ "radical self-love",
33
+ "reclaim your power",
34
+ "self-care is not selfish",
35
+ "set your intentions",
36
+ "show up for yourself",
37
+ "small changes can make a big difference",
38
+ "start where you are",
39
+ "stepping into your power",
40
+ "take control of your health",
41
+ "tap into your potential",
42
+ "the best version of yourself",
43
+ "the power within",
44
+ "this journey of self-discovery",
45
+ "thrive, not just survive",
46
+ "transformative power of",
47
+ "trust the process",
48
+ "unleash your potential",
49
+ "unlock your potential",
50
+ "where you are meant to be",
51
+ "you are capable of more than you think",
52
+ "you are enough",
53
+ "you are exactly where you need to be",
54
+ "you can't pour from an empty cup",
55
+ "you'll get through this",
56
+ "your authentic self",
57
+ "your best self",
58
+ "your best version",
59
+ "your higher self",
60
+ "your personal journey",
61
+ "your wellness journey"
62
+ ]
@@ -0,0 +1,34 @@
1
+ [
2
+ "as the world continues to evolve",
3
+ "as we look to the future",
4
+ "as we navigate",
5
+ "as we've explored",
6
+ "has a profound impact",
7
+ "in an era characterized by",
8
+ "in an ever-evolving landscape",
9
+ "in the digital age",
10
+ "in this comprehensive guide",
11
+ "in today's competitive landscape",
12
+ "in today's digital age",
13
+ "in today's fast-paced",
14
+ "in today's rapidly evolving landscape",
15
+ "it is no secret that",
16
+ "it is vital for",
17
+ "it's no secret that",
18
+ "join us as we",
19
+ "join us as we explore",
20
+ "let's deep dive",
21
+ "let's delve into",
22
+ "more than ever before",
23
+ "navigating the complexities of",
24
+ "navigating the world of",
25
+ "take a deep dive into",
26
+ "the ultimate guide to",
27
+ "this guide will cover",
28
+ "this has never been more important",
29
+ "we hope this article",
30
+ "we hope this guide",
31
+ "welcome to this article",
32
+ "welcome to this guide",
33
+ "whether you're a beginner or"
34
+ ]
@@ -1,10 +1 @@
1
- [
2
- "Thankfully",
3
- "bona fides",
4
- "deceptively",
5
- "decimate",
6
- "effete",
7
- "fulsome",
8
- "hopefully",
9
- "impassionate"
10
- ]
1
+ ["bona fides", "deceptively", "decimate", "effete", "fulsome", "impassionate"]
@@ -0,0 +1,37 @@
1
+ [
2
+ "a growing body of evidence suggests",
3
+ "a growing body of research suggests",
4
+ "according to health professionals",
5
+ "according to medical experts",
6
+ "analysts suggest",
7
+ "countless studies have shown",
8
+ "doctors say",
9
+ "economists say",
10
+ "health experts say",
11
+ "industry experts say",
12
+ "industry insiders say",
13
+ "industry leaders agree",
14
+ "it has been demonstrated that",
15
+ "it is commonly understood that",
16
+ "it is generally understood that",
17
+ "it is universally accepted that",
18
+ "leading experts say",
19
+ "many experts agree",
20
+ "many experts believe",
21
+ "most experts agree",
22
+ "multiple studies have found",
23
+ "new research suggests",
24
+ "new studies show",
25
+ "numerous studies have shown",
26
+ "numerous studies suggest",
27
+ "nutritionists say",
28
+ "observers have suggested",
29
+ "psychologists say",
30
+ "recent research has found",
31
+ "science confirms",
32
+ "science tells us",
33
+ "some researchers argue",
34
+ "some scientists believe",
35
+ "some would argue",
36
+ "the scientific community agrees"
37
+ ]
@@ -0,0 +1,17 @@
1
+ import genreCliches from "./data/genre-cliches.json" with { type: "json" };
2
+ import { findUnquotedPhraseMatches } from "../../shared/matchers/phrases.js";
3
+ import { oneToOneRule } from "../private/textlint-rule-builders.js";
4
+ const rule = oneToOneRule({
5
+ detect: (unit) => findUnquotedPhraseMatches(unit.text, genreCliches).map((match) => ({
6
+ evidence: match.text,
7
+ label: match.text,
8
+ range: { start: match.start, end: match.end }
9
+ })),
10
+ family: "phrases",
11
+ formatMessage: (report) => `Genre cliche found: "${report.evidence}". Replace it with a concrete, specific detail.`,
12
+ ignoredAncestorTypes: ["Link", "LinkReference"],
13
+ ruleId: "phrases:genre-cliches",
14
+ unitKind: "str"
15
+ });
16
+ export default rule;
17
+ //# sourceMappingURL=genre-cliches.js.map
@@ -1,10 +1,8 @@
1
1
  import { findPhraseMatches } from "../../shared/matchers/phrases.js";
2
2
  import { oneToOneRule } from "../private/textlint-rule-builders.js";
3
- const HUMBLE_BRAGGER_PHRASES = [
4
- "in my experience",
5
- "as someone who has",
6
- "having worked with"
7
- ];
3
+ // "in my experience" was removed: it is a normal epistemic qualifier (common in Q&A
4
+ // and essays), not credentialing - it was a mis-fire, not a humble-brag.
5
+ const HUMBLE_BRAGGER_PHRASES = ["as someone who has", "having worked with"];
8
6
  const rule = oneToOneRule({
9
7
  detect: (unit) => findPhraseMatches(unit.text, HUMBLE_BRAGGER_PHRASES).map((match) => ({
10
8
  evidence: match.text,
@@ -0,0 +1,17 @@
1
+ import selfHelpCliches from "./data/self-help-cliches.json" with { type: "json" };
2
+ import { findUnquotedPhraseMatches } from "../../shared/matchers/phrases.js";
3
+ import { oneToOneRule } from "../private/textlint-rule-builders.js";
4
+ const rule = oneToOneRule({
5
+ detect: (unit) => findUnquotedPhraseMatches(unit.text, selfHelpCliches).map((match) => ({
6
+ evidence: match.text,
7
+ label: match.text,
8
+ range: { start: match.start, end: match.end }
9
+ })),
10
+ family: "phrases",
11
+ formatMessage: (report) => `Self-help cliche found: "${report.evidence}". Replace it with a specific claim.`,
12
+ ignoredAncestorTypes: ["Link", "LinkReference"],
13
+ ruleId: "phrases:self-help-cliches",
14
+ unitKind: "str"
15
+ });
16
+ export default rule;
17
+ //# sourceMappingURL=self-help-cliches.js.map
@@ -0,0 +1,17 @@
1
+ import seoFiller from "./data/seo-filler.json" with { type: "json" };
2
+ import { findUnquotedPhraseMatches } from "../../shared/matchers/phrases.js";
3
+ import { oneToOneRule } from "../private/textlint-rule-builders.js";
4
+ const rule = oneToOneRule({
5
+ detect: (unit) => findUnquotedPhraseMatches(unit.text, seoFiller).map((match) => ({
6
+ evidence: match.text,
7
+ label: match.text,
8
+ range: { start: match.start, end: match.end }
9
+ })),
10
+ family: "phrases",
11
+ formatMessage: (report) => `SEO filler found: "${report.evidence}". Cut the filler and lead with the specific point.`,
12
+ ignoredAncestorTypes: ["Link", "LinkReference"],
13
+ ruleId: "phrases:seo-filler",
14
+ unitKind: "str"
15
+ });
16
+ export default rule;
17
+ //# sourceMappingURL=seo-filler.js.map
@@ -0,0 +1,21 @@
1
+ import weaselAttribution from "./data/weasel-attribution.json" with { type: "json" };
2
+ import { findUnquotedPhraseMatches } from "../../shared/matchers/phrases.js";
3
+ import { oneToOneRule } from "../private/textlint-rule-builders.js";
4
+ // Vague attribution: "studies show", "experts agree", "a growing body of evidence
5
+ // suggests" with no named source. Slop regardless of author; if the source is real,
6
+ // name it. The phrase list deliberately excludes attributions that are commonly used
7
+ // with a real citation alongside (those were screened out for false positives).
8
+ const rule = oneToOneRule({
9
+ detect: (unit) => findUnquotedPhraseMatches(unit.text, weaselAttribution).map((match) => ({
10
+ evidence: match.text,
11
+ label: match.text,
12
+ range: { start: match.start, end: match.end }
13
+ })),
14
+ family: "phrases",
15
+ formatMessage: (report) => `Vague attribution found: "${report.evidence}". Name the specific source or cut the claim.`,
16
+ ignoredAncestorTypes: ["Link", "LinkReference"],
17
+ ruleId: "phrases:weasel-attribution",
18
+ unitKind: "str"
19
+ });
20
+ export default rule;
21
+ //# sourceMappingURL=weasel-attribution.js.map
@@ -8,10 +8,15 @@ import { makeMeaningReframe, meaningReframe } from "./meaning-reframe.js";
8
8
  import { hasConcreteCorrectionEvidence } from "../../../../shared/matchers/concrete-evidence.js";
9
9
  import { splitSentences } from "../../../../shared/text/sentences.js";
10
10
  import { wordTokens } from "../../../../shared/text/tokens.js";
11
+ // "not only X but Y" is an additive correlative (it affirms both X and Y), not the
12
+ // replacement reframe this rule targets ("it is not X, it is Y"). It was a dominant
13
+ // false positive ("not only makes rounds harder but can trigger a down round"), so the
14
+ // "only" follower is excluded here.
11
15
  const INLINE_NON_CONTRAST_NEGATION_FOLLOWERS = new Set([
12
16
  "all",
13
17
  "any",
14
18
  "every",
19
+ "only",
15
20
  "too"
16
21
  ]);
17
22
  function inlineNegationContrast(sentence) {
@@ -82,7 +82,13 @@ function matchSoftening(sentence) {
82
82
  const qualifier = containsAny(normalized, QUALIFIER_PATTERNS);
83
83
  const variability = containsAny(normalized, VARIABILITY_PATTERNS);
84
84
  const reporting = containsAny(normalized, REPORTING_PATTERNS);
85
- const quantifier = findQuantifierPair(normalized);
85
+ // "in some cases"/"in some people" already count as a variability hedge; strip the
86
+ // matched phrase so its embedded "some cases"/"some people" is not double-counted as a
87
+ // separate quantifier signal (that self-overlap was the dominant false positive).
88
+ const quantifierText = variability === undefined
89
+ ? normalized
90
+ : normalized.split(variability).join(" ");
91
+ const quantifier = findQuantifierPair(quantifierText);
86
92
  const signalCount = Number(modal !== undefined) +
87
93
  Number(qualifier !== undefined) +
88
94
  Number(variability !== undefined) +
@@ -9,6 +9,11 @@ const PREFIXES = [
9
9
  "after all, ",
10
10
  "in the end, "
11
11
  ];
12
+ // Real universalizing claims: a broad human-group subject + a desire/certainty verb
13
+ // ("most people want", "everyone knows", "for many parents ... hope"). The previous
14
+ // vague-quantifier + abstract-noun arm ("many reasons", "some things", "several
15
+ // challenges") was removed: those are ordinary counts, not universalizing claims, and
16
+ // were the dominant false positive.
12
17
  const SUBJECT_PATTERNS = [
13
18
  ["everyone"],
14
19
  ["everybody"],
@@ -82,54 +87,6 @@ const GROUP_BEHAVIOR_GERUNDS = [
82
87
  "wondering"
83
88
  ];
84
89
  const BROAD_GROUP_LEADS = ["many", "most"];
85
- const VAGUE_QUANTIFIERS = ["a lot of", "many", "several", "some", "various"];
86
- const ABSTRACT_NOUNS = [
87
- "areas",
88
- "aspects",
89
- "benefits",
90
- "challenges",
91
- "changes",
92
- "concerns",
93
- "factors",
94
- "impacts",
95
- "issues",
96
- "outcomes",
97
- "reasons",
98
- "results",
99
- "stakeholders",
100
- "themes",
101
- "things"
102
- ];
103
- const ABSTRACT_MODIFIERS = [
104
- "important",
105
- "key",
106
- "major",
107
- "meaningful",
108
- "notable",
109
- "significant",
110
- "various"
111
- ];
112
- const EVIDENCE_MARKERS = [
113
- "for example",
114
- "including",
115
- "such as",
116
- "because",
117
- "from ",
118
- "after ",
119
- "before ",
120
- "when "
121
- ];
122
- function hasDigit(text) {
123
- for (const character of text) {
124
- if (character >= "0" && character <= "9") {
125
- return true;
126
- }
127
- }
128
- return false;
129
- }
130
- function hasEvidenceMarker(text) {
131
- return (hasDigit(text) || EVIDENCE_MARKERS.some((marker) => text.includes(marker)));
132
- }
133
90
  function matchGroupBehavior(words) {
134
91
  const [first, subject, third, gerund] = words;
135
92
  if (first !== undefined &&
@@ -161,40 +118,6 @@ function matchUniversalizing(sentence) {
161
118
  return `${subject.join(" ")} ${verb}`;
162
119
  }
163
120
  }
164
- const vagueQuantifier = matchVagueQuantifierAbstractNoun(words, cleaned);
165
- if (vagueQuantifier !== undefined) {
166
- return vagueQuantifier;
167
- }
168
- return undefined;
169
- }
170
- function matchVagueQuantifierAbstractNoun(words, text) {
171
- if (hasEvidenceMarker(text)) {
172
- return undefined;
173
- }
174
- for (let index = 0; index < words.length; index += 1) {
175
- const single = words[index];
176
- const twoWord = `${words[index] ?? ""} ${words[index + 1] ?? ""}`;
177
- const quantifier = VAGUE_QUANTIFIERS.includes(twoWord)
178
- ? twoWord
179
- : VAGUE_QUANTIFIERS.find((candidate) => candidate === single);
180
- if (quantifier === undefined) {
181
- continue;
182
- }
183
- const nounStart = quantifier.includes(" ") ? index + 2 : index + 1;
184
- const first = words[nounStart];
185
- const second = words[nounStart + 1];
186
- const noun = first !== undefined && ABSTRACT_NOUNS.includes(first)
187
- ? first
188
- : second !== undefined &&
189
- first !== undefined &&
190
- ABSTRACT_MODIFIERS.includes(first) &&
191
- ABSTRACT_NOUNS.includes(second)
192
- ? second
193
- : undefined;
194
- if (noun !== undefined) {
195
- return `${quantifier} ${noun}`;
196
- }
197
- }
198
121
  return undefined;
199
122
  }
200
123
  const rule = oneToOneRule({
@@ -62,9 +62,17 @@ const FILLER_OPENERS = [
62
62
  "let's be real",
63
63
  "here's the deal",
64
64
  "here's what's really going on",
65
- "when all is said and done",
66
65
  "the fact of the matter is",
67
- "the bottom line is"
66
+ "the bottom line is",
67
+ "here's the truth",
68
+ "here's where it gets interesting",
69
+ "in the grand scheme of things",
70
+ "it is worth considering that",
71
+ "it's fair to say",
72
+ "let's be honest",
73
+ "there are no easy answers",
74
+ "this is the important part",
75
+ "we are at an inflection point"
68
76
  ];
69
77
  function matchEnumerationPreface(words) {
70
78
  if (words.some((word) => VAGUE_INTROS.includes(word)) &&
@@ -88,11 +96,16 @@ function matchStarterFrame(words) {
88
96
  function matchBoilerplateFraming(sentence) {
89
97
  const stripped = cleanSentence(sentence, PREFIXES);
90
98
  const words = tokens(stripped);
99
+ const lowered = stripped
100
+ .toLocaleLowerCase("en")
101
+ .replaceAll(String.fromCharCode(0x2019), "'");
91
102
  const matches = [];
92
103
  if (tokensContainInOrder(words, [["following"], PREVIEW_OBJECTS, PREVIEW_VERBS])) {
93
104
  matches.push("following + explore");
94
105
  }
95
- if (stripped.includes("when it comes to")) {
106
+ // Opener only: "When it comes to X, ..." is a weak filler lead-in. Mid-sentence
107
+ // ("... tells us little when it comes to Y") is ordinary usage, not boilerplate.
108
+ if (lowered.startsWith("when it comes to")) {
96
109
  matches.push("when it comes to");
97
110
  }
98
111
  if (tokensContainInOrder(words, [
@@ -111,9 +124,6 @@ function matchBoilerplateFraming(sentence) {
111
124
  if (starter !== undefined) {
112
125
  matches.push(starter);
113
126
  }
114
- const lowered = stripped
115
- .toLocaleLowerCase("en")
116
- .replaceAll(String.fromCharCode(0x2019), "'");
117
127
  const filler = FILLER_OPENERS.find((opener) => lowered.startsWith(opener));
118
128
  if (filler !== undefined) {
119
129
  matches.push(filler);
@@ -3,7 +3,9 @@ import { cleanSentence, containsAny, tokens } from "../../../shared/matchers/pro
3
3
  import { oneToOneRule } from "../../private/textlint-rule-builders.js";
4
4
  import { matchDiscourseEvaluationFrame } from "./private/discourse-evaluation.js";
5
5
  const PREFIXES = ["however, ", "but ", "and ", "so "];
6
- const TRANSITION_PATTERNS = ["that being said", "as such"];
6
+ // "as such" was removed: it is a normal anaphoric connective ("a registered adviser; as
7
+ // such, it must...") and was the dominant false positive, not a signposting frame.
8
+ const TRANSITION_PATTERNS = ["that being said"];
7
9
  const CONSULTATION_PATTERNS = [
8
10
  "it's always best to consult",
9
11
  "it is always best to consult",
@@ -6,7 +6,6 @@
6
6
  "let's break this down",
7
7
  "break this down step by step",
8
8
  "break it down step by step",
9
- "certainly",
10
9
  "feel free to ask",
11
10
  "great question",
12
11
  "here are a few options",
@@ -156,6 +156,33 @@ const COMMON_OPENERS = new Set([
156
156
  "must",
157
157
  "shall"
158
158
  ]);
159
+ // Enumerators and document-structure openers: list markers ("2.", "b)") and
160
+ // reference words that head TOC entries, headings, and procedural steps. Three
161
+ // sentences opening this way is structure, not repetitive prose, and was a
162
+ // dominant source of false positives in the human-corpus audit.
163
+ const STRUCTURAL_OPENERS = new Set([
164
+ "chapter",
165
+ "section",
166
+ "part",
167
+ "appendix",
168
+ "figure",
169
+ "table",
170
+ "page",
171
+ "step",
172
+ "verse",
173
+ "item"
174
+ ]);
175
+ function isStructuralOpener(opener) {
176
+ if (STRUCTURAL_OPENERS.has(opener) || opener.length === 1) {
177
+ return true;
178
+ }
179
+ for (const character of opener) {
180
+ if (character < "0" || character > "9") {
181
+ return false;
182
+ }
183
+ }
184
+ return true;
185
+ }
159
186
  function isWordCharacter(character) {
160
187
  return ((character >= "a" && character <= "z") ||
161
188
  (character >= "0" && character <= "9") ||
@@ -213,6 +240,7 @@ function findTripleRepeats(text) {
213
240
  if (firstOpener === undefined ||
214
241
  firstOpener === "" ||
215
242
  COMMON_OPENERS.has(firstOpener) ||
243
+ isStructuralOpener(firstOpener) ||
216
244
  firstOpener !== secondOpener ||
217
245
  secondOpener !== thirdOpener) {
218
246
  continue;
@@ -0,0 +1,45 @@
1
+ import { defineTextlintRule } from "../../adapters/textlint/rule.js";
2
+ import { documentUnit } from "../../adapters/textlint/units.js";
3
+ import { ERROR_SEVERITY } from "../../reporting/density.js";
4
+ import { wordTokens } from "../../shared/text/tokens.js";
5
+ // The rule only detects occurrences of "actually" - one detection per use, no counting,
6
+ // rate, or severity. The density-rate report policy below lets the reporter judge the
7
+ // occurrences against the document word count and decide none / warning / error.
8
+ // "actually" is a normal contrastive adverb at low frequency; AI overuse shows up as
9
+ // density, which is the reporter's concern, not the detector's. See src/reporting/density.ts.
10
+ const TARGET = "actually";
11
+ const RULE_ID = "words:actually-overuse";
12
+ const rule = defineTextlintRule({
13
+ detector: {
14
+ detect: ({ units }) => units.flatMap((unit) => wordTokens(unit.text)
15
+ .filter((token) => token.normalized === TARGET)
16
+ .map((token) => ({
17
+ evidence: TARGET,
18
+ label: TARGET,
19
+ range: { end: token.end, start: token.start },
20
+ ruleId: RULE_ID,
21
+ unitId: unit.id
22
+ }))),
23
+ family: "words",
24
+ id: RULE_ID
25
+ },
26
+ formatMessage: (report) => {
27
+ const count = report.metric?.["count"] ?? report.detections.length;
28
+ const perUnit = report.metric?.["perUnit"] ?? 0;
29
+ const threshold = report.severity === ERROR_SEVERITY
30
+ ? "above the 2-per-1,000-word error threshold"
31
+ : "above the 1-per-1,000-word warning threshold";
32
+ return `"actually" used ${count} times (${perUnit} per 1,000 words), ${threshold}. Cut the filler uses; keep at most about one per 1,000 words.`;
33
+ },
34
+ reportPolicy: {
35
+ errorPerUnit: 2,
36
+ kind: "density-rate",
37
+ minimumOccurrences: 2,
38
+ scope: "document",
39
+ warningPerUnit: 1,
40
+ wordsPerUnit: 1000
41
+ },
42
+ units: (document) => [documentUnit(document)]
43
+ });
44
+ export default rule;
45
+ //# sourceMappingURL=actually-overuse.js.map
@@ -1,5 +1,4 @@
1
1
  [
2
- "actually",
3
2
  "delve",
4
3
  "elevate",
5
4
  "leverage",
@@ -76,7 +76,6 @@ const LLM_DENSITY_WORDS = new Set([
76
76
  "unlock",
77
77
  "unlocks",
78
78
  "unlocking",
79
- "value",
80
79
  "workflow",
81
80
  "workflows"
82
81
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slopless",
3
- "version": "0.2.19",
3
+ "version": "0.2.20",
4
4
  "description": "Deterministic textlint rules and CLI for catching prose slop in English Markdown.",
5
5
  "keywords": [
6
6
  "textlint",