sloplint 0.7.0 → 0.8.1

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.
@@ -6,12 +6,14 @@ module Sloplint
6
6
  # count_group: a Regexp scanned over the matched text to tally items; when set,
7
7
  # the Note carries `count` and the message may interpolate %{count}.
8
8
  # skip: Regexps that, if they match the matched text, drop the note (exclusions).
9
- # default_on: false keeps a noisy rule out of the default run (still selectable).
9
+ # severity: what the construct costs the prose -- "error", "warning", "info".
10
+ # confidence: how likely a match is a false positive -- "high", "medium", "low".
11
+ # A "low" rule stays out of the default run; --strict or its own id turns it on.
10
12
  Rule = Data.define(
11
- :id, :category, :severity, :pattern, :message, :suggestion,
12
- :examples_bad, :examples_ok, :count_group, :skip, :rationale, :default_on
13
+ :id, :category, :severity, :confidence, :pattern, :message, :suggestion,
14
+ :examples_bad, :examples_ok, :count_group, :skip, :rationale
13
15
  ) do
14
- def initialize(count_group: nil, skip: [], rationale: nil, default_on: true, **rest)
16
+ def initialize(count_group: nil, skip: [], rationale: nil, **rest)
15
17
  super
16
18
  end
17
19
  end
@@ -83,11 +85,11 @@ module Sloplint
83
85
  "|time|world|numbers?|roots?|money|estate|user"
84
86
 
85
87
  RULES = [
86
- # ── rhetorical-tic ────────────────────────────────────────────────────
87
88
  Rule.new(
88
89
  id: "no-x-no-y",
89
- category: "rhetorical-tic",
90
+ category: "cadence",
90
91
  severity: "warning",
92
+ confidence: "high",
91
93
  # Comma chains only: "no fluff, no filler, no jargon". The comma is the
92
94
  # evidence -- it makes the parallelism deliberate. Fragment chains split
93
95
  # by sentence punctuation are a separate, quieter rule (no-x-no-y-frag),
@@ -104,10 +106,11 @@ module Sloplint
104
106
  ),
105
107
  Rule.new(
106
108
  id: "no-x-no-y-frag",
107
- category: "rhetorical-tic",
108
- severity: "info",
109
+ category: "cadence",
110
+ severity: "warning",
111
+ confidence: "medium",
109
112
  # The same cadence built from sentence fragments: "No fluff. No filler."
110
- # Ships at info, not warning, because the shape is genuinely ambiguous --
113
+ # Medium confidence, because the shape is genuinely ambiguous --
111
114
  # two short "no" sentences in a row is also just writing ("No one moved.
112
115
  # No one spoke."). The agent reading the flag decides; see rationale.
113
116
  #
@@ -147,8 +150,9 @@ module Sloplint
147
150
  ),
148
151
  Rule.new(
149
152
  id: "thats-the-whole",
150
- category: "rhetorical-tic",
153
+ category: "closer",
151
154
  severity: "warning",
155
+ confidence: "high",
152
156
  # The nouns are WHOLE_CLOSERS. "value" and "fix" are the closers agents
153
157
  # write in technical prose ("That's the whole fix"), where the
154
158
  # contraction keeps them out of is-the-whole-x, which sees only "is".
@@ -198,8 +202,9 @@ module Sloplint
198
202
  ),
199
203
  Rule.new(
200
204
  id: "is-the-whole-x",
201
- category: "rhetorical-tic",
202
- severity: "info",
205
+ category: "closer",
206
+ severity: "warning",
207
+ confidence: "medium",
203
208
  # The generic form of thats-the-whole: a subject, then "is the whole /
204
209
  # real / actual / entire N" with N from a closed abstract list. "That
205
210
  # periodicity is the whole tell.", "Consistency is the real test." The
@@ -216,7 +221,7 @@ module Sloplint
216
221
  # "cost" because "the real deal", "the real thing" and "the whole cost"
217
222
  # are idioms or quantities. The noun may not run on into a compound
218
223
  # ("problem-solver"). Gaps may cross a hard-wrapped newline but never
219
- # a paragraph break. Ships at info because "the real question" and
224
+ # a paragraph break. Medium confidence: "the real question" and
220
225
  # "the whole point" are also how people talk.
221
226
  pattern: /(?<![\w'’-])
222
227
  (?!(?:that|this)#{WRAP_GAP}+(?:is)#{WRAP_GAP}+(?:the)#{WRAP_GAP}+(?:whole)#{WRAP_GAP}+(?:#{WHOLE_CLOSERS})(?![\w'’-]))
@@ -265,8 +270,9 @@ module Sloplint
265
270
  ),
266
271
  Rule.new(
267
272
  id: "bare-equative",
268
- category: "rhetorical-tic",
269
- severity: "info",
273
+ category: "closer",
274
+ severity: "warning",
275
+ confidence: "medium",
270
276
  # A sentence that opens on an abstract head noun and equates it with
271
277
  # something: "The tell here is the periodicity.", "The problem is not
272
278
  # the tool.", "The lesson is the handoff." The head noun list is closed
@@ -280,8 +286,8 @@ module Sloplint
280
286
  # answer is a mess"), a pointing complement ("the same", "the one",
281
287
  # "the first"), and a proper noun ("the Slack thread") are all out.
282
288
  # Gaps may cross a hard-wrapped newline but never a paragraph break.
283
- # Ships at info: "The problem is the cost" is how people write too, at
284
- # about four per million words on Hacker News; it is the density that
289
+ # Medium confidence: "The problem is the cost" is how people write too,
290
+ # at about four per million words on Hacker News; it is the density that
285
291
  # tells.
286
292
  pattern: /(?:^|(?<=[.!?])[ \t]{1,2})(?:[-*+•][ \t]+|\d+[.)][ \t]+)?\KThe(?:[ \t]|\r?\n(?!\s*\n))+
287
293
  (?:tell|point|question|problem|issue|lesson|difference|trick|move|risk|goal|reason|pattern|insight|takeaway|shift|bet|catch|gap|bottleneck|failure|mistake|secret|magic|challenge|tension|trap)(?:[ \t]|\r?\n(?!\s*\n))+
@@ -322,9 +328,9 @@ module Sloplint
322
328
  ),
323
329
  Rule.new(
324
330
  id: "epistrophe",
325
- category: "rhetorical-tic",
331
+ category: "cadence",
326
332
  severity: "info",
327
- default_on: false,
333
+ confidence: "low",
328
334
  # Two clauses that end on the same two-word phrase, the second closing
329
335
  # the sentence: "built for one desk, and almost no job is done at one
330
336
  # desk." Two backreferences catch the repeat, one per word, so the
@@ -374,9 +380,9 @@ module Sloplint
374
380
  ),
375
381
  Rule.new(
376
382
  id: "phrase-echo",
377
- category: "rhetorical-tic",
383
+ category: "cadence",
378
384
  severity: "info",
379
- default_on: false,
385
+ confidence: "low",
380
386
  # The same three words again a few paragraphs on. Three consecutive
381
387
  # words, each four characters or more and lowercase-led, one of them
382
388
  # six letters with nothing but letters, and the same three again
@@ -458,8 +464,9 @@ module Sloplint
458
464
  ),
459
465
  Rule.new(
460
466
  id: "did-not-x-did-not-y",
461
- category: "rhetorical-tic",
467
+ category: "cadence",
462
468
  severity: "warning",
469
+ confidence: "high",
463
470
  pattern: /\b(?:did\s+not|didn't)\s+[\w'-]+(?:,?\s+(?:and\s+)?(?:did\s+not|didn't)\s+[\w'-]+)+/i,
464
471
  message: '"did not X, did not Y" chain (%{count} items) reads as AI cadence.',
465
472
  suggestion: "Cut the chain or make it one plain sentence.",
@@ -470,8 +477,9 @@ module Sloplint
470
477
  ),
471
478
  Rule.new(
472
479
  id: "from-x-to-y-chain",
473
- category: "rhetorical-tic",
480
+ category: "cadence",
474
481
  severity: "warning",
482
+ confidence: "high",
475
483
  # Two or more "from X to Y" spans in a row, comma-separated: "from
476
484
  # private notes to shared files, from personal memory to team context".
477
485
  # Each span is "from", one to three words, "to", one to three words; the
@@ -546,8 +554,9 @@ module Sloplint
546
554
  ),
547
555
  Rule.new(
548
556
  id: "one-x-one-y",
549
- category: "rhetorical-tic",
557
+ category: "cadence",
550
558
  severity: "warning",
559
+ confidence: "high",
551
560
  # Three or more "one X" items in a comma chain that stands on its own:
552
561
  # "One owner, one repository, one weekly prune." or, after a colon,
553
562
  # "the setup is narrow: one reviewer, one queue, one deadline". The
@@ -617,8 +626,9 @@ module Sloplint
617
626
  ),
618
627
  Rule.new(
619
628
  id: "and-what-it-should",
620
- category: "rhetorical-tic",
629
+ category: "closer",
621
630
  severity: "warning",
631
+ confidence: "high",
622
632
  # The elliptical tail: "List what the assistant knows about the client,
623
633
  # and what it should." The second "what" clause borrows its verb from the
624
634
  # first and ends on a bare modal or a negated auxiliary, so the sentence
@@ -664,8 +674,9 @@ module Sloplint
664
674
  ),
665
675
  Rule.new(
666
676
  id: "abstract-lives-in",
667
- category: "rhetorical-tic",
668
- severity: "info",
677
+ category: "puffery",
678
+ severity: "warning",
679
+ confidence: "medium",
669
680
  # An abstraction given an address: "the craft that lives between the two
670
681
  # desks", "its context lives in a folder nobody else can open", "the
671
682
  # value sits in the follow-up". The subject list is closed and abstract,
@@ -681,7 +692,7 @@ module Sloplint
681
692
  # lies in the assumption") and "resides in" are ordinary English for
682
693
  # where a fault or an authority is, at any register.
683
694
  #
684
- # Ships at info. The same shape states where information literally is
695
+ # Medium confidence. The same shape states where information literally is
685
696
  # ("the knowledge lives in our heads", "the instructions live in the
686
697
  # README"), and a sample of pre-2022 Hacker News biased toward the
687
698
  # construction turns up a few of those per million words, all human.
@@ -724,8 +735,9 @@ module Sloplint
724
735
  ),
725
736
  Rule.new(
726
737
  id: "the-x-is-the-x",
727
- category: "rhetorical-tic",
728
- severity: "warning",
738
+ category: "cadence",
739
+ severity: "error",
740
+ confidence: "high",
729
741
  # The repeated-head equative: "the reason it holds up is the reason the
730
742
  # other half happens", "the problem with A is the problem with B". The
731
743
  # same abstract head noun sits on both sides of the copula, caught by a
@@ -787,8 +799,9 @@ module Sloplint
787
799
  ),
788
800
  Rule.new(
789
801
  id: "same-determiner-chain",
790
- category: "rhetorical-tic",
802
+ category: "cadence",
791
803
  severity: "info",
804
+ confidence: "medium",
792
805
  # The quiet cousin of one-x-one-y and no-x-no-y: three or more items in
793
806
  # a comma chain that all open on the same determiner or quantifier,
794
807
  # caught with a backreference: "every faculty, every thought, every
@@ -849,8 +862,9 @@ module Sloplint
849
862
  ),
850
863
  Rule.new(
851
864
  id: "dont-verb-it",
852
- category: "rhetorical-tic",
865
+ category: "false-correction",
853
866
  severity: "warning",
867
+ confidence: "high",
854
868
  pattern: /\b(?:don't|do\s+not)\s+(\w+)\s+it\b[^.!?]*[.!?]\s*\1\s+it\b/i,
855
869
  message: '"Don\'t X it. X it Y." reframing is a stock LLM move.',
856
870
  suggestion: "Drop the fake reframe and state the claim once.",
@@ -860,8 +874,9 @@ module Sloplint
860
874
  ),
861
875
  Rule.new(
862
876
  id: "sit-with-that",
863
- category: "rhetorical-tic",
877
+ category: "reader-address",
864
878
  severity: "warning",
879
+ confidence: "high",
865
880
  # Two branches. The deictic object is the tic anywhere in a sentence, so
866
881
  # "sit with that/this/it" needs no anchor. Anything else needs the
867
882
  # sentence-initial imperative, which is where the tic lives and where the
@@ -888,12 +903,13 @@ module Sloplint
888
903
  "The object does not matter: the tic is telling the reader to dwell instead " \
889
904
  "of giving them something to dwell on. The sentence-initial imperative does " \
890
905
  "flag the plain companion sense (\"Sit with the baby while I run out\"), " \
891
- "which is accepted -- hence warning, not error."
906
+ "which is accepted."
892
907
  ),
893
908
  Rule.new(
894
909
  id: "hold-onto-that",
895
- category: "rhetorical-tic",
910
+ category: "reader-address",
896
911
  severity: "warning",
912
+ confidence: "high",
897
913
  # Sentence-initial imperative only. Past tense and subordinate clauses
898
914
  # ("she held on to that letter", "if you hold onto that phrase") are a
899
915
  # different construction, not the tell. Everything AFTER that/this is
@@ -919,8 +935,9 @@ module Sloplint
919
935
  ),
920
936
  Rule.new(
921
937
  id: "cleanly",
922
- category: "rhetorical-tic",
923
- severity: "info",
938
+ category: "self-rating",
939
+ severity: "warning",
940
+ confidence: "medium",
924
941
  # The bare adverb was the whole rule, and the engineering idioms were
925
942
  # flagged on purpose. Technical prose says that was the wrong call: a
926
943
  # patch that applies cleanly, a build that compiles cleanly and a gear
@@ -936,7 +953,7 @@ module Sloplint
936
953
  # parts. The clause-final form ("the objection breaks down cleanly, and
937
954
  # neither half survives") is the same tell and is left out.
938
955
  #
939
- # The frame is not the sense, and this rule ships at info because of it.
956
+ # The frame is not the sense, and the confidence here is medium because of it.
940
957
  # "The argument splits cleanly into two parts" and "the gear retracted
941
958
  # cleanly into the well" are one pattern apart only in their subject,
942
959
  # and a regex cannot see the subject -- the same limit that keeps the
@@ -971,8 +988,9 @@ module Sloplint
971
988
  ),
972
989
  Rule.new(
973
990
  id: "clean-count",
974
- category: "rhetorical-tic",
991
+ category: "self-rating",
975
992
  severity: "warning",
993
+ confidence: "high",
976
994
  # Needs a partition noun. The bare count reaches the laundry: Ulysses
977
995
  # has "four clean strokes", Jane Eyre "two clean tuckers".
978
996
  pattern: /\b(?:two|three|four|five|six|seven|2|3|4|5|6|7)\s+
@@ -998,8 +1016,9 @@ module Sloplint
998
1016
  ),
999
1017
  Rule.new(
1000
1018
  id: "cleanest-x",
1001
- category: "rhetorical-tic",
1002
- severity: "warning",
1019
+ category: "self-rating",
1020
+ severity: "error",
1021
+ confidence: "high",
1003
1022
  # Noun list only. "The cleanest way to install the driver" and "the
1004
1023
  # cleanest cut of meat" are ordinary English, so "way" is admitted only
1005
1024
  # in front of a speech verb, and the concrete-capable nouns (cut, line,
@@ -1027,10 +1046,11 @@ module Sloplint
1027
1046
  ),
1028
1047
  Rule.new(
1029
1048
  id: "clean-x",
1030
- category: "rhetorical-tic",
1031
- severity: "info",
1032
- # The quiet half of cleanest-x, at info because the positive degree is
1033
- # where ordinary usage lives. "A clean separation of concerns" is
1049
+ category: "self-rating",
1050
+ severity: "warning",
1051
+ confidence: "medium",
1052
+ # The quiet half of cleanest-x, at medium confidence because the positive
1053
+ # degree is where ordinary usage lives. "A clean separation of concerns" is
1034
1054
  # standard engineering English, so "separation" stays out of the list;
1035
1055
  # "break" is admitted only in "clean break between", never bare, because
1036
1056
  # "make a clean break with the past" is an idiom and not a tell.
@@ -1052,13 +1072,14 @@ module Sloplint
1052
1072
  "The report gives a clean bill of health."
1053
1073
  ],
1054
1074
  rationale: "'Clean' in front of an idea is evaluation, not description -- it says the " \
1055
- "writer approves, and nothing about the idea. It ships at info because the " \
1056
- "same words carry a plain sense a regex cannot separate from the tic."
1075
+ "writer approves, and nothing about the idea. The same words carry a plain " \
1076
+ "sense a regex cannot separate from the tic."
1057
1077
  ),
1058
1078
  Rule.new(
1059
1079
  id: "you-already-know",
1060
- category: "rhetorical-tic",
1080
+ category: "reader-address",
1061
1081
  severity: "warning",
1082
+ confidence: "high",
1062
1083
  pattern: /\byou\s+already\s+know\b/i,
1063
1084
  message: '"You already know…" is a stock LLM rhetorical setup.',
1064
1085
  suggestion: "Just make the point; don't tell the reader they know it.",
@@ -1068,8 +1089,9 @@ module Sloplint
1068
1089
  ),
1069
1090
  Rule.new(
1070
1091
  id: "is-the-entire",
1071
- category: "rhetorical-tic",
1092
+ category: "closer",
1072
1093
  severity: "warning",
1094
+ confidence: "high",
1073
1095
  pattern: /\bis\s+the\s+entire\s+(?:point|game|thing|business\s+model|deal|story)\b/i,
1074
1096
  message: '"X is the entire point/game/…" is an LLM emphasis tic.',
1075
1097
  suggestion: "State the point plainly without the superlative frame.",
@@ -1079,8 +1101,9 @@ module Sloplint
1079
1101
  ),
1080
1102
  Rule.new(
1081
1103
  id: "the-entire-is",
1082
- category: "rhetorical-tic",
1104
+ category: "closer",
1083
1105
  severity: "warning",
1106
+ confidence: "high",
1084
1107
  pattern: /\bthe\s+entire\s+(?:point|game|thing|business\s+model|deal|story)\s+is\b/i,
1085
1108
  message: '"The entire point/game/… is" is an LLM emphasis tic.',
1086
1109
  suggestion: "State the point plainly without the superlative frame.",
@@ -1090,8 +1113,9 @@ module Sloplint
1090
1113
  ),
1091
1114
  Rule.new(
1092
1115
  id: "is-real-and-not",
1093
- category: "rhetorical-tic",
1094
- severity: "info",
1116
+ category: "false-concession",
1117
+ severity: "warning",
1118
+ confidence: "medium",
1095
1119
  pattern: /\bis\s+real,?\s+(?:and|but|not)\b/i,
1096
1120
  message: '"The X is real, and…" is a stock LLM concession move.',
1097
1121
  suggestion: "Drop the 'is real, and' scaffolding; assert the point directly.",
@@ -1112,8 +1136,9 @@ module Sloplint
1112
1136
  ),
1113
1137
  Rule.new(
1114
1138
  id: "real-x-real-y",
1115
- category: "rhetorical-tic",
1139
+ category: "cadence",
1116
1140
  severity: "info",
1141
+ confidence: "medium",
1117
1142
  # Ships at info, not warning: the only two hits the probe found in
1118
1143
  # 1.28M words were both false positives, and one corpus of one
1119
1144
  # register is thin evidence next to honestly, which sits at warning
@@ -1210,8 +1235,9 @@ module Sloplint
1210
1235
  ),
1211
1236
  Rule.new(
1212
1237
  id: "the-punchline-is",
1213
- category: "rhetorical-tic",
1238
+ category: "self-rating",
1214
1239
  severity: "warning",
1240
+ confidence: "high",
1215
1241
  # Same reveal, three nouns. "honest answer" and "honest version" join
1216
1242
  # "punchline" because they do the identical job: rate the sentence as
1217
1243
  # the candid one before the reader gets it. "short version" was tried
@@ -1236,8 +1262,9 @@ module Sloplint
1236
1262
  ),
1237
1263
  Rule.new(
1238
1264
  id: "worth-naming",
1239
- category: "rhetorical-tic",
1240
- severity: "info",
1265
+ category: "self-rating",
1266
+ severity: "warning",
1267
+ confidence: "medium",
1241
1268
  # Widened to optionally include a trailing "names" so the "naming
1242
1269
  # names" idiom is part of the matched text -- skip: checks the matched
1243
1270
  # text itself, and the tighter /\bworth\s+naming\b/ never captured
@@ -1274,8 +1301,9 @@ module Sloplint
1274
1301
  ),
1275
1302
  Rule.new(
1276
1303
  id: "worth-saying-plainly",
1277
- category: "rhetorical-tic",
1304
+ category: "self-rating",
1278
1305
  severity: "warning",
1306
+ confidence: "high",
1279
1307
  # Two branches, both sentence-initial.
1280
1308
  #
1281
1309
  # First: evaluative adjective + speech verb + manner adverb, all three
@@ -1327,8 +1355,9 @@ module Sloplint
1327
1355
  ),
1328
1356
  Rule.new(
1329
1357
  id: "not-nothing",
1330
- category: "rhetorical-tic",
1358
+ category: "false-concession",
1331
1359
  severity: "warning",
1360
+ confidence: "high",
1332
1361
  # Two branches: spelled-out copula with an optional captured subject, and
1333
1362
  # the contracted "X's not nothing". The optional subject + skip: is the
1334
1363
  # load-bearing idiom -- skip: only sees matched text, so the personal
@@ -1382,8 +1411,9 @@ module Sloplint
1382
1411
 
1383
1412
  Rule.new(
1384
1413
  id: "exact-exactly",
1385
- category: "rhetorical-tic",
1414
+ category: "self-rating",
1386
1415
  severity: "info",
1416
+ confidence: "medium",
1387
1417
  # Rewritten after 2.2M words of technical prose left ~111 of 125 hits
1388
1418
  # false. The shape was the problem, not the entries: the pattern matched
1389
1419
  # "exact" everywhere and subtracted an allow-list, and an open pattern
@@ -1489,8 +1519,9 @@ module Sloplint
1489
1519
  ),
1490
1520
  Rule.new(
1491
1521
  id: "load-bearing",
1492
- category: "rhetorical-tic",
1522
+ category: "borrowed-metaphor",
1493
1523
  severity: "warning",
1524
+ confidence: "high",
1494
1525
  # Two guards, both structural, over the same noun list so they can't
1495
1526
  # drift apart. Forward: a physical building part right after it is the
1496
1527
  # literal sense, checked with a negative lookahead. Backward: the
@@ -1539,8 +1570,9 @@ module Sloplint
1539
1570
  ),
1540
1571
  Rule.new(
1541
1572
  id: "intersection-of",
1542
- category: "rhetorical-tic",
1573
+ category: "borrowed-metaphor",
1543
1574
  severity: "warning",
1575
+ confidence: "high",
1544
1576
  # "at" is not load-bearing -- "explores the intersection of art and
1545
1577
  # technology" is the same move -- so the anchor is "the intersection of"
1546
1578
  # and the two guards carry the whole burden of separating the literal
@@ -1626,8 +1658,9 @@ module Sloplint
1626
1658
  ),
1627
1659
  Rule.new(
1628
1660
  id: "impact-verb",
1629
- category: "rhetorical-tic",
1661
+ category: "borrowed-metaphor",
1630
1662
  severity: "warning",
1663
+ confidence: "high",
1631
1664
  # "impact" and "impacts" are also nouns, so they only count as verbs
1632
1665
  # behind an auxiliary or a subject pronoun. One optional pronoun may sit
1633
1666
  # between the auxiliary and the verb ("does this impact the date");
@@ -1731,10 +1764,11 @@ module Sloplint
1731
1764
  Rule.new(
1732
1765
  id: "impact-noun-vague",
1733
1766
  category: "puffery",
1734
- severity: "warning",
1767
+ severity: "error",
1768
+ confidence: "high",
1735
1769
  # Only the puffed shapes: an intensity adjective, or make/have plus an
1736
- # article. "the impact of X" is left to impact-noun-bare, which sits at
1737
- # info because it is the standard word in research prose. "positive" and
1770
+ # article. "the impact of X" is left to impact-noun-bare, which takes the
1771
+ # bare form because it is the standard word in research prose. "positive" and
1738
1772
  # "negative" stay out of the adjective list on purpose -- they name a
1739
1773
  # direction, which is more than the intensity words do. "statistically"
1740
1774
  # is pulled into the match as an optional leading word so skip: can see
@@ -1782,8 +1816,9 @@ module Sloplint
1782
1816
  ),
1783
1817
  Rule.new(
1784
1818
  id: "impact-noun-bare",
1785
- category: "rhetorical-tic",
1786
- severity: "info",
1819
+ category: "borrowed-metaphor",
1820
+ severity: "warning",
1821
+ confidence: "medium",
1787
1822
  # Two shapes, each needing its own anchor: a measuring verb in front, or
1788
1823
  # "of" behind. That is what keeps the collision sense clear without a
1789
1824
  # list of collision verbs -- "the impact crushed the front bumper" has
@@ -1820,14 +1855,15 @@ module Sloplint
1820
1855
  "Torque the bolts with an impact wrench.",
1821
1856
  "Will the impact be permanent?"
1822
1857
  ],
1823
- rationale: "This one sits at info because 'the impact of X on Y' is the ordinary word in " \
1824
- "research and policy writing, not a tell. Elsewhere it postpones the sentence: " \
1825
- "the writer announces that an effect exists and stops before naming it."
1858
+ rationale: "'The impact of X on Y' is the ordinary word in research and policy writing, " \
1859
+ "not a tell. Elsewhere it postpones the sentence: the writer announces that " \
1860
+ "an effect exists and stops before naming it."
1826
1861
  ),
1827
1862
  Rule.new(
1828
1863
  id: "thats-how-x",
1829
- category: "rhetorical-tic",
1864
+ category: "closer",
1830
1865
  severity: "warning",
1866
+ confidence: "high",
1831
1867
  pattern: /(?:\A|[.!?]\s+|\n\s*\n)\s*(?:that|this)(?:'s| is)\s+how\b/i,
1832
1868
  message: '"That\'s how…" opening a sentence is a stock LLM aphorism closer.',
1833
1869
  suggestion: "Cut the closer, or replace it with the concrete result you mean.",
@@ -1843,8 +1879,9 @@ module Sloplint
1843
1879
  ),
1844
1880
  Rule.new(
1845
1881
  id: "announced-takeaway",
1846
- category: "rhetorical-tic",
1882
+ category: "self-rating",
1847
1883
  severity: "warning",
1884
+ confidence: "high",
1848
1885
  pattern: /(?:\A|[.!?]\s+|\n\s*\n)\s*(?:here'?s\s+)?the\s+(?:loop|pattern|trick|lesson|takeaway|playbook|framing|insight|kicker)\b[^.!?\n]{0,60}:/i,
1849
1886
  message: "Colon-led takeaway label announces the lesson before making it.",
1850
1887
  suggestion: "Give the observation first; let the reader decide it's the takeaway.",
@@ -1858,10 +1895,64 @@ module Sloplint
1858
1895
  rationale: "Labelling a claim as the portable lesson does the persuading that the claim " \
1859
1896
  "should be doing — a model habit borrowed from thought-leader prose."
1860
1897
  ),
1898
+ Rule.new(
1899
+ id: "cataphoric-teaser",
1900
+ category: "self-rating",
1901
+ severity: "warning",
1902
+ confidence: "high",
1903
+ # The forward-pointing tease: "Here's what nobody tells you", "the
1904
+ # part most people get wrong". Three parts, each a closed frame. A
1905
+ # sentence-initial position, optionally opened by a forward demonstrative
1906
+ # (here's / this is; "that's what nobody understands" points backward
1907
+ # and is left alone). A noun slot: what / the part / the thing / the
1908
+ # bit / the secret. A subject that claims scarcity for the reader's
1909
+ # benefit: nobody, no one, most people, few people, hardly anyone,
1910
+ # they, people. Then up to two adverbs and a verb of telling, getting
1911
+ # wrong, talking about, admitting or noticing. The bare subject form
1912
+ # ("What nobody tells you ...") must also reach a copula or colon in
1913
+ # the same sentence, since that is what makes it a setup rather than a
1914
+ # plain report ("What nobody tells you gets forgotten."), and it may
1915
+ # not take "they": without a demonstrative in front, "what they don't
1916
+ # realize is" refers to the people the story is about, and narrative
1917
+ # says it. Read across 8.7 MB of RAID that was the one human hit.
1918
+ pattern: /(?:\A|[.!?]\s+|\n\s*\n|:\s+)\s*
1919
+ (?:(?<demo>(?:\w+,?\s+)?(?:here'?s|here\s+is|this\s+is)\s+)|(?-i:(?=[A-Z])))
1920
+ (?:what|the\s+(?:part|thing|bit|secret))\s+
1921
+ (?:nobody|no[-\s]?one|most\s+\w+(?:\s+\w+)?|few\s+people|hardly\s+anyone|almost\s+(?:nobody|no[-\s]?one)|people|(?(<demo>)they|(?!)))\s+
1922
+ (?:(?:will|won'?t|don'?t|doesn'?t|actually|ever|really|never|rarely|wants?\s+to)\s+){0,2}
1923
+ (?:tells?|gets?|talks?|says?|admits?|mentions?|warns?|teach(?:es)?|explains?|notices?|realizes?|understands?|hears?)\b
1924
+ (?(<demo>)|[^.!?\n:]{0,60}?(?:\s+is\b|:))/ix,
1925
+ message: "Cataphoric teaser promises a hidden insight before giving it.",
1926
+ suggestion: "Cut the tease and state the thing itself.",
1927
+ examples_bad: [
1928
+ "Here's what nobody tells you about hiring.",
1929
+ "Here is the part most people get wrong.",
1930
+ "This is the thing they don't tell you.",
1931
+ "What nobody tells you is that the migration is the easy part.",
1932
+ "The part nobody talks about is the rollback.",
1933
+ "What most senior engineers won't admit is that the estimate was a guess.",
1934
+ "So here's what few people realize: the cache is cold on every deploy."
1935
+ ],
1936
+ examples_ok: [
1937
+ # Backward-pointing: the sentence reports, it does not tease.
1938
+ "That's what nobody understands.",
1939
+ "That is the part most people get wrong.",
1940
+ # A bare-subject sentence that never reaches a copula is a report.
1941
+ "What nobody tells you gets forgotten by lunch.",
1942
+ # Mid-sentence, the phrase is an object, not a setup.
1943
+ "I finally learned what nobody tells you about hiring.",
1944
+ # Lowercase after a hard wrap is a continuation, not a sentence start.
1945
+ "the reviewer read it and\nwhat nobody tells you is that she signed it anyway."
1946
+ ],
1947
+ rationale: "The tease sells the claim as rare knowledge before the claim exists, so the " \
1948
+ "reader is asked to rate it before reading it. Models reach for it as an " \
1949
+ "opener because engagement training rewards the hook."
1950
+ ),
1861
1951
  Rule.new(
1862
1952
  id: "is-is",
1863
- category: "rhetorical-tic",
1953
+ category: "cadence",
1864
1954
  severity: "warning",
1955
+ confidence: "high",
1865
1956
  # No anchor needed -- the doubled copula alone scored 0 across ~1.9M words.
1866
1957
  # The comma is allowed because "What it is, is a mystery" grates the same
1867
1958
  # way. Sentence and clause punctuation still block the weld ("what it is.
@@ -1896,8 +1987,9 @@ module Sloplint
1896
1987
 
1897
1988
  Rule.new(
1898
1989
  id: "earns-its-place",
1899
- category: "rhetorical-tic",
1990
+ category: "self-rating",
1900
1991
  severity: "warning",
1992
+ confidence: "high",
1901
1993
  # The possessive is the narrowing. "earned a place on the team" and
1902
1994
  # "earn a place in the final" are ordinary; "earns its place" is the
1903
1995
  # metaphor, a thing paying for the room it takes up.
@@ -1921,8 +2013,9 @@ module Sloplint
1921
2013
  ),
1922
2014
  Rule.new(
1923
2015
  id: "does-a-lot-of-work",
1924
- category: "rhetorical-tic",
2016
+ category: "self-rating",
1925
2017
  severity: "warning",
2018
+ confidence: "high",
1926
2019
  # Two arms, both narrowed away from the ordinary sense.
1927
2020
  #
1928
2021
  # "a lot of work" needs a locative ("here", "in that sentence") because
@@ -1954,8 +2047,9 @@ module Sloplint
1954
2047
  ),
1955
2048
  Rule.new(
1956
2049
  id: "failure-mode-here",
1957
- category: "rhetorical-tic",
2050
+ category: "borrowed-metaphor",
1958
2051
  severity: "warning",
2052
+ confidence: "high",
1959
2053
  # "here" is the whole narrowing, and it is doing a lot -- the bare "the
1960
2054
  # failure mode is" is ordinary engineering writing about real systems,
1961
2055
  # where the phrase means what it says. The deictic is what marks the
@@ -1981,8 +2075,9 @@ module Sloplint
1981
2075
  ),
1982
2076
  Rule.new(
1983
2077
  id: "thats-the-tension",
1984
- category: "rhetorical-tic",
2078
+ category: "closer",
1985
2079
  severity: "warning",
2080
+ confidence: "high",
1986
2081
  # Sentence-initial, only two nouns, and the noun must end the clause.
1987
2082
  #
1988
2083
  # "tradeoff" and "catch" were tried and cut: both are ordinary English
@@ -2015,8 +2110,9 @@ module Sloplint
2015
2110
  ),
2016
2111
  Rule.new(
2017
2112
  id: "right-up-until",
2018
- category: "rhetorical-tic",
2113
+ category: "closer",
2019
2114
  severity: "warning",
2115
+ confidence: "high",
2020
2116
  # The intensifier is the tell, not the reversal. "It works until it
2021
2117
  # doesn't" is an old human idiom and stays clean; stacking "right up"
2022
2118
  # in front of it is the model's version, and the two together are two
@@ -2041,8 +2137,9 @@ module Sloplint
2041
2137
  ),
2042
2138
  Rule.new(
2043
2139
  id: "two-things-true",
2044
- category: "rhetorical-tic",
2045
- severity: "warning",
2140
+ category: "false-concession",
2141
+ severity: "error",
2142
+ confidence: "high",
2046
2143
  # Closed phrase, no anchor needed. The optional "both" and the optional
2047
2144
  # "at once" tail are the two ways the sentence is padded; the count word
2048
2145
  # is fixed at two, because "three things can be true" is someone
@@ -2067,8 +2164,9 @@ module Sloplint
2067
2164
  ),
2068
2165
  Rule.new(
2069
2166
  id: "notice-what-there",
2070
- category: "rhetorical-tic",
2167
+ category: "reader-address",
2071
2168
  severity: "warning",
2169
+ confidence: "high",
2072
2170
  # The self-referential half of the attention cue: the sentence points
2073
2171
  # at the writing rather than at anything in the world. Two frames, both
2074
2172
  # sentence-initial -- "notice what X did there" and the bare "read that
@@ -2096,8 +2194,9 @@ module Sloplint
2096
2194
  ),
2097
2195
  Rule.new(
2098
2196
  id: "notice-what",
2099
- category: "rhetorical-tic",
2197
+ category: "reader-address",
2100
2198
  severity: "info",
2199
+ confidence: "medium",
2101
2200
  # The ambiguous half of the pair, and it ships at info because the
2102
2201
  # sentence-initial imperative is also how people point at something
2103
2202
  # real: "Notice what happens around Q3", "Notice what is not on the
@@ -2128,8 +2227,9 @@ module Sloplint
2128
2227
  ),
2129
2228
  Rule.new(
2130
2229
  id: "none-of-this-is-to-say",
2131
- category: "rhetorical-tic",
2132
- severity: "warning",
2230
+ category: "false-concession",
2231
+ severity: "error",
2232
+ confidence: "high",
2133
2233
  # Only the "none of" form. Every neighbouring phrasing is ordinary
2134
2234
  # English by an order of magnitude -- "which is not to say", "this is
2135
2235
  # not to say", "that's not to say" -- and admitting any of them would
@@ -2155,10 +2255,11 @@ module Sloplint
2155
2255
  ),
2156
2256
  Rule.new(
2157
2257
  id: "if-im-being-honest",
2158
- category: "rhetorical-tic",
2159
- severity: "info",
2258
+ category: "false-concession",
2259
+ severity: "warning",
2260
+ confidence: "medium",
2160
2261
  # The "being honest" frame only. Plain "to be honest" and "I'll be
2161
- # honest" are how people talk and are excluded. info, not warning:
2262
+ # honest" are how people talk and are excluded. Medium confidence:
2162
2263
  # writers really do say this out loud.
2163
2264
  pattern: /\bif\s+(?:I['’]?m|I\s+am|we['’]?re|we\s+are)\s+(?:being\s+)?honest\b
2164
2265
  |\bhonestly,\s+the\s+(?:answer|truth)\b/ix,
@@ -2180,8 +2281,9 @@ module Sloplint
2180
2281
  ),
2181
2282
  Rule.new(
2182
2283
  id: "honestly",
2183
- category: "rhetorical-tic",
2284
+ category: "self-rating",
2184
2285
  severity: "warning",
2286
+ confidence: "high",
2185
2287
  # The manner adverb, the way "cleanly" is the manner adverb: hung on a
2186
2288
  # subject that cannot be honest. Two guards, no verb list.
2187
2289
  #
@@ -2231,8 +2333,9 @@ module Sloplint
2231
2333
  ),
2232
2334
  Rule.new(
2233
2335
  id: "honest-x",
2234
- category: "rhetorical-tic",
2336
+ category: "self-rating",
2235
2337
  severity: "warning",
2338
+ confidence: "high",
2236
2339
  # The noun list is short on purpose, and the words left out are the
2237
2340
  # point. "An honest answer", "an honest assessment" and "an honest
2238
2341
  # account" are ordinary English about people -- six hits between them in
@@ -2274,8 +2377,9 @@ module Sloplint
2274
2377
  ),
2275
2378
  Rule.new(
2276
2379
  id: "most-honest-x",
2277
- category: "rhetorical-tic",
2278
- severity: "warning",
2380
+ category: "self-rating",
2381
+ severity: "error",
2382
+ confidence: "high",
2279
2383
  # The superlative frame carries the tell on its own, so this noun list is
2280
2384
  # wider than honest-x's -- "the most honest assessment" is self-ranking
2281
2385
  # in a way "an honest assessment" is not. The list still has to keep out
@@ -2310,14 +2414,14 @@ module Sloplint
2310
2414
  ),
2311
2415
  Rule.new(
2312
2416
  id: "genuinely",
2313
- category: "rhetorical-tic",
2417
+ category: "self-rating",
2314
2418
  severity: "info",
2419
+ confidence: "low",
2315
2420
  # Off by default. There is no narrowing here: the word is ordinary
2316
2421
  # English at every frequency we measured, and the difference between
2317
2422
  # the tell and the real use is whether a contrast exists in the
2318
2423
  # surrounding argument, which no regex can see. Selectable when a
2319
2424
  # writer wants every occurrence listed back to them.
2320
- default_on: false,
2321
2425
  pattern: /\bgenuinely\b/i,
2322
2426
  message: '"Genuinely" asserts sincerity instead of earning it (heuristic; high false-positive).',
2323
2427
  suggestion: "Cut the adverb. If the sentence needs it, the claim is doing the work.",
@@ -2339,8 +2443,9 @@ module Sloplint
2339
2443
  ),
2340
2444
  Rule.new(
2341
2445
  id: "actually-not-x",
2342
- category: "rhetorical-tic",
2446
+ category: "false-correction",
2343
2447
  severity: "warning",
2448
+ confidence: "high",
2344
2449
  # Two markers that each correct the reader, doubled up in one clause:
2345
2450
  # the adverb and the trailing "…, not X". Bare "actually" is not the
2346
2451
  # tell and is not matched, because "the build actually failed on the
@@ -2405,8 +2510,9 @@ module Sloplint
2405
2510
  ),
2406
2511
  Rule.new(
2407
2512
  id: "and-thats-fine",
2408
- category: "rhetorical-tic",
2409
- severity: "info",
2513
+ category: "false-concession",
2514
+ severity: "warning",
2515
+ confidence: "medium",
2410
2516
  # Three narrowings, and the rule needs all of them. "and" is required:
2411
2517
  # bare "that's fine" is a reply people write constantly. The match must
2412
2518
  # open a sentence and close it, so the concessive clause -- "and that's
@@ -2430,8 +2536,9 @@ module Sloplint
2430
2536
  ),
2431
2537
  Rule.new(
2432
2538
  id: "and-nothing-else",
2433
- category: "rhetorical-tic",
2539
+ category: "closer",
2434
2540
  severity: "warning",
2541
+ confidence: "high",
2435
2542
  # This rule is deliberately wide, and the cost is known. There is no verb
2436
2543
  # list and no imperative requirement, so the only narrowing is structural
2437
2544
  # -- which means the pattern cannot tell a leaked instruction from the
@@ -2489,8 +2596,9 @@ module Sloplint
2489
2596
  ),
2490
2597
  Rule.new(
2491
2598
  id: "nothing-else-frag",
2492
- category: "rhetorical-tic",
2599
+ category: "closer",
2493
2600
  severity: "warning",
2601
+ confidence: "high",
2494
2602
  # The same exclusion as its own sentence: "Return the JSON. Nothing
2495
2603
  # else." Built on the no-x-no-y-frag template -- the fragment must start
2496
2604
  # at a sentence boundary and the separator is at most two spaces or one
@@ -2526,18 +2634,18 @@ module Sloplint
2526
2634
  # letter guard is there to exclude.
2527
2635
  "and so on; nothing more."
2528
2636
  ],
2529
- rationale: "The fragment form of the same leaked instruction, and it reads the same way: " \
2530
- "a second sentence that only says the first one was complete. It ships at " \
2531
- "warning rather than info because the capital letter and the whole-sentence " \
2532
- "requirement keep it off the continuation sense, which is where ordinary prose " \
2533
- "puts the phrase: one hit in 1.92M words of public-domain prose and one in " \
2534
- "461k words of pre-2022 Hacker News."
2637
+ rationale: "The fragment form of the same leaked instruction, and it reads the same " \
2638
+ "way: a second sentence that only says the first one was complete. The " \
2639
+ "capital letter and the whole-sentence requirement keep it off the " \
2640
+ "continuation sense, which is where ordinary prose puts the phrase: one hit " \
2641
+ "in 1.92M words of public-domain prose and one in 461k words of pre-2022 " \
2642
+ "Hacker News."
2535
2643
  ),
2536
- # ── puffery ───────────────────────────────────────────────────────────
2537
2644
  Rule.new(
2538
2645
  id: "puffery-words",
2539
2646
  category: "puffery",
2540
- severity: "warning",
2647
+ severity: "error",
2648
+ confidence: "high",
2541
2649
  # "nestled" alone is the literal verb as often as the puffery sense --
2542
2650
  # a head nestling against a shoulder, a kitten nestling into a blanket
2543
2651
  # -- so it requires a following in/among/between, the same shape the
@@ -2573,7 +2681,8 @@ module Sloplint
2573
2681
  Rule.new(
2574
2682
  id: "stands-serves-as",
2575
2683
  category: "puffery",
2576
- severity: "info",
2684
+ severity: "warning",
2685
+ confidence: "medium",
2577
2686
  pattern: /\b(?:stands|serves)\s+as\b|\bis\s+a\s+(?:testament|reminder)\s+to\b/i,
2578
2687
  message: '"stands/serves as", "is a testament/reminder to" is puffed AI framing.',
2579
2688
  suggestion: "Say what it does, not what it 'stands as'.",
@@ -2584,7 +2693,8 @@ module Sloplint
2584
2693
  Rule.new(
2585
2694
  id: "vital-role",
2586
2695
  category: "puffery",
2587
- severity: "warning",
2696
+ severity: "error",
2697
+ confidence: "high",
2588
2698
  pattern: /\bplays?\s+a\s+(?:vital|crucial|pivotal|significant|key|central)\s+role\b/i,
2589
2699
  message: '"plays a vital/crucial/… role" is a stock AI puffery phrase.',
2590
2700
  suggestion: "State the specific role or effect instead.",
@@ -2595,7 +2705,8 @@ module Sloplint
2595
2705
  Rule.new(
2596
2706
  id: "underscores-highlights",
2597
2707
  category: "puffery",
2598
- severity: "info",
2708
+ severity: "warning",
2709
+ confidence: "medium",
2599
2710
  # "underscored/underscoring" are unambiguously the verb and flag bare.
2600
2711
  # "underscore/underscores" is also the character noun (a leading
2601
2712
  # underscore, snake_case docs), so those forms require a following
@@ -2636,15 +2747,16 @@ module Sloplint
2636
2747
  "Numbers accept underscores\n\nThe next section covers floats."
2637
2748
  ],
2638
2749
  rationale: "Models reach for 'underscore' as an all-purpose emphasis verb -- findings " \
2639
- "underscore, outages underscore -- asserting significance without earning it. " \
2640
- "Sincere journalistic and academic use exists, hence info: a flag means the " \
2641
- "move is present, not that it's slop. The character noun never takes the " \
2642
- "verb's frame and stays out."
2750
+ "underscore, outages underscore -- asserting significance without earning " \
2751
+ "it. Sincere journalistic and academic use exists: a flag means the move is " \
2752
+ "present, not that it's slop. The character noun never takes the verb's " \
2753
+ "frame and stays out."
2643
2754
  ),
2644
2755
  Rule.new(
2645
2756
  id: "rich-tapestry",
2646
2757
  category: "puffery",
2647
- severity: "warning",
2758
+ severity: "error",
2759
+ confidence: "high",
2648
2760
  pattern: /\brich\s+tapestry\b|\btapestry\s+of\b/i,
2649
2761
  message: '"rich tapestry"/"tapestry of" is a signature AI cliché.',
2650
2762
  suggestion: "Cut the metaphor; name the actual things.",
@@ -2653,11 +2765,11 @@ module Sloplint
2653
2765
  rationale: "'tapestry of' is one of the most reliable single-phrase model tells."
2654
2766
  ),
2655
2767
 
2656
- # ── structure ─────────────────────────────────────────────────────────
2657
2768
  Rule.new(
2658
2769
  id: "not-just-x-but-y",
2659
- category: "structure",
2770
+ category: "false-correction",
2660
2771
  severity: "warning",
2772
+ confidence: "high",
2661
2773
  # Two branches, both anchored on an explicit escalation word. (1) The
2662
2774
  # copula escalation: "is not just/only/merely/simply/solely A … but B".
2663
2775
  # (2) "not because A, but because B". The escalation word is what makes
@@ -2701,10 +2813,11 @@ module Sloplint
2701
2813
  ),
2702
2814
  Rule.new(
2703
2815
  id: "not-x-but-y",
2704
- category: "structure",
2705
- severity: "info",
2816
+ category: "false-correction",
2817
+ severity: "warning",
2818
+ confidence: "medium",
2706
2819
  # The bare corrective: "is not A but B", no escalation word, comma or no
2707
- # comma. Ships at info because the line between a corrective ("not an
2820
+ # comma. Medium confidence, because the line between a corrective ("not an
2708
2821
  # accident but a strategy") and an ordinary concession ("not warm but the
2709
2822
  # fire helped") is syntactic, and a regex cannot see syntax. What is here
2710
2823
  # is a set of cheap narrowings that cut the worst of the noise: A is
@@ -2718,7 +2831,8 @@ module Sloplint
2718
2831
  # ("was not warm but the fire helped") or a bare lexical verb ("was not
2719
2832
  # perfect but got us there"), because neither is distinguishable from the
2720
2833
  # corrective by surface form. That is the cost of the rule and the reason
2721
- # it is info: the agent reading the flag has the context to judge, and
2834
+ # its confidence is medium: the agent reading the flag has the context
2835
+ # to judge, and
2722
2836
  # should. Do not chase these by growing the B-list -- every word added
2723
2837
  # silently narrows recall with nothing pinning it.
2724
2838
  pattern: /(?:\bis|\bare|\bwas|\bwere|\bisn['’]t|\baren['’]t|\bwasn['’]t|\bweren['’]t|
@@ -2765,15 +2879,16 @@ module Sloplint
2765
2879
  "escalation word dropped, and models reach for it constantly. It is the " \
2766
2880
  "noisiest rule in the catalog by design: a loose version scored 174 hits " \
2767
2881
  "before narrowing, cut to 15 here -- but not all 15 are correctives. Some " \
2768
- "are concessions with an elided subject (Walden's " \
2769
- "'It was not lonely, but made all the earth lonely beneath it'), which no " \
2770
- "surface pattern can tell apart from the real thing. Hence info: a flag here " \
2771
- "means 'this has the shape', not 'this is slop'."
2882
+ "are concessions with an elided subject (Walden's 'It was not lonely, but " \
2883
+ "made all the earth lonely beneath it'), which no surface pattern can tell " \
2884
+ "apart from the real thing. A flag here means 'this has the shape', not " \
2885
+ "'this is slop'."
2772
2886
  ),
2773
2887
  Rule.new(
2774
2888
  id: "isnt-x-its-y",
2775
- category: "structure",
2776
- severity: "info",
2889
+ category: "false-correction",
2890
+ severity: "warning",
2891
+ confidence: "medium",
2777
2892
  # The corrective with the conjunction dropped: one clause rejects a
2778
2893
  # description, the next supplies the replacement through a second copula
2779
2894
  # ("It isn't the tool. It's the habit."). Every copula tense is covered,
@@ -2789,7 +2904,8 @@ module Sloplint
2789
2904
  # guards cut a loose version from 19 hits to 4 over 889k words of
2790
2905
  # 19th-century fiction.
2791
2906
  #
2792
- # What survives is the reason for info. An adjective outside the list
2907
+ # What survives is the reason the confidence is medium. An adjective
2908
+ # outside the list
2793
2909
  # still slips through, and some hits are the same shape written by a
2794
2910
  # person -- "was not the wife; it was the children" is Conan Doyle. A flag
2795
2911
  # here says the sentence has the frame, not that a model wrote it.
@@ -2861,13 +2977,14 @@ module Sloplint
2861
2977
  "one, so the sentence sounds like a correction while correcting nobody. It " \
2862
2978
  "is 'not A but B' with the conjunction dropped and the second half promoted " \
2863
2979
  "to its own clause, which is the form models reach for most. Ordinary prose " \
2864
- "contrasts two things this way too, so the rule ships at info: it reports " \
2865
- "the shape, not a verdict."
2980
+ "contrasts two things this way too, so the rule reports the shape, not a " \
2981
+ "verdict."
2866
2982
  ),
2867
2983
  Rule.new(
2868
2984
  id: "not-by-x-but-by-y",
2869
- category: "structure",
2870
- severity: "info",
2985
+ category: "false-correction",
2986
+ severity: "warning",
2987
+ confidence: "medium",
2871
2988
  # The corrective built on a repeated preposition: "not by A, but by B",
2872
2989
  # "not from A but from B". The copula rules above cannot see it because
2873
2990
  # nothing precedes "not" but the verb or a dash, so the anchor here is
@@ -2908,16 +3025,16 @@ module Sloplint
2908
3025
  "We won not by luck\n\nBut by then it hardly mattered."
2909
3026
  ],
2910
3027
  rationale: "The 'not A but B' corrective with the copula swapped for a repeated " \
2911
- "preposition, which is how a model corrects a claim about means or " \
2912
- "cause ('not by luck, but by design'). People write it too -- Thoreau " \
2913
- "and Melville both lean on it -- so the rule ships at info. It reports " \
2914
- "the shape; a human reader decides whether it earned its place."
3028
+ "preposition, which is how a model corrects a claim about means or cause " \
3029
+ "('not by luck, but by design'). People write it too -- Thoreau and Melville " \
3030
+ "both lean on it -- and the rule reports the shape; a human reader decides " \
3031
+ "whether it earned its place."
2915
3032
  ),
2916
3033
  Rule.new(
2917
3034
  id: "rule-of-three",
2918
- category: "structure",
3035
+ category: "cadence",
2919
3036
  severity: "info",
2920
- default_on: false,
3037
+ confidence: "low",
2921
3038
  pattern: /\b[\w'-]+,\s+[\w'-]+,\s+(?:and\s+)?[\w'-]+[.!?]/,
2922
3039
  message: "Three single words in a comma series closing a sentence (heuristic; high false-positive).",
2923
3040
  suggestion: "Fine in moderation; watch for the AI habit of ending on triplets.",
@@ -2943,8 +3060,9 @@ module Sloplint
2943
3060
  # CLAUDE.md: some tells can't be regexes; this was one.
2944
3061
  Rule.new(
2945
3062
  id: "everyone-nobody",
2946
- category: "structure",
3063
+ category: "cadence",
2947
3064
  severity: "warning",
3065
+ confidence: "high",
2948
3066
  # The comma-spliced antithesis on quantifier subjects: "Everyone wants
2949
3067
  # the dashboard, nobody maintains it." One clause opens on
2950
3068
  # everyone/everybody, the other on nobody/no one/none or "one N"
@@ -3015,9 +3133,84 @@ module Sloplint
3015
3133
  "or give the problem its own sentence."
3016
3134
  ),
3017
3135
  Rule.new(
3018
- id: "np-fragment-and",
3019
- category: "structure",
3136
+ id: "punch-sentence",
3137
+ category: "cadence",
3020
3138
  severity: "info",
3139
+ confidence: "medium",
3140
+ # The mid-paragraph punch: a verbless beat of one to three words wedged
3141
+ # between two long sentences. "...for the third time that quarter. Not
3142
+ # anymore. The new rota puts..." mic-drop-closer and
3143
+ # bare-auxiliary-closer take the long-then-short shape at the end of a
3144
+ # paragraph; this is the one in the middle, which the closer rules
3145
+ # refuse. The setup is the shared sixty-character prefix, and a
3146
+ # sentence of forty or more characters must follow on the same
3147
+ # paragraph, so a closer and a run of short sentences (short-run's
3148
+ # tell) are both left to their own rules.
3149
+ #
3150
+ # Read against RAID, the first draft fired on human prose at four
3151
+ # times its model rate, and every kind of hit is now a guard:
3152
+ #
3153
+ # A title or initial before the stop ("Mrs. Cadaver.", "John F.
3154
+ # Kennedy.", "St. Petersburg.") makes the proper name after it look
3155
+ # like a one-word sentence, so the stop the punch follows may not
3156
+ # close a capitalised word of one to four letters, or a lone lowercase
3157
+ # letter ("Swift v. Tyson"). The same guard on
3158
+ # the punch's own last word ("When Mr.", "Felix and Rev.") is why a
3159
+ # lone word must be five letters or more when capitalised; a lowercase
3160
+ # last word ("Not anymore.") is never an abbreviation.
3161
+ #
3162
+ # A short clause with a pronoun subject ("He agrees.", "She complies.",
3163
+ # "Both die.") is how a plot summary and a news brief move the story
3164
+ # along, and an imperative with a name or a noun subject ("Dawes
3165
+ # refuses.", "Set aside.") reads the same to a regex as a beat, so the
3166
+ # punch is two closed shapes: a negator (Not, No, Never, Nothing,
3167
+ # None) with up to two lowercase words after it, or one capitalised
3168
+ # word of five letters or more. A one-word imperative in procedural
3169
+ # prose ("Drain.", "Repeat.") still lands in the second shape, at about
3170
+ # one per 25,000 words of recipes, which the info severity allows for.
3171
+ pattern: /#{SENTENCE_OF_SIXTY_CHARACTERS_ENDING_IN_PUNCTUATION_AND_SPACE}\K
3172
+ (?<![A-Z]\.[ \t]|[A-Z][a-z]\.[ \t]|[A-Z][a-z]{2}\.[ \t]|[A-Z][a-z]{3}\.[ \t]|[ \t][a-z]\.[ \t]
3173
+ |[A-Z]\.[ \t]{2}|[A-Z][a-z]\.[ \t]{2}|[A-Z][a-z]{2}\.[ \t]{2}|[A-Z][a-z]{3}\.[ \t]{2}|[ \t][a-z]\.[ \t]{2})
3174
+ (?:(?:Not|No|Never|Nothing|None)(?:(?:[ \t]|\r?\n(?!\s*\n)[ \t]*)[a-z][A-Za-z'’-]*){0,2}
3175
+ |[A-Z][A-Za-z'’-]{4,})\.
3176
+ (?=[ \t]{1,2}[A-Z](?:[^.!?\n]|\r?\n(?!\s*\n)){40,})/x,
3177
+ message: "A verbless beat of three words or fewer between two long sentences is the AI punch.",
3178
+ suggestion: "Fold the beat into the sentence before or after it, or cut it.",
3179
+ examples_bad: [
3180
+ "The on-call engineer had been paged for the same cron job three nights running that week. Not anymore. The job now checks the lock file before it starts and exits quietly.",
3181
+ "The reviewers spent the whole afternoon arguing about whether the migration should run in one pass. Never again. The first half ran on Tuesday and the second half is still waiting on a sign-off.",
3182
+ "Every team we talked to had a wiki page describing the release process in loving detail. No exceptions. The page had not been touched since the person who wrote it left the company.",
3183
+ "The retry loop was supposed to give up after five attempts and page someone with the error. Simple. Instead it kept going all night because the counter reset on every restart."
3184
+ ],
3185
+ examples_ok: [
3186
+ # Paragraph-final: that is the closer rules' shape, not this one.
3187
+ "The reviewers spent the whole afternoon arguing about whether the migration should run in one pass. Not anymore.",
3188
+ # Followed by another short sentence: short-run's territory.
3189
+ "The reviewers spent the whole afternoon arguing about whether the migration should run in one pass. Not anymore. Nobody minded. The second half is still waiting on a sign-off from the platform team.",
3190
+ # A short clause with a pronoun subject is narrative, not a beat.
3191
+ "The reviewers spent the whole afternoon arguing about whether the migration should run in one pass. It didn't. The first half ran on Tuesday and the second half is still waiting on a sign-off.",
3192
+ "Every team we talked to had a wiki page describing the release process in loving detail. Nobody read it. The page had not been touched since the person who wrote it left the company.",
3193
+ # A title or initial with its complement on the far side of the stop.
3194
+ "The committee met for the last time on a wet Thursday in the back room of the library annex. Mr. Whitaker moved that the minutes be approved as read and the motion carried without a vote.",
3195
+ "The only survivor of the crash on the coast road that spring was the widow of the lighthouse keeper, Mrs. Cadaver. The inquest sat for two days in the town hall and returned no verdict at all.",
3196
+ "The freight line ran west from the river through three counties before it reached the yards. St. Louis was the end of the line for most of the crews and the start of it for the rest.",
3197
+ "The freight line ran west from the river through three counties before it reached the yards. Cyrus M. Jarrett owned the yards and every siding on the line from the river to the state line.",
3198
+ "The rule that a federal court sitting in diversity applies general common law was set out in Swift v. Tyson. The decision stood for nearly a century before the court overruled it in Erie.",
3199
+ # Digits are data, and a quotation mark is dialogue.
3200
+ "The freight line ran west from the river through three counties before it reached the yards. Fig. 2 shows the route as it stood in 1890 and the sidings that were added after the flood.",
3201
+ "The freight line ran west from the river through three counties before it reached the yards. \"Enough.\" The foreman waved the crew back toward the sheds and nobody argued with him.",
3202
+ # Four or more words is a sentence, not a beat.
3203
+ "The freight line ran west from the river through three counties before it reached the yards. The crews liked it. The foreman waved the crew back toward the sheds and nobody argued with him."
3204
+ ],
3205
+ rationale: "The beat withholds the verb and the object the long sentence set up and " \
3206
+ "hands the reader a pause instead. People write one on purpose; a draft " \
3207
+ "that keeps doing it is the tell, which is why it ships at info."
3208
+ ),
3209
+ Rule.new(
3210
+ id: "np-fragment-and",
3211
+ category: "cadence",
3212
+ severity: "warning",
3213
+ confidence: "medium",
3021
3214
  # A whole sentence that is two noun phrases and an "and": "A named owner
3022
3215
  # and a quarterly review." It is the fix half of a model's
3023
3216
  # problem-then-fix pair, with the verb left for the reader to supply.
@@ -3028,7 +3221,7 @@ module Sloplint
3028
3221
  # anywhere in it, contractions included, so "A man and a woman were
3029
3222
  # there." and "A man and a woman aren't here." never match.
3030
3223
  #
3031
- # Ships at info, and this is why: a lexical verb is invisible to the
3224
+ # Medium confidence, and this is why: a lexical verb is invisible to the
3032
3225
  # pattern, so "A car and a truck collided." has the same shape and
3033
3226
  # flags. The corpora say that sentence is rare (one hit in 1.25M words
3034
3227
  # of public-domain prose, most of it narrative), but it is a complete
@@ -3071,8 +3264,9 @@ module Sloplint
3071
3264
  ),
3072
3265
  Rule.new(
3073
3266
  id: "quip-question",
3074
- category: "structure",
3267
+ category: "reader-address",
3075
3268
  severity: "info",
3269
+ confidence: "medium",
3076
3270
  # The verbless question that opens a pitch: "No invite?", "New to the
3077
3271
  # tool?", "Still stuck?", "Ready to start?". It must start a sentence,
3078
3272
  # open on one of a short list of words, run one to four more words, and
@@ -3118,8 +3312,9 @@ module Sloplint
3118
3312
  ),
3119
3313
  Rule.new(
3120
3314
  id: "mic-drop-closer",
3121
- category: "structure",
3315
+ category: "cadence",
3122
3316
  severity: "info",
3317
+ confidence: "medium",
3123
3318
  # The kicker: a sentence of at least sixty characters, then a closer of
3124
3319
  # two to eight words that ends the paragraph and opens on a quantifier
3125
3320
  # ("Nothing here needs a new login.", "Most teams end up
@@ -3142,7 +3337,7 @@ module Sloplint
3142
3337
  # position." The quantifiers carry the tell; the demonstratives are
3143
3338
  # ordinary, and no examples_bad used one.
3144
3339
  #
3145
- # Ships at info, and the rationale says why: people end paragraphs this
3340
+ # Medium confidence, and the rationale says why: people end paragraphs this
3146
3341
  # way too, at about 150 per million words on Hacker News. One is
3147
3342
  # nothing. A draft where most paragraphs end this way is the tell, and
3148
3343
  # an agent that sees the flag repeat should read the family as a
@@ -3199,9 +3394,10 @@ module Sloplint
3199
3394
  "warning, and the fix is usually to delete the closer outright."
3200
3395
  ),
3201
3396
  Rule.new(
3202
- id: "ellipsis-closer",
3203
- category: "structure",
3397
+ id: "bare-auxiliary-closer",
3398
+ category: "cadence",
3204
3399
  severity: "info",
3400
+ confidence: "medium",
3205
3401
  # The same long-sentence-then-short-closer shape as mic-drop-closer,
3206
3402
  # but the tell lives in the verb, not the subject. mic-drop-closer's
3207
3403
  # closer opens on a quantifier and keeps a full verb with its object
@@ -3278,14 +3474,15 @@ module Sloplint
3278
3474
  ],
3279
3475
  rationale: "The closer withholds exactly the thing the long setup built toward -- the " \
3280
3476
  "verb and its object are gone, and only the bare confirmation that something " \
3281
- "happened is left standing. Ships at info: plenty of ordinary writing drops " \
3282
- "the verb the same way when it confirms an expectation, and one flag proves " \
3283
- "nothing; a draft where it repeats is the tell."
3477
+ "happened is left standing. Plenty of ordinary writing drops the verb the " \
3478
+ "same way when it confirms an expectation, and one flag proves nothing; a " \
3479
+ "draft where it repeats is the tell."
3284
3480
  ),
3285
3481
  Rule.new(
3286
3482
  id: "short-run",
3287
- category: "structure",
3483
+ category: "cadence",
3288
3484
  severity: "info",
3485
+ confidence: "medium",
3289
3486
  # Three consecutive sentences of thirty characters or fewer, each
3290
3487
  # opening on a letter and closing on a full stop, with no quotation
3291
3488
  # mark or digit in any of them: "Nobody used it. A named owner. Then a
@@ -3422,8 +3619,9 @@ module Sloplint
3422
3619
  ),
3423
3620
  Rule.new(
3424
3621
  id: "em-dash",
3425
- category: "structure",
3622
+ category: "punctuation",
3426
3623
  severity: "info",
3624
+ confidence: "medium",
3427
3625
  pattern: /—/,
3428
3626
  message: "Em dash — an AI punctuation tell.",
3429
3627
  suggestion: "Recast with a comma, parentheses, or a separate sentence.",
@@ -3442,8 +3640,9 @@ module Sloplint
3442
3640
  ),
3443
3641
  Rule.new(
3444
3642
  id: "em-dash-overuse",
3445
- category: "structure",
3643
+ category: "punctuation",
3446
3644
  severity: "warning",
3645
+ confidence: "high",
3447
3646
  pattern: /—(?:[^\n]|\n(?!\s*\n))*—(?:[^\n]|\n(?!\s*\n))*—/,
3448
3647
  message: "Three or more em dashes in one paragraph — an AI punctuation tell.",
3449
3648
  suggestion: "Recast with commas, parentheses, or separate sentences.",
@@ -3465,8 +3664,9 @@ module Sloplint
3465
3664
 
3466
3665
  Rule.new(
3467
3666
  id: "question-isnt",
3468
- category: "structure",
3469
- severity: "info",
3667
+ category: "false-correction",
3668
+ severity: "warning",
3669
+ confidence: "medium",
3470
3670
  # The resolving clause is required, so a plain rhetorical question never
3471
3671
  # matches. "The real question is" is ordinary English and is excluded by
3472
3672
  # the adjacency of "question" to the negated copula.
@@ -3492,8 +3692,9 @@ module Sloplint
3492
3692
  ),
3493
3693
  Rule.new(
3494
3694
  id: "less-about-more-about",
3495
- category: "structure",
3496
- severity: "info",
3695
+ category: "false-correction",
3696
+ severity: "warning",
3697
+ confidence: "medium",
3497
3698
  # The full frame is required at both ends. Bare "less about" and bare
3498
3699
  # "more about" are ordinary English on their own, and the subject slot
3499
3700
  # is limited to the pronouns so that a sentence with a real subject --
@@ -3519,8 +3720,9 @@ module Sloplint
3519
3720
  ),
3520
3721
  Rule.new(
3521
3722
  id: "trailing-significance-participle",
3522
- category: "structure",
3523
- severity: "warning",
3723
+ category: "puffery",
3724
+ severity: "error",
3725
+ confidence: "high",
3524
3726
  # The verb list is closed and short on purpose. Wikipedia's "signs of AI
3525
3727
  # writing" names eight watch words for this construction; half of them
3526
3728
  # did not survive probing. In 1.85M words of pre-2022 Hacker News and
@@ -3597,9 +3799,9 @@ module Sloplint
3597
3799
  ),
3598
3800
  Rule.new(
3599
3801
  id: "trailing-restatement",
3600
- category: "structure",
3601
- severity: "info",
3602
- default_on: false,
3802
+ category: "closer",
3803
+ severity: "warning",
3804
+ confidence: "low",
3603
3805
  # The restating tail: "…, which means working through the process
3604
3806
  # rather than around it". A regex sees the connective and not whether
3605
3807
  # the tail says the head again, and the sentence that prompted the rule
@@ -3687,11 +3889,11 @@ module Sloplint
3687
3889
  "use the same connective to state a consequence, and the pattern cannot " \
3688
3890
  "tell the two apart, so the rule is off by default."
3689
3891
  ),
3690
- # ── hedging ───────────────────────────────────────────────────────────
3691
3892
  Rule.new(
3692
3893
  id: "vague-attribution",
3693
- category: "hedging",
3694
- severity: "warning",
3894
+ category: "false-concession",
3895
+ severity: "error",
3896
+ confidence: "high",
3695
3897
  pattern: /\bsome\s+(?:critics|experts|observers|scholars|analysts)\s+(?:argue|say|believe|contend|maintain)\b|\bit\s+is\s+widely\s+(?:regarded|considered|seen|believed|acknowledged)\b|\bmany\s+would\s+argue\b/i,
3696
3898
  message: "Vague attribution ('some critics argue', 'it is widely…') — an AI hedging tell.",
3697
3899
  suggestion: "Name the source, or drop the appeal to unnamed authority.",